From 058a57ff7aa5632b71d41531496c539d49b042ae Mon Sep 17 00:00:00 2001 From: fffonion Date: Sun, 18 Oct 2026 17:40:15 +0800 Subject: [PATCH 01/21] feat(agent): integrate tool plugins and effects --- Cargo.lock | 7 + Cargo.toml | 3 + examples/plugin_schema_sync.rs | 33 + ...2026-10-17_plugin-graphs-effect-sandbox.md | 634 ++++++ rss/agent/main.rss | 54 +- rss/storage/admission.rss | 203 +- rss/storage/main.rss | 27 +- rss/storage/plugins.rss | 450 ++++ rss/storage/schema.rss | 68 +- rss/storage/sessions.rss | 18 +- rss/tools/dispatch.rss | 36 +- schemas/README.md | 13 + schemas/canonical/checkpoint.schema.json | 122 ++ schemas/canonical/effect-contract.schema.json | 288 +++ .../canonical/plugin-activation.schema.json | 33 + .../canonical/plugin-graph-lock.schema.json | 226 ++ .../canonical/plugin-graph-ref.schema.json | 38 + schemas/canonical/plugin-manifest.schema.json | 133 ++ schemas/effect-contract.schema.yaml | 180 ++ schemas/plugin-activation.schema.yaml | 28 + schemas/plugin-graph-lock.schema.yaml | 169 ++ schemas/plugin-graph-ref.schema.yaml | 32 + schemas/plugin-manifest.schema.yaml | 102 + schemas/v1/checkpoint.schema.yaml | 91 + src/capabilities/lifecycle.rs | 31 +- src/config.rs | 61 +- src/effects/checkpoint.rs | 473 +++++ src/effects/mod.rs | 22 + src/effects/tree_store.rs | 1227 +++++++++++ src/effects/types.rs | 444 ++++ src/effects/workspace.rs | 946 +++++++++ src/gateway/mod.rs | 19 + src/gateway/store.rs | 418 +++- src/lib.rs | 2 + src/plugins/control_store.rs | 547 +++++ src/plugins/error.rs | 132 ++ src/plugins/manifest.rs | 494 +++++ src/plugins/mod.rs | 41 + src/plugins/pin.rs | 605 ++++++ src/plugins/prepare.rs | 496 +++++ src/plugins/resolve.rs | 770 +++++++ src/plugins/schema.rs | 262 +++ src/plugins/store.rs | 1064 ++++++++++ src/plugins/yaml.rs | 250 +++ src/runtime/agent_host.rs | 827 +++++++- src/runtime/closed_bundle.rs | 44 + src/runtime/host_types.rs | 31 + src/runtime/mod.rs | 1 + src/runtime/native_tools.rs | 188 ++ src/runtime/rss_runner.rs | 303 ++- src/service.rs | 489 ++++- tests/agent_loop_tests.rs | 67 +- tests/coding_agent_edge_e2e_tests.rs | 72 + tests/effect_checkpoint_tests.rs | 683 ++++++ tests/effect_workspace_tests.rs | 1858 +++++++++++++++++ tests/plugin_graph_tests.rs | 841 ++++++++ tests/plugin_manifest_tests.rs | 611 ++++++ tests/plugin_schema_sync_tests.rs | 507 +++++ tests/plugin_store_recovery_tests.rs | 1350 ++++++++++++ tests/provider_opaque_tool_loop_tests.rs | 32 +- tests/storage_tests.rs | 15 +- tests/support/pinned_process_fixture.rs | 319 +++ tests/support/signed_pinned_variants.rs | 107 + tests/support/signed_tool_effects.rs | 148 ++ tests/tools_pin_invoke_tracer.rs | 906 ++++++++ tests/tools_pin_production_ab.rs | 1585 ++++++++++++++ tests/tools_pin_six_variants.rs | 720 +++++++ tests/tools_pinned_commit_outcome.rs | 616 ++++++ tests/tools_pinned_process_lifecycle.rs | 578 +++++ 69 files changed, 24112 insertions(+), 78 deletions(-) create mode 100644 examples/plugin_schema_sync.rs create mode 100644 plans/2026-10-17_plugin-graphs-effect-sandbox.md create mode 100644 rss/storage/plugins.rss create mode 100644 schemas/README.md create mode 100644 schemas/canonical/checkpoint.schema.json create mode 100644 schemas/canonical/effect-contract.schema.json create mode 100644 schemas/canonical/plugin-activation.schema.json create mode 100644 schemas/canonical/plugin-graph-lock.schema.json create mode 100644 schemas/canonical/plugin-graph-ref.schema.json create mode 100644 schemas/canonical/plugin-manifest.schema.json create mode 100644 schemas/effect-contract.schema.yaml create mode 100644 schemas/plugin-activation.schema.yaml create mode 100644 schemas/plugin-graph-lock.schema.yaml create mode 100644 schemas/plugin-graph-ref.schema.yaml create mode 100644 schemas/plugin-manifest.schema.yaml create mode 100644 schemas/v1/checkpoint.schema.yaml create mode 100644 src/effects/checkpoint.rs create mode 100644 src/effects/mod.rs create mode 100644 src/effects/tree_store.rs create mode 100644 src/effects/types.rs create mode 100644 src/effects/workspace.rs create mode 100644 src/plugins/control_store.rs create mode 100644 src/plugins/error.rs create mode 100644 src/plugins/manifest.rs create mode 100644 src/plugins/mod.rs create mode 100644 src/plugins/pin.rs create mode 100644 src/plugins/prepare.rs create mode 100644 src/plugins/resolve.rs create mode 100644 src/plugins/schema.rs create mode 100644 src/plugins/store.rs create mode 100644 src/plugins/yaml.rs create mode 100644 src/runtime/native_tools.rs create mode 100644 tests/effect_checkpoint_tests.rs create mode 100644 tests/effect_workspace_tests.rs create mode 100644 tests/plugin_graph_tests.rs create mode 100644 tests/plugin_manifest_tests.rs create mode 100644 tests/plugin_schema_sync_tests.rs create mode 100644 tests/plugin_store_recovery_tests.rs create mode 100644 tests/support/pinned_process_fixture.rs create mode 100644 tests/support/signed_pinned_variants.rs create mode 100644 tests/support/signed_tool_effects.rs create mode 100644 tests/tools_pin_invoke_tracer.rs create mode 100644 tests/tools_pin_production_ab.rs create mode 100644 tests/tools_pin_six_variants.rs create mode 100644 tests/tools_pinned_commit_outcome.rs create mode 100644 tests/tools_pinned_process_lifecycle.rs diff --git a/Cargo.lock b/Cargo.lock index 5bace4f..9ac032f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1282,6 +1282,7 @@ dependencies = [ "rcgen", "ring", "rustls", + "semver", "serde", "serde_json", "serde_yaml", @@ -1400,6 +1401,12 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "shlex" version = "2.0.1" diff --git a/Cargo.toml b/Cargo.toml index c92047d..8e84e04 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ pd-host-function = { git = "https://github.com/rustscript-lang/rustscript.git", serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" +semver = "1.0.28" yaml-rust2 = { version = "0.12", default-features = false } # Meta-schema validation only; resolver features stay disabled. jsonschema = { version = "0.52.1", default-features = false } @@ -51,6 +52,8 @@ tracing-core = "0.1" [features] default = [] config-fixture = [] +# Test-only one-shot simulated I/O faults. Unarmed production builds do not include it. +workspace-io-fault = [] # Optimize the embedded compiler while retaining development checks. [profile.dev.package.pd-vm] diff --git a/examples/plugin_schema_sync.rs b/examples/plugin_schema_sync.rs new file mode 100644 index 0000000..e6ec689 --- /dev/null +++ b/examples/plugin_schema_sync.rs @@ -0,0 +1,33 @@ +//! Regenerate or check canonical JSON projections of the public YAML schemas. +//! +//! The YAML files are the contract. This tool does not invent a second schema +//! and does not fetch anything. `--check` fails if `schemas/canonical` drifted. + +fn main() { + let command = std::env::args() + .nth(1) + .unwrap_or_else(|| "--check".to_string()); + let dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("schemas/canonical"); + match command.as_str() { + "--check" => match rustscript_agent::plugins::schema_snapshot_drift(&dir) { + Ok(()) => println!("schema snapshots match {}", dir.display()), + Err(err) => { + eprintln!("{err}"); + std::process::exit(1); + } + }, + "--write" => { + let docs = + rustscript_agent::plugins::canonical_schema_documents().expect("bundled schemas"); + std::fs::create_dir_all(&dir).expect("canonical dir"); + for (name, json) in docs { + std::fs::write(dir.join(name), json).expect(name); + } + println!("wrote {}", dir.display()); + } + _ => { + eprintln!("usage: plugin_schema_sync --check|--write"); + std::process::exit(2); + } + } +} diff --git a/plans/2026-10-17_plugin-graphs-effect-sandbox.md b/plans/2026-10-17_plugin-graphs-effect-sandbox.md new file mode 100644 index 0000000..e23ccdf --- /dev/null +++ b/plans/2026-10-17_plugin-graphs-effect-sandbox.md @@ -0,0 +1,634 @@ +# RSS 插件图与 Tools 调用虚拟化实施计划 + +> **当前范围更新:** 按用户最新要求,停止自建 OS sandbox;后续只推进 tools 侧调用虚拟化。保留插件版本/依赖图、显式 effect、Native 调用身份与资源映射、托管工作区/插件状态的 checkpoint 和恢复。进程沿用现有后端及有界取消/清理,不宣称具备新的 OS 安全隔离。Linux x86_64 为首期平台,不增加 32 位/x32 支持。下文 OCI、namespace、seccomp、cgroup 及相应探针和隔离门禁作为旧方案记录,不再是当前里程碑的前置条件;其余相关存储、调用、恢复和 schema 要求继续有效。 + +> **实施状态:** 已获准按本方案进行并发开发。独立工作区、依赖顺序、真实验证与独立审查继续作为合入门禁;下文拟新增能力须经相应阶段验收,不能因计划存在而视为已交付。 + +**Goal:** 将 tools 和可替换的 harness 业务逻辑组成可版本化、可解析依赖、可原子切换的 RSS 插件图;让工具对托管工作区和插件状态的 effect 在 Native 沙盒内执行,并支持一致 checkpoint、分支恢复和版本回退。 + +**Architecture:** 使用不可变插件包、闭合依赖图、按 run 固定的 graph lock,以及独立的 Native effect broker。代码版本、工作区版本、插件状态版本分别管理,通过显式 checkpoint/部署记录建立关联。热重载面向新 run 生效;恢复通过新分支与新 run 完成,保留旧执行记录。 + +**Tech Stack:** 当前 RustScript/RSS + Rust Native + Axum + 现有 typed SQLite/RSS storage;Linux 首期使用经过能力探测的 rootless OCI 后端。包版本解析预计新增 `semver` Rust 依赖;不计划改 Core、语言语法、JIT 或 VMBC。 + +--- + +## 1. 当前依据与设计边界 + +工作区:`/mnt/TEMP/workspace/rustscript-agent/worktrees/workspace-session-binding-10-959a765d`。 + +当前分支 `feat/production-delivery-integration`,已提交基线 `2f9678be44448fa5a28d3870c7eda63c7abd1573`。工作区包含尚未提交的 SSE、工具续轮和真实 API E2E 修复;实施时须先保全并单独验收这批修改,不能用已提交基线的测试结果代替当前候选验收。 + +已读取的复用点: +- `rss/tools/types.rss`、`registry.rss`、`dispatch.rss`:六工具描述、固定分派及 RSS 实现。 +- `src/registry.rs:923`:`ToolRegistrySnapshot` 已为不可变工具描述/校验器集合;其 identity 只用于一致性,不授予权限。 +- `src/runtime/module_snapshot.rs`:模块树源码快照和隔离编译;这与 effect 工作区快照属于不同概念。 +- `src/runtime/closed_bundle.rs`:现有有界、闭合 import 图编译,可作为包入口校验基础。 +- `src/service.rs`:admission、run 固定 registry、durable context、恢复与提交门。 +- `src/capabilities/lifecycle.rs`、`src/runtime/agent_host.rs`:`tool_prepare` / `tool_commit`、token、parent、approval 与 replay。 +- `src/capabilities/confined_io.rs`:dirfd/openat2 文件边界。 +- `src/capabilities/vm_io/bounded_process.rs:2016` 附近:当前子进程由 `std::process::Command` 启动,清空环境、固定 cwd,并使用 process group。所读启动路径未提供完整 OS namespace/seccomp 根文件系统隔离;不能据此承诺任意 terminal 程序的 effect 已被沙盒约束。 +- `rss/storage/*.rss`:业务 SQL、迁移与持久化投影继续由 RSS 管理;Native 负责授权与提交不变量。 +- `src/gateway/api_server.rs`、`tests/support/live_http_delivery.rs`:实际 HTTP admission、SSE 与消息查询测试入口。 + +环境只做了只读发现:存在 `/usr/sbin/runc`、`/usr/bin/unshare`;未发现 bwrap、podman、crun、fuse-overlayfs。工作区 statfs 报告 `ext2/ext3`;这不能证明可用的 CoW 或 snapshot 能力。尚未验证 rootless namespaces、cgroup 委派、seccomp 或运行时版本。 + +### 范围选择 + +推荐先覆盖 Linux、单机、托管工作区、RSS 插件。第三方 native 动态库、多机分布式事务、进程 RAM/连接恢复、任意网站操作撤销不列入首期保证。 + +插件化范围包括: +- tools; +- agent 主循环策略、Guardian 评估策略; +- context 选择、compaction、prompt 组合; +- provider 请求/响应 codec 与路由策略; +- delivery 格式化、事件投影、受控状态读写策略。 + +必须保留在可信 Native 核心的部分:权限交集、credential/secret 保管、执行 token 验证、工作区授权、绝对 deadline/cancel、进程隔离、持久化原子提交、effect journal、恢复协调和管理面鉴权。RSS 插件可提出策略和请求,不能替换这些强制边界。存储后端协议和全局 schema 的升级受宿主兼容规则约束,不开放第三方插件直接写控制面数据库。 + +--- + +## 2. 方案比较与推荐 + +### A. 推荐:RSS 插件 + 不可变 graph + Native effect broker + rootless OS 沙盒 + +复用现有 RSS/VM、typed host 与 registry。业务扩展保持 RSS;Native 收敛为授权、生命周期和真实 OS 操作。支持多图并存和精确回退;需要新增插件调用桥、版本化工作区与隔离后端。 + +### B. 每个插件都做独立 OCI 服务 + +隔离更直观、语言自由度高,但简单 tool 也需要 IPC、协议管理和服务生命周期;现有 RSS 调度与资源 API 需要大量重做。可保留为将来的 `PluginExecutor` 后端,不作为首期默认。 + +### C. Rust 动态库插件 + 原目录备份 + +接入看似直接,但同进程 native 插件可绕过 broker;目录备份不能约束 terminal 对其他路径或网络的 effect,也难以提供一致回退。不采用。 + +--- + +## 3. 对外语义:先固定四种不同操作 + +1. **reload / activate graph**:新 run 使用新插件闭包;在途 run 继续使用原图。 +2. **rollback graph**:将未来 run 的默认图切回保留的旧图,创建新的 activation generation;不回拨 generation,不修改旧 run。 +3. **checkpoint / restore effects**:记录并恢复托管工作区和受控插件状态;不撤销模型费用、已收到的事件或外部远端事实。 +4. **resume from checkpoint**:从 checkpoint 产生新 branch/run,显式选择原图或经过兼容迁移的新图,重新授权 Native 资源。 + +“热”表示 API Server 不停机、旧/新图并存。首期不在一条正在执行的模型/工具调用中替换代码,也不静默改写既有 run 的工具 schemas。若要在会话中立即采用新工具,由客户端显式开始新 run;若发生工具集合/状态契约不兼容,必须生成新会话分支或拒绝继承,不能沿用旧 pending call。 + +--- + +## 4. 插件包、版本与依赖图 + +### 4.1 不可变包 + +包目录只包含 manifest、RSS 源码、受控资源和测试描述。安装阶段复制到宿主拥有的不可变 store,完成闭合源码捕获后再编译,执行时不重新读取可变开发目录。 + +拟定 manifest 示例,字段均为新设计: + +```yaml +format: rustscript.plugin/v1 +id: org.rustscript.tools.files +version: 1.0.0 +kind: tool +runtime: ">=0.1.0, <0.2.0" +contract: agent.tool/v1 +host_abi: agent.cap/v1 +entry: + path: write_file.rss + export: invoke +dependencies: + - alias: text + id: org.rustscript.lib.text + version: ">=1.0.0, <2.0.0" +capabilities: + - workspace.read + - workspace.write +effects: + contract: agent.effects/v1 + entries: + invoke: + direct: + - kind: workspace.fs.read + scope: bound_workspace + recovery: none + - kind: workspace.fs.write + scope: bound_workspace + recovery: checkpoint + delegated: [] +state: + scope: run + schema: 1 +``` + +- `(publisher namespace, id, version)` 唯一;同版本不同内容拒绝覆盖。开发变更产生新的 revision,不能就地修改 active 包。 +- `artifact_id` 是 store 分配的不可变对象标识,`graph_id` 是持久化 lock 对象标识;二者均不构成权限。 +- 第三方发布包需要管理员接受的发布者与签名;本地 unsigned 开发包只能由管理员显式导入受限开发空间。签名可信不扩大 capability。 +- 首期安装入口为受控本地目录。禁止自动执行安装脚本、自动拉取依赖、跟随符号链接或解包到任意宿主路径;在线 registry 后续单独设计。 +- manifest 严格解析:拒绝未知安全字段、重复 key、YAML alias/tag/merge 扩展及超预算输入。包数量、文件数、字节数、图深度、编译耗时和诊断输出均有界;现有界限不因插件加载被取消。 +- 初始配置上界:每图最多 64 个包、256 条依赖边、深度 16;闭包源码仍受现有 256 文件 / 8 MiB 总量约束;每包最多 32 个可选版本,求解最多 4096 个决策节点。达到上界返回具体 budget 错误,不扩大现有 compiler/runtime 限制。运行时缓存以实际对象大小与条目数双重计量,实施时复用并核实既有上界。 +- 包分为宿主内置 `system` 与受限 `tenant` 扩展。第三方签名和安装不会将 tenant 包提升为 system 包;控制面存储迁移及强制策略适配器只能来自宿主兼容的 system 集合。两者都不能绕过 Native 的原子提交和权限校验。 + +### 4.2 三类依赖边 + +- `library`:纯 RSS 库,静态链接到调用者的 VM,不拥有独立权限或状态。 +- `plugin`:显式插件调用边,经 Native broker 进入被调用插件的独立 VM/调用主体。 +- `host ABI`:对宿主 named types、capability 接口和事件契约的兼容要求。 + +禁止以普通 RSS 跨包 import 绕过 plugin 主体边界。Native 根据已校验 manifest 生成静态入口映射;用户输入不能构造 import、export 或磁盘路径。禁止并入整个插件集合后向单个 VM 授予所有插件 capability 的并集。 + +### 4.3 解析与 lock + +- 首期每个 graph 内每个 package 只允许一个具体版本;不同 graph 可同时运行不同版本。 +- resolver 只查询管理员安装的候选,确定性排序、有限搜索。缺失、冲突、循环、歧义、超预算均返回带最小冲突链的 typed 错误。 +- 生成持久化 `GraphLock`:准确 package revision、完整依赖边、入口、接口 schema、host ABI、权限声明、每个入口的直接/委托 effect 契约及解析后的传递闭包、状态 schema、编译环境兼容标识。 +- `PreparedGraph` 在 admission 前预解析、编译和验证,不运行有 effect 的 load hook。昂贵工作不得进入全局提交门或挤占原有 admission 时间。 +- 共享缓存只允许经过审计的不可变公共源码/Program/schema;Native resources、AuthHost、workspace 句柄、token、cancel 和带 owner 的对象每次调用重新绑定。缓存必须有容量和失效规则。 + +### 4.4 类型与存储记录 + +新增核心 named records:`PluginManifest`、`PackageRevision`、`GraphLock`、`ActivationRecord`、`PluginInvocationContext`、`EntryEffectContract`、`EffectRequirement`、`DelegatedEffectRequirement`、`EffectIntent`、`EffectReceipt`、`WorkspaceHead`、`CheckpointRecord`。它们分别记录不可变对象引用、owner、schema version、期望 generation/epoch 与状态,不以任意 map/unknown 暴露新的 host API。 + +每个插件 entry 的输入/输出 schema 在 prepare 时验证并安装为带 package 命名空间的契约,拒绝同名冲突。通用调度桥使用 Native 已验证的 payload/resource 引用;跨 VM 由 broker 解封数据并重新发行本 VM 包装,不复制调用者的 guest resource。已有工具的内部 RSS map 兼容层不能成为新增 host 接口的开放动态通道。 + +新增持久化记录采用 graph/package/activation、run graph pin、effect journal、workspace root/head、checkpoint 和 plugin state ref 的分离集合。它们通过 typed storage 命令加入既有 RSS SQL 事务,外部插件不能提供 SQL 字符串或自行修改这些记录。数据库约束验证 owner/ref 存在性与单调 generation,权限仍由 Native 检查。 + +### 4.5 仓库内 YAML schema 与同步维护门禁 + +YAML 格式的 machine-readable schema 是正式交付物,必须随开发保持 up-to-date 并 check in repo;只在文档里给示例、只在本地生成或只上传构建产物均不满足要求。 + +拟定仓库路径: +- `schemas/plugin-manifest.schema.yaml`:包 manifest。 +- `schemas/effect-contract.schema.yaml`:入口 direct/delegated effects、kind、scope 和 recovery。 +- `schemas/plugin-graph-lock.schema.yaml`:持久化 graph lock。 +- `schemas/v1/checkpoint.schema.yaml`:checkpoint 的可序列化公开结构。 +- `schemas/README.md`:schema 方言、版本映射、兼容规则、校验/生成命令与维护方式。 + +约束: +1. 采用 YAML 表达的 JSON Schema Draft 2020-12;manifest `format`、effect `contract`、其他记录版本与对应 schema 显式映射。各 schema 与本地 refs 一并提交,运行时和 CI 不从网络解析远端 schema。 +2. 字段、required、默认值、enum、scope、依赖结构、effect catalog 可序列化契约或兼容规则发生变化时,对应 YAML schema、Rust/RSS 消费者、正反例 fixture 和文档必须在同一个变更中更新;提交时一并纳入版本控制。新增文件须实际 tracked,不能仅存在于忽略目录。 +3. 公开结构契约以仓库中的 YAML schema 为单一来源;Native 使用这些确切的本地 schema 验证去重后的数据,再执行更强的语义、依赖与授权检查。提供确定性的 YAML → canonical JSON 生成/检查入口,生成快照也需 check in;CI/test 要求重新生成无差异。结构校验、Native 语义校验和真实 authority 校验分别给出正反例,不能声称 JSON Schema 可以单独表达全部语义。 +4. CI 必须验证所有 schema 自身合法、全部示例通过、负例被拒绝,并运行 schema 与实际解析器/类型的漂移测试。需要更新 schema 却未更新,或 schema 修改后实际消费者未对齐,均阻止合并。 +5. 不兼容修改发布新 schema/contract 版本,保留仍被已安装包、graph、run 或 checkpoint 引用的旧版本;禁止覆盖旧 schema 后继续使用同一版本标识。向后兼容修改也必须同步提交,不能靠以后补文档。 +6. schema 是结构契约,权限、依赖闭包、目标授权和实际 effect 校验仍由 Native 执行;schema 校验通过不能替代这些检查。YAML 重复 key/alias/tag 等限制在转换为 JSON 数据之前执行。 + +--- + +## 5. 调用主体、权限与 harness 插件化 + +### 5.1 调用流程 + +```text +HTTP / Telegram admission + → 固定 GraphLock + ToolRegistrySnapshot + workspace binding + → RSS main 插件 + → Native plugin-call broker + → RSS Guardian / tool / codec / context 插件 + → Native effect broker + → 托管文件视图 / OS 沙盒 / 受控远端 adapter + → durable result + workspace publication + → 固定版本的 delivery projection / outbox +``` + +- 跨插件只传已验证、限长的 named request/result,以及宿主控制的调用上下文。不得通过 JSON、整数或复制 guest resource 跨 VM 搬运权限。 +- Native 可以基于原有 run authority 给子调用重新发行本 VM 的资源包装;保留 graph、plugin revision、run、branch epoch、effect、owner 和绝对 deadline 绑定。 +- 有效权限取管理员策略、可信工作区授权、run 授权、调用者可委托权限、被调用者声明的交集;依赖声明不能自授权限。 +- caller 到 callee 的调用只允许 lock 中声明的边。禁止递归插件调用图;业务循环留在明确的 RSS 主循环中,并受原预算限制。 +- 凭据引用可以作为公开配置;实际 access/refresh、TLS key、认证头、FD 不进入插件状态、manifest、snapshot 或日志。 + +### 5.2 从当前六工具迁移 + +保持现有工具名称和 JSON schemas。`rss/tools/dispatch.rss` 从静态六工具入口逐步变为 graph 固定的声明式路由;具体 file/search/patch/process 业务继续在 RSS。 + +`tool_prepare` 仍先验证 parent、run、registry、审批与状态,再记录 started/intent 和发行一次性授权;`tool_commit` 仍负责 durable result。新的 plugin revision / branch epoch / effect ID 加入同一授权事实,不能仅检查工具名称。 + +持久化重放必须同时匹配 graph、tool revision、branch lineage 与 call identity。重启后从 run 的 lock 恢复原图;无法找到原包时返回 `plugin_artifact_unavailable`,不以当前 active 版本继续旧调用。 + +### 5.3 状态约束 + +- `invocation` / `run` / `session` 状态分别使用 Native 管理的有界版本化根引用。 +- 首期可变状态限于 run/session;服务级共享状态仅提供只读配置或宿主定义的事务 API,第三方插件不得自行全局写入。 +- 状态迁移是独立、纯函数、无权限的 RSS entry,输入旧 schema,输出新 schema;不能执行 SQL DDL、网络或文件操作。 +- 新状态写入新对象,不覆盖旧对象。需要切换 session 状态 schema 时取得该 session 的维护 lease,并等待其旧 run 到达终止边界;其他 session 不暂停。 +- 无法迁回旧状态时,graph rollback 返回 `state_incompatible` 或显式选择较早 checkpoint;禁止静默丢失新状态。 +- `rss/storage/schema.rss` 的控制面迁移使用向前兼容、扩展优先的宿主发布流程;它不随任意插件 activate 自动执行。 + +--- + +## 6. 热重载、回退与对象保留 + +候选图生命周期: + +```text +installed → resolved → prepared → validated → staged → active + ↘ rejected +active → retired(仍可被旧 run / checkpoint / outbox 引用) +``` + +激活事务: +1. 在提交门外完成源码闭包、版本求解、编译、contract 检查、权限 diff、无 effect 的健康检查及必要的状态迁移准备。 +2. 管理员检查图 diff,提交 `expected_generation`。 +3. 原子提交新的 activation record 和 active graph/state-default 引用;更新内存。并发激活失败方返回 conflict,不能覆盖胜出方。 +4. 后续 admission 在原有原子事务内记录 graph lock 和 registry,不重新编译候选。 +5. 旧 run、旧 provider continuation、background process、pending delivery 使用它们已固定的版本完成。 + +- 回退为一次新的激活:例如 generation 8 指向以前保留的 graph;generation 不下降,防止 ABA。 +- 不兼容 ABI/schema、增加权限、改动已绑定工具 schemas 均不自动激活。 +- watcher 只发出“候选变化”信号。写到一半的包、编译失败或未确认的权限提升不会替换 active 图。 +- 显式撤销某插件时可禁止新调用并按策略取消在途 run;“已撤销”不能被旧 run pin 或 snapshot 恢复解除。 +- GC 以 run/session/checkpoint/activation/outbox 的 durable refs 和 Native 活跃 lease 为依据;有引用的包、状态和文件树不得删除。保留失败候选的有界诊断,不无限保留构建缓存。 + +--- + +## 7. Effect 显式契约、分类和可回退保证 + +### 7.1 所有插件入口必须显式声明 effects + +这是安装、依赖解析、激活与运行时的强制契约,覆盖 tool、main、Guardian、context、codec、delivery、library 和状态迁移入口。 + +- manifest 必须包含带版本的 `effects`,每个可调用 export 必须给出 `direct` 和 `delegated`。缺失、未知 kind、未覆盖入口或不明确的通配声明直接拒绝;不采用“省略等于纯”的规则。 +- `direct` 是本入口可能直接触发的 effect 上界;`delegated` 是本入口允许通过依赖调用触发的 effect 上界,按依赖 alias 和准确 entry 列出。调用者声明委托 effect 不会得到被调用插件的凭据或直接 host 能力。 +- 纯入口必须显式声明 `direct: []`、`delegated: []`。普通返回值和 Native 自身的计量/安全审计不计为插件 effect;插件主动写状态、发事件、读取时钟/随机数、调用模型和发送消息均必须声明。纯入口不得偷偷使用这些能力。 +- 每条声明采用 Native 已知的 effect kind 和该 kind 的 typed scope;例如 workspace 绑定、状态命名空间、受控 provider/远端 adapter。参数中的实际目标仍要由 Native 在执行前按该 scope 校验,不能用模型提供的 path/URL 放大范围。 +- `recovery` 描述期望的恢复能力,实际可用性由 Native effect catalog 和 adapter 决定。插件不能把远端发送标成 checkpoint,也不能把 managed 写入标成 pure;冲突在 prepare/activation 时拒绝。 +- effect 契约描述“可能做什么”;capability grant 决定“本次允许做什么”。实际执行必须同时属于已固定声明、caller 可委托上界、被调入口上界、管理员/run/workspace 授权,并通过当前撤销、deadline 和 budget 校验。 + +纯入口示例: + +```yaml +effects: + contract: agent.effects/v1 + entries: + normalize: + direct: [] + delegated: [] +``` + +委托文件写入示例: + +```yaml +effects: + contract: agent.effects/v1 + entries: + invoke: + direct: [] + delegated: + - dependency: files + entry: invoke + allow: + - kind: workspace.fs.read + scope: bound_workspace + recovery: none + - kind: workspace.fs.write + scope: bound_workspace + recovery: checkpoint +``` + +其中 `files` 必须在 manifest dependencies 中声明。解析后,将 callee entry 的全部可能 effects 连同它继续委托的 effects,经过目标绑定映射后检查为 caller 该边 `allow` 的子集;有一个未覆盖项就返回 `undeclared_transitive_effect`。闭包只包含显式允许的调用边,不能把依赖图的权限并集发给 VM。后台 job、回调和 outbox consumer 同样必须声明入口与委托边,并固定原 graph/effect 上界;不能通过异步调度隐藏 effect。新增外部投递订阅也必须进入 graph 的 effect diff 和审批。 + +### 7.2 静态校验、动态拦截与版本兼容 + +1. prepare 读取闭合 RSS/compiled imports,与应用侧 Native `EffectCatalog` 的准确 host ABI 对照。每个 effectful host 入口必须有明确 metadata;未知项拒绝。首期可采用保守的模块 imports 上界,不能声称已经具备语言级精确函数 effect 推导。 +2. effectful host 调用只绑定到相应的受控 bridge;跨插件调用校验锁定的 delegated 边。无法确定目标的动态调用不得绕过检查。 +3. Native 在真实 effect 开始前检查已固定入口契约。未声明操作返回 `undeclared_effect`,不得先执行再仅记录告警。journal 中保存声明契约 revision 和实际 receipt;契约声明不能替代真实 receipt。 +4. 对任意 terminal 程序,静态 argv 分析不能证明精确 effect。必须保守声明 `process.spawn`、`process.output` 及允许的 workspace 读写上界,并由 namespace、挂载和网络隔离约束其全部后代;执行脚本不能绕过声明扩大边界。 +5. effect 新增、scope 扩张、callee effects 扩张或 recovery 保证降低都属于安全契约变更。即使包版本标为 patch,也必须生成 graph effect diff 并重新审批,不允许自动热激活。scope 变窄或 effect 删除也要检查依赖调用兼容。 +6. Graph/API 管理视图显示入口级直接 effects、委托 effects、解析闭包、实际 grant 和恢复保证;调用审计可对照实际 receipt。模型可见的工具 metadata 从这个固定契约投影,不能由模型编写或修改声明。 + +首期 effect catalog 至少区分:`workspace.fs.read/write`、`plugin_state.read/write`、`process.spawn/output`、`clock.read`、`random.read`、`model.request`、`event.emit`、`delivery.send` 和受控远端 adapter 的 read/write。具体命名和 scope 是有版本的 host contract;不使用一个 `filesystem` 或 `network` 大标签隐藏所有行为。 + +### 可以完整恢复的受控对象 + +- 托管工作区内的文件树、内容和声明支持的权限元数据。 +- Native 管理的插件 run/session 状态。 +- 已终止本地进程产生的上述文件变化。 + +### 需要终止并重新启动的对象 + +- 进程、线程、打开的 FD、socket、锁和内存。checkpoint 只在无活动写入者的安全边界成立,不保存这些运行态。 +- 后台 job 是 open effect;spawn 返回成功不表示整个 job 已完成。checkpoint/restore 要求先停止并确认所有后代退出。首期可先拒绝 sandbox profile 的 writable background 模式,后续以明确的 open-effect lease 支持,不能假装已 snapshot。 + +### 不能承诺通用撤销的对象 + +- 模型请求、费用、已经流出的 stdout/SSE、已发送通知。 +- 远端 HTTP 写入、支付、邮件、Git push、第三方 DB 修改。 + +这些行为必须由专门 adapter 标记为 `remote_read`、`compensatable` 或 `irreversible`。补偿需要目标服务明确支持的协议,失败会进入 reconciliation 状态。未知 effect 默认拒绝;不能通过恢复本地 journal 把远端事实标为未发生。 + +首期 sandbox tool 默认禁网、无 daemon socket、无宿主设备和凭据。确需远端能力时,只能通过已审批的 Native broker adapter;provider 的 LLM transport 是单独的宿主能力,不向任意 tool 开放网络。 + +--- + +## 8. 真正的 OS 沙盒与统一文件视图 + +### 8.1 后端选择 + +选定 rootless OCI 作为首期进程后端,优先评估当前 runc;必须在实施阶段固定兼容版本并验证 rootless 能力。不得假定现有二进制已经具备所需配置。 + +强制能力: +- user/mount/PID/network namespace,private rootfs,默认无网络; +- 只读的固定 toolchain/rootfs,只有该 effect 的工作区和私有 temp 可写; +- 无宿主 home、Hermes pool、SSH agent、Docker socket、宿主 `/proc` 或设备挂载; +- 最小 capabilities、no_new_privs、宿主审定的 seccomp policy; +- cgroup v2 的进程数、内存/CPU 与整树终止;rootless 委派不可用时该 strict backend 不可用; +- 明确且有界的 FD 继承、stdin/stdout/stderr、wall deadline 和输出预算。 + +不用 Docker daemon group 权限作为不可信代码的默认执行边界。不采用 cwd/环境变量替代 namespace。无法满足 mandatory 能力时返回 `sandbox_unavailable`,不退回宿主 `Command`。 + +工具链镜像由管理员预置并固定版本;执行期间不拉取镜像、不自动安装依赖。Rust linker、动态库、解释器等只读依赖必须作为镜像的一部分,不能为编译成功额外挂载宿主根目录。 + +### 8.2 同一个 WorkspaceView + +RSS 的 `read_file/write_file/patch/search_files` 与 terminal 子进程使用同一个 Native 发行的 `WorkspaceView`。Native FS adapter 指向该 effect 的私有视图 dirfd,子进程挂载同一视图;不允许一个写宿主原目录、另一个写容器目录。 + +所有文件系统路径继续受原相对路径、symlink、magic-link、长度和枚举限制。Native object store、控制面 DB、插件包目录不在 tool 挂载空间。 + +### 8.3 工作区导入、发布和回退范围 + +首期以托管工作区为事务单位: +- 从授权源目录创建初始 seed,后续 run 在私有 branch 工作;原源目录不自动被覆写。 +- 对仍被外部写入的普通目录,复制不能提供跨文件的线性一致快照。严格导入必须依赖管理员静止源或经过验证的文件系统 snapshot;否则显式标记为普通导入、检测可见竞争并拒绝不一致,不能承诺来源的时间点一致性。 +- 管理端维护 `workspace_head` 引用;run 成功可按策略原子发布到托管 head。head 的 CAS 失败保留 private branch,返回 conflict;不覆盖其他 run 的更新。 +- 导出到宿主使用新目录的 staging + rename。对任意现有宿主目录做多文件合并不具备通用原子性,列为独立显式操作,不列入首期完整回退保证。 + +该模型会改变当前直接写原工作区的行为,API 必须返回 branch/head 与 publication 状态,客户端不能把“已 staged”显示成“宿主原目录已更新”。 + +--- + +## 9. Native Effect Broker 与 crash-safe 提交 + +### 9.1 事务单位 + +同步写工具默认使用子 effect view:基于当前 branch head 建立私有可写视图,在其中执行 RSS 多步文件操作或 terminal。工具成功后关闭写入者并生成不可变结果树;失败可丢弃子视图,父 head 不变。 + +初始后端采用有界复制创建独立树,避免假设 reflink/OverlayFS。后续可增加有能力探测和一致语义测试的 CoW 后端。禁止用普通 hardlink 把可写树与不可变 snapshot 共享 inode;也不能复制宿主外部 hardlink、device、socket 或提权元数据。 + +大型工作区的复制开销必须测量。只读 toolchain/依赖缓存不进入每次复制;可写 build 输出仍属于 effect 和配额,不能因性能需求绕过隔离。 + +### 9.2 与现有 lifecycle 对接 + +```text +prepare/approval + → durable tool.started + effect.intent + → 发行绑定 graph/plugin/run/branch epoch 的一次性授权 + → Native effect 执行,所有输出进入私有 view + → quiesce + sync + 不可变 tree/receipt 准备 + → durable effect receipt + branch-head CAS + tool result + → 内存 publication + provider continuation +``` + +- 源码编译、文件树复制、进程等待和 fsync 等重活在全局提交门外。 +- SQLite 仍是控制面原子点:不可变文件对象先落盘,事务只发布它们的引用。崩溃产生的未引用对象按 journal 回收;已提交引用不能指向未持久化树。 +- 现有 tool_call/started 配对、不成功不发行 token、取消/超时/撤销门禁、原始 deadline、输出预算和去重不变量继续保留。 +- RSS 报告的 effect 类型只是请求,Native 以实际已发行能力和真实操作生成 receipt;不相信插件自称“纯函数”。 +- 若 Native 已完成 effect 而 RSS 未提交工具结果,恢复读取已持久化 receipt;不得再次执行已经发生的操作。状态不明则返回 `effect_indeterminate`,保留分支并等待显式 reconcile,不自动重复 terminal。 +- shared-service 可变状态和不具备事务协议的远端 effect 不伪装成上述本地原子事务。 + +建议 journal 状态:`intent → running → prepared → committed`;异常分支 `aborted / interrupted / indeterminate / needs_reconciliation`。每条记录绑定准确 owner、effect、call、graph、plugin revision、branch epoch、父/子 root 与 output artifact。 + +--- + +## 10. Checkpoint 与恢复协议 + +### 10.1 Checkpoint 内容 + +`Checkpoint` 保存: +- workspace immutable root 与 branch lineage; +- 插件状态根及其 schema/revision; +- graph lock、registry identity、公开配置 revision; +- 业务 history cursor、已闭合 call/output 边界、parent run; +- 已知不可逆 effect 的 high-water mark 和受控 artifact refs。 + +不保存 access/refresh、VM heap、Native token、FD、socket、活进程、审批授权或整个控制面 DB 文件。 + +### 10.2 创建条件 + +1. 取得该 branch/session 的维护 lease,并让相关 run 在工具批次闭合的协作安全点暂停新 provider 请求和 tool dispatch。已有 provider stream / effect 必须先 settled;不在半个模型调用或半组 tool outputs 上截断。暂停等待受管理请求期限和原 run 剩余期限共同约束,不能延长原 run。 +2. 在提交门外等待当前 effect settled。活动写进程存在时返回 `checkpoint_busy`;显式 quiesce 模式才先取消、终止并确认该沙盒的全部后代退出。 +3. Native 在提交门内重新验证原 graph、head、状态 revision、history cursor 和 lease;checkpoint 只落在完整模型工具批次结束的位置,不能保留无对应 output 的 pending tool_call。发生相关并发变化时拒绝或在原期限内重新准备,不能记录混合状态。 +4. 原子记录 checkpoint 引用并释放维护 lease。其他工作区继续运行;不使用服务级全局暂停。 + +### 10.3 恢复 + +- 用户恢复产生新 branch。若目标 branch 有活动 run,先显式取消并等待实际资源退出;不在执行中替换脚下目录。 +- 使用 checkpoint 的确切 graph;选择新 graph 时先通过独立的 schema/state 兼容准备。 +- branch epoch 递增;旧 token、process handle 和未完成 effect 的权限全部失效。重新检查当前 workspace/credential/plugin 撤销策略。 +- 新 run 从 checkpoint 的闭合历史前缀开始,保留 lineage;旧 run、旧事件、旧计费及旧投递记录保持只读。后续新调用使用新 call ID 和 parent。 +- 新 run 有明确的新 admission/deadline;不复活旧 run,不重置旧 run 的计时来绕过超时。 +- 已在远端发生的行为或用户已经收到的进度不被抹除;恢复结果附带 `external_effects_not_reverted` 清单。 +- 不能用 SQLite 文件回滚实现该功能,否则会错误恢复权限、usage、审批和投递游标。 + +--- + +## 11. 拟新增管理 API 与事件 + +以下均为拟定新接口,不能在实现前对外宣称存在。普通 run 的既有 HTTP routes 保持兼容。 + +现有 `POST /v1/runs` 拟增加可选 `workspace_branch`、`graph_id`:普通新分支默认采用 active graph,恢复分支默认采用 checkpoint 的 graph。显式指定其他图必须通过部署空间许可和状态兼容检查;graph ID 本身不授予任意已安装插件的调用权。响应增加实际固定的 graph、branch、epoch 和 publication 策略。新字段必须进入 typed 请求契约,不能继续落入任意 options 或由客户端路径生成 Native authority。 + +插件管理面,要求独立管理员作用域并绑定 tenant: +- `POST /v1/plugins/install`:受控来源导入,返回不可变 revision。 +- `POST /v1/plugin-graphs/resolve`:解析 lock + diff,不激活。 +- `POST /v1/plugin-graphs/{graph_id}/prepare`:编译/验证,返回状态。 +- `POST /v1/plugin-activations`:携带 graph、目标部署空间、expected_generation。 +- `GET /v1/plugin-graphs/{graph_id}`:节点、边、contract、权限和错误链。 +- `POST /v1/plugin-activations/{activation_id}/rollback`:产生新 activation,权限与正常激活相同。 + +工作区管理面,要求该 workspace 的读/恢复/发布权限: +- `POST /v1/workspaces/{workspace_id}/checkpoints`。 +- `POST /v1/workspaces/{workspace_id}/branches`:可指定 checkpoint 来源。 +- `POST /v1/workspaces/{workspace_id}/restore`:返回新 branch,不能偷偷回写原目录。 +- `POST /v1/workspaces/{workspace_id}/publish`:携带 branch、expected_head。 +- `GET /v1/runs/{run_id}/effects`:有界 receipt/journal 列表。 + +新增 SSE 字段与事件保持显式 schema/version: +- 每个 run 可查询 `graph_id`、`activation_generation`、`workspace_branch`、`head`。 +- `plugin.graph_activated`、`effect.started/committed/aborted`、`checkpoint.created`、`workspace.restored/published`。 +- plugin/event version 不能在已冻结的 run 中改变语义;delivery outbox 对应的投影版本随记录固定。 +- 最终 `run.completed` 在配置要求的 workspace publication 完成后发出;若仅 staged,结果显式声明 staged branch。 +- 管理对象标识一律由 Native 查询并验证 owner;不能把 path、graph ID 或签名当作授权。 + +--- + +## 12. 分阶段实施任务与文件清单 + +所有代码任务遵循 RED → GREEN → REFACTOR:先增加具体失败断言并实际运行,再做最小实现,然后运行该目标及直接邻接回归。下面列出的新文件和测试目标均为拟创建;实际实现若文件落点调整,须先更新本计划。不要为完成一个阶段一次性拆散整个 `service.rs`。 + +### M0:验收基线与宿主能力探测 + +**T01 基线固定** +- 读取当前改动和真实 API E2E 记录;将既有生产修复与本计划实现分开管理。 +- Test:`tests/codex_access_live_e2e_tests.rs` 已有 `live_rust_program::real_api_server_http_rust_program_e2e`;普通回归先运行 `service_tests`、`rss_process_tool_tests`、`real_tool_continuation_tests`。 +- 不重复真实凭据测试作为日常单测;新真实 E2E 在最终阶段显式 opt-in。 + +**T02 sandbox 能力报告** +- Create:`src/sandbox/{mod.rs,types.rs,probe.rs}`、`tests/sandbox_backend_tests.rs`。OS 隔离后端独立于 workspace/effect 事务模块,便于独立实现与验证。 +- RED:缺少 mandatory namespace/cgroup/seccomp 任一项时 `strict_backend_is_unavailable_without_all_required_controls` 必须失败关闭。 +- GREEN:只读能力报告与单独的受控启动探针;不修改系统 sysctl,不自动提升权限。 +- Gate:`cargo test --locked --test sandbox_backend_tests`。缺少宿主条件是明确的交付前置条件,不能将跳过隔离用例记为 PASS。 + +### M1:manifest、依赖图与持久化 lock + +**T03 manifest 与包存储** +- Create:`src/plugins/{mod.rs,types.rs,manifest.rs,store.rs}`、`tests/plugin_manifest_tests.rs`、`tests/plugin_schema_sync_tests.rs`、`schemas/plugin-manifest.schema.yaml`、`schemas/effect-contract.schema.yaml`、`schemas/README.md`。 +- 先增加 schema/解析器漂移与正反例测试;从本任务开始,每个新增 YAML 格式都要有仓库内 schema,后续实现修改必须同步更新。 +- RED:重复字段、路径逃逸、symlink 包、同版本覆盖、缺失发布者授权、缺失 effects、未覆盖 export、未知 effect 和伪造 recovery 均拒绝;显式空 effect 的纯入口及有效受限写入口接受。 +- GREEN:严格有界解析、Native 所有的不可变包目录和 typed metadata;根据需要在 `Cargo.toml`/`Cargo.lock` 添加 `semver`。 +- Gate:`cargo test --locked --test plugin_manifest_tests --test plugin_schema_sync_tests`;提交前确认相关 YAML schemas 已 tracked,CI 拒绝未同步或生成后有差异的版本。 + +**T04 resolver 与 closed bundle 集成** +- Create:`src/plugins/{resolver.rs,graph.rs}`、`tests/plugin_graph_tests.rs`、`schemas/plugin-graph-lock.schema.yaml`;同步扩展 `tests/plugin_schema_sync_tests.rs`。 +- Modify:`src/runtime/closed_bundle.rs` 的 crate 内复用接口,不扩展 Core。 +- RED:冲突链、缺依赖、循环、越权 host import、未声明的直接 effect、callee 传递 effect 未被 allow 覆盖、不同文件枚举顺序造成不同解;正控为准确且满足入口 effect 上界的固定闭包。 +- GREEN:版本求解、不可变 lock、受限入口解析及准备。 +- Gate:`cargo test --locked --test plugin_graph_tests`。 + +**T05 lock / activation 的 storage 协议** +- Create:`rss/storage/plugins.rss`、`tests/plugin_store_recovery_tests.rs`。 +- Modify:`rss/storage/schema.rss`、`rss/storage/main.rss`、`src/domain.rs`、`src/sqlite_storage_rows.rs`。 +- RED:提交失败无半激活;重启重建同一 lock;有 durable 引用时不能 GC。 +- GREEN:向前兼容的对象/引用记录,SQLite 访问继续走生产 typed SDK/RSS 路径。 +- Gate:`cargo test --locked --test plugin_store_recovery_tests`。 + +### M2:按 run 固定图与 RSS 插件调用边界 + +**T06 admission / replay 固定图** +- Create:`tests/plugin_run_pinning_tests.rs`。 +- Modify:`src/service.rs`、`src/registry.rs`、`src/durable_provider.rs`、`rss/storage/admission.rss`、`rss/storage/runs.rss`。 +- RED:A run 开始后激活 B,A 的后续 provider/tool/replay 仍为 A;新 run 为 B;重启不改用 active 图;缺 A 包明确失败。 +- GREEN:既有 admission 事务加入 lock/ref 固定;prepared graph 在门外准备,不新增热路径编译。 +- Gate:`cargo test --locked --test plugin_run_pinning_tests --test provider_durable_replay_loop_tests`。 + +**T07 typed 插件调用桥与工具适配** +- Create:`src/plugins/invocation.rs`、`src/runtime/plugin_host.rs`、`tests/plugin_invocation_tests.rs`。 +- Modify:`src/runtime/{agent_host.rs,host_types.rs,rss_runner.rs}`、`rss/tools/{registry.rss,dispatch.rss}`。 +- RED:A 无法调用未声明的 B;B 不能继承 A 的其他权限;resource 无法借 JSON 跨 VM;同名不同 revision 不能重放旧结果;缺失 effect 的真实 Native 操作在发生前被拒绝;纯入口不能通过库、时钟或插件委托隐藏 effect。 +- GREEN:独立调用主体、Native 重新发行子 VM 资源、六工具 schemas 不变。 +- Gate:`cargo test --locked --test plugin_invocation_tests --test rss_process_tool_tests`。 +- 该阶段只为内置可信包提供兼容路径;在 M3 通过前不开放不可信进程插件。 + +### M3:统一 effect view 与 OS 隔离 + +**T08 托管文件树和子 effect view** +- Create:`src/effects/{mod.rs,types.rs,workspace.rs,tree_store.rs}`、`tests/effect_workspace_tests.rs`。 +- Modify:`src/capabilities/{filesystem.rs,confined_io.rs}`。 +- RED:写 view 不改 seed;文件工具与 terminal 看到同一内容;失败工具的父 head 不变;可写 view 不与 checkpoint 共享可写 inode。 +- GREEN:有界复制后端、private temp、原子 Native refs、quota 和安全文件类型处理。 +- Gate:`cargo test --locked --test effect_workspace_tests`。 + +**T09 rootless OCI 进程后端** +- Create:`src/sandbox/oci.rs`、`tests/sandbox_isolation_tests.rs`。 +- Modify:`src/capabilities/process.rs`、`src/capabilities/vm_io/bounded_process.rs`。 +- RED:尝试访问宿主哨兵文件、网络、daemon socket、宿主 PID namespace 必须拒绝;fork/setsid 后代在终止后实际消失。 +- GREEN:固定 OCI profile、工具链 rootfs、namespace/seccomp/cgroup 策略、Native launcher 生命周期。 +- Gate:`cargo test --locked --test sandbox_isolation_tests`;缺后端时给明确 NOT RUN 结果,发布验收不接受以 ignore 替代隔离证明。 + +**T10 effect journal 与真实工具提交** +- Create:`src/effects/{broker.rs,journal.rs}`、`rss/storage/effects.rss`、`tests/effect_commit_recovery_tests.rs`。 +- Modify:`src/capabilities/{lifecycle.rs,types.rs}`、`src/service.rs`、`rss/storage/schema.rss`。 +- RED:在 intent、文件准备、持久化 receipt、head CAS、result 提交各点崩溃,重启后无已提交悬空 root、无重复已执行 terminal。 +- GREEN:将 Native receipt 和现有 started/result 绑定;不扩大 token 权限、不绕过审批、不重置 deadline。 +- Gate:`cargo test --locked --test effect_commit_recovery_tests --test real_tool_continuation_tests`。 + +### M4:checkpoint、恢复与插件状态 + +**T11 checkpoint 和 branch restore** +- Create:`src/effects/{checkpoint.rs,recovery.rs}`、`rss/storage/checkpoints.rss`、`tests/effect_checkpoint_tests.rs`、`schemas/v1/checkpoint.schema.yaml`;同步扩展 schema 一致性测试。 +- RED:活动 writer 拒绝 checkpoint;恢复前确认沙盒后代退出;旧 epoch token 被拒绝;旧审计/usage/审批记录不被删除或复活。 +- GREEN:闭合 history cursor + root refs;恢复新 branch/new run,重新授权。 +- Gate:`cargo test --locked --test effect_checkpoint_tests`。 + +**T12 有版本的插件状态** +- Create:`src/plugins/state.rs`、`tests/plugin_state_migration_tests.rs`。 +- Modify:`rss/storage/plugins.rss`、`src/plugins/types.rs`。 +- RED:无 inverse 的破坏性迁移不能声称可 graph rollback;旧 run 不观察新 schema;迁移崩溃不切换 active ref。 +- GREEN:纯迁移、新状态对象、session 维护 lease 和 checkpoint 兼容检查。 +- Gate:`cargo test --locked --test plugin_state_migration_tests`。 + +### M5:热重载、回退与 harness 业务模块迁移 + +**T13 原子激活、回退和 GC** +- Create:`src/plugins/{activation.rs,retention.rs}`、`tests/plugin_hot_reload_tests.rs`。 +- RED:两个管理请求同 generation 只有一个成功;回退 generation 递增;旧 run 与 pending delivery 固定旧图;引用对象不能删除;patch 版本新增 effect、扩大 scope 或降低 recovery 保证时不得自动激活。 +- GREEN:候选图准备、权限 diff、CAS 激活、可审计撤销和保留策略。 +- Gate:`cargo test --locked --test plugin_hot_reload_tests`。 + +**T14 harness 按边界迁移** +- Create:`rss/plugins/` 内置 manifest/入口、`tests/harness_plugin_tests.rs`。 +- Modify:`rss/agent/{main.rss,guardian.rss,compact.rss,provider_runtime.rss}`、`rss/llm/provider_dispatch.rss`、`src/prompt/coding.rs`、`src/runtime/delivery.rs`。 +- 按顺序完成独立 RED/GREEN:context/prompt → Guardian 策略 → provider codec → delivery 投影 → main 策略。 +- 每一步证明替换该插件不改变 Native grant、durable 事件身份、固定 schemas、秘密边界和停止行为。 +- Gate:`cargo test --locked --test harness_plugin_tests --test agent_loop_tests --test prompt_tests --test codex_responses_codec_tests`。 +- 存储底层与强制授权逻辑不因“插件化”移入可任意替换的普通插件。 + +### M6:真正的对外 API 与操作界面 + +**T15 插件 / 工作区管理 API** +- Create:`src/gateway/{plugins.rs,workspaces.rs}`、`tests/plugin_admin_api_tests.rs`、`tests/effect_admin_api_tests.rs`。 +- Modify:`src/gateway/api_server.rs`、`src/config.rs`、`src/cli.rs`。 +- RED:普通 run token 不可激活插件;跨 tenant、workspace、branch 越权失败;冲突返回 409;不存在和无权的对象不泄露私有路径。 +- GREEN:typed 管理请求、管理员作用域、dry-run/diff、effect/checkpoint/publication 响应。 +- Gate:`cargo test --locked --test plugin_admin_api_tests --test effect_admin_api_tests`。 + +**T16 HTTP 全链路 E2E 与崩溃矩阵** +- Create:`tests/plugin_effect_api_e2e_tests.rs`。 +- Modify:`tests/support/live_http_delivery.rs`;最终新增 opt-in live case 于 `tests/codex_access_live_e2e_tests.rs` / support。 +- 从真实 socket 上的 `POST /api/sessions`、`POST /v1/runs` 发起,禁止用内部 SDK 代交任务后把 SSE 接收等同于 API 入口验证。 +- 场景:A 图 run 写/编译 Rust → checkpoint → 激活 B → A run 继续使用 A → 新 run 使用 B → 恢复 checkpoint 到新分支 → 回退默认图 → HTTP 查询 graph、effects、messages,并验证最终 delivery 与 durable 数据一致。 +- 普通 E2E 使用确定性 provider fixture,真实工具和真实 OS 后端;最后单独 opt-in 实际模型写程序。真实场景通过并不替代 crash、越权和资源回收负控。 +- Gate:`cargo test --locked --test plugin_effect_api_e2e_tests`,再执行明确授权的 ignored live case。 + +--- + +## 13. 必须覆盖的测试矩阵 + +### 插件与版本 +- 安装重复、损坏包、签名/发布者失败、依赖缺失/冲突/循环、契约不兼容。 +- 仓库 YAML schemas 自身合法、跨 schema 本地 refs 可解析、示例/负例与实际解析器一致;类型或 effect 契约变更而 schema 未更新时 CI 必须失败;生成文件无漂移且被版本控制。 +- effect 声明缺失、纯入口伪装、direct/delegated 边界、传递 effect 漏报、未知 host effect、伪造恢复能力、版本升级 effect 扩张及 runtime before-effect denial。 +- Native imports 越权、跨包直接 import 绕过、插件 ID 与 graph ID 伪造。 +- A/B 并存、并发 activation、rollback ABA、重启恢复、缺包、GC 与缓存容量。 +- 源目录在准备后改变,已准备图仍只执行原始已捕获源码;旧 VM owner 资源不可在新 VM 使用。 + +### 文件、进程与恢复 +- 创建/修改/删除/rename、执行权限、空目录、受限 symlink/hardlink 及超额文件树。 +- terminal 使用绝对路径、子进程派生、setsid、持有旧 FD、超时、取消、输出淹没、磁盘不足。 +- 文件工具写入后 terminal 立即读取;terminal 写入后 RSS read_file 返回同一 view。 +- snapshot 后修改再 restore,保证受控树与状态一致;不能恢复已撤销权限。 +- 所有关键 FS/SQLite publication 崩溃点;receipt 已存在时不重复 effect,未知状态不假成功。 +- 不改变 global suite concurrency,不使用放宽 deadline、取消断言或 retry-until-pass 使测试通过。 + +### API、交付与外部 effect +- 401/403、cross-tenant、409 CAS conflict、202 admission、实际 SSE 完成、消息/graph/effect 查询。 +- rollback 不删除已经投递的正文;outbox 继续使用固定版本;消息 ID、seq 和最终正文与数据库投影一致。 +- 模型调用用量不随 checkpoint 恢复清零;远端 effect 标为不可恢复或需要 reconcile。 +- 隔离检查用管理员预置的无敏感哨兵;不得读取真实凭据来证明逃逸被阻止。 + +### 真实最终验收 +- 模型自行生成 Rust 源码,真实文件写入/读回、编译、至少三个测试与正常/空/非法 CLI 分支,最终答复通过 API Server HTTP SSE 收到。 +- 模型生成物由父级独立编译核验;snapshot 前后文件与状态按受控 Native API 验证。 +- 如用户再次授权 Hermes pool access-only,仍只在隔离副本填假 refresh,记录零刷新请求;真实 token 不进入日志或插件快照。 +- 真实 OS 后端不可用、缺少 cgroup 委派、某负控未执行时按实际记录,不能报告整个沙盒已验收。 + +验证命令随阶段使用上述具名 targets,并保持现有 lease 的 target/TMP/offline/jobs 设置。最终在有后端能力的环境执行 Debug/Release 相关矩阵、`cargo fmt --all --check`、`cargo clippy --locked --all-features --all-targets -- -D warnings`、`git diff --check`。是否执行全 workspace suite 以变更范围及既有规则决定;不把 no-run 或零选中测试计为通过。 + +--- + +## 14. 上线顺序与验收门 + +1. **G1 插件基础完成**:manifest/resolver/lock、精确回放和 run pinning 通过;仅内置受信包可用。 +2. **G2 沙盒执行完成**:文件工具与 terminal 的统一 view、真实 OS 越权负控、进程全树终止及 journal recovery 通过。 +3. **G3 本地效果回退完成**:checkpoint、branch、状态迁移、epoch 撤销与 crash 矩阵通过。 +4. **G4 插件热重载完成**:原子激活、旧/新图并存、回退兼容、retention 与 harness 迁移通过。 +5. **G5 对外功能完成**:实际 HTTP admission → 工具 effects → snapshot/restore → graph rollback → 最终 delivery 全链路通过。 + +可独立发布的首个可用切片为:内置 tools 的版本化 graph + mandatory OS 沙盒 + 托管工作区 checkpoint。随后补齐 harness 扩展点与管理 API;不在 G2 前把第三方进程插件作为可用功能开放。 + +实施仍需实测并报告的环境条件:rootless runtime 版本、user namespace 策略、cgroup v2 委派、seccomp 可用性、toolchain 镜像和存储配额。方案选型已明确,能力不足时给出具体缺项;不得通过宿主裸执行、临时特权或弱化测试绕过。 + +## 15. 设计自检结论 + +- YAML 格式的 machine-readable schemas 纳入仓库版本控制,与实现、fixture 和文档同步更新,CI 对 drift 与兼容版本执行门禁。 +- 每个插件入口显式声明直接/委托 effects,纯入口显式为空;传递闭包和真实 Native 操作都受该上界约束,声明不授予权限。 +- 插件版本回退、状态回退和外部行为补偿已分开定义。 +- 所有 effectful 扩展均受 Native broker 与原权限/取消不变量约束。 +- 不在运行中替换 frozen tools/schema;明确新 run、旧 run、旧 outbox 的版本归属。 +- 不承诺任意外部目录的原子多文件合并,不承诺活进程内存或远端服务通用撤销。 +- 不快照/回滚控制面 DB、凭据、审批和 usage。 +- 当前仅产出设计计划;以上新类型、模块、路由和测试均待实现与实测。 diff --git a/rss/agent/main.rss b/rss/agent/main.rss index c458002..80a390d 100644 --- a/rss/agent/main.rss +++ b/rss/agent/main.rss @@ -554,6 +554,52 @@ fn decide_approval(context: map, action: map, approval_id: string) -> map { result } +fn execute_pinned_dispatch(context: map, dispatch_input: map, facts: map) -> map { + let action: map = ctx_map(facts, "action"); + let mut call: map = ctx_map(dispatch_input, "call"); + if ctx_bool(facts, "ok", false) { + call.arguments = ctx_map(action, "arguments"); + } + let arguments: map = ctx_map(call, "arguments"); + let encoded_arguments: string = json::encode(arguments); + // Descriptor/approval resolution stays in this original harness. The + // child needs the normalized call and bounded limits, not a second copy + // of all six public schemas. + let normalized_input: map = { call: call, config: ctx_map(dispatch_input, "config") }; + let canonical_input: string = json::encode(normalized_input); + let tool_metadata: map = ctx_map(facts, "metadata"); + let canonical_arguments: string = encoded_arguments; + let digest: string = encoded_arguments; + let tool_name: string = ctx_string(tool_metadata, "name", ctx_string(call, "name", "")); + let call_id: string = ctx_string(tool_metadata, "call_id", ctx_string(call, "id", "")); + let metadata: map = ctx_map(context, "metadata"); + let invoked: map = agent_runtime::invoke_pinned_tool({ + tool_name: tool_name, + call_id: call_id, + canonical_arguments: canonical_arguments, + canonical_input: canonical_input, + run_id: ctx_string(context, "run_id", ""), + registry_identity: ctx_string(metadata, "registry_identity", ""), + argument_digest: digest, + risk_class: ctx_string(tool_metadata, "risk_class", ""), + summary: ctx_string(tool_metadata, "summary", tool_name), + untrusted_graph_id: "" + }); + let canonical_result: string = ctx_string(invoked, "canonical_result", ""); + let mut result: map = tools::approval_failure( + dispatch_input, + ctx_string(invoked, "code", "pinned_tool_failed"), + ctx_string(invoked, "message", "pinned tool failed closed; no action was executed") + ); + if canonical_result.length > 0 { + let decoded: map = json::decode(canonical_result); + if decoded.has("ok") { + result = tools::result_envelope(call, decoded); + } + } + result +} + fn dispatch_reviewed(context: map, call: map) -> map { let metadata: map = ctx_map(context, "metadata"); let identity: string = ctx_string(metadata, "registry_identity", ""); @@ -601,7 +647,7 @@ fn dispatch_reviewed(context: map, call: map) -> map { } }; if replayed { - result = tools::dispatch(dispatch_input); + result = execute_pinned_dispatch(context, dispatch_input, facts); } else if mode == "deny" { result = tools::approval_failure( dispatch_input, @@ -612,7 +658,7 @@ fn dispatch_reviewed(context: map, call: map) -> map { if ctx_bool(facts, "review_required", false) { let attested: map = agent_runtime::approval_attest(tool_metadata); if ctx_bool(attested, "ok", false) { - result = tools::dispatch(dispatch_input); + result = execute_pinned_dispatch(context, dispatch_input, facts); } else { result = tools::approval_failure( dispatch_input, @@ -621,7 +667,7 @@ fn dispatch_reviewed(context: map, call: map) -> map { ); } } else { - result = tools::dispatch(dispatch_input); + result = execute_pinned_dispatch(context, dispatch_input, facts); } } else if mode == "ask" && ctx_bool(facts, "review_required", false) { let persisted: map = persist_approval_request(context, tool_metadata, action); @@ -649,7 +695,7 @@ fn dispatch_reviewed(context: map, call: map) -> map { "native approval facts changed; no action was executed" ); } else { - result = tools::dispatch(dispatch_input); + result = execute_pinned_dispatch(context, dispatch_input, facts); } } } diff --git a/rss/storage/admission.rss b/rss/storage/admission.rss index b8dd854..31d5526 100644 --- a/rss/storage/admission.rss +++ b/rss/storage/admission.rss @@ -40,6 +40,7 @@ fn sqlite_query_map(result: SqliteQueryResult) -> map { use self::existence as existence; use self::messages as messages; +use self::plugins as plugins; struct AdmissionReplayInput { idempotency_scope: string, @@ -71,11 +72,70 @@ struct AdmissionCreateInput { workspace_identity_json: string, event_id: string, now_ms: int, - expires_at_ms: int + expires_at_ms: int, + tool_graph_id: string, + tool_graph_generation: int, + tool_ref_id: string, + tool_pin_now_ms: int +} + +fn admission_owner_pin_sql() -> string { + "SELECT COALESCE(json_extract(idem.response_json, '$.tool_graph_id'), ''), COALESCE(json_extract(idem.response_json, '$.tool_ref_id'), ''), COALESCE(CAST(json_extract(idem.response_json, '$.tool_generation') AS INTEGER), 0), idem.resource_id, COALESCE((SELECT session_id FROM runs WHERE id = idem.resource_id), ''), COALESCE(json_extract(idem.response_json, '$.registry_identity'), ''), COALESCE(json_extract(idem.response_json, '$.workspace'), ''), COALESCE(json_extract(idem.response_json, '$.profile'), '') FROM idempotency_records idem WHERE idem.scope = ? AND idem.key = ? AND idem.resource_id = ? AND EXISTS (SELECT 1 FROM plugin_graph_refs refs WHERE refs.graph_id = json_extract(idem.response_json, '$.tool_graph_id') AND refs.owner_kind = 'run' AND refs.owner_id = idem.resource_id AND refs.ref_id = json_extract(idem.response_json, '$.tool_ref_id'))" +} + +fn admission_owner_pin(queried: SqliteQueryResult) -> map { + let mut graph_id: string = ""; + let mut ref_id: string = ""; + let mut generation: int = 0; + let mut run_id: string = ""; + let mut session_id: string = ""; + let mut registry_identity: string = ""; + let mut workspace: string = ""; + let mut profile: string = ""; + let mut pinned: bool = false; + let rows = queried.rows.copy(); + if rows.length == 1 { + let row: SqliteRow = rows[0].copy(); + let cells: array = row.cells.copy(); + graph_id = cells[0].text_value; + ref_id = cells[1].text_value; + generation = cells[2].int_value; + run_id = cells[3].text_value; + session_id = cells[4].text_value; + registry_identity = cells[5].text_value; + workspace = cells[6].text_value; + profile = cells[7].text_value; + pinned = graph_id.copy() != "" && ref_id.copy() != ""; + } + { + graph_id: graph_id, + ref_id: ref_id, + generation: generation, + run_id: run_id, + session_id: session_id, + registry_identity: registry_identity, + workspace: workspace, + profile: profile, + pinned: pinned + } } pub fn storage_admission_create(db_id: resource, payload_json: string) -> map { - let input: AdmissionCreateInput = json::decode::(payload_json); + let raw_payload: map = json::decode(payload_json); + let mut failpoint: string = ""; + let mut registry_identity: string = ""; + let mut workspace: string = ""; + if raw_payload.has("failpoint") { + failpoint = raw_payload["failpoint"].copy(); + } + if raw_payload.has("tool_registry_identity") { + registry_identity = raw_payload["tool_registry_identity"].copy(); + } + if raw_payload.has("tool_workspace") { + workspace = raw_payload["tool_workspace"].copy(); + } + let normalized: string = admission_with_pin_fields(payload_json); + let input: AdmissionCreateInput = json::decode::(normalized); let mut failed: bool = false; let mut failure_code: string = ""; let mut failure_message: string = ""; @@ -104,6 +164,15 @@ pub fn storage_admission_create(db_id: resource, payload_json let mut replay: bool = false; let mut conflict: bool = false; let mut existing_run_id: string = ""; + let mut pinned: bool = false; + let mut pin_graph_id: string = ""; + let mut pin_ref_id: string = ""; + let mut pin_generation: int = 0; + let mut pin_run_id: string = ""; + let mut pin_session_id: string = ""; + let mut pin_registry: string = ""; + let mut pin_workspace: string = ""; + let mut pin_profile: string = ""; if failed == false { if input.idempotency_key.copy() != "" { // Replay identity uses the original optional selector, never the current session default. @@ -164,10 +233,13 @@ pub fn storage_admission_create(db_id: resource, payload_json limits: sql_limits(1000, 4194304) }; } - if input.idempotency_key.copy() != "" { + // Unkeyed admissions still need an exact original receipt for restart. + let receipt_scope: string = if input.idempotency_key.copy() == "" => { "native.tool-pin" } else => { input.idempotency_scope.copy() }; + let receipt_key: string = if input.idempotency_key.copy() == "" => { input.run_id.copy() } else => { input.idempotency_key.copy() }; + if input.idempotency_key.copy() != "" || input.tool_graph_id.copy() != "" { statements[statements.length] = { - sql: "INSERT OR IGNORE INTO idempotency_records (scope, key, request_hash, resource_type, resource_id, state, response_json, created_at_ms, expires_at_ms) SELECT ?, ?, ?, 'run', ?, 'completed', CASE WHEN ? = '' THEN '{\"run_id\":\"' || ? || '\",\"status\":\"running\"}' ELSE json_object('run_id', ?, 'status', 'running', 'workspace_identity', json(?)) END, ?, ? WHERE ? <> '' AND EXISTS (SELECT 1 FROM runs admitted WHERE admitted.id = ? AND admitted.session_id = ?)", - params: [sql_text(input.idempotency_scope.copy()), sql_text(input.idempotency_key.copy()), sql_text(input.request_hash.copy()), sql_text(input.run_id.copy()), sql_text(input.workspace_identity_json.copy()), sql_text(input.run_id.copy()), sql_text(input.run_id.copy()), sql_text(input.workspace_identity_json.copy()), sql_int(input.now_ms.copy()), sql_int(input.expires_at_ms.copy()), sql_text(input.idempotency_key.copy()), sql_text(input.run_id.copy()), sql_text(input.session_id.copy())], + sql: "INSERT OR IGNORE INTO idempotency_records (scope, key, request_hash, resource_type, resource_id, state, response_json, created_at_ms, expires_at_ms) SELECT ?, ?, ?, 'run', ?, 'completed', json_set(CASE WHEN ? = '' THEN '{\"run_id\":\"' || ? || '\",\"status\":\"running\"}' ELSE json_object('run_id', ?, 'status', 'running', 'workspace_identity', json(?)) END, '$.tool_graph_id', ?, '$.tool_ref_id', ?, '$.tool_generation', ?, '$.registry_identity', ?, '$.workspace', ?, '$.profile', ?), ?, ? WHERE ? <> '' AND EXISTS (SELECT 1 FROM runs admitted WHERE admitted.id = ? AND admitted.session_id = ?)", + params: [sql_text(receipt_scope.copy()), sql_text(receipt_key.copy()), sql_text(input.request_hash.copy()), sql_text(input.run_id.copy()), sql_text(input.workspace_identity_json.copy()), sql_text(input.run_id.copy()), sql_text(input.run_id.copy()), sql_text(input.workspace_identity_json.copy()), sql_text(input.tool_graph_id.copy()), sql_text(input.tool_ref_id.copy()), sql_int(input.tool_graph_generation.copy()), sql_text(registry_identity.copy()), sql_text(workspace.copy()), sql_text(input.profile.copy()), sql_int(input.now_ms.copy()), sql_int(input.expires_at_ms.copy()), sql_text(receipt_key.copy()), sql_text(input.run_id.copy()), sql_text(input.session_id.copy())], query: false, limits: sql_limits(1000, 4194304) }; @@ -184,10 +256,31 @@ pub fn storage_admission_create(db_id: resource, payload_json query: false, limits: sql_limits(1000, 4194304) }; - sqlite::transaction(&db_id, statements); + if input.tool_graph_id.copy() != "" { + let pin_statements: array = plugins::storage_admission_pin_statements(input.tool_graph_id.copy(), input.tool_graph_generation.copy(), input.tool_ref_id.copy(), input.run_id.copy(), input.session_id.copy(), input.tool_pin_now_ms.copy()); + let mut pin_index: int = 0; + while pin_index < pin_statements.length { + statements[statements.length] = pin_statements[pin_index].copy(); + pin_index = pin_index + 1; + } + pinned = true; + } + let transaction_result: array = sqlite::transaction(&db_id, statements); + if pinned == true && transaction_result.length > 0 { + let last_index: int = transaction_result.length - 1; + let receipt: SqliteTransactionResult = transaction_result[last_index].copy(); + pin_graph_id = plugins::storage_pin_text(receipt.copy(), 0); + pin_ref_id = plugins::storage_pin_text(receipt.copy(), 1); + pin_generation = plugins::storage_pin_int(receipt.copy(), 2); + if pin_graph_id.copy() != input.tool_graph_id.copy() || pin_ref_id.copy() != input.tool_ref_id.copy() { + failed = true; + failure_code = "plugin_activation_conflict"; + failure_message = "tool graph pin did not commit"; + } + } } } - let query_run_id: string = if replay => { existing_run_id } else => { input.run_id.copy() }; + let query_run_id: string = if replay => { existing_run_id.copy() } else => { input.run_id.copy() }; let run_result = sqlite::query(&db_id, "SELECT id, session_id, parent_run_id, status, input_json, provider, model, script_hash, idempotency_scope, idempotency_key, turn_count, input_tokens, output_tokens, error_code, error_message, recovery_reason, created_at_ms, started_at_ms, finished_at_ms, updated_at_ms FROM runs WHERE id = ? LIMIT 1", [sql_text(query_run_id.copy())], sql_limits(1, 65536)); let run_result_copy: map = sqlite_query_map(run_result); let run_rows = run_result.rows; @@ -210,6 +303,33 @@ pub fn storage_admission_create(db_id: resource, payload_json } else => { sqlite::query(&db_id, "SELECT scope, key, request_hash, resource_type, resource_id, state, response_json, created_at_ms, expires_at_ms, completed_at_ms FROM idempotency_records WHERE scope = ? AND key = ? LIMIT 1", [sql_text(input.idempotency_scope.copy()), sql_text(input.idempotency_key.copy())], sql_limits(1, 8192)) }; + if replay == true { + let recovered: map = admission_owner_pin(sqlite::query(&db_id, admission_owner_pin_sql(), [sql_text(input.idempotency_scope.copy()), sql_text(input.idempotency_key.copy()), sql_text(existing_run_id.copy())], sql_limits(1, 8192))); + let recovered_graph_id: string = recovered["graph_id"]; + let recovered_ref_id: string = recovered["ref_id"]; + let recovered_generation: int = recovered["generation"]; + let recovered_run_id: string = recovered["run_id"]; + let recovered_session_id: string = recovered["session_id"]; + let recovered_registry: string = recovered["registry_identity"]; + let recovered_workspace: string = recovered["workspace"]; + let recovered_profile: string = recovered["profile"]; + let recovered_pinned: bool = recovered["pinned"]; + pin_graph_id = recovered_graph_id.copy(); + pin_ref_id = recovered_ref_id.copy(); + pin_generation = recovered_generation.copy(); + pin_run_id = recovered_run_id.copy(); + pin_session_id = recovered_session_id.copy(); + pin_registry = recovered_registry.copy(); + pin_workspace = recovered_workspace.copy(); + pin_profile = recovered_profile.copy(); + pinned = recovered_pinned; + } else { + pin_run_id = input.run_id.copy(); + pin_session_id = input.session_id.copy(); + pin_registry = registry_identity.copy(); + pin_workspace = workspace.copy(); + pin_profile = input.profile.copy(); + } let mut result = { ok: true, code: "ok", @@ -220,7 +340,18 @@ pub fn storage_admission_create(db_id: resource, payload_json run: run_result_copy, session: session_result, message: message_result, - idempotency: idempotency_result + idempotency: idempotency_result, + pin: { + graph_id: pin_graph_id, + ref_id: pin_ref_id, + generation: pin_generation, + run_id: pin_run_id, + session_id: pin_session_id, + registry_identity: pin_registry, + workspace: pin_workspace, + profile: pin_profile, + pinned: pinned + } } }; if conflict == true { @@ -254,6 +385,16 @@ pub fn storage_admission_create(db_id: resource, payload_json }; } } + let published: bool = result["ok"]; + if published == true && failpoint == "after_commit_before_publish" { + let published_result: map = result["result"]; + result = { + ok: false, + code: "failpoint_after_commit_before_publish", + message: "durable commit succeeded; live publish skipped", + result: published_result + }; + } result } @@ -297,11 +438,36 @@ pub fn storage_admission_replay(db_id: resource, payload_json result: { replayed: false, conflict: false, run: run_result_copy } }; } else { + let recovered: map = admission_owner_pin(sqlite::query(&db_id, admission_owner_pin_sql(), [sql_text(input.idempotency_scope.copy()), sql_text(input.idempotency_key.copy()), sql_text(existing_run_id.copy())], sql_limits(1, 8192))); + let recovered_graph_id: string = recovered["graph_id"]; + let recovered_ref_id: string = recovered["ref_id"]; + let recovered_generation: int = recovered["generation"]; + let recovered_run_id: string = recovered["run_id"]; + let recovered_session_id: string = recovered["session_id"]; + let recovered_registry: string = recovered["registry_identity"]; + let recovered_workspace: string = recovered["workspace"]; + let recovered_profile: string = recovered["profile"]; + let recovered_pinned: bool = recovered["pinned"]; result = { ok: true, code: "ok", message: "", - result: { replayed: true, conflict: false, run: run_result_copy } + result: { + replayed: true, + conflict: false, + run: run_result_copy, + pin: { + graph_id: recovered_graph_id, + ref_id: recovered_ref_id, + generation: recovered_generation, + run_id: recovered_run_id, + session_id: recovered_session_id, + registry_identity: recovered_registry, + workspace: recovered_workspace, + profile: recovered_profile, + pinned: recovered_pinned + } + } }; } } @@ -328,3 +494,22 @@ fn storage_admission_user_content(input_json: string) -> string { sqlite::close(db_id); content } + +fn admission_with_pin_fields(payload_json: string) -> string { + let db_id = sqlite::open({ + path: ":memory:", + mode: "memory", + root: null, + limits: sql_limits(1, 1048576) + }); + let extracted = sqlite::query(&db_id, "SELECT json_set(json_set(json_set(json_set(?, '$.tool_graph_id', COALESCE(json_extract(?, '$.tool_graph_id'), '')), '$.tool_graph_generation', COALESCE(json_extract(?, '$.tool_graph_generation'), 0)), '$.tool_ref_id', COALESCE(json_extract(?, '$.tool_ref_id'), '')), '$.tool_pin_now_ms', COALESCE(json_extract(?, '$.tool_pin_now_ms'), 0))", [sql_text(payload_json.copy()), sql_text(payload_json.copy()), sql_text(payload_json.copy()), sql_text(payload_json.copy()), sql_text(payload_json.copy())], sql_limits(1, 1048576)); + let rows = extracted.rows; + let mut normalized: string = payload_json.copy(); + if rows.length > 0 { + let row: SqliteRow = rows[0].copy(); + let cells: array = row.cells.copy(); + normalized = cells[0].text_value; + } + sqlite::close(db_id); + normalized +} diff --git a/rss/storage/main.rss b/rss/storage/main.rss index 3318dd2..acd2e08 100644 --- a/rss/storage/main.rss +++ b/rss/storage/main.rss @@ -33,6 +33,7 @@ use self::events as events; use self::approvals as approvals; use self::compactions as compactions; use self::jobs as jobs; +use self::plugins as plugins; use self::admission as admission; use self::load as load; @@ -516,7 +517,7 @@ fn storage_dispatch(db_id: resource, command: StorageCommand) result = storage_unwrap(request_id, op, admission::storage_admission_replay(db_id, command.payload_json)); } else if command.op == "session.delete" { let delete_results: array = sessions::storage_session_delete(db_id, command.payload_json); - if storage_nth_rows_affected(delete_results, 1) == 0 { + if storage_nth_rows_affected(delete_results, 2) == 0 { result = storage_error(request_id, op, "session_not_found", "session does not exist"); } else { result = storage_array_ok(request_id, op, delete_results); @@ -643,6 +644,30 @@ fn storage_dispatch(db_id: resource, command: StorageCommand) result = storage_unwrap(request_id, op, jobs::storage_job_get(db_id, command.payload_json, command.max_rows, command.max_bytes)); } else if command.op == "job.list" { result = storage_unwrap(request_id, op, jobs::storage_job_list(db_id, command.max_rows, command.max_bytes)); + } else if command.op == "plugin.lock_put" { + result = storage_unwrap(request_id, op, plugins::storage_plugin_lock_put(db_id, command.payload_json)); + } else if command.op == "plugin.lock_get" { + result = storage_unwrap(request_id, op, plugins::storage_plugin_lock_get(db_id, command.payload_json)); + } else if command.op == "plugin.lock_list" { + result = storage_unwrap(request_id, op, plugins::storage_plugin_lock_list(db_id)); + } else if command.op == "plugin.ref_list" { + result = storage_unwrap(request_id, op, plugins::storage_plugin_ref_list(db_id, command.payload_json)); + } else if command.op == "plugin.activate" { + result = storage_unwrap(request_id, op, plugins::storage_plugin_activate(db_id, command.payload_json)); + } else if command.op == "plugin.activation_get" { + result = storage_unwrap(request_id, op, plugins::storage_plugin_activation_get(db_id)); + } else if command.op == "plugin.activation_history" { + result = storage_unwrap(request_id, op, plugins::storage_plugin_activation_history(db_id)); + } else if command.op == "plugin.retain" { + result = storage_unwrap(request_id, op, plugins::storage_plugin_retain(db_id, command.payload_json)); + } else if command.op == "plugin.retention_get" { + result = storage_unwrap(request_id, op, plugins::storage_plugin_retention_get(db_id, command.payload_json)); + } else if command.op == "plugin.release" { + result = storage_unwrap(request_id, op, plugins::storage_plugin_release(db_id, command.payload_json)); + } else if command.op == "plugin.pin_for_owner" { + result = storage_unwrap(request_id, op, plugins::storage_plugin_pin_for_owner(db_id, command.payload_json)); + } else if command.op == "plugin.collect" { + result = storage_unwrap(request_id, op, plugins::storage_plugin_collect(db_id, command.payload_json)); } result } diff --git a/rss/storage/plugins.rss b/rss/storage/plugins.rss new file mode 100644 index 0000000..1dee398 --- /dev/null +++ b/rss/storage/plugins.rss @@ -0,0 +1,450 @@ +// Privileged plugin-control commands. The closed op set in storage_dispatch is +// the only writer. Guest approval operations are not plugin-control operations, +// and this module does not accept caller SQL. +use json; +use sqlite; + +fn sql_text(value: string) -> SqliteValue { + { kind: "text", int_value: null, float_value: null, text_value: value, blob_value: null } +} + +fn sql_int(value: int) -> SqliteValue { + { kind: "int", int_value: value, float_value: null, text_value: null, blob_value: null } +} + +fn plugin_limits() -> SqliteLimits { + { + max_connections: 16, + max_statements: 128, + max_rows: 512, + max_columns: 16, + max_result_bytes: 1048576, + max_statement_bytes: 1048576, + max_parameters: 128, + max_parameter_bytes: 1048576, + max_pending_operations: 32, + max_transaction_ms: 5000, + busy_timeout_ms: 5000 + } +} + +fn query_map(result: SqliteQueryResult) -> map { + { + columns: result.columns, + rows: result.rows, + truncated: result.truncated + } +} + +fn ok_query(result: SqliteQueryResult) -> map { + { ok: true, code: "ok", message: "", result: query_map(result) } +} + +fn ok_value(value: map) -> map { + { ok: true, code: "ok", message: "", result: value } +} + +fn plugin_control_error(code: string, message: string) -> map { + { ok: false, code: code, message: message, result: {} } +} + +fn plugin_tx_rows(entry: SqliteTransactionResult) -> int { + let mut affected: int = 0; + if entry.kind == "execute" { + let exec: SqliteExecuteResult = entry.execute; + affected = exec.rows_affected; + } + affected +} + +fn plugin_query_int(entry: SqliteTransactionResult, column: int) -> int { + let mut value: int = -1; + if entry.kind == "query" { + let queried: SqliteQueryResult = entry.query; + let rows = queried.rows.copy(); + if rows.length > 0 { + let row: SqliteRow = rows[0].copy(); + let cells = row.cells.copy(); + if cells.length > column { + let cell: SqliteValue = cells[column].copy(); + if cell.kind == "int" && cell.int_value.copy() != null { + value = cell.int_value; + } + } + } + } + value +} + +fn plugin_query_text(entry: SqliteTransactionResult, column: int) -> string { + let mut value: string = ""; + if entry.kind == "query" { + let queried: SqliteQueryResult = entry.query; + let rows = queried.rows.copy(); + if rows.length > 0 { + let row: SqliteRow = rows[0].copy(); + let cells = row.cells.copy(); + if cells.length > column { + let cell: SqliteValue = cells[column].copy(); + if cell.kind == "text" && cell.text_value.copy() != null { + value = cell.text_value; + } + } + } + } + value +} + +struct PluginLockPutInput { + graph_id: string, + lock_json: string, + packages_json: string, + package_count: int, + now_ms: int, + failpoint: string, +} + +struct PluginGraphInput { + graph_id: string, +} + +struct PluginActivateInput { + graph_id: string, + expected_generation: int, + packages_json: string, + package_count: int, + now_ms: int, + failpoint: string, +} + +struct PluginRetainInput { + graph_id: string, + owner_kind: string, + owner_id: string, + ref_id: string, + now_ms: int, + failpoint: string, +} + +struct PluginOwnerInput { + owner_id: string, +} + +struct PluginFailpointInput { + failpoint: string, +} + +pub fn storage_plugin_lock_put(db_id: resource, payload_json: string) -> map { + let input: PluginLockPutInput = json::decode::(payload_json); + let statements: array = [ + { + sql: "INSERT INTO plugin_package_refs (graph_id, package_id, version, kind, artifact_id) SELECT ?, json_extract(value, '$.package_id'), json_extract(value, '$.version'), json_extract(value, '$.kind'), json_extract(value, '$.artifact_id') FROM json_each(?)", + params: [sql_text(input.graph_id.copy()), sql_text(input.packages_json.copy())], + query: false, + limits: plugin_limits(), + }, + { + sql: "INSERT INTO plugin_graph_locks (graph_id, lock_json, stored_at_ms) SELECT ?, ?, ? WHERE (SELECT COUNT(*) FROM plugin_package_refs WHERE graph_id = ?) = ? AND NOT EXISTS (SELECT 1 FROM plugin_graph_locks WHERE graph_id = ?)", + params: [sql_text(input.graph_id.copy()), sql_text(input.lock_json.copy()), sql_int(input.now_ms.copy()), sql_text(input.graph_id.copy()), sql_int(input.package_count.copy()), sql_text(input.graph_id.copy())], + query: false, + limits: plugin_limits(), + }, + { + sql: "INSERT INTO plugin_control_guard (id) SELECT 2 WHERE ? = 'after_partial_write' OR (SELECT changes()) = 0", + params: [sql_text(input.failpoint.copy())], + query: false, + limits: plugin_limits(), + } + ]; + let transaction_result = sqlite::transaction(&db_id, statements); + let stored: map = { graph_id: input.graph_id.copy(), idempotent: false }; + let _used = transaction_result; + ok_value(stored) +} + +pub fn storage_plugin_lock_get(db_id: resource, payload_json: string) -> map { + let input: PluginGraphInput = json::decode::(payload_json); + ok_query(sqlite::query(&db_id, "SELECT lock_json FROM plugin_graph_locks WHERE graph_id = ? LIMIT 1", [sql_text(input.graph_id.copy())], plugin_limits())) +} + +pub fn storage_plugin_lock_list(db_id: resource) -> map { + ok_query(sqlite::query(&db_id, "SELECT graph_id FROM plugin_graph_locks ORDER BY graph_id", [], plugin_limits())) +} + +pub fn storage_plugin_ref_list(db_id: resource, payload_json: string) -> map { + let input: PluginGraphInput = json::decode::(payload_json); + ok_query(sqlite::query(&db_id, "SELECT package_id, version, kind, artifact_id FROM plugin_package_refs WHERE graph_id = ? ORDER BY rowid", [sql_text(input.graph_id.copy())], plugin_limits())) +} + +pub fn storage_plugin_activate(db_id: resource, payload_json: string) -> map { + let input: PluginActivateInput = json::decode::(payload_json); + let next_generation: int = input.expected_generation.copy() + 1; + let statements: array = [ + { + sql: "INSERT INTO plugin_activation_history (graph_id, generation, previous_graph_id, activated_at_ms) SELECT ?, ?, COALESCE((SELECT graph_id FROM plugin_activation), ''), ? WHERE EXISTS (SELECT 1 FROM plugin_graph_locks WHERE graph_id = ?) AND (SELECT COUNT(*) FROM plugin_package_refs WHERE graph_id = ?) = ? AND (SELECT COUNT(*) FROM json_each(?) AS provided WHERE EXISTS (SELECT 1 FROM plugin_package_refs AS stored WHERE stored.graph_id = ? AND stored.package_id = json_extract(provided.value, '$.package_id') AND stored.version = json_extract(provided.value, '$.version') AND stored.kind = json_extract(provided.value, '$.kind') AND stored.artifact_id = json_extract(provided.value, '$.artifact_id'))) = ? AND (((SELECT COUNT(*) FROM plugin_activation) = 0 AND ? = 0) OR (SELECT generation FROM plugin_activation) = ?)", + params: [sql_text(input.graph_id.copy()), sql_int(next_generation.copy()), sql_int(input.now_ms.copy()), sql_text(input.graph_id.copy()), sql_text(input.graph_id.copy()), sql_int(input.package_count.copy()), sql_text(input.packages_json.copy()), sql_text(input.graph_id.copy()), sql_int(input.package_count.copy()), sql_int(input.expected_generation.copy()), sql_int(input.expected_generation.copy())], + query: false, + limits: plugin_limits(), + }, + { + sql: "INSERT INTO plugin_activation (id, graph_id, generation, updated_at_ms) SELECT 1, ?, ?, ? WHERE (SELECT changes()) = 1 ON CONFLICT (id) DO UPDATE SET graph_id = excluded.graph_id, generation = excluded.generation, updated_at_ms = excluded.updated_at_ms", + params: [sql_text(input.graph_id.copy()), sql_int(next_generation.copy()), sql_int(input.now_ms.copy())], + query: false, + limits: plugin_limits(), + }, + { + sql: "INSERT INTO plugin_control_guard (id) SELECT 2 WHERE ? = 'after_partial_write'", + params: [sql_text(input.failpoint.copy())], + query: false, + limits: plugin_limits(), + }, + { + sql: "SELECT CASE WHEN EXISTS (SELECT 1 FROM plugin_graph_locks WHERE graph_id = ?) THEN 1 ELSE 0 END, COALESCE((SELECT generation FROM plugin_activation), -1), CASE WHEN (SELECT COUNT(*) FROM plugin_package_refs WHERE graph_id = ?) = ? AND (SELECT COUNT(*) FROM json_each(?) AS provided WHERE EXISTS (SELECT 1 FROM plugin_package_refs AS stored WHERE stored.graph_id = ? AND stored.package_id = json_extract(provided.value, '$.package_id') AND stored.version = json_extract(provided.value, '$.version') AND stored.kind = json_extract(provided.value, '$.kind') AND stored.artifact_id = json_extract(provided.value, '$.artifact_id'))) = ? THEN 1 ELSE 0 END", + params: [sql_text(input.graph_id.copy()), sql_text(input.graph_id.copy()), sql_int(input.package_count.copy()), sql_text(input.packages_json.copy()), sql_text(input.graph_id.copy()), sql_int(input.package_count.copy())], + query: true, + limits: plugin_limits(), + } + ]; + let transaction_result: array = sqlite::transaction(&db_id, statements); + let mut result: map = plugin_control_error("plugin_control_unavailable", "plugin control storage is unavailable"); + if transaction_result.length >= 4 { + let history_rows: int = plugin_tx_rows(transaction_result[0]); + let pointer_rows: int = plugin_tx_rows(transaction_result[1]); + if history_rows == 1 && pointer_rows == 1 { + let stored: map = { + graph_id: input.graph_id.copy(), + generation: next_generation.copy(), + updated_at_ms: input.now_ms.copy() + }; + result = ok_value(stored); + } else if history_rows == 0 && pointer_rows == 0 { + let classification: SqliteTransactionResult = transaction_result[3].copy(); + let graph_present: int = plugin_query_int(classification.copy(), 0); + let refs_ok: int = plugin_query_int(classification, 2); + if graph_present == 0 { + result = plugin_control_error("plugin_graph_missing", "plugin graph lock is not stored"); + } else if refs_ok == 0 { + result = plugin_control_error("plugin_package_ref_missing", "plugin package reference does not match the stored lock"); + } else { + result = plugin_control_error("plugin_activation_conflict", "plugin activation generation does not match"); + } + } + } + result +} + +pub fn storage_plugin_activation_get(db_id: resource) -> map { + ok_query(sqlite::query(&db_id, "SELECT graph_id, generation, updated_at_ms FROM plugin_activation LIMIT 1", [], plugin_limits())) +} + +pub fn storage_plugin_activation_history(db_id: resource) -> map { + ok_query(sqlite::query(&db_id, "SELECT graph_id, generation, previous_graph_id, activated_at_ms FROM plugin_activation_history ORDER BY generation ASC", [], plugin_limits())) +} + +pub fn storage_plugin_retention_get(db_id: resource, payload_json: string) -> map { + let input: PluginGraphInput = json::decode::(payload_json); + ok_query(sqlite::query(&db_id, "SELECT graph_id, owner_kind, owner_id, ref_id FROM plugin_graph_refs WHERE graph_id = ? ORDER BY owner_kind, owner_id, ref_id", [sql_text(input.graph_id.copy())], plugin_limits())) +} + +pub fn storage_plugin_retain(db_id: resource, payload_json: string) -> map { + let input: PluginRetainInput = json::decode::(payload_json); + let statements: array = [ + { + sql: "INSERT INTO plugin_graph_refs (graph_id, owner_kind, owner_id, ref_id, retained_at_ms) SELECT ?, ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM plugin_graph_locks WHERE graph_id = ?) AND ? IN ('run', 'checkpoint') AND NOT EXISTS (SELECT 1 FROM plugin_graph_refs WHERE graph_id = ? AND owner_kind = ? AND owner_id = ? AND ref_id <> ?) ON CONFLICT (graph_id, owner_kind, owner_id) DO NOTHING", + params: [sql_text(input.graph_id.copy()), sql_text(input.owner_kind.copy()), sql_text(input.owner_id.copy()), sql_text(input.ref_id.copy()), sql_int(input.now_ms.copy()), sql_text(input.graph_id.copy()), sql_text(input.owner_kind.copy()), sql_text(input.graph_id.copy()), sql_text(input.owner_kind.copy()), sql_text(input.owner_id.copy()), sql_text(input.ref_id.copy())], + query: false, + limits: plugin_limits(), + }, + { + sql: "INSERT INTO plugin_control_guard (id) SELECT 2 WHERE ? = 'after_partial_write'", + params: [sql_text(input.failpoint.copy())], + query: false, + limits: plugin_limits(), + }, + { + sql: "SELECT CASE WHEN EXISTS (SELECT 1 FROM plugin_graph_locks WHERE graph_id = ?) THEN 1 ELSE 0 END, COALESCE((SELECT ref_id FROM plugin_graph_refs WHERE graph_id = ? AND owner_kind = ? AND owner_id = ?), '')", + params: [sql_text(input.graph_id.copy()), sql_text(input.graph_id.copy()), sql_text(input.owner_kind.copy()), sql_text(input.owner_id.copy())], + query: true, + limits: plugin_limits(), + } + ]; + let transaction_result: array = sqlite::transaction(&db_id, statements); + let mut result: map = plugin_control_error("plugin_control_unavailable", "plugin control storage is unavailable"); + if transaction_result.length >= 3 { + let inserted: int = plugin_tx_rows(transaction_result[0]); + let classification: SqliteTransactionResult = transaction_result[2].copy(); + let graph_present: int = plugin_query_int(classification.copy(), 0); + let live_ref: string = plugin_query_text(classification, 1); + if inserted == 1 && live_ref.copy() == input.ref_id.copy() { + let stored: map = { + idempotent: false, + graph_id: input.graph_id.copy(), + owner_kind: input.owner_kind.copy(), + owner_id: input.owner_id.copy(), + ref_id: input.ref_id.copy() + }; + result = ok_value(stored); + } else if inserted == 0 && graph_present == 1 && live_ref.copy() == input.ref_id.copy() { + let stored: map = { + idempotent: true, + graph_id: input.graph_id.copy(), + owner_kind: input.owner_kind.copy(), + owner_id: input.owner_id.copy(), + ref_id: input.ref_id.copy() + }; + result = ok_value(stored); + } else if graph_present == 0 { + result = plugin_control_error("plugin_graph_missing", "plugin graph lock is not stored"); + } else { + result = plugin_control_error("plugin_ref_invalid", "plugin graph reference is invalid"); + } + } + result +} + +pub fn storage_plugin_release(db_id: resource, payload_json: string) -> map { + let input: PluginRetainInput = json::decode::(payload_json); + let statements: array = [ + { + sql: "DELETE FROM plugin_graph_refs WHERE graph_id = ? AND owner_kind = ? AND owner_id = ? AND ref_id = ?", + params: [sql_text(input.graph_id.copy()), sql_text(input.owner_kind.copy()), sql_text(input.owner_id.copy()), sql_text(input.ref_id.copy())], + query: false, + limits: plugin_limits(), + }, + { + sql: "INSERT INTO plugin_control_guard (id) SELECT 2 WHERE ? = 'after_partial_write'", + params: [sql_text(input.failpoint.copy())], + query: false, + limits: plugin_limits(), + }, + { + sql: "SELECT COALESCE((SELECT ref_id FROM plugin_graph_refs WHERE graph_id = ? AND owner_kind = ? AND owner_id = ?), '')", + params: [sql_text(input.graph_id.copy()), sql_text(input.owner_kind.copy()), sql_text(input.owner_id.copy())], + query: true, + limits: plugin_limits(), + } + ]; + let transaction_result: array = sqlite::transaction(&db_id, statements); + let mut result: map = plugin_control_error("plugin_control_unavailable", "plugin control storage is unavailable"); + if transaction_result.length >= 3 { + let deleted: int = plugin_tx_rows(transaction_result[0]); + let live_ref: string = plugin_query_text(transaction_result[2], 0); + if deleted == 1 && live_ref.copy() == "" { + result = ok_value({ graph_id: input.graph_id.copy(), ref_id: input.ref_id.copy(), deleted: deleted.copy() }); + } else if deleted == 0 && live_ref.copy() == "" { + result = ok_value({ graph_id: input.graph_id.copy(), ref_id: input.ref_id.copy(), deleted: deleted.copy() }); + } else if deleted == 0 && live_ref.copy() != "" { + result = plugin_control_error("plugin_ref_invalid", "plugin graph reference is invalid"); + } + } + result +} + +pub fn storage_plugin_pin_for_owner(db_id: resource, payload_json: string) -> map { + let input: PluginOwnerInput = json::decode::(payload_json); + ok_value(storage_pin_by_owner(db_id, input.owner_id.copy())) +} + +pub fn storage_plugin_collect(db_id: resource, payload_json: string) -> map { + let input: PluginFailpointInput = json::decode::(payload_json); + let before: SqliteQueryResult = sqlite::query(&db_id, "SELECT graph_id FROM plugin_graph_locks WHERE graph_id NOT IN (SELECT graph_id FROM plugin_activation) AND graph_id NOT IN (SELECT graph_id FROM plugin_graph_refs) ORDER BY graph_id", [], plugin_limits()); + let statements: array = [ + { + sql: "DELETE FROM plugin_package_refs WHERE graph_id IN (SELECT graph_id FROM plugin_graph_locks WHERE graph_id NOT IN (SELECT graph_id FROM plugin_activation) AND graph_id NOT IN (SELECT graph_id FROM plugin_graph_refs))", + params: [], + query: false, + limits: plugin_limits(), + }, + { + sql: "DELETE FROM plugin_graph_locks WHERE graph_id NOT IN (SELECT graph_id FROM plugin_activation) AND graph_id NOT IN (SELECT graph_id FROM plugin_graph_refs)", + params: [], + query: false, + limits: plugin_limits(), + }, + { + sql: "INSERT INTO plugin_control_guard (id) SELECT 2 WHERE ? = 'after_partial_write'", + params: [sql_text(input.failpoint.copy())], + query: false, + limits: plugin_limits(), + } + ]; + let transaction_result = sqlite::transaction(&db_id, statements); + let listed = query_map(before); + let _used = transaction_result; + ok_value(listed) +} + +pub fn storage_admission_pin_statements(graph_id: string, generation: int, ref_id: string, run_id: string, session_id: string, now_ms: int) -> array { + [ + { + sql: "INSERT INTO plugin_control_guard (id) SELECT 2 WHERE NOT EXISTS (SELECT 1 FROM plugin_activation WHERE graph_id = ? AND generation = ?) OR NOT EXISTS (SELECT 1 FROM plugin_graph_locks WHERE graph_id = ?)", + params: [sql_text(graph_id.copy()), sql_int(generation.copy()), sql_text(graph_id.copy())], + query: false, + limits: plugin_limits(), + }, + { + sql: "INSERT INTO plugin_graph_refs (graph_id, owner_kind, owner_id, ref_id, retained_at_ms) SELECT ?, 'run', ?, ?, ? WHERE EXISTS (SELECT 1 FROM plugin_activation WHERE graph_id = ? AND generation = ?) AND EXISTS (SELECT 1 FROM runs WHERE id = ? AND session_id = ?) AND NOT EXISTS (SELECT 1 FROM plugin_graph_refs existing WHERE existing.owner_kind = 'run' AND existing.owner_id = ? AND (existing.graph_id <> ? OR existing.ref_id <> ?))", + params: [sql_text(graph_id.copy()), sql_text(run_id.copy()), sql_text(ref_id.copy()), sql_int(now_ms.copy()), sql_text(graph_id.copy()), sql_int(generation.copy()), sql_text(run_id.copy()), sql_text(session_id.copy()), sql_text(run_id.copy()), sql_text(graph_id.copy()), sql_text(ref_id.copy())], + query: false, + limits: plugin_limits(), + }, + { + sql: "INSERT INTO plugin_control_guard (id) SELECT 2 WHERE NOT EXISTS (SELECT 1 FROM plugin_graph_refs WHERE owner_kind = 'run' AND owner_id = ? AND graph_id = ? AND ref_id = ?)", + params: [sql_text(run_id.copy()), sql_text(graph_id.copy()), sql_text(ref_id.copy())], + query: false, + limits: plugin_limits(), + }, + { + sql: "SELECT refs.graph_id, refs.ref_id, activation.generation FROM plugin_graph_refs refs JOIN plugin_activation activation ON activation.graph_id = refs.graph_id AND activation.generation = ? WHERE refs.owner_kind = 'run' AND refs.owner_id = ? AND refs.ref_id = ?", + params: [sql_int(generation.copy()), sql_text(run_id.copy()), sql_text(ref_id.copy())], + query: true, + limits: plugin_limits(), + } + ] +} + +pub fn storage_pin_text(entry: SqliteTransactionResult, column: int) -> string { + plugin_query_text(entry, column) +} + +pub fn storage_pin_int(entry: SqliteTransactionResult, column: int) -> int { + plugin_query_int(entry, column) +} + +pub fn storage_pin_by_owner(db_id: resource, owner_id: string) -> map { + let queried: SqliteQueryResult = sqlite::query(&db_id, "SELECT COALESCE(json_extract(idem.response_json, '$.tool_graph_id'), ''), COALESCE(json_extract(idem.response_json, '$.tool_ref_id'), ''), COALESCE(CAST(json_extract(idem.response_json, '$.tool_generation') AS INTEGER), 0), idem.resource_id, COALESCE((SELECT session_id FROM runs WHERE id = idem.resource_id), ''), COALESCE(json_extract(idem.response_json, '$.registry_identity'), ''), COALESCE(json_extract(idem.response_json, '$.workspace'), ''), COALESCE(json_extract(idem.response_json, '$.profile'), '') FROM idempotency_records idem JOIN runs original ON original.id = idem.resource_id WHERE idem.resource_id = ? AND ((original.idempotency_key <> '' AND idem.scope = original.idempotency_scope AND idem.key = original.idempotency_key) OR (original.idempotency_key = '' AND idem.scope = 'native.tool-pin' AND idem.key = original.id)) AND EXISTS (SELECT 1 FROM plugin_graph_refs refs WHERE refs.graph_id = json_extract(idem.response_json, '$.tool_graph_id') AND refs.owner_kind = 'run' AND refs.owner_id = idem.resource_id AND refs.ref_id = json_extract(idem.response_json, '$.tool_ref_id'))", [sql_text(owner_id.copy())], plugin_limits()); + let mut graph_id: string = ""; + let mut ref_id: string = ""; + let mut generation: int = 0; + let mut run_id: string = ""; + let mut session_id: string = ""; + let mut registry_identity: string = ""; + let mut workspace: string = ""; + let mut profile: string = ""; + let mut pinned: bool = false; + let rows = queried.rows.copy(); + if rows.length == 1 { + let row: SqliteRow = rows[0].copy(); + let cells: array = row.cells.copy(); + graph_id = cells[0].text_value; + ref_id = cells[1].text_value; + generation = cells[2].int_value; + run_id = cells[3].text_value; + session_id = cells[4].text_value; + registry_identity = cells[5].text_value; + workspace = cells[6].text_value; + profile = cells[7].text_value; + pinned = graph_id.copy() != "" && ref_id.copy() != ""; + } + { + graph_id: graph_id, + ref_id: ref_id, + generation: generation, + run_id: run_id, + session_id: session_id, + registry_identity: registry_identity, + workspace: workspace, + profile: profile, + pinned: pinned + } +} diff --git a/rss/storage/schema.rss b/rss/storage/schema.rss index c0b77f1..289d268 100644 --- a/rss/storage/schema.rss +++ b/rss/storage/schema.rss @@ -58,6 +58,44 @@ fn jobs_table_sql() -> string { "CREATE TABLE IF NOT EXISTS jobs (id TEXT PRIMARY KEY, name TEXT NOT NULL, schedule_json TEXT NOT NULL DEFAULT '{}', prompt TEXT NOT NULL, deliver_json TEXT NOT NULL DEFAULT '{}', skills_json TEXT NOT NULL DEFAULT '[]', repeat_count INTEGER NOT NULL DEFAULT 0, enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)), output_json TEXT, created_at_ms INTEGER NOT NULL, updated_at_ms INTEGER NOT NULL, last_run_at_ms INTEGER NOT NULL DEFAULT 0)" } +fn plugin_graph_locks_sql() -> string { + "CREATE TABLE IF NOT EXISTS plugin_graph_locks (graph_id TEXT PRIMARY KEY, lock_json TEXT NOT NULL, stored_at_ms INTEGER NOT NULL)" +} + +fn plugin_package_refs_sql() -> string { + "CREATE TABLE IF NOT EXISTS plugin_package_refs (graph_id TEXT NOT NULL, package_id TEXT NOT NULL, version TEXT NOT NULL, kind TEXT NOT NULL, artifact_id TEXT NOT NULL, PRIMARY KEY (graph_id, package_id))" +} + +fn plugin_activation_sql() -> string { + "CREATE TABLE IF NOT EXISTS plugin_activation (id INTEGER PRIMARY KEY CHECK (id = 1), graph_id TEXT NOT NULL, generation INTEGER NOT NULL, updated_at_ms INTEGER NOT NULL)" +} + +fn plugin_activation_history_sql() -> string { + "CREATE TABLE IF NOT EXISTS plugin_activation_history (generation INTEGER PRIMARY KEY, graph_id TEXT NOT NULL, previous_graph_id TEXT NOT NULL, activated_at_ms INTEGER NOT NULL)" +} + +fn plugin_graph_refs_sql() -> string { + "CREATE TABLE IF NOT EXISTS plugin_graph_refs (graph_id TEXT NOT NULL, owner_kind TEXT NOT NULL, owner_id TEXT NOT NULL, ref_id TEXT NOT NULL, retained_at_ms INTEGER NOT NULL, PRIMARY KEY (graph_id, owner_kind, owner_id), UNIQUE (ref_id))" +} + +fn plugin_control_guard_sql() -> string { + "CREATE TABLE IF NOT EXISTS plugin_control_guard (id INTEGER PRIMARY KEY CHECK (id = 1))" +} + +fn plugin_control_migration_statement(statement_index: int) -> string { + if statement_index == 0 => { plugin_graph_locks_sql() } else => { + if statement_index == 1 => { plugin_package_refs_sql() } else => { + if statement_index == 2 => { plugin_activation_sql() } else => { + if statement_index == 3 => { plugin_activation_history_sql() } else => { + if statement_index == 4 => { plugin_graph_refs_sql() } else => { + plugin_control_guard_sql() + } + } + } + } + } +} + fn index_statement(statement_index: int) -> string { if statement_index == 0 => { "CREATE INDEX IF NOT EXISTS idx_sessions_identity ON sessions (profile, platform, account_id, chat_id, thread_id)" @@ -107,13 +145,15 @@ fn index_statement(statement_index: int) -> string { } pub fn schema_migration_count() -> int { - 4 + 5 } pub fn schema_migration_version(migration_index: int) -> int { if migration_index == 0 => { 1 } else => { if migration_index == 1 => { 2 } else => { - if migration_index == 2 => { 3 } else => { 4 } + if migration_index == 2 => { 3 } else => { + if migration_index == 3 => { 4 } else => { 5 } + } } } } @@ -128,7 +168,11 @@ pub fn schema_migration_name(migration_index: int) -> string { if migration_index == 2 => { "agent-storage-v3-event-retention" } else => { - "agent-storage-v4-jobs-and-message-links" + if migration_index == 3 => { + "agent-storage-v4-jobs-and-message-links" + } else => { + "agent-storage-v5-plugin-control" + } } } } @@ -144,7 +188,11 @@ pub fn schema_migration_checksum(migration_index: int) -> string { if migration_index == 2 => { "rss-storage-v3-retention" } else => { - "rss-storage-v4-jobs-message-links" + if migration_index == 3 => { + "rss-storage-v4-jobs-message-links" + } else => { + "rss-storage-v5-plugin-control" + } } } } @@ -153,14 +201,16 @@ pub fn schema_migration_checksum(migration_index: int) -> string { pub fn schema_migration_statement_count(migration_index: int) -> int { if migration_index == 0 => { 11 } else => { if migration_index == 1 => { 12 } else => { - if migration_index == 2 => { 1 } else => { 5 } + if migration_index == 2 => { 1 } else => { + if migration_index == 3 => { 5 } else => { 6 } + } } } } /// The schema version a fully migrated database reaches. pub fn schema_current_version() -> int { - 4 + 5 } fn table_migration_statement(statement_index: int) -> string { @@ -221,7 +271,11 @@ pub fn schema_migration_statement(migration_index: int, statement_index: int) -> if migration_index == 2 => { retention_migration_statement(statement_index) } else => { - jobs_migration_statement(statement_index) + if migration_index == 3 => { + jobs_migration_statement(statement_index) + } else => { + plugin_control_migration_statement(statement_index) + } } } } diff --git a/rss/storage/sessions.rss b/rss/storage/sessions.rss index fed0ed8..8e4fc2a 100644 --- a/rss/storage/sessions.rss +++ b/rss/storage/sessions.rss @@ -172,15 +172,21 @@ pub fn storage_session_workspace_bind(db_id: resource, payloa /// One transactional cascade: deleting a session removes its messages, runs, /// retained events, retention floors, parent/child links, approvals, -/// compactions, delivery cursors, and run-scoped idempotency records. The -/// idempotency delete comes first because the session delete cascades to -/// runs (FK behavior may be enabled by the host), and the idempotency -/// cascade predicate needs the runs to still exist. The session row delete -/// is statement 1, so `rows_affected` of that result tells the caller -/// whether the session existed (0 -> session_not_found). +/// compactions, delivery cursors, run-scoped idempotency records, and run +/// graph refs. Both ref and idempotency deletes precede the session delete: +/// the latter may cascade to runs when the host enables FKs, and both +/// predicates need those runs to still exist. The session row delete is +/// statement 2, so `rows_affected` of that result tells the caller whether +/// the session existed (0 -> session_not_found). pub fn storage_session_delete(db_id: resource, payload_json: string) -> array { let input: SessionIdInput = json::decode::(payload_json); let statements: array = [ + { + sql: "DELETE FROM plugin_graph_refs WHERE owner_kind = 'run' AND owner_id IN (SELECT id FROM runs WHERE session_id = ?)", + params: [sql_text(input.session_id.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }, { sql: "DELETE FROM idempotency_records WHERE resource_type = 'run' AND resource_id IN (SELECT id FROM runs WHERE session_id = ?)", params: [sql_text(input.session_id.copy())], diff --git a/rss/tools/dispatch.rss b/rss/tools/dispatch.rss index b9104a9..075a169 100644 --- a/rss/tools/dispatch.rss +++ b/rss/tools/dispatch.rss @@ -254,7 +254,11 @@ fn tool_payload(result: map) -> map { } fn envelope(call: map, result: map, _terminal: bool) -> map { - let mut payload: map = tool_payload(result); + canonical_envelope(call, tool_payload(result)) +} + +fn canonical_envelope(call: map, result: map) -> map { + let mut payload: map = result; let ok: bool = map_bool(payload, "ok", false); let content: string = types::map_string(payload, "content", ""); let truncated: bool = map_bool(payload, "truncated", false); @@ -290,6 +294,21 @@ fn fail_envelope(call: map, code: string, message: string, terminal: bool) -> ma envelope(call, fail_result(code, message), terminal) } +/// Project the Native-witnessed canonical result into the public message +/// protocol. The retained child's return wrapper is not execution evidence. +pub fn result_envelope(call: map, result: map) -> map { + let mut projected: map = canonical_envelope(call, result); + let error: map = types::map_map(projected, "error"); + let code: string = types::map_string(error, "code", ""); + // A committed process-handle observation can report cancellation without + // cancelling its run. The loop's independent Native control_check decides + // run cancellation/deadline immediately after invocation. + if code == "cancelled" || code == "deadline_elapsed" { + projected.terminal = false; + } + projected +} + fn tool_context(input: map, call: map, descriptor: map) -> map { let args: map = call_arguments(call); let digest: string = json::encode(args); @@ -343,6 +362,21 @@ fn route(name: string, context: map) -> map { result } +/// Native-selected embedded entry. The original harness has already resolved +/// its frozen descriptor and approval facts. Native rebuilds this prepare map +/// from the original parent and binds it to the child's one-call grant. +pub fn invoke_prepared(input: map) -> map { + let call: map = call_from_input(input); + let name: string = call_name(call); + let mut context: map = types::map_map(input, "prepared_context"); + let mut config: map = types::map_map(context, "config"); + if config.has("max_output_bytes") == false { + config.max_output_bytes = map_int(config, "max_tool_output_bytes", 65536); + } + context.config = config; + envelope(call, route(name, context), false) +} + pub fn dispatch(input: map) -> map { let call: map = call_from_input(input); let name: string = call_name(call); diff --git a/schemas/README.md b/schemas/README.md new file mode 100644 index 0000000..88d18b0 --- /dev/null +++ b/schemas/README.md @@ -0,0 +1,13 @@ +# Plugin contract schemas + +These YAML files are the public structural contract for plugin manifests, effect entries, and graph locks. Native loads the same files. They are local and are not fetched. + +`cargo run --example plugin_schema_sync -- --check` compares `schemas/canonical/*.json` with a sorted JSON projection of these YAML files. `cargo run --example plugin_schema_sync -- --write` regenerates that projection after a reviewed schema edit. The plugin schema test runs the same comparison and a drift rejection. Do not edit the JSON by hand. + +The schemas express structure: required fields, enumerations, one public effect entry, safe relative paths, and the recovery/scope pairs that can be stated per effect kind. They do not express capability equality, semver satisfaction, dependency closure, or publisher authority. Those remain Native semantic checks. `replay` is not a v1 recovery. `checkpoint` is structural only for `workspace.fs.write`. `remote.write` and `process.spawn` cannot claim it. + +A graph-lock edge records the caller entry, callee entry, alias, version requirement, resolved revision, and delegation scope. Provenance stays `direct`, `plugin`, or `library`. It is not collapsed to `delegated`. + +`plugin-activation.schema.yaml` and `plugin-graph-ref.schema.yaml` are structural records for a stored activation pointer/history entry and a durable graph reference. A graph id is an opaque lock identity. These schemas do not grant host-catalog authority, rebuild a callable program, pin a live run, or claim hot reload. Reference owners are only `run` and `checkpoint`. + +The joined generator also projects `schemas/v1/checkpoint.schema.yaml` to `schemas/canonical/checkpoint.schema.json`. That projection is structural drift coverage only. It does not validate checkpoint writes, permissions, or serialization; those stay on the Native checkpoint consumer. diff --git a/schemas/canonical/checkpoint.schema.json b/schemas/canonical/checkpoint.schema.json new file mode 100644 index 0000000..2d9c4ac --- /dev/null +++ b/schemas/canonical/checkpoint.schema.json @@ -0,0 +1,122 @@ +{ + "$defs": { + "not_in_scope": { + "additionalProperties": false, + "properties": { + "availability": { + "const": "not_in_scope" + } + }, + "required": [ + "availability" + ], + "type": "object" + } + }, + "$id": "https://rustscript.dev/schemas/v1/checkpoint.schema.yaml", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "Public serializable checkpoint for one Native filesystem branch. Version rustscript.checkpoint/v1 records the immutable workspace root, branch lineage, and epoch only. Plugin state, graph lock, and history cursor are explicitly not in scope and cannot be marked snapshotted. external_effects_not_reverted is present so a consumer cannot treat checkpoint as remote undo; this version records no remote effect.\n", + "properties": { + "active_writers": { + "const": 0 + }, + "branch_id": { + "pattern": "^br-[0-9a-f]{32}$", + "type": "string" + }, + "checkpoint_id": { + "pattern": "^ckpt-[0-9a-f]{32}$", + "type": "string" + }, + "epoch": { + "minimum": 1, + "type": "integer" + }, + "external_effects_not_reverted": { + "items": { + "additionalProperties": false, + "properties": { + "kind": { + "enum": [ + "remote_read", + "compensatable", + "irreversible" + ] + }, + "reference": { + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "kind", + "reference" + ], + "type": "object" + }, + "maxItems": 0, + "type": "array" + }, + "format": { + "const": "rustscript.checkpoint/v1" + }, + "graph": { + "$ref": "#/$defs/not_in_scope" + }, + "history": { + "$ref": "#/$defs/not_in_scope" + }, + "immutable_root": { + "pattern": "^tree-[0-9a-f]{32}$", + "type": "string" + }, + "import_consistency": { + "const": "ordinary_copy" + }, + "lineage": { + "items": { + "pattern": "^br-[0-9a-f]{32}$", + "type": "string" + }, + "maxItems": 64, + "minItems": 1, + "type": "array" + }, + "owner_id": { + "pattern": "^own-[0-9a-f]{32}$", + "type": "string" + }, + "parent_head": { + "pattern": "^tree-[0-9a-f]{32}$", + "type": "string" + }, + "plugin_state": { + "$ref": "#/$defs/not_in_scope" + }, + "workspace_id": { + "pattern": "^ws-[0-9a-f]{32}$", + "type": "string" + } + }, + "required": [ + "format", + "checkpoint_id", + "workspace_id", + "owner_id", + "branch_id", + "lineage", + "immutable_root", + "epoch", + "parent_head", + "active_writers", + "import_consistency", + "plugin_state", + "graph", + "history", + "external_effects_not_reverted" + ], + "title": "RustScript workspace checkpoint", + "type": "object" +} diff --git a/schemas/canonical/effect-contract.schema.json b/schemas/canonical/effect-contract.schema.json new file mode 100644 index 0000000..03bfacb --- /dev/null +++ b/schemas/canonical/effect-contract.schema.json @@ -0,0 +1,288 @@ +{ + "$defs": { + "effect": { + "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "kind": { + "const": "workspace.fs.read" + } + } + }, + "then": { + "properties": { + "recovery": { + "const": "none" + }, + "scope": { + "const": "bound_workspace" + } + } + } + }, + { + "if": { + "properties": { + "kind": { + "const": "workspace.fs.write" + } + } + }, + "then": { + "properties": { + "recovery": { + "enum": [ + "none", + "checkpoint" + ] + }, + "scope": { + "const": "bound_workspace" + } + } + } + }, + { + "if": { + "properties": { + "kind": { + "const": "process.spawn" + } + } + }, + "then": { + "properties": { + "recovery": { + "const": "none" + }, + "scope": { + "const": "bound_process" + } + } + } + }, + { + "if": { + "properties": { + "kind": { + "const": "model.request" + } + } + }, + "then": { + "properties": { + "recovery": { + "const": "none" + }, + "scope": { + "const": "bound_run" + } + } + } + }, + { + "if": { + "properties": { + "kind": { + "const": "remote.write" + } + } + }, + "then": { + "properties": { + "recovery": { + "const": "none" + }, + "scope": { + "const": "bound_run" + } + } + } + }, + { + "if": { + "properties": { + "kind": { + "const": "clock.read" + } + } + }, + "then": { + "properties": { + "recovery": { + "const": "none" + }, + "scope": { + "const": "bound_run" + } + } + } + }, + { + "if": { + "properties": { + "kind": { + "const": "event.emit" + } + } + }, + "then": { + "properties": { + "recovery": { + "const": "none" + }, + "scope": { + "const": "bound_run" + } + } + } + }, + { + "if": { + "properties": { + "kind": { + "const": "delivery.send" + } + } + }, + "then": { + "properties": { + "recovery": { + "const": "none" + }, + "scope": { + "const": "bound_delivery" + } + } + } + }, + { + "if": { + "properties": { + "kind": { + "const": "secret.use" + } + } + }, + "then": { + "properties": { + "recovery": { + "const": "none" + }, + "scope": { + "const": "bound_secret" + } + } + } + } + ], + "properties": { + "kind": { + "enum": [ + "workspace.fs.read", + "workspace.fs.write", + "process.spawn", + "model.request", + "remote.write", + "clock.read", + "event.emit", + "delivery.send", + "secret.use" + ], + "type": "string" + }, + "recovery": { + "description": "replay is not a v1 recovery. checkpoint is structural only for workspace.fs.write. remote.write and process.spawn cannot claim it.\n", + "enum": [ + "none", + "checkpoint" + ], + "type": "string" + }, + "scope": { + "enum": [ + "bound_workspace", + "bound_process", + "bound_run", + "bound_delivery", + "bound_secret" + ], + "type": "string" + } + }, + "required": [ + "kind", + "scope", + "recovery" + ], + "type": "object" + }, + "entry": { + "additionalProperties": false, + "properties": { + "delegated": { + "items": { + "additionalProperties": false, + "properties": { + "allow": { + "items": { + "$ref": "#/$defs/effect" + }, + "type": "array" + }, + "dependency": { + "type": "string" + }, + "entry": { + "type": "string" + } + }, + "required": [ + "dependency", + "entry", + "allow" + ], + "type": "object" + }, + "type": "array" + }, + "direct": { + "items": { + "$ref": "#/$defs/effect" + }, + "type": "array" + } + }, + "required": [ + "direct", + "delegated" + ], + "type": "object" + } + }, + "$id": "https://schemas.rustscript.local/v1/effect-contract.schema.yaml", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "contract": { + "const": "agent.effects/v1" + }, + "entries": { + "additionalProperties": { + "$ref": "#/$defs/entry" + }, + "description": "v1 structural cardinality is one public effect entry. Matching that key to entry.export, and closing every supported entry, are Native semantic checks. This schema does not claim those checks.\n", + "maxProperties": 1, + "minProperties": 1, + "type": "object" + } + }, + "required": [ + "contract", + "entries" + ], + "title": "RustScript agent effect contract", + "type": "object" +} diff --git a/schemas/canonical/plugin-activation.schema.json b/schemas/canonical/plugin-activation.schema.json new file mode 100644 index 0000000..ddd95ff --- /dev/null +++ b/schemas/canonical/plugin-activation.schema.json @@ -0,0 +1,33 @@ +{ + "$id": "https://schemas.rustscript.local/v1/plugin-activation.schema.yaml", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "Structural activation pointer or history entry. graph_id is an opaque lock identity. This document does not grant host-catalog authority, rebuild a callable program, pin a live run, or claim hot reload.\n", + "properties": { + "activated_at_ms": { + "minimum": 0, + "type": "integer" + }, + "generation": { + "minimum": 0, + "type": "integer" + }, + "graph_id": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "previous_graph_id": { + "maxLength": 128, + "type": "string" + } + }, + "required": [ + "graph_id", + "generation", + "previous_graph_id", + "activated_at_ms" + ], + "title": "RustScript plugin activation record", + "type": "object" +} diff --git a/schemas/canonical/plugin-graph-lock.schema.json b/schemas/canonical/plugin-graph-lock.schema.json new file mode 100644 index 0000000..084e7b5 --- /dev/null +++ b/schemas/canonical/plugin-graph-lock.schema.json @@ -0,0 +1,226 @@ +{ + "$id": "https://schemas.rustscript.local/v1/plugin-graph-lock.schema.yaml", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "closure": { + "items": { + "additionalProperties": false, + "properties": { + "effects": { + "items": { + "additionalProperties": false, + "properties": { + "kind": { + "type": "string" + }, + "recovery": { + "type": "string" + }, + "scope": { + "type": "string" + }, + "via": { + "additionalProperties": false, + "description": "Provenance of one effect. direct, plugin, and library stay distinct. delegated is not a collapsed label. Semver satisfaction of version_requirement is a Native check.\n", + "properties": { + "alias": { + "type": "string" + }, + "callee_entry": { + "type": "string" + }, + "callee_package": { + "type": "string" + }, + "callee_version": { + "type": "string" + }, + "caller_entry": { + "type": "string" + }, + "caller_package": { + "type": "string" + }, + "relation": { + "enum": [ + "direct", + "plugin", + "library" + ], + "type": "string" + }, + "version_requirement": { + "type": "string" + } + }, + "required": [ + "relation", + "caller_package", + "caller_entry", + "alias", + "callee_package", + "callee_version", + "callee_entry", + "version_requirement" + ], + "type": "object" + } + }, + "required": [ + "kind", + "scope", + "recovery", + "via" + ], + "type": "object" + }, + "type": "array" + }, + "entry": { + "type": "string" + }, + "package": { + "type": "string" + } + }, + "required": [ + "package", + "entry", + "effects" + ], + "type": "object" + }, + "type": "array" + }, + "compile_environment": { + "const": "closed-bundle/agent.cap/v1" + }, + "edges": { + "items": { + "additionalProperties": false, + "properties": { + "alias": { + "type": "string" + }, + "bindings": { + "items": { + "additionalProperties": false, + "properties": { + "kind": { + "type": "string" + }, + "recovery": { + "type": "string" + }, + "scope": { + "type": "string" + } + }, + "required": [ + "kind", + "scope", + "recovery" + ], + "type": "object" + }, + "type": "array" + }, + "from": { + "type": "string" + }, + "from_entry": { + "type": "string" + }, + "relation": { + "enum": [ + "library", + "plugin" + ], + "type": "string" + }, + "to": { + "type": "string" + }, + "to_artifact_id": { + "description": "Installed object id from the existing integrity contract.", + "type": "string" + }, + "to_entry": { + "type": "string" + }, + "to_version": { + "description": "Resolved callee revision. Not a content digest.", + "type": "string" + }, + "version_requirement": { + "description": "Caller requirement, verbatim. Satisfaction is a Native semantic check and is not claimed by this schema.\n", + "type": "string" + } + }, + "required": [ + "from", + "from_entry", + "alias", + "to", + "to_version", + "to_artifact_id", + "to_entry", + "relation", + "version_requirement", + "bindings" + ], + "type": "object" + }, + "type": "array" + }, + "graph_id": { + "description": "Opaque lock identity. Not a capability and not a content digest.", + "type": "string" + }, + "host_runtime": { + "type": "string" + }, + "packages": { + "items": { + "additionalProperties": false, + "properties": { + "artifact_id": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "enum": [ + "library", + "tool" + ], + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "id", + "version", + "kind", + "artifact_id" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "closure", + "compile_environment", + "edges", + "graph_id", + "host_runtime", + "packages" + ], + "title": "RustScript plugin graph lock", + "type": "object" +} diff --git a/schemas/canonical/plugin-graph-ref.schema.json b/schemas/canonical/plugin-graph-ref.schema.json new file mode 100644 index 0000000..10df9d4 --- /dev/null +++ b/schemas/canonical/plugin-graph-ref.schema.json @@ -0,0 +1,38 @@ +{ + "$id": "https://schemas.rustscript.local/v1/plugin-graph-ref.schema.yaml", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "A durable reference that keeps one exact graph lock from unreferenced collection. It does not pin a live run, enable hot reload, or authorize a tool call. owner_kind is only run or checkpoint.\n", + "properties": { + "graph_id": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "owner_id": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "owner_kind": { + "enum": [ + "run", + "checkpoint" + ], + "type": "string" + }, + "ref_id": { + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "graph_id", + "owner_kind", + "owner_id", + "ref_id" + ], + "title": "RustScript plugin graph retention reference", + "type": "object" +} diff --git a/schemas/canonical/plugin-manifest.schema.json b/schemas/canonical/plugin-manifest.schema.json new file mode 100644 index 0000000..987f7d5 --- /dev/null +++ b/schemas/canonical/plugin-manifest.schema.json @@ -0,0 +1,133 @@ +{ + "$id": "https://schemas.rustscript.local/v1/plugin-manifest.schema.yaml", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "capabilities": { + "items": { + "enum": [ + "workspace.read", + "workspace.write", + "process.spawn", + "model.request", + "remote.write", + "clock.read", + "event.emit", + "delivery.send", + "secret.use" + ], + "type": "string" + }, + "type": "array" + }, + "contract": { + "enum": [ + "agent.library/v1", + "agent.tool/v1" + ], + "type": "string" + }, + "dependencies": { + "items": { + "additionalProperties": false, + "properties": { + "alias": { + "type": "string" + }, + "id": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "alias", + "id", + "version" + ], + "type": "object" + }, + "type": "array" + }, + "effects": { + "$ref": "https://schemas.rustscript.local/v1/effect-contract.schema.yaml" + }, + "entry": { + "additionalProperties": false, + "properties": { + "export": { + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", + "type": "string" + }, + "path": { + "description": "One safe relative path. Parent segments and absolute paths are structural rejects. Semver satisfaction, capability equality, and dependency closure are Native semantic checks, not schema claims.\n", + "pattern": "^[A-Za-z0-9_-]+([.][A-Za-z0-9_-]+)*(/[A-Za-z0-9_-]+([.][A-Za-z0-9_-]+)*)*$", + "type": "string" + } + }, + "required": [ + "path", + "export" + ], + "type": "object" + }, + "format": { + "const": "rustscript.plugin/v1" + }, + "host_abi": { + "const": "agent.cap/v1" + }, + "id": { + "pattern": "^[a-z0-9]+(\\.[a-z0-9]+)+$", + "type": "string" + }, + "kind": { + "enum": [ + "library", + "tool" + ], + "type": "string" + }, + "runtime": { + "minLength": 1, + "type": "string" + }, + "state": { + "additionalProperties": false, + "properties": { + "schema": { + "minimum": 1, + "type": "integer" + }, + "scope": { + "const": "run" + } + }, + "required": [ + "scope", + "schema" + ], + "type": "object" + }, + "version": { + "pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$", + "type": "string" + } + }, + "required": [ + "format", + "id", + "version", + "kind", + "runtime", + "contract", + "host_abi", + "entry", + "dependencies", + "capabilities", + "effects" + ], + "title": "RustScript plugin manifest", + "type": "object" +} diff --git a/schemas/effect-contract.schema.yaml b/schemas/effect-contract.schema.yaml new file mode 100644 index 0000000..f3f14b2 --- /dev/null +++ b/schemas/effect-contract.schema.yaml @@ -0,0 +1,180 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: https://schemas.rustscript.local/v1/effect-contract.schema.yaml +title: RustScript agent effect contract +type: object +additionalProperties: false +required: + - contract + - entries +properties: + contract: + const: agent.effects/v1 + entries: + type: object + minProperties: 1 + maxProperties: 1 + description: > + v1 structural cardinality is one public effect entry. Matching that key + to entry.export, and closing every supported entry, are Native semantic + checks. This schema does not claim those checks. + additionalProperties: + $ref: "#/$defs/entry" +$defs: + entry: + type: object + additionalProperties: false + required: + - direct + - delegated + properties: + direct: + type: array + items: + $ref: "#/$defs/effect" + delegated: + type: array + items: + type: object + additionalProperties: false + required: + - dependency + - entry + - allow + properties: + dependency: + type: string + entry: + type: string + allow: + type: array + items: + $ref: "#/$defs/effect" + effect: + type: object + additionalProperties: false + required: + - kind + - scope + - recovery + properties: + kind: + type: string + enum: + - workspace.fs.read + - workspace.fs.write + - process.spawn + - model.request + - remote.write + - clock.read + - event.emit + - delivery.send + - secret.use + scope: + type: string + enum: + - bound_workspace + - bound_process + - bound_run + - bound_delivery + - bound_secret + recovery: + type: string + enum: + - none + - checkpoint + description: > + replay is not a v1 recovery. checkpoint is structural only for + workspace.fs.write. remote.write and process.spawn cannot claim it. + allOf: + - if: + properties: + kind: + const: workspace.fs.read + then: + properties: + scope: + const: bound_workspace + recovery: + const: none + - if: + properties: + kind: + const: workspace.fs.write + then: + properties: + scope: + const: bound_workspace + recovery: + enum: + - none + - checkpoint + - if: + properties: + kind: + const: process.spawn + then: + properties: + scope: + const: bound_process + recovery: + const: none + - if: + properties: + kind: + const: model.request + then: + properties: + scope: + const: bound_run + recovery: + const: none + - if: + properties: + kind: + const: remote.write + then: + properties: + scope: + const: bound_run + recovery: + const: none + - if: + properties: + kind: + const: clock.read + then: + properties: + scope: + const: bound_run + recovery: + const: none + - if: + properties: + kind: + const: event.emit + then: + properties: + scope: + const: bound_run + recovery: + const: none + - if: + properties: + kind: + const: delivery.send + then: + properties: + scope: + const: bound_delivery + recovery: + const: none + - if: + properties: + kind: + const: secret.use + then: + properties: + scope: + const: bound_secret + recovery: + const: none diff --git a/schemas/plugin-activation.schema.yaml b/schemas/plugin-activation.schema.yaml new file mode 100644 index 0000000..8956319 --- /dev/null +++ b/schemas/plugin-activation.schema.yaml @@ -0,0 +1,28 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: https://schemas.rustscript.local/v1/plugin-activation.schema.yaml +title: RustScript plugin activation record +description: > + Structural activation pointer or history entry. graph_id is an opaque lock + identity. This document does not grant host-catalog authority, rebuild a + callable program, pin a live run, or claim hot reload. +type: object +additionalProperties: false +required: + - graph_id + - generation + - previous_graph_id + - activated_at_ms +properties: + graph_id: + type: string + minLength: 1 + maxLength: 128 + generation: + type: integer + minimum: 0 + previous_graph_id: + type: string + maxLength: 128 + activated_at_ms: + type: integer + minimum: 0 diff --git a/schemas/plugin-graph-lock.schema.yaml b/schemas/plugin-graph-lock.schema.yaml new file mode 100644 index 0000000..8c6f6ee --- /dev/null +++ b/schemas/plugin-graph-lock.schema.yaml @@ -0,0 +1,169 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: https://schemas.rustscript.local/v1/plugin-graph-lock.schema.yaml +title: RustScript plugin graph lock +type: object +additionalProperties: false +required: + - closure + - compile_environment + - edges + - graph_id + - host_runtime + - packages +properties: + closure: + type: array + items: + type: object + additionalProperties: false + required: + - package + - entry + - effects + properties: + package: + type: string + entry: + type: string + effects: + type: array + items: + type: object + additionalProperties: false + required: + - kind + - scope + - recovery + - via + properties: + kind: + type: string + scope: + type: string + recovery: + type: string + via: + type: object + additionalProperties: false + description: > + Provenance of one effect. direct, plugin, and library stay + distinct. delegated is not a collapsed label. Semver + satisfaction of version_requirement is a Native check. + required: + - relation + - caller_package + - caller_entry + - alias + - callee_package + - callee_version + - callee_entry + - version_requirement + properties: + relation: + type: string + enum: + - direct + - plugin + - library + caller_package: + type: string + caller_entry: + type: string + alias: + type: string + callee_package: + type: string + callee_version: + type: string + callee_entry: + type: string + version_requirement: + type: string + compile_environment: + const: closed-bundle/agent.cap/v1 + edges: + type: array + items: + type: object + additionalProperties: false + required: + - from + - from_entry + - alias + - to + - to_version + - to_artifact_id + - to_entry + - relation + - version_requirement + - bindings + properties: + from: + type: string + from_entry: + type: string + alias: + type: string + to: + type: string + to_version: + type: string + description: Resolved callee revision. Not a content digest. + to_artifact_id: + type: string + description: Installed object id from the existing integrity contract. + to_entry: + type: string + relation: + type: string + enum: + - library + - plugin + version_requirement: + type: string + description: > + Caller requirement, verbatim. Satisfaction is a Native semantic + check and is not claimed by this schema. + bindings: + type: array + items: + type: object + additionalProperties: false + required: + - kind + - scope + - recovery + properties: + kind: + type: string + scope: + type: string + recovery: + type: string + graph_id: + type: string + description: Opaque lock identity. Not a capability and not a content digest. + host_runtime: + type: string + packages: + type: array + items: + type: object + additionalProperties: false + required: + - id + - version + - kind + - artifact_id + properties: + id: + type: string + version: + type: string + kind: + type: string + enum: + - library + - tool + artifact_id: + type: string diff --git a/schemas/plugin-graph-ref.schema.yaml b/schemas/plugin-graph-ref.schema.yaml new file mode 100644 index 0000000..5063d39 --- /dev/null +++ b/schemas/plugin-graph-ref.schema.yaml @@ -0,0 +1,32 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: https://schemas.rustscript.local/v1/plugin-graph-ref.schema.yaml +title: RustScript plugin graph retention reference +description: > + A durable reference that keeps one exact graph lock from unreferenced + collection. It does not pin a live run, enable hot reload, or authorize a + tool call. owner_kind is only run or checkpoint. +type: object +additionalProperties: false +required: + - graph_id + - owner_kind + - owner_id + - ref_id +properties: + graph_id: + type: string + minLength: 1 + maxLength: 128 + owner_kind: + type: string + enum: + - run + - checkpoint + owner_id: + type: string + minLength: 1 + maxLength: 128 + ref_id: + type: string + minLength: 1 + maxLength: 128 diff --git a/schemas/plugin-manifest.schema.yaml b/schemas/plugin-manifest.schema.yaml new file mode 100644 index 0000000..fc5419f --- /dev/null +++ b/schemas/plugin-manifest.schema.yaml @@ -0,0 +1,102 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: https://schemas.rustscript.local/v1/plugin-manifest.schema.yaml +title: RustScript plugin manifest +type: object +additionalProperties: false +required: + - format + - id + - version + - kind + - runtime + - contract + - host_abi + - entry + - dependencies + - capabilities + - effects +properties: + capabilities: + type: array + items: + type: string + enum: + - workspace.read + - workspace.write + - process.spawn + - model.request + - remote.write + - clock.read + - event.emit + - delivery.send + - secret.use + contract: + type: string + enum: + - agent.library/v1 + - agent.tool/v1 + dependencies: + type: array + items: + type: object + additionalProperties: false + required: + - alias + - id + - version + properties: + alias: + type: string + id: + type: string + version: + type: string + effects: + $ref: https://schemas.rustscript.local/v1/effect-contract.schema.yaml + entry: + type: object + additionalProperties: false + required: + - path + - export + properties: + path: + type: string + pattern: "^[A-Za-z0-9_-]+([.][A-Za-z0-9_-]+)*(/[A-Za-z0-9_-]+([.][A-Za-z0-9_-]+)*)*$" + description: > + One safe relative path. Parent segments and absolute paths are + structural rejects. Semver satisfaction, capability equality, and + dependency closure are Native semantic checks, not schema claims. + export: + type: string + pattern: "^[A-Za-z_][A-Za-z0-9_]*$" + format: + const: rustscript.plugin/v1 + host_abi: + const: agent.cap/v1 + id: + type: string + pattern: "^[a-z0-9]+(\\.[a-z0-9]+)+$" + kind: + type: string + enum: + - library + - tool + runtime: + type: string + minLength: 1 + state: + type: object + additionalProperties: false + required: + - scope + - schema + properties: + scope: + const: run + schema: + type: integer + minimum: 1 + version: + type: string + pattern: "^[0-9]+\\.[0-9]+\\.[0-9]+$" diff --git a/schemas/v1/checkpoint.schema.yaml b/schemas/v1/checkpoint.schema.yaml new file mode 100644 index 0000000..39a2e04 --- /dev/null +++ b/schemas/v1/checkpoint.schema.yaml @@ -0,0 +1,91 @@ +$schema: https://json-schema.org/draft/2020-12/schema +$id: https://rustscript.dev/schemas/v1/checkpoint.schema.yaml +title: RustScript workspace checkpoint +description: > + Public serializable checkpoint for one Native filesystem branch. + Version rustscript.checkpoint/v1 records the immutable workspace root, + branch lineage, and epoch only. Plugin state, graph lock, and history + cursor are explicitly not in scope and cannot be marked snapshotted. + external_effects_not_reverted is present so a consumer cannot treat + checkpoint as remote undo; this version records no remote effect. +type: object +additionalProperties: false +required: + - format + - checkpoint_id + - workspace_id + - owner_id + - branch_id + - lineage + - immutable_root + - epoch + - parent_head + - active_writers + - import_consistency + - plugin_state + - graph + - history + - external_effects_not_reverted +properties: + format: + const: rustscript.checkpoint/v1 + checkpoint_id: + type: string + pattern: "^ckpt-[0-9a-f]{32}$" + workspace_id: + type: string + pattern: "^ws-[0-9a-f]{32}$" + owner_id: + type: string + pattern: "^own-[0-9a-f]{32}$" + branch_id: + type: string + pattern: "^br-[0-9a-f]{32}$" + lineage: + type: array + minItems: 1 + maxItems: 64 + items: + type: string + pattern: "^br-[0-9a-f]{32}$" + immutable_root: + type: string + pattern: "^tree-[0-9a-f]{32}$" + epoch: + type: integer + minimum: 1 + parent_head: + type: string + pattern: "^tree-[0-9a-f]{32}$" + active_writers: + const: 0 + import_consistency: + const: ordinary_copy + plugin_state: + $ref: "#/$defs/not_in_scope" + graph: + $ref: "#/$defs/not_in_scope" + history: + $ref: "#/$defs/not_in_scope" + external_effects_not_reverted: + type: array + maxItems: 0 + items: + type: object + additionalProperties: false + required: [kind, reference] + properties: + kind: + enum: [remote_read, compensatable, irreversible] + reference: + type: string + minLength: 1 + maxLength: 128 +$defs: + not_in_scope: + type: object + additionalProperties: false + required: [availability] + properties: + availability: + const: not_in_scope diff --git a/src/capabilities/lifecycle.rs b/src/capabilities/lifecycle.rs index a06e0ad..242195d 100644 --- a/src/capabilities/lifecycle.rs +++ b/src/capabilities/lifecycle.rs @@ -56,6 +56,16 @@ pub trait DurableToolLifecycle: Send + Sync { call_id: &str, tool_name: &str, ) -> Result<(), LifecycleError>; + /// Original Native parent arguments, independent of the bridge request. + /// Embedders that invoke retained programs directly must supply these facts. + fn parent_arguments( + &self, + _run_id: &str, + _call_id: &str, + _tool_name: &str, + ) -> Result, LifecycleError> { + Ok(None) + } fn replay_result( &self, run_id: &str, @@ -482,6 +492,25 @@ impl CapabilityLifecycle { &self.inner.workspace } + /// Registry identity captured when this lifecycle was admitted. + pub fn registry_identity(&self) -> &str { + &self.inner.registry_identity + } + + pub(crate) fn parent_arguments( + &self, + owner: &CapabilityOwner, + call_id: &str, + tool_name: &str, + ) -> Result, LifecycleError> { + if owner != &self.inner.owner { + return Err(LifecycleError::MissingParent); + } + self.inner + .durable + .parent_arguments(owner.run(), call_id, tool_name) + } + /// Monotonic milliseconds from the admitted clock. Callers cannot forge /// this value; they must present an authorized execution token via the /// generic host primitive. @@ -991,7 +1020,7 @@ fn should_rollback_unpublished(error: &LifecycleError) -> bool { ) } -fn validate_canonical_result(result: &Value) -> Result<(), LifecycleError> { +pub(crate) fn validate_canonical_result(result: &Value) -> Result<(), LifecycleError> { let object = result .as_object() .ok_or_else(|| LifecycleError::InvalidMetadata("tool result must be a map".to_string()))?; diff --git a/src/config.rs b/src/config.rs index e421ff5..3568707 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2441,7 +2441,66 @@ mod tests { parse_select_columns(&source, "FROM messages"), admission_query_column_names(ADMISSION_MESSAGE_QUERY_COLUMNS) ); - let idempotency_queries = parse_all_select_columns(&source, "FROM idempotency_records"); + // The operation-owned pin receipt has a nested runs SELECT and is not + // one of the three canonical idempotency projections below. Validate + // its complete positional protocol separately; rfind("SELECT") cannot + // parse the outer list of a query containing a scalar subquery. + let owner_function = source + .split_once("fn admission_owner_pin_sql() -> string {") + .expect("operation-owned pin SQL helper") + .1 + .split_once("\n}") + .expect("pin SQL helper end") + .0; + let owner_sql = owner_function.trim().trim_matches('"'); + let owner_columns = owner_sql + .strip_prefix("SELECT ") + .expect("pin receipt SELECT") + .split_once(" FROM idempotency_records idem ") + .expect("pin receipt idempotency source") + .0; + assert_eq!( + split_top_level_select_items(owner_columns), + vec![ + "COALESCE(json_extract(idem.response_json, '$.tool_graph_id'), '')", + "COALESCE(json_extract(idem.response_json, '$.tool_ref_id'), '')", + "COALESCE(CAST(json_extract(idem.response_json, '$.tool_generation') AS INTEGER), 0)", + "idem.resource_id", + "COALESCE((SELECT session_id FROM runs WHERE id = idem.resource_id), '')", + "COALESCE(json_extract(idem.response_json, '$.registry_identity'), '')", + "COALESCE(json_extract(idem.response_json, '$.workspace'), '')", + "COALESCE(json_extract(idem.response_json, '$.profile'), '')", + ], + "the operation-owned receipt must retain all eight cells in decoder order" + ); + for (index, (field, kind)) in [ + ("graph_id", "text"), + ("ref_id", "text"), + ("generation", "int"), + ("run_id", "text"), + ("session_id", "text"), + ("registry_identity", "text"), + ("workspace", "text"), + ("profile", "text"), + ] + .into_iter() + .enumerate() + { + assert!(source.contains(&format!("{field} = cells[{index}].{kind}_value;"))); + } + for predicate in [ + "idem.scope = ? AND idem.key = ? AND idem.resource_id = ?", + "refs.owner_kind = 'run' AND refs.owner_id = idem.resource_id", + "refs.ref_id = json_extract(idem.response_json, '$.tool_ref_id')", + ] { + assert!( + owner_sql.contains(predicate), + "pin receipt must bind its own owner/ref" + ); + } + let canonical_source = source.replacen(owner_sql, "", 1); + let idempotency_queries = + parse_all_select_columns(&canonical_source, "FROM idempotency_records"); assert_eq!( idempotency_queries.len(), 3, diff --git a/src/effects/checkpoint.rs b/src/effects/checkpoint.rs new file mode 100644 index 0000000..1a905bb --- /dev/null +++ b/src/effects/checkpoint.rs @@ -0,0 +1,473 @@ +//! Checkpoint documents and branch restore for one Native filesystem workspace. +//! +//! A checkpoint records the immutable root, lineage, and epoch. Plugin state, +//! graph lock, and history cursor stay explicitly out of scope. Restore appends +//! one ancestor and refuses, before changing a branch, epoch, journal, or file, +//! when that append would pass 64. Checkpoint applies the same 1..=64 document +//! bound before creating a file. The parser and schema maximum stay 64. + +use std::collections::HashSet; +use std::fs::{self, File}; +use std::io::Write; +use std::path::PathBuf; + +use serde::Serialize; +use yaml_rust2::YamlLoader; +use yaml_rust2::parser::{Event, Parser}; + +use super::types::{ + BranchEpoch, BranchId, CheckpointId, EffectError, EffectErrorKind, ExternalEffectNotice, + ImportConsistency, JournalEntry, JournalKind, MAX_LINEAGE, OwnerId, PublicCheckpointRef, + TreeId, WorkspaceId, +}; +use super::workspace::{BranchRecord, EffectStore, WorkspaceHandle}; + +const FORMAT: &str = "rustscript.checkpoint/v1"; +const NOT_IN_SCOPE: &str = "not_in_scope"; +const MAX_YAML_BYTES: usize = 65_536; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CheckpointRecord { + format: String, + checkpoint_id: CheckpointId, + workspace_id: WorkspaceId, + owner_id: OwnerId, + branch_id: BranchId, + lineage: Vec, + immutable_root: TreeId, + epoch: BranchEpoch, + parent_head: TreeId, + active_writers: u64, + import_consistency: ImportConsistency, + external_effects_not_reverted: Vec, +} + +impl CheckpointRecord { + pub fn checkpoint_id(&self) -> &CheckpointId { + &self.checkpoint_id + } + + pub fn immutable_root(&self) -> &TreeId { + &self.immutable_root + } + + pub fn active_writers(&self) -> u64 { + self.active_writers + } + + pub fn external_effects_not_reverted(&self) -> &[ExternalEffectNotice] { + &self.external_effects_not_reverted + } + + pub fn to_yaml(&self) -> String { + serde_yaml::to_string(&self.document()).expect("checkpoint document is serializable") + } + + pub fn to_json_value(&self) -> serde_json::Value { + serde_json::to_value(self.document()).expect("checkpoint document is serializable") + } + + fn document(&self) -> CheckpointDocument { + CheckpointDocument { + format: self.format.clone(), + checkpoint_id: self.checkpoint_id.as_str().to_string(), + workspace_id: self.workspace_id.as_str().to_string(), + owner_id: self.owner_id.as_str().to_string(), + branch_id: self.branch_id.as_str().to_string(), + lineage: self + .lineage + .iter() + .map(|branch| branch.as_str().to_string()) + .collect(), + immutable_root: self.immutable_root.as_str().to_string(), + epoch: self.epoch.get(), + parent_head: self.parent_head.as_str().to_string(), + active_writers: self.active_writers, + import_consistency: self.import_consistency.as_str().to_string(), + plugin_state: ScopeStatus { + availability: NOT_IN_SCOPE.to_string(), + }, + graph: ScopeStatus { + availability: NOT_IN_SCOPE.to_string(), + }, + history: ScopeStatus { + availability: NOT_IN_SCOPE.to_string(), + }, + external_effects_not_reverted: self.external_effects_not_reverted.clone(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct CheckpointDocument { + format: String, + checkpoint_id: String, + workspace_id: String, + owner_id: String, + branch_id: String, + lineage: Vec, + immutable_root: String, + epoch: u64, + parent_head: String, + active_writers: u64, + import_consistency: String, + plugin_state: ScopeStatus, + graph: ScopeStatus, + history: ScopeStatus, + external_effects_not_reverted: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct ScopeStatus { + availability: String, +} + +#[derive(Clone, Debug)] +pub struct RestoredBranch { + pub handle: WorkspaceHandle, + pub previous_branch: BranchId, + pub head: TreeId, + pub epoch: BranchEpoch, + pub external_effects_not_reverted: Vec, +} + +impl WorkspaceHandle { + pub fn checkpoint( + &self, + request: super::types::CheckpointRequest, + ) -> Result { + request.deadline.check()?; + request.cancellation.poll_quiet()?; + let mut state = self.store.state.lock(); + let workspace = self.workspace_mut(&mut state)?; + if workspace.revoked { + return Err(revoked()); + } + let branch = workspace + .branches + .get(&self.branch_id) + .ok_or_else(|| not_found("branch"))?; + if branch.epoch != self.epoch { + return Err(stale()); + } + if !branch.writers.is_empty() { + return Err(EffectError::new( + EffectErrorKind::CheckpointBusy, + "a writable view is still open", + )); + } + let head = branch.head.clone(); + let epoch = branch.epoch; + let lineage = workspace.lineage.clone(); + accept_lineage_document(lineage.len())?; + let record = CheckpointRecord { + format: FORMAT.to_string(), + checkpoint_id: CheckpointId::fresh(), + workspace_id: self.workspace_id.clone(), + owner_id: self.owner_id.clone(), + branch_id: self.branch_id.clone(), + lineage, + immutable_root: head.clone(), + epoch, + parent_head: head.clone(), + active_writers: 0, + import_consistency: workspace.import_consistency, + external_effects_not_reverted: Vec::new(), + }; + write_checkpoint_file(&self.store.root, &record)?; + workspace + .checkpoints + .insert(record.checkpoint_id.clone(), record.clone()); + workspace.journal.push(JournalEntry { + kind: JournalKind::Checkpoint, + branch_id: self.branch_id.clone(), + head, + epoch, + }); + Ok(record) + } + + pub fn restore( + &self, + request: super::types::RestoreRequest, + ) -> Result { + request.deadline.check()?; + request.cancellation.poll_quiet()?; + let mut state = self.store.state.lock(); + let workspace = self.workspace_mut(&mut state)?; + if workspace.revoked { + return Err(revoked()); + } + let current = workspace + .branches + .get(&self.branch_id) + .ok_or_else(|| not_found("branch"))?; + if current.epoch != self.epoch { + return Err(stale()); + } + if !current.writers.is_empty() { + return Err(EffectError::new( + EffectErrorKind::Busy, + "a writable view is still open", + )); + } + if request.expected_epoch != current.epoch { + return Err(stale()); + } + if request.expected_head != current.head { + return Err(EffectError::new( + EffectErrorKind::Conflict, + "expected head does not match", + )); + } + let lineage_len = workspace.lineage.len(); + let bumped = current.epoch.next(); + let record = workspace + .checkpoints + .get(&request.checkpoint_id) + .cloned() + .ok_or_else(|| not_found("checkpoint"))?; + if lineage_len >= MAX_LINEAGE { + return Err(schema("lineage cannot grow past 64")); + } + let previous_branch = self.branch_id.clone(); + let restored_head = record.immutable_root.clone(); + workspace + .branches + .get_mut(&self.branch_id) + .expect("branch exists") + .epoch = bumped; + let new_branch = BranchId::fresh(); + workspace.branches.insert( + new_branch.clone(), + BranchRecord { + epoch: bumped, + head: restored_head.clone(), + writers: Default::default(), + }, + ); + workspace.lineage.push(new_branch.clone()); + workspace.journal.push(JournalEntry { + kind: JournalKind::Restored, + branch_id: new_branch.clone(), + head: restored_head.clone(), + epoch: bumped, + }); + let handle = WorkspaceHandle { + store: std::sync::Arc::clone(&self.store), + workspace_id: self.workspace_id.clone(), + owner_id: self.owner_id.clone(), + branch_id: new_branch, + epoch: bumped, + secret: self.secret.clone(), + }; + Ok(RestoredBranch { + handle, + previous_branch, + head: restored_head, + epoch: bumped, + external_effects_not_reverted: Vec::new(), + }) + } +} + +impl EffectStore { + pub fn restore_from_public_ids( + &self, + _checkpoint: &PublicCheckpointRef, + ) -> Result { + Err(EffectError::new( + EffectErrorKind::AuthorityRequired, + "public identifiers cannot restore a branch", + )) + } +} + +pub fn parse_checkpoint_yaml(text: &str) -> Result { + if text.len() > MAX_YAML_BYTES { + return Err(schema("checkpoint yaml exceeds 65536 bytes")); + } + reject_yaml_constructs(text)?; + let documents = YamlLoader::load_from_str(text).map_err(|error| schema(error.to_string()))?; + if documents.len() != 1 { + return Err(schema("checkpoint yaml must contain one document")); + } + let document: CheckpointDocument = + serde_yaml::from_str(text).map_err(|error| schema(error.to_string()))?; + document.into_record() +} + +/// Production pre-conversion scanner used by [`parse_checkpoint_yaml`]. +/// +/// Test seam only: it does not restore a branch or accept a checkpoint. +#[doc(hidden)] +pub fn pre_conversion_yaml_error(text: &str) -> Result<(), EffectError> { + reject_yaml_constructs(text) +} + +impl CheckpointDocument { + fn into_record(self) -> Result { + if self.format != FORMAT { + return Err(schema("format is not rustscript.checkpoint/v1")); + } + if self.active_writers != 0 { + return Err(schema("active_writers must be 0")); + } + accept_lineage_document(self.lineage.len())?; + for scope in [&self.plugin_state, &self.graph, &self.history] { + if scope.availability != NOT_IN_SCOPE { + return Err(schema("plugin, graph, and history cannot be snapshotted")); + } + } + if !self.external_effects_not_reverted.is_empty() { + return Err(schema("this version records no remote effect")); + } + Ok(CheckpointRecord { + format: self.format, + checkpoint_id: CheckpointId::parse(&self.checkpoint_id)?, + workspace_id: WorkspaceId::parse(&self.workspace_id)?, + owner_id: OwnerId::parse(&self.owner_id)?, + branch_id: BranchId::parse(&self.branch_id)?, + lineage: self + .lineage + .iter() + .map(|branch| BranchId::parse(branch)) + .collect::, _>>()?, + immutable_root: TreeId::parse(&self.immutable_root)?, + epoch: BranchEpoch::from_raw(self.epoch)?, + parent_head: TreeId::parse(&self.parent_head)?, + active_writers: 0, + import_consistency: ImportConsistency::parse(&self.import_consistency)?, + external_effects_not_reverted: self.external_effects_not_reverted, + }) + } +} + +fn write_checkpoint_file( + root: &std::path::Path, + record: &CheckpointRecord, +) -> Result<(), EffectError> { + accept_lineage_document(record.lineage.len())?; + let final_path = root + .join("checkpoints") + .join(format!("{}.yaml", record.checkpoint_id)); + let temporary = PathBuf::from(format!("{}.tmp", final_path.display())); + let payload = record.to_yaml(); + { + let mut file = File::create(&temporary).map_err(|error| { + EffectError::new( + EffectErrorKind::Io, + format!("create checkpoint failed: {error}"), + ) + })?; + file.write_all(payload.as_bytes()).map_err(|error| { + EffectError::new( + EffectErrorKind::Io, + format!("write checkpoint failed: {error}"), + ) + })?; + file.sync_all().map_err(|error| { + EffectError::new( + EffectErrorKind::Io, + format!("sync checkpoint failed: {error}"), + ) + })?; + } + fs::rename(&temporary, &final_path).map_err(|error| { + let _ = fs::remove_file(&temporary); + EffectError::new( + EffectErrorKind::Io, + format!("rename checkpoint failed: {error}"), + ) + })?; + super::tree_store::fsync_dir(&root.join("checkpoints"))?; + Ok(()) +} + +fn reject_yaml_constructs(text: &str) -> Result<(), EffectError> { + let mut parser = Parser::new_from_str(text); + let mut stack: Vec = Vec::new(); + loop { + let (event, _) = parser + .next_token() + .map_err(|error| schema(error.to_string()))?; + match event { + Event::StreamEnd => break, + Event::Alias(_) => return Err(schema("yaml alias is not allowed")), + Event::Scalar(value, _, anchor, tag) => { + if anchor != 0 || tag.is_some() { + return Err(schema("yaml anchor or tag is not allowed")); + } + if let Some(YamlFrame::Mapping { expect_key, seen }) = stack.last_mut() { + if *expect_key { + if value == "<<" { + return Err(schema("yaml merge is not allowed")); + } + if !seen.insert(value) { + return Err(schema("duplicate yaml key before conversion")); + } + } + *expect_key = !*expect_key; + } + } + Event::SequenceStart(anchor, tag) | Event::MappingStart(anchor, tag) + if anchor != 0 || tag.is_some() => + { + return Err(schema("yaml anchor or tag is not allowed")); + } + Event::MappingStart(_, _) => stack.push(YamlFrame::mapping()), + Event::SequenceStart(_, _) => stack.push(YamlFrame::Sequence), + Event::MappingEnd | Event::SequenceEnd => { + stack.pop(); + if let Some(YamlFrame::Mapping { expect_key, .. }) = stack.last_mut() { + *expect_key = !*expect_key; + } + } + _ => {} + } + } + Ok(()) +} + +enum YamlFrame { + Mapping { + expect_key: bool, + seen: HashSet, + }, + Sequence, +} + +impl YamlFrame { + fn mapping() -> Self { + Self::Mapping { + expect_key: true, + seen: HashSet::new(), + } + } +} + +fn schema(message: impl Into) -> EffectError { + EffectError::new(EffectErrorKind::SchemaRejected, message) +} + +fn accept_lineage_document(len: usize) -> Result<(), EffectError> { + if len == 0 || len > MAX_LINEAGE { + Err(schema("lineage length is outside 1..=64")) + } else { + Ok(()) + } +} + +fn not_found(what: &str) -> EffectError { + EffectError::new(EffectErrorKind::NotFound, format!("{what} was not found")) +} + +fn stale() -> EffectError { + EffectError::new(EffectErrorKind::StaleEpoch, "branch epoch changed") +} + +fn revoked() -> EffectError { + EffectError::new(EffectErrorKind::Revoked, "workspace capability was revoked") +} diff --git a/src/effects/mod.rs b/src/effects/mod.rs new file mode 100644 index 0000000..d7c742d --- /dev/null +++ b/src/effects/mod.rs @@ -0,0 +1,22 @@ +//! Native filesystem effect: private views, immutable trees, and checkpoints. +//! +//! This module does not integrate plugins, OCI bundles, or a control plane. + +mod checkpoint; +mod tree_store; +mod types; +mod workspace; + +#[doc(hidden)] +pub use checkpoint::pre_conversion_yaml_error; +pub use checkpoint::{CheckpointRecord, RestoredBranch, parse_checkpoint_yaml}; +pub use types::{ + BranchEpoch, BranchId, CheckpointId, CheckpointRequest, EffectCancellation, EffectDeadline, + EffectError, EffectErrorKind, EffectFailure, ExternalEffectNotice, ImportConsistency, + ImportWorkspace, JournalEntry, JournalKind, MAX_TREE_BYTES, MAX_TREE_DEPTH, MAX_TREE_ENTRIES, + OpenViewRequest, OwnerId, PublicCheckpointRef, PublicWorkspaceRef, Publication, PublishRequest, + ResourceQuota, RestoreRequest, TreeId, WorkspaceId, +}; +#[cfg(feature = "workspace-io-fault")] +pub use workspace::SimulatedIoFault; +pub use workspace::{EffectStore, WorkspaceHandle, WritableView}; diff --git a/src/effects/tree_store.rs b/src/effects/tree_store.rs new file mode 100644 index 0000000..140ade5 --- /dev/null +++ b/src/effects/tree_store.rs @@ -0,0 +1,1227 @@ +//! Private directory copies for Native effect trees. +//! +//! Numbered cancellation checks run once before the copy phase and once before +//! each planned entry, in sorted relative-path order. The scan does not consume +//! those checks, so a caller can cancel or mutate the source at a stable point. + +use std::ffi::{CStr, CString, OsStr, OsString}; +use std::fs; +use std::io; +use std::mem::MaybeUninit; +use std::os::fd::{FromRawFd, OwnedFd}; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::io::AsRawFd; +use std::path::{Path, PathBuf}; +#[cfg(feature = "workspace-io-fault")] +use std::sync::atomic::AtomicI32; +use std::sync::atomic::{AtomicU64, Ordering}; + +use parking_lot::Mutex; + +use super::types::{ + EffectCancellation, EffectDeadline, EffectError, EffectErrorKind, MAX_COMPONENT_BYTES, + MAX_RELATIVE_BYTES, ResourceQuota, +}; + +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct Usage { + pub(crate) entries: u64, + pub(crate) bytes: u64, +} + +#[cfg(feature = "workspace-io-fault")] +pub(crate) const FAULT_PARTIAL_WRITE: u8 = 1; +#[cfg(feature = "workspace-io-fault")] +pub(crate) const FAULT_FILE_FSYNC: u8 = 2; +#[cfg(feature = "workspace-io-fault")] +pub(crate) const FAULT_POST_RENAME_CHMOD: u8 = 3; +#[cfg(feature = "workspace-io-fault")] +pub(crate) const FAULT_POST_RENAME_DIR_FSYNC: u8 = 4; +#[cfg(feature = "workspace-io-fault")] +pub(crate) const FAULT_OWNED_CLEANUP: u8 = 5; + +/// Per-instance one-shot faults. Unarmed `take` is one atomic swap, not a poll. +#[cfg(feature = "workspace-io-fault")] +pub(crate) struct IoFaultSlot { + errno: [AtomicI32; 6], +} + +#[cfg(feature = "workspace-io-fault")] +impl IoFaultSlot { + pub(crate) fn new() -> Self { + Self { + errno: std::array::from_fn(|_| AtomicI32::new(0)), + } + } + + pub(crate) fn arm(&self, point: u8, errno: i32) { + self.errno[point as usize].store(errno, Ordering::Release); + } + + pub(crate) fn take(&self, point: u8) -> Option { + let code = self.errno[point as usize].swap(0, Ordering::AcqRel); + if code == 0 { None } else { Some(code) } + } +} + +#[cfg(feature = "workspace-io-fault")] +pub(crate) fn simulated_io(operation: &str, code: i32) -> EffectError { + let name = match code { + libc::EIO => "EIO", + libc::ENOSPC => "ENOSPC", + _ => "errno", + }; + EffectError::new( + EffectErrorKind::Io, + format!( + "simulated I/O error: {operation} failed with {name} ({code}); not a hardware failure or disk-full event" + ), + ) +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct TreeCopy { + pub(crate) entries: u64, + pub(crate) bytes: u64, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum FileKind { + File, + Dir, + Symlink, + Special, +} + +struct Stamp { + len: u64, + executable: bool, +} + +struct PlanEntry { + rel: String, + components: Vec, + kind: PlanKind, + stamp: Stamp, +} + +enum PlanKind { + Dir, + File, +} + +struct Plan { + entries: Vec, + inodes: std::collections::HashMap<(u64, u64), (u64, u64)>, + entries_count: u64, + bytes: u64, +} + +pub(crate) struct WorkRoot { + path: PathBuf, + dir: OwnedFd, + quota: ResourceQuota, + usage: Mutex, + mutation: Mutex<()>, + scratch_seq: AtomicU64, + #[cfg(feature = "workspace-io-fault")] + io_fault: IoFaultSlot, +} + +impl WorkRoot { + pub(crate) fn open( + path: PathBuf, + quota: ResourceQuota, + usage: Usage, + ) -> Result { + let dir = open_dir(&path)?; + Ok(Self { + path, + dir, + quota, + usage: Mutex::new(usage), + mutation: Mutex::new(()), + scratch_seq: AtomicU64::new(0), + #[cfg(feature = "workspace-io-fault")] + io_fault: IoFaultSlot::new(), + }) + } + + pub(crate) fn path(&self) -> &Path { + &self.path + } + + pub(crate) fn write_file(&self, rel: &str, bytes: &[u8]) -> Result<(), EffectError> { + let _mutation = self.mutation.lock(); + let components = validate_relative(rel)?; + self.ensure_depth(&components)?; + let parent = self.open_parent(&components)?; + let name = components.last().expect("relative path has a component"); + let old = match stat_at(&parent, name) { + Ok(stat) => { + reject_special(&stat, "write")?; + if file_kind(stat.st_mode) != FileKind::File { + return Err(unsupported("write target is not a regular file")); + } + if stat.st_nlink != 1 { + return Err(unsupported("refusing to write through a hardlink")); + } + let mode = stat.st_mode & 0o777; + Some((file_len(stat)?, mode)) + } + Err(error) if error.kind() == EffectErrorKind::NotFound => None, + Err(error) => return Err(error), + }; + let mut usage = self.usage.lock(); + let old_len = old.map(|(len, _)| len); + let mode = old.map(|(_, mode)| mode).unwrap_or(0o644); + let next_entries = usage + .entries + .checked_add(u64::from(old_len.is_none())) + .ok_or_else(stale_usage)?; + let retained_bytes = match old_len { + Some(len) => usage.bytes.checked_sub(len).ok_or_else(stale_usage)?, + None => usage.bytes, + }; + let next_bytes = retained_bytes + .checked_add(bytes.len() as u64) + .ok_or_else(stale_usage)?; + if next_entries > self.quota.max_entries || next_bytes > self.quota.max_bytes { + return Err(quota_error("view write exceeds quota")); + } + let scratch_dir = self.private_scratch_dir()?; + let scratch_name = self.scratch_file_name(); + let fd = create_file(&scratch_dir, &scratch_name, mode)?; + let staged = StagedFile { + dir: scratch_dir, + name: scratch_name, + fd, + }; + let published = (|| { + fchmod_fd(&staged.fd, mode)?; + #[cfg(feature = "workspace-io-fault")] + if let Some(code) = self.io_fault.take(FAULT_PARTIAL_WRITE) { + return inject_partial_write(&staged.fd, bytes, code); + } + write_all(&staged.fd, bytes)?; + #[cfg(feature = "workspace-io-fault")] + if let Some(code) = self.io_fault.take(FAULT_FILE_FSYNC) { + return Err(simulated_io("fsync", code)); + } + fsync_fd(&staged.fd)?; + rename_at(&staged.dir, &staged.name, &parent, name) + })(); + if let Err(error) = published { + return Err(retain_cleanup_failure(error, staged.cleanup_owned())); + } + usage.entries = next_entries; + usage.bytes = next_bytes; + Ok(()) + } + + #[cfg(feature = "workspace-io-fault")] + pub(crate) fn arm_io_fault(&self, point: u8, errno: i32) { + self.io_fault.arm(point, errno); + } + + #[cfg(feature = "workspace-io-fault")] + pub(crate) fn usage_snapshot(&self) -> (u64, u64) { + let usage = self.usage.lock(); + (usage.entries, usage.bytes) + } + + pub(crate) fn read_file(&self, rel: &str) -> Result, EffectError> { + read_from(&self.dir, rel) + } + + pub(crate) fn delete(&self, rel: &str) -> Result<(), EffectError> { + let _mutation = self.mutation.lock(); + let components = validate_relative(rel)?; + let parent = self.open_parent(&components)?; + let name = components.last().expect("relative path has a component"); + let stat = stat_at(&parent, name)?; + reject_special(&stat, "delete")?; + let kind = file_kind(stat.st_mode); + let removed_bytes = match kind { + FileKind::File => file_len(stat)?, + FileKind::Dir => { + if !dir_is_empty(&parent, name)? { + return Err(io_error( + "delete", + io::Error::from_raw_os_error(libc::ENOTEMPTY), + )); + } + 0 + } + FileKind::Symlink | FileKind::Special => { + return Err(unsupported("refusing to delete a symlink or special file")); + } + }; + let mut usage = self.usage.lock(); + let next_entries = usage.entries.checked_sub(1).ok_or_else(stale_usage)?; + let next_bytes = usage + .bytes + .checked_sub(removed_bytes) + .ok_or_else(stale_usage)?; + unlink_at(&parent, name, kind == FileKind::Dir)?; + usage.entries = next_entries; + usage.bytes = next_bytes; + Ok(()) + } + + pub(crate) fn rename(&self, from: &str, to: &str) -> Result<(), EffectError> { + let _mutation = self.mutation.lock(); + let from_parts = validate_relative(from)?; + let to_parts = validate_relative(to)?; + self.ensure_depth(&to_parts)?; + if stat_relative(&self.dir, &to_parts).is_ok() { + return Err(io_error( + "rename", + io::Error::from_raw_os_error(libc::EEXIST), + )); + } + let from_parent = self.open_parent(&from_parts)?; + let to_parent = self.open_parent(&to_parts)?; + let from_name = c_name(from_parts.last().expect("from component"))?; + let to_name = c_name(to_parts.last().expect("to component"))?; + let rc = unsafe { + libc::renameat( + from_parent.as_raw_fd(), + from_name.as_ptr(), + to_parent.as_raw_fd(), + to_name.as_ptr(), + ) + }; + if rc < 0 { + Err(from_errno("rename", errno())) + } else { + Ok(()) + } + } + + pub(crate) fn create_dir(&self, rel: &str) -> Result<(), EffectError> { + let _mutation = self.mutation.lock(); + let components = validate_relative(rel)?; + self.ensure_depth(&components)?; + let parent = self.open_parent(&components)?; + let name = components.last().expect("relative path has a component"); + let mut usage = self.usage.lock(); + let next_entries = usage.entries.checked_add(1).ok_or_else(stale_usage)?; + if next_entries > self.quota.max_entries { + return Err(quota_error("directory exceeds entry quota")); + } + mkdir_at(&parent, name)?; + usage.entries = next_entries; + Ok(()) + } + + pub(crate) fn set_executable(&self, rel: &str, executable: bool) -> Result<(), EffectError> { + let _mutation = self.mutation.lock(); + let components = validate_relative(rel)?; + let parent = self.open_parent(&components)?; + let name = components.last().expect("relative path has a component"); + let stat = stat_at(&parent, name)?; + reject_special(&stat, "chmod")?; + if file_kind(stat.st_mode) != FileKind::File { + return Err(unsupported("executable bit is only for regular files")); + } + let fd = open_read(&parent, name)?; + let mode = if executable { 0o755 } else { 0o644 }; + let rc = unsafe { libc::fchmod(fd.as_raw_fd(), mode) }; + if rc < 0 { + Err(from_errno("fchmod", errno())) + } else { + Ok(()) + } + } + + pub(crate) fn list(&self, rel: &str) -> Result, EffectError> { + let dir = if rel.is_empty() { + dup_dir(&self.dir)? + } else { + open_existing_dir(&self.dir, &validate_relative(rel)?)? + }; + let names = read_names(&dir)?; + let mut listed = Vec::new(); + for name in names { + let text = name + .to_str() + .ok_or_else(|| rejected("directory entry is not utf-8"))? + .to_string(); + listed.push(text); + } + listed.sort(); + Ok(listed) + } + + fn ensure_depth(&self, components: &[String]) -> Result<(), EffectError> { + if components.len() as u32 > self.quota.max_depth { + Err(quota_error("path exceeds depth quota")) + } else { + Ok(()) + } + } + + fn open_parent(&self, components: &[String]) -> Result { + if components.len() == 1 { + return dup_dir(&self.dir); + } + open_existing_dir(&self.dir, &components[..components.len() - 1]) + } + + fn private_scratch_dir(&self) -> Result { + let view_dir = self + .path + .parent() + .ok_or_else(|| rejected("work root has no parent"))?; + let parent = open_dir(view_dir)?; + ensure_private_dir(&parent, ".scratch") + } + + fn scratch_file_name(&self) -> String { + let seq = self.scratch_seq.fetch_add(1, Ordering::Relaxed); + format!(".w-{seq}") + } +} + +pub(crate) fn copy_tree( + source: &Path, + dest: &Path, + quota: &ResourceQuota, + cancellation: &EffectCancellation, + deadline: &EffectDeadline, + seal: bool, +) -> Result { + deadline.check()?; + cancellation.poll()?; + let source_stat = lstat_path(source)?; + if file_kind(source_stat.st_mode) != FileKind::Dir { + return Err(unsupported("import source is not a real directory")); + } + let source_dir = open_dir(source)?; + let mut plan = scan(&source_dir, quota, deadline, cancellation)?; + plan.entries.sort_by(|left, right| left.rel.cmp(&right.rel)); + fs::create_dir(dest).map_err(|error| io_error("create copy", error))?; + let guard = RemoveGuard { + path: dest.to_path_buf(), + armed: true, + }; + let dest_dir = open_dir(dest)?; + materialize(&source_dir, &dest_dir, &plan, cancellation, deadline, seal)?; + verify_unchanged(&source_dir, &dest_dir, &plan)?; + fsync_fd(&dest_dir)?; + guard.keep(); + Ok(TreeCopy { + entries: plan.entries_count, + bytes: plan.bytes, + }) +} + +pub(crate) fn read_from(root: &OwnedFd, rel: &str) -> Result, EffectError> { + let components = validate_relative(rel)?; + let parent = if components.len() == 1 { + dup_dir(root)? + } else { + open_existing_dir(root, &components[..components.len() - 1])? + }; + let name = components.last().expect("relative path has a component"); + let stat = stat_at(&parent, name)?; + reject_special(&stat, "read")?; + if file_kind(stat.st_mode) != FileKind::File { + return Err(unsupported("read target is not a regular file")); + } + let len = file_len(stat)?; + if len > super::types::MAX_TREE_BYTES { + return Err(quota_error("file exceeds the hard byte ceiling")); + } + let fd = open_read(&parent, name)?; + read_fd(&fd, len as usize) +} + +pub(crate) fn remove_tree(path: &Path) -> Result<(), EffectError> { + match lstat_path(path) { + Err(error) if error.kind() == EffectErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + Ok(stat) => match file_kind(stat.st_mode) { + FileKind::Dir => { + let dir = open_dir(path)?; + remove_children(&dir)?; + fs::remove_dir(path).map_err(|error| io_error("remove directory", error)) + } + _ => fs::remove_file(path).map_err(|error| io_error("remove path", error)), + }, + } +} + +pub(crate) fn remove_owned_tree(path: &Path) -> Result<(), EffectError> { + match lstat_path(path) { + Err(error) if error.kind() == EffectErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + Ok(stat) => { + if file_kind(stat.st_mode) != FileKind::Dir { + return Err(rejected("owned cleanup target is not a directory")); + } + let dir = open_dir(path)?; + chmod_tree_writable(&dir)?; + remove_children(&dir)?; + fs::remove_dir(path).map_err(|error| io_error("remove owned tree", error)) + } + } +} + +pub(crate) fn fsync_dir(path: &Path) -> Result<(), EffectError> { + let dir = open_dir(path)?; + fsync_fd(&dir) +} + +pub(crate) fn chmod_dir(path: &Path, mode: u32) -> Result<(), EffectError> { + let dir = open_dir(path)?; + let rc = unsafe { libc::fchmod(dir.as_raw_fd(), mode) }; + if rc < 0 { + Err(from_errno("fchmod", errno())) + } else { + Ok(()) + } +} + +fn component_rejected(component: &str) -> bool { + // NUL is a byte below 0x20, the same component rule validate_relative already uses. + component.is_empty() + || component == "." + || component == ".." + || component.ends_with('.') + || component.len() > MAX_COMPONENT_BYTES + || component.as_bytes().iter().any(|byte| *byte < 0x20) +} + +pub(crate) fn validate_relative(path: &str) -> Result, EffectError> { + if path.is_empty() + || path.len() > MAX_RELATIVE_BYTES + || path.starts_with('/') + || path.as_bytes().contains(&b'\\') + || path.as_bytes().contains(&0) + { + return Err(rejected( + "path is absolute, empty, or contains a forbidden byte", + )); + } + let mut components = Vec::new(); + for component in path.split('/') { + if component_rejected(component) { + return Err(rejected("path component is not allowed")); + } + components.push(component.to_string()); + } + Ok(components) +} + +struct RemoveGuard { + path: PathBuf, + armed: bool, +} + +impl RemoveGuard { + fn keep(mut self) { + self.armed = false; + } +} + +impl Drop for RemoveGuard { + fn drop(&mut self) { + if self.armed { + let _ = remove_tree(&self.path); + } + } +} + +fn scan( + root: &OwnedFd, + quota: &ResourceQuota, + deadline: &EffectDeadline, + cancellation: &EffectCancellation, +) -> Result { + let mut plan = Plan { + entries: Vec::new(), + inodes: std::collections::HashMap::new(), + entries_count: 0, + bytes: 0, + }; + scan_dir(root, "", 0, quota, deadline, cancellation, &mut plan)?; + for ((_, _), (nlink, seen)) in &plan.inodes { + if *nlink > *seen { + return Err(unsupported("external hardlink is not importable")); + } + if *seen > *nlink { + return Err(inconsistent("hardlink count changed during scan")); + } + } + Ok(plan) +} + +fn scan_dir( + dir: &OwnedFd, + prefix: &str, + depth: u32, + quota: &ResourceQuota, + deadline: &EffectDeadline, + cancellation: &EffectCancellation, + plan: &mut Plan, +) -> Result<(), EffectError> { + deadline.check()?; + cancellation.poll_quiet()?; + for name in read_names(dir)? { + let text = name + .to_str() + .ok_or_else(|| rejected("directory entry is not utf-8"))?; + if component_rejected(text) { + return Err(rejected("directory entry name is not allowed")); + } + let rel = if prefix.is_empty() { + text.to_string() + } else { + format!("{prefix}/{text}") + }; + if rel.len() > MAX_RELATIVE_BYTES { + return Err(rejected("relative path is too long")); + } + let next_depth = depth + 1; + if next_depth > quota.max_depth { + return Err(quota_error("tree exceeds depth quota")); + } + let stat = stat_at(dir, text)?; + let mut components = Vec::new(); + if !prefix.is_empty() { + components.extend(prefix.split('/').map(OsString::from)); + } + components.push(name.clone()); + match file_kind(stat.st_mode) { + FileKind::Symlink => return Err(unsupported("symlink is not importable")), + FileKind::Special => return Err(unsupported("special file is not importable")), + FileKind::Dir => { + account_entry(plan, quota, 0)?; + plan.entries.push(PlanEntry { + rel: rel.clone(), + components, + kind: PlanKind::Dir, + stamp: Stamp { + len: 0, + executable: true, + }, + }); + let child = open_child_dir(dir, text)?; + scan_dir( + &child, + &rel, + next_depth, + quota, + deadline, + cancellation, + plan, + )?; + } + FileKind::File => { + let len = file_len(stat)?; + account_entry(plan, quota, len)?; + let key = (stat_u64(stat.st_dev), stat_u64(stat.st_ino)); + let slot = plan + .inodes + .entry(key) + .or_insert((stat_u64(stat.st_nlink), 0)); + slot.1 += 1; + plan.entries.push(PlanEntry { + rel, + components, + kind: PlanKind::File, + stamp: Stamp { + len, + executable: stat.st_mode & 0o111 != 0, + }, + }); + } + } + } + Ok(()) +} + +fn account_entry(plan: &mut Plan, quota: &ResourceQuota, bytes: u64) -> Result<(), EffectError> { + plan.entries_count += 1; + plan.bytes += bytes; + if plan.entries_count > quota.max_entries || plan.bytes > quota.max_bytes { + Err(quota_error("tree exceeds quota")) + } else { + Ok(()) + } +} + +fn materialize( + source: &OwnedFd, + dest: &OwnedFd, + plan: &Plan, + cancellation: &EffectCancellation, + deadline: &EffectDeadline, + seal: bool, +) -> Result<(), EffectError> { + for entry in &plan.entries { + deadline.check()?; + cancellation.poll()?; + match entry.kind { + PlanKind::Dir => { + let parent = parent_fd(dest, &entry.components)?; + let name = entry + .components + .last() + .and_then(|component| component.to_str()) + .expect("utf-8 directory name"); + mkdir_at(&parent, name)?; + } + PlanKind::File => copy_planned_file(source, dest, entry, seal)?, + } + } + if seal { + seal_directories(dest, plan)?; + } + Ok(()) +} + +fn copy_planned_file( + source: &OwnedFd, + dest: &OwnedFd, + entry: &PlanEntry, + seal: bool, +) -> Result<(), EffectError> { + let source_parent = parent_fd(source, &entry.components)?; + let dest_parent = parent_fd(dest, &entry.components)?; + let name = entry + .components + .last() + .expect("file component") + .to_str() + .expect("utf-8 file name"); + let stat = stat_at(&source_parent, name)?; + if file_kind(stat.st_mode) != FileKind::File || file_len(stat)? != entry.stamp.len { + return Err(inconsistent("source file changed before copy")); + } + let readable = open_read(&source_parent, name)?; + let bytes = read_fd(&readable, entry.stamp.len as usize)?; + let mode = if seal { + if entry.stamp.executable { 0o555 } else { 0o444 } + } else if entry.stamp.executable { + 0o755 + } else { + 0o644 + }; + let created = create_file(&dest_parent, name, mode)?; + write_all(&created, &bytes)?; + fchmod_fd(&created, mode)?; + fsync_fd(&created)?; + Ok(()) +} + +fn seal_directories(dest: &OwnedFd, plan: &Plan) -> Result<(), EffectError> { + for entry in &plan.entries { + if !matches!(entry.kind, PlanKind::Dir) { + continue; + } + let parent = parent_fd(dest, &entry.components)?; + let name = entry + .components + .last() + .and_then(|component| component.to_str()) + .ok_or_else(|| inconsistent("directory name disappeared"))?; + let created = open_child_dir(&parent, name)?; + fchmod_fd(&created, 0o555)?; + } + Ok(()) +} + +fn verify_unchanged(source: &OwnedFd, dest: &OwnedFd, plan: &Plan) -> Result<(), EffectError> { + for entry in &plan.entries { + if !matches!(entry.kind, PlanKind::File) { + continue; + } + let name = entry + .components + .last() + .and_then(|name| name.to_str()) + .ok_or_else(|| inconsistent("planned name disappeared"))?; + let source_parent = parent_fd(source, &entry.components)?; + let dest_parent = parent_fd(dest, &entry.components)?; + let source_bytes = read_named(&source_parent, name)?; + let dest_bytes = read_named(&dest_parent, name)?; + if source_bytes != dest_bytes { + return Err(inconsistent("source changed during copy")); + } + } + Ok(()) +} + +fn read_named(parent: &OwnedFd, name: &str) -> Result, EffectError> { + let stat = stat_at(parent, name).map_err(|_| inconsistent("source entry disappeared"))?; + if file_kind(stat.st_mode) != FileKind::File { + return Err(inconsistent("source entry changed type")); + } + let fd = open_read(parent, name)?; + read_fd(&fd, file_len(stat)? as usize) +} + +fn parent_fd(root: &OwnedFd, components: &[OsString]) -> Result { + if components.len() <= 1 { + return dup_dir(root); + } + let mut current = dup_dir(root)?; + for component in &components[..components.len() - 1] { + let name = component + .to_str() + .ok_or_else(|| rejected("component is not utf-8"))?; + current = open_child_dir(¤t, name)?; + } + Ok(current) +} + +fn open_existing_dir(root: &OwnedFd, components: &[String]) -> Result { + let mut current = dup_dir(root)?; + for component in components { + current = open_child_dir(¤t, component)?; + } + Ok(current) +} + +fn stat_relative(root: &OwnedFd, components: &[String]) -> Result { + let parent = if components.len() == 1 { + dup_dir(root)? + } else { + open_existing_dir(root, &components[..components.len() - 1])? + }; + stat_at(&parent, components.last().expect("component")) +} + +fn chmod_tree_writable(dir: &OwnedFd) -> Result<(), EffectError> { + fchmod_fd(dir, 0o700)?; + for name in read_names(dir)? { + let text = name + .to_str() + .ok_or_else(|| rejected("directory entry is not utf-8"))?; + let stat = stat_at(dir, text)?; + if file_kind(stat.st_mode) == FileKind::Dir { + let child = open_child_dir(dir, text)?; + chmod_tree_writable(&child)?; + } + } + Ok(()) +} + +fn remove_children(dir: &OwnedFd) -> Result<(), EffectError> { + for name in read_names(dir)? { + let text = name + .to_str() + .ok_or_else(|| rejected("directory entry is not utf-8"))?; + let stat = stat_at(dir, text)?; + match file_kind(stat.st_mode) { + FileKind::Dir => { + let child = open_child_dir(dir, text)?; + remove_children(&child)?; + unlink_at(dir, text, true)?; + } + _ => unlink_at(dir, text, false)?, + } + } + Ok(()) +} + +fn dir_is_empty(parent: &OwnedFd, name: &str) -> Result { + let child = open_child_dir(parent, name)?; + Ok(read_names(&child)?.is_empty()) +} + +fn read_names(dir: &OwnedFd) -> Result, EffectError> { + let dup_fd = unsafe { libc::dup(dir.as_raw_fd()) }; + if dup_fd < 0 { + return Err(from_errno("dup", errno())); + } + if unsafe { libc::lseek(dup_fd, 0, libc::SEEK_SET) } < 0 { + let code = errno(); + unsafe { libc::close(dup_fd) }; + return Err(from_errno("lseek", code)); + } + let stream = unsafe { libc::fdopendir(dup_fd) }; + if stream.is_null() { + let code = errno(); + unsafe { libc::close(dup_fd) }; + return Err(from_errno("fdopendir", code)); + } + let mut names = Vec::new(); + loop { + clear_errno(); + let entry = unsafe { libc::readdir(stream) }; + if entry.is_null() { + let code = errno(); + unsafe { libc::closedir(stream) }; + if code != 0 { + return Err(from_errno("readdir", code)); + } + break; + } + let name = unsafe { CStr::from_ptr((*entry).d_name.as_ptr()) }; + let bytes = name.to_bytes(); + if bytes == b"." || bytes == b".." { + continue; + } + names.push(OsStr::from_bytes(bytes).to_os_string()); + } + names.sort(); + Ok(names) +} + +fn open_dir(path: &Path) -> Result { + let c_path = CString::new(path.as_os_str().as_bytes()) + .map_err(|_| rejected("path contains an interior NUL"))?; + let fd = unsafe { + libc::open( + c_path.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW, + ) + }; + owned_fd(fd, "open directory") +} + +fn dup_dir(dir: &OwnedFd) -> Result { + let fd = unsafe { libc::dup(dir.as_raw_fd()) }; + owned_fd(fd, "dup") +} + +fn open_child_dir(parent: &OwnedFd, name: &str) -> Result { + let c_name = c_name(name)?; + let fd = unsafe { + libc::openat( + parent.as_raw_fd(), + c_name.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW, + ) + }; + owned_fd(fd, "open child directory") +} + +fn open_read(parent: &OwnedFd, name: &str) -> Result { + let c_name = c_name(name)?; + let fd = unsafe { + libc::openat( + parent.as_raw_fd(), + c_name.as_ptr(), + libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW, + ) + }; + owned_fd(fd, "open file") +} + +fn create_file(parent: &OwnedFd, name: &str, mode: u32) -> Result { + let c_name = c_name(name)?; + let fd = unsafe { + libc::openat( + parent.as_raw_fd(), + c_name.as_ptr(), + libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW, + mode, + ) + }; + owned_fd(fd, "create file") +} + +fn mkdir_at(parent: &OwnedFd, name: &str) -> Result<(), EffectError> { + let c_name = c_name(name)?; + let rc = unsafe { libc::mkdirat(parent.as_raw_fd(), c_name.as_ptr(), 0o755) }; + if rc < 0 { + Err(from_errno("mkdir", errno())) + } else { + Ok(()) + } +} + +fn unlink_at(parent: &OwnedFd, name: &str, directory: bool) -> Result<(), EffectError> { + let c_name = c_name(name)?; + let flags = if directory { libc::AT_REMOVEDIR } else { 0 }; + let rc = unsafe { libc::unlinkat(parent.as_raw_fd(), c_name.as_ptr(), flags) }; + if rc < 0 { + Err(from_errno("unlink", errno())) + } else { + Ok(()) + } +} + +fn stat_at(parent: &OwnedFd, name: &str) -> Result { + let c_name = c_name(name)?; + let mut stat = MaybeUninit::::uninit(); + let rc = unsafe { + libc::fstatat( + parent.as_raw_fd(), + c_name.as_ptr(), + stat.as_mut_ptr(), + libc::AT_SYMLINK_NOFOLLOW, + ) + }; + if rc < 0 { + Err(from_errno("fstatat", errno())) + } else { + Ok(unsafe { stat.assume_init() }) + } +} + +fn lstat_path(path: &Path) -> Result { + let c_path = CString::new(path.as_os_str().as_bytes()) + .map_err(|_| rejected("path contains an interior NUL"))?; + let mut stat = MaybeUninit::::uninit(); + let rc = unsafe { libc::lstat(c_path.as_ptr(), stat.as_mut_ptr()) }; + if rc < 0 { + Err(from_errno("lstat", errno())) + } else { + Ok(unsafe { stat.assume_init() }) + } +} + +#[cfg(feature = "workspace-io-fault")] +fn inject_partial_write(fd: &OwnedFd, bytes: &[u8], code: i32) -> Result<(), EffectError> { + if !bytes.is_empty() { + let wrote = + unsafe { libc::write(fd.as_raw_fd(), bytes.as_ptr().cast::(), 1) }; + if wrote < 0 { + return Err(from_errno("write", errno())); + } + } + Err(simulated_io("write", code)) +} + +fn write_all(fd: &OwnedFd, bytes: &[u8]) -> Result<(), EffectError> { + let mut offset = 0; + while offset < bytes.len() { + let wrote = unsafe { + libc::write( + fd.as_raw_fd(), + bytes[offset..].as_ptr().cast::(), + bytes.len() - offset, + ) + }; + if wrote < 0 { + let code = errno(); + if code == libc::EINTR { + continue; + } + return Err(from_errno("write", code)); + } + offset += wrote as usize; + } + Ok(()) +} + +fn read_fd(fd: &OwnedFd, len: usize) -> Result, EffectError> { + let mut bytes = vec![0_u8; len]; + let mut offset = 0; + while offset < len { + let read = unsafe { + libc::read( + fd.as_raw_fd(), + bytes[offset..].as_mut_ptr().cast::(), + len - offset, + ) + }; + if read < 0 { + let code = errno(); + if code == libc::EINTR { + continue; + } + return Err(from_errno("read", code)); + } + if read == 0 { + break; + } + offset += read as usize; + } + bytes.truncate(offset); + Ok(bytes) +} + +fn fsync_fd(fd: &OwnedFd) -> Result<(), EffectError> { + let rc = unsafe { libc::fsync(fd.as_raw_fd()) }; + if rc < 0 { + Err(from_errno("fsync", errno())) + } else { + Ok(()) + } +} + +fn fchmod_fd(fd: &OwnedFd, mode: u32) -> Result<(), EffectError> { + let rc = unsafe { libc::fchmod(fd.as_raw_fd(), mode) }; + if rc < 0 { + Err(from_errno("fchmod", errno())) + } else { + Ok(()) + } +} + +struct StagedFile { + dir: OwnedFd, + name: String, + fd: OwnedFd, +} + +impl StagedFile { + fn cleanup_owned(&self) -> Result<(), EffectError> { + let live = fstat_fd(&self.fd)?; + let at = match stat_at(&self.dir, &self.name) { + Ok(stat) => stat, + Err(error) if error.kind() == EffectErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error), + }; + if live.st_dev != at.st_dev || live.st_ino != at.st_ino || live.st_nlink != 1 { + return Err(rejected( + "refusing to remove a scratch file that is not exclusively owned", + )); + } + unlink_at(&self.dir, &self.name, false) + } +} + +fn fstat_fd(fd: &OwnedFd) -> Result { + let mut stat = MaybeUninit::::uninit(); + let rc = unsafe { libc::fstat(fd.as_raw_fd(), stat.as_mut_ptr()) }; + if rc < 0 { + Err(from_errno("fstat", errno())) + } else { + Ok(unsafe { stat.assume_init() }) + } +} + +fn ensure_private_dir(parent: &OwnedFd, name: &str) -> Result { + let c_name = c_name(name)?; + let rc = unsafe { libc::mkdirat(parent.as_raw_fd(), c_name.as_ptr(), 0o700) }; + if rc < 0 { + let code = errno(); + if code != libc::EEXIST { + return Err(from_errno("mkdir", code)); + } + } + let fd = unsafe { + libc::openat( + parent.as_raw_fd(), + c_name.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + ) + }; + owned_fd(fd, "open scratch") +} + +fn rename_at( + from_dir: &OwnedFd, + from: &str, + to_dir: &OwnedFd, + to: &str, +) -> Result<(), EffectError> { + let from_c = c_name(from)?; + let to_c = c_name(to)?; + let rc = unsafe { + libc::renameat( + from_dir.as_raw_fd(), + from_c.as_ptr(), + to_dir.as_raw_fd(), + to_c.as_ptr(), + ) + }; + if rc < 0 { + Err(from_errno("rename", errno())) + } else { + Ok(()) + } +} + +fn retain_cleanup_failure(error: EffectError, cleanup: Result<(), EffectError>) -> EffectError { + match cleanup { + Ok(()) => error, + Err(cleanup) => EffectError::new( + error.kind(), + format!("{error}; scratch cleanup failed: {cleanup}"), + ), + } +} + +fn owned_fd(fd: i32, operation: &str) -> Result { + if fd < 0 { + Err(from_errno(operation, errno())) + } else { + Ok(unsafe { OwnedFd::from_raw_fd(fd) }) + } +} + +fn c_name(name: &str) -> Result { + CString::new(name.as_bytes()).map_err(|_| rejected("component contains NUL")) +} + +fn file_kind(mode: libc::mode_t) -> FileKind { + match mode & libc::S_IFMT { + libc::S_IFREG => FileKind::File, + libc::S_IFDIR => FileKind::Dir, + libc::S_IFLNK => FileKind::Symlink, + _ => FileKind::Special, + } +} + +fn reject_special(stat: &libc::stat, operation: &str) -> Result<(), EffectError> { + match file_kind(stat.st_mode) { + FileKind::Symlink => Err(unsupported(format!("{operation} target is a symlink"))), + FileKind::Special => Err(unsupported(format!("{operation} target is a special file"))), + FileKind::File | FileKind::Dir => Ok(()), + } +} + +fn file_len(stat: libc::stat) -> Result { + if stat.st_size < 0 { + Err(unsupported("file size is negative")) + } else { + Ok(stat.st_size as u64) + } +} + +fn stat_u64(value: u64) -> u64 { + value +} + +fn clear_errno() { + unsafe { + *libc::__errno_location() = 0; + } +} + +fn errno() -> i32 { + unsafe { *libc::__errno_location() } +} + +fn from_errno(operation: &str, code: i32) -> EffectError { + let kind = match code { + libc::ENOENT => EffectErrorKind::NotFound, + libc::ELOOP => EffectErrorKind::UnsupportedEntry, + libc::ENAMETOOLONG => EffectErrorKind::RejectedPath, + _ => EffectErrorKind::Io, + }; + EffectError::new(kind, format!("{operation} failed with errno {code}")) +} + +fn io_error(operation: &str, error: io::Error) -> EffectError { + from_errno(operation, error.raw_os_error().unwrap_or(0)) +} + +fn rejected(message: impl Into) -> EffectError { + EffectError::new(EffectErrorKind::RejectedPath, message) +} + +fn unsupported(message: impl Into) -> EffectError { + EffectError::new(EffectErrorKind::UnsupportedEntry, message) +} + +fn quota_error(message: impl Into) -> EffectError { + EffectError::new(EffectErrorKind::QuotaExceeded, message) +} + +fn inconsistent(message: impl Into) -> EffectError { + EffectError::new(EffectErrorKind::InconsistentSource, message) +} + +fn stale_usage() -> EffectError { + inconsistent("accounted usage does not match the destination") +} diff --git a/src/effects/types.rs b/src/effects/types.rs new file mode 100644 index 0000000..4c82307 --- /dev/null +++ b/src/effects/types.rs @@ -0,0 +1,444 @@ +//! Typed values for the Native filesystem effect. +//! +//! These types carry owner capability, epoch, and quota. They do not open a +//! database, talk to a plugin host, or snapshot remote effects. + +use std::fmt; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::time::Instant; + +use parking_lot::Mutex; +use uuid::Uuid; +use zeroize::Zeroize; + +pub const MAX_TREE_ENTRIES: u64 = 1_000_000; +pub const MAX_TREE_BYTES: u64 = 64 * 1024 * 1024; +pub const MAX_TREE_DEPTH: u32 = 32; +pub(crate) const MAX_COMPONENT_BYTES: usize = 255; +pub(crate) const MAX_RELATIVE_BYTES: usize = 4096; +pub(crate) const MAX_LINEAGE: usize = 64; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum EffectErrorKind { + AuthorityRequired, + OwnerMismatch, + Revoked, + StaleEpoch, + Conflict, + Busy, + CheckpointBusy, + NotFound, + RejectedPath, + UnsupportedEntry, + UnsupportedPlatform, + QuotaExceeded, + InvalidConfiguration, + InconsistentSource, + Cancelled, + DeadlineExceeded, + SchemaRejected, + Io, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct EffectError { + kind: EffectErrorKind, + message: String, +} + +impl EffectError { + pub fn kind(&self) -> EffectErrorKind { + self.kind + } + + pub(crate) fn new(kind: EffectErrorKind, message: impl Into) -> Self { + Self { + kind, + message: message.into(), + } + } +} + +impl fmt::Display for EffectError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{:?}: {}", self.kind, self.message) + } +} + +impl std::error::Error for EffectError {} + +#[derive(Debug)] +pub struct EffectFailure { + pub error: EffectError, + pub recovered: T, +} + +impl EffectFailure { + pub(crate) fn new(error: EffectError, recovered: T) -> Self { + Self { error, recovered } + } +} + +macro_rules! id_newtype { + ($name:ident, $prefix:literal) => { + #[derive(Clone, Debug, Eq, PartialEq, Hash)] + pub struct $name(String); + + impl $name { + pub(crate) fn fresh() -> Self { + Self(format!("{}-{}", $prefix, Uuid::new_v4().simple())) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub(crate) fn parse(value: &str) -> Result { + parse_id($prefix, value).map(Self) + } + } + + impl fmt::Display for $name { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } + } + }; +} + +id_newtype!(WorkspaceId, "ws"); +id_newtype!(OwnerId, "own"); +id_newtype!(BranchId, "br"); +id_newtype!(TreeId, "tree"); +id_newtype!(CheckpointId, "ckpt"); + +impl WorkspaceId { + pub fn parse_public(value: &str) -> Result { + Self::parse(value) + } +} + +fn parse_id(prefix: &str, value: &str) -> Result { + let Some(rest) = value.strip_prefix(prefix) else { + return Err(EffectError::new( + EffectErrorKind::SchemaRejected, + "identifier prefix is not valid", + )); + }; + let Some(hex) = rest.strip_prefix('-') else { + return Err(EffectError::new( + EffectErrorKind::SchemaRejected, + "identifier is missing its separator", + )); + }; + if hex.len() == 32 + && hex + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + { + Ok(value.to_string()) + } else { + Err(EffectError::new( + EffectErrorKind::SchemaRejected, + "identifier is not 32 lowercase hex characters", + )) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd)] +pub struct BranchEpoch(u64); + +impl BranchEpoch { + pub fn get(self) -> u64 { + self.0 + } + + pub(crate) fn initial() -> Self { + Self(1) + } + + pub(crate) fn from_raw(value: u64) -> Result { + if value == 0 { + Err(EffectError::new( + EffectErrorKind::SchemaRejected, + "epoch must be at least 1", + )) + } else { + Ok(Self(value)) + } + } + + pub(crate) fn next(self) -> Self { + Self(self.0.saturating_add(1)) + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ImportConsistency { + OrdinaryCopy, +} + +impl ImportConsistency { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::OrdinaryCopy => "ordinary_copy", + } + } + + pub(crate) fn parse(value: &str) -> Result { + match value { + "ordinary_copy" => Ok(Self::OrdinaryCopy), + _ => Err(EffectError::new( + EffectErrorKind::SchemaRejected, + "import consistency is not ordinary_copy", + )), + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ResourceQuota { + pub(crate) max_entries: u64, + pub(crate) max_bytes: u64, + pub(crate) max_depth: u32, +} + +impl ResourceQuota { + pub fn new(max_entries: u64, max_bytes: u64, max_depth: u32) -> Result { + if max_entries == 0 + || max_entries > MAX_TREE_ENTRIES + || max_bytes == 0 + || max_bytes > MAX_TREE_BYTES + || max_depth == 0 + || max_depth > MAX_TREE_DEPTH + { + return Err(EffectError::new( + EffectErrorKind::InvalidConfiguration, + "quota is zero or above the hard ceiling", + )); + } + Ok(Self { + max_entries, + max_bytes, + max_depth, + }) + } +} + +#[derive(Clone, Copy, Debug)] +pub struct EffectDeadline { + at: Instant, +} + +impl EffectDeadline { + pub fn at(absolute: Instant) -> Self { + Self { at: absolute } + } + + pub(crate) fn check(self) -> Result<(), EffectError> { + if Instant::now() >= self.at { + Err(EffectError::new( + EffectErrorKind::DeadlineExceeded, + "deadline already passed", + )) + } else { + Ok(()) + } + } +} + +type CheckHook = Arc; + +#[derive(Clone)] +pub struct EffectCancellation { + inner: Arc, +} + +struct CancelInner { + flag: AtomicBool, + cancel_at: AtomicU64, + checks: AtomicU64, + hook: Mutex>, +} + +impl fmt::Debug for EffectCancellation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("EffectCancellation") + .field("cancelled", &self.inner.flag.load(Ordering::SeqCst)) + .finish_non_exhaustive() + } +} + +impl EffectCancellation { + pub fn new() -> Self { + Self { + inner: Arc::new(CancelInner { + flag: AtomicBool::new(false), + cancel_at: AtomicU64::new(u64::MAX), + checks: AtomicU64::new(0), + hook: Mutex::new(None), + }), + } + } + + pub fn cancel(&self) { + self.inner.flag.store(true, Ordering::SeqCst); + } + + pub fn cancel_at_check(&self, check: u64) { + self.inner.cancel_at.store(check, Ordering::SeqCst); + } + + pub fn set_before_check_hook(&self, hook: impl Fn(u64) + Send + Sync + 'static) { + *self.inner.hook.lock() = Some(Arc::new(hook)); + } + + /// Numbered check used at copy boundaries. Scan does not consume these. + pub(crate) fn poll(&self) -> Result<(), EffectError> { + let check = self.inner.checks.fetch_add(1, Ordering::SeqCst) + 1; + let cancel_at = self.inner.cancel_at.load(Ordering::SeqCst); + if check >= cancel_at { + self.inner.flag.store(true, Ordering::SeqCst); + } + let hook = self.inner.hook.lock().clone(); + if let Some(hook) = hook { + hook(check); + } + self.poll_quiet() + } + + pub(crate) fn poll_quiet(&self) -> Result<(), EffectError> { + if self.inner.flag.load(Ordering::SeqCst) { + Err(EffectError::new( + EffectErrorKind::Cancelled, + "cancellation requested", + )) + } else { + Ok(()) + } + } +} + +impl Default for EffectCancellation { + fn default() -> Self { + Self::new() + } +} + +#[derive(Clone)] +pub(crate) struct CapabilitySecret([u8; 32]); + +impl CapabilitySecret { + pub(crate) fn fresh() -> Self { + let mut bytes = [0_u8; 32]; + bytes[..16].copy_from_slice(Uuid::new_v4().as_bytes()); + bytes[16..].copy_from_slice(Uuid::new_v4().as_bytes()); + Self(bytes) + } +} + +impl PartialEq for CapabilitySecret { + fn eq(&self, other: &Self) -> bool { + self.0 == other.0 + } +} + +impl Eq for CapabilitySecret {} + +impl fmt::Debug for CapabilitySecret { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("CapabilitySecret([redacted])") + } +} + +impl Drop for CapabilitySecret { + fn drop(&mut self) { + self.0.zeroize(); + } +} + +#[derive(Clone, Debug)] +pub struct ImportWorkspace { + pub source: std::path::PathBuf, + pub quota: ResourceQuota, + pub cancellation: EffectCancellation, + pub deadline: EffectDeadline, +} + +#[derive(Clone, Debug)] +pub struct OpenViewRequest { + pub expected_head: TreeId, + pub expected_epoch: BranchEpoch, + pub cancellation: EffectCancellation, + pub deadline: EffectDeadline, +} + +#[derive(Clone, Debug)] +pub struct PublishRequest { + pub expected_head: TreeId, + pub expected_epoch: BranchEpoch, + pub cancellation: EffectCancellation, + pub deadline: EffectDeadline, +} + +#[derive(Clone, Debug)] +pub struct CheckpointRequest { + pub cancellation: EffectCancellation, + pub deadline: EffectDeadline, +} + +#[derive(Clone, Debug)] +pub struct RestoreRequest { + pub checkpoint_id: CheckpointId, + pub expected_head: TreeId, + pub expected_epoch: BranchEpoch, + pub cancellation: EffectCancellation, + pub deadline: EffectDeadline, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PublicWorkspaceRef { + pub workspace_id: WorkspaceId, + pub branch_id: BranchId, + pub head: TreeId, + pub epoch: BranchEpoch, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PublicCheckpointRef { + pub workspace_id: WorkspaceId, + pub checkpoint_id: CheckpointId, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Publication { + pub head: TreeId, + pub epoch: BranchEpoch, + pub previous_head: TreeId, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum JournalKind { + Imported, + Published, + Discarded, + Checkpoint, + Restored, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct JournalEntry { + pub kind: JournalKind, + pub branch_id: BranchId, + pub head: TreeId, + pub epoch: BranchEpoch, +} + +#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ExternalEffectNotice { + kind: String, + reference: String, +} diff --git a/src/effects/workspace.rs b/src/effects/workspace.rs new file mode 100644 index 0000000..829a18c --- /dev/null +++ b/src/effects/workspace.rs @@ -0,0 +1,946 @@ +//! Native workspace views, immutable roots, and compare-and-swap publish. + +use std::collections::{HashMap, HashSet}; +use std::fmt; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use parking_lot::Mutex; + +use super::tree_store::{self, Usage, WorkRoot}; +#[cfg(feature = "workspace-io-fault")] +use super::tree_store::{ + FAULT_FILE_FSYNC, FAULT_OWNED_CLEANUP, FAULT_PARTIAL_WRITE, FAULT_POST_RENAME_CHMOD, + FAULT_POST_RENAME_DIR_FSYNC, IoFaultSlot, +}; +use super::types::{ + BranchEpoch, BranchId, CapabilitySecret, EffectError, EffectErrorKind, EffectFailure, + ImportConsistency, ImportWorkspace, JournalEntry, JournalKind, OpenViewRequest, OwnerId, + PublicWorkspaceRef, Publication, PublishRequest, ResourceQuota, TreeId, WorkspaceId, +}; + +pub(crate) struct StoreInner { + pub(crate) root: PathBuf, + pub(crate) state: Mutex, + #[cfg(feature = "workspace-io-fault")] + io_fault: IoFaultSlot, +} + +pub(crate) struct StoreState { + pub(crate) workspaces: HashMap, +} + +pub(crate) struct WorkspaceRecord { + pub(crate) secret: CapabilitySecret, + pub(crate) owner_id: OwnerId, + pub(crate) quota: ResourceQuota, + pub(crate) import_consistency: ImportConsistency, + pub(crate) branches: HashMap, + pub(crate) lineage: Vec, + pub(crate) trees: HashSet, + pub(crate) checkpoints: + HashMap, + pub(crate) journal: Vec, + pub(crate) revoked: bool, +} + +pub(crate) struct BranchRecord { + pub(crate) epoch: BranchEpoch, + pub(crate) head: TreeId, + pub(crate) writers: HashSet, +} + +#[derive(Clone)] +pub struct EffectStore { + inner: Arc, +} + +impl fmt::Debug for EffectStore { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("EffectStore") + .field("root", &self.inner.root) + .finish_non_exhaustive() + } +} + +impl EffectStore { + pub fn create(root: impl AsRef) -> Result { + let root = root.as_ref().to_path_buf(); + fs::create_dir(&root).map_err(|error| { + EffectError::new(EffectErrorKind::Io, format!("create store failed: {error}")) + })?; + for name in ["trees", "views", "checkpoints", "staging"] { + let path = root.join(name); + fs::create_dir(&path).map_err(|error| { + EffectError::new( + EffectErrorKind::Io, + format!("create {name} failed: {error}"), + ) + })?; + tree_store::chmod_dir(&path, 0o700)?; + } + tree_store::chmod_dir(&root, 0o700)?; + Ok(Self { + inner: Arc::new(StoreInner { + root, + state: Mutex::new(StoreState { + workspaces: HashMap::new(), + }), + #[cfg(feature = "workspace-io-fault")] + io_fault: IoFaultSlot::new(), + }), + }) + } + + pub fn root(&self) -> &Path { + &self.inner.root + } + + pub fn import_workspace( + &self, + request: ImportWorkspace, + ) -> Result { + request.deadline.check()?; + let tree_id = TreeId::fresh(); + let staging = self.staging_path(&tree_id); + let copied = tree_store::copy_tree( + &request.source, + &staging, + &request.quota, + &request.cancellation, + &request.deadline, + true, + )?; + let published = self.tree_path(&tree_id); + if let Err(error) = fs::rename(&staging, &published) { + let _ = tree_store::remove_tree(&staging); + return Err(EffectError::new( + EffectErrorKind::Io, + format!("publish import tree failed: {error}"), + )); + } + if let Err(error) = self.inner.seal_published_tree( + &published, + #[cfg(feature = "workspace-io-fault")] + &self.inner.io_fault, + ) { + return Err(self.inner.cleanup_owned_published( + &published, + error, + #[cfg(feature = "workspace-io-fault")] + &self.inner.io_fault, + )); + } + let _ = copied; + let workspace_id = WorkspaceId::fresh(); + let owner_id = OwnerId::fresh(); + let branch_id = BranchId::fresh(); + let secret = CapabilitySecret::fresh(); + let epoch = BranchEpoch::initial(); + let mut state = self.inner.state.lock(); + state.workspaces.insert( + workspace_id.clone(), + WorkspaceRecord { + secret: secret.clone(), + owner_id: owner_id.clone(), + quota: request.quota, + import_consistency: ImportConsistency::OrdinaryCopy, + branches: HashMap::from([( + branch_id.clone(), + BranchRecord { + epoch, + head: tree_id.clone(), + writers: HashSet::new(), + }, + )]), + lineage: vec![branch_id.clone()], + trees: HashSet::from([tree_id.clone()]), + checkpoints: HashMap::new(), + journal: vec![JournalEntry { + kind: JournalKind::Imported, + branch_id: branch_id.clone(), + head: tree_id, + epoch, + }], + revoked: false, + }, + ); + Ok(WorkspaceHandle { + store: Arc::clone(&self.inner), + workspace_id, + owner_id, + branch_id, + epoch, + secret, + }) + } + + pub fn open_view_from_public_ids( + &self, + _workspace: &PublicWorkspaceRef, + ) -> Result { + Err(EffectError::new( + EffectErrorKind::AuthorityRequired, + "public identifiers cannot open a writable view", + )) + } + + pub fn revoke(&self, handle: &WorkspaceHandle) -> Result<(), EffectError> { + handle.revoke() + } + + fn tree_path(&self, tree: &TreeId) -> PathBuf { + self.inner.root.join("trees").join(tree.as_str()) + } + + fn staging_path(&self, tree: &TreeId) -> PathBuf { + self.inner.root.join("staging").join(tree.as_str()) + } +} + +#[derive(Clone)] +pub struct WorkspaceHandle { + pub(crate) store: Arc, + pub(crate) workspace_id: WorkspaceId, + pub(crate) owner_id: OwnerId, + pub(crate) branch_id: BranchId, + pub(crate) epoch: BranchEpoch, + pub(crate) secret: CapabilitySecret, +} + +impl fmt::Debug for WorkspaceHandle { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WorkspaceHandle") + .field("workspace_id", &self.workspace_id) + .field("owner_id", &self.owner_id) + .field("branch_id", &self.branch_id) + .field("epoch", &self.epoch) + .finish_non_exhaustive() + } +} + +impl WorkspaceHandle { + pub fn workspace_id(&self) -> &WorkspaceId { + &self.workspace_id + } + + pub fn branch_id(&self) -> &BranchId { + &self.branch_id + } + + pub fn epoch(&self) -> BranchEpoch { + self.epoch + } + + pub fn head(&self) -> TreeId { + let state = self.store.state.lock(); + state + .workspaces + .get(&self.workspace_id) + .and_then(|workspace| workspace.branches.get(&self.branch_id)) + .map(|branch| branch.head.clone()) + .unwrap_or_else(TreeId::fresh) + } + + pub fn public_ref(&self) -> PublicWorkspaceRef { + PublicWorkspaceRef { + workspace_id: self.workspace_id.clone(), + branch_id: self.branch_id.clone(), + head: self.head(), + epoch: self.epoch, + } + } + + pub fn journal(&self) -> Vec { + let state = self.store.state.lock(); + state + .workspaces + .get(&self.workspace_id) + .filter(|workspace| workspace.secret == self.secret) + .map(|workspace| workspace.journal.clone()) + .unwrap_or_default() + } + + pub fn lineage(&self) -> Vec { + let state = self.store.state.lock(); + state + .workspaces + .get(&self.workspace_id) + .filter(|workspace| workspace.secret == self.secret) + .map(|workspace| workspace.lineage.clone()) + .unwrap_or_default() + } + + pub fn immutable_path(&self, tree: &TreeId) -> Result { + let state = self.store.state.lock(); + let workspace = self.workspace(&state)?; + if !workspace.trees.contains(tree) { + return Err(not_found("tree")); + } + Ok(self.store.root.join("trees").join(tree.as_str())) + } + + pub fn checkpoint_path(&self, id: &super::types::CheckpointId) -> Result { + let state = self.store.state.lock(); + let workspace = self.workspace(&state)?; + if !workspace.checkpoints.contains_key(id) { + return Err(not_found("checkpoint")); + } + Ok(self + .store + .root + .join("checkpoints") + .join(format!("{id}.yaml"))) + } + + pub fn read_committed(&self, rel: &str) -> Result, EffectError> { + let path = { + let state = self.store.state.lock(); + let workspace = self.workspace(&state)?; + if workspace.revoked { + return Err(revoked()); + } + let branch = workspace + .branches + .get(&self.branch_id) + .ok_or_else(|| not_found("branch"))?; + self.store.root.join("trees").join(branch.head.as_str()) + }; + let dir = open_tree(&path)?; + tree_store::read_from(&dir, rel) + } + + pub fn open_view(&self, request: OpenViewRequest) -> Result { + request.deadline.check()?; + request.cancellation.poll()?; + let view_id = format!("view-{}", uuid::Uuid::new_v4().simple()); + let quota = { + let mut state = self.store.state.lock(); + let workspace = self.workspace_mut(&mut state)?; + if workspace.revoked { + return Err(revoked()); + } + let branch = workspace + .branches + .get_mut(&self.branch_id) + .ok_or_else(|| not_found("branch"))?; + if branch.epoch != self.epoch || request.expected_epoch != branch.epoch { + return Err(stale()); + } + if branch.head != request.expected_head { + return Err(conflict("expected head does not match")); + } + branch.writers.insert(view_id.clone()); + workspace.quota + }; + let view_dir = self.store.root.join("views").join(&view_id); + let work_path = view_dir.join("work"); + let temp_path = view_dir.join("tmp"); + let opened = (|| { + fs::create_dir(&view_dir).map_err(|error| { + EffectError::new(EffectErrorKind::Io, format!("create view failed: {error}")) + })?; + tree_store::chmod_dir(&view_dir, 0o700)?; + fs::create_dir(&temp_path).map_err(|error| { + EffectError::new(EffectErrorKind::Io, format!("create temp failed: {error}")) + })?; + tree_store::chmod_dir(&temp_path, 0o700)?; + let source = self + .store + .root + .join("trees") + .join(request.expected_head.as_str()); + let copied = tree_store::copy_tree( + &source, + &work_path, + "a, + &request.cancellation, + &request.deadline, + false, + )?; + WorkRoot::open( + work_path, + quota, + Usage { + entries: copied.entries, + bytes: copied.bytes, + }, + ) + })(); + match opened { + Ok(work) => Ok(WritableView { + store: Arc::clone(&self.store), + workspace_id: self.workspace_id.clone(), + owner_id: self.owner_id.clone(), + branch_id: self.branch_id.clone(), + epoch: self.epoch, + secret: self.secret.clone(), + view_id, + view_dir, + temp_path, + work, + released: AtomicBool::new(false), + #[cfg(feature = "workspace-io-fault")] + io_fault: IoFaultSlot::new(), + }), + Err(error) => { + let _ = tree_store::remove_tree(&view_dir); + self.release_writer(&view_id); + Err(error) + } + } + } + + // The error carries the live view so the caller can discard or retry it. + #[allow(clippy::result_large_err)] + pub fn publish( + &self, + view: WritableView, + request: PublishRequest, + ) -> Result> { + if let Err(error) = self.prepare_publish(&view, &request) { + return Err(EffectFailure::new(error, view)); + } + let tree_id = TreeId::fresh(); + let staging = self.store.root.join("staging").join(tree_id.as_str()); + let quota = { + let state = self.store.state.lock(); + match self.workspace(&state) { + Ok(workspace) => workspace.quota, + Err(error) => return Err(EffectFailure::new(error, view)), + } + }; + if let Err(error) = tree_store::copy_tree( + view.work.path(), + &staging, + "a, + &request.cancellation, + &request.deadline, + true, + ) { + let _ = tree_store::remove_tree(&staging); + return Err(EffectFailure::new(error, view)); + } + match self.commit_publish(&view, &request, &tree_id, &staging) { + Ok(publication) => { + view.detach(); + Ok(publication) + } + Err(error) => { + let _ = tree_store::remove_tree(&staging); + Err(EffectFailure::new(error, view)) + } + } + } + + #[allow(clippy::result_large_err)] + pub fn discard(&self, view: WritableView) -> Result<(), EffectFailure> { + if let Err(error) = self.reject_foreign_view(&view) { + return Err(EffectFailure::new(error, view)); + } + let head = self.head(); + view.detach(); + self.append_journal(JournalKind::Discarded, head); + Ok(()) + } + + fn reject_foreign_view(&self, view: &WritableView) -> Result<(), EffectError> { + if view.secret != self.secret + || view.owner_id != self.owner_id + || view.workspace_id != self.workspace_id + { + return Err(owner_mismatch()); + } + if view.branch_id != self.branch_id { + return Err(conflict("view belongs to another branch")); + } + if view.epoch != self.epoch { + return Err(stale()); + } + Ok(()) + } + + pub fn revoke(&self) -> Result<(), EffectError> { + let mut state = self.store.state.lock(); + let workspace = self.workspace_mut(&mut state)?; + workspace.revoked = true; + for branch in workspace.branches.values_mut() { + branch.epoch = branch.epoch.next(); + } + Ok(()) + } + + pub(crate) fn workspace<'a>( + &self, + state: &'a StoreState, + ) -> Result<&'a WorkspaceRecord, EffectError> { + let workspace = state + .workspaces + .get(&self.workspace_id) + .ok_or_else(|| not_found("workspace"))?; + if workspace.secret != self.secret || workspace.owner_id != self.owner_id { + return Err(authority_required()); + } + Ok(workspace) + } + + pub(crate) fn workspace_mut<'a>( + &self, + state: &'a mut StoreState, + ) -> Result<&'a mut WorkspaceRecord, EffectError> { + let workspace = state + .workspaces + .get_mut(&self.workspace_id) + .ok_or_else(|| not_found("workspace"))?; + if workspace.secret != self.secret || workspace.owner_id != self.owner_id { + return Err(authority_required()); + } + Ok(workspace) + } + + fn prepare_publish( + &self, + view: &WritableView, + request: &PublishRequest, + ) -> Result<(), EffectError> { + request.deadline.check()?; + request.cancellation.poll()?; + if view.secret != self.secret + || view.owner_id != self.owner_id + || view.workspace_id != self.workspace_id + { + return Err(owner_mismatch()); + } + Ok(()) + } + + fn commit_publish( + &self, + view: &WritableView, + request: &PublishRequest, + tree_id: &TreeId, + staging: &Path, + ) -> Result { + { + let mut state = self.store.state.lock(); + self.validate_publish(&mut state, view, request)?; + } + let published = self.store.root.join("trees").join(tree_id.as_str()); + fs::rename(staging, &published).map_err(|error| { + EffectError::new( + EffectErrorKind::Io, + format!("rename published tree failed: {error}"), + ) + })?; + if let Err(error) = self.store.seal_published_tree( + &published, + #[cfg(feature = "workspace-io-fault")] + &view.io_fault, + ) { + return Err(self.store.cleanup_owned_published( + &published, + error, + #[cfg(feature = "workspace-io-fault")] + &view.io_fault, + )); + } + let finished = { + let mut state = self.store.state.lock(); + self.finish_publish(&mut state, view, request, tree_id) + }; + match finished { + Ok(publication) => Ok(publication), + Err(error) => Err(self.store.cleanup_owned_published( + &published, + error, + #[cfg(feature = "workspace-io-fault")] + &view.io_fault, + )), + } + } + + fn validate_publish( + &self, + state: &mut StoreState, + view: &WritableView, + request: &PublishRequest, + ) -> Result<(), EffectError> { + let workspace = self.workspace_mut(state)?; + if workspace.revoked { + return Err(revoked()); + } + let branch = workspace + .branches + .get(&self.branch_id) + .ok_or_else(|| not_found("branch"))?; + if branch.epoch != self.epoch || request.expected_epoch != branch.epoch { + return Err(stale()); + } + if branch.head != request.expected_head || !branch.writers.contains(&view.view_id) { + return Err(conflict("head or writer changed before publish")); + } + Ok(()) + } + + fn finish_publish( + &self, + state: &mut StoreState, + view: &WritableView, + request: &PublishRequest, + tree_id: &TreeId, + ) -> Result { + self.validate_publish(state, view, request)?; + let workspace = self.workspace_mut(state)?; + let branch = workspace + .branches + .get_mut(&self.branch_id) + .ok_or_else(|| not_found("branch"))?; + let previous_head = branch.head.clone(); + branch.head = tree_id.clone(); + branch.writers.remove(&view.view_id); + let epoch = branch.epoch; + workspace.trees.insert(tree_id.clone()); + workspace.journal.push(JournalEntry { + kind: JournalKind::Published, + branch_id: self.branch_id.clone(), + head: tree_id.clone(), + epoch, + }); + Ok(Publication { + head: tree_id.clone(), + epoch, + previous_head, + }) + } + + fn release_writer(&self, view_id: &str) { + let mut state = self.store.state.lock(); + let Ok(workspace) = self.workspace_mut(&mut state) else { + return; + }; + let Some(branch) = workspace.branches.get_mut(&self.branch_id) else { + return; + }; + branch.writers.remove(view_id); + } + + fn append_journal(&self, kind: JournalKind, head: TreeId) { + let mut state = self.store.state.lock(); + if let Ok(workspace) = self.workspace_mut(&mut state) { + let epoch = workspace + .branches + .get(&self.branch_id) + .map(|branch| branch.epoch) + .unwrap_or(self.epoch); + workspace.journal.push(JournalEntry { + kind, + branch_id: self.branch_id.clone(), + head, + epoch, + }); + } + } +} + +pub struct WritableView { + store: Arc, + workspace_id: WorkspaceId, + owner_id: OwnerId, + branch_id: BranchId, + epoch: BranchEpoch, + secret: CapabilitySecret, + view_id: String, + view_dir: PathBuf, + temp_path: PathBuf, + work: WorkRoot, + released: AtomicBool, + /// Publication and cleanup faults for this view. Import faults stay on the store. + #[cfg(feature = "workspace-io-fault")] + io_fault: IoFaultSlot, +} + +impl fmt::Debug for WritableView { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WritableView") + .field("work", &self.work.path()) + .finish_non_exhaustive() + } +} + +impl WritableView { + pub fn work_root(&self) -> &Path { + self.work.path() + } + + pub fn private_temp(&self) -> &Path { + &self.temp_path + } + + pub fn write_file(&self, rel: &str, bytes: &[u8]) -> Result<(), EffectError> { + self.ensure_live()?; + self.work.write_file(rel, bytes) + } + + pub fn read_file(&self, rel: &str) -> Result, EffectError> { + self.work.read_file(rel) + } + + pub fn delete(&self, rel: &str) -> Result<(), EffectError> { + self.ensure_live()?; + self.work.delete(rel) + } + + pub fn rename(&self, from: &str, to: &str) -> Result<(), EffectError> { + self.ensure_live()?; + self.work.rename(from, to) + } + + pub fn create_dir(&self, rel: &str) -> Result<(), EffectError> { + self.ensure_live()?; + self.work.create_dir(rel) + } + + pub fn set_executable(&self, rel: &str, executable: bool) -> Result<(), EffectError> { + self.ensure_live()?; + self.work.set_executable(rel, executable) + } + + pub fn list(&self, rel: &str) -> Result, EffectError> { + self.work.list(rel) + } + + fn ensure_live(&self) -> Result<(), EffectError> { + if self.released.load(Ordering::SeqCst) { + return Err(stale()); + } + let state = self.store.state.lock(); + let workspace = state + .workspaces + .get(&self.workspace_id) + .ok_or_else(|| not_found("workspace"))?; + if workspace.secret != self.secret || workspace.owner_id != self.owner_id { + return Err(authority_required()); + } + if workspace.revoked { + return Err(revoked()); + } + let branch = workspace + .branches + .get(&self.branch_id) + .ok_or_else(|| not_found("branch"))?; + if branch.epoch != self.epoch { + return Err(stale()); + } + Ok(()) + } + + fn detach(&self) { + self.finalize(); + } + + fn finalize(&self) { + if self.released.swap(true, Ordering::SeqCst) { + return; + } + self.release_originating_writer(); + let _ = tree_store::remove_tree(&self.view_dir); + } + + fn release_originating_writer(&self) { + let mut state = self.store.state.lock(); + let Some(workspace) = state.workspaces.get_mut(&self.workspace_id) else { + return; + }; + if workspace.secret != self.secret { + return; + } + if let Some(branch) = workspace.branches.get_mut(&self.branch_id) { + branch.writers.remove(&self.view_id); + } + } +} + +impl Drop for WritableView { + fn drop(&mut self) { + self.finalize(); + } +} + +#[cfg(feature = "workspace-io-fault")] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SimulatedIoFault { + PartialWrite { errno: i32 }, + FileFsync { errno: i32 }, + PostRenameChmod { errno: i32 }, + PostRenameDirFsync { errno: i32 }, + OwnedCleanup { errno: i32 }, +} + +#[cfg(feature = "workspace-io-fault")] +impl SimulatedIoFault { + pub const EIO: i32 = libc::EIO; + pub const ENOSPC: i32 = libc::ENOSPC; + + fn checked(self) -> Result<(u8, i32), EffectError> { + let (point, errno) = match self { + Self::PartialWrite { errno } => (FAULT_PARTIAL_WRITE, errno), + Self::FileFsync { errno } => (FAULT_FILE_FSYNC, errno), + Self::PostRenameChmod { errno } => (FAULT_POST_RENAME_CHMOD, errno), + Self::PostRenameDirFsync { errno } => (FAULT_POST_RENAME_DIR_FSYNC, errno), + Self::OwnedCleanup { errno } => (FAULT_OWNED_CLEANUP, errno), + }; + if errno != libc::EIO && errno != libc::ENOSPC { + return Err(EffectError::new( + EffectErrorKind::InvalidConfiguration, + "simulated I/O fault errno must be EIO or ENOSPC", + )); + } + Ok((point, errno)) + } +} + +#[cfg(feature = "workspace-io-fault")] +impl EffectStore { + pub fn arm_simulated_io_fault(&self, fault: SimulatedIoFault) -> Result<(), EffectError> { + let (point, errno) = fault.checked()?; + self.inner.io_fault.arm(point, errno); + Ok(()) + } +} + +#[cfg(feature = "workspace-io-fault")] +impl WritableView { + pub fn arm_simulated_io_fault(&self, fault: SimulatedIoFault) -> Result<(), EffectError> { + let (point, errno) = fault.checked()?; + match point { + FAULT_PARTIAL_WRITE | FAULT_FILE_FSYNC => self.work.arm_io_fault(point, errno), + _ => self.io_fault.arm(point, errno), + } + Ok(()) + } + + pub fn accounted_usage(&self) -> (u64, u64) { + self.work.usage_snapshot() + } +} + +impl StoreInner { + fn cleanup_owned_published( + &self, + published: &Path, + error: EffectError, + #[cfg(feature = "workspace-io-fault")] faults: &IoFaultSlot, + ) -> EffectError { + let tree_id = published + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""); + if self.tree_is_registered(tree_id) { + return EffectError::new( + error.kind(), + format!("{error}; refusing to remove a registered tree"), + ); + } + #[cfg(feature = "workspace-io-fault")] + if let Some(code) = faults.take(FAULT_OWNED_CLEANUP) { + let cleanup = tree_store::simulated_io("cleanup", code); + return EffectError::new( + error.kind(), + format!("{error}; owned tree cleanup failed: {cleanup}"), + ); + } + match tree_store::remove_owned_tree(published) { + Ok(()) => error, + Err(cleanup) => EffectError::new( + error.kind(), + format!("{error}; owned tree cleanup failed: {cleanup}"), + ), + } + } + + fn tree_is_registered(&self, tree_id: &str) -> bool { + if tree_id.is_empty() { + return true; + } + let state = self.state.lock(); + state.workspaces.values().any(|workspace| { + workspace + .trees + .iter() + .any(|registered| registered.as_str() == tree_id) + }) + } + + fn seal_published_tree( + &self, + published: &Path, + #[cfg(feature = "workspace-io-fault")] faults: &IoFaultSlot, + ) -> Result<(), EffectError> { + #[cfg(feature = "workspace-io-fault")] + if let Some(code) = faults.take(FAULT_POST_RENAME_CHMOD) { + return Err(tree_store::simulated_io("fchmod", code)); + } + tree_store::chmod_dir(published, 0o555)?; + #[cfg(feature = "workspace-io-fault")] + if let Some(code) = faults.take(FAULT_POST_RENAME_DIR_FSYNC) { + return Err(tree_store::simulated_io("fsync", code)); + } + tree_store::fsync_dir(&self.root.join("trees")) + } +} + +fn open_tree(path: &Path) -> Result { + use std::os::fd::FromRawFd; + use std::os::unix::ffi::OsStrExt; + let c_path = std::ffi::CString::new(path.as_os_str().as_bytes()) + .map_err(|_| EffectError::new(EffectErrorKind::RejectedPath, "tree path contains NUL"))?; + let fd = unsafe { + libc::open( + c_path.as_ptr(), + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW, + ) + }; + if fd < 0 { + Err(EffectError::new( + EffectErrorKind::Io, + "open immutable tree failed", + )) + } else { + Ok(unsafe { std::os::fd::OwnedFd::from_raw_fd(fd) }) + } +} + +fn not_found(what: &str) -> EffectError { + EffectError::new(EffectErrorKind::NotFound, format!("{what} was not found")) +} + +fn stale() -> EffectError { + EffectError::new(EffectErrorKind::StaleEpoch, "branch epoch changed") +} + +fn conflict(message: &str) -> EffectError { + EffectError::new(EffectErrorKind::Conflict, message) +} + +fn revoked() -> EffectError { + EffectError::new(EffectErrorKind::Revoked, "workspace capability was revoked") +} + +fn authority_required() -> EffectError { + EffectError::new( + EffectErrorKind::AuthorityRequired, + "native capability is required", + ) +} + +fn owner_mismatch() -> EffectError { + EffectError::new( + EffectErrorKind::OwnerMismatch, + "view owner does not match the handle", + ) +} diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 3511a1c..1e6d580 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -267,6 +267,9 @@ impl AgentGatewayState { CompactionSettings::default(), )); service.install_agent_runner(runner); + service + .install_native_tool_runtime(std::path::Path::new(":memory:")) + .map_err(|error| format!("compile embedded tool runtime: {error}"))?; Ok(Self { config: Arc::clone(service.config()), store, @@ -307,6 +310,9 @@ impl AgentGatewayState { )); service.install_agent_entry(path); service.install_agent_runner(runner); + service + .install_native_tool_runtime(std::path::Path::new(":memory:")) + .map_err(|error| format!("compile embedded tool runtime: {error}"))?; Ok(Self { config: Arc::clone(service.config()), store, @@ -391,6 +397,9 @@ impl AgentGatewayState { CompactionSettings::default(), )); service.install_agent_runner(runner); + service + .install_native_tool_runtime(path.as_ref()) + .map_err(|error| format!("compile embedded tool runtime: {error}"))?; Ok(Self { config: Arc::clone(service.config()), store, @@ -462,6 +471,11 @@ impl AgentGatewayState { service.install_agent_entry(entry); } service.install_agent_runner(runner); + service + .install_native_tool_runtime( + sqlite_path.unwrap_or_else(|| std::path::Path::new(":memory:")), + ) + .map_err(|error| format!("compile embedded tool runtime: {error}"))?; Ok(Self { config: Arc::clone(service.config()), store, @@ -499,6 +513,11 @@ impl AgentGatewayState { compaction_settings, )); service.install_bundled_agent_entry(key)?; + service + .install_native_tool_runtime( + sqlite_path.unwrap_or_else(|| std::path::Path::new(":memory:")), + ) + .map_err(|error| format!("compile embedded tool runtime: {error}"))?; let hash = service .installed_runner_snapshot_digest() .ok_or_else(|| "native bundled agent source could not be compiled".to_string())?; diff --git a/src/gateway/store.rs b/src/gateway/store.rs index ded2408..f376a48 100644 --- a/src/gateway/store.rs +++ b/src/gateway/store.rs @@ -19,7 +19,7 @@ use std::sync::{ atomic::{AtomicBool, Ordering}, mpsc::{self, Receiver, RecvTimeoutError, Sender}, }; -use std::time::Duration; +use std::time::{Duration, Instant}; use rustscript_vm::Value as VmValue; use serde::{Deserialize, Serialize}; @@ -35,6 +35,15 @@ use crate::{AgentConfig, AgentRunner}; /// every RSS op is bounded, so this only guards against a wedged worker. const STORAGE_COMMAND_TIMEOUT: Duration = Duration::from_secs(30); +/// One absolute bound captured when a fixture pause or rendezvous is armed. +/// Entered and release waits consume the remainder; a phase does not start +/// another 5s, and unarmed production calls do not consult this bound. +const PLUGIN_CONTROL_FIXTURE_BOUND: Duration = Duration::from_secs(5); + +pub(crate) const PLUGIN_CONTROL_FIXTURE_ABORTED: &str = "plugin_control_fixture_aborted"; +pub(crate) const PLUGIN_CONTROL_FIXTURE_TIMEOUT: &str = "plugin_control_fixture_timeout"; +pub(crate) const PLUGIN_CONTROL_FIXTURE_CLOSED: &str = "plugin_control_fixture_closed"; + /// One recovery batch processes at most this many interrupted runs; the /// gateway loops until a batch reports zero recovered. const RECOVERY_BATCH: i64 = 512; @@ -85,6 +94,8 @@ impl std::fmt::Display for StorageError { impl std::error::Error for StorageError {} +type AdmissionPrecommitHook = Box; + #[cfg(feature = "config-fixture")] struct ApprovalExpireHook { before: Box, @@ -100,7 +111,16 @@ pub struct GatewayPersistence { fail_after_partial_write: std::sync::atomic::AtomicBool, fail_after_commit_before_publish: std::sync::atomic::AtomicBool, fail_model_requested_append: std::sync::atomic::AtomicBool, + fail_plugin_control_partial: std::sync::atomic::AtomicBool, persist_block: Mutex>>, + /// Fixture-local one-shot rendezvous. Armed tests wait here after a stale + /// pre-check and before the guarded command. Unarmed production calls do + /// not wait. Dropping the controller aborts cloned waiters; clearing this + /// slot alone does not. + plugin_control_precommit_gate: Mutex>>, + plugin_retain_precommit_pause: Mutex>>, + plugin_activate_receipt_hook: Mutex>, + admission_precommit_hook: Mutex>, #[cfg(feature = "config-fixture")] approval_expire_hook: Mutex>, #[cfg(test)] @@ -140,6 +160,156 @@ impl PersistBlockGuard { } } +type PluginActivateReceiptHook = Arc; + +struct PluginRetainPauseState { + entered: bool, + released: bool, + aborted: bool, +} + +struct PluginRetainPause { + mutex: Mutex, + cvar: Condvar, + deadline: Instant, +} + +/// Pauses `plugin.retain` after the graph pre-read and before the retain command. +pub struct PluginRetainPauseGuard { + inner: Arc, +} + +fn lock_retain(pause: &PluginRetainPause) -> std::sync::MutexGuard<'_, PluginRetainPauseState> { + pause + .mutex + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +impl PluginRetainPauseGuard { + /// Fails if retain does not reach the precommit pause within the deadline + /// captured when the pause was armed. This is a missed-seam detector, not + /// a retry used to manufacture a race. + pub fn wait_entered(&self) { + let mut state = lock_retain(&self.inner); + while !state.entered { + let now = Instant::now(); + if state.aborted || now >= self.inner.deadline { + panic!("plugin retain precommit pause was not entered"); + } + let (next, timeout) = self + .inner + .cvar + .wait_timeout(state, self.inner.deadline - now) + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state = next; + if timeout.timed_out() && !state.entered { + panic!("plugin retain precommit pause was not entered"); + } + } + } + + /// Lets the paused retain continue into its command. + /// + /// The released predicate and the notify happen under the same mutex the + /// waiter uses, so a release that lands before `wait` is not lost. + pub fn release(&self) { + let mut state = lock_retain(&self.inner); + if state.aborted { + return; + } + state.released = true; + self.inner.cvar.notify_all(); + } +} + +impl Drop for PluginRetainPauseGuard { + fn drop(&mut self) { + let mut state = lock_retain(&self.inner); + if state.released || state.aborted { + return; + } + state.aborted = true; + self.inner.cvar.notify_all(); + } +} + +fn plugin_control_fixture_error(code: &'static str) -> StorageError { + let message = match code { + PLUGIN_CONTROL_FIXTURE_ABORTED => "plugin control fixture aborted", + PLUGIN_CONTROL_FIXTURE_TIMEOUT => "plugin control fixture timed out", + PLUGIN_CONTROL_FIXTURE_CLOSED => "plugin control fixture rendezvous is closed", + _ => "plugin control fixture fault", + }; + StorageError { + code: code.to_string(), + message: message.to_string(), + } +} + +struct PluginControlGateState { + arrived: usize, + completed: bool, + aborted: bool, +} + +struct PluginControlGate { + mutex: Mutex, + cvar: Condvar, + parties: usize, + deadline: Instant, +} + +/// Aborts every cloned waiter of one armed rendezvous. Drop does not join +/// threads and does not call fixture callbacks. +#[must_use = "dropping the gate controller aborts waiters"] +pub struct PluginControlGateGuard { + inner: Arc, +} + +impl PluginControlGate { + fn lock(&self) -> std::sync::MutexGuard<'_, PluginControlGateState> { + self.mutex + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + fn abort(&self) { + let mut state = self.lock(); + if state.completed || state.aborted { + return; + } + state.aborted = true; + self.cvar.notify_all(); + } +} + +impl Drop for PluginControlGateGuard { + fn drop(&mut self) { + self.inner.abort(); + } +} + +/// Owns the receipt hook claimed for one activation attempt. Drop discards an +/// unconsumed hook without running it and without holding the slot mutex. +pub(crate) struct PluginActivateReceiptAttempt { + hook: Option, +} + +impl PluginActivateReceiptAttempt { + pub(crate) fn run(mut self) { + if let Some(hook) = self.hook.take() { + hook(); + } + } +} + +impl Drop for PluginActivateReceiptAttempt { + fn drop(&mut self) { + self.hook.take(); + } +} + /// One serialized storage request for the dedicated worker thread. struct StorageRequest { op: String, @@ -395,7 +565,12 @@ impl GatewayPersistence { fail_after_partial_write: std::sync::atomic::AtomicBool::new(false), fail_after_commit_before_publish: std::sync::atomic::AtomicBool::new(false), fail_model_requested_append: std::sync::atomic::AtomicBool::new(false), + fail_plugin_control_partial: std::sync::atomic::AtomicBool::new(false), persist_block: Mutex::new(None), + plugin_control_precommit_gate: Mutex::new(None), + plugin_retain_precommit_pause: Mutex::new(None), + plugin_activate_receipt_hook: Mutex::new(None), + admission_precommit_hook: Mutex::new(None), #[cfg(feature = "config-fixture")] approval_expire_hook: Mutex::new(None), #[cfg(test)] @@ -499,6 +674,34 @@ impl GatewayPersistence { decode_command_data(result.get("data").cloned().unwrap_or(Value::Null)) } + pub(crate) fn plugin_control_command( + &self, + op: &str, + payload: &Value, + ) -> Result { + const OPS: &[&str] = &[ + "plugin.lock_put", + "plugin.lock_get", + "plugin.lock_list", + "plugin.ref_list", + "plugin.activate", + "plugin.activation_get", + "plugin.activation_history", + "plugin.retain", + "plugin.retention_get", + "plugin.release", + "plugin.pin_for_owner", + "plugin.collect", + ]; + if !OPS.contains(&op) { + return Err(StorageError { + code: "plugin_control_unavailable".to_string(), + message: "plugin control operation is not registered".to_string(), + }); + } + self.command_data(op, payload) + } + // ------------------------------------------------------------------ // Typed repository commands (production read/write path). // ------------------------------------------------------------------ @@ -508,9 +711,26 @@ impl GatewayPersistence { /// retention floor commit in a single transaction. The returned data /// carries `replayed`, `run`, `session`, `message`, and `idempotency`. pub fn admission_create(&self, payload: &Value) -> Result { + let hook = self + .admission_precommit_hook + .lock() + .expect("admission hook") + .take(); + if let Some(hook) = hook { + hook(payload); + } self.command_data("admission.create", payload) } + /// Fixture-only Native seam after preparation and before the original + /// guarded transaction. No effect grants or storage authority are exposed. + pub fn inject_admission_precommit_hook(&self, hook: impl FnOnce(&Value) + Send + 'static) { + *self + .admission_precommit_hook + .lock() + .expect("admission hook") = Some(Box::new(hook)); + } + /// Reads one durable admission identity without mutating repository state. pub fn admission_replay( &self, @@ -562,7 +782,7 @@ impl GatewayPersistence { } /// Cascades one session (messages, runs, events, links, approvals, - /// compactions, delivery cursors, idempotency) in one transaction. + /// compactions, delivery cursors, idempotency, run graph refs) in one transaction. pub fn session_delete(&self, session_id: &str) -> Result { self.command_data("session.delete", &json!({ "session_id": session_id })) } @@ -679,6 +899,18 @@ impl GatewayPersistence { .store(true, std::sync::atomic::Ordering::SeqCst); } + /// Test failpoint: the next plugin-control write aborts inside its SQLite + /// transaction after the partial statements, so that transaction rolls back. + pub fn inject_fail_plugin_control_partial(&self) { + self.fail_plugin_control_partial + .store(true, std::sync::atomic::Ordering::SeqCst); + } + + pub(crate) fn take_plugin_control_failpoint(&self) -> bool { + self.fail_plugin_control_partial + .swap(false, std::sync::atomic::Ordering::SeqCst) + } + /// Test failpoint: the next storage command blocks until the returned /// guard is released. Used to prove GET/write can proceed without the /// GatewayStore lock being held across SQLite IO. @@ -694,6 +926,182 @@ impl GatewayPersistence { PersistBlockGuard { inner } } + /// Arms a one-shot rendezvous of `parties` callers. Each guarded plugin + /// command waits after its stale pre-check and before the mutation. + /// The returned controller aborts every cloned waiter on drop. + pub fn arm_plugin_control_precommit_gate(&self, parties: usize) -> PluginControlGateGuard { + assert!(parties >= 1, "plugin precommit gate needs a participant"); + let inner = Arc::new(PluginControlGate { + mutex: Mutex::new(PluginControlGateState { + arrived: 0, + completed: false, + aborted: false, + }), + cvar: Condvar::new(), + parties, + deadline: Instant::now() + PLUGIN_CONTROL_FIXTURE_BOUND, + }); + *self + .plugin_control_precommit_gate + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(Arc::clone(&inner)); + PluginControlGateGuard { inner } + } + + /// Aborts cloned waiters and removes the current rendezvous slot. + pub fn disarm_plugin_control_precommit_gate(&self) { + let gate = self + .plugin_control_precommit_gate + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take(); + if let Some(gate) = gate { + gate.abort(); + } + } + + pub(crate) fn abort_plugin_control_precommit_gate(&self) { + let gate = self + .plugin_control_precommit_gate + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone(); + if let Some(gate) = gate { + gate.abort(); + } + } + + pub(crate) fn enter_plugin_control_precommit_gate(&self) -> Result<(), StorageError> { + let gate = self + .plugin_control_precommit_gate + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone(); + let Some(gate) = gate else { + return Ok(()); + }; + let mut state = gate.lock(); + if state.completed { + return Err(plugin_control_fixture_error(PLUGIN_CONTROL_FIXTURE_CLOSED)); + } + if state.aborted { + return Err(plugin_control_fixture_error(PLUGIN_CONTROL_FIXTURE_ABORTED)); + } + if Instant::now() >= gate.deadline { + state.aborted = true; + gate.cvar.notify_all(); + return Err(plugin_control_fixture_error(PLUGIN_CONTROL_FIXTURE_TIMEOUT)); + } + state.arrived = state.arrived.saturating_add(1); + if state.arrived == gate.parties { + state.completed = true; + gate.cvar.notify_all(); + return Ok(()); + } + if state.arrived > gate.parties { + return Err(plugin_control_fixture_error(PLUGIN_CONTROL_FIXTURE_CLOSED)); + } + while !state.completed && !state.aborted { + let now = Instant::now(); + if now >= gate.deadline { + state.aborted = true; + gate.cvar.notify_all(); + return Err(plugin_control_fixture_error(PLUGIN_CONTROL_FIXTURE_TIMEOUT)); + } + let (next, timeout) = gate + .cvar + .wait_timeout(state, gate.deadline - now) + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state = next; + if timeout.timed_out() && !state.completed && !state.aborted { + state.aborted = true; + gate.cvar.notify_all(); + return Err(plugin_control_fixture_error(PLUGIN_CONTROL_FIXTURE_TIMEOUT)); + } + } + if state.aborted { + return Err(plugin_control_fixture_error(PLUGIN_CONTROL_FIXTURE_ABORTED)); + } + if state.completed { + return Ok(()); + } + Err(plugin_control_fixture_error(PLUGIN_CONTROL_FIXTURE_TIMEOUT)) + } + + /// Arms a one-shot pause between retain's graph pre-read and its command. + pub fn arm_plugin_retain_precommit_pause(&self) -> PluginRetainPauseGuard { + let inner = Arc::new(PluginRetainPause { + mutex: Mutex::new(PluginRetainPauseState { + entered: false, + released: false, + aborted: false, + }), + cvar: Condvar::new(), + deadline: Instant::now() + PLUGIN_CONTROL_FIXTURE_BOUND, + }); + *self + .plugin_retain_precommit_pause + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(Arc::clone(&inner)); + PluginRetainPauseGuard { inner } + } + + pub(crate) fn enter_plugin_retain_precommit_pause(&self) -> Result<(), StorageError> { + let pause = self + .plugin_retain_precommit_pause + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone(); + let Some(pause) = pause else { + return Ok(()); + }; + let mut state = lock_retain(&pause); + state.entered = true; + pause.cvar.notify_all(); + while !state.released && !state.aborted { + let now = Instant::now(); + if now >= pause.deadline { + state.aborted = true; + pause.cvar.notify_all(); + return Err(plugin_control_fixture_error(PLUGIN_CONTROL_FIXTURE_TIMEOUT)); + } + let (next, timeout) = pause + .cvar + .wait_timeout(state, pause.deadline - now) + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state = next; + if timeout.timed_out() && !state.released && !state.aborted { + state.aborted = true; + pause.cvar.notify_all(); + return Err(plugin_control_fixture_error(PLUGIN_CONTROL_FIXTURE_TIMEOUT)); + } + } + if state.aborted { + return Err(plugin_control_fixture_error(PLUGIN_CONTROL_FIXTURE_ABORTED)); + } + Ok(()) + } + + /// Runs once after this activation's command returns and before the caller + /// builds its result. A later legitimate activation can commit here without + /// a sleep or a process-global lock. + pub fn arm_plugin_activate_receipt_hook(&self, hook: impl Fn() + Send + Sync + 'static) { + *self + .plugin_activate_receipt_hook + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(Arc::new(hook)); + } + + pub(crate) fn begin_plugin_activate_receipt_attempt(&self) -> PluginActivateReceiptAttempt { + PluginActivateReceiptAttempt { + hook: self + .plugin_activate_receipt_hook + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take(), + } + } + /// One atomic terminal commit: run status transition plus terminal /// events (and optional assistant message) in a single transaction. /// The returned data carries the run row and the run's event rows. @@ -871,7 +1279,7 @@ impl GatewayPersistence { let migrated = self .command_data("migrate", &json!({})) .map_err(|error| format!("migrate gateway state: {error}"))?; - if migrated.get("schema_version") != Some(&json!(4)) { + if migrated.get("schema_version") != Some(&json!(5)) { return Err(format!( "gateway schema migrated to an unexpected version: {migrated}" )); @@ -1312,6 +1720,10 @@ impl GatewayStore { let mut idempotency = HashMap::new(); for row in idempotency_rows { + if string_cell(&row, 0, "idempotency scope")? == "native.tool-pin" { + // Internal original receipts are not caller idempotency keys. + continue; + } let key = string_cell(&row, 1, "idempotency key")?; let state = string_cell(&row, 5, "idempotency state")?; if state == "completed" { diff --git a/src/lib.rs b/src/lib.rs index 55232f5..24ac809 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,11 +14,13 @@ pub mod config_file; #[cfg(feature = "config-fixture")] mod config_host; pub mod domain; +pub mod effects; pub mod events; pub mod gateway; #[cfg(any(test, feature = "config-fixture"))] mod host_opaque; pub mod metrics; +pub mod plugins; pub mod prompt; pub mod registry; pub mod runtime; diff --git a/src/plugins/control_store.rs b/src/plugins/control_store.rs new file mode 100644 index 0000000..8e3b211 --- /dev/null +++ b/src/plugins/control_store.rs @@ -0,0 +1,547 @@ +//! Durable plugin-control metadata on the production RSS SQLite path. +//! +//! A loaded lock is validated metadata only. It does not rebuild a callable +//! program, grant host-catalog authority, pin a live run, or hot-swap tools. +//! Guest approval operations are not plugin-control operations. + +use serde::Serialize; +use serde_json::{Value, json}; + +use super::resolve::GraphLock; +use super::schema::validate_lock; +use crate::GatewayPersistence; + +const MAX_LOCK_BYTES: usize = 256 * 1024; +const MAX_ID_BYTES: usize = 128; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PluginControlError { + pub code: &'static str, + pub message: &'static str, +} + +impl PluginControlError { + fn new(code: &'static str, message: &'static str) -> Self { + Self { code, message } + } + + fn unavailable() -> Self { + Self::new( + "plugin_control_unavailable", + "plugin control store is not available", + ) + } + + fn invalid() -> Self { + Self::new("plugin_lock_invalid", "plugin lock metadata is invalid") + } + + fn conflict() -> Self { + Self::new("plugin_lock_conflict", "plugin lock identity is immutable") + } + + fn missing_graph() -> Self { + Self::new("plugin_graph_missing", "plugin graph lock is not stored") + } + + fn missing_ref() -> Self { + Self::new( + "plugin_package_ref_missing", + "plugin package reference does not match the stored lock", + ) + } + + fn activation_conflict() -> Self { + Self::new( + "plugin_activation_conflict", + "plugin activation generation does not match", + ) + } + + fn fixture_aborted() -> Self { + Self::new( + "plugin_control_fixture_aborted", + "plugin control fixture aborted", + ) + } + + fn fixture_timeout() -> Self { + Self::new( + "plugin_control_fixture_timeout", + "plugin control fixture timed out", + ) + } + + fn fixture_closed() -> Self { + Self::new( + "plugin_control_fixture_closed", + "plugin control fixture rendezvous is closed", + ) + } + + fn invalid_ref() -> Self { + Self::new("plugin_ref_invalid", "plugin graph reference is invalid") + } +} + +impl std::fmt::Display for PluginControlError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.message) + } +} + +impl std::error::Error for PluginControlError {} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct PackageArtifactRef { + pub package_id: String, + pub version: String, + pub kind: String, + pub artifact_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ValidatedLockMetadata { + pub lock: GraphLock, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StoreLockReceipt { + pub graph_id: String, + pub idempotent: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ActivationPointer { + pub graph_id: String, + pub generation: u64, + pub updated_at_ms: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ActivationHistoryEntry { + pub graph_id: String, + pub generation: u64, + pub previous_graph_id: String, + pub activated_at_ms: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GraphRetention { + pub graph_id: String, + pub owner_kind: String, + pub owner_id: String, + pub ref_id: String, +} + +pub struct PluginControlStore<'a> { + persistence: &'a GatewayPersistence, +} + +impl<'a> PluginControlStore<'a> { + pub fn new(persistence: &'a GatewayPersistence) -> Self { + Self { persistence } + } + + pub fn store_lock(&self, lock: &GraphLock) -> Result { + let document = validated_document(lock)?; + let encoded = + serde_json::to_string(&document).map_err(|_| PluginControlError::invalid())?; + if encoded.len() > MAX_LOCK_BYTES { + return Err(PluginControlError::invalid()); + } + match self.load_lock(&lock.graph_id) { + Ok(existing) if existing.lock == *lock => { + return Ok(StoreLockReceipt { + graph_id: lock.graph_id.clone(), + idempotent: true, + }); + } + Ok(_) => return Err(PluginControlError::conflict()), + Err(err) if err.code == "plugin_graph_missing" => {} + Err(err) => return Err(err), + } + let refs = refs_of(lock); + self.command( + "plugin.lock_put", + &json!({ + "graph_id": lock.graph_id, + "lock_json": encoded, + "packages_json": serde_json::to_string(&refs).map_err(|_| PluginControlError::invalid())?, + "package_count": refs.len(), + "now_ms": now_ms(), + "failpoint": self.failpoint(), + }), + )?; + Ok(StoreLockReceipt { + graph_id: lock.graph_id.clone(), + idempotent: false, + }) + } + + pub fn load_lock(&self, graph_id: &str) -> Result { + let data = self.command("plugin.lock_get", &json!({ "graph_id": graph_id }))?; + let rows = storage_rows(&data)?; + let Some(row) = rows.first() else { + return Err(PluginControlError::missing_graph()); + }; + let encoded = text_at(row, 0)?; + let lock = parse_lock(&encoded)?; + if lock.graph_id != graph_id { + return Err(PluginControlError::invalid()); + } + let stored_refs = self.package_refs(graph_id)?; + if stored_refs != refs_of(&lock) { + return Err(PluginControlError::missing_ref()); + } + Ok(ValidatedLockMetadata { lock }) + } + + pub fn list_locks(&self) -> Result, PluginControlError> { + let data = self.command("plugin.lock_list", &json!({}))?; + storage_rows(&data)? + .iter() + .map(|row| text_at(row, 0)) + .collect() + } + + /// Reads the live retention slot for one graph through the production command. + pub fn retained(&self, graph_id: &str) -> Result, PluginControlError> { + let data = self.command("plugin.retention_get", &json!({ "graph_id": graph_id }))?; + storage_rows(&data)? + .iter() + .map(|row| { + Ok(GraphRetention { + graph_id: text_at(row, 0)?, + owner_kind: text_at(row, 1)?, + owner_id: text_at(row, 2)?, + ref_id: text_at(row, 3)?, + }) + }) + .collect() + } + + pub fn package_refs( + &self, + graph_id: &str, + ) -> Result, PluginControlError> { + let data = self.command("plugin.ref_list", &json!({ "graph_id": graph_id }))?; + storage_rows(&data)? + .iter() + .map(|row| { + Ok(PackageArtifactRef { + package_id: text_at(row, 0)?, + version: text_at(row, 1)?, + kind: text_at(row, 2)?, + artifact_id: text_at(row, 3)?, + }) + }) + .collect() + } + + pub fn activate( + &self, + graph_id: &str, + expected_generation: u64, + refs: &[PackageArtifactRef], + ) -> Result { + let attempt = self.persistence.begin_plugin_activate_receipt_attempt(); + let loaded = match self.load_lock(graph_id) { + Ok(loaded) => loaded, + Err(err) => { + self.persistence.abort_plugin_control_precommit_gate(); + return Err(err); + } + }; + if !same_refs(&refs_of(&loaded.lock), refs) { + self.persistence.abort_plugin_control_precommit_gate(); + return Err(PluginControlError::missing_ref()); + } + let current = match self.activation() { + Ok(current) => current, + Err(err) => { + self.persistence.abort_plugin_control_precommit_gate(); + return Err(err); + } + }; + if current.generation != expected_generation { + self.persistence.abort_plugin_control_precommit_gate(); + return Err(PluginControlError::activation_conflict()); + } + self.persistence + .enter_plugin_control_precommit_gate() + .map_err(map_storage)?; + let next = expected_generation.saturating_add(1); + let now = now_ms(); + let data = self.command( + "plugin.activate", + &json!({ + "graph_id": graph_id, + "expected_generation": expected_generation, + "packages_json": serde_json::to_string(refs).map_err(|_| PluginControlError::invalid())?, + "package_count": refs.len(), + "now_ms": now, + "failpoint": self.failpoint(), + }), + )?; + attempt.run(); + let pointer = activation_receipt(&data)?; + if pointer.graph_id != graph_id + || pointer.generation != next + || pointer.updated_at_ms != now + { + return Err(PluginControlError::unavailable()); + } + Ok(pointer) + } + + pub fn activation(&self) -> Result { + let data = self.command("plugin.activation_get", &json!({}))?; + let rows = storage_rows(&data)?; + let Some(row) = rows.first() else { + return Ok(ActivationPointer { + graph_id: String::new(), + generation: 0, + updated_at_ms: 0, + }); + }; + Ok(ActivationPointer { + graph_id: text_at(row, 0)?, + generation: int_at(row, 1)?, + updated_at_ms: int_at(row, 2)?, + }) + } + + pub fn activation_history(&self) -> Result, PluginControlError> { + let data = self.command("plugin.activation_history", &json!({}))?; + storage_rows(&data)? + .iter() + .map(|row| { + Ok(ActivationHistoryEntry { + graph_id: text_at(row, 0)?, + generation: int_at(row, 1)?, + previous_graph_id: text_at(row, 2)?, + activated_at_ms: int_at(row, 3)?, + }) + }) + .collect() + } + + pub fn retain(&self, retention: &GraphRetention) -> Result { + if let Err(err) = validate_retention(retention) { + self.persistence.abort_plugin_control_precommit_gate(); + return Err(err); + } + if let Err(err) = self.load_lock(&retention.graph_id) { + self.persistence.abort_plugin_control_precommit_gate(); + return Err(err); + } + self.persistence + .enter_plugin_retain_precommit_pause() + .map_err(map_storage)?; + self.persistence + .enter_plugin_control_precommit_gate() + .map_err(map_storage)?; + let data = self.command( + "plugin.retain", + &retention_payload(retention, self.failpoint()), + )?; + data.get("idempotent") + .and_then(Value::as_bool) + .ok_or_else(PluginControlError::unavailable) + } + + pub fn release(&self, retention: &GraphRetention) -> Result<(), PluginControlError> { + validate_retention(retention)?; + self.command( + "plugin.release", + &retention_payload(retention, self.failpoint()), + )?; + Ok(()) + } + + pub fn pin_for_run(&self, owner_id: &str) -> Result { + self.command("plugin.pin_for_owner", &json!({ "owner_id": owner_id })) + } + + pub fn collect_unreferenced(&self) -> Result, PluginControlError> { + let data = self.command("plugin.collect", &json!({ "failpoint": self.failpoint() }))?; + let mut collected = Vec::new(); + for row in storage_rows(&data)? { + let graph_id = text_at(&row, 0)?; + match self.load_lock(&graph_id) { + Err(err) if err.code == "plugin_graph_missing" => collected.push(graph_id), + Err(err) => return Err(err), + Ok(_) => {} + } + } + Ok(collected) + } + + fn command(&self, op: &str, payload: &Value) -> Result { + self.persistence + .plugin_control_command(op, payload) + .map_err(map_storage) + } + + fn failpoint(&self) -> &'static str { + if self.persistence.take_plugin_control_failpoint() { + "after_partial_write" + } else { + "" + } + } +} + +fn activation_receipt(data: &Value) -> Result { + Ok(ActivationPointer { + graph_id: data + .get("graph_id") + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .ok_or_else(PluginControlError::unavailable)?, + generation: json_u64(data.get("generation"))?, + updated_at_ms: json_u64(data.get("updated_at_ms"))?, + }) +} + +fn json_u64(value: Option<&Value>) -> Result { + value + .and_then(Value::as_u64) + .or_else(|| { + value + .and_then(Value::as_i64) + .and_then(|item| u64::try_from(item).ok()) + }) + .ok_or_else(PluginControlError::unavailable) +} + +fn retention_payload(retention: &GraphRetention, failpoint: &str) -> Value { + json!({ + "graph_id": retention.graph_id, + "owner_kind": retention.owner_kind, + "owner_id": retention.owner_id, + "ref_id": retention.ref_id, + "now_ms": now_ms(), + "failpoint": failpoint, + }) +} + +fn validated_document(lock: &GraphLock) -> Result { + let document = lock.schema_value(); + validate_lock(&document).map_err(|_| PluginControlError::invalid())?; + let parsed = + GraphLock::from_schema_value(document).map_err(|_| PluginControlError::invalid())?; + if parsed != *lock { + return Err(PluginControlError::invalid()); + } + parsed + .validate_persisted() + .map_err(|_| PluginControlError::invalid())?; + Ok(lock.schema_value()) +} + +fn parse_lock(encoded: &str) -> Result { + if encoded.len() > MAX_LOCK_BYTES { + return Err(PluginControlError::invalid()); + } + let document = serde_json::from_str(encoded).map_err(|_| PluginControlError::invalid())?; + validate_lock(&document).map_err(|_| PluginControlError::invalid())?; + let lock = GraphLock::from_schema_value(document).map_err(|_| PluginControlError::invalid())?; + lock.validate_persisted() + .map_err(|_| PluginControlError::invalid())?; + Ok(lock) +} + +fn refs_of(lock: &GraphLock) -> Vec { + lock.packages + .iter() + .map(|package| PackageArtifactRef { + package_id: package.id.clone(), + version: package.version.clone(), + kind: package.kind.clone(), + artifact_id: package.artifact_id.clone(), + }) + .collect() +} + +fn validate_retention(retention: &GraphRetention) -> Result<(), PluginControlError> { + if retention.owner_kind != "run" && retention.owner_kind != "checkpoint" { + return Err(PluginControlError::invalid_ref()); + } + if !plain_id(&retention.graph_id) + || !plain_id(&retention.owner_id) + || !plain_id(&retention.ref_id) + { + return Err(PluginControlError::invalid_ref()); + } + Ok(()) +} + +fn plain_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= MAX_ID_BYTES + && !value.contains('/') + && !value.contains('\\') + && !value.contains("..") + && value.chars().all(|item| !item.is_control()) +} + +fn storage_rows(data: &Value) -> Result>, PluginControlError> { + crate::sqlite_storage_rows::sqlite_storage_rows(data) + .map_err(|_| PluginControlError::unavailable()) +} + +fn text_at(row: &[Value], index: usize) -> Result { + row.get(index) + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .ok_or_else(PluginControlError::unavailable) +} + +fn int_at(row: &[Value], index: usize) -> Result { + row.get(index) + .and_then(Value::as_u64) + .or_else(|| { + row.get(index) + .and_then(Value::as_i64) + .and_then(|value| u64::try_from(value).ok()) + }) + .ok_or_else(PluginControlError::unavailable) +} + +fn map_storage(err: crate::gateway::store::StorageError) -> PluginControlError { + match err.code.as_str() { + "plugin_lock_conflict" => PluginControlError::conflict(), + "plugin_lock_invalid" => PluginControlError::invalid(), + "plugin_graph_missing" => PluginControlError::missing_graph(), + "plugin_package_ref_missing" => PluginControlError::missing_ref(), + "plugin_activation_conflict" => PluginControlError::activation_conflict(), + "plugin_ref_invalid" => PluginControlError::invalid_ref(), + crate::gateway::store::PLUGIN_CONTROL_FIXTURE_ABORTED => { + PluginControlError::fixture_aborted() + } + crate::gateway::store::PLUGIN_CONTROL_FIXTURE_TIMEOUT => { + PluginControlError::fixture_timeout() + } + crate::gateway::store::PLUGIN_CONTROL_FIXTURE_CLOSED => { + PluginControlError::fixture_closed() + } + _ => PluginControlError::unavailable(), + } +} + +fn same_refs(left: &[PackageArtifactRef], right: &[PackageArtifactRef]) -> bool { + let mut left = left.to_vec(); + let mut right = right.to_vec(); + left.sort_by(|left, right| left.package_id.cmp(&right.package_id)); + right.sort_by(|left, right| left.package_id.cmp(&right.package_id)); + left == right +} + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis() as u64) + .unwrap_or(0) +} diff --git a/src/plugins/error.rs b/src/plugins/error.rs new file mode 100644 index 0000000..e7082b9 --- /dev/null +++ b/src/plugins/error.rs @@ -0,0 +1,132 @@ +//! Typed plugin-contract failures. +//! +//! A signature, publisher record, or graph id never becomes a capability grant. +//! These errors name the failed boundary and the shortest package chain. + +use std::fmt; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PluginErrorKind { + DuplicateField, + YamlAlias, + YamlTag, + YamlMerge, + MissingEffects, + UnknownEffect, + ForgedRecovery, + UncoveredExport, + UnknownField, + PathEscape, + SymlinkDenied, + PublisherUnauthorized, + VersionOverwrite, + MissingDependency, + Conflict, + Cycle, + Ambiguous, + BudgetExceeded, + UndeclaredTransitiveEffect, + UndeclaredDirectEffect, + UnauthorizedHostImport, + PluginBoundaryBypass, + ContractIncompatible, + CompileFailed, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PluginError { + pub kind: PluginErrorKind, + pub chain: Vec, +} + +impl PluginError { + pub(crate) fn new(kind: PluginErrorKind, item: impl Into) -> Self { + Self { + kind, + chain: vec![item.into()], + } + } +} + +impl fmt::Display for PluginError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{:?}: {}", self.kind, self.chain.join(" -> ")) + } +} + +pub const PLUGIN_KINDS: &[&str] = &["library", "tool"]; + +pub const EFFECT_KINDS: &[&str] = &[ + "workspace.fs.read", + "workspace.fs.write", + "process.spawn", + "model.request", + "remote.write", + "clock.read", + "event.emit", + "delivery.send", + "secret.use", +]; + +pub const EFFECT_RECOVERY: &[&str] = &["none", "checkpoint"]; + +pub const MANIFEST_FIELDS: &[&str] = &[ + "capabilities", + "contract", + "dependencies", + "effects", + "entry", + "format", + "host_abi", + "id", + "kind", + "runtime", + "state", + "version", +]; + +pub const GRAPH_LOCK_FIELDS: &[&str] = &[ + "closure", + "compile_environment", + "edges", + "graph_id", + "host_runtime", + "packages", +]; + +pub const HOST_RUNTIME: &str = "0.1.0"; +pub const COMPILE_ENVIRONMENT: &str = "closed-bundle/agent.cap/v1"; + +pub(crate) fn capability_for(kind: &str) -> Option<&'static str> { + Some(match kind { + "workspace.fs.read" => "workspace.read", + "workspace.fs.write" => "workspace.write", + "process.spawn" => "process.spawn", + "model.request" => "model.request", + "remote.write" => "remote.write", + "clock.read" => "clock.read", + "event.emit" => "event.emit", + "delivery.send" => "delivery.send", + "secret.use" => "secret.use", + _ => return None, + }) +} + +pub(crate) fn scope_for(kind: &str) -> Option<&'static str> { + Some(match kind { + "workspace.fs.read" | "workspace.fs.write" => "bound_workspace", + "process.spawn" => "bound_process", + "model.request" | "remote.write" | "clock.read" | "event.emit" => "bound_run", + "delivery.send" => "bound_delivery", + "secret.use" => "bound_secret", + _ => return None, + }) +} + +pub(crate) fn recovery_allowed(kind: &str, recovery: &str) -> bool { + match (kind, recovery) { + ("workspace.fs.write", "none" | "checkpoint") => true, + (_, "none") => scope_for(kind).is_some(), + _ => false, + } +} diff --git a/src/plugins/manifest.rs b/src/plugins/manifest.rs new file mode 100644 index 0000000..df26676 --- /dev/null +++ b/src/plugins/manifest.rs @@ -0,0 +1,494 @@ +//! Explicit plugin manifest contract. +//! +//! Missing effects, unknown kinds, uncovered exports, and forged recovery are +//! rejected here. Nothing in this module grants a capability. + +use std::collections::{BTreeMap, BTreeSet}; + +use semver::{Version, VersionReq}; +use serde_json::Value; + +use super::error::{PluginError, PluginErrorKind, capability_for, recovery_allowed, scope_for}; +use super::yaml::strict_yaml_to_json; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EffectRequirement { + pub kind: String, + pub scope: String, + pub recovery: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DelegatedCall { + pub dependency: String, + pub entry: String, + pub allow: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EntryEffects { + pub direct: Vec, + pub delegated: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EffectContract { + pub entries: BTreeMap, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PluginDependency { + pub alias: String, + pub id: String, + pub version: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PluginEntry { + pub path: String, + pub export: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StateDeclaration { + pub scope: String, + pub schema: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PluginManifest { + pub id: String, + pub version: String, + pub kind: String, + pub publisher: String, + pub runtime: String, + pub contract: String, + pub entry: PluginEntry, + pub dependencies: Vec, + pub capabilities: Vec, + pub effects: EffectContract, + pub state: Option, +} + +impl PluginManifest { + pub fn declared_capabilities(&self) -> Vec { + self.capabilities.clone() + } + + pub(crate) fn parsed_version(&self) -> Result { + Version::parse(&self.version) + .map_err(|_| PluginError::new(PluginErrorKind::UnknownField, "version")) + } + + pub(crate) fn runtime_matches(&self, host: &Version) -> Result { + let requirement = VersionReq::parse(&self.runtime).map_err(|_| { + PluginError::new(PluginErrorKind::ContractIncompatible, self.runtime.clone()) + })?; + Ok(requirement.matches(host)) + } +} + +pub fn parse_plugin_manifest(text: &str) -> Result { + let value = strict_yaml_to_json(text)?; + super::schema::validate_manifest(&value)?; + let object = value + .as_object() + .ok_or_else(|| PluginError::new(PluginErrorKind::UnknownField, "manifest"))?; + for key in object.keys() { + if !super::error::MANIFEST_FIELDS.contains(&key.as_str()) { + return Err(PluginError::new(PluginErrorKind::UnknownField, key.clone())); + } + } + require(object, "format", "rustscript.plugin/v1")?; + require(object, "host_abi", "agent.cap/v1")?; + if !object.contains_key("effects") { + return Err(PluginError::new(PluginErrorKind::MissingEffects, "effects")); + } + let id = string_field(object, "id")?; + if !valid_id(&id) { + return Err(PluginError::new(PluginErrorKind::UnknownField, "id")); + } + let version = string_field(object, "version")?; + Version::parse(&version) + .map_err(|_| PluginError::new(PluginErrorKind::UnknownField, "version"))?; + let kind = string_field(object, "kind")?; + if !super::error::PLUGIN_KINDS.contains(&kind.as_str()) { + return Err(PluginError::new(PluginErrorKind::UnknownField, "kind")); + } + let contract = string_field(object, "contract")?; + let expected_contract = match kind.as_str() { + "library" => "agent.library/v1", + "tool" => "agent.tool/v1", + _ => return Err(PluginError::new(PluginErrorKind::UnknownField, "kind")), + }; + if contract != expected_contract { + return Err(PluginError::new( + PluginErrorKind::ContractIncompatible, + contract, + )); + } + let runtime = string_field(object, "runtime")?; + VersionReq::parse(&runtime) + .map_err(|_| PluginError::new(PluginErrorKind::UnknownField, "runtime"))?; + let entry = parse_entry( + object + .get("entry") + .ok_or_else(|| PluginError::new(PluginErrorKind::UnknownField, "entry"))?, + )?; + let dependencies = parse_dependencies( + object + .get("dependencies") + .ok_or_else(|| PluginError::new(PluginErrorKind::UnknownField, "dependencies"))?, + )?; + let capabilities = parse_capabilities( + object + .get("capabilities") + .ok_or_else(|| PluginError::new(PluginErrorKind::UnknownField, "capabilities"))?, + )?; + let effects = parse_effects(object.get("effects").unwrap(), &entry.export, &dependencies)?; + let state = match object.get("state") { + Some(value) => Some(parse_state(value)?), + None => None, + }; + let required = required_capabilities(&effects); + if capabilities.iter().collect::>() != required.iter().collect::>() { + return Err(PluginError::new( + PluginErrorKind::ContractIncompatible, + "capabilities", + )); + } + let mut parts = id.split('.'); + let publisher = format!("{}.{}", parts.next().unwrap(), parts.next().unwrap()); + Ok(PluginManifest { + id, + version, + kind, + publisher, + runtime, + contract, + entry, + dependencies, + capabilities, + effects, + state, + }) +} + +fn required_capabilities(effects: &EffectContract) -> Vec { + let mut caps = BTreeSet::new(); + for entry in effects.entries.values() { + for effect in &entry.direct { + if let Some(cap) = capability_for(&effect.kind) { + caps.insert(cap.to_string()); + } + } + } + caps.into_iter().collect() +} + +fn parse_entry(value: &Value) -> Result { + let object = value + .as_object() + .ok_or_else(|| PluginError::new(PluginErrorKind::UnknownField, "entry"))?; + reject_unknown(object, &["path", "export"], "entry")?; + let path = string_field(object, "path")?; + let export = string_field(object, "export")?; + if !safe_relative(&path) { + return Err(PluginError::new(PluginErrorKind::PathEscape, path)); + } + if !safe_export(&export) { + return Err(PluginError::new(PluginErrorKind::UnknownField, "export")); + } + Ok(PluginEntry { path, export }) +} + +fn parse_dependencies(value: &Value) -> Result, PluginError> { + let items = value + .as_array() + .ok_or_else(|| PluginError::new(PluginErrorKind::UnknownField, "dependencies"))?; + let mut aliases = BTreeSet::new(); + let mut dependencies = Vec::new(); + for item in items { + let object = item + .as_object() + .ok_or_else(|| PluginError::new(PluginErrorKind::UnknownField, "dependency"))?; + reject_unknown(object, &["alias", "id", "version"], "dependency")?; + let alias = string_field(object, "alias")?; + let id = string_field(object, "id")?; + let version = string_field(object, "version")?; + if !safe_export(&alias) || !valid_id(&id) || VersionReq::parse(&version).is_err() { + return Err(PluginError::new( + PluginErrorKind::UnknownField, + "dependency", + )); + } + if !aliases.insert(alias.clone()) { + return Err(PluginError::new(PluginErrorKind::DuplicateField, alias)); + } + dependencies.push(PluginDependency { alias, id, version }); + } + Ok(dependencies) +} + +fn parse_capabilities(value: &Value) -> Result, PluginError> { + let items = value + .as_array() + .ok_or_else(|| PluginError::new(PluginErrorKind::UnknownField, "capabilities"))?; + let mut capabilities = Vec::new(); + for item in items { + let text = item + .as_str() + .ok_or_else(|| PluginError::new(PluginErrorKind::UnknownField, "capability"))?; + if capability_for_name(text).is_none() { + return Err(PluginError::new(PluginErrorKind::UnknownField, text)); + } + capabilities.push(text.to_string()); + } + capabilities.sort(); + if capabilities.windows(2).any(|pair| pair[0] == pair[1]) { + return Err(PluginError::new( + PluginErrorKind::DuplicateField, + "capabilities", + )); + } + Ok(capabilities) +} + +fn capability_for_name(name: &str) -> Option<&'static str> { + [ + "workspace.read", + "workspace.write", + "process.spawn", + "model.request", + "remote.write", + "clock.read", + "event.emit", + "delivery.send", + "secret.use", + ] + .into_iter() + .find(|item| *item == name) +} + +fn parse_effects( + value: &Value, + export: &str, + dependencies: &[PluginDependency], +) -> Result { + let object = value + .as_object() + .ok_or_else(|| PluginError::new(PluginErrorKind::MissingEffects, "effects"))?; + reject_unknown(object, &["contract", "entries"], "effects")?; + require(object, "contract", "agent.effects/v1")?; + let entries = object + .get("entries") + .and_then(Value::as_object) + .ok_or_else(|| PluginError::new(PluginErrorKind::MissingEffects, "entries"))?; + if !entries.contains_key(export) { + return Err(PluginError::new(PluginErrorKind::UncoveredExport, export)); + } + let mut parsed = BTreeMap::new(); + for (name, entry) in entries { + if !safe_export(name) { + return Err(PluginError::new( + PluginErrorKind::UncoveredExport, + name.clone(), + )); + } + parsed.insert(name.clone(), parse_entry_effects(entry, dependencies)?); + } + Ok(EffectContract { entries: parsed }) +} + +fn parse_entry_effects( + value: &Value, + dependencies: &[PluginDependency], +) -> Result { + let object = value + .as_object() + .ok_or_else(|| PluginError::new(PluginErrorKind::MissingEffects, "entry effects"))?; + reject_unknown(object, &["direct", "delegated"], "entry effects")?; + let direct = object + .get("direct") + .and_then(Value::as_array) + .ok_or_else(|| PluginError::new(PluginErrorKind::MissingEffects, "direct"))?; + let delegated = object + .get("delegated") + .and_then(Value::as_array) + .ok_or_else(|| PluginError::new(PluginErrorKind::MissingEffects, "delegated"))?; + Ok(EntryEffects { + direct: direct + .iter() + .map(parse_effect) + .collect::, _>>()?, + delegated: delegated + .iter() + .map(|item| parse_delegated(item, dependencies)) + .collect::, _>>()?, + }) +} + +fn parse_delegated( + value: &Value, + dependencies: &[PluginDependency], +) -> Result { + let object = value + .as_object() + .ok_or_else(|| PluginError::new(PluginErrorKind::UnknownField, "delegated"))?; + reject_unknown(object, &["dependency", "entry", "allow"], "delegated")?; + let dependency = string_field(object, "dependency")?; + let entry = string_field(object, "entry")?; + if !dependencies.iter().any(|item| item.alias == dependency) || !safe_export(&entry) { + return Err(PluginError::new(PluginErrorKind::UnknownField, dependency)); + } + let allow = object + .get("allow") + .and_then(Value::as_array) + .ok_or_else(|| PluginError::new(PluginErrorKind::UnknownField, "allow"))?; + Ok(DelegatedCall { + dependency, + entry, + allow: allow + .iter() + .map(parse_effect) + .collect::, _>>()?, + }) +} + +fn parse_effect(value: &Value) -> Result { + let object = value + .as_object() + .ok_or_else(|| PluginError::new(PluginErrorKind::UnknownEffect, "effect"))?; + reject_unknown(object, &["kind", "scope", "recovery"], "effect")?; + let kind = string_field(object, "kind")?; + let scope = string_field(object, "scope")?; + let recovery = string_field(object, "recovery")?; + if capability_for(&kind).is_none() { + return Err(PluginError::new(PluginErrorKind::UnknownEffect, kind)); + } + if scope_for(&kind) != Some(scope.as_str()) { + return Err(PluginError::new(PluginErrorKind::UnknownEffect, scope)); + } + if !recovery_allowed(&kind, &recovery) { + return Err(PluginError::new( + PluginErrorKind::ForgedRecovery, + format!("{kind} {recovery}"), + )); + } + Ok(EffectRequirement { + kind, + scope, + recovery, + }) +} + +fn parse_state(value: &Value) -> Result { + let object = value + .as_object() + .ok_or_else(|| PluginError::new(PluginErrorKind::UnknownField, "state"))?; + reject_unknown(object, &["scope", "schema"], "state")?; + let scope = string_field(object, "scope")?; + if scope != "run" { + return Err(PluginError::new( + PluginErrorKind::UnknownField, + "state.scope", + )); + } + let schema = object + .get("schema") + .and_then(Value::as_u64) + .ok_or_else(|| PluginError::new(PluginErrorKind::UnknownField, "state.schema"))?; + if schema < 1 { + return Err(PluginError::new( + PluginErrorKind::UnknownField, + "state.schema", + )); + } + Ok(StateDeclaration { scope, schema }) +} + +fn require( + object: &serde_json::Map, + key: &str, + expected: &str, +) -> Result<(), PluginError> { + let actual = string_field(object, key)?; + if actual != expected { + Err(PluginError::new(PluginErrorKind::UnknownField, key)) + } else { + Ok(()) + } +} + +fn string_field(object: &serde_json::Map, key: &str) -> Result { + object + .get(key) + .and_then(Value::as_str) + .map(str::to_string) + .ok_or_else(|| PluginError::new(PluginErrorKind::UnknownField, key)) +} + +fn reject_unknown( + object: &serde_json::Map, + allowed: &[&str], + label: &str, +) -> Result<(), PluginError> { + for key in object.keys() { + if !allowed.contains(&key.as_str()) { + return Err(PluginError::new( + PluginErrorKind::UnknownField, + format!("{label}.{key}"), + )); + } + } + for key in allowed { + if !object.contains_key(*key) { + return Err(PluginError::new( + PluginErrorKind::UnknownField, + format!("{label}.{key}"), + )); + } + } + Ok(()) +} + +fn valid_id(id: &str) -> bool { + let mut parts = id.split('.'); + let Some(first) = parts.next() else { + return false; + }; + let Some(second) = parts.next() else { + return false; + }; + [first, second].into_iter().chain(parts).all(safe_ident) +} + +fn safe_ident(text: &str) -> bool { + let mut chars = text.chars(); + matches!(chars.next(), Some(first) if first.is_ascii_lowercase() || first.is_ascii_digit()) + && chars.all(|character| character.is_ascii_lowercase() || character.is_ascii_digit()) + && !text.is_empty() +} + +fn safe_export(text: &str) -> bool { + let mut chars = text.chars(); + matches!(chars.next(), Some(first) if first == '_' || first.is_ascii_alphabetic()) + && chars.all(|character| character == '_' || character.is_ascii_alphanumeric()) +} + +pub(crate) fn safe_relative(path: &str) -> bool { + !path.is_empty() + && !path.starts_with('/') + && !path.contains('\\') + && !path + .split('/') + .any(|part| part.is_empty() || part == "." || part == "..") + && path.split('/').all(|part| { + part.chars().all(|character| { + character.is_ascii_alphanumeric() + || character == '_' + || character == '-' + || character == '.' + }) + }) +} diff --git a/src/plugins/mod.rs b/src/plugins/mod.rs new file mode 100644 index 0000000..a9b6280 --- /dev/null +++ b/src/plugins/mod.rs @@ -0,0 +1,41 @@ +//! RSS plugin manifest, immutable package store, and effect closure. +//! +//! This module stops at a prepared closed entry. It does not activate a +//! service, open global storage, or turn a signature into a capability. + +mod control_store; +mod error; +mod manifest; +mod pin; +mod prepare; +mod resolve; +mod schema; +mod store; +mod yaml; + +pub use crate::runtime::native_tools::trusted_tool_package_root; +pub use control_store::{ + ActivationHistoryEntry, ActivationPointer, GraphRetention, PackageArtifactRef, + PluginControlError, PluginControlStore, StoreLockReceipt, ValidatedLockMetadata, +}; +pub use error::{ + COMPILE_ENVIRONMENT, EFFECT_KINDS, EFFECT_RECOVERY, GRAPH_LOCK_FIELDS, HOST_RUNTIME, + MANIFEST_FIELDS, PLUGIN_KINDS, PluginError, PluginErrorKind, +}; +pub use manifest::parse_plugin_manifest; +pub use pin::{ + PinnedAdmitContext, PinnedToolAdmission, PinnedToolAdmitError, admit_pinned_tool_run, + restore_owned_pin, +}; +pub(crate) use pin::{prepare_production_pin, seal_production_receipt}; +pub use resolve::{ + EffectOrigin, GraphLock, LockedEdge, LockedEffect, LockedEffectItem, LockedPackage, + PackageRequirement, ResolveRequest, ScopeBinding, resolve_installed, +}; +pub use schema::{canonical_schema_documents, schema_snapshot_drift}; +pub(crate) use store::embedding_payload_bytes; +pub use store::{ + InstallAuthorization, InstalledPackage, PackageRevision, PackageStore, PackageTrust, + PublisherKey, +}; +pub use yaml::strict_yaml_to_json; diff --git a/src/plugins/pin.rs b/src/plugins/pin.rs new file mode 100644 index 0000000..4e67678 --- /dev/null +++ b/src/plugins/pin.rs @@ -0,0 +1,605 @@ +//! Native admission pins one captured tool program inside the original +//! admission transaction. +//! +//! Caller JSON cannot select the graph, mint a ref, or choose the callable. +//! A successful replay returns the original run and ref without reading the +//! current activation or compiling a newer graph. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use serde_json::{Value, json}; +use uuid::Uuid; + +use super::control_store::PluginControlStore; +use super::resolve::{GraphLock, LockedPackage}; +use super::store::PackageStore; +use crate::gateway::store::GatewayPersistence; +use crate::runtime::agent_host::{PinnedToolCode, PinnedToolProgram, PinnedToolReceipt}; + +const POSTCOMMIT_UNPUBLISHED: &str = "failpoint_after_commit_before_publish"; + +#[derive(Debug)] +pub struct PinnedToolAdmitError { + pub message: String, +} + +impl std::fmt::Display for PinnedToolAdmitError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.message) + } +} + +impl PinnedToolAdmitError { + fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +/// Trusted host identity for a new admission. It is not read from caller JSON. +#[derive(Clone, Debug)] +pub struct PinnedAdmitContext { + pub registry_identity: String, + pub workspace: PathBuf, +} + +/// Admission receipt plus the callable program compiled from the pinned capture. +/// Display fields are not the execution identity. `program_handle` clones the +/// binding sealed once from the verified capture and committed receipt. +#[derive(Clone)] +pub struct PinnedToolAdmission { + binding: Arc, + pub graph_id: String, + pub generation: u64, + pub ref_id: String, + pub run_id: String, + pub session_id: String, + pub profile: String, + pub entry: String, +} + +impl PinnedToolAdmission { + pub fn program_handle(&self) -> PinnedToolProgram { + (*self.binding).clone() + } + + pub fn shares_program(&self, other: &Arc) -> bool { + self.binding.shares_program(other) + } +} + +/// Pins the active tool program, or replays the original pin for the same key. +/// +/// Replay does not read the current activation, allocate a ref, or prepare a +/// newer graph. A post-commit publication failure is recovered from the +/// committed receipt; it is not reported as a rollback. +pub fn admit_pinned_tool_run( + persistence: &GatewayPersistence, + store: &PackageStore, + caller: &Value, + context: &PinnedAdmitContext, +) -> Result { + let key = text_field(caller, "idempotency_key"); + let scope = text_field(caller, "idempotency_scope"); + let hash = text_field(caller, "request_hash"); + if !key.is_empty() { + let selector = optional_selector(caller); + let replayed = persistence + .admission_replay(&scope, &key, &hash, selector.as_deref()) + .map_err(|error| { + PinnedToolAdmitError::new(format!("{}: {}", error.code, error.message)) + })?; + if replayed.get("conflict") == Some(&Value::Bool(true)) { + return Err(PinnedToolAdmitError::new( + "idempotency key was used with a different request", + )); + } + if replayed.get("replayed") == Some(&Value::Bool(true)) { + return recover_pinned(persistence, store, &replayed); + } + } + admit_new(persistence, store, caller, context) +} + +fn admit_new( + persistence: &GatewayPersistence, + store: &PackageStore, + caller: &Value, + context: &PinnedAdmitContext, +) -> Result { + let control = PluginControlStore::new(persistence); + let active = control + .activation() + .map_err(|error| PinnedToolAdmitError::new(error.message))?; + if active.graph_id.is_empty() { + return Err(PinnedToolAdmitError::new( + "tool admission requires an active plugin graph", + )); + } + let loaded = control + .load_lock(&active.graph_id) + .map_err(|error| PinnedToolAdmitError::new(error.message))?; + let package = select_tool_package(&loaded.lock)?; + let prepared = store + .prepare(&loaded.lock) + .map_err(|error| PinnedToolAdmitError::new(error.to_string()))?; + let captured = capture_tool(&prepared, package)?; + let ref_id = Uuid::new_v4().to_string(); + let now_ms = caller.get("now_ms").cloned().unwrap_or(json!(0)); + let mut payload = caller.clone(); + let Some(object) = payload.as_object_mut() else { + return Err(PinnedToolAdmitError::new( + "admission payload must be an object", + )); + }; + for key in [ + "graph_id", + "tool_graph_id", + "tool_graph_generation", + "tool_ref_id", + "tool_pin_now_ms", + "tool_registry_identity", + "tool_workspace", + ] { + object.remove(key); + } + object.insert("tool_graph_id".to_string(), json!(active.graph_id)); + object.insert( + "tool_graph_generation".to_string(), + json!(active.generation), + ); + object.insert("tool_ref_id".to_string(), json!(ref_id)); + object.insert("tool_pin_now_ms".to_string(), now_ms); + object.insert( + "tool_registry_identity".to_string(), + json!(context.registry_identity), + ); + object.insert( + "tool_workspace".to_string(), + json!(path_text(&context.workspace)), + ); + match persistence.admission_create(&payload) { + Ok(data) => { + if data.get("replayed") == Some(&Value::Bool(true)) { + return recover_pinned(persistence, store, &data); + } + let pin = data + .get("pin") + .ok_or_else(|| PinnedToolAdmitError::new("admission did not return a tool pin"))?; + let pinned_graph = text_field(pin, "graph_id"); + let pinned_ref = text_field(pin, "ref_id"); + let pinned_generation = number_field(pin, "generation"); + if pinned_graph != active.graph_id + || pinned_ref != ref_id + || pinned_generation != active.generation + { + return Err(PinnedToolAdmitError::new( + "admission pin receipt does not match the active graph", + )); + } + let run_id = first_present(&[text_field(pin, "run_id"), text_field(caller, "run_id")]); + let session_id = first_present(&[ + text_field(pin, "session_id"), + text_field(caller, "session_id"), + ]); + let profile = + first_present(&[text_field(pin, "profile"), text_field(caller, "profile")]); + if run_id.is_empty() || session_id.is_empty() || profile.is_empty() { + return Err(PinnedToolAdmitError::new( + "admission receipt is missing the committed run identity", + )); + } + Ok(binding_from_capture( + captured, + PinnedToolReceipt { + graph_id: active.graph_id, + generation: active.generation, + ref_id, + run_id, + session_id, + profile, + registry_identity: context.registry_identity.clone(), + workspace: context.workspace.clone(), + }, + )) + } + Err(error) if error.code == POSTCOMMIT_UNPUBLISHED => recover_unpublished( + persistence, + store, + &text_field(caller, "idempotency_scope"), + &text_field(caller, "idempotency_key"), + &text_field(caller, "request_hash"), + optional_selector(caller).as_deref(), + ), + Err(error) => Err(PinnedToolAdmitError::new(format!( + "{}: {}", + error.code, error.message + ))), + } +} + +fn recover_unpublished( + persistence: &GatewayPersistence, + store: &PackageStore, + scope: &str, + key: &str, + hash: &str, + selector: Option<&str>, +) -> Result { + if key.is_empty() { + return Err(PinnedToolAdmitError::new( + "admission committed but live publication failed and no idempotency key can recover it", + )); + } + let replayed = persistence + .admission_replay(scope, key, hash, selector) + .map_err(|error| PinnedToolAdmitError::new(format!("{}: {}", error.code, error.message)))?; + if replayed.get("replayed") != Some(&Value::Bool(true)) { + return Err(PinnedToolAdmitError::new( + "admission committed but live publication failed and exact recovery did not return the original run", + )); + } + recover_pinned(persistence, store, &replayed) +} + +fn recover_pinned( + persistence: &GatewayPersistence, + store: &PackageStore, + data: &Value, +) -> Result { + let pin = data + .get("pin") + .ok_or_else(|| PinnedToolAdmitError::new("replay did not return the original tool pin"))?; + if pin.get("pinned") != Some(&Value::Bool(true)) { + return Err(PinnedToolAdmitError::new( + "replayed admission has no durable tool pin", + )); + } + let graph_id = required_text(pin, "graph_id")?; + let ref_id = required_text(pin, "ref_id")?; + let run_id = required_text(pin, "run_id")?; + let session_id = required_text(pin, "session_id")?; + let profile = required_text(pin, "profile")?; + let registry_identity = required_text(pin, "registry_identity")?; + let workspace = pinned_workspace(pin)?; + let generation = number_field(pin, "generation"); + let control = PluginControlStore::new(persistence); + let loaded = control + .load_lock(&graph_id) + .map_err(|error| PinnedToolAdmitError::new(error.message))?; + let package = select_tool_package(&loaded.lock)?; + if !control + .package_refs(&graph_id) + .map_err(|error| PinnedToolAdmitError::new(error.message))? + .iter() + .any(|item| item.package_id == package.id && item.artifact_id == package.artifact_id) + { + return Err(PinnedToolAdmitError::new( + "pinned package artifact is no longer the captured ref", + )); + } + let prepared = store + .prepare(&loaded.lock) + .map_err(|error| PinnedToolAdmitError::new(error.to_string()))?; + let captured = capture_tool(&prepared, package)?; + Ok(binding_from_capture( + captured, + PinnedToolReceipt { + graph_id, + generation, + ref_id, + run_id, + session_id, + profile, + registry_identity, + workspace, + }, + )) +} + +struct CapturedTool { + program: Arc, + invoke_entry: String, + export: String, + package_id: String, + artifact_id: String, + registry: Result, String>, +} + +fn capture_tool( + prepared: &super::prepare::PreparedGraph, + package: &LockedPackage, +) -> Result { + let program = prepared.program(&package.id).ok_or_else(|| { + PinnedToolAdmitError::new(format!("prepared graph dropped program {}", package.id)) + })?; + let invoke_entry = prepared + .entry_callable(&package.id) + .filter(|entry| !entry.is_empty()) + .ok_or_else(|| PinnedToolAdmitError::new("captured tool has no callable entry"))? + .to_string(); + let export = prepared + .export_name(&package.id) + .filter(|export| !export.is_empty()) + .ok_or_else(|| PinnedToolAdmitError::new("captured manifest has no export"))? + .to_string(); + Ok(CapturedTool { + registry: prepared + .registry(&package.id) + .ok_or_else(|| PinnedToolAdmitError::new("prepared graph dropped tool registry"))?, + program, + invoke_entry, + export, + package_id: package.id.clone(), + artifact_id: package.artifact_id.clone(), + }) +} + +fn binding_from_capture(captured: CapturedTool, receipt: PinnedToolReceipt) -> PinnedToolAdmission { + let binding = PinnedToolProgram::seal( + PinnedToolCode { + program: captured.program, + entry: captured.invoke_entry, + package_id: captured.package_id.clone(), + artifact_id: captured.artifact_id, + export: captured.export.clone(), + registry: captured.registry, + expects_dispatch_input: captured.package_id + == crate::runtime::native_tools::EMBEDDING_PACKAGE_ID, + }, + receipt.clone(), + ); + PinnedToolAdmission { + binding: Arc::new(binding), + graph_id: receipt.graph_id, + generation: receipt.generation, + ref_id: receipt.ref_id, + run_id: receipt.run_id, + session_id: receipt.session_id, + profile: receipt.profile, + entry: captured.export, + } +} + +/// Active graph prepared outside the admission gate. The ref is this attempt's +/// receipt identity, not a caller-selected graph. +pub(crate) struct PreparedProductionPin { + pub(crate) graph_id: String, + pub(crate) generation: u64, + pub(crate) ref_id: String, + captured: CapturedTool, +} + +pub(crate) fn prepare_production_pin( + persistence: &GatewayPersistence, + store: &PackageStore, + active: &super::control_store::ActivationPointer, +) -> Result { + let control = PluginControlStore::new(persistence); + if active.graph_id.is_empty() { + return Err(PinnedToolAdmitError::new( + "tool admission requires an active plugin graph", + )); + } + let cached = store + .prepared + .lock() + .unwrap_or_else(|error| error.into_inner()) + .as_ref() + .filter(|(lock, _)| lock.graph_id == active.graph_id) + .map(|(lock, _)| lock.clone()); + let lock = match cached { + Some(lock) => lock, + None => { + control + .load_lock(&active.graph_id) + .map_err(|error| PinnedToolAdmitError::new(error.message))? + .lock + } + }; + let package = select_tool_package(&lock)?; + let prepared = store + .prepare(&lock) + .map_err(|error| PinnedToolAdmitError::new(error.to_string()))?; + let captured = capture_tool(&prepared, package)?; + Ok(PreparedProductionPin { + graph_id: active.graph_id.clone(), + generation: active.generation, + ref_id: Uuid::new_v4().to_string(), + captured, + }) +} + +pub(crate) fn seal_production_receipt( + prepared: PreparedProductionPin, + receipt: &Value, + registry_identity: String, + workspace: PathBuf, +) -> Result { + let pin = receipt + .get("pin") + .ok_or_else(|| PinnedToolAdmitError::new("admission did not return a tool pin"))?; + let pinned_graph = required_text(pin, "graph_id")?; + let pinned_ref = required_text(pin, "ref_id")?; + let pinned_generation = number_field(pin, "generation"); + if pinned_graph != prepared.graph_id + || pinned_ref != prepared.ref_id + || pinned_generation != prepared.generation + { + return Err(PinnedToolAdmitError::new( + "admission pin receipt does not match the prepared graph", + )); + } + let run_id = required_text(pin, "run_id")?; + let session_id = required_text(pin, "session_id")?; + let profile = required_text(pin, "profile")?; + if required_text(pin, "registry_identity")? != registry_identity + || pinned_workspace(pin)? != workspace + { + return Err(PinnedToolAdmitError::new( + "admission pin Native context does not match its own receipt", + )); + } + Ok(binding_from_capture( + prepared.captured, + PinnedToolReceipt { + graph_id: prepared.graph_id, + generation: prepared.generation, + ref_id: prepared.ref_id, + run_id, + session_id, + profile, + registry_identity, + workspace, + }, + ) + .program_handle()) +} + +pub fn restore_owned_pin( + persistence: &GatewayPersistence, + store: &PackageStore, + owner_id: &str, +) -> Result { + let control = PluginControlStore::new(persistence); + let pin = control + .pin_for_run(owner_id) + .map_err(|error| PinnedToolAdmitError::new(error.message))?; + if pin.get("pinned") != Some(&Value::Bool(true)) { + return Err(PinnedToolAdmitError::new( + "durable run has no retained tool pin", + )); + } + let graph_id = required_text(&pin, "graph_id")?; + let ref_id = required_text(&pin, "ref_id")?; + let run_id = required_text(&pin, "run_id")?; + let session_id = required_text(&pin, "session_id")?; + let profile = required_text(&pin, "profile")?; + let registry_identity = required_text(&pin, "registry_identity")?; + let workspace = pinned_workspace(&pin)?; + let generation = number_field(&pin, "generation"); + if run_id != owner_id || graph_id.is_empty() || ref_id.is_empty() { + return Err(PinnedToolAdmitError::new( + "retained tool pin does not match the original run", + )); + } + let loaded = control + .load_lock(&graph_id) + .map_err(|error| PinnedToolAdmitError::new(error.message))?; + let package = select_tool_package(&loaded.lock)?; + if !control + .package_refs(&graph_id) + .map_err(|error| PinnedToolAdmitError::new(error.message))? + .iter() + .any(|item| item.package_id == package.id && item.artifact_id == package.artifact_id) + { + return Err(PinnedToolAdmitError::new( + "pinned package artifact is no longer the captured ref", + )); + } + let prepared = store + .prepare(&loaded.lock) + .map_err(|error| PinnedToolAdmitError::new(error.to_string()))?; + let captured = capture_tool(&prepared, package)?; + Ok(binding_from_capture( + captured, + PinnedToolReceipt { + graph_id, + generation, + ref_id, + run_id, + session_id, + profile, + registry_identity, + workspace, + }, + ) + .program_handle()) +} + +fn select_tool_package(lock: &GraphLock) -> Result<&LockedPackage, PinnedToolAdmitError> { + let mut tools = lock + .packages + .iter() + .filter(|package| package.kind == "tool"); + let Some(selected) = tools.next() else { + return Err(PinnedToolAdmitError::new( + "pinned graph has no tool package", + )); + }; + if tools.next().is_some() { + return Err(PinnedToolAdmitError::new( + "pinned graph tool entry is ambiguous", + )); + } + Ok(selected) +} + +fn optional_selector(caller: &Value) -> Option { + let identity = caller + .get("workspace_identity_json") + .and_then(Value::as_str) + .unwrap_or(""); + if identity.is_empty() { + return None; + } + let Ok(parsed) = serde_json::from_str::(identity) else { + return None; + }; + if parsed.get("request_selector_present") == Some(&Value::Bool(true)) + || parsed.get("request_selector_present") == Some(&json!(1)) + { + return Some(text_field(&parsed, "request_selector")); + } + None +} + +fn text_field(value: &Value, key: &str) -> String { + value + .get(key) + .and_then(Value::as_str) + .unwrap_or("") + .to_string() +} + +fn pinned_workspace(pin: &Value) -> Result { + // Empty represents the original NoAuthority binding. It is not a path grant. + pin.get("workspace") + .and_then(Value::as_str) + .map(PathBuf::from) + .ok_or_else(|| PinnedToolAdmitError::new("replayed pin is missing workspace identity")) +} + +fn required_text(value: &Value, key: &str) -> Result { + let text = text_field(value, key); + if text.is_empty() { + return Err(PinnedToolAdmitError::new(format!( + "replayed pin is missing {key}" + ))); + } + Ok(text) +} + +fn number_field(value: &Value, key: &str) -> u64 { + value + .get(key) + .and_then(|item| { + item.as_u64() + .or_else(|| item.as_i64().map(|number| number as u64)) + }) + .unwrap_or(0) +} + +fn first_present(values: &[String]) -> String { + values + .iter() + .find(|value| !value.is_empty()) + .cloned() + .unwrap_or_default() +} + +fn path_text(path: &Path) -> String { + path.to_string_lossy().into_owned() +} diff --git a/src/plugins/prepare.rs b/src/plugins/prepare.rs new file mode 100644 index 0000000..2810db4 --- /dev/null +++ b/src/plugins/prepare.rs @@ -0,0 +1,496 @@ +//! Prepare a locked graph's real closed RSS entries. +//! +//! Compilation sees only captured package bytes. Host imports are charged from +//! the native catalog, never from a plugin's own description of itself. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; +use std::sync::Arc; + +use rustscript_vm::{ImportClause, SharedParserOptions, UsePathSegment, parse_source_with_dialect}; + +use crate::runtime::agent_host::agent_host_catalog; +use crate::runtime::closed_bundle::{self, ClosedBundleLimits, ClosedBundleOptions}; +use crate::runtime::module_snapshot::snapshot_parser_dialect; + +use super::error::{COMPILE_ENVIRONMENT, PluginError, PluginErrorKind}; +use super::resolve::GraphLock; +use super::store::{InstalledPackage, PackageStore, PackageTrust}; + +const VIRTUAL_ROOT: &str = "/__rustscript_agent_plugin_rss__"; + +#[derive(Clone)] +pub struct PreparedGraph { + sources: BTreeMap<(String, String), Vec>, + callables: BTreeMap, + exports: BTreeMap, + trust: BTreeMap, + programs: BTreeMap>, + // Prepared with the captured code, never with a run or capability owner. + registries: BTreeMap, String>>, +} + +impl std::fmt::Debug for PreparedGraph { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("PreparedGraph") + .field("callables", &self.callables) + .field("trust", &self.trust) + .field("programs", &self.programs.keys().collect::>()) + .finish() + } +} + +impl PreparedGraph { + pub fn captured_source(&self, package_id: &str, path: &str) -> Option<&[u8]> { + self.sources + .get(&(package_id.to_string(), path.to_string())) + .map(Vec::as_slice) + } + + pub fn entry_callable(&self, package_id: &str) -> Option<&str> { + self.callables.get(package_id).map(String::as_str) + } + + /// Export name from the captured package manifest, not a later store read. + pub fn export_name(&self, package_id: &str) -> Option<&str> { + self.exports.get(package_id).map(String::as_str) + } + + pub fn trust(&self, package_id: &str) -> Option { + self.trust.get(package_id).copied() + } + + /// Code-scoped import binding only; no run, owner, token or grant is retained. + pub(crate) fn registry( + &self, + package_id: &str, + ) -> Option, String>> { + self.registries.get(package_id).cloned() + } + + /// Callable program compiled from the captured bytes. The graph id is not + /// a substitute for this program. + pub fn program(&self, package_id: &str) -> Option> { + self.programs.get(package_id).cloned() + } +} + +impl PackageStore { + pub fn resolve( + &self, + request: &super::resolve::ResolveRequest, + ) -> Result { + super::resolve::resolve_installed(&self.load_installed()?, request) + } + + pub fn prepare(&self, lock: &GraphLock) -> Result { + if lock.compile_environment != COMPILE_ENVIRONMENT { + return Err(PluginError::new( + PluginErrorKind::ContractIncompatible, + "compile environment", + )); + } + if let Some((cached_lock, prepared)) = self + .prepared + .lock() + .unwrap_or_else(|error| error.into_inner()) + .as_ref() + && cached_lock == lock + { + return Ok(prepared.clone()); + } + // One verified capture. Compilation uses these bytes and does not re-read the store. + let required: Vec<(&str, &str)> = lock + .packages + .iter() + .map(|locked| (locked.id.as_str(), locked.artifact_id.as_str())) + .collect(); + let installed = self.verified_capture(&required)?; + let mut selected = Vec::new(); + for locked in &lock.packages { + let package = installed + .iter() + .find(|package| { + package.artifact_id == locked.artifact_id && package.manifest.id == locked.id + }) + .ok_or_else(|| { + PluginError::new(PluginErrorKind::MissingDependency, locked.id.clone()) + })?; + selected.push(package); + } + for package in &selected { + scan_package(package, &selected)?; + } + let mut prepared = PreparedGraph { + sources: BTreeMap::new(), + callables: BTreeMap::new(), + exports: BTreeMap::new(), + trust: BTreeMap::new(), + programs: BTreeMap::new(), + registries: BTreeMap::new(), + }; + for package in &selected { + for (path, bytes) in &package.sources { + prepared + .sources + .insert((package.manifest.id.clone(), path.clone()), bytes.clone()); + } + prepared + .trust + .insert(package.manifest.id.clone(), package.trust); + prepared.exports.insert( + package.manifest.id.clone(), + package.manifest.entry.export.clone(), + ); + let (callable, program) = compile_package(package)?; + let imports: Vec<_> = program + .imports + .iter() + .map(|import| import.name.clone()) + .collect(); + // Retain preparation errors for the same invocation-time failure boundary. + let registry = crate::runtime::rss_runner::build_pinned_registry(&imports) + .map(Arc::new) + .map_err(|error| error.to_string()); + prepared + .registries + .insert(package.manifest.id.clone(), registry); + prepared + .callables + .insert(package.manifest.id.clone(), callable); + prepared + .programs + .insert(package.manifest.id.clone(), program); + } + *self + .prepared + .lock() + .unwrap_or_else(|error| error.into_inner()) = Some((lock.clone(), prepared.clone())); + Ok(prepared) + } +} + +fn scan_package( + package: &InstalledPackage, + selected: &[&InstalledPackage], +) -> Result<(), PluginError> { + let aliases: BTreeSet<&str> = package + .manifest + .dependencies + .iter() + .map(|dependency| dependency.alias.as_str()) + .collect(); + let roots = host_roots(); + let prelude = catalog_prelude(); + for (path, bytes) in &package.sources { + let source = std::str::from_utf8(bytes) + .map_err(|_| PluginError::new(PluginErrorKind::CompileFailed, path.clone()))?; + let Ok(imports) = scan_source(&format!("{prelude}{source}")) else { + continue; + }; + let prelude_count = scan_source(&prelude).map(|items| items.len()).unwrap_or(0); + for import in imports.into_iter().skip(prelude_count) { + let Some(first) = import.first() else { + continue; + }; + if aliases.contains(first.as_str()) { + return Err(PluginError::new( + PluginErrorKind::PluginBoundaryBypass, + format!("{} {first}", package.manifest.id), + )); + } + if roots.contains(first) && !is_allowed_host_root(first) { + return Err(PluginError::new( + PluginErrorKind::UnauthorizedHostImport, + format!("{} {first}", package.manifest.id), + )); + } + } + } + let _ = selected; + Ok(()) +} + +fn scan_source( + source: &str, +) -> Result>, crate::runtime::closed_bundle::ClosedBundleError> { + let ir = parse_source_with_dialect( + source, + snapshot_parser_dialect(), + SharedParserOptions { + source_id: 0, + allow_implicit_externs: true, + allow_implicit_semicolons: false, + enforce_mutable_bindings: false, + import_scan_mode: true, + }, + ) + .map_err(|_| crate::runtime::closed_bundle::ClosedBundleError::SourceInvalid)?; + let mut imports = Vec::new(); + for declaration in ir.use_declarations { + if !matches!( + declaration.clause, + ImportClause::AllPublic | ImportClause::Named(_) | ImportClause::Namespace(_) + ) { + return Err(crate::runtime::closed_bundle::ClosedBundleError::ImportDenied); + } + let mut path = Vec::new(); + for segment in declaration.path { + match segment { + UsePathSegment::Ident(name) => path.push(name), + UsePathSegment::Self_ | UsePathSegment::Super => {} + } + } + imports.push(path); + } + Ok(imports) +} + +fn compile_package( + package: &InstalledPackage, +) -> Result<(String, Arc), PluginError> { + let host_roots = host_roots(); + let catalog = agent_host_catalog(); + let owned: Vec<(String, String)> = package + .sources + .iter() + .filter_map(|(path, bytes)| { + std::str::from_utf8(bytes).ok().map(|text| { + let compiled_text = if path == &package.manifest.entry.path { + // Pure builtin namespaces only. This is compile context, not + // stored package bytes, and it cannot grant an effect. + format!("use bytes;\nuse json;\n{text}") + } else { + text.to_string() + }; + (path.clone(), compiled_text) + }) + }) + .collect(); + let sources: Vec<(&str, &str)> = owned + .iter() + .map(|(path, text)| (path.as_str(), text.as_str())) + .collect(); + let compiled = closed_bundle::compile_bundled_closed_bundle( + &package.manifest.entry.path, + &sources, + ClosedBundleOptions { + limits: ClosedBundleLimits { + max_files: 64, + max_depth: 16, + max_file_bytes: 1024 * 1024, + max_total_bytes: 8 * 1024 * 1024, + }, + virtual_root: Path::new(VIRTUAL_ROOT), + host_roots: &host_roots, + catalog: Arc::clone(&catalog), + dialect: snapshot_parser_dialect(), + entry_export: &package.manifest.entry.export, + }, + ) + .map_err(|_| PluginError::new(PluginErrorKind::CompileFailed, package.manifest.id.clone()))?; + let entry = compiled.entry_name.ok_or_else(|| { + PluginError::new( + PluginErrorKind::UncoveredExport, + package.manifest.entry.export.clone(), + ) + })?; + charge_program(package, &compiled.program)?; + Ok((entry, Arc::new(compiled.program))) +} + +fn is_tool_role(package: &InstalledPackage) -> bool { + package.manifest.kind == "tool" && package.manifest.contract == "agent.tool/v1" +} + +fn charge_program( + package: &InstalledPackage, + program: &rustscript_vm::Program, +) -> Result<(), PluginError> { + let tool_role = is_tool_role(package); + for import in &program.imports { + // Role fencing precedes embedding exemptions: bounded completion and + // cancellation-aware waiting are tool operations regardless of trust. + if (is_tool_lease(&import.name) || import.name == "agent::sleep_ms") && !tool_role { + return Err(PluginError::new( + PluginErrorKind::UnauthorizedHostImport, + import.name.clone(), + )); + } + if package.trust == PackageTrust::EmbeddingOwnedBuiltin + && is_embedding_runtime_import(&import.name) + { + continue; + } + if let Some(effect) = charge_name(&import.name)? { + let declared = package + .manifest + .effects + .entries + .get(&package.manifest.entry.export) + .map(|entry| entry.direct.iter().any(|item| item.kind == effect)) + .unwrap_or(false); + if !declared { + return Err(PluginError::new( + PluginErrorKind::UndeclaredDirectEffect, + format!("{} {}", package.manifest.id, effect), + )); + } + } + } + Ok(()) +} + +fn charge_name(name: &str) -> Result, PluginError> { + if is_pure(name) { + return Ok(None); + } + let effect = match name { + "cap::fs_metadata" | "cap::fs_read_range" | "cap::fs_list" => Some("workspace.fs.read"), + "cap::fs_write_atomic" => Some("workspace.fs.write"), + "cap::process_spawn" | "cap::process_poll" | "cap::process_wait" | "cap::process_log" + | "cap::process_write" | "cap::process_close" | "cap::process_kill" => { + Some("process.spawn") + } + "cap::clock_monotonic_ms" | "agent::sleep_ms" => Some("clock.read"), + "agent::model_request" => Some("model.request"), + "stream::emit" => Some("event.emit"), + _ if name.starts_with("http::") => Some("remote.write"), + _ if is_tool_lease(name) => None, + _ if name.starts_with("sqlite::") || name.starts_with("auth::") => None, + _ => None, + }; + if name.starts_with("sqlite::") + || name.starts_with("auth::") + || (name.starts_with("agent_runtime::") && !is_tool_lease(name)) + { + return Err(PluginError::new( + PluginErrorKind::UnauthorizedHostImport, + name, + )); + } + if effect.is_none() && !is_pure(name) && !is_tool_lease(name) { + return Err(PluginError::new( + PluginErrorKind::UnauthorizedHostImport, + name, + )); + } + Ok(effect) +} + +fn is_embedding_runtime_import(name: &str) -> bool { + name.starts_with("cap::artifact_") +} + +fn is_tool_lease(name: &str) -> bool { + // Exact bounded call-result publication shares the prepared-call protocol. + // Native owner/call/run, metadata, single-result, quota and rollback checks + // remain authoritative; this grants no generic artifact put/get/reference. + matches!( + name, + "agent_runtime::tool_prepare" | "agent_runtime::tool_commit" | "cap::artifact_put_result" + ) +} + +fn is_pure(name: &str) -> bool { + name.starts_with("json::") + || name.starts_with("bytes::") + || matches!( + name, + "agent::utf8_byte_len" + | "agent::utf8_truncate" + | "agent::parse_json_object" + | "agent::control_check" + ) +} + +fn is_allowed_host_root(root: &str) -> bool { + matches!( + root, + "json" | "bytes" | "cap" | "agent" | "agent_runtime" | "http" | "stream" + ) +} + +fn catalog_prelude() -> String { + let mut prelude = String::new(); + for root in host_roots() { + prelude.push_str("use "); + prelude.push_str(&root); + prelude.push_str(";\n"); + } + prelude +} + +fn host_roots() -> BTreeSet { + let mut roots = BTreeSet::from(["json".to_string(), "bytes".to_string()]); + for function in agent_host_catalog().functions() { + if let Some((namespace, _)) = function.name.split_once("::") { + roots.insert(namespace.to_string()); + } + } + roots +} + +#[cfg(test)] +mod policy_tests { + use super::*; + + #[test] + fn completion_names_are_exact_and_embedding_trust_cannot_bypass_tool_role() { + assert_eq!(charge_name("cap::artifact_put_result").unwrap(), None); + for name in [ + "cap::artifact_put", + "cap::artifact_get", + "cap::artifact_reference", + "cap::artifact_put_results", + "cap::artifact_unknown", + ] { + assert_eq!( + charge_name(name).unwrap_err().kind, + PluginErrorKind::UnauthorizedHostImport + ); + } + let manifest = super::super::manifest::parse_plugin_manifest( + "format: rustscript.plugin/v1\nid: org.rustscript.policy\nversion: 1.0.0\nkind: library\nruntime: \">=0.1.0, <0.2.0\"\ncontract: agent.library/v1\nhost_abi: agent.cap/v1\nentry:\n path: invoke.rss\n export: invoke\ndependencies: []\ncapabilities: []\neffects:\n contract: agent.effects/v1\n entries:\n invoke:\n direct: []\n delegated: []\n" + ).unwrap(); + for trust in [ + PackageTrust::AcceptedPublisher, + PackageTrust::EmbeddingOwnedBuiltin, + ] { + for source in [ + "pub fn invoke(token: string) -> map { cap::artifact_put_result(token, bytes::from_utf8(\"result\"), {}) }\n", + "pub fn invoke(value: map) -> map { agent_runtime::tool_prepare(value) }\n", + "pub fn invoke(token: string) -> map { agent_runtime::tool_commit(token, {}) }\n", + "pub fn invoke(value: int) -> int { agent::sleep_ms(value) }\n", + ] { + let mut package = InstalledPackage::new( + "policy", + manifest.clone(), + BTreeMap::from([("invoke.rss".into(), source.as_bytes().to_vec())]), + ); + package.trust = trust; + assert_eq!( + compile_package(&package).unwrap_err().kind, + PluginErrorKind::UnauthorizedHostImport + ); + } + } + let mut manifest = manifest; + manifest.kind = "tool".into(); + manifest.contract = "agent.tool/v1".into(); + let mut package = InstalledPackage::new( + "policy", + manifest, + BTreeMap::from([( + "invoke.rss".into(), + b"pub fn invoke(value: int) -> int { agent::sleep_ms(value) }\n".to_vec(), + )]), + ); + package.trust = PackageTrust::EmbeddingOwnedBuiltin; + assert_eq!( + compile_package(&package).unwrap_err().kind, + PluginErrorKind::UndeclaredDirectEffect + ); + } +} diff --git a/src/plugins/resolve.rs b/src/plugins/resolve.rs new file mode 100644 index 0000000..c5695b4 --- /dev/null +++ b/src/plugins/resolve.rs @@ -0,0 +1,770 @@ +//! Version resolution and effect-closure construction. +//! +//! The graph id is an opaque lock identity. It is not a permission, and the +//! semantic lock excludes it so install order cannot change the solution. +//! Edge fields keep the caller entry, callee entry, alias, requirement, and +//! resolved revision. Provenance is not collapsed to a single delegated label. + +use std::collections::{BTreeMap, BTreeSet}; + +use semver::{Version, VersionReq}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use uuid::Uuid; + +use super::error::{ + COMPILE_ENVIRONMENT, EFFECT_KINDS, PLUGIN_KINDS, PluginError, PluginErrorKind, + recovery_allowed, scope_for, +}; +use super::manifest::EffectRequirement; +use super::store::InstalledPackage; + +const MAX_PACKAGES: usize = 64; +const MAX_EDGES: usize = 256; +const MAX_DEPTH: usize = 16; +const MAX_VERSIONS: usize = 32; +const MAX_DECISIONS: usize = 4096; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PackageRequirement { + pub id: String, + pub version: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResolveRequest { + pub roots: Vec, + pub host_runtime: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LockedPackage { + pub id: String, + pub version: String, + pub kind: String, + pub artifact_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ScopeBinding { + pub kind: String, + pub scope: String, + pub recovery: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LockedEdge { + pub from: String, + pub from_entry: String, + pub alias: String, + pub to: String, + pub to_version: String, + pub to_artifact_id: String, + pub to_entry: String, + pub relation: String, + pub version_requirement: String, + pub bindings: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EffectOrigin { + pub relation: String, + pub caller_package: String, + pub caller_entry: String, + pub alias: String, + pub callee_package: String, + pub callee_version: String, + pub callee_entry: String, + pub version_requirement: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LockedEffect { + pub package: String, + pub entry: String, + pub effects: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LockedEffectItem { + pub kind: String, + pub scope: String, + pub recovery: String, + pub via: EffectOrigin, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GraphLock { + pub graph_id: String, + pub host_runtime: String, + pub compile_environment: String, + pub packages: Vec, + pub edges: Vec, + pub closure: Vec, +} + +impl GraphLock { + pub fn closure_kinds(&self, package_id: &str, entry: &str) -> Vec { + self.closure + .iter() + .find(|item| item.package == package_id && item.entry == entry) + .map(|item| { + item.effects + .iter() + .map(|effect| effect.kind.clone()) + .collect() + }) + .unwrap_or_default() + } + + pub fn semantic_yaml(&self) -> String { + let mut yaml = String::new(); + yaml.push_str("compile_environment: "); + yaml.push_str(&self.compile_environment); + yaml.push('\n'); + yaml.push_str("host_runtime: "); + yaml.push_str(&self.host_runtime); + yaml.push_str("\npackages:\n"); + for package in &self.packages { + yaml.push_str(" - id: "); + yaml.push_str(&yaml_scalar(&package.id)); + yaml.push_str("\n version: "); + yaml.push_str(&yaml_scalar(&package.version)); + yaml.push_str("\n kind: "); + yaml.push_str(&yaml_scalar(&package.kind)); + yaml.push_str("\n artifact_id: "); + yaml.push_str(&yaml_scalar(&package.artifact_id)); + yaml.push('\n'); + } + yaml.push_str("edges:\n"); + for edge in &self.edges { + yaml.push_str(" - from: "); + yaml.push_str(&yaml_scalar(&edge.from)); + yaml.push_str("\n from_entry: "); + yaml.push_str(&yaml_scalar(&edge.from_entry)); + yaml.push_str("\n alias: "); + yaml.push_str(&yaml_scalar(&edge.alias)); + yaml.push_str("\n to: "); + yaml.push_str(&yaml_scalar(&edge.to)); + yaml.push_str("\n to_version: "); + yaml.push_str(&yaml_scalar(&edge.to_version)); + yaml.push_str("\n to_artifact_id: "); + yaml.push_str(&yaml_scalar(&edge.to_artifact_id)); + yaml.push_str("\n to_entry: "); + yaml.push_str(&yaml_scalar(&edge.to_entry)); + yaml.push_str("\n relation: "); + yaml.push_str(&yaml_scalar(&edge.relation)); + yaml.push_str("\n version_requirement: "); + yaml.push_str(&yaml_scalar(&edge.version_requirement)); + yaml.push_str("\n bindings:\n"); + for binding in &edge.bindings { + yaml.push_str(" - kind: "); + yaml.push_str(&yaml_scalar(&binding.kind)); + yaml.push_str("\n scope: "); + yaml.push_str(&yaml_scalar(&binding.scope)); + yaml.push_str("\n recovery: "); + yaml.push_str(&yaml_scalar(&binding.recovery)); + yaml.push('\n'); + } + } + yaml.push_str("closure:\n"); + for entry in &self.closure { + yaml.push_str(" - package: "); + yaml.push_str(&yaml_scalar(&entry.package)); + yaml.push_str("\n entry: "); + yaml.push_str(&yaml_scalar(&entry.entry)); + yaml.push_str("\n effects:\n"); + for effect in &entry.effects { + yaml.push_str(" - kind: "); + yaml.push_str(&yaml_scalar(&effect.kind)); + yaml.push_str("\n scope: "); + yaml.push_str(&yaml_scalar(&effect.scope)); + yaml.push_str("\n recovery: "); + yaml.push_str(&yaml_scalar(&effect.recovery)); + yaml.push_str("\n via:\n"); + yaml.push_str(" relation: "); + yaml.push_str(&yaml_scalar(&effect.via.relation)); + yaml.push_str("\n caller_package: "); + yaml.push_str(&yaml_scalar(&effect.via.caller_package)); + yaml.push_str("\n caller_entry: "); + yaml.push_str(&yaml_scalar(&effect.via.caller_entry)); + yaml.push_str("\n alias: "); + yaml.push_str(&yaml_scalar(&effect.via.alias)); + yaml.push_str("\n callee_package: "); + yaml.push_str(&yaml_scalar(&effect.via.callee_package)); + yaml.push_str("\n callee_version: "); + yaml.push_str(&yaml_scalar(&effect.via.callee_version)); + yaml.push_str("\n callee_entry: "); + yaml.push_str(&yaml_scalar(&effect.via.callee_entry)); + yaml.push_str("\n version_requirement: "); + yaml.push_str(&yaml_scalar(&effect.via.version_requirement)); + yaml.push('\n'); + } + } + yaml + } + + pub(crate) fn schema_value(&self) -> Value { + json!({ + "graph_id": self.graph_id, + "host_runtime": self.host_runtime, + "compile_environment": self.compile_environment, + "packages": self.packages.iter().map(|package| json!({ + "id": package.id, + "version": package.version, + "kind": package.kind, + "artifact_id": package.artifact_id, + })).collect::>(), + "edges": self.edges.iter().map(|edge| json!({ + "from": edge.from, + "from_entry": edge.from_entry, + "alias": edge.alias, + "to": edge.to, + "to_version": edge.to_version, + "to_artifact_id": edge.to_artifact_id, + "to_entry": edge.to_entry, + "relation": edge.relation, + "version_requirement": edge.version_requirement, + "bindings": edge.bindings.iter().map(|binding| json!({ + "kind": binding.kind, + "scope": binding.scope, + "recovery": binding.recovery, + })).collect::>(), + })).collect::>(), + "closure": self.closure.iter().map(|entry| json!({ + "package": entry.package, + "entry": entry.entry, + "effects": entry.effects.iter().map(|effect| json!({ + "kind": effect.kind, + "scope": effect.scope, + "recovery": effect.recovery, + "via": { + "relation": effect.via.relation, + "caller_package": effect.via.caller_package, + "caller_entry": effect.via.caller_entry, + "alias": effect.via.alias, + "callee_package": effect.via.callee_package, + "callee_version": effect.via.callee_version, + "callee_entry": effect.via.callee_entry, + "version_requirement": effect.via.version_requirement, + }, + })).collect::>(), + })).collect::>(), + }) + } + + pub(crate) fn from_schema_value(value: Value) -> Result { + serde_json::from_value(value) + .map_err(|_| PluginError::new(PluginErrorKind::UnknownField, "lock")) + } + + pub(crate) fn validate_persisted(&self) -> Result<(), PluginError> { + if !plain_identity(&self.graph_id) { + return Err(PluginError::new(PluginErrorKind::PathEscape, "graph_id")); + } + Version::parse(&self.host_runtime) + .map_err(|_| PluginError::new(PluginErrorKind::ContractIncompatible, "host runtime"))?; + if self.compile_environment != COMPILE_ENVIRONMENT { + return Err(PluginError::new( + PluginErrorKind::ContractIncompatible, + "compile environment", + )); + } + if self.packages.is_empty() || self.packages.len() > 64 { + return Err(PluginError::new(PluginErrorKind::UnknownField, "packages")); + } + let mut package_ids = BTreeSet::new(); + let mut packages = BTreeMap::new(); + for package in &self.packages { + if !package_ids.insert(package.id.clone()) + || !plain_identity(&package.id) + || !plain_identity(&package.artifact_id) + || !PLUGIN_KINDS.contains(&package.kind.as_str()) + || Version::parse(&package.version).is_err() + { + return Err(PluginError::new(PluginErrorKind::UnknownField, "package")); + } + packages.insert(package.id.clone(), package); + } + if self.edges.len() > 256 || self.closure.len() > 64 { + return Err(PluginError::new(PluginErrorKind::UnknownField, "closure")); + } + for edge in &self.edges { + let from = packages + .get(&edge.from) + .ok_or_else(|| PluginError::new(PluginErrorKind::MissingDependency, &edge.from))?; + let to = packages + .get(&edge.to) + .ok_or_else(|| PluginError::new(PluginErrorKind::MissingDependency, &edge.to))?; + let expected_relation = if to.kind == "library" { + "library" + } else { + "plugin" + }; + let requirement = VersionReq::parse(&edge.version_requirement).map_err(|_| { + PluginError::new(PluginErrorKind::UnknownField, "version_requirement") + })?; + let resolved = Version::parse(&to.version) + .map_err(|_| PluginError::new(PluginErrorKind::UnknownField, "version"))?; + if edge.from_entry.is_empty() + || edge.to_entry.is_empty() + || edge.alias.is_empty() + || edge.to_version != to.version + || edge.to_artifact_id != to.artifact_id + || edge.relation != expected_relation + || from.id == to.id + || !requirement.matches(&resolved) + { + return Err(PluginError::new(PluginErrorKind::UnknownField, "edge")); + } + for binding in &edge.bindings { + validate_effect(&binding.kind, &binding.scope, &binding.recovery)?; + } + } + for entry in &self.closure { + if !packages.contains_key(&entry.package) || entry.entry.is_empty() { + return Err(PluginError::new(PluginErrorKind::UnknownField, "closure")); + } + for effect in &entry.effects { + validate_effect(&effect.kind, &effect.scope, &effect.recovery)?; + validate_origin(&effect.via)?; + } + } + Ok(()) + } +} + +fn plain_identity(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && !value.contains('/') + && !value.contains('\\') + && !value.contains("..") + && value.chars().all(|item| !item.is_control()) +} + +fn validate_effect(kind: &str, scope: &str, recovery: &str) -> Result<(), PluginError> { + if !EFFECT_KINDS.contains(&kind) + || scope_for(kind) != Some(scope) + || !recovery_allowed(kind, recovery) + { + return Err(PluginError::new(PluginErrorKind::UnknownEffect, kind)); + } + Ok(()) +} + +fn validate_origin(origin: &EffectOrigin) -> Result<(), PluginError> { + if !matches!(origin.relation.as_str(), "direct" | "plugin" | "library") + || origin.caller_package.is_empty() + || origin.caller_entry.is_empty() + || origin.callee_package.is_empty() + || origin.callee_entry.is_empty() + || Version::parse(&origin.callee_version).is_err() + { + return Err(PluginError::new(PluginErrorKind::UnknownField, "via")); + } + let direct = origin.relation == "direct"; + if direct != origin.alias.is_empty() { + return Err(PluginError::new(PluginErrorKind::UnknownField, "via")); + } + if !origin.version_requirement.is_empty() { + let requirement = VersionReq::parse(&origin.version_requirement) + .map_err(|_| PluginError::new(PluginErrorKind::UnknownField, "via"))?; + let resolved = Version::parse(&origin.callee_version) + .map_err(|_| PluginError::new(PluginErrorKind::UnknownField, "via"))?; + if !requirement.matches(&resolved) { + return Err(PluginError::new(PluginErrorKind::UnknownField, "via")); + } + } + Ok(()) +} + +struct Bound { + from: String, + raw: String, + requirement: VersionReq, +} + +struct ResolveCtx<'a> { + by_id: &'a BTreeMap<&'a str, Vec<&'a InstalledPackage>>, + host: &'a Version, + decisions: usize, + selected: BTreeMap, + bounds: BTreeMap>, + stack: Vec, +} + +pub fn resolve_installed( + packages: &[InstalledPackage], + request: &ResolveRequest, +) -> Result { + let host = Version::parse(&request.host_runtime) + .map_err(|_| PluginError::new(PluginErrorKind::ContractIncompatible, "host runtime"))?; + let mut by_id: BTreeMap<&str, Vec<&InstalledPackage>> = BTreeMap::new(); + for package in packages { + by_id + .entry(package.manifest.id.as_str()) + .or_default() + .push(package); + } + let mut ctx = ResolveCtx { + by_id: &by_id, + host: &host, + decisions: 0, + selected: BTreeMap::new(), + bounds: BTreeMap::new(), + stack: Vec::new(), + }; + for root in &request.roots { + visit(&mut ctx, root.id.as_str(), &root.version, "root")?; + } + if ctx.selected.len() > MAX_PACKAGES { + return Err(budget("packages")); + } + let mut edges = Vec::new(); + for package in ctx.selected.values() { + let from_entry = package.manifest.entry.export.clone(); + for dependency in &package.manifest.dependencies { + let target = ctx.selected.get(&dependency.id).ok_or_else(|| { + PluginError::new(PluginErrorKind::MissingDependency, dependency.id.clone()) + })?; + let mut bindings = Vec::new(); + let mut to_entry = target.manifest.entry.export.clone(); + if let Some(contract) = package.manifest.effects.entries.get(&from_entry) { + for delegated in &contract.delegated { + if delegated.dependency != dependency.alias { + continue; + } + to_entry = delegated.entry.clone(); + for allowed in &delegated.allow { + bindings.push(ScopeBinding { + kind: allowed.kind.clone(), + scope: allowed.scope.clone(), + recovery: allowed.recovery.clone(), + }); + } + } + } + edges.push(LockedEdge { + from: package.manifest.id.clone(), + from_entry: from_entry.clone(), + alias: dependency.alias.clone(), + to: dependency.id.clone(), + to_version: target.manifest.version.clone(), + to_artifact_id: target.artifact_id.clone(), + to_entry, + relation: if target.manifest.kind == "library" { + "library" + } else { + "plugin" + } + .to_string(), + version_requirement: dependency.version.clone(), + bindings, + }); + } + } + edges.sort_by(|left, right| { + (&left.from, &left.alias, &left.to, &left.to_entry).cmp(&( + &right.from, + &right.alias, + &right.to, + &right.to_entry, + )) + }); + if edges.len() > MAX_EDGES { + return Err(budget("edges")); + } + let mut closure = Vec::new(); + for package in ctx.selected.values() { + let entry = package.manifest.entry.export.clone(); + let effects = entry_closure(&package.manifest.id, &entry, &ctx.selected, &mut Vec::new())?; + closure.push(LockedEffect { + package: package.manifest.id.clone(), + entry, + effects, + }); + } + closure + .sort_by(|left, right| (&left.package, &left.entry).cmp(&(&right.package, &right.entry))); + let mut locked = ctx + .selected + .values() + .map(|package| LockedPackage { + id: package.manifest.id.clone(), + version: package.manifest.version.clone(), + kind: package.manifest.kind.clone(), + artifact_id: package.artifact_id.clone(), + }) + .collect::>(); + locked.sort_by(|left, right| left.id.cmp(&right.id)); + let lock = GraphLock { + graph_id: Uuid::new_v4().to_string(), + host_runtime: request.host_runtime.clone(), + compile_environment: COMPILE_ENVIRONMENT.to_string(), + packages: locked, + edges, + closure, + }; + super::schema::validate_lock(&lock.schema_value())?; + Ok(lock) +} + +fn visit(ctx: &mut ResolveCtx<'_>, id: &str, raw: &str, from: &str) -> Result<(), PluginError> { + if ctx.stack.iter().any(|item| item == id) { + let mut chain = ctx.stack.clone(); + chain.push(id.to_string()); + return Err(PluginError { + kind: PluginErrorKind::Cycle, + chain, + }); + } + let requirement = + VersionReq::parse(raw).map_err(|_| PluginError::new(PluginErrorKind::UnknownField, raw))?; + ctx.bounds.entry(id.to_string()).or_default().push(Bound { + from: from.to_string(), + raw: raw.to_string(), + requirement, + }); + if let Some(existing) = ctx.selected.get(id) { + if !ctx.bounds[id].iter().all(|bound| { + bound + .requirement + .matches(&existing.manifest.parsed_version().unwrap()) + }) { + return Err(conflict(id, &ctx.bounds[id])); + } + return Ok(()); + } + let candidates = ctx + .by_id + .get(id) + .ok_or_else(|| PluginError::new(PluginErrorKind::MissingDependency, id))?; + if candidates.len() > MAX_VERSIONS { + return Err(budget("versions_per_package")); + } + ctx.decisions = ctx.decisions.saturating_add(candidates.len()); + if ctx.decisions > MAX_DECISIONS { + return Err(budget("decision_nodes")); + } + let mut versions = BTreeSet::new(); + for candidate in candidates { + if !versions.insert(candidate.manifest.version.clone()) { + return Err(PluginError::new( + PluginErrorKind::Ambiguous, + format!("{id} {}", candidate.manifest.version), + )); + } + } + let mut matching = candidates + .iter() + .copied() + .filter(|candidate| { + let version = candidate.manifest.parsed_version().ok(); + version.is_some() + && ctx.bounds[id] + .iter() + .all(|bound| bound.requirement.matches(version.as_ref().unwrap())) + && candidate + .manifest + .runtime_matches(ctx.host) + .unwrap_or(false) + }) + .collect::>(); + if matching.is_empty() { + let host_mismatch = candidates + .iter() + .any(|candidate| candidate.manifest.runtime_matches(ctx.host).ok() == Some(false)); + if host_mismatch && ctx.bounds[id].len() == 1 { + return Err(PluginError::new(PluginErrorKind::ContractIncompatible, id)); + } + if ctx.bounds[id].len() > 1 { + return Err(conflict(id, &ctx.bounds[id])); + } + return Err(PluginError::new(PluginErrorKind::MissingDependency, id)); + } + matching.sort_by_key(|candidate| candidate.manifest.parsed_version().ok()); + let chosen = *matching.last().unwrap(); + if ctx.stack.len() + 1 > MAX_DEPTH { + return Err(budget("depth")); + } + ctx.selected.insert(id.to_string(), chosen); + ctx.stack.push(id.to_string()); + for dependency in &chosen.manifest.dependencies { + visit(ctx, &dependency.id, &dependency.version, id)?; + } + ctx.stack.pop(); + Ok(()) +} + +fn entry_closure( + package_id: &str, + entry: &str, + selected: &BTreeMap, + stack: &mut Vec<(String, String)>, +) -> Result, PluginError> { + if stack + .iter() + .any(|(id, name)| id == package_id && name == entry) + { + return Err(PluginError { + kind: PluginErrorKind::Cycle, + chain: stack + .iter() + .map(|(id, name)| format!("{id}::{name}")) + .collect(), + }); + } + let package = selected + .get(package_id) + .ok_or_else(|| PluginError::new(PluginErrorKind::MissingDependency, package_id))?; + let contract = package + .manifest + .effects + .entries + .get(entry) + .ok_or_else(|| PluginError::new(PluginErrorKind::UncoveredExport, entry))?; + if entry != package.manifest.entry.export { + return Err(PluginError::new(PluginErrorKind::UncoveredExport, entry)); + } + let mut effects = contract + .direct + .iter() + .map(|effect| locked_direct(package, entry, effect)) + .collect::>(); + stack.push((package_id.to_string(), entry.to_string())); + for delegated in &contract.delegated { + let dependency = package + .manifest + .dependencies + .iter() + .find(|item| item.alias == delegated.dependency) + .ok_or_else(|| { + PluginError::new( + PluginErrorKind::MissingDependency, + delegated.dependency.clone(), + ) + })?; + let callee_package = selected.get(&dependency.id).ok_or_else(|| { + PluginError::new(PluginErrorKind::MissingDependency, dependency.id.clone()) + })?; + if delegated.entry != callee_package.manifest.entry.export { + return Err(PluginError::new( + PluginErrorKind::UncoveredExport, + delegated.entry.clone(), + )); + } + let callee = entry_closure(&dependency.id, &delegated.entry, selected, stack)?; + for effect in &callee { + let covered = delegated.allow.iter().any(|allowed| { + allowed.kind == effect.kind + && allowed.scope == effect.scope + && allowed.recovery == effect.recovery + }); + if !covered { + return Err(PluginError { + kind: PluginErrorKind::UndeclaredTransitiveEffect, + chain: vec![ + package_id.to_string(), + dependency.id.clone(), + effect.kind.clone(), + ], + }); + } + } + let relation = if callee_package.manifest.kind == "library" { + "library" + } else { + "plugin" + }; + for effect in callee { + effects.push(LockedEffectItem { + kind: effect.kind, + scope: effect.scope, + recovery: effect.recovery, + via: EffectOrigin { + relation: relation.to_string(), + caller_package: package_id.to_string(), + caller_entry: entry.to_string(), + alias: dependency.alias.clone(), + callee_package: dependency.id.clone(), + callee_version: callee_package.manifest.version.clone(), + callee_entry: delegated.entry.clone(), + version_requirement: dependency.version.clone(), + }, + }); + } + } + stack.pop(); + effects.sort_by(|left, right| { + ( + &left.kind, + &left.via.alias, + &left.via.callee_package, + &left.via.caller_entry, + ) + .cmp(&( + &right.kind, + &right.via.alias, + &right.via.callee_package, + &right.via.caller_entry, + )) + }); + effects.dedup(); + Ok(effects) +} + +fn locked_direct( + package: &InstalledPackage, + entry: &str, + effect: &EffectRequirement, +) -> LockedEffectItem { + LockedEffectItem { + kind: effect.kind.clone(), + scope: effect.scope.clone(), + recovery: effect.recovery.clone(), + via: EffectOrigin { + relation: "direct".to_string(), + caller_package: package.manifest.id.clone(), + caller_entry: entry.to_string(), + alias: String::new(), + callee_package: package.manifest.id.clone(), + callee_version: package.manifest.version.clone(), + callee_entry: entry.to_string(), + version_requirement: String::new(), + }, + } +} + +fn conflict(id: &str, bounds: &[Bound]) -> PluginError { + PluginError { + kind: PluginErrorKind::Conflict, + chain: bounds + .iter() + .map(|bound| format!("{} requires {id} {}", bound.from, bound.raw)) + .collect(), + } +} + +fn budget(name: &str) -> PluginError { + PluginError::new(PluginErrorKind::BudgetExceeded, name) +} + +fn yaml_scalar(value: &str) -> String { + if value.is_empty() + || value + .chars() + .any(|ch| !(ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-' | '/'))) + { + format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\"")) + } else { + value.to_string() + } +} diff --git a/src/plugins/schema.rs b/src/plugins/schema.rs new file mode 100644 index 0000000..de6a20e --- /dev/null +++ b/src/plugins/schema.rs @@ -0,0 +1,262 @@ +//! Bundled YAML schemas are the public structural contract. +//! +//! Native loads these files, validates duplicate-free JSON against them, then +//! applies stronger semantic and authority checks. Canonical JSON is a sorted +//! projection of the same YAML. It is not a second contract and not a digest. + +use std::collections::BTreeMap; +use std::fs; +use std::path::Path; + +use serde_json::{Map, Value}; + +use super::error::{PluginError, PluginErrorKind}; +use super::yaml::strict_yaml_to_json; + +const MANIFEST_SCHEMA: &str = include_str!("../../schemas/plugin-manifest.schema.yaml"); +const EFFECT_SCHEMA: &str = include_str!("../../schemas/effect-contract.schema.yaml"); +const LOCK_SCHEMA: &str = include_str!("../../schemas/plugin-graph-lock.schema.yaml"); +const ACTIVATION_SCHEMA: &str = include_str!("../../schemas/plugin-activation.schema.yaml"); +const GRAPH_REF_SCHEMA: &str = include_str!("../../schemas/plugin-graph-ref.schema.yaml"); +const CHECKPOINT_SCHEMA: &str = include_str!("../../schemas/v1/checkpoint.schema.yaml"); + +const SCHEMA_SOURCES: &[(&str, &str)] = &[ + ("plugin-manifest.schema.yaml", MANIFEST_SCHEMA), + ("effect-contract.schema.yaml", EFFECT_SCHEMA), + ("plugin-graph-lock.schema.yaml", LOCK_SCHEMA), + ("plugin-activation.schema.yaml", ACTIVATION_SCHEMA), + ("plugin-graph-ref.schema.yaml", GRAPH_REF_SCHEMA), + ("checkpoint.schema.yaml", CHECKPOINT_SCHEMA), +]; + +pub fn canonical_schema_documents() -> Result, PluginError> { + let mut documents = BTreeMap::new(); + for (name, text) in SCHEMA_SOURCES { + let json_name = name.replace(".yaml", ".json"); + let value = strict_yaml_to_json(text)?; + let canonical = canonical_json(&value); + match json_name.as_str() { + "plugin-manifest.schema.json" => { + documents.insert("plugin-manifest.schema.json", canonical) + } + "effect-contract.schema.json" => { + documents.insert("effect-contract.schema.json", canonical) + } + "plugin-graph-lock.schema.json" => { + documents.insert("plugin-graph-lock.schema.json", canonical) + } + "plugin-activation.schema.json" => { + documents.insert("plugin-activation.schema.json", canonical) + } + "plugin-graph-ref.schema.json" => { + documents.insert("plugin-graph-ref.schema.json", canonical) + } + "checkpoint.schema.json" => documents.insert("checkpoint.schema.json", canonical), + _ => None, + }; + } + Ok(documents) +} + +pub fn schema_snapshot_drift(dir: &Path) -> Result<(), String> { + let documents = canonical_schema_documents().map_err(|err| err.to_string())?; + for (name, expected) in documents { + let path = dir.join(name); + let actual = fs::read_to_string(&path).map_err(|err| format!("missing {name}: {err}"))?; + if actual != expected { + return Err(format!("schema snapshot drifted: {name}")); + } + } + Ok(()) +} + +pub(crate) fn validate_manifest(value: &Value) -> Result<(), PluginError> { + validate_named("plugin-manifest.schema.yaml", value) +} + +pub(crate) fn validate_lock(value: &Value) -> Result<(), PluginError> { + validate_named("plugin-graph-lock.schema.yaml", value) +} + +fn validate_named(name: &str, value: &Value) -> Result<(), PluginError> { + let schemas = bundled_values()?; + let registry = registry(&schemas)?; + let selected = schemas + .get(name) + .ok_or_else(|| PluginError::new(PluginErrorKind::ContractIncompatible, name))?; + let validator = jsonschema::options() + .with_registry(®istry) + .build(selected) + .map_err(|err| PluginError::new(PluginErrorKind::ContractIncompatible, err.to_string()))?; + let errors: Vec<_> = validator.iter_errors(value).collect(); + if errors.is_empty() { + return Ok(()); + } + Err(PluginError::new( + classify(&errors), + format!("{name} {}", errors[0]), + )) +} + +fn bundled_values() -> Result, PluginError> { + let mut values = BTreeMap::new(); + for (name, text) in SCHEMA_SOURCES { + values.insert(*name, strict_yaml_to_json(text)?); + } + Ok(values) +} + +fn registry( + schemas: &BTreeMap<&'static str, Value>, +) -> Result, PluginError> { + let mut building = jsonschema::Registry::new(); + for (name, value) in schemas { + building = building + .add(schema_uri(name), value.clone()) + .map_err(|err| { + PluginError::new(PluginErrorKind::ContractIncompatible, err.to_string()) + })?; + } + building + .prepare() + .map_err(|err| PluginError::new(PluginErrorKind::ContractIncompatible, err.to_string())) +} + +fn schema_uri(name: &str) -> &'static str { + match name { + "plugin-manifest.schema.yaml" => { + "https://schemas.rustscript.local/v1/plugin-manifest.schema.yaml" + } + "effect-contract.schema.yaml" => { + "https://schemas.rustscript.local/v1/effect-contract.schema.yaml" + } + "plugin-graph-lock.schema.yaml" => { + "https://schemas.rustscript.local/v1/plugin-graph-lock.schema.yaml" + } + "plugin-activation.schema.yaml" => { + "https://schemas.rustscript.local/v1/plugin-activation.schema.yaml" + } + "plugin-graph-ref.schema.yaml" => { + "https://schemas.rustscript.local/v1/plugin-graph-ref.schema.yaml" + } + "checkpoint.schema.yaml" => "https://rustscript.dev/schemas/v1/checkpoint.schema.yaml", + _ => "https://schemas.rustscript.local/v1/unknown", + } +} + +fn classify(errors: &[jsonschema::ValidationError<'_>]) -> PluginErrorKind { + let kinds: Vec<_> = errors.iter().map(classify_one).collect(); + for preferred in [ + PluginErrorKind::ForgedRecovery, + PluginErrorKind::PathEscape, + PluginErrorKind::MissingEffects, + PluginErrorKind::UncoveredExport, + PluginErrorKind::UnknownEffect, + PluginErrorKind::UnknownField, + ] { + if kinds.contains(&preferred) { + return preferred; + } + } + PluginErrorKind::UnknownField +} + +fn classify_one(err: &jsonschema::ValidationError<'_>) -> PluginErrorKind { + let path = err.instance_path().to_string(); + let keyword = err.kind().keyword(); + if path.contains("recovery") { + return PluginErrorKind::ForgedRecovery; + } + if path.ends_with("/path") && (keyword == "pattern" || keyword == "minLength") { + return PluginErrorKind::PathEscape; + } + if keyword == "required" && err.to_string().contains("effects") { + return PluginErrorKind::MissingEffects; + } + if path.contains("entries") && (keyword == "maxProperties" || keyword == "additionalProperties") + { + return PluginErrorKind::UncoveredExport; + } + if path.contains("/effects") && (path.ends_with("/kind") || path.ends_with("/scope")) { + return PluginErrorKind::UnknownEffect; + } + PluginErrorKind::UnknownField +} + +fn canonical_json(value: &Value) -> String { + let mut text = serde_json::to_string_pretty(&sort_value(value)).expect("schema json"); + text.push('\n'); + text +} + +fn sort_value(value: &Value) -> Value { + match value { + Value::Object(map) => { + let mut keys: Vec<_> = map.keys().cloned().collect(); + keys.sort(); + let mut sorted = Map::new(); + for key in keys { + sorted.insert(key.clone(), sort_value(&map[&key])); + } + Value::Object(sorted) + } + Value::Array(items) => Value::Array(items.iter().map(sort_value).collect()), + other => other.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::super::error::{EFFECT_KINDS, capability_for, recovery_allowed, scope_for}; + use super::validate_manifest; + use serde_json::json; + + fn sample(kind: &str, scope: &str, recovery: &str) -> serde_json::Value { + let capability = capability_for(kind).unwrap_or("workspace.read"); + json!({ + "format": "rustscript.plugin/v1", + "id": "org.rustscript.tools.sample", + "version": "1.0.0", + "kind": "tool", + "runtime": ">=0.1.0, <0.2.0", + "contract": "agent.tool/v1", + "host_abi": "agent.cap/v1", + "entry": {"path": "invoke.rss", "export": "invoke"}, + "dependencies": [], + "capabilities": [capability], + "effects": { + "contract": "agent.effects/v1", + "entries": { + "invoke": { + "direct": [{"kind": kind, "scope": scope, "recovery": recovery}], + "delegated": [] + } + } + } + }) + } + + #[test] + fn recovery_and_scope_matrix_matches_native_allowance() { + for kind in EFFECT_KINDS { + let scope = scope_for(kind).unwrap(); + for recovery in ["none", "checkpoint", "replay"] { + let accepted = validate_manifest(&sample(kind, scope, recovery)).is_ok(); + assert_eq!( + accepted, + recovery_allowed(kind, recovery), + "{kind} {recovery}" + ); + } + let wrong_scope = if scope == "bound_run" { + "bound_workspace" + } else { + "bound_run" + }; + assert!( + validate_manifest(&sample(kind, wrong_scope, "none")).is_err(), + "{kind} must not accept scope {wrong_scope}" + ); + } + } +} diff --git a/src/plugins/store.rs b/src/plugins/store.rs new file mode 100644 index 0000000..08b0306 --- /dev/null +++ b/src/plugins/store.rs @@ -0,0 +1,1064 @@ +//! Immutable plugin package store. +//! +//! Installed bytes are the only bytes later compilation may see. A signature +//! proves publisher identity; it does not add a capability. + +use std::collections::BTreeMap; +use std::ffi::CString; +use std::fs::{self, File}; +use std::io::{Read, Write}; +use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; +use std::os::unix::fs::OpenOptionsExt; +use std::path::{Component, Path, PathBuf}; +use std::sync::Mutex; + +use ring::digest; +use ring::signature::{ED25519, UnparsedPublicKey}; + +use super::error::{PluginError, PluginErrorKind}; +use super::manifest::{PluginManifest, parse_plugin_manifest, safe_relative}; +use super::resolve::GraphLock; + +const SIGNATURE_FILE: &str = "signature.ed25519"; +const MAX_CAPTURE_FILE: usize = 1024 * 1024; +const MAX_CAPTURE_TOTAL: usize = 8 * 1024 * 1024; +const MAX_CAPTURE_FILES: usize = 64; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PublisherKey { + pub publisher: String, + pub verifying_key: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct InstallAuthorization { + pub admin: bool, + pub accepted_publishers: Vec, + pub unsigned_dev_space: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PackageTrust { + AcceptedPublisher, + DevUnsigned, + /// Closed catalog bytes installed by the trusted embedder, not a signature + /// or a dev-unsigned bypass. + EmbeddingOwnedBuiltin, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PackageRevision { + pub artifact_id: String, + pub trust: PackageTrust, + manifest: PluginManifest, +} + +impl PackageRevision { + pub fn declared_capabilities(&self) -> Vec { + self.manifest.declared_capabilities() + } +} + +#[derive(Clone, Debug)] +pub struct InstalledPackage { + pub artifact_id: String, + pub trust: PackageTrust, + pub manifest: PluginManifest, + pub sources: BTreeMap>, +} + +impl InstalledPackage { + pub fn new( + artifact_id: &str, + manifest: PluginManifest, + sources: BTreeMap>, + ) -> Self { + Self { + artifact_id: artifact_id.to_string(), + trust: PackageTrust::DevUnsigned, + manifest, + sources, + } + } +} + +pub struct PackageStore { + root: PathBuf, + artifacts: OwnedFd, + ledger: OwnedFd, + policy: Mutex>, + verified: Mutex>>, + // One immutable compiled graph; no VM, owner, grant, or mutable registry. + pub(super) prepared: Mutex>, +} + +impl PackageStore { + pub fn open(root: impl Into) -> Result { + let root = root.into(); + reject_symlink_path(&root)?; + fs::create_dir_all(root.join("artifacts")) + .map_err(|_| PluginError::new(PluginErrorKind::PathEscape, "store"))?; + fs::create_dir_all(root.join("ledger")) + .map_err(|_| PluginError::new(PluginErrorKind::PathEscape, "ledger"))?; + let artifacts = open_dir_nofollow(&root.join("artifacts"))?; + let ledger = open_dir_nofollow(&root.join("ledger"))?; + Ok(Self { + root, + artifacts, + ledger, + policy: Mutex::new(None), + verified: Mutex::new(None), + prepared: Mutex::new(None), + }) + } + + pub fn bind_policy(&self, auth: &InstallAuthorization) { + let mut policy = self.policy.lock().unwrap_or_else(|err| err.into_inner()); + *policy = Some(auth.clone()); + drop(policy); + self.forget_verified(); + } + + /// The already verified immutable builtin capture, if policy has not + /// invalidated it. This exposes code preparation only, never activation, + /// a durable owner receipt, or mutable host state. + pub(crate) fn prepared_embedding_lock(&self) -> Option { + let prepared = self + .prepared + .lock() + .unwrap_or_else(|error| error.into_inner()); + let (lock, graph) = prepared.as_ref()?; + let [package] = lock.packages.as_slice() else { + return None; + }; + (package.id == crate::runtime::native_tools::EMBEDDING_PACKAGE_ID + && graph.trust(&package.id) == Some(PackageTrust::EmbeddingOwnedBuiltin)) + .then(|| lock.clone()) + } + + pub fn signature_payload(&self, package: &Path) -> Result, PluginError> { + let files = read_package(package)?; + Ok(payload_bytes(&files)) + } + + pub fn install( + &self, + package: &Path, + auth: &InstallAuthorization, + ) -> Result { + if !auth.admin { + return Err(PluginError::new( + PluginErrorKind::PublisherUnauthorized, + "admin authorization required", + )); + } + self.bind_policy(auth); + let files = read_package(package)?; + let manifest_bytes = files + .get("manifest.yaml") + .ok_or_else(|| PluginError::new(PluginErrorKind::UnknownField, "manifest.yaml"))?; + let manifest_text = std::str::from_utf8(manifest_bytes) + .map_err(|_| PluginError::new(PluginErrorKind::UnknownField, "manifest.yaml"))?; + let manifest = parse_plugin_manifest(manifest_text)?; + if !files.contains_key(&manifest.entry.path) { + return Err(PluginError::new( + PluginErrorKind::UnknownField, + "entry source", + )); + } + let payload = payload_bytes(&files); + let artifact_id = payload_artifact_id(&payload); + let trust = authorize(package, auth, &manifest, &files, &payload)?; + if let Some(existing) = self.find_version(&manifest.id, &manifest.version)? { + if existing == artifact_id { + return self.revision_from_capture(&artifact_id); + } + return Err(PluginError::new( + PluginErrorKind::VersionOverwrite, + format!("{} {}", manifest.id, manifest.version), + )); + } + self.write_artifact(&artifact_id, &files, trust, &manifest.publisher, &payload)?; + Ok(PackageRevision { + artifact_id, + trust, + manifest, + }) + } + + /// Installs the closed embedded tool catalog as an immutable builtin. + /// A signature or dev-unsigned space is not accepted for these bytes. + pub fn install_embedding_owned(&self) -> Result { + let files = crate::runtime::native_tools::embedding_package_files() + .map_err(|message| PluginError::new(PluginErrorKind::CompileFailed, message))?; + let manifest_bytes = files + .get("manifest.yaml") + .ok_or_else(|| PluginError::new(PluginErrorKind::UnknownField, "manifest.yaml"))?; + let manifest = parse_plugin_manifest( + std::str::from_utf8(manifest_bytes) + .map_err(|_| PluginError::new(PluginErrorKind::UnknownField, "manifest"))?, + )?; + if manifest.id != crate::runtime::native_tools::EMBEDDING_PACKAGE_ID { + return Err(PluginError::new( + PluginErrorKind::ContractIncompatible, + "embedding package", + )); + } + let payload = payload_bytes(&files); + let artifact_id = payload_artifact_id(&payload); + if let Some(existing) = self.find_version(&manifest.id, &manifest.version)? { + if existing == artifact_id { + return self.revision_from_capture(&artifact_id); + } + return Err(PluginError::new( + PluginErrorKind::VersionOverwrite, + format!("{} {}", manifest.id, manifest.version), + )); + } + self.write_artifact( + &artifact_id, + &files, + PackageTrust::EmbeddingOwnedBuiltin, + &manifest.publisher, + &payload, + )?; + Ok(PackageRevision { + artifact_id, + trust: PackageTrust::EmbeddingOwnedBuiltin, + manifest, + }) + } + + pub fn load_installed(&self) -> Result, PluginError> { + reject_symlink_path(&self.root.join("artifacts"))?; + reject_symlink_path(&self.root.join("ledger"))?; + let mut packages = Vec::new(); + for name in list_dir(&self.artifacts)? { + if name.ends_with(".tmp") { + continue; + } + packages.push(self.read_installed(&name)?); + } + let mut verified = self.verified.lock().unwrap_or_else(|err| err.into_inner()); + *verified = Some(packages.clone()); + Ok(packages) + } + + pub(crate) fn verified_capture( + &self, + required: &[(&str, &str)], + ) -> Result, PluginError> { + let cached = self.verified.lock().unwrap_or_else(|err| err.into_inner()); + if let Some(packages) = cached.as_ref() + && required.iter().all(|(id, artifact_id)| { + packages.iter().any(|package| { + package.manifest.id == *id && package.artifact_id == *artifact_id + }) + }) + { + return Ok(packages.clone()); + } + drop(cached); + let mut packages = Vec::with_capacity(required.len()); + for (id, artifact_id) in required { + let installed = self.read_installed(artifact_id)?; + if installed.manifest.id != *id || installed.artifact_id != *artifact_id { + return Err(PluginError::new( + PluginErrorKind::PathEscape, + "captured artifact identity", + )); + } + packages.push(installed); + } + Ok(packages) + } + + fn forget_verified(&self) { + let mut verified = self.verified.lock().unwrap_or_else(|err| err.into_inner()); + *verified = None; + *self + .prepared + .lock() + .unwrap_or_else(|error| error.into_inner()) = None; + } + + fn find_version(&self, id: &str, version: &str) -> Result, PluginError> { + for package in self.load_installed()? { + if package.manifest.id == id && package.manifest.version == version { + return Ok(Some(package.artifact_id)); + } + } + Ok(None) + } + + fn revision_from_capture(&self, artifact_id: &str) -> Result { + let installed = self.read_installed(artifact_id)?; + Ok(PackageRevision { + artifact_id: installed.artifact_id, + trust: installed.trust, + manifest: installed.manifest, + }) + } + + fn write_artifact( + &self, + artifact_id: &str, + files: &BTreeMap>, + trust: PackageTrust, + publisher: &str, + payload: &[u8], + ) -> Result<(), PluginError> { + if list_dir(&self.artifacts)? + .iter() + .any(|name| name == artifact_id) + { + let captured = self.capture(artifact_id)?; + if captured.files == *files && self.ledger_matches(artifact_id, payload)? { + return Ok(()); + } + return Err(PluginError::new( + PluginErrorKind::VersionOverwrite, + artifact_id, + )); + } + let temporary = format!("{artifact_id}.tmp"); + if list_dir(&self.artifacts)? + .iter() + .any(|name| name == &temporary) + { + remove_tree(&self.artifacts, &temporary)?; + } + mkdir_at(&self.artifacts, &temporary)?; + let temp_dir = open_dir_at(&self.artifacts, &temporary)?; + mkdir_at(&temp_dir, "sources")?; + let sources = open_dir_at(&temp_dir, "sources")?; + for (path, bytes) in files { + if path == "manifest.yaml" { + write_file_at(&temp_dir, "manifest.yaml", bytes)?; + } else if path == SIGNATURE_FILE { + write_file_at(&temp_dir, SIGNATURE_FILE, bytes)?; + } else { + write_relative(&sources, path, bytes)?; + } + } + sync_dir(&temp_dir)?; + sync_dir(&sources)?; + rename_at(&self.artifacts, &temporary, artifact_id)?; + sync_dir(&self.artifacts)?; + self.forget_verified(); + if let Err(err) = self.write_ledger(artifact_id, trust, publisher, payload) { + let _ = remove_tree(&self.artifacts, artifact_id); + return Err(err); + } + Ok(()) + } + + pub(crate) fn read_installed( + &self, + artifact_id: &str, + ) -> Result { + let captured = self.capture(artifact_id)?; + let payload = payload_bytes(&captured.files); + if payload_artifact_id(&payload) != artifact_id { + return Err(PluginError::new( + PluginErrorKind::PathEscape, + "artifact bytes", + )); + } + let ledger = self.read_ledger(artifact_id)?; + if ledger.payload != payload { + return Err(PluginError::new( + PluginErrorKind::PathEscape, + "artifact ledger", + )); + } + let manifest_bytes = captured + .files + .get("manifest.yaml") + .ok_or_else(|| PluginError::new(PluginErrorKind::UnknownField, "manifest.yaml"))?; + let manifest = parse_plugin_manifest( + std::str::from_utf8(manifest_bytes) + .map_err(|_| PluginError::new(PluginErrorKind::UnknownField, "manifest"))?, + )?; + if !captured.files.contains_key(&manifest.entry.path) { + return Err(PluginError::new( + PluginErrorKind::UnknownField, + "entry source", + )); + } + if ledger.publisher != manifest.publisher { + return Err(PluginError::new( + PluginErrorKind::PublisherUnauthorized, + "publisher record", + )); + } + let policy = self.current_policy()?; + let trust = revalidate( + &policy, + &manifest, + captured.files.get(SIGNATURE_FILE), + &payload, + ledger.trust, + )?; + let mut sources = BTreeMap::new(); + for (path, bytes) in captured.files { + if path == "manifest.yaml" || path == SIGNATURE_FILE { + continue; + } + sources.insert(path, bytes); + } + Ok(InstalledPackage { + artifact_id: artifact_id.to_string(), + trust, + manifest, + sources, + }) + } + + fn current_policy(&self) -> Result { + self.policy + .lock() + .map_err(|_| { + PluginError::new( + PluginErrorKind::PublisherUnauthorized, + "installation policy", + ) + })? + .clone() + .ok_or_else(|| { + PluginError::new( + PluginErrorKind::PublisherUnauthorized, + "installation policy", + ) + }) + } + + fn ledger_matches(&self, artifact_id: &str, payload: &[u8]) -> Result { + Ok(self.read_ledger(artifact_id)?.payload == payload) + } + + fn write_ledger( + &self, + artifact_id: &str, + trust: PackageTrust, + publisher: &str, + payload: &[u8], + ) -> Result<(), PluginError> { + let bytes = encode_ledger(trust, publisher, payload); + write_file_at(&self.ledger, artifact_id, &bytes)?; + sync_dir(&self.ledger)?; + Ok(()) + } + + fn read_ledger(&self, artifact_id: &str) -> Result { + let fd = open_file_at(&self.ledger, artifact_id)?; + let bytes = read_limited(fd, MAX_CAPTURE_TOTAL)?; + decode_ledger(&bytes) + } + + fn capture(&self, artifact_id: &str) -> Result { + reject_artifact_id(artifact_id)?; + let dir = open_dir_at(&self.artifacts, artifact_id)?; + let mut files = BTreeMap::new(); + let mut total = 0usize; + for name in list_dir(&dir)? { + if name == "trust" { + let meta = open_file_at(&dir, &name); + if meta.is_err() { + return Err(PluginError::new(PluginErrorKind::SymlinkDenied, "trust")); + } + continue; + } + if name == "sources" { + let sources = open_dir_at(&dir, "sources")?; + capture_sources(&sources, "", &mut files, &mut total)?; + continue; + } + if name != "manifest.yaml" && name != SIGNATURE_FILE { + return Err(PluginError::new(PluginErrorKind::PathEscape, name)); + } + let bytes = read_limited(open_file_at(&dir, &name)?, MAX_CAPTURE_FILE)?; + total = total.saturating_add(bytes.len()); + if total > MAX_CAPTURE_TOTAL || files.len() >= MAX_CAPTURE_FILES { + return Err(PluginError::new( + PluginErrorKind::BudgetExceeded, + "artifact", + )); + } + files.insert(name, bytes); + } + if !files.contains_key("manifest.yaml") { + return Err(PluginError::new( + PluginErrorKind::UnknownField, + "manifest.yaml", + )); + } + Ok(CapturedArtifact { files }) + } +} + +fn authorize( + package: &Path, + auth: &InstallAuthorization, + manifest: &PluginManifest, + files: &BTreeMap>, + payload: &[u8], +) -> Result { + if let Some(signature) = files.get(SIGNATURE_FILE) { + return verify_signature(auth, &manifest.publisher, signature, payload); + } + if let Some(dev) = &auth.unsigned_dev_space + && path_inside(dev, package)? + { + return Ok(PackageTrust::DevUnsigned); + } + Err(PluginError::new( + PluginErrorKind::PublisherUnauthorized, + manifest.publisher.clone(), + )) +} + +fn revalidate( + auth: &InstallAuthorization, + manifest: &PluginManifest, + signature: Option<&Vec>, + payload: &[u8], + recorded: PackageTrust, +) -> Result { + if recorded == PackageTrust::EmbeddingOwnedBuiltin { + if signature.is_some() || !crate::runtime::native_tools::embedding_payload_matches(payload) + { + return Err(PluginError::new( + PluginErrorKind::PublisherUnauthorized, + "embedding payload", + )); + } + let _ = auth; + return Ok(PackageTrust::EmbeddingOwnedBuiltin); + } + if let Some(signature) = signature { + let trust = verify_signature(auth, &manifest.publisher, signature, payload)?; + if recorded != PackageTrust::AcceptedPublisher { + return Err(PluginError::new( + PluginErrorKind::PublisherUnauthorized, + "trust record", + )); + } + return Ok(trust); + } + if recorded != PackageTrust::DevUnsigned || auth.unsigned_dev_space.is_none() { + return Err(PluginError::new( + PluginErrorKind::PublisherUnauthorized, + "unsigned package", + )); + } + Ok(PackageTrust::DevUnsigned) +} + +fn verify_signature( + auth: &InstallAuthorization, + publisher: &str, + signature: &[u8], + payload: &[u8], +) -> Result { + let key = auth + .accepted_publishers + .iter() + .find(|key| key.publisher == publisher) + .ok_or_else(|| PluginError::new(PluginErrorKind::PublisherUnauthorized, publisher))?; + if key.verifying_key.len() != 32 || signature.len() != 64 { + return Err(PluginError::new( + PluginErrorKind::PublisherUnauthorized, + "signature", + )); + } + UnparsedPublicKey::new(&ED25519, &key.verifying_key) + .verify(payload, signature) + .map_err(|_| PluginError::new(PluginErrorKind::PublisherUnauthorized, "signature"))?; + Ok(PackageTrust::AcceptedPublisher) +} + +pub(crate) fn embedding_payload_bytes(files: &BTreeMap>) -> Vec { + payload_bytes(files) +} + +fn payload_bytes(files: &BTreeMap>) -> Vec { + let mut payload = Vec::new(); + for (path, bytes) in files { + if path == SIGNATURE_FILE { + continue; + } + payload.extend(path.as_bytes()); + payload.push(0); + payload.extend(bytes); + payload.push(0); + } + payload +} + +fn payload_artifact_id(payload: &[u8]) -> String { + let digest = digest::digest(&digest::SHA256, payload); + let mut id = String::from("pkg-"); + for byte in digest.as_ref() { + id.push(char::from(b"0123456789abcdef"[usize::from(byte >> 4)])); + id.push(char::from(b"0123456789abcdef"[usize::from(byte & 0x0f)])); + } + id +} + +fn read_package(package: &Path) -> Result>, PluginError> { + reject_symlink_path(package)?; + let mut files = BTreeMap::new(); + read_tree(package, package, &mut files)?; + if !files.contains_key("manifest.yaml") { + return Err(PluginError::new( + PluginErrorKind::UnknownField, + "manifest.yaml", + )); + } + Ok(files) +} + +fn read_tree( + root: &Path, + dir: &Path, + files: &mut BTreeMap>, +) -> Result<(), PluginError> { + let mut entries = Vec::new(); + for entry in + fs::read_dir(dir).map_err(|_| PluginError::new(PluginErrorKind::PathEscape, "package"))? + { + entries.push(entry.map_err(|_| PluginError::new(PluginErrorKind::PathEscape, "package"))?); + } + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let name = entry.file_name(); + let name = name + .to_str() + .ok_or_else(|| PluginError::new(PluginErrorKind::PathEscape, "name"))?; + if name == "." || name == ".." || name.contains('/') { + return Err(PluginError::new(PluginErrorKind::PathEscape, name)); + } + let path = entry.path(); + let meta = fs::symlink_metadata(&path) + .map_err(|_| PluginError::new(PluginErrorKind::PathEscape, name))?; + if meta.file_type().is_symlink() { + return Err(PluginError::new(PluginErrorKind::SymlinkDenied, name)); + } + if meta.is_dir() { + read_tree(root, &path, files)?; + continue; + } + if !meta.is_file() { + return Err(PluginError::new(PluginErrorKind::PathEscape, name)); + } + let relative = path + .strip_prefix(root) + .map_err(|_| PluginError::new(PluginErrorKind::PathEscape, name))?; + let relative = relative + .to_str() + .ok_or_else(|| PluginError::new(PluginErrorKind::PathEscape, name))?; + if relative != "manifest.yaml" && relative != SIGNATURE_FILE && !safe_relative(relative) { + return Err(PluginError::new(PluginErrorKind::PathEscape, relative)); + } + let mut file = File::options() + .read(true) + .custom_flags(libc::O_NOFOLLOW) + .open(&path) + .map_err(|error| { + if error.raw_os_error() == Some(libc::ELOOP) { + PluginError::new(PluginErrorKind::SymlinkDenied, relative) + } else { + PluginError::new(PluginErrorKind::PathEscape, relative) + } + })?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes) + .map_err(|_| PluginError::new(PluginErrorKind::PathEscape, relative))?; + if files.insert(relative.to_string(), bytes).is_some() { + return Err(PluginError::new(PluginErrorKind::DuplicateField, relative)); + } + } + Ok(()) +} + +fn reject_symlink_path(path: &Path) -> Result<(), PluginError> { + if path + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + return Err(PluginError::new(PluginErrorKind::PathEscape, "parent")); + } + let mut current = PathBuf::new(); + for component in path.components() { + match component { + Component::RootDir => current.push("/"), + Component::Normal(name) => current.push(name), + Component::CurDir => return Err(PluginError::new(PluginErrorKind::PathEscape, "dot")), + Component::ParentDir => { + return Err(PluginError::new(PluginErrorKind::PathEscape, "parent")); + } + Component::Prefix(_) => { + return Err(PluginError::new(PluginErrorKind::PathEscape, "prefix")); + } + } + if current.as_os_str() == "/" { + continue; + } + if !current.exists() { + continue; + } + let meta = fs::symlink_metadata(¤t) + .map_err(|_| PluginError::new(PluginErrorKind::PathEscape, "path"))?; + if meta.file_type().is_symlink() { + return Err(PluginError::new(PluginErrorKind::SymlinkDenied, "path")); + } + } + Ok(()) +} + +fn path_inside(root: &Path, candidate: &Path) -> Result { + reject_symlink_path(root)?; + reject_symlink_path(candidate)?; + let root = + fs::canonicalize(root).map_err(|_| PluginError::new(PluginErrorKind::PathEscape, "dev"))?; + let candidate = fs::canonicalize(candidate) + .map_err(|_| PluginError::new(PluginErrorKind::PathEscape, "package"))?; + Ok(candidate.starts_with(&root) && candidate != root) +} + +struct CapturedArtifact { + files: BTreeMap>, +} + +struct LedgerRecord { + trust: PackageTrust, + publisher: String, + payload: Vec, +} + +fn encode_ledger(trust: PackageTrust, publisher: &str, payload: &[u8]) -> Vec { + let trust_text = match trust { + PackageTrust::AcceptedPublisher => "accepted-publisher", + PackageTrust::DevUnsigned => "dev-unsigned", + PackageTrust::EmbeddingOwnedBuiltin => "embedding-owned-builtin", + }; + let mut bytes = format!( + "v1\ntrust={trust_text}\npublisher={publisher}\npayload_len={}\n", + payload.len() + ) + .into_bytes(); + bytes.extend_from_slice(payload); + bytes +} + +fn decode_ledger(bytes: &[u8]) -> Result { + let header_end = bytes + .iter() + .enumerate() + .take(512) + .fold(0usize, |count, (index, byte)| { + if count == 4 { + count + } else if *byte == b'\n' { + count + 1 + } else if index > 256 { + 5 + } else { + count + } + }); + if header_end != 4 { + return Err(PluginError::new(PluginErrorKind::PathEscape, "ledger")); + } + let split = bytes + .iter() + .enumerate() + .filter(|(_, byte)| **byte == b'\n') + .nth(3) + .map(|(index, _)| index + 1) + .ok_or_else(|| PluginError::new(PluginErrorKind::PathEscape, "ledger"))?; + let header = std::str::from_utf8(&bytes[..split]) + .map_err(|_| PluginError::new(PluginErrorKind::PathEscape, "ledger"))?; + let mut lines = header.lines(); + if lines.next() != Some("v1") { + return Err(PluginError::new(PluginErrorKind::PathEscape, "ledger")); + } + let trust = match lines.next() { + Some("trust=accepted-publisher") => PackageTrust::AcceptedPublisher, + Some("trust=dev-unsigned") => PackageTrust::DevUnsigned, + Some("trust=embedding-owned-builtin") => PackageTrust::EmbeddingOwnedBuiltin, + _ => { + return Err(PluginError::new( + PluginErrorKind::PublisherUnauthorized, + "ledger trust", + )); + } + }; + let publisher = lines + .next() + .and_then(|line| line.strip_prefix("publisher=")) + .ok_or_else(|| PluginError::new(PluginErrorKind::PathEscape, "ledger publisher"))?; + let payload_len = lines + .next() + .and_then(|line| line.strip_prefix("payload_len=")) + .and_then(|text| text.parse::().ok()) + .ok_or_else(|| PluginError::new(PluginErrorKind::PathEscape, "ledger length"))?; + let payload = bytes + .get(split..split + payload_len) + .ok_or_else(|| PluginError::new(PluginErrorKind::PathEscape, "ledger payload"))?; + if split + payload_len != bytes.len() { + return Err(PluginError::new( + PluginErrorKind::PathEscape, + "ledger trailer", + )); + } + Ok(LedgerRecord { + trust, + publisher: publisher.to_string(), + payload: payload.to_vec(), + }) +} + +fn reject_artifact_id(artifact_id: &str) -> Result<(), PluginError> { + let Some(hex) = artifact_id.strip_prefix("pkg-") else { + return Err(PluginError::new(PluginErrorKind::PathEscape, "artifact")); + }; + if hex.len() != 64 || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(PluginError::new(PluginErrorKind::PathEscape, "artifact")); + } + Ok(()) +} + +fn open_dir_nofollow(path: &Path) -> Result { + let file = File::options() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_DIRECTORY) + .open(path) + .map_err(|error| { + if error.raw_os_error() == Some(libc::ELOOP) { + PluginError::new(PluginErrorKind::SymlinkDenied, "store") + } else { + PluginError::new(PluginErrorKind::PathEscape, "store") + } + })?; + Ok(OwnedFd::from(file)) +} + +fn c_name(name: &str) -> Result { + if name.is_empty() || name.contains('/') || name.contains('\0') || name == "." || name == ".." { + return Err(PluginError::new(PluginErrorKind::PathEscape, name)); + } + CString::new(name).map_err(|_| PluginError::new(PluginErrorKind::PathEscape, name)) +} + +fn open_at(dir: &OwnedFd, name: &str, flags: i32) -> Result { + let c_name = c_name(name)?; + let fd = unsafe { + libc::openat( + dir.as_raw_fd(), + c_name.as_ptr(), + flags | libc::O_CLOEXEC | libc::O_NOFOLLOW, + 0o644, + ) + }; + if fd < 0 { + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ELOOP) { + return Err(PluginError::new(PluginErrorKind::SymlinkDenied, name)); + } + return Err(PluginError::new(PluginErrorKind::PathEscape, name)); + } + Ok(unsafe { OwnedFd::from_raw_fd(fd) }) +} + +fn open_dir_at(dir: &OwnedFd, name: &str) -> Result { + open_at(dir, name, libc::O_RDONLY | libc::O_DIRECTORY) +} + +fn open_file_at(dir: &OwnedFd, name: &str) -> Result { + let fd = open_at(dir, name, libc::O_RDONLY)?; + if !is_regular_file(&fd)? { + return Err(PluginError::new(PluginErrorKind::PathEscape, name)); + } + Ok(fd) +} + +fn is_regular_file(fd: &OwnedFd) -> Result { + let mut stat = unsafe { std::mem::zeroed::() }; + let rc = unsafe { libc::fstat(fd.as_raw_fd(), &mut stat) }; + if rc != 0 { + return Err(PluginError::new(PluginErrorKind::PathEscape, "stat")); + } + Ok((stat.st_mode & libc::S_IFMT) == libc::S_IFREG) +} + +fn list_dir(dir: &OwnedFd) -> Result, PluginError> { + let proc = format!("/proc/self/fd/{}", dir.as_raw_fd()); + let mut names = Vec::new(); + for entry in fs::read_dir(&proc) + .map_err(|_| PluginError::new(PluginErrorKind::PathEscape, "directory"))? + { + let entry = + entry.map_err(|_| PluginError::new(PluginErrorKind::PathEscape, "directory"))?; + let name = entry.file_name(); + let name = name + .to_str() + .ok_or_else(|| PluginError::new(PluginErrorKind::PathEscape, "name"))?; + if name == "." || name == ".." { + continue; + } + names.push(name.to_string()); + } + names.sort(); + Ok(names) +} + +fn read_limited(fd: OwnedFd, limit: usize) -> Result, PluginError> { + let mut file = File::from(fd); + let mut bytes = Vec::new(); + let mut buf = [0u8; 8192]; + loop { + let read = file + .read(&mut buf) + .map_err(|_| PluginError::new(PluginErrorKind::PathEscape, "read"))?; + if read == 0 { + break; + } + if bytes.len().saturating_add(read) > limit { + return Err(PluginError::new( + PluginErrorKind::BudgetExceeded, + "artifact file", + )); + } + bytes.extend_from_slice(&buf[..read]); + } + Ok(bytes) +} + +fn write_file_at(dir: &OwnedFd, name: &str, bytes: &[u8]) -> Result<(), PluginError> { + if bytes.len() > MAX_CAPTURE_FILE { + return Err(PluginError::new(PluginErrorKind::BudgetExceeded, name)); + } + let fd = open_at(dir, name, libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL)?; + let mut file = File::from(fd); + file.write_all(bytes) + .map_err(|_| PluginError::new(PluginErrorKind::PathEscape, name))?; + file.sync_all() + .map_err(|_| PluginError::new(PluginErrorKind::PathEscape, name))?; + Ok(()) +} + +fn write_relative(dir: &OwnedFd, relative: &str, bytes: &[u8]) -> Result<(), PluginError> { + if !safe_relative(relative) { + return Err(PluginError::new(PluginErrorKind::PathEscape, relative)); + } + let mut parts = relative.split('/').collect::>(); + let file_name = parts + .pop() + .ok_or_else(|| PluginError::new(PluginErrorKind::PathEscape, relative))?; + let mut current = dir + .try_clone() + .map_err(|_| PluginError::new(PluginErrorKind::PathEscape, relative))?; + for part in parts { + if open_dir_at(¤t, part).is_err() { + mkdir_at(¤t, part)?; + } + current = open_dir_at(¤t, part)?; + } + write_file_at(¤t, file_name, bytes) +} + +fn mkdir_at(dir: &OwnedFd, name: &str) -> Result<(), PluginError> { + let c_name = c_name(name)?; + let rc = unsafe { libc::mkdirat(dir.as_raw_fd(), c_name.as_ptr(), 0o755) }; + if rc != 0 { + return Err(PluginError::new(PluginErrorKind::PathEscape, name)); + } + Ok(()) +} + +fn rename_at(dir: &OwnedFd, from: &str, to: &str) -> Result<(), PluginError> { + let from_name = c_name(from)?; + let to_name = c_name(to)?; + let rc = unsafe { + libc::renameat( + dir.as_raw_fd(), + from_name.as_ptr(), + dir.as_raw_fd(), + to_name.as_ptr(), + ) + }; + if rc != 0 { + return Err(PluginError::new(PluginErrorKind::PathEscape, to)); + } + Ok(()) +} + +fn sync_dir(dir: &OwnedFd) -> Result<(), PluginError> { + let file = File::from( + dir.try_clone() + .map_err(|_| PluginError::new(PluginErrorKind::PathEscape, "sync"))?, + ); + file.sync_all() + .map_err(|_| PluginError::new(PluginErrorKind::PathEscape, "sync")) +} + +fn remove_tree(parent: &OwnedFd, name: &str) -> Result<(), PluginError> { + if let Ok(dir) = open_dir_at(parent, name) { + for child in list_dir(&dir)? { + remove_tree(&dir, &child)?; + } + unlink_at(parent, name, libc::AT_REMOVEDIR)?; + return Ok(()); + } + unlink_at(parent, name, 0) +} + +fn unlink_at(dir: &OwnedFd, name: &str, flags: i32) -> Result<(), PluginError> { + let c_name = c_name(name)?; + let rc = unsafe { libc::unlinkat(dir.as_raw_fd(), c_name.as_ptr(), flags) }; + if rc != 0 { + return Err(PluginError::new(PluginErrorKind::PathEscape, name)); + } + Ok(()) +} + +fn capture_sources( + dir: &OwnedFd, + prefix: &str, + files: &mut BTreeMap>, + total: &mut usize, +) -> Result<(), PluginError> { + for name in list_dir(dir)? { + if let Ok(child) = open_dir_at(dir, &name) { + let next = if prefix.is_empty() { + name + } else { + format!("{prefix}/{name}") + }; + capture_sources(&child, &next, files, total)?; + continue; + } + let relative = if prefix.is_empty() { + name.clone() + } else { + format!("{prefix}/{name}") + }; + if !safe_relative(&relative) { + return Err(PluginError::new(PluginErrorKind::PathEscape, relative)); + } + let bytes = read_limited(open_file_at(dir, &name)?, MAX_CAPTURE_FILE)?; + *total = total.saturating_add(bytes.len()); + if *total > MAX_CAPTURE_TOTAL || files.len() >= MAX_CAPTURE_FILES { + return Err(PluginError::new( + PluginErrorKind::BudgetExceeded, + "artifact", + )); + } + if files.insert(relative.clone(), bytes).is_some() { + return Err(PluginError::new(PluginErrorKind::DuplicateField, relative)); + } + } + Ok(()) +} diff --git a/src/plugins/yaml.rs b/src/plugins/yaml.rs new file mode 100644 index 0000000..726553b --- /dev/null +++ b/src/plugins/yaml.rs @@ -0,0 +1,250 @@ +//! Strict YAML subset for plugin contracts. +//! +//! Aliases, tags, merge keys, and duplicate fields are rejected before any +//! JSON value is produced. This parser never fetches a schema or a remote URI. + +use std::collections::BTreeSet; + +use serde_json::{Map, Value}; +use yaml_rust2::parser::{Event, EventReceiver, Parser}; +use yaml_rust2::scanner::TScalarStyle; + +use super::error::{PluginError, PluginErrorKind}; + +struct StrictLoader { + stack: Vec, + error: Option, + docs: Vec, +} + +enum Frame { + Map { + values: Map, + key: Option, + }, + Seq(Vec), +} + +impl StrictLoader { + fn fail(&mut self, kind: PluginErrorKind) { + if self.error.is_none() { + self.error = Some(PluginError::new(kind, kind_label(kind))); + } + } + + fn insert(&mut self, value: Value) { + if self.error.is_some() { + return; + } + if self.stack.is_empty() { + self.docs.push(value); + return; + } + match self.stack.last_mut() { + Some(Frame::Seq(items)) => items.push(value), + Some(Frame::Map { values, key }) => { + let Some(key) = key.take() else { + self.error = Some(PluginError::new( + PluginErrorKind::UnknownField, + "mapping value without key", + )); + return; + }; + if key == "<<" { + self.error = Some(PluginError::new(PluginErrorKind::YamlMerge, "merge key")); + return; + } + if values.contains_key(&key) { + self.error = Some(PluginError::new(PluginErrorKind::DuplicateField, key)); + return; + } + values.insert(key, value); + } + None => self.docs.push(value), + } + } +} + +impl EventReceiver for StrictLoader { + fn on_event(&mut self, ev: Event) { + if self.error.is_some() { + return; + } + match ev { + Event::StreamStart | Event::StreamEnd | Event::DocumentStart | Event::Nothing => {} + Event::DocumentEnd => { + if self.stack.len() == 1 { + let frame = self.stack.pop().unwrap(); + let value = match frame { + Frame::Map { values, key } if key.is_none() => Value::Object(values), + Frame::Seq(items) => Value::Array(items), + _ => { + self.fail(PluginErrorKind::UnknownField); + return; + } + }; + self.docs.push(value); + } else if !self.stack.is_empty() { + self.fail(PluginErrorKind::UnknownField); + } + } + Event::Alias(_) => self.fail(PluginErrorKind::YamlAlias), + Event::SequenceStart(anchor, tag) => { + if anchor != 0 { + self.fail(PluginErrorKind::YamlAlias); + return; + } + if tag.is_some() { + self.fail(PluginErrorKind::YamlTag); + return; + } + self.stack.push(Frame::Seq(Vec::new())); + } + Event::SequenceEnd => { + let Some(Frame::Seq(items)) = self.stack.pop() else { + self.fail(PluginErrorKind::UnknownField); + return; + }; + self.insert(Value::Array(items)); + } + Event::MappingStart(anchor, tag) => { + if anchor != 0 { + self.fail(PluginErrorKind::YamlAlias); + return; + } + if tag.is_some() { + self.fail(PluginErrorKind::YamlTag); + return; + } + self.stack.push(Frame::Map { + values: Map::new(), + key: None, + }); + } + Event::MappingEnd => { + let Some(Frame::Map { values, key }) = self.stack.pop() else { + self.fail(PluginErrorKind::UnknownField); + return; + }; + if key.is_some() { + self.fail(PluginErrorKind::UnknownField); + return; + } + self.insert(Value::Object(values)); + } + Event::Scalar(text, style, anchor, tag) => { + if anchor != 0 { + self.fail(PluginErrorKind::YamlAlias); + return; + } + if tag.is_some() { + self.fail(PluginErrorKind::YamlTag); + return; + } + if let Some(Frame::Map { key, .. }) = self.stack.last_mut() + && key.is_none() + { + if text == "<<" { + self.fail(PluginErrorKind::YamlMerge); + return; + } + *key = Some(text); + return; + } + self.insert(scalar_value(&text, style)); + } + } + } +} + +fn scalar_value(text: &str, style: TScalarStyle) -> Value { + if style != TScalarStyle::Plain { + return Value::String(text.to_string()); + } + match text { + "true" => Value::Bool(true), + "false" => Value::Bool(false), + "~" | "null" | "Null" | "NULL" => Value::Null, + _ => { + if text.bytes().all(|byte| byte.is_ascii_digit()) + && let Ok(number) = text.parse::() + { + return Value::Number(number.into()); + } + Value::String(text.to_string()) + } + } +} + +fn kind_label(kind: PluginErrorKind) -> &'static str { + match kind { + PluginErrorKind::YamlAlias => "yaml alias", + PluginErrorKind::YamlTag => "yaml tag", + PluginErrorKind::YamlMerge => "yaml merge", + PluginErrorKind::DuplicateField => "duplicate field", + PluginErrorKind::UnknownField => "unknown field", + _ => "yaml", + } +} + +pub fn strict_yaml_to_json(text: &str) -> Result { + let mut loader = StrictLoader { + stack: Vec::new(), + error: None, + docs: Vec::new(), + }; + let mut parser = Parser::new_from_str(text); + if let Err(error) = parser.load(&mut loader, false) { + if let Some(parsed) = loader.error { + return Err(parsed); + } + let message = error.to_string(); + let kind = if message.contains("alias") || message.contains("anchor") { + PluginErrorKind::YamlAlias + } else if message.contains("tag") { + PluginErrorKind::YamlTag + } else { + PluginErrorKind::UnknownField + }; + return Err(PluginError::new(kind, "yaml syntax")); + } + if let Some(error) = loader.error { + return Err(error); + } + if loader.docs.len() != 1 { + return Err(PluginError::new( + PluginErrorKind::UnknownField, + "exactly one yaml document", + )); + } + let value = loader.docs.pop().unwrap(); + if value.is_null() { + return Err(PluginError::new( + PluginErrorKind::UnknownField, + "null document", + )); + } + reject_nulls(&value)?; + Ok(value) +} + +fn reject_nulls(value: &Value) -> Result<(), PluginError> { + match value { + Value::Null => Err(PluginError::new(PluginErrorKind::UnknownField, "null")), + Value::Array(items) => items.iter().try_for_each(reject_nulls), + Value::Object(map) => { + let mut seen = BTreeSet::new(); + for (key, child) in map { + if !seen.insert(key) { + return Err(PluginError::new( + PluginErrorKind::DuplicateField, + key.clone(), + )); + } + reject_nulls(child)?; + } + Ok(()) + } + _ => Ok(()), + } +} diff --git a/src/runtime/agent_host.rs b/src/runtime/agent_host.rs index 88ff9d9..615608c 100644 --- a/src/runtime/agent_host.rs +++ b/src/runtime/agent_host.rs @@ -39,6 +39,7 @@ const TOOL_REPLAY: &str = "agent_runtime::tool_replay"; const APPROVAL_ATTEST: &str = "agent_runtime::approval_attest"; const APPROVAL_STORAGE: &str = "agent::approval_storage"; const TOOL_COMMIT: &str = "agent_runtime::tool_commit"; +const INVOKE_PINNED_TOOL: &str = "agent_runtime::invoke_pinned_tool"; const CAP_FS_METADATA: &str = "cap::fs_metadata"; const CAP_FS_READ_RANGE: &str = "cap::fs_read_range"; const CAP_FS_LIST: &str = "cap::fs_list"; @@ -78,6 +79,7 @@ const AGENT_HOST_FUNCTIONS: &[fn() -> HostFunctionDescriptor] = &[ approval_storage_descriptor, tool_prepare_descriptor, tool_commit_descriptor, + invoke_pinned_tool_descriptor, cap_fs_metadata_descriptor, cap_fs_read_range_descriptor, cap_fs_list_descriptor, @@ -105,6 +107,39 @@ pub fn agent_host_module() -> HostModuleDescriptor { } } +/// Child tools do not install provider, approval, auth, or storage adapters. +pub(crate) fn retained_tool_host_module() -> HostModuleDescriptor { + HostModuleDescriptor { + name: "retained-tool", + functions: &[ + utf8_byte_len_descriptor, + utf8_truncate_descriptor, + sleep_ms_descriptor, + control_check_descriptor, + tool_prepare_descriptor, + tool_commit_descriptor, + cap_fs_metadata_descriptor, + cap_fs_read_range_descriptor, + cap_fs_list_descriptor, + cap_fs_write_atomic_descriptor, + cap_process_spawn_descriptor, + cap_process_poll_descriptor, + cap_process_wait_descriptor, + cap_process_log_descriptor, + cap_process_write_descriptor, + cap_process_close_descriptor, + cap_process_kill_descriptor, + cap_artifact_put_descriptor, + cap_artifact_put_result_descriptor, + cap_artifact_get_descriptor, + cap_artifact_reference_descriptor, + cap_clock_monotonic_ms_descriptor, + parse_json_object_descriptor, + ], + resources: &[], + } +} + fn token_param() -> HostParamSchema { HostParamSchema::value("execution_token", HostTypeSchema::String) } @@ -229,6 +264,18 @@ fn tool_commit_descriptor() -> HostFunctionDescriptor { ) } +fn invoke_pinned_tool_descriptor() -> HostFunctionDescriptor { + stack_desc( + INVOKE_PINNED_TOOL, + vec![HostParamSchema::value( + "request", + super::host_types::PinnedToolInvokeRequest::host_type_schema(), + )], + super::host_types::PinnedToolInvokeResult::host_type_schema(), + invoke_pinned_tool_adapter, + ) +} + fn cap_fs_metadata_descriptor() -> HostFunctionDescriptor { stack_desc( CAP_FS_METADATA, @@ -515,6 +562,168 @@ pub type ControlCheckHook = Arc; pub type AuthHostFactory = Arc std::result::Result + Send + Sync>; +/// Program compiled from a verified capture or the closed embedded catalog. +/// Fields are sealed by the factory. Callers cannot swap the program under a +/// retained identity. +#[derive(Clone)] +pub struct PinnedToolProgram { + program: Arc, + entry: String, + graph_id: String, + package_id: String, + artifact_id: String, + export: String, + run_id: String, + session_id: String, + profile: String, + ref_id: String, + generation: u64, + registry_identity: String, + workspace: std::path::PathBuf, + registry: Result, String>, + expects_dispatch_input: bool, +} + +#[derive(Clone)] +pub(crate) struct PinnedToolCode { + pub(crate) program: Arc, + pub(crate) entry: String, + pub(crate) package_id: String, + pub(crate) artifact_id: String, + pub(crate) export: String, + pub(crate) registry: Result, String>, + pub(crate) expects_dispatch_input: bool, +} + +#[derive(Clone)] +pub(crate) struct PinnedToolReceipt { + pub(crate) graph_id: String, + pub(crate) generation: u64, + pub(crate) ref_id: String, + pub(crate) run_id: String, + pub(crate) session_id: String, + pub(crate) profile: String, + pub(crate) registry_identity: String, + pub(crate) workspace: std::path::PathBuf, +} + +impl PinnedToolProgram { + pub(crate) fn seal(code: PinnedToolCode, receipt: PinnedToolReceipt) -> Self { + Self { + program: code.program, + entry: code.entry, + package_id: code.package_id, + artifact_id: code.artifact_id, + export: code.export, + registry: code.registry, + expects_dispatch_input: code.expects_dispatch_input, + graph_id: receipt.graph_id, + generation: receipt.generation, + ref_id: receipt.ref_id, + run_id: receipt.run_id, + session_id: receipt.session_id, + profile: receipt.profile, + registry_identity: receipt.registry_identity, + workspace: receipt.workspace, + } + } + + pub fn entry(&self) -> &str { + &self.entry + } + + pub fn graph_id(&self) -> &str { + &self.graph_id + } + + pub fn generation(&self) -> u64 { + self.generation + } + + /// Original sealed identity only. This path does not confer workspace IO + /// authority and must never be used to construct filesystem capabilities. + pub(crate) fn workspace_identity(&self) -> &std::path::Path { + &self.workspace + } + + pub fn shares_program(&self, other: &Arc) -> bool { + Arc::ptr_eq(&self.program, other) + } +} + +/// One approved tool call. The child may not replace its identity fields. +#[derive(Clone)] +pub(crate) struct ExpectedCallGrant { + pub run_id: String, + pub call_id: String, + pub tool_name: String, + pub argument_digest: String, + pub registry_identity: String, + pub risk_class: String, + pub summary: String, + outcome: Arc>, +} + +/// Native-only evidence for one child invocation. Guest return values cannot +/// populate this witness or borrow a sibling invocation's execution token. +#[derive(Default)] +enum PinnedCallOutcome { + #[default] + Pending, + Executing { + token: String, + }, + Complete { + result: JsonValue, + }, + Failed { + error: JsonValue, + }, +} + +impl PinnedCallOutcome { + fn resolve(&self, child: &JsonValue) -> JsonValue { + match self { + Self::Complete { result } => { + match crate::capabilities::lifecycle::validate_canonical_result(result) { + Ok(()) => pinned_tool_result(result), + Err(error) => { + pinned_tool_result(&crate::capabilities::host::error_envelope(&error)) + } + } + } + Self::Failed { error } if error.get("ok") == Some(&JsonValue::Bool(false)) => { + pinned_tool_result(error) + } + Self::Pending | Self::Executing { .. } + if child.get("ok") == Some(&JsonValue::Bool(false)) => + { + pinned_tool_result(child) + } + Self::Pending | Self::Executing { .. } | Self::Failed { .. } => pinned_outcome( + false, + "", + "", + "pinned_result_uncommitted", + "pinned tool did not complete a matching durable commit or replay", + "", + ), + } + } +} + +impl ExpectedCallGrant { + fn matches(&self, metadata: &crate::capabilities::PrepareMetadata) -> bool { + self.run_id == metadata.run_id + && self.call_id == metadata.call_id + && self.tool_name == metadata.tool_name + && self.argument_digest == metadata.argument_digest + && self.registry_identity == metadata.registry_identity + && self.risk_class == metadata.risk_class.as_str() + && self.summary == metadata.summary + } +} + /// Injectable host bridges for one compiled runner. #[derive(Clone, Default)] pub struct AgentHostBridges { @@ -534,6 +743,11 @@ pub struct AgentHostBridges { /// Optional test hook invoked from `agent::control_check` before reading /// cancellation/deadline. Production callers leave this unset. pub control_hook: Option, + /// Callable program retained for this run. Absent until admission pins one. + pub pinned_tool: Option, + /// Set only by a Native host that is explicitly stateless. A missing pin + /// on a persistent host is a failure, not a reason to compile the embedding. + pub allow_stateless_pin: bool, } /// Per-VM state installed before `run(context)`. @@ -552,6 +766,10 @@ pub struct AgentHostState { pub artifacts: Option>, pub(crate) leases: Arc>>, pub(crate) control_hook: Option, + pub(crate) pinned_tool: Option, + pub(crate) allow_stateless_pin: bool, + pub(crate) expected_call: Option, + pub(crate) provider_arguments: Arc>>, } impl AgentHostState { @@ -577,7 +795,51 @@ impl AgentHostState { if let Some(error) = self.control_error() { return error; } - normalize_provider_envelope(self.provider.call(request, &self.cancellation)) + let envelope = normalize_provider_envelope(self.provider.call(request, &self.cancellation)); + let mut parents = self + .provider_arguments + .lock() + .unwrap_or_else(|error| error.into_inner()); + parents.clear(); + if let Some(calls) = envelope + .get("response") + .and_then(|response| response.get("tool_calls")) + .and_then(JsonValue::as_array) + { + for call in calls { + let key = ( + call.get("id") + .and_then(JsonValue::as_str) + .unwrap_or("") + .to_string(), + call.get("name") + .and_then(JsonValue::as_str) + .unwrap_or("") + .to_string(), + ); + let arguments = call + .get("arguments") + .filter(|value| value.is_object()) + .cloned() + .or_else(|| { + let text = call.get("arguments_json")?.as_str()?; + if json_object_rejected(text) { + return None; + } + serde_json::from_str::(text) + .ok() + .filter(JsonValue::is_object) + }) + .unwrap_or(JsonValue::Null); + if let std::collections::hash_map::Entry::Vacant(entry) = parents.entry(key.clone()) + { + entry.insert(arguments); + } else { + parents.insert(key, JsonValue::Null); + } + } + } + envelope } fn approval_storage(&self, operation: &str, payload: &JsonValue) -> JsonValue { @@ -613,10 +875,28 @@ impl AgentHostState { "capability owner is not installed".to_string(), )); }; - let envelope = match parse_prepare_metadata(metadata) { - Ok(metadata) => tool_prepare(lifecycle, owner, metadata), + let parsed = match parse_prepare_metadata(metadata) { + Ok(metadata) => metadata, Err(error) => return crate::capabilities::host::error_envelope(&error), }; + if let Some(grant) = &self.expected_call + && !grant.matches(&parsed) + { + return pinned_call_mismatch(); + } + let mut outcome = self.expected_call.as_ref().map(|grant| { + grant + .outcome + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + }); + if outcome + .as_deref() + .is_some_and(|outcome| !matches!(outcome, PinnedCallOutcome::Pending)) + { + return pinned_call_mismatch(); + } + let envelope = tool_prepare(lifecycle, owner, parsed); if envelope.get("ok") == Some(&JsonValue::Bool(true)) && envelope.get("kind") == Some(&JsonValue::String("execute".to_string())) && let Some(token) = envelope.get("execution_token").and_then(JsonValue::as_str) @@ -626,10 +906,253 @@ impl AgentHostState { .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) .insert(token.to_string(), lease); + if let Some(outcome) = outcome.as_deref_mut() { + *outcome = PinnedCallOutcome::Executing { + token: token.to_string(), + }; + } + } else if let Some(outcome) = outcome.as_deref_mut() { + *outcome = if envelope.get("ok") == Some(&JsonValue::Bool(true)) + && envelope.get("kind").and_then(JsonValue::as_str) == Some("replay") + && let Some(result) = envelope.get("result") + { + PinnedCallOutcome::Complete { + result: result.clone(), + } + } else { + PinnedCallOutcome::Failed { + error: envelope.clone(), + } + }; } envelope } + fn invoke_pinned_tool(&self, request: &JsonValue) -> JsonValue { + let pinned = match self.pinned_tool.clone() { + Some(pinned) => pinned, + None => { + if !self.allow_stateless_pin { + return pinned_outcome( + false, + "", + "", + "pinned_tool_missing", + "this run has no retained tool program", + "", + ); + } + match self.stateless_default_binding() { + Some(pinned) => pinned, + None => { + return pinned_outcome( + false, + "", + "", + "pinned_tool_missing", + "this run has no retained tool program", + "", + ); + } + } + } + }; + if let Some(error) = self.binding_mismatch(&pinned) { + return error; + } + let field = |key: &str| { + request + .get(key) + .and_then(JsonValue::as_str) + .unwrap_or("") + .to_string() + }; + let requested_run = field("run_id"); + if !requested_run.is_empty() && requested_run != pinned.run_id { + return pinned_binding_mismatch(); + } + let requested_registry = field("registry_identity"); + if !requested_registry.is_empty() && requested_registry != pinned.registry_identity { + return pinned_binding_mismatch(); + } + let _ignored_graph = request.get("untrusted_graph_id"); + let canonical = field("canonical_arguments"); + let digest = field("argument_digest"); + if canonical.is_empty() || digest != canonical || json_object_rejected(&canonical) { + return pinned_call_mismatch(); + } + let Ok(arguments) = serde_json::from_str::(&canonical) else { + return pinned_call_mismatch(); + }; + if !arguments.is_object() { + return pinned_call_mismatch(); + } + let call_id = field("call_id"); + let tool_name = field("tool_name"); + let expected = match self.lifecycle.as_ref().zip(self.capability_owner.as_ref()) { + Some((lifecycle, owner)) => { + match lifecycle.parent_arguments(owner, &call_id, &tool_name) { + Ok(Some(arguments)) => Some(arguments), + Ok(None) => self + .provider_arguments + .lock() + .unwrap_or_else(|error| error.into_inner()) + .get(&(call_id.clone(), tool_name.clone())) + .cloned(), + Err(_) => None, + } + } + None => None, + }; + if expected.as_ref() != Some(&arguments) { + return pinned_call_mismatch(); + } + let risk_class = field("risk_class"); + let summary = field("summary"); + let canonical_input = field("canonical_input"); + let context = if pinned.expects_dispatch_input { + if canonical_input.is_empty() || json_object_rejected(&canonical_input) { + return pinned_call_mismatch(); + } + let Ok(input) = serde_json::from_str::(&canonical_input) else { + return pinned_call_mismatch(); + }; + let call_arguments = input + .get("call") + .and_then(|call| call.get("arguments")) + .cloned() + .unwrap_or(JsonValue::Null); + let call = input.get("call").unwrap_or(&JsonValue::Null); + if call_arguments != arguments + || call + .get("id") + .and_then(JsonValue::as_str) + .filter(|id| !id.is_empty()) + .or_else(|| call.get("tool_call_id").and_then(JsonValue::as_str)) + != Some(call_id.as_str()) + || call.get("name").and_then(JsonValue::as_str) != Some(tool_name.as_str()) + { + return pinned_call_mismatch(); + } + json!({ + "call": call, + "prepared_context": { + "kind": "execute", + "prepare": { + "run_id": pinned.run_id, + "call_id": call_id.clone(), + "tool_name": tool_name.clone(), + "name": tool_name.clone(), + "argument_digest": canonical.clone(), + "registry_identity": pinned.registry_identity, + "risk_class": risk_class.clone(), + "summary": summary.clone(), + }, + "arguments": arguments, + "config": input.get("config").filter(|value| value.is_object()).cloned().unwrap_or_else(|| json!({})), + } + }) + } else { + json!({ + "prepare": { + "run_id": pinned.run_id, + "call_id": call_id.clone(), + "tool_name": tool_name.clone(), + "name": tool_name.clone(), + "argument_digest": canonical.clone(), + "registry_identity": pinned.registry_identity, + "risk_class": risk_class.clone(), + "summary": summary.clone(), + }, + "arguments": arguments + }) + }; + let outcome = Arc::new(Mutex::new(PinnedCallOutcome::Pending)); + let grant = ExpectedCallGrant { + run_id: pinned.run_id.clone(), + call_id, + tool_name, + argument_digest: canonical, + registry_identity: pinned.registry_identity.clone(), + risk_class, + summary, + outcome: Arc::clone(&outcome), + }; + if grant.call_id.is_empty() || grant.tool_name.is_empty() { + return pinned_call_mismatch(); + } + let host = AgentHostBridges { + provider: None, + auth_host_factory: None, + cancellation: Some(self.cancellation.clone()), + sleeps: Arc::clone(&self.sleeps), + skip_sleep: self.skip_sleep, + metrics: self.metrics.clone(), + lifecycle: self.lifecycle.clone(), + capability_owner: self.capability_owner.clone(), + filesystem: self.filesystem.clone(), + processes: self.processes.clone(), + artifacts: self.artifacts.clone(), + control_hook: self.control_hook.clone(), + pinned_tool: None, + allow_stateless_pin: false, + }; + match super::rss_runner::invoke_retained_tool( + Arc::clone(&pinned.program), + &pinned.entry, + host, + json_to_vm_value(&context), + &self.cancellation, + &pinned.registry, + Some(grant), + ) { + Ok(value) => outcome + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .resolve(&vm_value_to_json(&value)), + Err(error) => pinned_outcome(false, "", "", "pinned_tool_failed", &error, ""), + } + } + + fn stateless_default_binding(&self) -> Option { + let owner = self.capability_owner.as_ref()?; + let lifecycle = self.lifecycle.as_ref()?; + let runtime = super::native_tools::shared_embedded_runtime().ok()?; + Some(runtime.binding_for( + owner.run(), + owner.session(), + owner.profile(), + lifecycle.registry_identity(), + lifecycle.workspace().to_path_buf(), + )) + } + + fn binding_mismatch(&self, pinned: &PinnedToolProgram) -> Option { + let Some(owner) = self.capability_owner.as_ref() else { + return Some(pinned_binding_mismatch()); + }; + let Some(lifecycle) = self.lifecycle.as_ref() else { + return Some(pinned_binding_mismatch()); + }; + if pinned.run_id.is_empty() + || pinned.session_id.is_empty() + || pinned.ref_id.is_empty() + || pinned.profile.is_empty() + || pinned.entry.is_empty() + || pinned.export.is_empty() + || pinned.package_id.is_empty() + || pinned.artifact_id.is_empty() + || owner.run() != pinned.run_id + || owner.session() != pinned.session_id + || owner.profile() != pinned.profile + || lifecycle.registry_identity() != pinned.registry_identity + || !same_workspace(lifecycle.workspace(), &pinned.workspace) + { + return Some(pinned_binding_mismatch()); + } + None + } + fn capability_replay(&self, metadata: &JsonValue) -> JsonValue { let Some(lifecycle) = self.lifecycle.as_ref() else { return crate::capabilities::host::error_envelope(&LifecycleError::InvalidMetadata( @@ -682,7 +1205,43 @@ impl AgentHostState { "capability owner is not installed".to_string(), )); }; + let mut outcome = self.expected_call.as_ref().map(|grant| { + grant + .outcome + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + }); + if outcome.as_deref().is_some_and(|outcome| { + !matches!(outcome, PinnedCallOutcome::Executing { token: expected } if expected == token) + }) || (self.expected_call.is_some() + && !self + .leases + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .contains_key(token)) + { + return pinned_call_mismatch(); + } let envelope = tool_commit(lifecycle, owner, token, result.clone()); + if let Some(outcome) = outcome.as_deref_mut() { + *outcome = if envelope.get("ok") == Some(&JsonValue::Bool(true)) + && envelope.get("kind").and_then(JsonValue::as_str) == Some("committed") + && envelope.get("call_id").and_then(JsonValue::as_str) + == self + .expected_call + .as_ref() + .map(|grant| grant.call_id.as_str()) + && let Some(result) = envelope.get("result") + { + PinnedCallOutcome::Complete { + result: result.clone(), + } + } else { + PinnedCallOutcome::Failed { + error: envelope.clone(), + } + }; + } let Some(mut lease) = self .leases .lock() @@ -1275,6 +1834,264 @@ fn tool_commit_adapter(vm: &mut Vm, args: &[Value]) -> VmResult { return_json(state.capability_commit(&token, &vm_value_to_json(&result))) } +fn invoke_pinned_tool_adapter(vm: &mut Vm, args: &[Value]) -> VmResult { + let request = args.first().cloned().unwrap_or(Value::Null); + let state = installed_state(vm)?; + return_json(state.invoke_pinned_tool(&vm_value_to_json(&request))) +} + +fn pinned_outcome( + ok: bool, + content: &str, + kind: &str, + code: &str, + message: &str, + canonical_result: &str, +) -> JsonValue { + json!({ + "ok": ok, + "text": content, + "content": content, + "kind": kind, + "code": code, + "message": message, + "canonical_result": canonical_result, + }) +} + +fn pinned_binding_mismatch() -> JsonValue { + pinned_outcome( + false, + "", + "", + "pinned_binding_mismatch", + "retained tool binding does not match this owner", + "", + ) +} + +fn pinned_call_mismatch() -> JsonValue { + pinned_outcome( + false, + "", + "", + "pinned_call_mismatch", + "tool call does not match the approved grant", + "", + ) +} + +fn same_workspace(left: &std::path::Path, right: &std::path::Path) -> bool { + left == right +} + +const PINNED_CANONICAL_RESULT_MAX_BYTES: usize = 1024 * 1024; + +fn pinned_tool_result(value: &JsonValue) -> JsonValue { + let explicit_ok = value.get("ok").and_then(JsonValue::as_bool); + let content = value + .get("content") + .and_then(JsonValue::as_str) + .or_else(|| { + value + .get("content_block") + .and_then(|block| block.get("content")) + .and_then(JsonValue::as_str) + }) + .or_else(|| value.get("text").and_then(JsonValue::as_str)) + .unwrap_or(""); + let code = value + .get("code") + .and_then(JsonValue::as_str) + .or_else(|| { + value + .get("error") + .and_then(|error| error.get("code")) + .and_then(JsonValue::as_str) + }) + .unwrap_or(""); + let message = value + .get("message") + .and_then(JsonValue::as_str) + .or_else(|| { + value + .get("error") + .and_then(|error| error.get("message")) + .and_then(JsonValue::as_str) + }) + .unwrap_or(""); + let kind = value.get("kind").and_then(JsonValue::as_str).unwrap_or(""); + let canonical = match serde_json::to_string(value) { + Ok(encoded) if encoded.len() <= PINNED_CANONICAL_RESULT_MAX_BYTES => encoded, + Ok(_) => { + return pinned_outcome( + false, + "", + "", + "pinned_result_too_large", + "pinned tool result exceeds the canonical bound", + "", + ); + } + Err(_) => String::new(), + }; + let Some(ok) = explicit_ok else { + return pinned_outcome( + false, + "", + kind, + if code.is_empty() { + "pinned_tool_failed" + } else { + code + }, + if message.is_empty() { + "pinned tool result omitted explicit ok" + } else { + message + }, + &canonical, + ); + }; + pinned_outcome(ok, content, kind, code, message, &canonical) +} + +fn json_object_rejected(raw: &str) -> bool { + json_has_duplicate_keys(raw).unwrap_or(true) +} + +fn json_has_duplicate_keys(raw: &str) -> Result { + let mut chars = raw.chars().peekable(); + let duplicated = walk_json(&mut chars)?; + skip_json_ws(&mut chars); + if chars.next().is_some() { + return Err(()); + } + Ok(duplicated) +} + +fn skip_json_ws(chars: &mut std::iter::Peekable>) { + while chars.peek().is_some_and(|ch| ch.is_whitespace()) { + chars.next(); + } +} + +fn walk_json(chars: &mut std::iter::Peekable>) -> Result { + skip_json_ws(chars); + match chars.peek().copied() { + Some('{') => { + chars.next(); + let mut keys = std::collections::BTreeSet::new(); + loop { + skip_json_ws(chars); + if chars.peek() == Some(&'}') { + chars.next(); + return Ok(false); + } + let key = parse_json_string(chars)?; + if !keys.insert(key) { + return Ok(true); + } + skip_json_ws(chars); + if chars.next() != Some(':') { + return Err(()); + } + if walk_json(chars)? { + return Ok(true); + } + skip_json_ws(chars); + match chars.next() { + Some(',') => continue, + Some('}') => return Ok(false), + _ => return Err(()), + } + } + } + Some('[') => { + chars.next(); + loop { + skip_json_ws(chars); + if chars.peek() == Some(&']') { + chars.next(); + return Ok(false); + } + if walk_json(chars)? { + return Ok(true); + } + skip_json_ws(chars); + match chars.next() { + Some(',') => continue, + Some(']') => return Ok(false), + _ => return Err(()), + } + } + } + Some('"') => { + parse_json_string(chars)?; + Ok(false) + } + Some(ch) if ch == '-' || ch.is_ascii_digit() => { + chars.next(); + while chars + .peek() + .is_some_and(|next| matches!(next, '0'..='9' | '.' | 'e' | 'E' | '+' | '-')) + { + chars.next(); + } + Ok(false) + } + Some('t') => expect_json_literal(chars, "true"), + Some('f') => expect_json_literal(chars, "false"), + Some('n') => expect_json_literal(chars, "null"), + _ => Err(()), + } +} + +fn expect_json_literal( + chars: &mut std::iter::Peekable>, + literal: &str, +) -> Result { + for expected in literal.chars() { + if chars.next() != Some(expected) { + return Err(()); + } + } + Ok(false) +} + +fn parse_json_string(chars: &mut std::iter::Peekable>) -> Result { + if chars.next() != Some('"') { + return Err(()); + } + let mut out = String::new(); + loop { + match chars.next() { + Some('"') => return Ok(out), + Some('\\') => match chars.next() { + Some('"') => out.push('"'), + Some('\\') => out.push('\\'), + Some('/') => out.push('/'), + Some('b') => out.push('\u{0008}'), + Some('f') => out.push('\u{000c}'), + Some('n') => out.push('\n'), + Some('r') => out.push('\r'), + Some('t') => out.push('\t'), + Some('u') => { + let mut hex = String::new(); + for _ in 0..4 { + hex.push(chars.next().ok_or(())?); + } + let code = u32::from_str_radix(&hex, 16).map_err(|_| ())?; + out.push(char::from_u32(code).ok_or(())?); + } + _ => return Err(()), + }, + Some(ch) => out.push(ch), + None => return Err(()), + } + } +} + fn cap_fs_metadata_adapter(vm: &mut Vm, args: &[Value]) -> VmResult { let state = installed_state(vm)?; decode_then( @@ -2057,6 +2874,10 @@ pub fn b1_send( artifacts: None, leases: Arc::new(Mutex::new(HashMap::new())), control_hook: None, + pinned_tool: None, + allow_stateless_pin: false, + expected_call: None, + provider_arguments: Default::default(), }); let context = auth_host.bind_vm(&mut vm).expect("bind genuine auth owner"); let home = field(&context, "host_home").clone(); diff --git a/src/runtime/closed_bundle.rs b/src/runtime/closed_bundle.rs index d5a3973..2ad5d00 100644 --- a/src/runtime/closed_bundle.rs +++ b/src/runtime/closed_bundle.rs @@ -522,6 +522,50 @@ fn resolve_bundle_import( Ok(target) } +/// Compile the embedded tool dispatcher from closed catalog bytes. +/// The caller supplies the host catalog; this function opens no path. +pub(crate) fn compile_embedded_tool_dispatch( + catalog: &Arc, + host_roots: &BTreeSet, +) -> Result { + let closure = select_catalog_closure( + crate::bundled_rss_sources(), + catalog.as_ref(), + "rss/tools/dispatch.rss", + host_roots, + )?; + let sources: Vec<(&str, &str)> = closure + .iter() + .map(|key| { + ( + key.as_str(), + *crate::bundled_rss_sources() + .iter() + .find(|(candidate, _)| candidate == key) + .map(|(_, source)| source) + .expect("selected catalog key"), + ) + }) + .collect(); + compile_bundled_closed_bundle( + "rss/tools/dispatch.rss", + &sources, + ClosedBundleOptions { + limits: ClosedBundleLimits { + max_files: MAX_CATALOG_FILES, + max_depth: MAX_CATALOG_DEPTH, + max_file_bytes: MAX_CATALOG_FILE_BYTES, + max_total_bytes: MAX_CATALOG_TOTAL_BYTES, + }, + virtual_root: Path::new("/__rustscript_agent_bundled_rss__"), + host_roots, + catalog: Arc::clone(catalog), + dialect: bundled_rss_dialect(), + entry_export: "invoke_prepared", + }, + ) +} + #[cfg(test)] pub(crate) mod tests { use super::*; diff --git a/src/runtime/host_types.rs b/src/runtime/host_types.rs index 3ae42e0..568e6a2 100644 --- a/src/runtime/host_types.rs +++ b/src/runtime/host_types.rs @@ -221,6 +221,37 @@ named_struct!( ] ); +named_struct!( + PinnedToolInvokeRequest, + "PinnedToolInvokeRequest", + [ + ("tool_name", HostTypeSchema::String), + ("call_id", HostTypeSchema::String), + ("canonical_arguments", HostTypeSchema::String), + ("canonical_input", HostTypeSchema::String), + ("run_id", HostTypeSchema::String), + ("registry_identity", HostTypeSchema::String), + ("argument_digest", HostTypeSchema::String), + ("risk_class", HostTypeSchema::String), + ("summary", HostTypeSchema::String), + ("untrusted_graph_id", HostTypeSchema::String) + ] +); + +named_struct!( + PinnedToolInvokeResult, + "PinnedToolInvokeResult", + [ + ("ok", HostTypeSchema::Bool), + ("text", HostTypeSchema::String), + ("content", HostTypeSchema::String), + ("kind", HostTypeSchema::String), + ("code", HostTypeSchema::String), + ("message", HostTypeSchema::String), + ("canonical_result", HostTypeSchema::String) + ] +); + named_struct!( AgentFsReadResult, "AgentFsReadResult", diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs index fffeb43..db4ccd9 100644 --- a/src/runtime/mod.rs +++ b/src/runtime/mod.rs @@ -9,6 +9,7 @@ pub(crate) mod host_compose; pub(crate) mod host_types; pub(crate) mod host_wait; pub(crate) mod module_snapshot; +pub(crate) mod native_tools; pub mod rss_runner; pub use agent_host::{ diff --git a/src/runtime/native_tools.rs b/src/runtime/native_tools.rs new file mode 100644 index 0000000..70eedf2 --- /dev/null +++ b/src/runtime/native_tools.rs @@ -0,0 +1,188 @@ +//! Default tool runtime compiled once from closed embedded catalog bytes. +//! +//! A signed package root, when configured, is a private sibling of the trusted +//! SQLite path. Request JSON never selects that directory or the program. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, OnceLock}; + +use super::agent_host::{PinnedToolCode, PinnedToolProgram, PinnedToolReceipt}; + +pub(crate) const EMBEDDING_GRAPH_ID: &str = "embedding:agent.tools/v1"; + +/// Trusted package directory derived only from the gateway SQLite path. +pub fn trusted_tool_package_root(sqlite_path: &Path) -> PathBuf { + let name = sqlite_path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("state.db"); + let parent = sqlite_path.parent().unwrap_or_else(|| Path::new(".")); + parent.join(format!(".{name}.tool-packages")) +} + +/// One compiled embedded dispatcher, shared by runs that have no signed pin. +pub(crate) struct NativeToolRuntime { + program: Arc, + entry: String, + registry: Result, String>, +} + +impl NativeToolRuntime { + pub(crate) fn compile() -> Result { + let catalog = super::agent_host::agent_host_catalog(); + let mut host_roots = BTreeSet::new(); + host_roots.insert("json".to_string()); + host_roots.insert("bytes".to_string()); + for function in catalog.functions() { + if let Some((namespace, _)) = function.name.split_once("::") { + host_roots.insert(namespace.to_string()); + } + } + let compiled = super::closed_bundle::compile_embedded_tool_dispatch(&catalog, &host_roots) + .map_err(|error| format!("compile embedded tool dispatch: {error:?}"))?; + let entry = compiled + .entry_name + .filter(|entry| !entry.is_empty()) + .ok_or_else(|| "embedded tool dispatch has no callable".to_string())?; + let authorized_imports: Vec<_> = compiled + .program + .imports + .iter() + .map(|import| import.name.clone()) + .collect(); + // Immutable adapters only; owner, cancellation and invocation witness bind later. + let registry = super::rss_runner::build_pinned_registry(&authorized_imports) + .map(Arc::new) + .map_err(|error| error.to_string()); + Ok(Self { + program: Arc::new(compiled.program), + entry, + registry, + }) + } + + pub(crate) fn binding_for( + &self, + run_id: &str, + session_id: &str, + profile: &str, + registry_identity: &str, + workspace: PathBuf, + ) -> PinnedToolProgram { + PinnedToolProgram::seal( + PinnedToolCode { + program: Arc::clone(&self.program), + entry: self.entry.clone(), + package_id: "embedding".to_string(), + artifact_id: "embedding".to_string(), + export: "invoke_prepared".to_string(), + registry: self.registry.clone(), + expects_dispatch_input: true, + }, + PinnedToolReceipt { + graph_id: EMBEDDING_GRAPH_ID.to_string(), + generation: 0, + ref_id: format!("embedding:{run_id}"), + run_id: run_id.to_string(), + session_id: session_id.to_string(), + profile: profile.to_string(), + registry_identity: registry_identity.to_string(), + workspace, + }, + ) + } +} + +/// Process-wide embedded dispatcher. Only an explicit stateless host may +/// execute it when no pin is present. Durable missing pins do not use it. +pub(crate) fn shared_embedded_runtime() -> Result, String> { + static RUNTIME: OnceLock, String>> = OnceLock::new(); + RUNTIME + .get_or_init(|| NativeToolRuntime::compile().map(Arc::new)) + .clone() +} + +pub(crate) const EMBEDDING_PACKAGE_ID: &str = "org.rustscript.embedding.tools"; +const EMBEDDING_ENTRY: &str = "rss/tools/dispatch.rss"; + +pub(crate) fn embedding_package_files() +-> Result>, String> { + let catalog = super::agent_host::agent_host_catalog(); + let mut host_roots = BTreeSet::new(); + host_roots.insert("json".to_string()); + host_roots.insert("bytes".to_string()); + for function in catalog.functions() { + if let Some((namespace, _)) = function.name.split_once("::") { + host_roots.insert(namespace.to_string()); + } + } + let sources = crate::bundled_rss_sources(); + let keys = super::closed_bundle::select_catalog_closure( + sources, + &catalog, + EMBEDDING_ENTRY, + &host_roots, + ) + .map_err(|error| format!("embedding closure: {error:?}"))?; + let mut files = std::collections::BTreeMap::new(); + files.insert( + "manifest.yaml".to_string(), + embedding_manifest().into_bytes(), + ); + for key in keys { + let bytes = sources + .iter() + .find(|(name, _)| *name == key) + .map(|(_, source)| source.as_bytes().to_vec()) + .ok_or_else(|| format!("embedding source missing {key}"))?; + files.insert(key, bytes); + } + Ok(files) +} + +pub(crate) fn embedding_payload_matches(payload: &[u8]) -> bool { + embedding_package_files() + .ok() + .is_some_and(|files| crate::plugins::embedding_payload_bytes(&files) == payload) +} + +fn embedding_manifest() -> String { + format!( + r#"format: rustscript.plugin/v1 +id: {EMBEDDING_PACKAGE_ID} +version: 1.0.0 +kind: tool +runtime: ">=0.1.0, <0.2.0" +contract: agent.tool/v1 +host_abi: agent.cap/v1 +entry: + path: {EMBEDDING_ENTRY} + export: invoke_prepared +dependencies: [] +capabilities: + - clock.read + - process.spawn + - workspace.read + - workspace.write +effects: + contract: agent.effects/v1 + entries: + invoke_prepared: + direct: + - kind: clock.read + scope: bound_run + recovery: none + - kind: process.spawn + scope: bound_process + recovery: none + - kind: workspace.fs.read + scope: bound_workspace + recovery: none + - kind: workspace.fs.write + scope: bound_workspace + recovery: none + delegated: [] +"# + ) +} diff --git a/src/runtime/rss_runner.rs b/src/runtime/rss_runner.rs index 2e8b539..1803e7a 100644 --- a/src/runtime/rss_runner.rs +++ b/src/runtime/rss_runner.rs @@ -42,8 +42,8 @@ use super::closed_bundle; use super::host_wait::wait_for_host_op_blocking_with_cancel; use super::agent_host::{ - AgentHostBridges, AgentHostState, AgentProviderHost, AuthHostFactory, agent_host_catalog, - agent_host_module, register_agent_host_functions, + AgentHostBridges, AgentHostState, AgentProviderHost, AuthHostFactory, ExpectedCallGrant, + agent_host_catalog, agent_host_module, register_agent_host_functions, }; use crate::auth::host::AuthHost; use crate::capabilities::sha256_hex; @@ -933,6 +933,7 @@ pub struct AgentRunner { /// the requested logical export, and the exact root callable. `None` for /// legacy `from_source`/`from_file` runners. bundled_origin: Option, + expected_call: Option, } struct NativeAuthBinding { @@ -1146,6 +1147,7 @@ impl AgentRunner { prepare_fault: RunnerPrepareFault::None, snapshot_digest, bundled_origin, + expected_call: None, }) } @@ -1456,6 +1458,10 @@ impl AgentRunner { artifacts: self.host.artifacts.clone(), leases: Arc::new(Mutex::new(HashMap::new())), control_hook: self.host.control_hook.clone(), + pinned_tool: self.host.pinned_tool.clone(), + allow_stateless_pin: self.host.allow_stateless_pin, + expected_call: self.expected_call.clone(), + provider_arguments: Default::default(), }); let auth_binding = if let Some(factory) = &self.host.auth_host_factory { let cancellation = cancellation @@ -1649,6 +1655,65 @@ impl AgentRunner { } } +/// Pure compiler-lowered conversions. They are not recorded as host imports, +/// but the capability profile still checks them. Effect builtins stay denied +/// unless the captured program imported them. +const PINNED_PURE_BUILTINS: &[&str] = &[ + "json::encode", + "json::decode", + "bytes::to_utf8", + "bytes::to_utf8_lossy", + "bytes::to_array_u8", + "bytes::from_utf8", +]; + +#[cfg(test)] +thread_local! { + static PINNED_REGISTRY_BUILDS: std::cell::Cell = const { std::cell::Cell::new(0) }; +} + +/// Prepare immutable tool adapters against the original compile catalog. +/// The sealed code retains this registry; each child binds fresh native state. +pub(crate) fn build_pinned_registry( + allowed: &[String], +) -> std::result::Result { + #[cfg(test)] + PINNED_REGISTRY_BUILDS.with(|count| count.set(count.get() + 1)); + let catalog = agent_host_catalog(); + let mut registry = HostFunctionRegistry::empty(); + registry.set_capability_profile(CapabilityProfile::deny_all()); + super::agent_host::retained_tool_host_module() + .install_from_catalog(&mut registry, catalog.as_ref())?; + if allowed.iter().any(|name| name.starts_with("http::")) { + register_http_builtin_module_from_catalog(&mut registry, catalog.as_ref())?; + } + if allowed.iter().any(|name| name == "stream::emit") { + for module in standard_host_modules() { + if module.name == "context" { + HostModuleDescriptor { + name: "context", + functions: module.owned, + resources: &[], + } + .install_from_catalog(&mut registry, catalog.as_ref())?; + } + } + } + registry.set_capability_profile(CapabilityProfile::deny_all()); + for name in allowed + .iter() + .map(String::as_str) + .chain(PINNED_PURE_BUILTINS.iter().copied()) + { + if registry.contains_name(name) { + registry.authorize_registered_builtin_import(name); + } else { + registry.allow_builtin(name)?; + } + } + Ok(registry) +} + /// Binds the restricted capability registry: JSON, bytes conversion, the /// invocation stream emit builtin, generic SQLite, the HTTP client, and the /// bounded agent provider/tool host bridges. Ambient runtime input/emit @@ -1817,6 +1882,36 @@ impl RunEventSink for DiscardSink { } } +/// Runs one retained tool program on a child cancellation of the caller's run. +/// The child shares the original deadline and cancel flag and owns only its watcher. +pub(crate) fn invoke_retained_tool( + program: Arc, + entry: &str, + mut host: AgentHostBridges, + context: Value, + cancellation: &RunCancellation, + registry: &std::result::Result, String>, + expected_call: Option, +) -> std::result::Result { + let child = cancellation.child(); + host.cancellation = Some(child.clone()); + host.pinned_tool = None; + let registry = registry.as_ref().map_err(Clone::clone)?; + let runner = AgentRunner { + program, + config: AgentConfig::default(), + registry: Arc::clone(registry), + host, + prepare_fault: RunnerPrepareFault::None, + snapshot_digest: "pinned-tool".to_string(), + bundled_origin: None, + expected_call, + }; + runner + .run_named_with_context_and_events(entry, context, &mut DiscardSink, &child) + .map_err(|error| error.to_string()) +} + fn adapter_fail(code: &str, message: &str) -> serde_json::Value { serde_json::json!({ "ok": false, @@ -2250,6 +2345,22 @@ mod shared_program_tests { .expect("state B") .clone(); assert!(!Arc::ptr_eq(&state_a.leases, &state_b.leases)); + assert!(!Arc::ptr_eq( + &state_a.provider_arguments, + &state_b.provider_arguments + )); + state_a + .provider_arguments + .lock() + .expect("arguments A") + .insert(("run-a".into(), "call-a".into()), json!({"a": 1})); + assert!( + state_b + .provider_arguments + .lock() + .expect("arguments B") + .is_empty() + ); state_a.cancellation.request(CancellationReason::Requested); assert_eq!(state_b.cancellation.requested(), None); for (runner, vm, callable) in [(&runner, &mut a, callable_a), (©, &mut b, callable_b)] { @@ -2311,6 +2422,20 @@ mod shared_program_tests { runner.prepare_vm("wrong-entry", None), Err(RunError::NoEntry) )); + let registry = Err("retained registry rejected".to_string()); + assert_eq!( + invoke_retained_tool( + Arc::clone(&runner.program), + "run", + AgentHostBridges::default(), + Value::map(vec![]), + &RunCancellation::with_timeout(Duration::from_secs(1)), + ®istry, + None + ) + .expect_err("registry first"), + "retained registry rejected" + ); } let mut boundary = program.as_ref().clone(); boundary.local_count = limit; @@ -2326,6 +2451,180 @@ mod shared_program_tests { mod restricted_registry_tests { use super::*; + #[test] + fn retained_shared_code_rechecks_each_invocations_registry_profile() { + let (program, _) = compiled_source_program( + "use agent; pub fn run(context: map) -> int { agent::utf8_byte_len(\"test\") }", + ) + .expect("host import fixture"); + let imports: Vec<_> = program + .imports + .iter() + .map(|import| import.name.clone()) + .collect(); + let allowed = build_pinned_registry(&imports) + .map(Arc::new) + .map_err(|error| error.to_string()); + let denied = build_pinned_registry(&[]) + .map(Arc::new) + .map_err(|error| error.to_string()); + for allow in [true, false, false, true] { + let cancellation = RunCancellation::with_timeout(Duration::from_secs(1)); + let result = invoke_retained_tool( + Arc::clone(&program), + "run", + AgentHostBridges::default(), + Value::map(vec![]), + &cancellation, + if allow { &allowed } else { &denied }, + None, + ); + if allow { + assert_eq!( + vm_value_to_json(&result.expect("authorized execution")), + json!(4) + ); + } else { + assert!( + result + .expect_err("fresh profile rejects import") + .contains("capability profile does not allow host import") + ); + } + assert!(!cancellation.watcher_is_armed()); + } + } + + #[test] + fn retained_adapter_preserves_code_allocation_for_each_fresh_invocation() { + let (program, _) = compiled_source_program( + "pub fn run(context: map) -> map { { retained_shared: true } }", + ) + .expect("compile retained code"); + let registry = build_pinned_registry(&[]) + .map(Arc::new) + .map_err(|error| error.to_string()); + for _ in 0..2 { + PREPARED_CODE_ALLOCATION.with(|seen| seen.set(None)); + let cancellation = RunCancellation::with_timeout(Duration::from_secs(1)); + let value = invoke_retained_tool( + Arc::clone(&program), + "run", + AgentHostBridges::default(), + Value::map(vec![]), + &cancellation, + ®istry, + None, + ) + .expect("real retained invocation"); + assert_eq!(vm_value_to_json(&value), json!({"retained_shared": true})); + assert_eq!( + PREPARED_CODE_ALLOCATION.with(Cell::get), + Some(program.code.as_ptr() as usize), + "retained adapter must not copy the code before the checked VM constructor" + ); + assert!(!cancellation.watcher_is_armed()); + } + } + + #[test] + fn retained_invocations_reuse_prepared_registry_without_reconstruction() { + let (program, _) = + compiled_source_program("pub fn run(context: map) -> map { { ok: true } }") + .expect("compile immutable retained program"); + let allowed: Vec<_> = program + .imports + .iter() + .map(|import| import.name.clone()) + .collect(); + let prepared_registry = build_pinned_registry(&allowed) + .map(Arc::new) + .map_err(|error| error.to_string()); + assert!(prepared_registry.is_ok()); + let before = PINNED_REGISTRY_BUILDS.with(|count| count.get()); + for _ in 0..2 { + let cancellation = RunCancellation::with_timeout(Duration::from_secs(1)); + let value = invoke_retained_tool( + Arc::clone(&program), + "run", + AgentHostBridges::default(), + Value::map(vec![]), + &cancellation, + &prepared_registry, + None, + ) + .expect("real retained child completes"); + assert_eq!(vm_value_to_json(&value), serde_json::json!({"ok":true})); + } + assert_eq!( + PINNED_REGISTRY_BUILDS.with(|count| count.get()) - before, + 0, + "invocation must not repeat immutable descriptor/catalog preparation" + ); + } + + #[test] + fn retained_registry_keeps_captured_import_profiles_separate() { + let (program, _) = compiled_source_program( + "use agent; pub fn run(context: map) -> int { agent::utf8_byte_len(\"test\") }", + ) + .expect("compile real retained host import"); + let imports: Vec<_> = program + .imports + .iter() + .map(|import| import.name.clone()) + .collect(); + assert!(!imports.is_empty()); + let allowed = build_pinned_registry(&imports).expect("captured imports"); + let denied = build_pinned_registry(&[]).expect("separate captured profile"); + for _ in 0..2 { + let mut allowed_vm = Vm::try_new(program.as_ref().clone()).expect("allowed VM"); + allowed + .bind_vm_cached(&mut allowed_vm) + .expect("captured import binds"); + let mut denied_vm = Vm::try_new(program.as_ref().clone()).expect("denied VM"); + let error = denied + .bind_vm_cached(&mut denied_vm) + .expect_err("other capture cannot borrow import permission"); + assert!( + error + .to_string() + .contains("capability profile does not allow host import"), + "{error}" + ); + } + } + + #[test] + fn retained_registry_preparation_error_stays_fail_closed_at_invocation() { + let (program, _) = + compiled_source_program("pub fn run(context: map) -> map { { ok: true } }") + .expect("compile immutable retained program"); + let registry = build_pinned_registry(&["unavailable::retained_import".to_string()]) + .map(Arc::new) + .map_err(|error| error.to_string()); + let expected = registry + .as_ref() + .err() + .expect("unavailable import rejected") + .clone(); + let before = PINNED_REGISTRY_BUILDS.with(|count| count.get()); + let cancellation = RunCancellation::with_timeout(Duration::from_secs(1)); + let error = invoke_retained_tool( + program, + "run", + AgentHostBridges::default(), + Value::map(vec![]), + &cancellation, + ®istry, + None, + ) + .expect_err("failed capture cannot execute"); + assert_eq!(error, expected); + assert!(!cancellation.watcher_is_armed()); + assert_eq!(PINNED_REGISTRY_BUILDS.with(|count| count.get()), before); + } + #[test] fn restricted_registry_installs_stream_emit() { build_restricted_registry().unwrap_or_else(|error| { diff --git a/src/service.rs b/src/service.rs index 47f53bd..3906aca 100644 --- a/src/service.rs +++ b/src/service.rs @@ -24,7 +24,7 @@ //! crash window. use std::collections::{HashMap, HashSet}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; #[cfg(test)] use std::sync::atomic::AtomicUsize; use std::sync::{ @@ -261,6 +261,8 @@ pub struct RunHandle { /// Exclusive worker occupancy. Concurrent `run_worker` tasks cannot both /// call the provider or advance the turn. Released on Drop (error/panic). occupancy: AtomicBool, + /// Sealed tool program for this run. Never reconstructed from request JSON. + pinned_tool: Option, } /// Run-scoped capability host shared by RSS dispatch and lifecycle cleanup. @@ -390,6 +392,8 @@ impl CapabilityHostState { processes: self.processes.clone(), artifacts: Some(Arc::clone(&self.artifacts)), control_hook: None, + pinned_tool: None, + allow_stateless_pin: false, } } } @@ -919,6 +923,8 @@ struct AgentServiceInner { crash_after_provider_request: AtomicBool, crash_after_tool_commit: AtomicBool, provider_commit_crashed: AtomicBool, + tool_packages: Mutex>>, + stateless_tools: AtomicBool, } impl Drop for AgentServiceInner { @@ -958,6 +964,7 @@ impl AgentService { .expect("built-in provider profile validates"); let mut provider_profiles = HashMap::new(); provider_profiles.insert(default_provider, default_profile); + let stateless_tools = AtomicBool::new(persistence.is_none()); let inner = Arc::new(AgentServiceInner { config, compaction_settings, @@ -1014,6 +1021,8 @@ impl AgentService { crash_after_provider_request: AtomicBool::new(false), crash_after_tool_commit: AtomicBool::new(false), provider_commit_crashed: AtomicBool::new(false), + tool_packages: Mutex::new(None), + stateless_tools, }); spawn_lifecycle_janitor(Arc::clone(&inner)); Self { inner } @@ -1310,9 +1319,37 @@ impl AgentService { self.cached_workspace_runner("open", config.clone())?; self.cached_workspace_runner("bind", config)?; } + // Preparation belongs to the existing setup path, before any run's + // original absolute admission deadline begins. + self.prepare_native_tool_runtime()?; Ok(runner.config().clone()) } + /// Capture and compile code only. Activation is re-read by each admission; + /// no run, owner receipt, workspace authority or expected-call grant is + /// created by initialization. Policy invalidation still clears the store's + /// verified/prepared cache, so subsequent drift uses the original clock. + fn prepare_native_tool_runtime(&self) -> Result<(), String> { + if self.inner.stateless_tools.load(Ordering::Acquire) { + crate::runtime::native_tools::shared_embedded_runtime()?; + } else if let Some(persistence) = self.inner.persistence.as_ref() { + let store = self + .tool_store() + .ok_or_else(|| "validated tool store is not installed".to_string())?; + let active = self + .ensure_active_tool_graph(&store, persistence) + .map_err(|error| error.to_string())?; + let control = crate::plugins::PluginControlStore::new(persistence); + let locked = control + .load_lock(&active.graph_id) + .map_err(|error| error.to_string())?; + store + .prepare(&locked.lock) + .map_err(|error| error.to_string())?; + } + Ok(()) + } + /// Test failpoint: panic after a successful provider-step commit, before /// the envelope is returned to RSS. The worker leaves the run started so /// a restart can replay the durable step. @@ -1395,6 +1432,179 @@ impl AgentService { Ok(()) } + /// Opens the trusted package sibling and remembers it. Memory-only + /// gateways are an explicit stateless embedding; they do not invent a pin. + pub fn install_native_tool_runtime( + &self, + sqlite_path: &Path, + ) -> Result<(), crate::plugins::PluginError> { + if self.inner.persistence.is_none() || sqlite_path == Path::new(":memory:") { + crate::runtime::native_tools::shared_embedded_runtime().map_err(|message| { + crate::plugins::PluginError::new( + crate::plugins::PluginErrorKind::CompileFailed, + message, + ) + })?; + self.inner.stateless_tools.store(true, Ordering::Release); + return Ok(()); + } + let store = crate::plugins::PackageStore::open(crate::plugins::trusted_tool_package_root( + sqlite_path, + ))?; + store.bind_policy(&crate::plugins::InstallAuthorization { + admin: false, + accepted_publishers: Vec::new(), + unsigned_dev_space: None, + }); + // Prepare the closed builtin code, never an activation or owner pin. + // A later trusted plugin-store installation must still be able to + // activate its first signed graph at generation zero. + let lock = Self::embedding_tool_graph(&store).map_err(|error| { + crate::plugins::PluginError::new( + crate::plugins::PluginErrorKind::CompileFailed, + error.to_string(), + ) + })?; + store.prepare(&lock)?; + *self.inner.tool_packages.lock().expect("tool packages") = Some(Arc::new(store)); + self.inner.stateless_tools.store(false, Ordering::Release); + Ok(()) + } + + /// Trusted embedder/test hook. The store must already carry its validated + /// policy. Request JSON cannot select this store. + pub fn install_validated_tool_store( + &self, + store: Arc, + ) -> Result<(), crate::plugins::PluginError> { + *self.inner.tool_packages.lock().expect("tool packages") = Some(store); + self.inner.stateless_tools.store(false, Ordering::Release); + Ok(()) + } + + fn tool_store(&self) -> Option> { + self.inner + .tool_packages + .lock() + .expect("tool packages") + .clone() + } + + fn seal_stateless_tool( + &self, + run_id: &str, + session_id: &str, + registry_identity: &str, + workspace: PathBuf, + ) -> Result { + if !self.inner.stateless_tools.load(Ordering::Acquire) { + return Err(AdmitError::Persistence( + "persistent tool pin is missing".to_string(), + )); + } + let runtime = crate::runtime::native_tools::shared_embedded_runtime() + .map_err(AdmitError::Persistence)?; + Ok(runtime.binding_for( + run_id, + session_id, + ADMISSION_SESSION_PROFILE, + registry_identity, + workspace, + )) + } + + fn embedding_tool_graph( + store: &crate::plugins::PackageStore, + ) -> Result { + if let Some(lock) = store.prepared_embedding_lock() { + return Ok(lock); + } + store.install_embedding_owned().map_err(|error| { + AdmitError::Persistence(format!("embedding bootstrap failed: {error}")) + })?; + store + .resolve(&crate::plugins::ResolveRequest { + roots: vec![crate::plugins::PackageRequirement { + id: crate::runtime::native_tools::EMBEDDING_PACKAGE_ID.to_string(), + version: "1.0.0".to_string(), + }], + host_runtime: crate::plugins::HOST_RUNTIME.to_string(), + }) + .map_err(|error| AdmitError::Persistence(format!("embedding resolve failed: {error}"))) + } + + fn ensure_active_tool_graph( + &self, + store: &crate::plugins::PackageStore, + persistence: &GatewayPersistence, + ) -> Result { + let control = crate::plugins::PluginControlStore::new(persistence); + let current = control + .activation() + .map_err(|error| AdmitError::Persistence(error.message.to_string()))?; + if !current.graph_id.is_empty() { + return Ok(current); + } + let lock = Self::embedding_tool_graph(store)?; + control + .store_lock(&lock) + .map_err(|error| AdmitError::Persistence(error.message.to_string()))?; + let refs: Vec = lock + .packages + .iter() + .map(|package| crate::plugins::PackageArtifactRef { + package_id: package.id.clone(), + version: package.version.clone(), + kind: package.kind.clone(), + artifact_id: package.artifact_id.clone(), + }) + .collect(); + match control.activate(&lock.graph_id, 0, &refs) { + Ok(activated) => Ok(activated), + Err(error) if error.code == "plugin_activation_conflict" => { + let raced = control + .activation() + .map_err(|error| AdmitError::Persistence(error.message.to_string()))?; + if raced.graph_id.is_empty() { + Err(AdmitError::Persistence(error.message.to_string())) + } else { + Ok(raced) + } + } + Err(error) => Err(AdmitError::Persistence(error.message.to_string())), + } + } + + fn restore_pinned_tool( + &self, + run_id: &str, + ) -> Result, AdmitError> { + // A live handle retains the sealed admission binding. Read-only + // idempotent replay must not reopen storage or select current activation. + if let Some(pin) = self + .handle(run_id) + .and_then(|handle| handle.pinned_tool.clone()) + { + return Ok(Some(pin)); + } + if self.inner.stateless_tools.load(Ordering::Acquire) { + return Ok(None); + } + let Some(persistence) = self.inner.persistence.as_ref() else { + return Err(AdmitError::Persistence( + "persistent tool pin is missing".to_string(), + )); + }; + let Some(store) = self.tool_store() else { + return Err(AdmitError::Persistence( + "validated tool store is not installed".to_string(), + )); + }; + crate::plugins::restore_owned_pin(persistence, store.as_ref(), run_id) + .map(Some) + .map_err(|error| AdmitError::Persistence(error.to_string())) + } + /// Replaces the validated limits used by future admissions. pub fn set_run_limits(&self, limits: RunLimits) -> Result<(), RunLimitsError> { let limits = limits.normalized()?; @@ -2989,10 +3199,17 @@ impl AgentService { let lifecycle = CapabilityLifecycle::builder() .owner(capability_owner.clone()) .registry_identity(expected.to_string()) - // CapabilityLifecycle currently requires a path-shaped token claim. - // The empty value is an absent marker only: filesystem/process - // capabilities are omitted below when no trusted root exists. - .workspace(limits_root) + // Identity survives revocation and legacy-handle eviction. Restore + // it only from the sealed original pin, never the saved limits path. + // Current authority above alone decides whether filesystem/process + // capabilities exist; this token claim grants no IO by itself. + .workspace(workspace.clone().unwrap_or_else(|| { + handle + .pinned_tool + .as_ref() + .map(|pin| pin.workspace_identity().to_path_buf()) + .unwrap_or_default() + })) .limits(LifecycleLimits { max_tool_calls, max_output_bytes: output_cap, @@ -3002,6 +3219,7 @@ impl AgentService { .clock(Arc::new(SystemClock)) .tokens(Arc::new(UuidIssuer)) .durable(Arc::new(ServiceDurableLifecycle { + store: Arc::clone(&self.inner.store), events: Arc::clone(&events), }) as Arc) .approval(Arc::new(GuardianApprovalGate::new( @@ -3392,21 +3610,31 @@ impl AgentService { } } - fn restore_handle_from_frozen_context(&self, run_id: &str) -> Option> { + fn restore_handle_from_frozen_context( + &self, + run_id: &str, + ) -> Result>, AdmitError> { let status = { let store = self.inner.store.read(); - store.runs.get(run_id)?.status.clone() + let Some(run) = store.runs.get(run_id) else { + return Ok(None); + }; + run.status.clone() }; if !matches!(status.as_str(), "started" | "stopping") { - return None; + return Ok(None); } - let context = self.run_context(run_id)?; - let deadline_at_ms = context.metadata.get("deadline_at_ms").and_then(|value| { + let Some(context) = self.run_context(run_id) else { + return Ok(None); + }; + let Some(deadline_at_ms) = context.metadata.get("deadline_at_ms").and_then(|value| { value .as_u64() .or_else(|| value.as_i64().and_then(|n| u64::try_from(n).ok())) .or_else(|| value.as_str().and_then(|text| text.parse().ok())) - })?; + }) else { + return Ok(None); + }; let cancel = RunCancellation::from_wall_deadline_ms(deadline_at_ms, timestamp()); let prompt = context.coding_system_prompt.clone().unwrap_or_default(); // A persisted limits path is never a grant. Re-open only an originally @@ -3452,6 +3680,7 @@ impl AgentService { legacy_workspace_root: None, coding_system_prompt: Arc::from(prompt), occupancy: AtomicBool::new(false), + pinned_tool: self.restore_pinned_tool(run_id)?, }); let registered = Arc::clone( self.inner @@ -3464,7 +3693,7 @@ impl AgentService { if status == "stopping" { registered.request_user_stop(); } - Some(registered) + Ok(Some(registered)) } /// Shared in-memory artifact capability for an initialized run, if any. @@ -4637,6 +4866,41 @@ impl AgentService { AdmitError::Invalid(error.to_string()) })?; + let pinned_workspace = workspace_binding + .as_ref() + .map(|binding| PathBuf::from(&binding.canonical_path)) + .or_else(|| legacy_workspace_root.clone()) + .unwrap_or_default(); + let mut prepared_pin = match ( + self.inner.stateless_tools.load(Ordering::Acquire), + self.inner.persistence.as_ref(), + ) { + (false, Some(persistence)) => { + let store = self.tool_store().ok_or_else(|| { + AdmitError::Persistence("validated tool store is not installed".to_string()) + })?; + let active = self.ensure_active_tool_graph(store.as_ref(), persistence)?; + Some( + crate::plugins::prepare_production_pin( + persistence, + store.as_ref(), + &active, + ) + .map_err(|error| AdmitError::Persistence(error.to_string()))?, + ) + } + _ => None, + }; + let mut admitted_pin = if prepared_pin.is_none() { + Some(self.seal_stateless_tool( + &run_id, + &session_id, + snapshot.registry.identity(), + pinned_workspace.clone(), + )?) + } else { + None + }; let payload = json!({ "session_id": session_id, "session_new": if session_new { 1 } else { 0 }, @@ -4660,6 +4924,21 @@ impl AgentService { "event_id": event_id, "now_ms": now, "expires_at_ms": 0, + "tool_graph_id": prepared_pin + .as_ref() + .map(|pin| pin.graph_id.clone()) + .unwrap_or_default(), + "tool_graph_generation": prepared_pin + .as_ref() + .map(|pin| pin.generation) + .unwrap_or(0), + "tool_ref_id": prepared_pin + .as_ref() + .map(|pin| pin.ref_id.clone()) + .unwrap_or_default(), + "tool_pin_now_ms": now, + "tool_registry_identity": snapshot.registry.identity(), + "tool_workspace": pinned_workspace.display().to_string(), }); // Commit phase: fresh revalidation then atomic durable + in-memory @@ -4853,6 +5132,7 @@ impl AgentService { .get(&existing.run_id) .map(|r| (r.session_id.clone(), r.status.clone())) .unwrap_or_else(|| (String::new(), "unknown".to_string())); + self.restore_pinned_tool(&existing.run_id)?; return Ok(AdmittedRun { run_id: existing.run_id.clone(), session_id: sid, @@ -4863,6 +5143,7 @@ impl AgentService { if cur_run_exists { let store = self.inner.store.read(); if let Some(existing) = store.runs.get(&run_id) { + self.restore_pinned_tool(&run_id)?; return Ok(AdmittedRun { run_id: run_id.clone(), session_id: existing.session_id.clone(), @@ -4881,27 +5162,76 @@ impl AgentService { return Err(self.admission_deadline_rejection()); } let durable = match self.inner.persistence.as_ref() { - Some(persistence) => persistence.admission_create(&payload).map_err(|error| { - self.inner - .metrics - .admission_rejected(AdmitRejectReason::Persistence); - match error.code.as_str() { - "idempotency_key_conflict" => AdmitError::IdempotencyConflict, - _ => AdmitError::Persistence(format!( - "run admission could not be durably committed: {error}" - )), + Some(persistence) => { + let outcome = persistence.admission_create(&payload); + // Only relevant activation drift after a rolled-back + // attempt may reprepare. A committed run, including a + // response/publication failure, must never be retried + // as a new admission. The original cancellation root + // and absolute deadline remain outside this loop. + if outcome.is_err() + && let Some(prepared) = prepared_pin.as_ref() + { + let active = crate::plugins::PluginControlStore::new(persistence) + .activation() + .map_err(|error| AdmitError::Persistence(error.to_string()))?; + if active.graph_id != prepared.graph_id + || active.generation != prepared.generation + { + let existing = persistence + .run_get(&run_id) + .map_err(|error| AdmitError::Persistence(error.to_string()))?; + if existing + .get("rows") + .and_then(JsonValue::as_array) + .is_some_and(Vec::is_empty) + { + continue; + } + } } - }), + outcome.map_err(|error| { + self.inner + .metrics + .admission_rejected(AdmitRejectReason::Persistence); + match error.code.as_str() { + "idempotency_key_conflict" => AdmitError::IdempotencyConflict, + _ => AdmitError::Persistence(format!( + "run admission could not be durably committed: {error}" + )), + } + }) + } None => Ok(JsonValue::Null), }; let data = durable?; if data.get("replayed") == Some(&JsonValue::Bool(true)) { return self.finish_durable_replay(&data); } + if admitted_pin.is_none() { + let prepared = prepared_pin.take().ok_or_else(|| { + AdmitError::Persistence("prepared tool pin was dropped".to_string()) + })?; + admitted_pin = Some( + crate::plugins::seal_production_receipt( + prepared, + &data, + snapshot.registry.identity().to_string(), + pinned_workspace.clone(), + ) + .map_err(|error| AdmitError::Persistence(error.to_string()))?, + ); + } + let admitted_pin = admitted_pin.take().ok_or_else(|| { + AdmitError::Persistence( + "tool pin was not sealed from the admission receipt".to_string(), + ) + })?; let mut store = self.inner.store.write(); if store.runs.contains_key(&run_id) && let Some(existing) = store.runs.get(&run_id) { + self.restore_pinned_tool(&run_id)?; return Ok(AdmittedRun { run_id: run_id.clone(), session_id: existing.session_id.clone(), @@ -4930,6 +5260,7 @@ impl AgentService { .get(&existing.run_id) .map(|r| (r.session_id.clone(), r.status.clone())) .unwrap_or_else(|| (String::new(), "unknown".to_string())); + self.restore_pinned_tool(&existing.run_id)?; return Ok(AdmittedRun { run_id: existing.run_id.clone(), session_id: sid, @@ -5037,6 +5368,7 @@ impl AgentService { legacy_workspace_root, coding_system_prompt: Arc::from(coding_system_prompt), occupancy: AtomicBool::new(false), + pinned_tool: Some(admitted_pin), }); self.inner .runs @@ -5089,6 +5421,7 @@ impl AgentService { .admission_rejected(AdmitRejectReason::IdempotencyConflict); return Err(AdmitError::IdempotencyConflict); } + self.restore_pinned_tool(&existing.run_id)?; let store = self.inner.store.write(); let Some(current) = store.idempotency.get(key) else { return Ok(None); @@ -5216,6 +5549,7 @@ impl AgentService { let replayed_status = admission_run_str(&run_row, ADMISSION_RUN_COL_STATUS) .unwrap_or("unknown") .to_string(); + self.restore_pinned_tool(&replayed_run_id)?; let store = self.inner.store.write(); if let Some(run) = store.runs.get(&replayed_run_id) { return Ok(AdmittedRun { @@ -5492,11 +5826,21 @@ impl AgentService { /// the terminal commit. pub async fn run_worker(self: Arc, run_id: String, _input: String) { tokio::task::yield_now().await; - let Some(handle) = self - .handle(&run_id) - .or_else(|| self.restore_handle_from_frozen_context(&run_id)) - else { - return; + let handle = if let Some(handle) = self.handle(&run_id) { + handle + } else { + match self.restore_handle_from_frozen_context(&run_id) { + Ok(Some(handle)) => handle, + Ok(None) => return, + Err(error) => { + self.finish_failed( + &run_id, + failed_payload_with_code("tool_pin_unavailable", error.to_string()), + ) + .await; + return; + } + } }; if handle.cancel.has_deadline_overflow() { if self.commit_cleanup_or_continue(&run_id, &handle).await { @@ -5639,6 +5983,8 @@ impl AgentService { processes, artifacts, control_hook: None, + pinned_tool: handle.pinned_tool.clone(), + allow_stateless_pin: self.inner.stateless_tools.load(Ordering::Acquire), }; // One bounded delivery path: the worker blocks on this channel // when the delivery task is busy, which pauses invocation polling @@ -7152,6 +7498,7 @@ impl CancellationFlag for HandleCancelFlag { } struct ServiceDurableLifecycle { + store: Arc>, events: Arc, } @@ -7176,6 +7523,48 @@ impl DurableToolLifecycle for ServiceDurableLifecycle { .map_err(map_event_commit_error) } + fn parent_arguments( + &self, + run_id: &str, + call_id: &str, + tool_name: &str, + ) -> Result, LifecycleError> { + let store = self.store.read(); + let run = store + .runs + .get(run_id) + .ok_or(LifecycleError::MissingParent)?; + let session = store + .sessions + .get(&run.session_id) + .ok_or(LifecycleError::MissingParent)?; + let mut found = None; + for message in &session.messages { + if message.role != "assistant" || message.run_id.as_deref() != Some(run_id) { + continue; + } + for block in decode_message_blocks(&message.content) { + if block.block_type == "tool_call" && block.tool_call_id.as_deref() == Some(call_id) + { + if block.name.as_deref() != Some(tool_name) || found.is_some() { + return Err(LifecycleError::MissingParent); + } + let arguments = block + .arguments_json + .as_deref() + .ok_or(LifecycleError::MissingParent)?; + let parsed: JsonValue = serde_json::from_str(arguments) + .map_err(|_| LifecycleError::MissingParent)?; + if !parsed.is_object() { + return Err(LifecycleError::MissingParent); + } + found = Some(parsed); + } + } + } + found.map(Some).ok_or(LifecycleError::MissingParent) + } + fn replay_result( &self, _run_id: &str, @@ -10974,6 +11363,52 @@ mod external_legacy_admission_tests { std::fs::remove_dir_all(&root).expect("private external-legacy fixture cleanup"); } + #[tokio::test] + async fn sqlite_constructor_prepares_active_tool_graph_without_admitting_a_run() { + let root = private_root("prepared"); + let state = AgentGatewayState::with_agent_source_and_sqlite( + short_config(), + INLINE_SOURCE, + root.join("gateway.db"), + ) + .expect("SQLite state should open"); + let service = state.service(); + let persistence = service.persistence_handle().expect("durable store"); + let control = crate::plugins::PluginControlStore::new(&persistence); + let active = control.activation().expect("trusted activation"); + assert!( + active.graph_id.is_empty(), + "code preparation must preserve generation-zero activation for trusted plugin setup" + ); + let store = service.tool_store().expect("installed Native store"); + assert!( + store.prepared_embedding_lock().is_some(), + "trusted Native initialization must capture builtin code before an admission clock starts" + ); + store.bind_policy(&crate::plugins::InstallAuthorization { + admin: false, + accepted_publishers: Vec::new(), + unsigned_dev_space: None, + }); + assert!( + store.prepared_embedding_lock().is_none(), + "a policy replacement must invalidate even the prepared builtin capture" + ); + assert_eq!(service.handle_count(), 0, "setup must not mint a run grant"); + assert_eq!( + service.available_capacity(), + service.config().max_concurrent_runs + ); + let pin = control + .pin_for_run("not-admitted") + .expect("typed pin lookup"); + assert_eq!(pin["pinned"], false, "setup must not mint an owner receipt"); + drop(persistence); + drop(service); + drop(state); + std::fs::remove_dir_all(root).expect("private preparation fixture cleanup"); + } + #[tokio::test] async fn external_selector_none_admits_without_legacy_root_sqlite() { let root = private_root("sqlite"); diff --git a/tests/agent_loop_tests.rs b/tests/agent_loop_tests.rs index 175fba0..e103d29 100644 --- a/tests/agent_loop_tests.rs +++ b/tests/agent_loop_tests.rs @@ -576,12 +576,9 @@ impl DurableToolLifecycle for LoopDurable { if let Some(cancellation) = &self.cancel_after { cancellation.request(CancellationReason::Requested); } - Ok(json!({ - "ok": true, - "kind": "committed", - "call_id": call_id, - "result": result, - })) + // CapabilityLifecycle supplies the commit receipt; this boundary + // returns the canonical stored result, as ServiceDurableLifecycle does. + Ok(result.clone()) } fn interrupt(&self, _call_id: &str) -> Result<(), LifecycleError> { @@ -614,13 +611,22 @@ fn loop_host_with( executor: Arc, cancellation: Option, root: PathBuf, +) -> AgentHostBridges { + let durable = LoopDurable::new(executor, cancellation.clone()); + loop_host_with_durable(max_tool_calls, cancellation, root, durable) +} + +fn loop_host_with_durable( + max_tool_calls: u64, + cancellation: Option, + root: PathBuf, + durable: Arc, ) -> AgentHostBridges { seed_loop_workspace(&root); let identity = bundled_tool_registry() .expect("RSS registry") .identity() .to_string(); - let durable = LoopDurable::new(Arc::clone(&executor), cancellation.clone()); let clock = Arc::new(SystemClock); let deadline_ms = clock.now_ms() + 30_000; let lifecycle = Arc::new( @@ -680,6 +686,7 @@ fn loop_host_with( filesystem: Some(filesystem), processes: Some(processes), artifacts: Some(artifacts), + allow_stateless_pin: true, ..AgentHostBridges::default() } } @@ -815,6 +822,44 @@ fn loop_text_only_response_produces_final_answer() { assert_eq!(request["messages"][0]["role"], json!("user")); } +#[test] +fn loop_canonical_replay_payload_is_not_a_transport_wrapper() { + let root = unique_loop_workspace("canonical-replay", 0); + let executor = CountingExecutor::new(); + let durable = LoopDurable::new(Arc::clone(&executor), None); + let original = json!({ + "ok":true, "content":"original canonical observation", "kind":"committed", + "result":{"ok":true,"content":"not a transport result"}, + "data":{"original":true}, "error":null, "truncated":false, "artifacts":[] + }); + durable + .results + .lock() + .insert("replayed-1".to_string(), original.clone()); + let host = loop_host_with_durable(8, None, root.clone(), durable); + let provider = ScriptedProvider::new(); + provider.push_ok(tool_response( + "", + json!([ + {"id":"replayed-1","name":"read_file","arguments":{"path":"note.txt"}} + ]), + )); + provider.push_ok(text_response("replay complete")); + let runner = configure_loop_runner(loop_runner(), provider.clone(), Some(host)); + let decision = decide( + &runner, + run_context(4, 8, loop_config(false, false), echo_tool()), + ); + assert_eq!(decision["kind"], "run.completed"); + assert_eq!(executor.count.load(Ordering::SeqCst), 0); + let followup = &provider.requests()[1]; + let block = &followup["messages"][2]["content"][0]; + assert_eq!(block["type"], "tool_result"); + assert_eq!(block["result"], original); + assert_eq!(block["content"], "original canonical observation"); + fs::remove_dir_all(root).unwrap(); +} + #[test] fn loop_one_serial_tool_call_then_final() { let provider = ScriptedProvider::new(); @@ -880,7 +925,13 @@ fn loop_one_serial_tool_call_then_final() { ); assert_eq!( second["messages"][2]["content"][0]["is_error"], - json!(false) + json!(false), + "actual Native tool result: {}", + second["messages"][2] + ); + assert_eq!( + second["messages"][2]["content"][0]["result"]["content"], + json!("note.txt body\n") ); } diff --git a/tests/coding_agent_edge_e2e_tests.rs b/tests/coding_agent_edge_e2e_tests.rs index 254f2d3..6054b2d 100644 --- a/tests/coding_agent_edge_e2e_tests.rs +++ b/tests/coding_agent_edge_e2e_tests.rs @@ -1337,6 +1337,60 @@ async fn pending_provider_request_restart_retries_without_duplicate_tool() { fixture.cleanup(); } +#[tokio::test(flavor = "multi_thread")] +async fn persistent_recovery_without_original_pin_fails_before_provider_or_effect() { + let mut fixture = Fixture::new("missing-original-pin"); + let provider = ScriptedProvider::new(); + provider.push_ok(text_response("must not be sent")); + let state = loop_service_sqlite(AgentGatewayConfig::default(), &provider, &fixture.db_path()); + let service = state.service(); + apply_workspace_limits(&service, &fixture.workspace, 64 * 1024); + let admitted = service + .admit(admit_request()) + .await + .expect("admit original pin"); + let persistence = state.persistence().expect("durable store"); + let control = rustscript_agent::plugins::PluginControlStore::new(persistence.as_ref()); + let original = control + .pin_for_run(&admitted.run_id) + .expect("original Native receipt"); + assert_eq!(original["pinned"], true); + control + .release(&rustscript_agent::plugins::GraphRetention { + graph_id: original["graph_id"].as_str().unwrap().to_string(), + owner_kind: "run".to_string(), + owner_id: admitted.run_id.clone(), + ref_id: original["ref_id"].as_str().unwrap().to_string(), + }) + .expect("release original retention through the SDK"); + service.evict_run_handle(&admitted.run_id); + tokio::time::timeout( + WORKER_BUDGET, + service + .clone() + .run_worker(admitted.run_id.clone(), "ignored".to_string()), + ) + .await + .expect("bounded recovery denial"); + assert_eq!( + terminal_events(&service, &admitted.run_id), + vec!["run.failed"] + ); + let events = service.run_events(&admitted.run_id); + assert!( + events + .iter() + .any(|event| event["data"]["error_code"] == "tool_pin_unavailable") + ); + assert_eq!(provider.call_count(), 0); + assert_eq!(tool_event_count(&service, &admitted.run_id), 0); + assert!(durable_tool_result_messages(&service, &admitted.session_id).is_empty()); + assert_eq!(service.process_owner_count(&admitted.run_id), 0); + drop(service); + drop(state); + fixture.cleanup(); +} + #[tokio::test(flavor = "multi_thread")] async fn pending_recovery_rejects_changed_workspace_binding() { let Some(sh) = require_posix_sh() else { @@ -1421,6 +1475,15 @@ async fn pending_recovery_rejects_changed_workspace_binding() { "{content}" ); assert_eq!(content[0]["is_error"], true); + assert_eq!( + terminal_events(&service, &admitted.run_id), + vec!["run.completed"] + ); + assert_eq!( + provider.call_count(), + 3, + "pending request, Guardian and final reply" + ); assert_eq!(service.process_owner_count(&admitted.run_id), 0); drop(service); drop(state); @@ -1504,6 +1567,15 @@ async fn pending_recovery_does_not_turn_limits_path_into_authority() { "{content}" ); assert_eq!(content[0]["is_error"], true); + assert_eq!( + terminal_events(&service, &admitted.run_id), + vec!["run.completed"] + ); + assert_eq!( + provider.call_count(), + 3, + "pending request, Guardian and final reply" + ); assert_eq!(service.process_owner_count(&admitted.run_id), 0); drop(service); drop(state); diff --git a/tests/effect_checkpoint_tests.rs b/tests/effect_checkpoint_tests.rs new file mode 100644 index 0000000..05964b1 --- /dev/null +++ b/tests/effect_checkpoint_tests.rs @@ -0,0 +1,683 @@ +//! Checkpoint and branch restore for the Native filesystem effect API. + +use std::{ + collections::HashSet, + fs, + path::{Path, PathBuf}, + process, + sync::atomic::{AtomicU64, Ordering}, + time::{Duration, Instant}, +}; + +use rustscript_agent::effects::{ + CheckpointRecord, CheckpointRequest, EffectCancellation, EffectDeadline, EffectErrorKind, + EffectStore, ImportWorkspace, JournalKind, OpenViewRequest, PublicCheckpointRef, + PublishRequest, ResourceQuota, RestoreRequest, WorkspaceHandle, parse_checkpoint_yaml, + pre_conversion_yaml_error, +}; + +fn scratch(test: &str) -> PathBuf { + static NEXT: AtomicU64 = AtomicU64::new(1); + let n = NEXT.fetch_add(1, Ordering::Relaxed); + let root = PathBuf::from( + std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").expect("RUSTSCRIPT_AGENT_TEST_TMP"), + ) + .join(format!("effect-checkpoint-{test}-{}-{n}", process::id())); + fs::create_dir_all(&root).expect("scratch"); + root +} + +fn generous() -> ResourceQuota { + ResourceQuota::new(128, 1024 * 1024, 8).expect("quota") +} + +fn far_deadline() -> EffectDeadline { + EffectDeadline::at(Instant::now() + Duration::from_secs(30)) +} + +fn expired_deadline() -> EffectDeadline { + EffectDeadline::at(Instant::now() - Duration::from_millis(1)) +} + +fn import_ok(store: &EffectStore, source: &Path) -> WorkspaceHandle { + store + .import_workspace(ImportWorkspace { + source: source.to_path_buf(), + quota: generous(), + cancellation: EffectCancellation::new(), + deadline: far_deadline(), + }) + .expect("import") +} + +fn publish_edit( + handle: &WorkspaceHandle, + edit: impl FnOnce(&rustscript_agent::effects::WritableView), +) { + let view = handle + .open_view(OpenViewRequest { + expected_head: handle.head(), + expected_epoch: handle.epoch(), + cancellation: EffectCancellation::new(), + deadline: far_deadline(), + }) + .expect("open"); + edit(&view); + handle + .publish( + view, + PublishRequest { + expected_head: handle.head(), + expected_epoch: handle.epoch(), + cancellation: EffectCancellation::new(), + deadline: far_deadline(), + }, + ) + .expect("publish"); +} + +#[test] +fn active_writer_rejects_checkpoint_immediately() { + let root = scratch("busy"); + let seed = root.join("seed"); + fs::create_dir_all(&seed).unwrap(); + fs::write(seed.join("a.txt"), b"a").unwrap(); + let store = EffectStore::create(root.join("store")).unwrap(); + let handle = import_ok(&store, &seed); + let head = handle.head(); + let view = handle + .open_view(OpenViewRequest { + expected_head: head.clone(), + expected_epoch: handle.epoch(), + cancellation: EffectCancellation::new(), + deadline: far_deadline(), + }) + .unwrap(); + let started = Instant::now(); + let err = handle + .checkpoint(CheckpointRequest { + cancellation: EffectCancellation::new(), + deadline: far_deadline(), + }) + .expect_err("busy"); + assert_eq!(err.kind(), EffectErrorKind::CheckpointBusy); + assert!(started.elapsed() < Duration::from_secs(1)); + assert_eq!(handle.head(), head); + view.write_file("a.txt", b"still-open").unwrap(); + handle.discard(view).unwrap(); + let record = handle + .checkpoint(CheckpointRequest { + cancellation: EffectCancellation::new(), + deadline: far_deadline(), + }) + .expect("quiescent checkpoint"); + assert_eq!(record.active_writers(), 0); + assert!(record.external_effects_not_reverted().is_empty()); + assert_eq!(record.immutable_root(), &head); +} + +#[test] +fn expired_deadline_rejects_checkpoint_without_waiting() { + let root = scratch("deadline"); + let seed = root.join("seed"); + fs::create_dir_all(&seed).unwrap(); + fs::write(seed.join("a.txt"), b"a").unwrap(); + let store = EffectStore::create(root.join("store")).unwrap(); + let handle = import_ok(&store, &seed); + let started = Instant::now(); + let err = handle + .checkpoint(CheckpointRequest { + cancellation: EffectCancellation::new(), + deadline: expired_deadline(), + }) + .expect_err("deadline"); + assert_eq!(err.kind(), EffectErrorKind::DeadlineExceeded); + assert!(started.elapsed() < Duration::from_secs(1)); + assert!( + fs::read_dir(store.root().join("checkpoints")) + .unwrap() + .next() + .is_none() + ); +} + +#[test] +fn restore_replays_deleted_and_renamed_files_on_new_branch() { + let root = scratch("restore"); + let seed = root.join("seed"); + fs::create_dir_all(seed.join("keep")).unwrap(); + fs::write(seed.join("a.txt"), b"one").unwrap(); + let store = EffectStore::create(root.join("store")).unwrap(); + let handle = import_ok(&store, &seed); + publish_edit(&handle, |view| { + view.delete("a.txt").unwrap(); + view.write_file("b.txt", b"two").unwrap(); + view.write_file("c.txt", b"c").unwrap(); + view.rename("c.txt", "d.txt").unwrap(); + }); + let record = handle + .checkpoint(CheckpointRequest { + cancellation: EffectCancellation::new(), + deadline: far_deadline(), + }) + .unwrap(); + let checkpoint_head = record.immutable_root().clone(); + publish_edit(&handle, |view| { + view.write_file("b.txt", b"changed").unwrap(); + view.write_file("e.txt", b"extra").unwrap(); + }); + let mutated = handle.head(); + assert_ne!(mutated, checkpoint_head); + let old_epoch = handle.epoch(); + let old_branch = handle.branch_id().clone(); + let journal_before = handle.journal(); + let restored = handle + .restore(RestoreRequest { + checkpoint_id: record.checkpoint_id().clone(), + expected_head: mutated.clone(), + expected_epoch: old_epoch, + cancellation: EffectCancellation::new(), + deadline: far_deadline(), + }) + .unwrap(); + assert_ne!(restored.handle.branch_id(), &old_branch); + assert!(restored.handle.epoch().get() > old_epoch.get()); + assert_eq!(restored.head, checkpoint_head); + assert!(restored.external_effects_not_reverted.is_empty()); + assert_eq!(restored.handle.read_committed("b.txt").unwrap(), b"two"); + assert_eq!(restored.handle.read_committed("d.txt").unwrap(), b"c"); + assert!(restored.handle.read_committed("a.txt").is_err()); + assert!(restored.handle.read_committed("e.txt").is_err()); + assert!(restored.handle.read_committed("c.txt").is_err()); + assert!( + restored + .handle + .immutable_path(&restored.head) + .unwrap() + .join("keep") + .is_dir() + ); + assert_eq!(handle.read_committed("e.txt").unwrap(), b"extra"); + assert_eq!(handle.head(), mutated); + let err = handle + .open_view(OpenViewRequest { + expected_head: mutated, + expected_epoch: old_epoch, + cancellation: EffectCancellation::new(), + deadline: far_deadline(), + }) + .expect_err("old epoch"); + assert_eq!(err.kind(), EffectErrorKind::StaleEpoch); + let journal_after = handle.journal(); + assert!(journal_after.len() > journal_before.len()); + assert_eq!(&journal_after[..journal_before.len()], &journal_before[..]); + assert_eq!(journal_after.last().unwrap().kind, JournalKind::Restored); + assert!( + journal_after + .iter() + .any(|entry| entry.kind == JournalKind::Imported) + ); + assert!( + journal_after + .iter() + .any(|entry| entry.kind == JournalKind::Published) + ); + assert!( + journal_after + .iter() + .any(|entry| entry.kind == JournalKind::Checkpoint) + ); + assert!(restored.handle.lineage().contains(&old_branch)); +} + +#[test] +fn restore_with_active_writer_is_rejected() { + let root = scratch("restore-busy"); + let seed = root.join("seed"); + fs::create_dir_all(&seed).unwrap(); + fs::write(seed.join("a.txt"), b"a").unwrap(); + let store = EffectStore::create(root.join("store")).unwrap(); + let handle = import_ok(&store, &seed); + let record = handle + .checkpoint(CheckpointRequest { + cancellation: EffectCancellation::new(), + deadline: far_deadline(), + }) + .unwrap(); + let view = handle + .open_view(OpenViewRequest { + expected_head: handle.head(), + expected_epoch: handle.epoch(), + cancellation: EffectCancellation::new(), + deadline: far_deadline(), + }) + .unwrap(); + let started = Instant::now(); + let err = handle + .restore(RestoreRequest { + checkpoint_id: record.checkpoint_id().clone(), + expected_head: handle.head(), + expected_epoch: handle.epoch(), + cancellation: EffectCancellation::new(), + deadline: far_deadline(), + }) + .expect_err("writer"); + assert_eq!(err.kind(), EffectErrorKind::Busy); + assert!(started.elapsed() < Duration::from_secs(1)); + handle.discard(view).unwrap(); +} + +#[test] +fn revoked_checkpoint_file_remains_but_restore_fails() { + let root = scratch("revoke"); + let seed = root.join("seed"); + fs::create_dir_all(&seed).unwrap(); + fs::write(seed.join("a.txt"), b"a").unwrap(); + let store = EffectStore::create(root.join("store")).unwrap(); + let handle = import_ok(&store, &seed); + let record = handle + .checkpoint(CheckpointRequest { + cancellation: EffectCancellation::new(), + deadline: far_deadline(), + }) + .unwrap(); + let path = handle.checkpoint_path(record.checkpoint_id()).unwrap(); + let yaml = fs::read_to_string(&path).unwrap(); + assert!(yaml.contains("rustscript.checkpoint/v1")); + store.revoke(&handle).unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), yaml); + let err = handle + .restore(RestoreRequest { + checkpoint_id: record.checkpoint_id().clone(), + expected_head: handle.head(), + expected_epoch: handle.epoch(), + cancellation: EffectCancellation::new(), + deadline: far_deadline(), + }) + .expect_err("revoked"); + assert_eq!(err.kind(), EffectErrorKind::Revoked); + let refs = PublicCheckpointRef { + workspace_id: handle.workspace_id().clone(), + checkpoint_id: record.checkpoint_id().clone(), + }; + assert_eq!( + store.restore_from_public_ids(&refs).unwrap_err().kind(), + EffectErrorKind::AuthorityRequired + ); +} + +#[test] +fn other_owner_cannot_restore() { + let root = scratch("owner-restore"); + let seed_a = root.join("a"); + let seed_b = root.join("b"); + fs::create_dir_all(&seed_a).unwrap(); + fs::create_dir_all(&seed_b).unwrap(); + fs::write(seed_a.join("a.txt"), b"A").unwrap(); + fs::write(seed_b.join("b.txt"), b"B").unwrap(); + let store = EffectStore::create(root.join("store")).unwrap(); + let owner_a = import_ok(&store, &seed_a); + let owner_b = import_ok(&store, &seed_b); + let record = owner_b + .checkpoint(CheckpointRequest { + cancellation: EffectCancellation::new(), + deadline: far_deadline(), + }) + .unwrap(); + let head_a = owner_a.head(); + let err = owner_a + .restore(RestoreRequest { + checkpoint_id: record.checkpoint_id().clone(), + expected_head: head_a.clone(), + expected_epoch: owner_a.epoch(), + cancellation: EffectCancellation::new(), + deadline: far_deadline(), + }) + .expect_err("foreign checkpoint"); + assert_eq!(err.kind(), EffectErrorKind::NotFound); + assert_eq!(owner_a.head(), head_a); + assert_eq!(owner_b.read_committed("b.txt").unwrap(), b"B"); +} + +#[test] +fn checkpoint_yaml_matches_schema_and_rejects_unsafe_input() { + let root = scratch("schema"); + let seed = root.join("seed"); + fs::create_dir_all(&seed).unwrap(); + fs::write(seed.join("a.txt"), b"a").unwrap(); + let store = EffectStore::create(root.join("store")).unwrap(); + let handle = import_ok(&store, &seed); + let record = handle + .checkpoint(CheckpointRequest { + cancellation: EffectCancellation::new(), + deadline: far_deadline(), + }) + .unwrap(); + let yaml = record.to_yaml(); + let parsed = parse_checkpoint_yaml(&yaml).unwrap(); + assert_eq!(parsed, record); + let schema_path = + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("schemas/v1/checkpoint.schema.yaml"); + let schema_text = fs::read_to_string(&schema_path).unwrap(); + let schema_yaml: serde_yaml::Value = serde_yaml::from_str(&schema_text).unwrap(); + let schema_json = serde_json::to_value(schema_yaml).unwrap(); + jsonschema::meta::validate(&schema_json).expect("schema is draft 2020-12"); + let validator = jsonschema::draft202012::options() + .build(&schema_json) + .expect("validator"); + let instance = record.to_json_value(); + validator + .validate(&instance) + .expect("instance matches schema"); + let mut extra = instance.clone(); + extra + .as_object_mut() + .unwrap() + .insert("token".to_string(), serde_json::json!("secret")); + assert!(validator.validate(&extra).is_err()); + let mut snapped = instance.clone(); + snapped["plugin_state"]["availability"] = serde_json::json!("snapshotted"); + assert!(validator.validate(&snapped).is_err()); + assert_eq!( + parse_checkpoint_yaml("format: &a rustscript.checkpoint/v1\ncopy: *a\n") + .unwrap_err() + .kind(), + EffectErrorKind::SchemaRejected + ); + assert_eq!( + parse_checkpoint_yaml("format: !!str rustscript.checkpoint/v1\n") + .unwrap_err() + .kind(), + EffectErrorKind::SchemaRejected + ); + assert_eq!( + parse_checkpoint_yaml( + "format: rustscript.checkpoint/v1\nformat: rustscript.checkpoint/v2\n" + ) + .unwrap_err() + .kind(), + EffectErrorKind::SchemaRejected + ); + let mut unknown = yaml.clone(); + unknown.push_str("token: secret\n"); + assert_eq!( + parse_checkpoint_yaml(&unknown).unwrap_err().kind(), + EffectErrorKind::SchemaRejected + ); + assert_eq!( + parse_checkpoint_yaml(&format!( + "{yaml}\nplugin_state:\n availability: snapshotted\n" + )) + .unwrap_err() + .kind(), + EffectErrorKind::SchemaRejected + ); + let huge = "a: ".to_string() + &"x".repeat(70_000); + assert_eq!( + parse_checkpoint_yaml(&huge).unwrap_err().kind(), + EffectErrorKind::SchemaRejected + ); +} + +fn complete_checkpoint_yaml() -> String { + let root = scratch("scanner-base"); + let source = root.join("source"); + fs::create_dir_all(&source).unwrap(); + fs::write(source.join("a.txt"), b"a").unwrap(); + let store = EffectStore::create(root.join("store")).unwrap(); + let handle = import_ok(&store, &source); + handle + .checkpoint(CheckpointRequest { + cancellation: EffectCancellation::new(), + deadline: far_deadline(), + }) + .expect("quiescent checkpoint") + .to_yaml() +} + +fn duplicate_mapping_line(yaml: &str, key: &str) -> String { + let needle = format!("{key}:"); + let mut out = String::new(); + let mut duplicated = false; + for line in yaml.split_inclusive('\n') { + out.push_str(line); + if !duplicated && line.trim_start().starts_with(&needle) { + out.push_str(line); + duplicated = true; + } + } + assert!(duplicated, "complete checkpoint yaml missing key {key}"); + out +} + +fn assert_scanner_rejects_duplicate(yaml: &str) { + let scan = pre_conversion_yaml_error(yaml); + assert!( + matches!( + scan.as_ref(), + Err(error) + if error.kind() == EffectErrorKind::SchemaRejected + && error.to_string().contains("duplicate yaml key before conversion") + ), + "pre-conversion scanner must reject at its own boundary, got {scan:?}" + ); + let parsed = parse_checkpoint_yaml(yaml); + assert!( + matches!( + parsed.as_ref(), + Err(error) if error.to_string().contains("duplicate yaml key before conversion") + ), + "production parse must surface the scanner rejection before conversion, got {parsed:?}" + ); +} + +#[test] +fn duplicate_owner_id_rejected_before_conversion() { + let yaml = duplicate_mapping_line(&complete_checkpoint_yaml(), "owner_id"); + assert_scanner_rejects_duplicate(&yaml); +} + +#[test] +fn duplicate_immutable_root_rejected_before_conversion() { + let yaml = duplicate_mapping_line(&complete_checkpoint_yaml(), "immutable_root"); + assert_scanner_rejects_duplicate(&yaml); +} + +#[test] +fn duplicate_epoch_rejected_before_conversion() { + let yaml = duplicate_mapping_line(&complete_checkpoint_yaml(), "epoch"); + assert_scanner_rejects_duplicate(&yaml); +} + +#[test] +fn duplicate_nested_availability_rejected_before_conversion() { + let yaml = duplicate_mapping_line(&complete_checkpoint_yaml(), "availability"); + assert_scanner_rejects_duplicate(&yaml); +} + +const LINEAGE_BOUND: usize = 64; + +fn document_lineage(record: &CheckpointRecord) -> Vec { + record.to_json_value()["lineage"] + .as_array() + .expect("checkpoint lineage") + .iter() + .map(|item| item.as_str().expect("branch id").to_string()) + .collect() +} + +fn lineage_text(handle: &WorkspaceHandle) -> Vec { + handle + .lineage() + .iter() + .map(|branch| branch.as_str().to_string()) + .collect() +} + +fn restore_recorded( + handle: &WorkspaceHandle, + checkpoint: &rustscript_agent::effects::CheckpointId, +) -> Result { + handle.restore(RestoreRequest { + checkpoint_id: checkpoint.clone(), + expected_head: handle.head(), + expected_epoch: handle.epoch(), + cancellation: EffectCancellation::new(), + deadline: far_deadline(), + }) +} + +fn roundtrip_lineage(handle: &WorkspaceHandle, record: &CheckpointRecord) -> Vec { + let path = handle.checkpoint_path(record.checkpoint_id()).unwrap(); + let text = fs::read_to_string(&path).unwrap(); + let parsed = parse_checkpoint_yaml(&text).expect("parser accepts checkpoint producer output"); + let from_file = document_lineage(&parsed); + assert_eq!(from_file, document_lineage(record)); + assert_eq!(from_file, lineage_text(handle)); + from_file +} + +fn store_files(root: &Path) -> Vec<(String, Option>)> { + let mut files = Vec::new(); + fn walk(root: &Path, dir: &Path, files: &mut Vec<(String, Option>)>) { + let mut entries = fs::read_dir(dir) + .expect("store entry") + .map(|entry| entry.expect("store dir")) + .collect::>(); + entries.sort_by_key(|entry| entry.file_name()); + for entry in entries { + let path = entry.path(); + let rel = path + .strip_prefix(root) + .expect("store prefix") + .to_string_lossy() + .replace('\\', "/"); + let kind = entry.file_type().expect("file type"); + if kind.is_dir() { + files.push((rel, None)); + walk(root, &path, files); + } else if kind.is_file() { + files.push((rel, Some(fs::read(&path).expect("store file")))); + } else { + panic!("store snapshot saw a non-file at {rel}"); + } + } + } + walk(root, root, &mut files); + files +} + +#[test] +fn lineage_bound_restore_is_rejected() { + let root = scratch("lineage-bound"); + let seed = root.join("seed"); + fs::create_dir_all(&seed).unwrap(); + fs::write(seed.join("a.txt"), b"a").unwrap(); + let store = EffectStore::create(root.join("store")).unwrap(); + let mut handle = import_ok(&store, &seed); + let import_branch = handle.branch_id().clone(); + let recorded = handle + .checkpoint(CheckpointRequest { + cancellation: EffectCancellation::new(), + deadline: far_deadline(), + }) + .unwrap(); + assert_eq!(handle.lineage().len(), 1); + while handle.lineage().len() < LINEAGE_BOUND { + handle = restore_recorded(&handle, recorded.checkpoint_id()) + .expect("restore inside the lineage bound") + .handle; + } + let lineage = handle.lineage(); + assert_eq!(lineage.len(), LINEAGE_BOUND); + assert_eq!(lineage.first(), Some(&import_branch)); + assert_eq!(lineage.last(), Some(handle.branch_id())); + assert_eq!(lineage.iter().collect::>().len(), LINEAGE_BOUND); + let bound = handle + .checkpoint(CheckpointRequest { + cancellation: EffectCancellation::new(), + deadline: far_deadline(), + }) + .expect("checkpoint at the lineage bound"); + let bound_lineage = roundtrip_lineage(&handle, &bound); + assert_eq!(bound_lineage.len(), LINEAGE_BOUND); + assert_eq!( + bound_lineage.first().map(String::as_str), + Some(import_branch.as_str()) + ); + assert_eq!( + bound_lineage.last().map(String::as_str), + Some(handle.branch_id().as_str()) + ); + + let held = handle + .open_view(OpenViewRequest { + expected_head: handle.head(), + expected_epoch: handle.epoch(), + cancellation: EffectCancellation::new(), + deadline: far_deadline(), + }) + .unwrap(); + held.write_file("held.txt", b"held").unwrap(); + let busy = restore_recorded(&handle, recorded.checkpoint_id()).expect_err("live writer"); + assert_eq!(busy.kind(), EffectErrorKind::Busy); + assert_eq!(held.read_file("held.txt").unwrap(), b"held"); + held.write_file("held.txt", b"still-owner").unwrap(); + let still_busy = handle + .checkpoint(CheckpointRequest { + cancellation: EffectCancellation::new(), + deadline: far_deadline(), + }) + .expect_err("writer lease remains on the original branch"); + assert_eq!(still_busy.kind(), EffectErrorKind::CheckpointBusy); + handle.discard(held).unwrap(); + + let head = handle.head(); + let epoch = handle.epoch(); + let branch = handle.branch_id().clone(); + let journal = handle.journal(); + let lineage_before = handle.lineage(); + let files_before = store_files(store.root()); + let rejected = restore_recorded(&handle, recorded.checkpoint_id()) + .expect_err("restore past the lineage bound"); + assert_eq!(rejected.kind(), EffectErrorKind::SchemaRejected); + let shown = rejected.to_string(); + assert!( + !shown.contains('/') && !shown.contains('\\'), + "lineage rejection leaked a path: {shown}" + ); + assert_eq!(shown, "SchemaRejected: lineage cannot grow past 64"); + assert_eq!(handle.branch_id(), &branch); + assert_eq!(handle.head(), head); + assert_eq!(handle.epoch(), epoch); + assert_eq!(handle.lineage(), lineage_before); + assert_eq!(handle.journal(), journal); + assert_eq!(store_files(store.root()), files_before); + assert_eq!(handle.read_committed("a.txt").unwrap(), b"a"); + + let resumed = handle + .open_view(OpenViewRequest { + expected_head: head.clone(), + expected_epoch: epoch, + cancellation: EffectCancellation::new(), + deadline: far_deadline(), + }) + .expect("original branch epoch and writer lease stay usable"); + resumed.write_file("probe.txt", b"probe").unwrap(); + assert_eq!(resumed.read_file("probe.txt").unwrap(), b"probe"); + handle.discard(resumed).unwrap(); + publish_edit(&handle, |view| { + view.write_file("after.txt", b"after").unwrap(); + }); + assert_eq!(handle.read_committed("after.txt").unwrap(), b"after"); + assert_eq!(handle.lineage().len(), LINEAGE_BOUND); + let after = handle + .checkpoint(CheckpointRequest { + cancellation: EffectCancellation::new(), + deadline: far_deadline(), + }) + .expect("legal checkpoint after the rejected restore"); + let after_lineage = roundtrip_lineage(&handle, &after); + assert_eq!(after_lineage, lineage_text(&handle)); + assert_eq!(after_lineage.len(), LINEAGE_BOUND); +} diff --git a/tests/effect_workspace_tests.rs b/tests/effect_workspace_tests.rs new file mode 100644 index 0000000..911f339 --- /dev/null +++ b/tests/effect_workspace_tests.rs @@ -0,0 +1,1858 @@ +//! Native workspace views, immutable roots, and publish conflicts. +//! +//! These tests drive the filesystem effect API directly. They do not claim +//! plugin, OCI, or control-plane integration. + +use std::{ + ffi::CString, + fs, + os::unix::{ + ffi::OsStrExt, + fs::{MetadataExt, PermissionsExt}, + net::UnixListener, + }, + path::{Path, PathBuf}, + process, + sync::{ + Arc, Barrier, + atomic::{AtomicU64, Ordering}, + }, + thread, + time::{Duration, Instant}, +}; + +use rustscript_agent::effects::{ + CheckpointRecord, CheckpointRequest, EffectCancellation, EffectDeadline, EffectError, + EffectErrorKind, EffectFailure, EffectStore, ImportWorkspace, OpenViewRequest, + PublicWorkspaceRef, PublishRequest, ResourceQuota, RestoreRequest, RestoredBranch, + WorkspaceHandle, WorkspaceId, WritableView, +}; + +fn scratch(test: &str) -> PathBuf { + static NEXT: AtomicU64 = AtomicU64::new(1); + let n = NEXT.fetch_add(1, Ordering::Relaxed); + let root = assigned_temp().join(format!("effect-workspace-{test}-{}-{n}", process::id())); + fs::create_dir_all(&root).expect("scratch"); + root +} + +fn assigned_temp() -> PathBuf { + PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").expect("RUSTSCRIPT_AGENT_TEST_TMP")) +} + +fn generous() -> ResourceQuota { + ResourceQuota::new(128, 1024 * 1024, 8).expect("quota") +} + +fn far_deadline() -> EffectDeadline { + EffectDeadline::at(Instant::now() + Duration::from_secs(30)) +} + +fn expired_deadline() -> EffectDeadline { + EffectDeadline::at(Instant::now() - Duration::from_millis(1)) +} + +fn import_req( + source: &Path, + quota: ResourceQuota, + cancellation: EffectCancellation, +) -> ImportWorkspace { + ImportWorkspace { + source: source.to_path_buf(), + quota, + cancellation, + deadline: far_deadline(), + } +} + +fn import_ok(store: &EffectStore, source: &Path) -> WorkspaceHandle { + store + .import_workspace(import_req(source, generous(), EffectCancellation::new())) + .expect("import") +} + +fn open_ok(handle: &WorkspaceHandle) -> WritableView { + handle + .open_view(OpenViewRequest { + expected_head: handle.head(), + expected_epoch: handle.epoch(), + cancellation: EffectCancellation::new(), + deadline: far_deadline(), + }) + .expect("open view") +} + +fn publish_ok(handle: &WorkspaceHandle, view: WritableView) { + handle + .publish( + view, + PublishRequest { + expected_head: handle.head(), + expected_epoch: handle.epoch(), + cancellation: EffectCancellation::new(), + deadline: far_deadline(), + }, + ) + .expect("publish"); +} + +fn kind(err: EffectError) -> EffectErrorKind { + err.kind() +} + +fn dev_ino(path: &Path) -> (u64, u64) { + let meta = fs::symlink_metadata(path).expect("metadata"); + (meta.dev(), meta.ino()) +} + +#[test] +fn write_view_does_not_change_seed() { + let root = scratch("seed"); + let seed = root.join("seed"); + fs::create_dir_all(&seed).unwrap(); + fs::write(seed.join("a.txt"), b"seed-bytes").unwrap(); + let before = fs::read(seed.join("a.txt")).unwrap(); + let before_meta = fs::symlink_metadata(seed.join("a.txt")).unwrap(); + let store = EffectStore::create(root.join("store")).unwrap(); + let handle = import_ok(&store, &seed); + let view = open_ok(&handle); + view.write_file("a.txt", b"view-bytes").unwrap(); + view.write_file("new.txt", b"created").unwrap(); + assert_eq!(fs::read(seed.join("a.txt")).unwrap(), before); + assert!(!seed.join("new.txt").exists()); + let after_meta = fs::symlink_metadata(seed.join("a.txt")).unwrap(); + assert_eq!(before_meta.mode(), after_meta.mode()); + assert_eq!( + dev_ino(&seed.join("a.txt")), + (after_meta.dev(), after_meta.ino()) + ); + assert_eq!(handle.read_committed("a.txt").unwrap(), b"seed-bytes"); +} + +#[test] +fn file_tool_and_terminal_root_see_the_same_bytes() { + let root = scratch("same-view"); + let seed = root.join("seed"); + fs::create_dir_all(&seed).unwrap(); + fs::write(seed.join("a.txt"), b"base").unwrap(); + let store = EffectStore::create(root.join("store")).unwrap(); + let handle = import_ok(&store, &seed); + let view = open_ok(&handle); + view.write_file("from-tool.txt", b"tool").unwrap(); + assert_eq!( + fs::read(view.work_root().join("from-tool.txt")).unwrap(), + b"tool" + ); + fs::write(view.work_root().join("from-terminal.txt"), b"terminal").unwrap(); + assert_eq!(view.read_file("from-terminal.txt").unwrap(), b"terminal"); + assert_eq!(view.read_file("from-tool.txt").unwrap(), b"tool"); + assert_ne!(view.private_temp(), view.work_root()); + fs::write(view.private_temp().join("scratch"), b"tmp").unwrap(); + assert!(view.read_file("scratch").is_err()); +} + +#[test] +fn discarded_view_keeps_parent_head() { + let root = scratch("discard"); + let seed = root.join("seed"); + fs::create_dir_all(&seed).unwrap(); + fs::write(seed.join("a.txt"), b"head").unwrap(); + let store = EffectStore::create(root.join("store")).unwrap(); + let handle = import_ok(&store, &seed); + let head = handle.head(); + let epoch = handle.epoch(); + let view = open_ok(&handle); + let view_root = view.work_root().to_path_buf(); + view.write_file("a.txt", b"dirty").unwrap(); + handle.discard(view).unwrap(); + assert_eq!(handle.head(), head); + assert_eq!(handle.epoch(), epoch); + assert!(!view_root.exists()); + assert_eq!(handle.read_committed("a.txt").unwrap(), b"head"); + assert_eq!(fs::read(seed.join("a.txt")).unwrap(), b"head"); +} + +#[test] +fn rejected_publish_keeps_parent_head() { + let root = scratch("reject-publish"); + let seed = root.join("seed"); + fs::create_dir_all(&seed).unwrap(); + fs::write(seed.join("a.txt"), b"head").unwrap(); + let store = EffectStore::create(root.join("store")).unwrap(); + let handle = import_ok(&store, &seed); + let head = handle.head(); + let view = open_ok(&handle); + view.write_file("a.txt", b"dirty").unwrap(); + std::os::unix::fs::symlink("a.txt", view.work_root().join("link")).unwrap(); + let EffectFailure { error, recovered } = handle + .publish( + view, + PublishRequest { + expected_head: head.clone(), + expected_epoch: handle.epoch(), + cancellation: EffectCancellation::new(), + deadline: far_deadline(), + }, + ) + .expect_err("symlink publish"); + assert_eq!(error.kind(), EffectErrorKind::UnsupportedEntry); + assert_eq!(handle.head(), head); + assert_eq!(handle.read_committed("a.txt").unwrap(), b"head"); + assert_eq!( + fs::read(recovered.work_root().join("a.txt")).unwrap(), + b"dirty" + ); + let retained = recovered.work_root().to_path_buf(); + handle.discard(recovered).unwrap(); + assert!(!retained.exists()); + assert_eq!(handle.head(), head); +} + +#[test] +fn writable_view_does_not_share_inode_with_snapshot() { + let root = scratch("inode"); + let seed = root.join("seed"); + fs::create_dir_all(&seed).unwrap(); + fs::write(seed.join("a.txt"), b"same").unwrap(); + let store = EffectStore::create(root.join("store")).unwrap(); + let handle = import_ok(&store, &seed); + let immutable = handle.immutable_path(&handle.head()).unwrap().join("a.txt"); + let view = open_ok(&handle); + let view_file = view.work_root().join("a.txt"); + let seed_id = dev_ino(&seed.join("a.txt")); + let tree_id = dev_ino(&immutable); + let path_view_id = dev_ino(&view_file); + // Keep the pre-write view open so its inode cannot be retired and reused. + let original_view = fs::File::open(&view_file).expect("hold original view"); + let view_id = { + let meta = original_view.metadata().expect("original view metadata"); + (meta.dev(), meta.ino()) + }; + assert_eq!(view_id, path_view_id); + assert_ne!(seed_id, tree_id); + assert_ne!(view_id, tree_id); + assert_ne!(view_id, seed_id); + assert_eq!(fs::symlink_metadata(&immutable).unwrap().nlink(), 1); + assert_eq!(fs::symlink_metadata(&view_file).unwrap().nlink(), 1); + view.write_file("a.txt", b"changed").unwrap(); + assert_eq!(fs::read(&immutable).unwrap(), b"same"); + assert_eq!(fs::read(seed.join("a.txt")).unwrap(), b"same"); + fs::OpenOptions::new() + .write(true) + .open(&immutable) + .expect_err("sealed file"); + fs::write(immutable.parent().unwrap().join("extra.txt"), b"no").expect_err("sealed dir"); + let replaced_view = fs::File::open(&view_file).expect("hold replaced view"); + let replaced_id = { + let meta = replaced_view.metadata().expect("replaced view metadata"); + (meta.dev(), meta.ino()) + }; + let recorded_view = view_id; + publish_ok(&handle, view); + let published = handle.immutable_path(&handle.head()).unwrap().join("a.txt"); + let original_after = { + let meta = original_view + .metadata() + .expect("original view metadata after publish"); + (meta.dev(), meta.ino()) + }; + let replaced_after = { + let meta = replaced_view + .metadata() + .expect("replaced view metadata after publish"); + (meta.dev(), meta.ino()) + }; + assert_eq!(original_after, recorded_view); + assert_eq!(replaced_after, replaced_id); + assert_ne!(dev_ino(&published), recorded_view); + assert_ne!(dev_ino(&published), original_after); + assert_ne!(dev_ino(&published), replaced_after); + assert_ne!(dev_ino(&published), seed_id); + assert_eq!(fs::symlink_metadata(&published).unwrap().nlink(), 1); + assert_eq!(fs::read(&published).unwrap(), b"changed"); + drop(original_view); + drop(replaced_view); +} + +#[test] +fn import_rejects_external_symlink() { + let root = scratch("symlink"); + let seed = root.join("seed"); + fs::create_dir_all(&seed).unwrap(); + fs::write(seed.join("inside.txt"), b"in").unwrap(); + std::os::unix::fs::symlink(seed.join("inside.txt"), seed.join("rel")).unwrap(); + std::os::unix::fs::symlink("/etc/hostname", seed.join("out")).unwrap(); + let store = EffectStore::create(root.join("store")).unwrap(); + let err = store + .import_workspace(import_req(&seed, generous(), EffectCancellation::new())) + .expect_err("symlink"); + assert_eq!(err.kind(), EffectErrorKind::UnsupportedEntry); + assert!( + fs::read_dir(store.root().join("trees")) + .unwrap() + .next() + .is_none() + ); + assert_eq!(fs::read(seed.join("inside.txt")).unwrap(), b"in"); +} + +#[test] +fn import_rejects_device_path() { + let root = scratch("device"); + let store = EffectStore::create(root.join("store")).unwrap(); + let err = store + .import_workspace(import_req( + Path::new("/dev/null"), + generous(), + EffectCancellation::new(), + )) + .expect_err("device"); + assert_eq!(err.kind(), EffectErrorKind::UnsupportedEntry); + assert!( + fs::read_dir(store.root().join("trees")) + .unwrap() + .next() + .is_none() + ); +} + +#[test] +fn import_rejects_fifo_and_socket() { + let root = scratch("fifo"); + let seed = root.join("seed"); + fs::create_dir_all(&seed).unwrap(); + let fifo = seed.join("pipe"); + let rc = unsafe { + libc::mkfifo( + CString::new(fifo.as_os_str().as_bytes()).unwrap().as_ptr(), + 0o644, + ) + }; + assert_eq!(rc, 0, "mkfifo {}", std::io::Error::last_os_error()); + let store = EffectStore::create(root.join("store")).unwrap(); + let err = store + .import_workspace(import_req(&seed, generous(), EffectCancellation::new())) + .expect_err("fifo"); + assert_eq!(err.kind(), EffectErrorKind::UnsupportedEntry); + + // sun_path is 108 bytes, so the socket has to live in a short assigned-temp directory. + let sock_seed = assigned_temp() + .join(format!("s{}", process::id())) + .join("s"); + let _ = fs::remove_dir_all(sock_seed.parent().unwrap()); + fs::create_dir_all(&sock_seed).unwrap(); + let _listener = UnixListener::bind(sock_seed.join("k")).unwrap(); + let err = store + .import_workspace(import_req( + &sock_seed, + generous(), + EffectCancellation::new(), + )) + .expect_err("socket"); + assert_eq!(err.kind(), EffectErrorKind::UnsupportedEntry); + drop(_listener); + let _ = fs::remove_dir_all(sock_seed.parent().unwrap()); +} + +#[test] +fn import_rejects_external_hardlink() { + let root = scratch("hardlink"); + let seed = root.join("seed"); + fs::create_dir_all(&seed).unwrap(); + fs::write(seed.join("a.txt"), b"linked").unwrap(); + fs::hard_link(seed.join("a.txt"), root.join("outside.txt")).unwrap(); + let store = EffectStore::create(root.join("store")).unwrap(); + let err = store + .import_workspace(import_req(&seed, generous(), EffectCancellation::new())) + .expect_err("external hardlink"); + assert_eq!(err.kind(), EffectErrorKind::UnsupportedEntry); + assert_eq!(fs::read(root.join("outside.txt")).unwrap(), b"linked"); + assert!( + fs::read_dir(store.root().join("trees")) + .unwrap() + .next() + .is_none() + ); +} + +#[test] +fn internal_hardlink_copies_do_not_share_inodes() { + let root = scratch("internal-link"); + let seed = root.join("seed"); + fs::create_dir_all(&seed).unwrap(); + fs::write(seed.join("a.txt"), b"linked").unwrap(); + fs::hard_link(seed.join("a.txt"), seed.join("b.txt")).unwrap(); + let store = EffectStore::create(root.join("store")).unwrap(); + let handle = import_ok(&store, &seed); + let tree = handle.immutable_path(&handle.head()).unwrap(); + let a = dev_ino(&tree.join("a.txt")); + let b = dev_ino(&tree.join("b.txt")); + assert_ne!(a, b); + assert_ne!(a, dev_ino(&seed.join("a.txt"))); + assert_ne!(b, dev_ino(&seed.join("b.txt"))); + assert_eq!(fs::read(tree.join("a.txt")).unwrap(), b"linked"); + assert_eq!(fs::read(tree.join("b.txt")).unwrap(), b"linked"); + assert_eq!(fs::symlink_metadata(tree.join("a.txt")).unwrap().nlink(), 1); +} + +#[test] +fn quota_excess_does_not_publish() { + let root = scratch("quota"); + let seed = root.join("seed"); + fs::create_dir_all(seed.join("nested")).unwrap(); + fs::write(seed.join("a.txt"), b"1234").unwrap(); + fs::write(seed.join("b.txt"), b"zzzz").unwrap(); + let store = EffectStore::create(root.join("store")).unwrap(); + let err = store + .import_workspace(import_req( + &seed, + ResourceQuota::new(1, 1024, 8).unwrap(), + EffectCancellation::new(), + )) + .expect_err("entries"); + assert_eq!(err.kind(), EffectErrorKind::QuotaExceeded); + assert!( + fs::read_dir(store.root().join("trees")) + .unwrap() + .next() + .is_none() + ); + + fs::remove_dir_all(&seed).unwrap(); + fs::create_dir_all(&seed).unwrap(); + fs::write(seed.join("a.txt"), b"1234").unwrap(); + let err = store + .import_workspace(import_req( + &seed, + ResourceQuota::new(8, 3, 8).unwrap(), + EffectCancellation::new(), + )) + .expect_err("bytes"); + assert_eq!(err.kind(), EffectErrorKind::QuotaExceeded); + + fs::remove_dir_all(&seed).unwrap(); + fs::create_dir_all(seed.join("nested")).unwrap(); + fs::write(seed.join("nested/a.txt"), b"x").unwrap(); + let err = store + .import_workspace(import_req( + &seed, + ResourceQuota::new(8, 1024, 1).unwrap(), + EffectCancellation::new(), + )) + .expect_err("depth"); + assert_eq!(err.kind(), EffectErrorKind::QuotaExceeded); + + fs::remove_dir_all(&seed).unwrap(); + fs::create_dir_all(&seed).unwrap(); + fs::write(seed.join("a.txt"), b"ok").unwrap(); + let handle = store + .import_workspace(import_req( + &seed, + ResourceQuota::new(4, 4, 4).unwrap(), + EffectCancellation::new(), + )) + .unwrap(); + let head = handle.head(); + let view = open_ok(&handle); + let err = view.write_file("b.txt", b"0123456789").unwrap_err(); + assert_eq!(kind(err), EffectErrorKind::QuotaExceeded); + fs::write(view.work_root().join("terminal-extra.txt"), b"yyyy").unwrap(); + let EffectFailure { error, recovered } = handle + .publish( + view, + PublishRequest { + expected_head: head.clone(), + expected_epoch: handle.epoch(), + cancellation: EffectCancellation::new(), + deadline: far_deadline(), + }, + ) + .expect_err("terminal quota"); + assert_eq!(error.kind(), EffectErrorKind::QuotaExceeded); + assert_eq!(handle.head(), head); + handle.discard(recovered).unwrap(); +} + +#[test] +fn cancellation_discards_partial_import() { + let root = scratch("cancel"); + let seed = root.join("seed"); + fs::create_dir_all(&seed).unwrap(); + fs::write(seed.join("a.txt"), b"one").unwrap(); + fs::write(seed.join("b.txt"), b"two").unwrap(); + fs::write(seed.join("c.txt"), b"three").unwrap(); + let store = EffectStore::create(root.join("store")).unwrap(); + let cancellation = EffectCancellation::new(); + cancellation.cancel_at_check(2); + let started = Instant::now(); + let err = store + .import_workspace(import_req(&seed, generous(), cancellation)) + .expect_err("cancel"); + assert_eq!(err.kind(), EffectErrorKind::Cancelled); + assert!(started.elapsed() < Duration::from_secs(2)); + assert!( + fs::read_dir(store.root().join("trees")) + .unwrap() + .next() + .is_none() + ); + assert_eq!(fs::read(seed.join("a.txt")).unwrap(), b"one"); + + let pre = EffectCancellation::new(); + pre.cancel(); + let err = store + .import_workspace(import_req(&seed, generous(), pre)) + .expect_err("pre-cancel"); + assert_eq!(err.kind(), EffectErrorKind::Cancelled); +} + +#[test] +fn expired_deadline_is_not_extended() { + let root = scratch("deadline"); + let seed = root.join("seed"); + fs::create_dir_all(&seed).unwrap(); + fs::write(seed.join("a.txt"), b"one").unwrap(); + let store = EffectStore::create(root.join("store")).unwrap(); + let started = Instant::now(); + let err = store + .import_workspace(ImportWorkspace { + source: seed.clone(), + quota: generous(), + cancellation: EffectCancellation::new(), + deadline: expired_deadline(), + }) + .expect_err("deadline"); + assert_eq!(err.kind(), EffectErrorKind::DeadlineExceeded); + assert!(started.elapsed() < Duration::from_secs(1)); + assert!( + fs::read_dir(store.root().join("trees")) + .unwrap() + .next() + .is_none() + ); + assert_eq!( + ResourceQuota::new(0, 10, 1).unwrap_err().kind(), + EffectErrorKind::InvalidConfiguration + ); + assert_eq!( + ResourceQuota::new(1_000_001, 10, 1).unwrap_err().kind(), + EffectErrorKind::InvalidConfiguration + ); +} + +#[test] +fn concurrent_publish_conflicts_on_parent_head() { + let root = scratch("cas"); + let seed = root.join("seed"); + fs::create_dir_all(&seed).unwrap(); + fs::write(seed.join("base.txt"), b"base").unwrap(); + let store = EffectStore::create(root.join("store")).unwrap(); + let handle = import_ok(&store, &seed); + let expected = handle.head(); + let epoch = handle.epoch(); + let left = open_ok(&handle); + let right = open_ok(&handle); + left.write_file("left.txt", b"L").unwrap(); + right.write_file("right.txt", b"R").unwrap(); + let barrier = Arc::new(Barrier::new(2)); + let spawn = |handle: WorkspaceHandle, + view: WritableView, + marker: &'static str, + expected_head: rustscript_agent::effects::TreeId| { + let barrier = Arc::clone(&barrier); + thread::spawn( + #[allow(clippy::result_large_err)] + move || { + barrier.wait(); + handle + .publish( + view, + PublishRequest { + expected_head, + expected_epoch: epoch, + cancellation: EffectCancellation::new(), + deadline: far_deadline(), + }, + ) + .map(|publication| (marker, publication.head)) + }, + ) + }; + let first = spawn(handle.clone(), left, "left", expected.clone()); + let second = spawn(handle.clone(), right, "right", expected); + let results = [first.join().unwrap(), second.join().unwrap()]; + let mut winner = None; + let mut loser = None; + for result in results { + match result { + Ok(publication) => winner = Some(publication), + Err(failure) => loser = Some(failure), + } + } + let winner = winner.expect("one publish wins"); + let EffectFailure { error, recovered } = loser.expect("one publish conflicts"); + assert_eq!(handle.head(), winner.1); + assert_eq!(error.kind(), EffectErrorKind::Conflict); + let loser_name = if winner.0 == "left" { + "right.txt" + } else { + "left.txt" + }; + let loser_bytes: &[u8] = if winner.0 == "left" { b"R" } else { b"L" }; + assert_eq!(recovered.read_file(loser_name).unwrap(), loser_bytes); + assert!(handle.read_committed(loser_name).is_err()); + let winner_name = if winner.0 == "left" { + "left.txt" + } else { + "right.txt" + }; + assert_eq!( + handle.read_committed(winner_name).unwrap(), + if winner.0 == "left" { + &b"L"[..] + } else { + &b"R"[..] + } + ); + handle.discard(recovered).unwrap(); + assert_eq!(handle.head(), winner.1); +} + +#[test] +fn public_ids_do_not_authorize() { + let root = scratch("ids"); + let seed = root.join("seed"); + fs::create_dir_all(&seed).unwrap(); + fs::write(seed.join("a.txt"), b"secret-not-really").unwrap(); + let store = EffectStore::create(root.join("store")).unwrap(); + let handle = import_ok(&store, &seed); + let refs = handle.public_ref(); + let err = store.open_view_from_public_ids(&refs).unwrap_err(); + assert_eq!(err.kind(), EffectErrorKind::AuthorityRequired); + let forged = PublicWorkspaceRef { + workspace_id: WorkspaceId::parse_public("ws-00000000000000000000000000000000").unwrap(), + branch_id: refs.branch_id.clone(), + head: refs.head.clone(), + epoch: refs.epoch, + }; + assert_eq!( + store.open_view_from_public_ids(&forged).unwrap_err().kind(), + EffectErrorKind::AuthorityRequired + ); + assert_eq!( + handle.read_committed("a.txt").unwrap(), + b"secret-not-really" + ); +} + +#[test] +fn other_owner_cannot_publish() { + let root = scratch("owners"); + let seed_a = root.join("a"); + let seed_b = root.join("b"); + fs::create_dir_all(&seed_a).unwrap(); + fs::create_dir_all(&seed_b).unwrap(); + fs::write(seed_a.join("a.txt"), b"A").unwrap(); + fs::write(seed_b.join("b.txt"), b"B").unwrap(); + let store = EffectStore::create(root.join("store")).unwrap(); + let owner_a = import_ok(&store, &seed_a); + let owner_b = import_ok(&store, &seed_b); + let head_b = owner_b.head(); + let view_b = open_ok(&owner_b); + view_b.write_file("b.txt", b"stolen").unwrap(); + let EffectFailure { error, recovered } = owner_a + .publish( + view_b, + PublishRequest { + expected_head: head_b.clone(), + expected_epoch: owner_b.epoch(), + cancellation: EffectCancellation::new(), + deadline: far_deadline(), + }, + ) + .expect_err("cross owner"); + assert_eq!(error.kind(), EffectErrorKind::OwnerMismatch); + assert_eq!(owner_b.head(), head_b); + assert_eq!(owner_b.read_committed("b.txt").unwrap(), b"B"); + owner_b.discard(recovered).unwrap(); + let own_view = open_ok(&owner_a); + own_view.write_file("a.txt", b"AA").unwrap(); + publish_ok(&owner_a, own_view); + assert_eq!(owner_a.read_committed("a.txt").unwrap(), b"AA"); + assert_eq!(owner_b.read_committed("b.txt").unwrap(), b"B"); +} + +#[test] +fn private_temp_is_not_in_snapshot() { + let root = scratch("temp"); + let seed = root.join("seed"); + fs::create_dir_all(&seed).unwrap(); + fs::write(seed.join("a.txt"), b"a").unwrap(); + let store = EffectStore::create(root.join("store")).unwrap(); + let handle = import_ok(&store, &seed); + let first = open_ok(&handle); + let second = open_ok(&handle); + assert_ne!(first.private_temp(), second.private_temp()); + fs::write(first.private_temp().join("only-first"), b"1").unwrap(); + assert!(!second.private_temp().join("only-first").exists()); + first.write_file("a.txt", b"published").unwrap(); + let temp = first.private_temp().to_path_buf(); + publish_ok(&handle, first); + let tree = handle.immutable_path(&handle.head()).unwrap(); + assert!(!tree.join("only-first").exists()); + assert!(!tree.join("tmp").exists()); + assert_eq!(fs::read(tree.join("a.txt")).unwrap(), b"published"); + let _ = temp; + handle.discard(second).unwrap(); +} + +#[test] +fn source_change_during_import_is_rejected() { + let root = scratch("race"); + let seed = root.join("seed"); + fs::create_dir_all(&seed).unwrap(); + let first = seed.join("a.txt"); + fs::write(&first, b"before").unwrap(); + fs::write(seed.join("b.txt"), b"stable").unwrap(); + let store = EffectStore::create(root.join("store")).unwrap(); + let cancellation = EffectCancellation::new(); + let mutated = first.clone(); + cancellation.set_before_check_hook(move |n| { + if n == 3 { + fs::write(&mutated, b"during").unwrap(); + } + }); + let err = store + .import_workspace(import_req(&seed, generous(), cancellation)) + .expect_err("inconsistent"); + assert_eq!(err.kind(), EffectErrorKind::InconsistentSource); + assert!( + fs::read_dir(store.root().join("trees")) + .unwrap() + .next() + .is_none() + ); + assert_eq!(fs::read(&first).unwrap(), b"during"); +} + +#[test] +fn setuid_bit_is_stripped_and_empty_dir_remains() { + let root = scratch("mode"); + let seed = root.join("seed"); + fs::create_dir_all(seed.join("empty")).unwrap(); + let exe = seed.join("run.sh"); + fs::write(&exe, b"#!/bin/sh\n").unwrap(); + let mut perms = fs::metadata(&exe).unwrap().permissions(); + perms.set_mode(0o4755); + fs::set_permissions(&exe, perms).unwrap(); + let store = EffectStore::create(root.join("store")).unwrap(); + let handle = import_ok(&store, &seed); + let tree = handle.immutable_path(&handle.head()).unwrap(); + assert!(tree.join("empty").is_dir()); + let mode = fs::symlink_metadata(tree.join("run.sh")).unwrap().mode(); + assert_eq!(mode & 0o4000, 0); + assert_eq!(mode & 0o2000, 0); + assert_ne!(mode & 0o111, 0); + let view = open_ok(&handle); + let view_mode = fs::symlink_metadata(view.work_root().join("run.sh")) + .unwrap() + .mode(); + assert_eq!(view_mode & 0o4000, 0); + assert_ne!(view_mode & 0o111, 0); + view.create_dir("added").unwrap(); + view.set_executable("run.sh", false).unwrap(); + assert_eq!( + fs::symlink_metadata(view.work_root().join("run.sh")) + .unwrap() + .mode() + & 0o111, + 0 + ); + publish_ok(&handle, view); + assert!( + handle + .immutable_path(&handle.head()) + .unwrap() + .join("added") + .is_dir() + ); +} + +#[test] +fn relative_escape_is_rejected() { + let root = scratch("escape"); + let seed = root.join("seed"); + fs::create_dir_all(&seed).unwrap(); + fs::write(seed.join("a.txt"), b"a").unwrap(); + let outside = root.join("outside.txt"); + let store = EffectStore::create(root.join("store")).unwrap(); + let handle = import_ok(&store, &seed); + let view = open_ok(&handle); + for bad in [ + "../outside.txt", + "/tmp/x", + "a/../../outside.txt", + "a\\b", + "a\0b", + "", + ] { + let err = view.write_file(bad, b"no").unwrap_err(); + assert_eq!(kind(err), EffectErrorKind::RejectedPath, "{bad}"); + } + assert!(!outside.exists()); + handle.discard(view).unwrap(); +} + +fn checkpoint_now(handle: &WorkspaceHandle) -> Result { + handle.checkpoint(CheckpointRequest { + cancellation: EffectCancellation::new(), + deadline: far_deadline(), + }) +} + +fn restore_now(handle: &WorkspaceHandle, record: &CheckpointRecord) -> RestoredBranch { + handle + .restore(RestoreRequest { + checkpoint_id: record.checkpoint_id().clone(), + expected_head: handle.head(), + expected_epoch: handle.epoch(), + cancellation: EffectCancellation::new(), + deadline: far_deadline(), + }) + .expect("restore") +} + +#[test] +fn writer_lease_stale_handle_discard_after_restore_is_rejected() { + let root = scratch("stale-discard"); + let seed = root.join("seed"); + fs::create_dir_all(&seed).unwrap(); + fs::write(seed.join("a.txt"), b"a").unwrap(); + let store = EffectStore::create(root.join("store")).unwrap(); + let old = import_ok(&store, &seed); + let record = checkpoint_now(&old).expect("quiescent checkpoint"); + let restored = restore_now(&old, &record); + assert_ne!(restored.handle.branch_id(), old.branch_id()); + let view = open_ok(&restored.handle); + let sibling = open_ok(&restored.handle); + view.write_file("a.txt", b"live").unwrap(); + let EffectFailure { error, recovered } = old + .discard(view) + .expect_err("stale handle must not discard the restored view"); + assert_eq!(error.kind(), EffectErrorKind::Conflict); + assert_eq!( + recovered + .read_file("a.txt") + .expect("returned view stays readable"), + b"live" + ); + recovered + .write_file("note.txt", b"continued") + .expect("legitimate owner can continue"); + let started = Instant::now(); + let busy = checkpoint_now(&restored.handle).expect_err("held views keep the writer"); + assert_eq!(busy.kind(), EffectErrorKind::CheckpointBusy); + assert!(started.elapsed() < Duration::from_secs(1)); + restored.handle.discard(recovered).unwrap(); + let still = checkpoint_now(&restored.handle).expect_err("sibling writer remains"); + assert_eq!(still.kind(), EffectErrorKind::CheckpointBusy); + drop(sibling); + let after = checkpoint_now(&restored.handle).expect("originating writers released once"); + let again = restore_now(&restored.handle, &after); + assert_eq!(again.handle.read_committed("a.txt").unwrap(), b"a"); + assert_eq!( + store + .open_view_from_public_ids(&again.handle.public_ref()) + .unwrap_err() + .kind(), + EffectErrorKind::AuthorityRequired + ); +} + +#[test] +fn writer_lease_wrong_owner_discard_does_not_leave_branch_busy() { + let root = scratch("wrong-owner-discard"); + let seed_a = root.join("a"); + let seed_b = root.join("b"); + fs::create_dir_all(&seed_a).unwrap(); + fs::create_dir_all(&seed_b).unwrap(); + fs::write(seed_a.join("a.txt"), b"A").unwrap(); + fs::write(seed_b.join("b.txt"), b"B").unwrap(); + let store = EffectStore::create(root.join("store")).unwrap(); + let owner_a = import_ok(&store, &seed_a); + let owner_b = import_ok(&store, &seed_b); + let view = open_ok(&owner_b); + let sibling = open_ok(&owner_b); + view.write_file("b.txt", b"kept").unwrap(); + let EffectFailure { error, recovered } = owner_a + .discard(view) + .expect_err("wrong owner must not discard"); + assert_eq!(error.kind(), EffectErrorKind::OwnerMismatch); + assert_eq!( + recovered + .read_file("b.txt") + .expect("returned view stays readable"), + b"kept" + ); + recovered + .write_file("b.txt", b"still-owner") + .expect("legitimate owner can continue"); + let started = Instant::now(); + let busy = checkpoint_now(&owner_b).expect_err("returned view still holds the writer"); + assert_eq!(busy.kind(), EffectErrorKind::CheckpointBusy); + assert!(started.elapsed() < Duration::from_secs(1)); + drop(recovered); + let still = checkpoint_now(&owner_b).expect_err("sibling writer remains after one drop"); + assert_eq!(still.kind(), EffectErrorKind::CheckpointBusy); + owner_b.discard(sibling).unwrap(); + let record = checkpoint_now(&owner_b).expect("cleanup after exactly one release each"); + let restored = restore_now(&owner_b, &record); + assert_eq!(restored.handle.read_committed("b.txt").unwrap(), b"B"); + assert_eq!( + store + .open_view_from_public_ids(&owner_b.public_ref()) + .unwrap_err() + .kind(), + EffectErrorKind::AuthorityRequired + ); +} + +#[test] +fn writer_lease_same_branch_discard_and_drop_release_once() { + let root = scratch("release-once"); + let seed = root.join("seed"); + fs::create_dir_all(&seed).unwrap(); + fs::write(seed.join("a.txt"), b"a").unwrap(); + let store = EffectStore::create(root.join("store")).unwrap(); + let handle = import_ok(&store, &seed); + let first = open_ok(&handle); + let second = open_ok(&handle); + handle.discard(first).unwrap(); + let started = Instant::now(); + let busy = checkpoint_now(&handle).expect_err("second view still holds the writer"); + assert_eq!(busy.kind(), EffectErrorKind::CheckpointBusy); + assert!(started.elapsed() < Duration::from_secs(1)); + drop(second); + checkpoint_now(&handle).expect("drop released the remaining writer once"); + let record = checkpoint_now(&handle).expect("a second checkpoint is not blocked"); + let restored = restore_now(&handle, &record); + assert_eq!(restored.handle.read_committed("a.txt").unwrap(), b"a"); +} + +fn dir_names(path: &Path) -> Vec { + let mut names = fs::read_dir(path) + .unwrap() + .map(|entry| entry.unwrap().file_name().into_string().unwrap()) + .collect::>(); + names.sort(); + names +} + +struct ControlCase { + label: &'static str, + byte: u8, + control_dir: bool, + legal_parent: bool, + bytes: &'static [u8], +} + +fn control_layout(case: &ControlCase) -> (Vec, String) { + let marked = format!("bad{}name", char::from(case.byte)); + if case.control_dir { + (vec![marked], "child.txt".to_string()) + } else if case.legal_parent { + (vec!["ok".to_string()], format!("{marked}.txt")) + } else { + (Vec::new(), format!("{marked}.txt")) + } +} + +fn join_rel(dirs: &[String], file: &str) -> String { + let mut parts = dirs.to_vec(); + parts.push(file.to_string()); + parts.join("/") +} + +fn write_rel(seed: &Path, dirs: &[String], file: &str, bytes: &[u8]) -> PathBuf { + let mut path = seed.to_path_buf(); + for dir in dirs { + path.push(dir); + fs::create_dir_all(&path).unwrap(); + } + path.push(file); + fs::write(&path, bytes).unwrap(); + path +} + +// Import must reject a component validate_relative already rejects. +// This is a path-policy boundary, not a symlink, hardlink, or traversal check. +#[test] +fn import_rejects_control_byte_components() { + let cases = [ + ControlCase { + label: "newline-file", + byte: 0x0a, + control_dir: false, + legal_parent: false, + bytes: b"newline-bytes", + }, + ControlCase { + label: "tab-file", + byte: 0x09, + control_dir: false, + legal_parent: false, + bytes: b"tab-bytes", + }, + ControlCase { + label: "cr-file", + byte: 0x0d, + control_dir: false, + legal_parent: false, + bytes: b"cr-bytes", + }, + ControlCase { + label: "us-file", + byte: 0x1f, + control_dir: false, + legal_parent: false, + bytes: b"us-bytes", + }, + ControlCase { + label: "newline-dir", + byte: 0x0a, + control_dir: true, + legal_parent: false, + bytes: b"nested-bytes", + }, + ControlCase { + label: "tab-nested-file", + byte: 0x09, + control_dir: false, + legal_parent: true, + bytes: b"nested-tab", + }, + ]; + let mut accepted = Vec::new(); + for case in cases { + assert!(case.byte < 0x20, "{}", case.label); + let (dirs, file) = control_layout(&case); + let rel = join_rel(&dirs, &file); + assert!( + rel.bytes().any(|byte| byte < 0x20), + "{} must contain a control byte", + case.label + ); + let root = scratch(&format!("ctrl-{}", case.label)); + let legal = root.join("legal"); + fs::create_dir_all(&legal).unwrap(); + fs::write(legal.join("keep.txt"), b"kept").unwrap(); + let store = EffectStore::create(root.join("store")).unwrap(); + let handle = import_ok(&store, &legal); + let head = handle.head(); + let epoch = handle.epoch(); + let branch = handle.branch_id().clone(); + let view = open_ok(&handle); + view.write_file("note.txt", b"live").unwrap(); + let trees_before = dir_names(&store.root().join("trees")); + let staging_before = dir_names(&store.root().join("staging")); + let views_before = dir_names(&store.root().join("views")); + let seed = root.join("seed"); + fs::create_dir_all(&seed).unwrap(); + let seed_file = write_rel(&seed, &dirs, &file, case.bytes); + let parent = seed_file.parent().unwrap().to_path_buf(); + let parent_before = dir_names(&parent); + let seed_root_before = dir_names(&seed); + let imported = + store.import_workspace(import_req(&seed, generous(), EffectCancellation::new())); + assert_eq!(fs::read(&seed_file).unwrap(), case.bytes, "{}", case.label); + assert_eq!(dir_names(&parent), parent_before, "{}", case.label); + assert_eq!(dir_names(&seed), seed_root_before, "{}", case.label); + assert_eq!(handle.head(), head, "{}", case.label); + assert_eq!(handle.epoch(), epoch, "{}", case.label); + assert_eq!(handle.branch_id(), &branch, "{}", case.label); + assert_eq!(handle.read_committed("keep.txt").unwrap(), b"kept"); + assert_eq!(view.read_file("note.txt").unwrap(), b"live"); + view.write_file("after.txt", b"after").unwrap(); + assert_eq!(view.read_file("after.txt").unwrap(), b"after"); + assert_eq!(handle.journal().len(), 1, "{}", case.label); + match imported { + Err(err) => { + assert_eq!(err.kind(), EffectErrorKind::RejectedPath, "{}", case.label); + assert_eq!( + dir_names(&store.root().join("trees")), + trees_before, + "{}", + case.label + ); + assert_eq!( + dir_names(&store.root().join("staging")), + staging_before, + "{}", + case.label + ); + assert_eq!( + dir_names(&store.root().join("views")), + views_before, + "{}", + case.label + ); + } + Ok(bad) => { + let read_kind = bad + .read_committed(&rel) + .map(|bytes| format!("ok-{}", bytes.len())) + .unwrap_or_else(|err| format!("{:?}", err.kind())); + let bad_view = open_ok(&bad); + let write_kind = bad_view + .write_file(&rel, b"no") + .map(|_| "ok".to_string()) + .unwrap_or_else(|err| format!("{:?}", err.kind())); + let delete_kind = bad_view + .delete(&rel) + .map(|_| "ok".to_string()) + .unwrap_or_else(|err| format!("{:?}", err.kind())); + let mut copied = bad.immutable_path(&bad.head()).expect("imported tree"); + for dir in &dirs { + copied.push(dir); + } + copied.push(&file); + let copied_ok = fs::read(&copied).ok().as_deref() == Some(case.bytes); + let tree_created = dir_names(&store.root().join("trees")) != trees_before; + accepted.push(format!( + "{}: import accepted; read={} write={} delete={} copied={} tree_created={}", + case.label, read_kind, write_kind, delete_kind, copied_ok, tree_created + )); + } + } + } + assert!( + accepted.is_empty(), + "import accepted entries the relative API cannot address: {accepted:?}" + ); +} + +#[test] +fn legal_utf8_names_round_trip_through_publish() { + let root = scratch("legal-utf8"); + let seed = root.join("seed"); + let dir = "目录"; + let nested = "文件.txt"; + let created = "新建.txt"; + let del = format!("del{}.txt", char::from(0x7f)); + let pad = format!("pad{}.txt", char::from(0x80)); + assert_eq!(dir.as_bytes(), &[0xe7, 0x9b, 0xae, 0xe5, 0xbd, 0x95]); + assert_eq!( + nested.as_bytes(), + &[0xe6, 0x96, 0x87, 0xe4, 0xbb, 0xb6, b'.', b't', b'x', b't'] + ); + assert!(del.as_bytes().contains(&0x7f)); + assert!(pad.chars().any(|ch| ch == char::from(0x80))); + for name in [ + &del, + &pad, + &dir.to_string(), + &nested.to_string(), + &created.to_string(), + ] { + assert!( + name.bytes().all(|byte| byte >= 0x20), + "{name:?} is a legal component" + ); + } + fs::create_dir_all(seed.join(dir)).unwrap(); + fs::write(seed.join("plain.txt"), b"plain").unwrap(); + fs::write(seed.join(&del), b"del-bytes").unwrap(); + fs::write(seed.join(&pad), b"pad-bytes").unwrap(); + fs::write(seed.join(dir).join(nested), b"cjk-bytes").unwrap(); + let store = EffectStore::create(root.join("store")).unwrap(); + let handle = import_ok(&store, &seed); + let nested_rel = format!("{dir}/{nested}"); + assert_eq!(handle.read_committed("plain.txt").unwrap(), b"plain"); + assert_eq!(handle.read_committed(&del).unwrap(), b"del-bytes"); + assert_eq!(handle.read_committed(&pad).unwrap(), b"pad-bytes"); + assert_eq!(handle.read_committed(&nested_rel).unwrap(), b"cjk-bytes"); + let tree = handle.immutable_path(&handle.head()).unwrap(); + assert_eq!(fs::read(tree.join(&del)).unwrap(), b"del-bytes"); + assert_eq!(fs::read(tree.join(dir).join(nested)).unwrap(), b"cjk-bytes"); + let view = open_ok(&handle); + assert_eq!(view.read_file("plain.txt").unwrap(), b"plain"); + assert_eq!(view.read_file(&del).unwrap(), b"del-bytes"); + assert_eq!(view.read_file(&pad).unwrap(), b"pad-bytes"); + assert_eq!(view.read_file(&nested_rel).unwrap(), b"cjk-bytes"); + assert_eq!(view.list(dir).unwrap(), vec![nested.to_string()]); + view.write_file("plain.txt", b"updated").unwrap(); + view.write_file(created, b"created").unwrap(); + view.delete(&del).unwrap(); + let rejected = view.write_file(&format!("no{}pe.txt", char::from(0x0a)), b"x"); + assert_eq!(rejected.unwrap_err().kind(), EffectErrorKind::RejectedPath); + assert!( + !view + .list("") + .unwrap() + .iter() + .any(|name| name.bytes().any(|byte| byte < 0x20)) + ); + publish_ok(&handle, view); + assert_eq!(handle.read_committed("plain.txt").unwrap(), b"updated"); + assert_eq!(handle.read_committed(created).unwrap(), b"created"); + assert_eq!(handle.read_committed(&pad).unwrap(), b"pad-bytes"); + assert_eq!(handle.read_committed(&nested_rel).unwrap(), b"cjk-bytes"); + assert_eq!( + handle.read_committed(&del).unwrap_err().kind(), + EffectErrorKind::NotFound + ); + assert_eq!(fs::read(seed.join("plain.txt")).unwrap(), b"plain"); + assert_eq!(fs::read(seed.join(&del)).unwrap(), b"del-bytes"); + assert_eq!(fs::read(seed.join(dir).join(nested)).unwrap(), b"cjk-bytes"); +} + +#[cfg(feature = "workspace-io-fault")] +fn assert_simulated_io(err: &EffectError, errno_name: &str) { + assert_eq!(err.kind(), EffectErrorKind::Io); + let text = err.to_string(); + assert!( + text.contains("simulated I/O error"), + "fault must be labeled simulated, not a hardware or disk-full event: {text}" + ); + assert!( + text.contains("not a hardware failure or disk-full event"), + "{text}" + ); + assert!(text.contains(errno_name), "{text}"); +} + +#[cfg(feature = "workspace-io-fault")] +#[test] +fn failed_new_file_partial_write_keeps_path_absent_and_quota() { + let root = scratch("io-new-partial"); + let seed = root.join("seed"); + fs::create_dir_all(&seed).unwrap(); + let store = EffectStore::create(root.join("store")).unwrap(); + let handle = store + .import_workspace(import_req( + &seed, + ResourceQuota::new(4, 32, 4).unwrap(), + EffectCancellation::new(), + )) + .unwrap(); + let view = open_ok(&handle); + assert_eq!(view.accounted_usage(), (0, 0)); + view.arm_simulated_io_fault(rustscript_agent::effects::SimulatedIoFault::PartialWrite { + errno: rustscript_agent::effects::SimulatedIoFault::EIO, + }) + .unwrap(); + let err = view.write_file("new.txt", b"0123456789abcdef").unwrap_err(); + assert_simulated_io(&err, "EIO"); + let leaked = fs::read(view.work_root().join("new.txt")).ok(); + assert!( + leaked.is_none(), + "partial new file leaked into the view: {leaked:?}" + ); + assert_eq!(view.accounted_usage(), (0, 0)); + view.write_file("new.txt", b"0123456789abcdef").unwrap(); + assert_eq!(view.read_file("new.txt").unwrap(), b"0123456789abcdef"); + assert_eq!(view.accounted_usage(), (1, 16)); + let again = view.write_file("other.txt", b"0123456789abcdef0"); + assert_eq!(again.unwrap_err().kind(), EffectErrorKind::QuotaExceeded); + assert_eq!(view.accounted_usage(), (1, 16)); +} + +#[cfg(feature = "workspace-io-fault")] +fn private_file_residue(view: &WritableView) -> Vec { + let view_dir = view.work_root().parent().expect("view dir"); + let mut found = Vec::new(); + for entry in fs::read_dir(view_dir).unwrap() { + let entry = entry.unwrap(); + let name = entry.file_name().to_string_lossy().into_owned(); + if name == "work" || name == "tmp" { + continue; + } + let path = entry.path(); + if path.is_dir() { + for child in fs::read_dir(&path).unwrap() { + let child = child.unwrap(); + found.push(format!("{name}/{}", child.file_name().to_string_lossy())); + } + } else { + found.push(name); + } + } + found.sort(); + found +} + +#[cfg(feature = "workspace-io-fault")] +fn mode_bits(path: &Path) -> u32 { + fs::symlink_metadata(path).unwrap().mode() & 0o777 +} + +#[cfg(feature = "workspace-io-fault")] +#[test] +fn failed_overwrite_partial_write_keeps_bytes_mode_and_quota() { + let root = scratch("io-overwrite-partial"); + let seed = root.join("seed"); + fs::create_dir_all(&seed).unwrap(); + let original = b"0123456789abcdef0123456789abcdef01234567"; + assert_eq!(original.len(), 40); + let source = seed.join("a.txt"); + fs::write(&source, original).unwrap(); + let mut perms = fs::metadata(&source).unwrap().permissions(); + perms.set_mode(0o755); + fs::set_permissions(&source, perms).unwrap(); + let store = EffectStore::create(root.join("store")).unwrap(); + let handle = store + .import_workspace(import_req( + &seed, + ResourceQuota::new(4, 40, 4).unwrap(), + EffectCancellation::new(), + )) + .unwrap(); + let view = open_ok(&handle); + let dest = view.work_root().join("a.txt"); + assert_eq!(fs::read(&dest).unwrap(), original); + assert_eq!(mode_bits(&dest), 0o755); + assert_eq!(view.accounted_usage(), (1, 40)); + view.arm_simulated_io_fault(rustscript_agent::effects::SimulatedIoFault::PartialWrite { + errno: rustscript_agent::effects::SimulatedIoFault::ENOSPC, + }) + .unwrap(); + let replacement = b"abcdefghijklmnopqrstuvwxyz0123456789ABCD"; + assert_eq!(replacement.len(), 40); + let err = view.write_file("a.txt", replacement).unwrap_err(); + assert_simulated_io(&err, "ENOSPC"); + assert_eq!(fs::read(&dest).unwrap(), original); + assert_eq!(mode_bits(&dest), 0o755); + assert_eq!(view.accounted_usage(), (1, 40)); + assert!(private_file_residue(&view).is_empty()); + view.write_file("a.txt", replacement).unwrap(); + assert_eq!(view.read_file("a.txt").unwrap(), replacement); + assert_eq!(mode_bits(&dest), 0o755); + assert_eq!(view.accounted_usage(), (1, 40)); +} + +#[cfg(feature = "workspace-io-fault")] +#[test] +fn failed_new_file_fsync_keeps_path_absent_and_usage() { + let root = scratch("io-new-fsync"); + let seed = root.join("seed"); + fs::create_dir_all(&seed).unwrap(); + let store = EffectStore::create(root.join("store")).unwrap(); + let handle = store + .import_workspace(import_req( + &seed, + ResourceQuota::new(4, 32, 4).unwrap(), + EffectCancellation::new(), + )) + .unwrap(); + let view = open_ok(&handle); + view.arm_simulated_io_fault(rustscript_agent::effects::SimulatedIoFault::FileFsync { + errno: rustscript_agent::effects::SimulatedIoFault::EIO, + }) + .unwrap(); + let err = view.write_file("new.txt", b"0123456789abcdef").unwrap_err(); + assert_simulated_io(&err, "EIO"); + let leaked = fs::read(view.work_root().join("new.txt")).ok(); + assert!( + leaked.is_none(), + "fsync failure published a new file: {leaked:?}" + ); + assert_eq!(view.accounted_usage(), (0, 0)); + assert!(private_file_residue(&view).is_empty()); + view.write_file("new.txt", b"0123456789abcdef").unwrap(); + assert_eq!(view.accounted_usage(), (1, 16)); +} + +#[cfg(feature = "workspace-io-fault")] +#[test] +fn failed_overwrite_fsync_keeps_bytes_mode_and_usage() { + let root = scratch("io-overwrite-fsync"); + let seed = root.join("seed"); + fs::create_dir_all(&seed).unwrap(); + let original = b"kept-bytes"; + fs::write(seed.join("a.txt"), original).unwrap(); + let store = EffectStore::create(root.join("store")).unwrap(); + let handle = import_ok(&store, &seed); + let view = open_ok(&handle); + view.set_executable("a.txt", true).unwrap(); + let dest = view.work_root().join("a.txt"); + let mode = mode_bits(&dest); + assert_ne!(mode & 0o111, 0); + let usage = view.accounted_usage(); + view.arm_simulated_io_fault(rustscript_agent::effects::SimulatedIoFault::FileFsync { + errno: rustscript_agent::effects::SimulatedIoFault::ENOSPC, + }) + .unwrap(); + let err = view.write_file("a.txt", b"replaced").unwrap_err(); + assert_simulated_io(&err, "ENOSPC"); + assert_eq!(fs::read(&dest).unwrap(), original); + assert_eq!(mode_bits(&dest), mode); + assert_eq!(view.accounted_usage(), usage); + assert!(private_file_residue(&view).is_empty()); + view.write_file("a.txt", b"replaced").unwrap(); + assert_eq!(view.read_file("a.txt").unwrap(), b"replaced"); + assert_eq!(mode_bits(&dest), mode); +} + +#[cfg(feature = "workspace-io-fault")] +fn nested_seed(root: &Path) -> PathBuf { + let seed = root.join("seed"); + fs::create_dir_all(seed.join("sub")).unwrap(); + fs::write(seed.join("sub").join("a.txt"), b"kept").unwrap(); + seed +} + +#[cfg(feature = "workspace-io-fault")] +fn publish_request(handle: &WorkspaceHandle) -> PublishRequest { + PublishRequest { + expected_head: handle.head(), + expected_epoch: handle.epoch(), + cancellation: EffectCancellation::new(), + deadline: far_deadline(), + } +} + +#[cfg(feature = "workspace-io-fault")] +#[test] +fn failed_import_post_rename_chmod_removes_unregistered_tree() { + let root = scratch("io-import-chmod"); + let seed = nested_seed(&root); + let store = EffectStore::create(root.join("store")).unwrap(); + store + .arm_simulated_io_fault( + rustscript_agent::effects::SimulatedIoFault::PostRenameChmod { + errno: rustscript_agent::effects::SimulatedIoFault::EIO, + }, + ) + .unwrap(); + let err = store + .import_workspace(import_req(&seed, generous(), EffectCancellation::new())) + .unwrap_err(); + assert_simulated_io(&err, "EIO"); + let leaked = dir_names(&store.root().join("trees")); + assert!( + leaked.is_empty(), + "post-rename import failure left an unregistered tree: {leaked:?}; error={err}" + ); + assert!(dir_names(&store.root().join("staging")).is_empty()); + assert!(dir_names(&store.root().join("views")).is_empty()); + let handle = import_ok(&store, &seed); + assert_eq!(handle.read_committed("sub/a.txt").unwrap(), b"kept"); + assert!(dir_names(&store.root().join("staging")).is_empty()); +} + +#[cfg(feature = "workspace-io-fault")] +#[test] +fn failed_import_post_rename_fsync_removes_unregistered_tree() { + let root = scratch("io-import-fsync"); + let seed = nested_seed(&root); + let store = EffectStore::create(root.join("store")).unwrap(); + store + .arm_simulated_io_fault( + rustscript_agent::effects::SimulatedIoFault::PostRenameDirFsync { + errno: rustscript_agent::effects::SimulatedIoFault::ENOSPC, + }, + ) + .unwrap(); + let err = store + .import_workspace(import_req(&seed, generous(), EffectCancellation::new())) + .unwrap_err(); + assert_simulated_io(&err, "ENOSPC"); + let leaked = dir_names(&store.root().join("trees")); + assert!( + leaked.is_empty(), + "post-rename import fsync failure left an unregistered tree: {leaked:?}; error={err}" + ); + assert!(dir_names(&store.root().join("staging")).is_empty()); + import_ok(&store, &seed); +} + +#[cfg(feature = "workspace-io-fault")] +#[test] +fn failed_import_cleanup_retains_unregistered_tree_evidence() { + let root = scratch("io-import-cleanup"); + let seed = nested_seed(&root); + let store = EffectStore::create(root.join("store")).unwrap(); + store + .arm_simulated_io_fault( + rustscript_agent::effects::SimulatedIoFault::PostRenameChmod { + errno: rustscript_agent::effects::SimulatedIoFault::EIO, + }, + ) + .unwrap(); + store + .arm_simulated_io_fault(rustscript_agent::effects::SimulatedIoFault::OwnedCleanup { + errno: rustscript_agent::effects::SimulatedIoFault::ENOSPC, + }) + .unwrap(); + let err = store + .import_workspace(import_req(&seed, generous(), EffectCancellation::new())) + .unwrap_err(); + let text = err.to_string(); + assert!( + text.contains("simulated I/O error"), + "primary fault must stay simulated: {text}" + ); + assert!( + text.contains("owned tree cleanup failed"), + "cleanup failure must stay explicit, not be reported as a clean failure: {text}" + ); + assert!( + text.contains("ENOSPC"), + "cleanup fault must be labeled simulated ENOSPC, not a disk-full event: {text}" + ); + let leaked = dir_names(&store.root().join("trees")); + assert_eq!( + leaked.len(), + 1, + "failed cleanup must retain the unregistered tree as evidence, not pretend it is gone: {leaked:?}" + ); + assert!(dir_names(&store.root().join("staging")).is_empty()); +} + +#[cfg(feature = "workspace-io-fault")] +#[test] +fn failed_publish_post_rename_chmod_keeps_head_writer_and_removes_new_tree() { + let root = scratch("io-publish-chmod"); + let seed = nested_seed(&root); + let store = EffectStore::create(root.join("store")).unwrap(); + let handle = import_ok(&store, &seed); + let head = handle.head(); + let epoch = handle.epoch(); + let journal = handle.journal(); + let trees = dir_names(&store.root().join("trees")); + let committed = store.root().join("trees").join(head.as_str()); + let committed_mode = mode_bits(&committed); + let view = open_ok(&handle); + view.write_file("sub/a.txt", b"next").unwrap(); + let view_mode = mode_bits(&view.work_root().join("sub").join("a.txt")); + view.arm_simulated_io_fault( + rustscript_agent::effects::SimulatedIoFault::PostRenameChmod { + errno: rustscript_agent::effects::SimulatedIoFault::EIO, + }, + ) + .unwrap(); + let EffectFailure { error, recovered } = handle + .publish(view, publish_request(&handle)) + .expect_err("chmod after rename must fail"); + assert_simulated_io(&error, "EIO"); + assert_eq!(handle.head(), head); + assert_eq!(handle.epoch(), epoch); + assert_eq!(handle.journal(), journal); + assert_eq!( + dir_names(&store.root().join("trees")), + trees, + "post-rename publish failure left an unregistered tree; error={error}" + ); + assert!(dir_names(&store.root().join("staging")).is_empty()); + assert_eq!(mode_bits(&committed), committed_mode); + assert_eq!(handle.read_committed("sub/a.txt").unwrap(), b"kept"); + assert_eq!( + recovered.read_file("sub/a.txt").unwrap(), + b"next", + "failed publish must return the original writer view" + ); + assert_eq!( + mode_bits(&recovered.work_root().join("sub").join("a.txt")), + view_mode + ); + assert_eq!( + checkpoint_now(&handle).unwrap_err().kind(), + EffectErrorKind::CheckpointBusy + ); + assert!(private_file_residue(&recovered).is_empty()); + handle + .publish(recovered, publish_request(&handle)) + .expect("same view retries after the one-shot fault"); + assert_ne!(handle.head(), head); + assert_eq!(handle.epoch(), epoch); + assert_eq!(handle.read_committed("sub/a.txt").unwrap(), b"next"); + assert!(dir_names(&store.root().join("staging")).is_empty()); + checkpoint_now(&handle).expect("successful publish released the writer"); +} + +#[cfg(feature = "workspace-io-fault")] +#[test] +fn failed_publish_post_rename_fsync_drop_discards_view_without_new_tree() { + let root = scratch("io-publish-fsync"); + let seed = nested_seed(&root); + let store = EffectStore::create(root.join("store")).unwrap(); + let handle = import_ok(&store, &seed); + let head = handle.head(); + let epoch = handle.epoch(); + let journal = handle.journal(); + let trees = dir_names(&store.root().join("trees")); + let view = open_ok(&handle); + let view_dir = view.work_root().parent().unwrap().to_path_buf(); + view.arm_simulated_io_fault( + rustscript_agent::effects::SimulatedIoFault::PostRenameDirFsync { + errno: rustscript_agent::effects::SimulatedIoFault::ENOSPC, + }, + ) + .unwrap(); + let EffectFailure { error, recovered } = handle + .publish(view, publish_request(&handle)) + .expect_err("dir fsync after rename must fail"); + assert_simulated_io(&error, "ENOSPC"); + assert_eq!(handle.head(), head); + assert_eq!(handle.epoch(), epoch); + assert_eq!(handle.journal(), journal); + assert_eq!( + dir_names(&store.root().join("trees")), + trees, + "post-rename publish fsync failure left an unregistered tree; error={error}" + ); + assert!(dir_names(&store.root().join("staging")).is_empty()); + assert!( + view_dir.exists(), + "writer view must survive the failed publish" + ); + drop(recovered); + assert!(!view_dir.exists(), "drop must remove the view directory"); + assert_eq!(handle.head(), head); + assert_eq!(handle.journal(), journal); + checkpoint_now(&handle).expect("drop released the writer"); + let again = open_ok(&handle); + handle.discard(again).unwrap(); + assert_eq!(handle.head(), head); + assert_eq!(handle.epoch(), epoch); + assert!(dir_names(&store.root().join("views")).is_empty()); +} + +const STALE_USAGE: &str = "InconsistentSource: accounted usage does not match the destination"; + +fn assert_stale_usage(err: &EffectError) { + assert_eq!(err.kind(), EffectErrorKind::InconsistentSource); + assert_eq!(err.to_string(), STALE_USAGE); +} + +fn plant_unaccounted_file(view: &WritableView) -> (PathBuf, Vec, u32) { + let dest = view.work_root().join("ext.txt"); + let bytes = b"external".to_vec(); + fs::write(&dest, &bytes).unwrap(); + let mut perms = fs::metadata(&dest).unwrap().permissions(); + perms.set_mode(0o640); + fs::set_permissions(&dest, perms).unwrap(); + let mode = fs::symlink_metadata(&dest).unwrap().mode() & 0o777; + assert_eq!(mode, 0o640); + (dest, bytes, mode) +} + +fn open_tight(store: &EffectStore, seed: &Path) -> (WorkspaceHandle, WritableView) { + let handle = store + .import_workspace(import_req( + seed, + ResourceQuota::new(2, 16, 4).unwrap(), + EffectCancellation::new(), + )) + .unwrap(); + let view = open_ok(&handle); + (handle, view) +} + +fn prove_usage_did_not_move(view: &WritableView) { + // Removing the external file is the test's reconciliation. Two later SDK + // writes fit only if the rejected operation left entries and bytes at zero. + // Aggregate counters still do not detect every external mutation. + view.write_file("a.txt", b"12345678").unwrap(); + view.write_file("b.txt", b"12345678").unwrap(); + let over = view.write_file("c.txt", b"1").unwrap_err(); + assert_eq!(over.kind(), EffectErrorKind::QuotaExceeded); + assert_eq!(view.read_file("a.txt").unwrap(), b"12345678"); + assert_eq!(view.read_file("b.txt").unwrap(), b"12345678"); + #[cfg(feature = "workspace-io-fault")] + assert_eq!(view.accounted_usage(), (2, 16)); +} + +#[test] +fn external_nonempty_overwrite_rejects_stale_usage() { + let root = scratch("stale-overwrite"); + let seed = root.join("seed"); + fs::create_dir_all(&seed).unwrap(); + let store = EffectStore::create(root.join("store")).unwrap(); + let (_handle, view) = open_tight(&store, &seed); + #[cfg(feature = "workspace-io-fault")] + assert_eq!(view.accounted_usage(), (0, 0)); + let (dest, bytes, mode) = plant_unaccounted_file(&view); + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + view.write_file("ext.txt", b"replaced") + })); + let err = match outcome { + Ok(Err(err)) => err, + Ok(Ok(())) => panic!("overwrite of an unaccounted file must fail before replacement"), + Err(_) => panic!("overwrite must return a typed error, not panic"), + }; + assert_stale_usage(&err); + assert_eq!(fs::read(&dest).unwrap(), bytes); + assert_eq!(fs::symlink_metadata(&dest).unwrap().mode() & 0o777, mode); + #[cfg(feature = "workspace-io-fault")] + assert_eq!(view.accounted_usage(), (0, 0)); + fs::remove_file(&dest).unwrap(); + prove_usage_did_not_move(&view); +} + +#[test] +fn external_nonempty_delete_rejects_stale_usage() { + let root = scratch("stale-delete"); + let seed = root.join("seed"); + fs::create_dir_all(&seed).unwrap(); + let store = EffectStore::create(root.join("store")).unwrap(); + let (_handle, view) = open_tight(&store, &seed); + #[cfg(feature = "workspace-io-fault")] + assert_eq!(view.accounted_usage(), (0, 0)); + let (dest, bytes, mode) = plant_unaccounted_file(&view); + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| view.delete("ext.txt"))); + let err = match outcome { + Ok(Err(err)) => err, + Ok(Ok(())) => panic!("delete of an unaccounted file must fail before unlink"), + Err(_) => panic!("delete must return a typed error, not panic"), + }; + assert_stale_usage(&err); + assert_eq!(fs::read(&dest).unwrap(), bytes); + assert_eq!(fs::symlink_metadata(&dest).unwrap().mode() & 0o777, mode); + #[cfg(feature = "workspace-io-fault")] + assert_eq!(view.accounted_usage(), (0, 0)); + fs::remove_file(&dest).unwrap(); + prove_usage_did_not_move(&view); +} + +#[cfg(feature = "workspace-io-fault")] +fn arm_publication_faults(view: &WritableView) { + // Both post-rename points belong to this view. A shared one-shot slot would + // let the first consumer hide the leak from the next owner. + view.arm_simulated_io_fault( + rustscript_agent::effects::SimulatedIoFault::PostRenameChmod { + errno: rustscript_agent::effects::SimulatedIoFault::EIO, + }, + ) + .unwrap(); + view.arm_simulated_io_fault( + rustscript_agent::effects::SimulatedIoFault::PostRenameDirFsync { + errno: rustscript_agent::effects::SimulatedIoFault::ENOSPC, + }, + ) + .unwrap(); +} + +#[cfg(feature = "workspace-io-fault")] +fn empty_seed(root: &Path, name: &str, body: &[u8]) -> PathBuf { + let seed = root.join(name); + fs::create_dir_all(&seed).unwrap(); + fs::write(seed.join("f.txt"), body).unwrap(); + seed +} + +#[cfg(feature = "workspace-io-fault")] +#[test] +fn discarded_view_publication_fault_does_not_reach_sibling_or_import() { + let root = scratch("io-fault-drop"); + let store = EffectStore::create(root.join("store")).unwrap(); + let handle_a = import_ok(&store, &empty_seed(&root, "seed-a", b"a-kept")); + let handle_b = import_ok(&store, &empty_seed(&root, "seed-b", b"b-kept")); + let head_b = handle_b.head(); + { + let view_a = open_ok(&handle_a); + arm_publication_faults(&view_a); + handle_a.discard(view_a).unwrap(); + } + let view_b = open_ok(&handle_b); + view_b.write_file("f.txt", b"b-next").unwrap(); + let sibling = handle_b.publish(view_b, publish_request(&handle_b)); + let later_seed = empty_seed(&root, "seed-c", b"c-kept"); + let later = store.import_workspace(import_req( + &later_seed, + generous(), + EffectCancellation::new(), + )); + let sibling_text = match &sibling { + Ok(_) => "ok".to_string(), + Err(failure) => failure.error.to_string(), + }; + let later_text = match &later { + Ok(_) => "ok".to_string(), + Err(err) => err.to_string(), + }; + assert!( + sibling.is_ok() && later.is_ok(), + "discarded view fault leaked: sibling={sibling_text} import={later_text}" + ); + assert_ne!(handle_b.head(), head_b); + assert_eq!(handle_b.read_committed("f.txt").unwrap(), b"b-next"); + assert_eq!(later.unwrap().read_committed("f.txt").unwrap(), b"c-kept"); + + store + .arm_simulated_io_fault( + rustscript_agent::effects::SimulatedIoFault::PostRenameChmod { + errno: rustscript_agent::effects::SimulatedIoFault::EIO, + }, + ) + .unwrap(); + drop(open_ok(&handle_a)); + let preserved = store + .import_workspace(import_req( + &empty_seed(&root, "seed-d", b"d-kept"), + generous(), + EffectCancellation::new(), + )) + .unwrap_err(); + assert_simulated_io(&preserved, "EIO"); +} + +#[cfg(feature = "workspace-io-fault")] +#[test] +fn concurrent_publication_fault_stays_on_arming_view() { + let root = scratch("io-fault-concurrent"); + let store = EffectStore::create(root.join("store")).unwrap(); + let handle_a = import_ok(&store, &empty_seed(&root, "seed-a", b"a-kept")); + let handle_b = import_ok(&store, &empty_seed(&root, "seed-b", b"b-kept")); + let head_a = handle_a.head(); + let epoch_a = handle_a.epoch(); + let head_b = handle_b.head(); + let view_a = open_ok(&handle_a); + let view_b = open_ok(&handle_b); + view_a.write_file("f.txt", b"a-next").unwrap(); + view_b.write_file("f.txt", b"b-next").unwrap(); + arm_publication_faults(&view_a); + let barrier = Arc::new(Barrier::new(2)); + let spawn = |handle: WorkspaceHandle, view: WritableView, barrier: Arc| { + thread::spawn( + #[allow(clippy::result_large_err)] + move || { + barrier.wait(); + handle.publish(view, publish_request(&handle)) + }, + ) + }; + let armed = spawn(handle_a.clone(), view_a, Arc::clone(&barrier)); + let sibling = spawn(handle_b.clone(), view_b, barrier); + let armed = armed.join().expect("arming publisher finished"); + let sibling = sibling.join().expect("sibling publisher finished"); + let armed_text = match &armed { + Ok(_) => "ok".to_string(), + Err(failure) => failure.error.to_string(), + }; + let sibling_text = match &sibling { + Ok(_) => "ok".to_string(), + Err(failure) => failure.error.to_string(), + }; + let Err(failure) = armed else { + panic!("only the arming view must fail, sibling={sibling_text}"); + }; + assert_simulated_io(&failure.error, "EIO"); + assert!( + sibling.is_ok(), + "sibling consumed the arming view's fault: armed={armed_text} sibling={sibling_text}" + ); + assert_eq!(failure.recovered.read_file("f.txt").unwrap(), b"a-next"); + assert_eq!(handle_a.head(), head_a); + assert_eq!(handle_a.epoch(), epoch_a); + assert_eq!(handle_a.read_committed("f.txt").unwrap(), b"a-kept"); + assert_ne!(handle_b.head(), head_b); + assert_eq!(handle_b.read_committed("f.txt").unwrap(), b"b-next"); + assert!(dir_names(&store.root().join("staging")).is_empty()); + assert!(store.root().join("trees").join(head_a.as_str()).is_dir()); + handle_a.discard(failure.recovered).unwrap(); + checkpoint_now(&handle_a).expect("failed publish kept the writer until discard"); + checkpoint_now(&handle_b).expect("sibling publish released its writer"); + let later = import_ok(&store, &empty_seed(&root, "seed-c", b"c-kept")); + assert_eq!(later.read_committed("f.txt").unwrap(), b"c-kept"); +} diff --git a/tests/plugin_graph_tests.rs b/tests/plugin_graph_tests.rs new file mode 100644 index 0000000..450870d --- /dev/null +++ b/tests/plugin_graph_tests.rs @@ -0,0 +1,841 @@ +//! Dependency lock, effect closure, and real closed RSS entry preparation. +//! +//! Service activation and control-plane storage are not part of this slice. + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use ring::rand::SystemRandom; +use ring::signature::{Ed25519KeyPair, KeyPair}; +use rustscript_agent::plugins::{ + InstallAuthorization, InstalledPackage, PackageRequirement, PackageStore, PackageTrust, + PluginErrorKind, PublisherKey, ResolveRequest, parse_plugin_manifest, resolve_installed, +}; + +static TEMP_SEQ: AtomicU64 = AtomicU64::new(0); + +fn temp_dir(label: &str) -> FixtureDir { + let root = + PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").expect("assigned test temp")); + let dir = root.join(format!( + "plugin-graph-{}-{}-{}", + std::process::id(), + label, + TEMP_SEQ.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&dir) + .unwrap_or_else(|err| panic!("fixture {} must be a new directory: {err}", dir.display())); + FixtureDir { path: dir } +} + +struct FixtureDir { + path: PathBuf, +} + +impl Drop for FixtureDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +impl std::ops::Deref for FixtureDir { + type Target = Path; + fn deref(&self) -> &Path { + &self.path + } +} + +fn assert_kind(err: rustscript_agent::plugins::PluginError, kind: PluginErrorKind) { + assert_eq!(err.kind, kind, "{err:?}"); + assert!( + !err.chain.is_empty(), + "typed error must carry a chain: {err:?}" + ); +} + +struct Signer { + pair: Ed25519KeyPair, + key: PublisherKey, +} + +fn signer() -> Signer { + let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap(); + let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap(); + let key = PublisherKey { + publisher: "org.rustscript".to_string(), + verifying_key: pair.public_key().as_ref().to_vec(), + }; + Signer { pair, key } +} + +fn auth(signer: &Signer, dev: Option) -> InstallAuthorization { + InstallAuthorization { + admin: true, + accepted_publishers: vec![signer.key.clone()], + unsigned_dev_space: dev, + } +} + +fn library_manifest(id: &str, version: &str, export: &str) -> String { + format!( + r#"format: rustscript.plugin/v1 +id: {id} +version: {version} +kind: library +runtime: ">=0.1.0, <0.2.0" +contract: agent.library/v1 +host_abi: agent.cap/v1 +entry: + path: {export}.rss + export: {export} +dependencies: [] +capabilities: [] +effects: + contract: agent.effects/v1 + entries: + {export}: + direct: [] + delegated: [] +"# + ) +} + +fn tool_manifest(id: &str, version: &str, deps: &str, effects: &str, capabilities: &str) -> String { + let capabilities = if capabilities.is_empty() { + "capabilities: []\n".to_string() + } else { + format!("capabilities:\n{capabilities}") + }; + let dependencies = if deps.is_empty() { + "dependencies: []\n".to_string() + } else { + format!("dependencies:\n{deps}") + }; + format!( + r#"format: rustscript.plugin/v1 +id: {id} +version: {version} +kind: tool +runtime: ">=0.1.0, <0.2.0" +contract: agent.tool/v1 +host_abi: agent.cap/v1 +entry: + path: invoke.rss + export: invoke +{dependencies}{capabilities}effects: + contract: agent.effects/v1 + entries: + invoke: +{effects}state: + scope: run + schema: 1 +"# + ) +} + +fn write_signed( + store: &PackageStore, + signer: &Signer, + dir: &Path, + manifest: &str, + files: &[(&str, &str)], +) { + fs::create_dir_all(dir).unwrap(); + fs::write(dir.join("manifest.yaml"), manifest).unwrap(); + for (name, body) in files { + fs::write(dir.join(name), body).unwrap(); + } + let payload = store.signature_payload(dir).unwrap(); + let signature = signer.pair.sign(&payload); + fs::write(dir.join("signature.ed25519"), signature.as_ref()).unwrap(); + store.install(dir, &auth(signer, None)).unwrap(); +} + +#[test] +fn missing_conflict_and_cycle_return_minimal_chains() { + let root = temp_dir("errors"); + let store = PackageStore::open(root.join("store")).unwrap(); + let signer = signer(); + write_signed( + &store, + &signer, + &root.join("lib1"), + &library_manifest("org.rustscript.lib.text", "1.0.0", "length"), + &[( + "length.rss", + "pub fn length(value: string) -> int { value.length }\n", + )], + ); + write_signed( + &store, + &signer, + &root.join("lib2"), + &library_manifest("org.rustscript.lib.text", "2.0.0", "length"), + &[( + "length.rss", + "pub fn length(value: string) -> int { value.length }\n", + )], + ); + let low = tool_manifest( + "org.rustscript.tools.low", + "1.0.0", + " - alias: text\n id: org.rustscript.lib.text\n version: \">=1.0.0, <2.0.0\"\n", + " direct: []\n delegated: []\n", + "", + ); + let high = tool_manifest( + "org.rustscript.tools.high", + "1.0.0", + " - alias: text\n id: org.rustscript.lib.text\n version: \">=2.0.0, <3.0.0\"\n", + " direct: []\n delegated: []\n", + "", + ); + write_signed( + &store, + &signer, + &root.join("low"), + &low, + &[("invoke.rss", "pub fn invoke(value: int) -> int { value }\n")], + ); + write_signed( + &store, + &signer, + &root.join("high"), + &high, + &[("invoke.rss", "pub fn invoke(value: int) -> int { value }\n")], + ); + let missing = store.resolve(&ResolveRequest { + roots: vec![PackageRequirement { + id: "org.rustscript.missing".to_string(), + version: "1.0.0".to_string(), + }], + host_runtime: "0.1.0".to_string(), + }); + assert_kind(missing.unwrap_err(), PluginErrorKind::MissingDependency); + + let conflict = store.resolve(&ResolveRequest { + roots: vec![ + PackageRequirement { + id: "org.rustscript.tools.low".to_string(), + version: "1.0.0".to_string(), + }, + PackageRequirement { + id: "org.rustscript.tools.high".to_string(), + version: "1.0.0".to_string(), + }, + ], + host_runtime: "0.1.0".to_string(), + }); + let err = conflict.unwrap_err(); + assert_kind(err.clone(), PluginErrorKind::Conflict); + let chain = err.chain.join("\n"); + assert!(chain.contains("org.rustscript.lib.text"), "{chain}"); + assert!( + chain.contains("<2.0.0") && chain.contains(">=2.0.0"), + "{chain}" + ); + + let left = tool_manifest( + "org.rustscript.tools.left", + "1.0.0", + " - alias: right\n id: org.rustscript.tools.right\n version: \"1.0.0\"\n", + " direct: []\n delegated:\n - dependency: right\n entry: invoke\n allow: []\n", + "", + ); + let right = tool_manifest( + "org.rustscript.tools.right", + "1.0.0", + " - alias: left\n id: org.rustscript.tools.left\n version: \"1.0.0\"\n", + " direct: []\n delegated:\n - dependency: left\n entry: invoke\n allow: []\n", + "", + ); + write_signed( + &store, + &signer, + &root.join("left"), + &left, + &[("invoke.rss", "pub fn invoke(value: int) -> int { value }\n")], + ); + write_signed( + &store, + &signer, + &root.join("right"), + &right, + &[("invoke.rss", "pub fn invoke(value: int) -> int { value }\n")], + ); + let cycle = store.resolve(&ResolveRequest { + roots: vec![PackageRequirement { + id: "org.rustscript.tools.left".to_string(), + version: "1.0.0".to_string(), + }], + host_runtime: "0.1.0".to_string(), + }); + assert_kind(cycle.unwrap_err(), PluginErrorKind::Cycle); +} + +#[test] +fn duplicate_version_candidates_are_ambiguous() { + let manifest = parse_plugin_manifest(&library_manifest( + "org.rustscript.lib.text", + "1.0.0", + "length", + )) + .unwrap(); + let sources = BTreeMap::from([( + "length.rss".to_string(), + b"pub fn length(value: string) -> int { value.length }\n".to_vec(), + )]); + let packages = vec![ + InstalledPackage::new("pkg-a", manifest.clone(), sources.clone()), + InstalledPackage::new("pkg-b", manifest, sources), + ]; + let err = resolve_installed( + &packages, + &ResolveRequest { + roots: vec![PackageRequirement { + id: "org.rustscript.lib.text".to_string(), + version: "1.0.0".to_string(), + }], + host_runtime: "0.1.0".to_string(), + }, + ) + .unwrap_err(); + assert_kind(err, PluginErrorKind::Ambiguous); +} + +#[test] +fn install_order_does_not_change_the_lock_and_closure_is_exact() { + let signer = signer(); + let build = |label: &str, order: &[&str]| { + let root = temp_dir(label); + let store = PackageStore::open(root.join("store")).unwrap(); + let files = BTreeMap::from([ + ( + "text", + ( + library_manifest("org.rustscript.lib.text", "1.0.0", "length"), + vec![( + "length.rss", + "pub fn length(value: string) -> int { value.length }\n", + )], + ), + ), + ( + "files", + ( + tool_manifest( + "org.rustscript.tools.files", + "1.0.0", + "", + " direct:\n - kind: workspace.fs.write\n scope: bound_workspace\n recovery: checkpoint\n delegated: []\n", + " - workspace.write\n", + ), + vec![( + "invoke.rss", + "pub fn invoke(token: string) -> map {\n cap::fs_write_atomic(token, \"a.txt\", \"*\", bytes::from_utf8(\"hi\"))\n}\n", + )], + ), + ), + ( + "caller", + ( + tool_manifest( + "org.rustscript.tools.caller", + "1.0.0", + " - alias: text\n id: org.rustscript.lib.text\n version: \">=1.0.0, <2.0.0\"\n - alias: files\n id: org.rustscript.tools.files\n version: \"1.0.0\"\n", + " direct: []\n delegated:\n - dependency: files\n entry: invoke\n allow:\n - kind: workspace.fs.write\n scope: bound_workspace\n recovery: checkpoint\n", + "", + ), + vec![( + "invoke.rss", + "pub fn invoke(value: string) -> int { value.length }\n", + )], + ), + ), + ]); + for name in order { + let (manifest, sources) = &files[name]; + write_signed(&store, &signer, &root.join(name), manifest, sources); + } + let lock = store + .resolve(&ResolveRequest { + roots: vec![PackageRequirement { + id: "org.rustscript.tools.caller".to_string(), + version: "1.0.0".to_string(), + }], + host_runtime: "0.1.0".to_string(), + }) + .unwrap(); + let closure = lock.closure_kinds("org.rustscript.tools.caller", "invoke"); + assert_eq!(closure, vec!["workspace.fs.write".to_string()]); + assert!(lock.semantic_yaml().contains("relation: plugin")); + assert!(lock.semantic_yaml().contains("relation: library")); + lock.semantic_yaml() + }; + let forward = build("forward", &["text", "files", "caller"]); + let reverse = build("reverse", &["caller", "files", "text"]); + assert_eq!(forward, reverse); +} + +#[test] +fn lock_persists_caller_entry_alias_requirement_and_distinct_provenance() { + let root = temp_dir("edge-fields"); + let store = PackageStore::open(root.join("store")).unwrap(); + let signer = signer(); + write_signed( + &store, + &signer, + &root.join("files"), + &tool_manifest( + "org.rustscript.tools.files", + "1.2.3", + "", + " direct:\n - kind: workspace.fs.write\n scope: bound_workspace\n recovery: checkpoint\n delegated: []\n", + " - workspace.write\n", + ), + &[( + "invoke.rss", + "pub fn invoke(token: string) -> map {\n cap::fs_write_atomic(token, \"a.txt\", \"*\", bytes::from_utf8(\"hi\"))\n}\n", + )], + ); + write_signed( + &store, + &signer, + &root.join("caller"), + &tool_manifest( + "org.rustscript.tools.caller", + "1.0.0", + " - alias: files\n id: org.rustscript.tools.files\n version: \">=1.0.0, <2.0.0\"\n", + " direct: []\n delegated:\n - dependency: files\n entry: invoke\n allow:\n - kind: workspace.fs.write\n scope: bound_workspace\n recovery: checkpoint\n", + "", + ), + &[( + "invoke.rss", + "pub fn invoke(value: string) -> int { value.length }\n", + )], + ); + let lock = store + .resolve(&ResolveRequest { + roots: vec![PackageRequirement { + id: "org.rustscript.tools.caller".to_string(), + version: "1.0.0".to_string(), + }], + host_runtime: "0.1.0".to_string(), + }) + .unwrap(); + let yaml = lock.semantic_yaml(); + assert!(yaml.contains("alias: files"), "{yaml}"); + assert!(yaml.contains("from_entry: invoke"), "{yaml}"); + assert!(yaml.contains("to_entry: invoke"), "{yaml}"); + assert!( + yaml.contains("version_requirement: \">=1.0.0, <2.0.0\""), + "{yaml}" + ); + assert!(yaml.contains("to_version: 1.2.3"), "{yaml}"); + assert!(yaml.contains("scope: bound_workspace"), "{yaml}"); + assert!( + !yaml.lines().any(|line| line.trim() == "via: delegated"), + "{yaml}" + ); +} + +#[test] +fn prepare_does_not_compile_store_bytes_replaced_after_install() { + let root = temp_dir("prepare-replaced"); + let store = PackageStore::open(root.join("store")).unwrap(); + let signer = signer(); + let source = "pub fn invoke(value: int) -> int { value + 1 }\n"; + write_signed( + &store, + &signer, + &root.join("tool"), + &tool_manifest( + "org.rustscript.tools.pure", + "1.0.0", + "", + " direct: []\n delegated: []\n", + "", + ), + &[("invoke.rss", source)], + ); + let lock = store + .resolve(&ResolveRequest { + roots: vec![PackageRequirement { + id: "org.rustscript.tools.pure".to_string(), + version: "1.0.0".to_string(), + }], + host_runtime: "0.1.0".to_string(), + }) + .unwrap(); + let artifact = root + .join("store/artifacts") + .join(&lock.packages[0].artifact_id); + fs::write( + artifact.join("sources/invoke.rss"), + "pub fn invoke(value: int) -> int { 0 }\n", + ) + .unwrap(); + let prepared = store.prepare(&lock).unwrap(); + assert_eq!( + prepared.captured_source("org.rustscript.tools.pure", "invoke.rss"), + Some(source.as_bytes()), + "prepare must compile the verified capture, not the replaced store bytes" + ); +} + +#[test] +fn undeclared_transitive_effect_is_rejected() { + let root = temp_dir("transitive"); + let store = PackageStore::open(root.join("store")).unwrap(); + let signer = signer(); + write_signed( + &store, + &signer, + &root.join("files"), + &tool_manifest( + "org.rustscript.tools.files", + "1.0.0", + "", + " direct:\n - kind: workspace.fs.write\n scope: bound_workspace\n recovery: checkpoint\n delegated: []\n", + " - workspace.write\n", + ), + &[( + "invoke.rss", + "pub fn invoke(token: string) -> map {\n cap::fs_write_atomic(token, \"a.txt\", \"*\", bytes::from_utf8(\"hi\"))\n}\n", + )], + ); + write_signed( + &store, + &signer, + &root.join("caller"), + &tool_manifest( + "org.rustscript.tools.caller", + "1.0.0", + " - alias: files\n id: org.rustscript.tools.files\n version: \"1.0.0\"\n", + " direct: []\n delegated:\n - dependency: files\n entry: invoke\n allow:\n - kind: workspace.fs.read\n scope: bound_workspace\n recovery: none\n", + "", + ), + &[("invoke.rss", "pub fn invoke(value: int) -> int { value }\n")], + ); + let err = store + .resolve(&ResolveRequest { + roots: vec![PackageRequirement { + id: "org.rustscript.tools.caller".to_string(), + version: "1.0.0".to_string(), + }], + host_runtime: "0.1.0".to_string(), + }) + .unwrap_err(); + assert_kind(err, PluginErrorKind::UndeclaredTransitiveEffect); +} + +#[test] +fn prepare_rejects_undeclared_direct_effect_unauthorized_host_and_pure_disguise() { + let root = temp_dir("prepare-deny"); + let store = PackageStore::open(root.join("store")).unwrap(); + let signer = signer(); + write_signed( + &store, + &signer, + &root.join("disguise"), + &tool_manifest( + "org.rustscript.tools.disguise", + "1.0.0", + "", + " direct: []\n delegated: []\n", + "", + ), + &[( + "invoke.rss", + "pub fn invoke(token: string) -> map {\n cap::fs_write_atomic(token, \"a.txt\", \"*\", bytes::from_utf8(\"hi\"))\n}\n", + )], + ); + let lock = store + .resolve(&ResolveRequest { + roots: vec![PackageRequirement { + id: "org.rustscript.tools.disguise".to_string(), + version: "1.0.0".to_string(), + }], + host_runtime: "0.1.0".to_string(), + }) + .unwrap(); + assert_eq!( + store.prepare(&lock).unwrap_err().kind, + PluginErrorKind::UndeclaredDirectEffect + ); + + write_signed( + &store, + &signer, + &root.join("sqlite"), + &tool_manifest( + "org.rustscript.tools.sqlite", + "1.0.0", + "", + " direct: []\n delegated: []\n", + "", + ), + &[( + "invoke.rss", + "use sqlite;\npub fn invoke(value: int) -> int { value }\n", + )], + ); + let lock = store + .resolve(&ResolveRequest { + roots: vec![PackageRequirement { + id: "org.rustscript.tools.sqlite".to_string(), + version: "1.0.0".to_string(), + }], + host_runtime: "0.1.0".to_string(), + }) + .unwrap(); + assert_eq!( + store.prepare(&lock).unwrap_err().kind, + PluginErrorKind::UnauthorizedHostImport + ); +} + +#[test] +fn prepare_uses_captured_source_and_compiles_the_closed_entry() { + let root = temp_dir("prepare-ok"); + let store = PackageStore::open(root.join("store")).unwrap(); + let signer = signer(); + let source = "pub fn invoke(value: int) -> int { value + 1 }\n"; + let dir = root.join("tool"); + write_signed( + &store, + &signer, + &dir, + &tool_manifest( + "org.rustscript.tools.pure", + "1.0.0", + "", + " direct: []\n delegated: []\n", + "", + ), + &[("invoke.rss", source)], + ); + fs::write( + dir.join("invoke.rss"), + "pub fn invoke(value: int) -> int { 0 }\n", + ) + .unwrap(); + let lock = store + .resolve(&ResolveRequest { + roots: vec![PackageRequirement { + id: "org.rustscript.tools.pure".to_string(), + version: "1.0.0".to_string(), + }], + host_runtime: "0.1.0".to_string(), + }) + .unwrap(); + let prepared = store.prepare(&lock).unwrap(); + assert_eq!( + prepared.captured_source("org.rustscript.tools.pure", "invoke.rss"), + Some(source.as_bytes()) + ); + assert!( + prepared + .entry_callable("org.rustscript.tools.pure") + .is_some() + ); + assert_eq!( + prepared.trust("org.rustscript.tools.pure"), + Some(PackageTrust::AcceptedPublisher) + ); +} + +#[test] +fn plugin_import_cannot_bypass_the_broker_boundary() { + let root = temp_dir("bypass"); + let store = PackageStore::open(root.join("store")).unwrap(); + let signer = signer(); + write_signed( + &store, + &signer, + &root.join("files"), + &tool_manifest( + "org.rustscript.tools.files", + "1.0.0", + "", + " direct:\n - kind: workspace.fs.write\n scope: bound_workspace\n recovery: checkpoint\n delegated: []\n", + " - workspace.write\n", + ), + &[( + "invoke.rss", + "pub fn invoke(token: string) -> map {\n cap::fs_write_atomic(token, \"a.txt\", \"*\", bytes::from_utf8(\"hi\"))\n}\n", + )], + ); + write_signed( + &store, + &signer, + &root.join("caller"), + &tool_manifest( + "org.rustscript.tools.caller", + "1.0.0", + " - alias: files\n id: org.rustscript.tools.files\n version: \"1.0.0\"\n", + " direct: []\n delegated:\n - dependency: files\n entry: invoke\n allow:\n - kind: workspace.fs.write\n scope: bound_workspace\n recovery: checkpoint\n", + "", + ), + &[( + "invoke.rss", + "use files::invoke::{invoke};\npub fn invoke(token: string) -> map { invoke(token) }\n", + )], + ); + let lock = store + .resolve(&ResolveRequest { + roots: vec![PackageRequirement { + id: "org.rustscript.tools.caller".to_string(), + version: "1.0.0".to_string(), + }], + host_runtime: "0.1.0".to_string(), + }) + .unwrap(); + assert_eq!( + store.prepare(&lock).unwrap_err().kind, + PluginErrorKind::PluginBoundaryBypass + ); +} + +fn prepare_policy_source( + manifest: &str, + source: &str, +) -> Result<(), rustscript_agent::plugins::PluginError> { + parse_plugin_manifest(manifest)?; + let root = temp_dir("completion-policy"); + let store = PackageStore::open(root.join("store")).unwrap(); + let signer = signer(); + write_signed( + &store, + &signer, + &root.join("package"), + manifest, + &[("invoke.rss", source)], + ); + let lock = store.resolve(&ResolveRequest { + roots: vec![PackageRequirement { + id: "org.rustscript.tools.policy".to_string(), + version: "1.0.0".to_string(), + }], + host_runtime: "0.1.0".to_string(), + })?; + store.prepare(&lock).map(|_| ()) +} + +#[test] +fn signed_tool_wait_requires_declared_bound_run_clock() { + let source = "pub fn invoke(value: int) -> int { agent::sleep_ms(value) }\n"; + let manifest = tool_manifest( + "org.rustscript.tools.policy", + "1.0.0", + "", + " direct:\n - kind: clock.read\n scope: bound_run\n recovery: none\n delegated: []\n", + " - clock.read\n", + ); + prepare_policy_source(&manifest, source).unwrap(); + let undeclared = tool_manifest( + "org.rustscript.tools.policy", + "1.0.0", + "", + " direct: []\n delegated: []\n", + "", + ); + assert_kind( + prepare_policy_source(&undeclared, source).unwrap_err(), + PluginErrorKind::UndeclaredDirectEffect, + ); + assert_kind( + prepare_policy_source( + &manifest.replace("scope: bound_run", "scope: bound_workspace"), + source, + ) + .unwrap_err(), + PluginErrorKind::UnknownEffect, + ); +} + +#[test] +fn signed_completion_protocol_is_role_fenced_and_generic_artifacts_stay_denied() { + let manifest = tool_manifest( + "org.rustscript.tools.policy", + "1.0.0", + "", + " direct: []\n delegated: []\n", + "", + ); + let result = "pub fn invoke(token: string) -> map { cap::artifact_put_result(token, bytes::from_utf8(\"result\"), {}) }\n"; + prepare_policy_source(&manifest, result).unwrap(); + let library = manifest + .replace("kind: tool", "kind: library") + .replace("contract: agent.tool/v1", "contract: agent.library/v1"); + let harness = manifest + .replace("kind: tool", "kind: harness") + .replace("contract: agent.tool/v1", "contract: agent.harness/v1"); + for source in [ + result, + "pub fn invoke(value: map) -> map { agent_runtime::tool_prepare(value) }\n", + "pub fn invoke(token: string) -> map { agent_runtime::tool_commit(token, {}) }\n", + "pub fn invoke(value: int) -> int { agent::sleep_ms(value) }\n", + ] { + // Harness packages are not an installable role in this manifest contract. + assert_kind( + prepare_policy_source(&harness, source).unwrap_err(), + PluginErrorKind::UnknownField, + ); + assert_kind( + prepare_policy_source(&library, source).unwrap_err(), + PluginErrorKind::UnauthorizedHostImport, + ); + } + for source in [ + "pub fn invoke(token: string) -> map { cap::artifact_put(token, bytes::from_utf8(\"result\"), {}) }\n", + "pub fn invoke(token: string) -> map { cap::artifact_get(token, \"id\") }\n", + "pub fn invoke(token: string) -> map { cap::artifact_reference(token, \"id\") }\n", + ] { + assert_kind( + prepare_policy_source(&manifest, source).unwrap_err(), + PluginErrorKind::UnauthorizedHostImport, + ); + } +} + +#[test] +fn resolver_budget_is_a_typed_error() { + let mut packages = Vec::new(); + for version in 0..33 { + let manifest = parse_plugin_manifest(&library_manifest( + "org.rustscript.lib.text", + &format!("1.0.{version}"), + "length", + )) + .unwrap(); + packages.push(InstalledPackage::new( + &format!("pkg-{version}"), + manifest, + BTreeMap::from([( + "length.rss".to_string(), + b"pub fn length(value: string) -> int { value.length }\n".to_vec(), + )]), + )); + } + let err = resolve_installed( + &packages, + &ResolveRequest { + roots: vec![PackageRequirement { + id: "org.rustscript.lib.text".to_string(), + version: ">=1.0.0".to_string(), + }], + host_runtime: "0.1.0".to_string(), + }, + ) + .unwrap_err(); + assert_eq!(err.kind, PluginErrorKind::BudgetExceeded); + assert!( + err.chain + .iter() + .any(|item: &String| item.contains("versions_per_package")), + "{err:?}" + ); +} diff --git a/tests/plugin_manifest_tests.rs b/tests/plugin_manifest_tests.rs new file mode 100644 index 0000000..9fa4c20 --- /dev/null +++ b/tests/plugin_manifest_tests.rs @@ -0,0 +1,611 @@ +//! Strict plugin manifest and immutable package-store boundary. +//! +//! These tests exercise the real parser and store. Schema agreement lives in +//! `plugin_schema_sync_tests`; activation and control-plane storage are absent. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use ring::rand::SystemRandom; +use ring::signature::{Ed25519KeyPair, KeyPair}; +use rustscript_agent::plugins::{ + InstallAuthorization, PackageStore, PluginErrorKind, PublisherKey, parse_plugin_manifest, +}; + +static TEMP_SEQ: AtomicU64 = AtomicU64::new(0); + +fn temp_dir(label: &str) -> FixtureDir { + let root = + PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").expect("assigned test temp")); + let dir = root.join(format!( + "plugin-manifest-{}-{}-{}", + std::process::id(), + label, + TEMP_SEQ.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir(&dir) + .unwrap_or_else(|err| panic!("fixture {} must be a new directory: {err}", dir.display())); + FixtureDir { path: dir } +} + +struct FixtureDir { + path: PathBuf, +} + +impl Drop for FixtureDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +impl std::ops::Deref for FixtureDir { + type Target = Path; + fn deref(&self) -> &Path { + &self.path + } +} + +fn pure_manifest() -> String { + r#"format: rustscript.plugin/v1 +id: org.rustscript.lib.text +version: 1.0.0 +kind: library +runtime: ">=0.1.0, <0.2.0" +contract: agent.library/v1 +host_abi: agent.cap/v1 +entry: + path: normalize.rss + export: normalize +dependencies: [] +capabilities: [] +effects: + contract: agent.effects/v1 + entries: + normalize: + direct: [] + delegated: [] +"# + .to_string() +} + +fn write_manifest() -> String { + r#"format: rustscript.plugin/v1 +id: org.rustscript.tools.files +version: 1.0.0 +kind: tool +runtime: ">=0.1.0, <0.2.0" +contract: agent.tool/v1 +host_abi: agent.cap/v1 +entry: + path: invoke.rss + export: invoke +dependencies: [] +capabilities: + - workspace.read + - workspace.write +effects: + contract: agent.effects/v1 + entries: + invoke: + direct: + - kind: workspace.fs.read + scope: bound_workspace + recovery: none + - kind: workspace.fs.write + scope: bound_workspace + recovery: checkpoint + delegated: [] +state: + scope: run + schema: 1 +"# + .to_string() +} + +fn assert_kind( + result: Result, + kind: PluginErrorKind, +) { + let err = result.expect_err("boundary must reject"); + assert_eq!(err.kind, kind, "{err:?}"); +} + +fn effect_manifest(id: &str, capability: &str, kind: &str, scope: &str, recovery: &str) -> String { + format!( + r#"format: rustscript.plugin/v1 +id: {id} +version: 1.0.0 +kind: tool +runtime: ">=0.1.0, <0.2.0" +contract: agent.tool/v1 +host_abi: agent.cap/v1 +entry: + path: invoke.rss + export: invoke +dependencies: [] +capabilities: + - {capability} +effects: + contract: agent.effects/v1 + entries: + invoke: + direct: + - kind: {kind} + scope: {scope} + recovery: {recovery} + delegated: [] +"# + ) +} + +#[test] +fn duplicate_manifest_field_is_rejected() { + let yaml = pure_manifest().replace( + "format: rustscript.plugin/v1\n", + "format: rustscript.plugin/v1\nformat: rustscript.plugin/v1\n", + ); + assert_kind( + parse_plugin_manifest(&yaml), + PluginErrorKind::DuplicateField, + ); +} + +#[test] +fn yaml_alias_tag_and_merge_are_rejected_before_json() { + assert_kind( + parse_plugin_manifest("anchor: &a 1\nformat: *a\n"), + PluginErrorKind::YamlAlias, + ); + assert_kind( + parse_plugin_manifest("format: !!str rustscript.plugin/v1\n"), + PluginErrorKind::YamlTag, + ); + assert_kind( + parse_plugin_manifest("<<: {format: rustscript.plugin/v1}\n"), + PluginErrorKind::YamlMerge, + ); +} + +#[test] +fn missing_effects_unknown_effect_and_forged_recovery_are_rejected() { + let missing = pure_manifest().replace( + "effects:\n contract: agent.effects/v1\n entries:\n normalize:\n direct: []\n delegated: []\n", + "", + ); + assert_kind( + parse_plugin_manifest(&missing), + PluginErrorKind::MissingEffects, + ); + let unknown = write_manifest().replace("workspace.fs.write", "filesystem"); + assert_kind( + parse_plugin_manifest(&unknown), + PluginErrorKind::UnknownEffect, + ); + let forged = write_manifest().replace( + "kind: workspace.fs.write\n scope: bound_workspace\n recovery: checkpoint", + "kind: delivery.send\n scope: bound_delivery\n recovery: checkpoint", + ); + assert_kind( + parse_plugin_manifest(&forged), + PluginErrorKind::ForgedRecovery, + ); +} + +#[test] +fn remote_write_and_process_spawn_cannot_claim_checkpoint_recovery() { + let remote = effect_manifest( + "org.rustscript.tools.remote", + "remote.write", + "remote.write", + "bound_run", + "checkpoint", + ); + assert_kind( + parse_plugin_manifest(&remote), + PluginErrorKind::ForgedRecovery, + ); + let process = effect_manifest( + "org.rustscript.tools.proc", + "process.spawn", + "process.spawn", + "bound_process", + "checkpoint", + ); + assert_kind( + parse_plugin_manifest(&process), + PluginErrorKind::ForgedRecovery, + ); + let replay = effect_manifest( + "org.rustscript.tools.clock", + "clock.read", + "clock.read", + "bound_run", + "replay", + ); + assert_kind( + parse_plugin_manifest(&replay), + PluginErrorKind::ForgedRecovery, + ); + assert!( + parse_plugin_manifest(&effect_manifest( + "org.rustscript.tools.remote", + "remote.write", + "remote.write", + "bound_run", + "none", + )) + .is_ok() + ); + assert!( + parse_plugin_manifest(&effect_manifest( + "org.rustscript.tools.proc", + "process.spawn", + "process.spawn", + "bound_process", + "none", + )) + .is_ok() + ); +} + +#[test] +fn extra_public_effect_entry_is_rejected() { + let extra = pure_manifest().replace( + " normalize:\n direct: []\n delegated: []\n", + " normalize:\n direct: []\n delegated: []\n other:\n direct: []\n delegated: []\n", + ); + assert_kind( + parse_plugin_manifest(&extra), + PluginErrorKind::UncoveredExport, + ); +} + +#[test] +fn uncovered_export_and_unknown_security_field_are_rejected() { + let uncovered = pure_manifest().replace("normalize:", "other:"); + assert_kind( + parse_plugin_manifest(&uncovered), + PluginErrorKind::UncoveredExport, + ); + let unknown = pure_manifest().replace( + "host_abi: agent.cap/v1\n", + "host_abi: agent.cap/v1\ngrant: true\n", + ); + assert_kind( + parse_plugin_manifest(&unknown), + PluginErrorKind::UnknownField, + ); +} + +#[test] +fn path_escape_in_entry_is_rejected() { + let escaped = pure_manifest().replace("path: normalize.rss", "path: ../secret.rss"); + assert_kind(parse_plugin_manifest(&escaped), PluginErrorKind::PathEscape); +} + +#[test] +fn explicit_empty_effects_and_restricted_write_entry_are_accepted() { + let pure = parse_plugin_manifest(&pure_manifest()).expect("pure entry"); + let entry = &pure.effects.entries["normalize"]; + assert!(entry.direct.is_empty()); + assert!(entry.delegated.is_empty()); + assert_eq!(pure.publisher, "org.rustscript"); + let write = parse_plugin_manifest(&write_manifest()).expect("restricted write"); + assert_eq!(write.effects.entries["invoke"].direct.len(), 2); + assert_eq!(write.state.as_ref().unwrap().scope, "run"); + assert!( + write + .declared_capabilities() + .iter() + .all(|cap: &String| cap.starts_with("workspace.")) + ); +} + +fn publisher_key() -> (Ed25519KeyPair, PublisherKey) { + let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap(); + let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap(); + let key = PublisherKey { + publisher: "org.rustscript".to_string(), + verifying_key: pair.public_key().as_ref().to_vec(), + }; + (pair, key) +} + +fn write_package(dir: &Path, manifest: &str, files: &[(&str, &str)]) { + fs::write(dir.join("manifest.yaml"), manifest).unwrap(); + for (name, body) in files { + let path = dir.join(name); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, body).unwrap(); + } +} + +fn signed_auth(key: PublisherKey) -> InstallAuthorization { + InstallAuthorization { + admin: true, + accepted_publishers: vec![key], + unsigned_dev_space: None, + } +} + +#[test] +fn missing_publisher_authorization_is_rejected() { + let root = temp_dir("store"); + let store = PackageStore::open(root.join("store")).unwrap(); + let package = root.join("pkg"); + fs::create_dir_all(&package).unwrap(); + write_package( + &package, + &pure_manifest(), + &[( + "normalize.rss", + "pub fn normalize(value: int) -> int { value }\n", + )], + ); + let unauthorized = InstallAuthorization { + admin: true, + accepted_publishers: vec![], + unsigned_dev_space: None, + }; + assert_kind( + store.install(&package, &unauthorized), + PluginErrorKind::PublisherUnauthorized, + ); +} + +#[test] +fn symlink_package_install_is_rejected() { + let root = temp_dir("symlink"); + let store = PackageStore::open(root.join("store")).unwrap(); + let package = root.join("pkg"); + fs::create_dir_all(&package).unwrap(); + write_package( + &package, + &pure_manifest(), + &[( + "normalize.rss", + "pub fn normalize(value: int) -> int { value }\n", + )], + ); + let (_pair, key) = publisher_key(); + let link = root.join("linked-pkg"); + std::os::unix::fs::symlink(&package, &link).unwrap(); + assert_kind( + store.install(&link, &signed_auth(key)), + PluginErrorKind::SymlinkDenied, + ); +} + +#[test] +fn signed_overwrite_is_rejected() { + let root = temp_dir("overwrite"); + let store = PackageStore::open(root.join("store")).unwrap(); + let package = root.join("pkg"); + fs::create_dir_all(&package).unwrap(); + write_package( + &package, + &pure_manifest(), + &[( + "normalize.rss", + "pub fn normalize(value: int) -> int { value }\n", + )], + ); + let (pair, key) = publisher_key(); + let signature = pair.sign(&store.signature_payload(&package).unwrap()); + fs::write(package.join("signature.ed25519"), signature.as_ref()).unwrap(); + let first = store.install(&package, &signed_auth(key.clone())).unwrap(); + assert_eq!( + first.trust, + rustscript_agent::plugins::PackageTrust::AcceptedPublisher + ); + assert_eq!(first.declared_capabilities(), Vec::::new()); + fs::write( + package.join("normalize.rss"), + "pub fn normalize(value: int) -> int { value + 1 }\n", + ) + .unwrap(); + let signature = pair.sign(&store.signature_payload(&package).unwrap()); + fs::write(package.join("signature.ed25519"), signature.as_ref()).unwrap(); + assert_kind( + store.install(&package, &signed_auth(key)), + PluginErrorKind::VersionOverwrite, + ); +} + +#[test] +fn fixture_cleanup_does_not_reuse_or_delete_a_sibling() { + let tmp = + PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").expect("assigned test temp")); + let sibling = tmp.join(format!("preexisting-sibling-{}", std::process::id())); + fs::write(&sibling, b"keep").unwrap(); + let first = temp_dir("reentry"); + let first_path = first.to_path_buf(); + let second = temp_dir("reentry"); + assert_ne!(first_path, second.to_path_buf()); + drop(first); + assert!( + !first_path.exists(), + "owned cleanup removes only the fixture" + ); + assert_eq!(fs::read(&sibling).unwrap(), b"keep"); + let third = temp_dir("reentry"); + assert_ne!(third.to_path_buf(), first_path); + let _ = fs::remove_file(&sibling); +} + +#[test] +fn unsigned_dev_import_stays_inside_granted_space_and_does_not_gain_capability() { + let root = temp_dir("dev"); + let dev = root.join("dev-space"); + let package = dev.join("pkg"); + fs::create_dir_all(&package).unwrap(); + write_package( + &package, + &write_manifest(), + &[("invoke.rss", "pub fn invoke(value: int) -> int { value }\n")], + ); + let store = PackageStore::open(root.join("store")).unwrap(); + let outside = InstallAuthorization { + admin: true, + accepted_publishers: vec![], + unsigned_dev_space: Some(root.join("other")), + }; + fs::create_dir_all(root.join("other")).unwrap(); + assert_kind( + store.install(&package, &outside), + PluginErrorKind::PublisherUnauthorized, + ); + let granted = InstallAuthorization { + admin: true, + accepted_publishers: vec![], + unsigned_dev_space: Some(dev), + }; + let revision = store.install(&package, &granted).unwrap(); + assert_eq!( + revision.trust, + rustscript_agent::plugins::PackageTrust::DevUnsigned + ); + assert_eq!( + revision.declared_capabilities(), + vec!["workspace.read".to_string(), "workspace.write".to_string()] + ); + assert!(revision.artifact_id.starts_with("pkg-")); +} + +fn artifact_dir(store_root: &Path, artifact_id: &str) -> PathBuf { + store_root.join("artifacts").join(artifact_id) +} + +#[test] +fn load_rejects_source_replaced_after_install() { + let root = temp_dir("replace-source"); + let package = root.join("pkg"); + fs::create_dir_all(&package).unwrap(); + write_package( + &package, + &pure_manifest(), + &[( + "normalize.rss", + "pub fn normalize(value: int) -> int { value }\n", + )], + ); + let store_root = root.join("store"); + let store = PackageStore::open(&store_root).unwrap(); + let granted = InstallAuthorization { + admin: true, + accepted_publishers: vec![], + unsigned_dev_space: Some(root.to_path_buf()), + }; + let revision = store.install(&package, &granted).unwrap(); + fs::write( + artifact_dir(&store_root, &revision.artifact_id).join("sources/normalize.rss"), + "pub fn normalize(value: int) -> int { 0 }\n", + ) + .unwrap(); + assert!( + store.load_installed().is_err(), + "replaced store bytes must not load as the installed artifact" + ); +} + +#[test] +fn load_rejects_manifest_symlink_substitution() { + let root = temp_dir("manifest-symlink"); + let package = root.join("pkg"); + fs::create_dir_all(&package).unwrap(); + write_package( + &package, + &pure_manifest(), + &[( + "normalize.rss", + "pub fn normalize(value: int) -> int { value }\n", + )], + ); + let store_root = root.join("store"); + let store = PackageStore::open(&store_root).unwrap(); + let granted = InstallAuthorization { + admin: true, + accepted_publishers: vec![], + unsigned_dev_space: Some(root.to_path_buf()), + }; + let revision = store.install(&package, &granted).unwrap(); + let dir = artifact_dir(&store_root, &revision.artifact_id); + let manifest = dir.join("manifest.yaml"); + let real = dir.join("manifest.yaml.real"); + fs::rename(&manifest, &real).unwrap(); + std::os::unix::fs::symlink(&real, &manifest).unwrap(); + assert!( + store.load_installed().is_err(), + "manifest symlink must not be followed" + ); +} + +#[test] +fn replaced_trust_label_does_not_become_publisher_proof() { + let root = temp_dir("trust-label"); + let package = root.join("pkg"); + fs::create_dir_all(&package).unwrap(); + write_package( + &package, + &pure_manifest(), + &[( + "normalize.rss", + "pub fn normalize(value: int) -> int { value }\n", + )], + ); + let store_root = root.join("store"); + let store = PackageStore::open(&store_root).unwrap(); + let granted = InstallAuthorization { + admin: true, + accepted_publishers: vec![], + unsigned_dev_space: Some(root.to_path_buf()), + }; + let revision = store.install(&package, &granted).unwrap(); + fs::write( + artifact_dir(&store_root, &revision.artifact_id).join("trust"), + "accepted-publisher", + ) + .unwrap(); + match store.load_installed() { + Ok(packages) => assert_eq!( + packages[0].trust, + rustscript_agent::plugins::PackageTrust::DevUnsigned + ), + Err(err) => assert_eq!(err.kind, PluginErrorKind::PublisherUnauthorized), + } +} + +#[test] +fn preexisting_artifact_is_not_reused_without_byte_equality() { + let root = temp_dir("preseed"); + let package = root.join("pkg"); + fs::create_dir_all(&package).unwrap(); + write_package( + &package, + &pure_manifest(), + &[( + "normalize.rss", + "pub fn normalize(value: int) -> int { value }\n", + )], + ); + let store_root = root.join("store"); + let store = PackageStore::open(&store_root).unwrap(); + let granted = InstallAuthorization { + admin: true, + accepted_publishers: vec![], + unsigned_dev_space: Some(root.to_path_buf()), + }; + let revision = store.install(&package, &granted).unwrap(); + fs::write( + artifact_dir(&store_root, &revision.artifact_id).join("sources/normalize.rss"), + "pub fn normalize(value: int) -> int { 9 }\n", + ) + .unwrap(); + assert!( + store.install(&package, &granted).is_err(), + "existing object must not be reused when its bytes differ" + ); +} diff --git a/tests/plugin_schema_sync_tests.rs b/tests/plugin_schema_sync_tests.rs new file mode 100644 index 0000000..6bdc3bc --- /dev/null +++ b/tests/plugin_schema_sync_tests.rs @@ -0,0 +1,507 @@ +//! Parser and YAML schema must describe the same contract. +//! +//! Negative documents that the strict YAML layer rejects before JSON are +//! parser obligations. Structural negatives must fail both the schema and the +//! parser. Schema identifiers are local and are not fetched. + +use std::fs; +use std::path::PathBuf; + +use rustscript_agent::plugins::{ + EFFECT_KINDS, EFFECT_RECOVERY, GRAPH_LOCK_FIELDS, MANIFEST_FIELDS, PLUGIN_KINDS, + parse_plugin_manifest, strict_yaml_to_json, +}; + +fn schema_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("schemas") +} + +fn load(name: &str) -> serde_json::Value { + let text = fs::read_to_string(schema_dir().join(name)) + .unwrap_or_else(|err| panic!("missing {name}: {err}")); + strict_yaml_to_json(&text).unwrap_or_else(|err| panic!("{name} is not strict YAML: {err:?}")) +} + +fn schema_accepts(name: &str, value: &serde_json::Value) -> bool { + let manifest = load("plugin-manifest.schema.yaml"); + let effects = load("effect-contract.schema.yaml"); + let lock = load("plugin-graph-lock.schema.yaml"); + for schema in [&manifest, &effects, &lock] { + jsonschema::draft202012::meta::validate(schema).expect("draft 2020-12 meta"); + } + let registry = jsonschema::Registry::new() + .add( + "https://schemas.rustscript.local/v1/plugin-manifest.schema.yaml", + manifest, + ) + .expect("register manifest") + .add( + "https://schemas.rustscript.local/v1/effect-contract.schema.yaml", + effects, + ) + .expect("register effects") + .add( + "https://schemas.rustscript.local/v1/plugin-graph-lock.schema.yaml", + lock, + ) + .expect("register lock") + .prepare() + .expect("local registry"); + let selected = load(name); + let validator = jsonschema::options() + .with_registry(®istry) + .build(&selected) + .expect("schema builds offline"); + validator.validate(value).is_ok() +} + +fn assert_schema_and_parser(name: &str, yaml: &str, accept: bool) { + let value = strict_yaml_to_json(yaml).expect("fixture is strict YAML"); + let schema_ok = schema_accepts(name, &value); + let parser_ok = parse_plugin_manifest(yaml).is_ok(); + assert_eq!(schema_ok, accept, "schema acceptance drifted for {yaml}"); + assert_eq!(parser_ok, accept, "parser acceptance drifted for {yaml}"); +} + +#[test] +fn schema_enums_and_fields_match_the_parser_contract() { + let manifest = load("plugin-manifest.schema.yaml"); + let properties: Vec = manifest["properties"] + .as_object() + .unwrap() + .keys() + .cloned() + .collect(); + assert_eq!( + properties, + MANIFEST_FIELDS + .iter() + .map(|item| (*item).to_string()) + .collect::>() + ); + let kinds: Vec = manifest["properties"]["kind"]["enum"] + .as_array() + .unwrap() + .iter() + .map(|item| item.as_str().unwrap().to_string()) + .collect(); + assert_eq!( + kinds, + PLUGIN_KINDS + .iter() + .map(|item| (*item).to_string()) + .collect::>() + ); + let effects = load("effect-contract.schema.yaml"); + let effect_kinds: Vec = effects["$defs"]["effect"]["properties"]["kind"]["enum"] + .as_array() + .unwrap() + .iter() + .map(|item| item.as_str().unwrap().to_string()) + .collect(); + assert_eq!( + effect_kinds, + EFFECT_KINDS + .iter() + .map(|item| (*item).to_string()) + .collect::>() + ); + let recovery: Vec = effects["$defs"]["effect"]["properties"]["recovery"]["enum"] + .as_array() + .unwrap() + .iter() + .map(|item| item.as_str().unwrap().to_string()) + .collect(); + assert_eq!( + recovery, + EFFECT_RECOVERY + .iter() + .map(|item| (*item).to_string()) + .collect::>() + ); + let lock = load("plugin-graph-lock.schema.yaml"); + let lock_fields: Vec = lock["properties"] + .as_object() + .unwrap() + .keys() + .cloned() + .collect(); + assert_eq!( + lock_fields, + GRAPH_LOCK_FIELDS + .iter() + .map(|item| (*item).to_string()) + .collect::>() + ); + assert!( + fs::read_to_string(schema_dir().join("README.md")) + .unwrap() + .contains("not fetched") + ); +} + +#[test] +fn positive_and_negative_examples_agree() { + assert_schema_and_parser( + "plugin-manifest.schema.yaml", + r#"format: rustscript.plugin/v1 +id: org.rustscript.lib.text +version: 1.0.0 +kind: library +runtime: ">=0.1.0, <0.2.0" +contract: agent.library/v1 +host_abi: agent.cap/v1 +entry: + path: normalize.rss + export: normalize +dependencies: [] +capabilities: [] +effects: + contract: agent.effects/v1 + entries: + normalize: + direct: [] + delegated: [] +"#, + true, + ); + assert_schema_and_parser( + "plugin-manifest.schema.yaml", + r#"format: rustscript.plugin/v1 +id: org.rustscript.lib.text +version: 1.0.0 +kind: service +runtime: ">=0.1.0, <0.2.0" +contract: agent.library/v1 +host_abi: agent.cap/v1 +entry: + path: normalize.rss + export: normalize +dependencies: [] +capabilities: [] +effects: + contract: agent.effects/v1 + entries: + normalize: + direct: [] + delegated: [] +"#, + false, + ); + assert_schema_and_parser( + "plugin-manifest.schema.yaml", + r#"format: rustscript.plugin/v1 +id: org.rustscript.tools.files +version: 1.0.0 +kind: tool +runtime: ">=0.1.0, <0.2.0" +contract: agent.tool/v1 +host_abi: agent.cap/v1 +entry: + path: invoke.rss + export: invoke +dependencies: [] +capabilities: + - workspace.write +effects: + contract: agent.effects/v1 + entries: + invoke: + direct: + - kind: filesystem + scope: bound_workspace + recovery: none + delegated: [] +"#, + false, + ); +} + +fn tool_effect(kind: &str, scope: &str, recovery: &str, capability: &str, path: &str) -> String { + format!( + r#"format: rustscript.plugin/v1 +id: org.rustscript.tools.sample +version: 1.0.0 +kind: tool +runtime: ">=0.1.0, <0.2.0" +contract: agent.tool/v1 +host_abi: agent.cap/v1 +entry: + path: {path} + export: invoke +dependencies: [] +capabilities: + - {capability} +effects: + contract: agent.effects/v1 + entries: + invoke: + direct: + - kind: {kind} + scope: {scope} + recovery: {recovery} + delegated: [] +"# + ) +} + +#[test] +fn recovery_scope_and_path_pairs_are_rejected_by_schema_and_parser() { + assert_schema_and_parser( + "plugin-manifest.schema.yaml", + &tool_effect( + "remote.write", + "bound_run", + "checkpoint", + "remote.write", + "invoke.rss", + ), + false, + ); + assert_schema_and_parser( + "plugin-manifest.schema.yaml", + &tool_effect( + "process.spawn", + "bound_process", + "checkpoint", + "process.spawn", + "invoke.rss", + ), + false, + ); + assert_schema_and_parser( + "plugin-manifest.schema.yaml", + &tool_effect( + "clock.read", + "bound_run", + "replay", + "clock.read", + "invoke.rss", + ), + false, + ); + assert_schema_and_parser( + "plugin-manifest.schema.yaml", + &tool_effect( + "workspace.fs.write", + "bound_run", + "checkpoint", + "workspace.write", + "invoke.rss", + ), + false, + ); + assert_schema_and_parser( + "plugin-manifest.schema.yaml", + &tool_effect( + "workspace.fs.read", + "bound_workspace", + "none", + "workspace.read", + "../secret.rss", + ), + false, + ); + assert_schema_and_parser( + "plugin-manifest.schema.yaml", + &tool_effect( + "remote.write", + "bound_run", + "none", + "remote.write", + "invoke.rss", + ), + true, + ); + assert_schema_and_parser( + "plugin-manifest.schema.yaml", + &tool_effect( + "process.spawn", + "bound_process", + "none", + "process.spawn", + "invoke.rss", + ), + true, + ); + assert_schema_and_parser( + "plugin-manifest.schema.yaml", + &tool_effect( + "workspace.fs.write", + "bound_workspace", + "checkpoint", + "workspace.write", + "invoke.rss", + ), + true, + ); +} + +#[test] +fn lock_schema_requires_entry_alias_requirement_and_distinct_provenance() { + let lock = load("plugin-graph-lock.schema.yaml"); + let required = lock["properties"]["edges"]["items"]["required"] + .as_array() + .unwrap(); + let fields: Vec<&str> = required.iter().map(|item| item.as_str().unwrap()).collect(); + assert!(fields.contains(&"alias"), "{fields:?}"); + assert!(fields.contains(&"from_entry"), "{fields:?}"); + assert!(fields.contains(&"to_entry"), "{fields:?}"); + assert!(fields.contains(&"version_requirement"), "{fields:?}"); + assert!(fields.contains(&"to_version"), "{fields:?}"); + let via = &lock["properties"]["closure"]["items"]["properties"]["effects"]["items"]["properties"] + ["via"]; + let collapsed = via + .get("enum") + .and_then(|item| item.as_array()) + .is_some_and(|items| items.iter().any(|item| item.as_str() == Some("delegated"))); + assert!( + !collapsed, + "via must not collapse delegated origins to one label: {via}" + ); +} + +#[test] +fn cross_field_and_malformed_constraints_stay_native_semantic_checks() { + let mismatch = r#"format: rustscript.plugin/v1 +id: org.rustscript.tools.files +version: 1.0.0 +kind: tool +runtime: ">=0.1.0, <0.2.0" +contract: agent.tool/v1 +host_abi: agent.cap/v1 +entry: + path: invoke.rss + export: invoke +dependencies: [] +capabilities: + - workspace.write +effects: + contract: agent.effects/v1 + entries: + invoke: + direct: + - kind: workspace.fs.read + scope: bound_workspace + recovery: none + delegated: [] +"#; + let value = strict_yaml_to_json(mismatch).unwrap(); + assert!( + schema_accepts("plugin-manifest.schema.yaml", &value), + "capability/effect equality is not a schema claim" + ); + assert!(parse_plugin_manifest(mismatch).is_err()); + let bad_runtime = + mismatch.replace("runtime: \">=0.1.0, <0.2.0\"", "runtime: \"not-a-version\""); + let runtime_value = strict_yaml_to_json(&bad_runtime).unwrap(); + assert!( + schema_accepts("plugin-manifest.schema.yaml", &runtime_value), + "semver constraint satisfaction is not a schema claim" + ); + assert!(parse_plugin_manifest(&bad_runtime).is_err()); + let bad_format = mismatch.replace( + "format: rustscript.plugin/v1", + "format: rustscript.plugin/v9", + ); + assert_schema_and_parser("plugin-manifest.schema.yaml", &bad_format, false); +} + +#[test] +fn canonical_snapshots_match_and_a_mutated_copy_is_rejected() { + let dir = schema_dir().join("canonical"); + rustscript_agent::plugins::schema_snapshot_drift(&dir).expect("checked-in snapshots"); + let produced = rustscript_agent::plugins::canonical_schema_documents().expect("producer"); + let checkpoint = produced + .get("checkpoint.schema.json") + .expect("joined producer covers schemas/v1/checkpoint.schema.yaml"); + let projected: serde_json::Value = serde_json::from_str(checkpoint).expect("canonical json"); + assert_eq!( + projected["$id"], + "https://rustscript.dev/schemas/v1/checkpoint.schema.yaml" + ); + assert_eq!( + projected["properties"]["plugin_state"]["$ref"], + "#/$defs/not_in_scope" + ); + let source = fs::read_to_string(schema_dir().join("v1/checkpoint.schema.yaml")).unwrap(); + assert!( + source.contains("additionalProperties: false"), + "projection must follow the existing checkpoint YAML, not a rewritten contract" + ); + let tmp = + PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").expect("assigned test temp")); + let drift = tmp.join(format!( + "schema-drift-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir(&drift).unwrap(); + for name in [ + "plugin-manifest.schema.json", + "effect-contract.schema.json", + "plugin-graph-lock.schema.json", + "plugin-activation.schema.json", + "plugin-graph-ref.schema.json", + "checkpoint.schema.json", + ] { + fs::copy(dir.join(name), drift.join(name)).unwrap(); + } + let mut mutated = fs::read_to_string(drift.join("plugin-manifest.schema.json")).unwrap(); + mutated.push_str(" \n"); + fs::write(drift.join("plugin-manifest.schema.json"), mutated).unwrap(); + assert!(rustscript_agent::plugins::schema_snapshot_drift(&drift).is_err()); + let _ = fs::remove_dir_all(&drift); +} + +fn control_schema_accepts(name: &str, value: &serde_json::Value) -> bool { + let schema = load(name); + jsonschema::draft202012::meta::validate(&schema).expect("draft 2020-12 meta"); + let validator = jsonschema::validator_for(&schema).expect("schema builds offline"); + validator.validate(value).is_ok() +} + +#[test] +fn activation_and_graph_ref_schemas_reject_authority_and_guest_owners() { + let activation = serde_json::json!({ + "graph_id": "graph-exact", + "generation": 1, + "previous_graph_id": "", + "activated_at_ms": 10 + }); + assert!(control_schema_accepts( + "plugin-activation.schema.yaml", + &activation + )); + let mut forged = activation.clone(); + forged["program"] = serde_json::json!("rebuilt"); + assert!(!control_schema_accepts( + "plugin-activation.schema.yaml", + &forged + )); + let reference = serde_json::json!({ + "graph_id": "graph-held", + "owner_kind": "run", + "owner_id": "run-1", + "ref_id": "ref-1" + }); + assert!(control_schema_accepts( + "plugin-graph-ref.schema.yaml", + &reference + )); + let mut guest = reference.clone(); + guest["owner_kind"] = serde_json::json!("guest"); + assert!(!control_schema_accepts( + "plugin-graph-ref.schema.yaml", + &guest + )); + let mut authority = reference.clone(); + authority["host_catalog"] = serde_json::json!(true); + assert!(!control_schema_accepts( + "plugin-graph-ref.schema.yaml", + &authority + )); +} diff --git a/tests/plugin_store_recovery_tests.rs b/tests/plugin_store_recovery_tests.rs new file mode 100644 index 0000000..41771f8 --- /dev/null +++ b/tests/plugin_store_recovery_tests.rs @@ -0,0 +1,1350 @@ +//! Exact plugin-control persistence through the production RSS SQLite path. +//! +//! These tests do not call the resolver, do not install a newer package, and +//! do not treat a persisted graph id as host-catalog authority. + +use std::fs; +use std::io::{Read, Write}; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::sync::{Arc, Barrier}; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use rustscript_agent::plugins::{ + ActivationPointer, COMPILE_ENVIRONMENT, EffectOrigin, GraphLock, GraphRetention, HOST_RUNTIME, + LockedEdge, LockedEffect, LockedEffectItem, LockedPackage, PackageArtifactRef, + PluginControlStore, ScopeBinding, ValidatedLockMetadata, +}; +use rustscript_agent::{ + AdmitRunRequest, AgentGatewayConfig, AgentGatewayState, AgentProviderHost, GatewayPersistence, + ScriptedProvider, +}; +use serde_json::json; + +fn db_path(label: &str) -> PathBuf { + let base = PathBuf::from( + std::env::var_os("RUSTSCRIPT_AGENT_TEST_TMPDIR") + .expect("RUSTSCRIPT_AGENT_TEST_TMPDIR must point into assigned TEMP"), + ); + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time after Unix epoch") + .as_nanos(); + let dir = base.join(format!( + "plugin-control-{label}-{}-{nonce}", + std::process::id() + )); + fs::create_dir_all(&dir).expect("assigned temp accepts the db directory"); + dir.join("gateway.db") +} + +fn open(path: &Path) -> GatewayPersistence { + GatewayPersistence::open(&AgentGatewayConfig::default(), path).expect("repository opens") +} + +fn origin(relation: &str, alias: &str, requirement: &str) -> EffectOrigin { + EffectOrigin { + relation: relation.into(), + caller_package: "org.example.tools".into(), + caller_entry: "invoke".into(), + alias: alias.into(), + callee_package: if relation == "direct" { + "org.example.tools".into() + } else { + "org.example.lib".into() + }, + callee_version: if relation == "direct" { + "1.2.3".into() + } else { + "0.4.1".into() + }, + callee_entry: "invoke".into(), + version_requirement: requirement.into(), + } +} + +fn sample_lock(graph_id: &str, tool_artifact: &str) -> GraphLock { + GraphLock { + graph_id: graph_id.into(), + host_runtime: HOST_RUNTIME.into(), + compile_environment: COMPILE_ENVIRONMENT.into(), + packages: vec![ + LockedPackage { + id: "org.example.tools".into(), + version: "1.2.3".into(), + kind: "tool".into(), + artifact_id: tool_artifact.into(), + }, + LockedPackage { + id: "org.example.lib".into(), + version: "0.4.1".into(), + kind: "library".into(), + artifact_id: "artifact-lib-exact".into(), + }, + ], + edges: vec![LockedEdge { + from: "org.example.tools".into(), + from_entry: "invoke".into(), + alias: "files".into(), + to: "org.example.lib".into(), + to_version: "0.4.1".into(), + to_artifact_id: "artifact-lib-exact".into(), + to_entry: "invoke".into(), + relation: "library".into(), + version_requirement: "^0.4.1".into(), + bindings: vec![ScopeBinding { + kind: "workspace.fs.read".into(), + scope: "bound_workspace".into(), + recovery: "none".into(), + }], + }], + closure: vec![LockedEffect { + package: "org.example.tools".into(), + entry: "invoke".into(), + effects: vec![ + LockedEffectItem { + kind: "workspace.fs.read".into(), + scope: "bound_workspace".into(), + recovery: "none".into(), + via: origin("direct", "", ""), + }, + LockedEffectItem { + kind: "workspace.fs.read".into(), + scope: "bound_workspace".into(), + recovery: "none".into(), + via: origin("library", "files", "^0.4.1"), + }, + ], + }], + } +} + +fn refs_of(lock: &GraphLock) -> Vec { + lock.packages + .iter() + .map(|package| PackageArtifactRef { + package_id: package.id.clone(), + version: package.version.clone(), + kind: package.kind.clone(), + artifact_id: package.artifact_id.clone(), + }) + .collect() +} + +fn admit(persistence: &GatewayPersistence) { + persistence + .admission_create(&json!({ + "session_id": "kept-session", + "session_new": 1, + "profile": "gateway", + "platform": "api_server", + "account_id": "kept-session", + "model": "m", + "provider": "p", + "system_prompt": "", + "session_metadata_json": "{}", + "run_id": "kept-run", + "parent_run_id": "", + "input_json": "{\"text\":\"keep\"}", + "message_id": "kept-message", + "message_run_id": "kept-run", + "script_hash": "s", + "idempotency_scope": "api:chat", + "idempotency_key": "", + "request_hash": "", + "workspace_identity_json": "", + "event_id": "kept-event", + "now_ms": 2_000_000, + "expires_at_ms": 0, + })) + .expect("existing admission row commits"); +} + +fn assert_session_kept(persistence: &GatewayPersistence) { + let session = persistence + .session_get("kept-session") + .expect("existing session remains readable"); + assert_eq!(session["rows"].as_array().map(Vec::len), Some(1)); + assert!( + session.to_string().contains("kept-session"), + "existing session row was not preserved: {session}" + ); +} + +fn code(err: &rustscript_agent::plugins::PluginControlError) -> &'static str { + err.code +} + +#[test] +fn exact_lock_survives_close_and_reopen_without_reresolve() { + let path = db_path("reopen"); + let persistence = open(&path); + let store = PluginControlStore::new(&persistence); + let original = sample_lock("graph-exact", "artifact-tool-old"); + let stored = store.store_lock(&original).expect("store exact lock"); + assert_eq!(stored.graph_id, "graph-exact"); + assert!(!stored.idempotent); + persistence.shutdown(); + drop(persistence); + + let reopened = open(&path); + let store = PluginControlStore::new(&reopened); + let ValidatedLockMetadata { lock } = store.load_lock("graph-exact").expect("load exact lock"); + assert_eq!(lock, original); + assert_eq!(lock.packages[0].version, "1.2.3"); + assert_eq!(lock.packages[0].artifact_id, "artifact-tool-old"); + assert_eq!(lock.packages[1].artifact_id, "artifact-lib-exact"); + assert_eq!( + store.package_refs("graph-exact").expect("refs"), + refs_of(&original) + ); + assert_eq!(store.activation().expect("inactive").generation, 0); + reopened.shutdown(); +} + +#[test] +fn identical_store_is_idempotent_and_changed_bytes_conflict() { + let path = db_path("idempotent"); + let persistence = open(&path); + let store = PluginControlStore::new(&persistence); + let original = sample_lock("graph-fixed", "artifact-a"); + store.store_lock(&original).expect("first store"); + let again = store.store_lock(&original).expect("identical store"); + assert!(again.idempotent); + assert_eq!(store.list_locks().expect("list").len(), 1); + let mut changed = original.clone(); + changed.packages[0].artifact_id = "artifact-b".into(); + let conflict = store.store_lock(&changed).expect_err("immutable id"); + assert_eq!(code(&conflict), "plugin_lock_conflict"); + let loaded = store.load_lock("graph-fixed").expect("original remains"); + assert_eq!(loaded.lock.packages[0].artifact_id, "artifact-a"); + persistence.shutdown(); +} + +#[test] +fn invalid_lock_writes_nothing() { + let path = db_path("invalid"); + let persistence = open(&path); + let store = PluginControlStore::new(&persistence); + let mut forged = sample_lock("graph-bad", "artifact-a"); + forged.compile_environment = "other".into(); + let schema_err = store.store_lock(&forged).expect_err("schema reject"); + assert_eq!(code(&schema_err), "plugin_lock_invalid"); + let mut illegal = sample_lock("graph-spawn", "artifact-a"); + illegal.closure[0].effects[0].kind = "process.spawn".into(); + illegal.closure[0].effects[0].scope = "bound_process".into(); + illegal.closure[0].effects[0].recovery = "checkpoint".into(); + let semantic = store.store_lock(&illegal).expect_err("recovery reject"); + assert_eq!(code(&semantic), "plugin_lock_invalid"); + let mut dangling = sample_lock("graph-edge", "artifact-a"); + dangling.edges[0].to = "org.example.missing".into(); + let reference = store.store_lock(&dangling).expect_err("dangling edge"); + assert_eq!(code(&reference), "plugin_lock_invalid"); + assert!(store.list_locks().expect("empty").is_empty()); + persistence.shutdown(); +} + +#[test] +fn activation_cas_history_and_concurrent_single_winner() { + let path = db_path("cas"); + let persistence = Arc::new(open(&path)); + let store = PluginControlStore::new(&persistence); + let first = sample_lock("graph-a", "artifact-a"); + let second = sample_lock("graph-b", "artifact-b"); + store.store_lock(&first).expect("store a"); + store.store_lock(&second).expect("store b"); + let stale = store + .activate("graph-a", 7, &refs_of(&first)) + .expect_err("stale generation"); + assert_eq!(code(&stale), "plugin_activation_conflict"); + assert_eq!(store.activation().expect("still empty").generation, 0); + assert!(store.activation_history().expect("no history").is_empty()); + + let barrier = Arc::new(Barrier::new(2)); + let left = thread::spawn({ + let persistence = Arc::clone(&persistence); + let barrier = Arc::clone(&barrier); + let refs = refs_of(&first); + move || { + barrier.wait(); + PluginControlStore::new(&persistence).activate("graph-a", 0, &refs) + } + }); + let right = thread::spawn({ + let persistence = Arc::clone(&persistence); + let barrier = Arc::clone(&barrier); + let refs = refs_of(&second); + move || { + barrier.wait(); + PluginControlStore::new(&persistence).activate("graph-b", 0, &refs) + } + }); + let left = left.join().expect("left thread"); + let right = right.join().expect("right thread"); + let outcomes = [left, right]; + assert_eq!(outcomes.iter().filter(|item| item.is_ok()).count(), 1); + assert_eq!( + outcomes + .iter() + .filter(|item| item + .as_ref() + .err() + .is_some_and(|err| err.code == "plugin_activation_conflict")) + .count(), + 1 + ); + let active = store.activation().expect("winner"); + assert_eq!(active.generation, 1); + assert!(active.graph_id == "graph-a" || active.graph_id == "graph-b"); + let history = store.activation_history().expect("history"); + assert_eq!(history.len(), 1); + assert_eq!(history[0].generation, 1); + assert_eq!(history[0].graph_id, active.graph_id); + assert_eq!(history[0].previous_graph_id, ""); + + let loser = if active.graph_id == "graph-a" { + "graph-b" + } else { + "graph-a" + }; + let switched = store + .activate( + loser, + 1, + &refs_of(&sample_lock( + loser, + if loser == "graph-a" { + "artifact-a" + } else { + "artifact-b" + }, + )), + ) + .expect("second generation"); + assert_eq!(switched.generation, 2); + let conflict = store + .activate("graph-a", 1, &refs_of(&first)) + .expect_err("stale after switch"); + assert_eq!(code(&conflict), "plugin_activation_conflict"); + let history = store.activation_history().expect("history after conflict"); + assert_eq!(history.len(), 2); + assert_eq!(history[1].generation, 2); + assert_eq!(history[1].previous_graph_id, active.graph_id); + assert_eq!(store.activation().expect("pointer").generation, 2); + assert_eq!( + store.load_lock("graph-a").expect("a remains").lock.graph_id, + "graph-a" + ); + assert_eq!( + store.load_lock("graph-b").expect("b remains").lock.graph_id, + "graph-b" + ); + persistence.shutdown(); +} + +#[test] +fn missing_refs_do_not_activate() { + let path = db_path("refs"); + let persistence = open(&path); + let store = PluginControlStore::new(&persistence); + let lock = sample_lock("graph-refs", "artifact-a"); + store.store_lock(&lock).expect("store"); + let missing_graph = store + .activate("graph-absent", 0, &[]) + .expect_err("unknown graph"); + assert_eq!(code(&missing_graph), "plugin_graph_missing"); + let missing_refs = store + .activate("graph-refs", 0, &[]) + .expect_err("empty refs"); + assert_eq!(code(&missing_refs), "plugin_package_ref_missing"); + let mut wrong = refs_of(&lock); + wrong[0].artifact_id = "artifact-other".into(); + let mismatch = store + .activate("graph-refs", 0, &wrong) + .expect_err("artifact mismatch"); + assert_eq!(code(&mismatch), "plugin_package_ref_missing"); + assert_eq!(store.activation().expect("unchanged").generation, 0); + let activated = store + .activate("graph-refs", 0, &refs_of(&lock)) + .expect("exact refs"); + assert_eq!( + activated, + ActivationPointer { + graph_id: "graph-refs".into(), + generation: 1, + updated_at_ms: activated.updated_at_ms, + } + ); + persistence.shutdown(); +} + +#[test] +fn partial_commit_rolls_back_and_preserves_existing_rows() { + let path = db_path("rollback"); + let persistence = open(&path); + admit(&persistence); + let store = PluginControlStore::new(&persistence); + let kept = sample_lock("graph-kept", "artifact-kept"); + store.store_lock(&kept).expect("store kept"); + store + .activate("graph-kept", 0, &refs_of(&kept)) + .expect("activate kept"); + persistence.inject_fail_plugin_control_partial(); + let failed = store + .store_lock(&sample_lock("graph-new", "artifact-new")) + .expect_err("partial store rolls back"); + assert_eq!(code(&failed), "plugin_control_unavailable"); + assert!(store.load_lock("graph-new").is_err()); + assert_eq!( + store + .load_lock("graph-kept") + .expect("kept lock") + .lock + .packages[0] + .artifact_id, + "artifact-kept" + ); + assert_eq!(store.activation().expect("pointer").graph_id, "graph-kept"); + assert_eq!(store.activation_history().expect("history").len(), 1); + assert_session_kept(&persistence); + persistence.shutdown(); + drop(persistence); + + let reopened = open(&path); + let store = PluginControlStore::new(&reopened); + assert!(store.load_lock("graph-new").is_err()); + assert_eq!(store.activation().expect("reopened pointer").generation, 1); + assert_session_kept(&reopened); + let pending = sample_lock("graph-pending", "artifact-pending"); + store.store_lock(&pending).expect("store pending"); + reopened.inject_fail_plugin_control_partial(); + let rolled = store + .activate("graph-pending", 1, &refs_of(&pending)) + .expect_err("partial activation rolls back"); + assert_eq!(code(&rolled), "plugin_control_unavailable"); + assert_eq!( + store.activation().expect("still kept").graph_id, + "graph-kept" + ); + assert_eq!(store.activation_history().expect("history intact").len(), 1); + reopened.shutdown(); +} + +#[test] +fn retention_survives_restart_and_gc_keeps_referenced_graphs() { + let path = db_path("gc"); + let persistence = open(&path); + let store = PluginControlStore::new(&persistence); + let active = sample_lock("graph-active", "artifact-active"); + let held = sample_lock("graph-held", "artifact-held"); + let loose = sample_lock("graph-loose", "artifact-loose"); + store.store_lock(&active).expect("store active"); + store.store_lock(&held).expect("store held"); + store.store_lock(&loose).expect("store loose"); + store + .activate("graph-active", 0, &refs_of(&active)) + .expect("activate"); + let retention = GraphRetention { + graph_id: "graph-held".into(), + owner_kind: "run".into(), + owner_id: "run-1".into(), + ref_id: "ref-1".into(), + }; + assert!(!store.retain(&retention).expect("retain")); + assert!(store.retain(&retention).expect("idempotent retain")); + let bad = GraphRetention { + graph_id: "graph-held".into(), + owner_kind: "guest".into(), + owner_id: "run-1".into(), + ref_id: "ref-2".into(), + }; + assert_eq!( + code(&store.retain(&bad).expect_err("owner kind")), + "plugin_ref_invalid" + ); + persistence.shutdown(); + drop(persistence); + + let reopened = open(&path); + let store = PluginControlStore::new(&reopened); + let collected = store.collect_unreferenced().expect("collect loose"); + assert_eq!(collected, vec!["graph-loose".to_string()]); + assert!(store.load_lock("graph-loose").is_err()); + assert_eq!( + store + .load_lock("graph-active") + .expect("active kept") + .lock + .graph_id, + "graph-active" + ); + assert_eq!( + store + .load_lock("graph-held") + .expect("held kept") + .lock + .graph_id, + "graph-held" + ); + store.release(&retention).expect("release"); + let collected = store.collect_unreferenced().expect("collect held"); + assert_eq!(collected, vec!["graph-held".to_string()]); + assert!(store.load_lock("graph-held").is_err()); + let active_ref = GraphRetention { + graph_id: "graph-active".into(), + owner_kind: "checkpoint".into(), + owner_id: "checkpoint-1".into(), + ref_id: "ref-active".into(), + }; + store.retain(&active_ref).expect("retain active"); + assert!( + store + .collect_unreferenced() + .expect("active retained") + .is_empty() + ); + store.release(&active_ref).expect("release active ref"); + assert!( + store + .collect_unreferenced() + .expect("active pointer") + .is_empty(), + "the active graph is not collected" + ); + assert_eq!( + store.activation().expect("still active").graph_id, + "graph-active" + ); + reopened.shutdown(); +} + +#[test] +fn approval_storage_does_not_create_plugin_control_rows() { + let path = db_path("approval-fence"); + let persistence = open(&path); + admit(&persistence); + persistence + .approval_request(&json!({ + "id": "plugin.lock_put", + "run_id": "kept-run", + "session_id": "kept-session", + "tool_call_id": "tool-1", + "tool_name": "plugin.lock_put", + "arguments_json": "{}", + "risk_class": "read", + "decision_scope": "", + "one_time": 0, + "requested_at_ms": 2_000_000, + "expires_at_ms": 0, + })) + .expect("approval storage accepts its own operation"); + let store = PluginControlStore::new(&persistence); + assert!(store.list_locks().expect("no locks").is_empty()); + assert_eq!(store.activation().expect("no activation").generation, 0); + assert_session_kept(&persistence); + persistence.shutdown(); +} + +#[test] +fn loaded_metadata_does_not_grant_execution_authority() { + let path = db_path("authority"); + let persistence = open(&path); + let store = PluginControlStore::new(&persistence); + let lock = sample_lock("graph-meta", "artifact-meta"); + store.store_lock(&lock).expect("store"); + let ValidatedLockMetadata { lock: loaded } = store.load_lock("graph-meta").expect("load"); + assert_eq!(loaded, lock); + assert_eq!( + store + .activation() + .expect("load is not activation") + .generation, + 0 + ); + assert!(store.activation_history().expect("no history").is_empty()); + persistence.shutdown(); +} + +#[test] +fn conflicting_live_ref_is_not_idempotent_and_release_does_not_delete_other_ref() { + let path = db_path("ref-identity"); + let persistence = open(&path); + let store = PluginControlStore::new(&persistence); + let lock = sample_lock("graph-ref-id", "artifact-ref-id"); + store.store_lock(&lock).expect("store"); + let live = GraphRetention { + graph_id: "graph-ref-id".into(), + owner_kind: "run".into(), + owner_id: "run-1".into(), + ref_id: "ref-live".into(), + }; + assert!(!store.retain(&live).expect("first retain")); + let other = GraphRetention { + ref_id: "ref-other".into(), + ..live.clone() + }; + let conflict = store + .retain(&other) + .expect_err("different live ref is not an idempotent retain"); + assert_eq!(code(&conflict), "plugin_ref_invalid"); + assert_eq!( + store.retained("graph-ref-id").expect("rows"), + vec![live.clone()] + ); + let release_other = store + .release(&other) + .expect_err("different live ref is not an idempotent release"); + assert_eq!(code(&release_other), "plugin_ref_invalid"); + assert_eq!( + store.retained("graph-ref-id").expect("other release"), + vec![live.clone()] + ); + assert!(store.collect_unreferenced().expect("held").is_empty()); + store.release(&live).expect("release live ref"); + assert!(store.retained("graph-ref-id").expect("released").is_empty()); + store + .release(&live) + .expect("absent requested identity stays idempotent"); + assert_eq!( + store.collect_unreferenced().expect("collect"), + vec!["graph-ref-id".to_string()] + ); + persistence.shutdown(); +} + +#[test] +fn retain_after_collect_between_preread_and_commit_is_not_success() { + let path = db_path("retain-collected"); + let persistence = Arc::new(open(&path)); + let store = PluginControlStore::new(&persistence); + store + .store_lock(&sample_lock("graph-raced", "artifact-raced")) + .expect("store"); + let retention = GraphRetention { + graph_id: "graph-raced".into(), + owner_kind: "checkpoint".into(), + owner_id: "checkpoint-1".into(), + ref_id: "ref-raced".into(), + }; + let pause = persistence.arm_plugin_retain_precommit_pause(); + let worker = thread::spawn({ + let persistence = Arc::clone(&persistence); + move || PluginControlStore::new(&persistence).retain(&retention) + }); + pause.wait_entered(); + assert_eq!( + PluginControlStore::new(&persistence) + .collect_unreferenced() + .expect("collect during stale pre-read"), + vec!["graph-raced".to_string()] + ); + pause.release(); + let missing = worker + .join() + .expect("retain thread") + .expect_err("graph collected before retain commit must not succeed"); + assert_eq!(code(&missing), "plugin_graph_missing"); + assert!( + PluginControlStore::new(&persistence) + .load_lock("graph-raced") + .is_err() + ); + assert!( + PluginControlStore::new(&persistence) + .retained("graph-raced") + .expect("no ref") + .is_empty() + ); + persistence.shutdown(); +} + +#[test] +fn concurrent_distinct_refs_keep_one_live_identity() { + let path = db_path("retain-race"); + let persistence = Arc::new(open(&path)); + let store = PluginControlStore::new(&persistence); + store + .store_lock(&sample_lock("graph-slot", "artifact-slot")) + .expect("store"); + let _precommit_gate = persistence.arm_plugin_control_precommit_gate(2); + let run = |ref_id: &'static str| { + let persistence = Arc::clone(&persistence); + thread::spawn(move || { + PluginControlStore::new(&persistence).retain(&GraphRetention { + graph_id: "graph-slot".into(), + owner_kind: "run".into(), + owner_id: "run-1".into(), + ref_id: ref_id.into(), + }) + }) + }; + let left = run("ref-left"); + let right = run("ref-right"); + let left = left.join().expect("left thread"); + let right = right.join().expect("right thread"); + persistence.disarm_plugin_control_precommit_gate(); + let outcomes = [left, right]; + assert_eq!(outcomes.iter().filter(|item| item.is_ok()).count(), 1); + assert_eq!( + outcomes + .iter() + .filter(|item| item + .as_ref() + .err() + .is_some_and(|err| err.code == "plugin_ref_invalid")) + .count(), + 1 + ); + let stored = store.retained("graph-slot").expect("one slot"); + assert_eq!(stored.len(), 1); + let winner = stored[0].ref_id.as_str(); + assert!(winner == "ref-left" || winner == "ref-right"); + let loser = if winner == "ref-left" { + "ref-right" + } else { + "ref-left" + }; + let release_loser = store + .release(&GraphRetention { + graph_id: "graph-slot".into(), + owner_kind: "run".into(), + owner_id: "run-1".into(), + ref_id: loser.into(), + }) + .expect_err("loser must not delete the live ref"); + assert_eq!(code(&release_loser), "plugin_ref_invalid"); + assert_eq!(store.retained("graph-slot").expect("still winner").len(), 1); + store.release(&stored[0]).expect("winner release"); + assert_eq!( + store.collect_unreferenced().expect("collect"), + vec!["graph-slot".to_string()] + ); + persistence.shutdown(); +} + +#[test] +fn same_graph_same_generation_activate_has_one_winner() { + let path = db_path("cas-same"); + let persistence = Arc::new(open(&path)); + let store = PluginControlStore::new(&persistence); + let lock = sample_lock("graph-cas", "artifact-cas"); + store.store_lock(&lock).expect("store"); + let refs = refs_of(&lock); + let _precommit_gate = persistence.arm_plugin_control_precommit_gate(2); + let spawn = || { + let persistence = Arc::clone(&persistence); + let refs = refs.clone(); + thread::spawn(move || PluginControlStore::new(&persistence).activate("graph-cas", 0, &refs)) + }; + let left = spawn(); + let right = spawn(); + let left = left.join().expect("left thread"); + let right = right.join().expect("right thread"); + persistence.disarm_plugin_control_precommit_gate(); + let outcomes = [left, right]; + assert_eq!(outcomes.iter().filter(|item| item.is_ok()).count(), 1); + assert_eq!( + outcomes + .iter() + .filter(|item| item + .as_ref() + .err() + .is_some_and(|err| err.code == "plugin_activation_conflict")) + .count(), + 1 + ); + let active = store.activation().expect("winner"); + assert_eq!(active.graph_id, "graph-cas"); + assert_eq!(active.generation, 1); + let history = store.activation_history().expect("history"); + assert_eq!(history.len(), 1); + assert_eq!(history[0].generation, 1); + assert_eq!(history[0].graph_id, "graph-cas"); + let winner = outcomes.into_iter().find_map(Result::ok).expect("winner"); + assert_eq!(winner.generation, 1); + assert_eq!(winner.graph_id, "graph-cas"); + persistence.shutdown(); +} + +#[test] +fn activate_receipt_survives_later_legitimate_activation() { + let path = db_path("cas-successor"); + let persistence = Arc::new(open(&path)); + let store = PluginControlStore::new(&persistence); + let lock = sample_lock("graph-receipt", "artifact-receipt"); + store.store_lock(&lock).expect("store"); + let refs = refs_of(&lock); + let hook_persistence = Arc::clone(&persistence); + let hook_refs = refs.clone(); + persistence.arm_plugin_activate_receipt_hook(move || { + PluginControlStore::new(&hook_persistence) + .activate("graph-receipt", 1, &hook_refs) + .expect("later legitimate activation"); + }); + let first = store + .activate("graph-receipt", 0, &refs) + .expect("this invocation returns its own receipt"); + assert_eq!(first.graph_id, "graph-receipt"); + assert_eq!(first.generation, 1); + let latest = store.activation().expect("latest pointer"); + assert_eq!(latest.generation, 2); + assert_eq!(latest.graph_id, "graph-receipt"); + assert_ne!(first.generation, latest.generation); + let history = store.activation_history().expect("both commits"); + assert_eq!(history.len(), 2); + assert_eq!(history[0].generation, 1); + assert_eq!(first.updated_at_ms, history[0].activated_at_ms); + persistence.shutdown(); +} + +#[tokio::test] +async fn guest_approval_storage_does_not_forward_plugin_control_ops() { + let path = db_path("guest-fence"); + let source = r#" +pub fn run(context: map) -> map { + let run_id: string = context["run_id"]; + let session_id: string = context["session_id"]; + let retain: map = agent::approval_storage("plugin.retain", { + run_id: run_id.copy(), + session_id: session_id.copy(), + graph_id: "graph-guest", + owner_kind: "run", + owner_id: "run-guest", + ref_id: "ref-guest" + }); + let activate: map = agent::approval_storage("plugin.activate", { + run_id: run_id.copy(), + session_id: session_id.copy(), + graph_id: "graph-guest", + expected_generation: 0 + }); + let lock_put: map = agent::approval_storage("plugin.lock_put", { + run_id: run_id.copy(), + session_id: session_id.copy(), + graph_id: "graph-guest" + }); + let release: map = agent::approval_storage("plugin.release", { + run_id: run_id.copy(), + session_id: session_id.copy(), + graph_id: "graph-guest", + owner_kind: "run", + owner_id: "run-guest", + ref_id: "ref-guest" + }); + let listed: map = agent::approval_storage("list", { + run_id: run_id.copy(), + session_id: session_id.copy() + }); + let retain_error: map = retain["error"]; + let activate_error: map = activate["error"]; + let lock_error: map = lock_put["error"]; + let release_error: map = release["error"]; + let retain_code: string = retain_error["code"]; + let activate_code: string = activate_error["code"]; + let lock_code: string = lock_error["code"]; + let release_code: string = release_error["code"]; + let list_ok: bool = listed["ok"]; + let mut list_flag: string = "0"; + if list_ok == true { + list_flag = "1"; + } + let answer: string = "retain=" + retain_code.copy() + ";activate=" + activate_code.copy() + ";lock=" + lock_code.copy() + ";release=" + release_code.copy() + ";list_ok=" + list_flag; + { kind: "run.completed", answer: answer } +} +"#; + let state = AgentGatewayState::with_agent_source_and_sqlite( + AgentGatewayConfig::default(), + source, + &path, + ) + .expect("guest fixture compiles through the production host catalog"); + let service = state.service(); + let provider = ScriptedProvider::new(); + let host: Arc = Arc::new(provider.clone()); + service.inject_provider_host(host); + let admitted = service + .admit(AdmitRunRequest { + input: json!({"message": "guest fence"}), + platform: "plugin_store_recovery".to_string(), + ..AdmitRunRequest::default() + }) + .await + .expect("admit"); + let persistence = state.persistence().expect("sqlite persistence"); + let store = PluginControlStore::new(persistence.as_ref()); + let locks_before = store.list_locks().expect("Native admission locks"); + let active_before = store.activation().expect("Native admission activation"); + let history_before = store + .activation_history() + .expect("Native admission history"); + assert_eq!( + locks_before.len(), + 1, + "Native default bootstrap owns one graph" + ); + assert_eq!(active_before.generation, 1); + Arc::clone(&service) + .run_worker(admitted.run_id.clone(), "ignored".to_string()) + .await; + assert_eq!( + provider.call_count(), + 0, + "plugin control must not reach the fake provider" + ); + assert_eq!(store.list_locks().expect("locks"), locks_before); + assert_eq!(store.activation().expect("activation"), active_before); + assert_eq!(store.activation_history().expect("history"), history_before); + assert!(store.retained("graph-guest").expect("refs").is_empty()); + let messages = persistence + .message_list(&admitted.session_id, 0) + .expect("messages"); + let rendered = messages.to_string(); + assert!( + rendered.contains("retain=approval_operation_invalid;activate=approval_operation_invalid;lock=approval_operation_invalid;release=approval_operation_invalid;list_ok=1"), + "guest host did not deny plugin control ops: {rendered}" + ); + persistence.shutdown(); +} + +const SEAM_CHILD_MARKER: &str = "RUSTSCRIPT_PLUGIN_STORE_SEAM_CHILD"; +const SEAM_CHILD_BOUND: Duration = Duration::from_secs(20); +const SEAM_CHILD_OUTPUT_LIMIT: usize = 64 * 1024; + +struct SeamOwnedChild(Option); + +impl SeamOwnedChild { + fn wait_before( + &mut self, + deadline: Instant, + ) -> std::io::Result> { + loop { + let child = self.0.as_mut().expect("owned seam child"); + match child.try_wait() { + Ok(Some(status)) => { + self.0.take(); + return Ok(Some(status)); + } + Ok(None) if Instant::now() >= deadline => { + self.kill_and_reap(); + return Ok(None); + } + Ok(None) => thread::sleep(Duration::from_millis(10)), + Err(error) => { + self.kill_and_reap(); + return Err(error); + } + } + } + } + + fn kill_and_reap(&mut self) { + if let Some(mut child) = self.0.take() { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + +impl Drop for SeamOwnedChild { + fn drop(&mut self) { + self.kill_and_reap(); + } +} + +struct SeamBoundedOutput { + bytes: Vec, + discarded: usize, +} + +fn capture_seam_output(mut reader: impl Read) -> std::io::Result { + let mut output = SeamBoundedOutput { + bytes: Vec::with_capacity(SEAM_CHILD_OUTPUT_LIMIT), + discarded: 0, + }; + let mut buffer = [0u8; 8192]; + loop { + match reader.read(&mut buffer) { + Ok(0) => return Ok(output), + Ok(read) => { + let keep = read.min(SEAM_CHILD_OUTPUT_LIMIT.saturating_sub(output.bytes.len())); + output.bytes.extend_from_slice(&buffer[..keep]); + output.discarded += read - keep; + } + Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue, + Err(error) => return Err(error), + } + } +} + +fn seam_child_is_current(test_name: &str) -> bool { + let Ok(marker) = std::env::var(SEAM_CHILD_MARKER) else { + return false; + }; + let mut parts = marker.splitn(3, ':'); + let (Some(marked_test), Some(parent_pid), Some(nonce)) = + (parts.next(), parts.next(), parts.next()) + else { + return false; + }; + if marked_test != test_name + || parent_pid.parse::().ok() != Some(unsafe { libc::getppid() }) + || uuid::Uuid::parse_str(nonce).is_err() + { + return false; + } + std::env::args() + .collect::>() + .windows(2) + .any(|args| args[0] == "--exact" && args[1] == test_name) +} + +/// Parent returns true after the exact child has passed. The child returns false +/// and runs the body. A hang is killed and reaped before drain threads are joined. +fn seam_parent_finished(test_name: &str) -> bool { + if seam_child_is_current(test_name) { + return false; + } + let deadline = Instant::now() + SEAM_CHILD_BOUND; + let nonce = uuid::Uuid::new_v4(); + let marker = format!("{test_name}:{}:{nonce}", std::process::id()); + let mut child = SeamOwnedChild(Some( + Command::new(std::env::current_exe().expect("seam test executable")) + .args(["--exact", test_name, "--nocapture"]) + .env_clear() + .envs( + [ + "PATH", + "HOME", + "XDG_CACHE_HOME", + "RUSTC", + "RUSTUP_HOME", + "RUSTUP_TOOLCHAIN", + "CARGO_HOME", + "LD_LIBRARY_PATH", + "CEF_PATH", + "TMPDIR", + "TEMP", + "TMP", + "TEMPDIR", + "TEST_TMPDIR", + "RUSTSCRIPT_AGENT_TEST_TMP", + "RUSTSCRIPT_AGENT_TEST_TMPDIR", + ] + .into_iter() + .filter_map(|key| std::env::var_os(key).map(|value| (key, value))), + ) + .env("CARGO_TERM_COLOR", "never") + .env(SEAM_CHILD_MARKER, marker) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn exact seam child"), + )); + let stdout = child + .0 + .as_mut() + .expect("owned seam child") + .stdout + .take() + .expect("seam child stdout"); + let stderr = child + .0 + .as_mut() + .expect("owned seam child") + .stderr + .take() + .expect("seam child stderr"); + let stdout_reader = thread::spawn(move || capture_seam_output(stdout)); + let stderr_reader = thread::spawn(move || capture_seam_output(stderr)); + let status = child.wait_before(deadline); + let stdout = stdout_reader + .join() + .expect("seam stdout drain joined") + .expect("seam stdout"); + let stderr = stderr_reader + .join() + .expect("seam stderr drain joined") + .expect("seam stderr"); + let stdout_text = String::from_utf8_lossy(&stdout.bytes); + let stderr_text = String::from_utf8_lossy(&stderr.bytes); + let output = format!("{stdout_text}\n{stderr_text}"); + let truncated = stdout.discarded + stderr.discarded; + let ran_exactly_one = output + .lines() + .filter(|line| line.trim() == "running 1 test") + .count() + == 1 + && output + .lines() + .filter(|line| line.trim() == format!("test {test_name} ... ok")) + .count() + == 1 + && output.lines().any(|line| { + line.trim() + .starts_with("test result: ok. 1 passed; 0 failed; 0 ignored;") + }); + let evidence: Vec<&str> = output + .lines() + .filter(|line| line.contains("SEAM_EVIDENCE")) + .collect(); + let status = status.expect("seam child wait"); + assert!( + matches!(status, Some(ref exit) if exit.success()) && ran_exactly_one && truncated == 0, + "exact seam child {test_name} failed: status={status:?} ran_exactly_one={ran_exactly_one} truncated={truncated} evidence={evidence:?}" + ); + true +} + +fn seam_note(phase: &str) { + eprintln!("SEAM_EVIDENCE phase={phase}"); + let _ = std::io::stderr().flush(); +} + +fn seam_retention(graph_id: &str, ref_id: &str) -> GraphRetention { + GraphRetention { + graph_id: graph_id.into(), + owner_kind: "run".into(), + owner_id: "run-seam".into(), + ref_id: ref_id.into(), + } +} + +#[test] +fn seam_retain_release_before_entry_still_reaches_command() { + let path = db_path("seam-release-before"); + let persistence = Arc::new(open(&path)); + let store = PluginControlStore::new(&persistence); + store + .store_lock(&sample_lock("graph-before", "artifact-before")) + .expect("store"); + let retention = seam_retention("graph-before", "ref-before"); + let pause = persistence.arm_plugin_retain_precommit_pause(); + pause.release(); + let worker = thread::spawn({ + let persistence = Arc::clone(&persistence); + let retention = retention.clone(); + move || PluginControlStore::new(&persistence).retain(&retention) + }); + let committed = worker + .join() + .expect("retain thread") + .expect("release before entry"); + assert!(!committed, "first retain is not idempotent"); + pause.wait_entered(); + assert_eq!( + store.retained("graph-before").expect("rows"), + vec![retention] + ); + persistence.shutdown(); +} + +#[test] +fn seam_retain_controller_drop_must_not_commit() { + let path = db_path("seam-drop"); + let persistence = Arc::new(open(&path)); + let store = PluginControlStore::new(&persistence); + store + .store_lock(&sample_lock("graph-drop", "artifact-drop")) + .expect("store"); + let retention = seam_retention("graph-drop", "ref-drop"); + let pause = persistence.arm_plugin_retain_precommit_pause(); + let worker = thread::spawn({ + let persistence = Arc::clone(&persistence); + let retention = retention.clone(); + move || PluginControlStore::new(&persistence).retain(&retention) + }); + pause.wait_entered(); + drop(pause); + let outcome = worker.join().expect("retain thread"); + let err = outcome.expect_err("controller drop after entered pause must not commit"); + assert_eq!(code(&err), "plugin_control_fixture_aborted"); + assert!(store.retained("graph-drop").expect("rows").is_empty()); + persistence.shutdown(); +} + +#[test] +fn seam_retain_controller_unwind_must_not_commit() { + let path = db_path("seam-unwind"); + let persistence = Arc::new(open(&path)); + let store = PluginControlStore::new(&persistence); + store + .store_lock(&sample_lock("graph-unwind", "artifact-unwind")) + .expect("store"); + let retention = seam_retention("graph-unwind", "ref-unwind"); + let pause = persistence.arm_plugin_retain_precommit_pause(); + let worker = thread::spawn({ + let persistence = Arc::clone(&persistence); + let retention = retention.clone(); + move || PluginControlStore::new(&persistence).retain(&retention) + }); + let unwound = catch_unwind(AssertUnwindSafe(move || { + pause.wait_entered(); + panic!("controller unwind"); + })); + assert!(unwound.is_err(), "controller panic must unwind"); + let outcome = worker.join().expect("retain thread"); + let err = outcome.expect_err("unwinding controller must not commit the paused retain"); + assert_eq!(code(&err), "plugin_control_fixture_aborted"); + assert!(store.retained("graph-unwind").expect("rows").is_empty()); + persistence.shutdown(); +} + +#[test] +fn seam_retain_forgotten_controller_is_bounded_fixture_fault() { + if seam_parent_finished("seam_retain_forgotten_controller_is_bounded_fixture_fault") { + return; + } + let path = db_path("seam-forget"); + let persistence = Arc::new(open(&path)); + let store = PluginControlStore::new(&persistence); + store + .store_lock(&sample_lock("graph-forget", "artifact-forget")) + .expect("store"); + let retention = seam_retention("graph-forget", "ref-forget"); + let pause = persistence.arm_plugin_retain_precommit_pause(); + let worker = thread::spawn({ + let persistence = Arc::clone(&persistence); + let retention = retention.clone(); + move || PluginControlStore::new(&persistence).retain(&retention) + }); + pause.wait_entered(); + seam_note("entered"); + std::mem::forget(pause); + let outcome = worker.join().expect("retain thread"); + let err = outcome.expect_err("forgotten controller must not commit"); + assert_eq!(code(&err), "plugin_control_fixture_timeout"); + assert!(store.retained("graph-forget").expect("rows").is_empty()); + persistence.shutdown(); +} + +#[test] +fn seam_gate_missing_precheck_participant_is_bounded_fixture_fault() { + if seam_parent_finished("seam_gate_missing_precheck_participant_is_bounded_fixture_fault") { + return; + } + let path = db_path("seam-missing-gate"); + let persistence = Arc::new(open(&path)); + let store = PluginControlStore::new(&persistence); + store + .store_lock(&sample_lock("graph-miss", "artifact-miss")) + .expect("store"); + let retention = seam_retention("graph-miss", "ref-miss"); + let _precommit_gate = persistence.arm_plugin_control_precommit_gate(2); + let pause = persistence.arm_plugin_retain_precommit_pause(); + let worker = thread::spawn({ + let persistence = Arc::clone(&persistence); + let retention = retention.clone(); + move || PluginControlStore::new(&persistence).retain(&retention) + }); + pause.wait_entered(); + seam_note("entered"); + let missing = + PluginControlStore::new(&persistence).retain(&seam_retention("graph-absent", "ref-absent")); + assert_eq!( + code(&missing.expect_err("missing graph")), + "plugin_graph_missing" + ); + seam_note("peer_precheck_failed"); + pause.release(); + seam_note("released_toward_gate"); + let outcome = worker.join().expect("retain thread"); + let err = outcome.expect_err("missing gate participant must not commit"); + assert!( + code(&err) == "plugin_control_fixture_timeout" + || code(&err) == "plugin_control_fixture_aborted", + "bounded fixture fault, got {}", + code(&err) + ); + assert!(store.retained("graph-miss").expect("rows").is_empty()); + persistence.disarm_plugin_control_precommit_gate(); + persistence.shutdown(); +} + +#[test] +fn seam_gate_extra_call_after_completion_does_not_wait() { + if seam_parent_finished("seam_gate_extra_call_after_completion_does_not_wait") { + return; + } + let path = db_path("seam-extra"); + let persistence = Arc::new(open(&path)); + let store = PluginControlStore::new(&persistence); + store + .store_lock(&sample_lock("graph-extra", "artifact-extra")) + .expect("store"); + let _precommit_gate = persistence.arm_plugin_control_precommit_gate(2); + let spawn_ref = |ref_id: &'static str| { + let persistence = Arc::clone(&persistence); + thread::spawn(move || { + PluginControlStore::new(&persistence).retain(&seam_retention("graph-extra", ref_id)) + }) + }; + let left = spawn_ref("ref-left"); + let right = spawn_ref("ref-right"); + let left = left.join().expect("left thread"); + let right = right.join().expect("right thread"); + assert_eq!([left, right].iter().filter(|item| item.is_ok()).count(), 1); + seam_note("first_round_done"); + let before = store.retained("graph-extra").expect("winner"); + assert_eq!(before.len(), 1); + let pause = persistence.arm_plugin_retain_precommit_pause(); + let third = spawn_ref("ref-third"); + pause.wait_entered(); + seam_note("entered"); + pause.release(); + seam_note("released_toward_gate"); + let outcome = third.join().expect("third thread"); + let err = outcome.expect_err("completed rendezvous must not start another wait"); + assert_eq!(code(&err), "plugin_control_fixture_closed"); + assert_eq!(store.retained("graph-extra").expect("unchanged"), before); + persistence.disarm_plugin_control_precommit_gate(); + persistence.shutdown(); +} + +#[test] +fn seam_activate_precheck_failure_drops_unconsumed_hook() { + let path = db_path("seam-hook-precheck"); + let persistence = Arc::new(open(&path)); + let captured = Arc::clone(&persistence); + persistence.arm_plugin_activate_receipt_hook(move || { + let _keep = &captured; + }); + let store = PluginControlStore::new(&persistence); + let failed = store + .activate("graph-hook-missing", 0, &[]) + .expect_err("precheck fails before the hook"); + assert_eq!(code(&failed), "plugin_graph_missing"); + assert_eq!( + Arc::strong_count(&persistence), + 1, + "unconsumed receipt hook retained persistence after precheck failure" + ); + persistence.shutdown(); +} + +#[test] +fn seam_activate_command_failure_drops_unconsumed_hook() { + let path = db_path("seam-hook-command"); + let persistence = Arc::new(open(&path)); + let store = PluginControlStore::new(&persistence); + let lock = sample_lock("graph-hook-cmd", "artifact-hook-cmd"); + store.store_lock(&lock).expect("store"); + let captured = Arc::clone(&persistence); + persistence.arm_plugin_activate_receipt_hook(move || { + let _keep = &captured; + }); + persistence.inject_fail_plugin_control_partial(); + let failed = store + .activate("graph-hook-cmd", 0, &refs_of(&lock)) + .expect_err("command fails before the hook is consumed"); + assert_eq!(code(&failed), "plugin_control_unavailable"); + assert_eq!(store.activation().expect("no pointer").generation, 0); + assert_eq!( + Arc::strong_count(&persistence), + 1, + "unconsumed receipt hook retained persistence after command failure" + ); + persistence.shutdown(); +} diff --git a/tests/provider_opaque_tool_loop_tests.rs b/tests/provider_opaque_tool_loop_tests.rs index 42bb84e..5baa4be 100644 --- a/tests/provider_opaque_tool_loop_tests.rs +++ b/tests/provider_opaque_tool_loop_tests.rs @@ -64,7 +64,7 @@ impl DurableToolLifecycle for ReadLifecycle { self.results .lock() .insert(call_id.to_string(), result.clone()); - Ok(json!({"ok": true, "kind": "committed", "call_id": call_id, "result": result})) + Ok(result.clone()) } fn interrupt(&self, _: &str) -> Result<(), LifecycleError> { Ok(()) @@ -148,10 +148,16 @@ fn tool_host(workspace: &Workspace, durable: Arc) -> AgentHostBri filesystem: Some(filesystem), processes: Some(processes), artifacts: Some(artifacts), + // Standalone SDK fixture: there is no service admission/persistent pin. + allow_stateless_pin: true, ..AgentHostBridges::default() } } fn run_tool_followup(retry: bool) { + run_tool_followup_with_mode(retry, true); +} + +fn run_tool_followup_with_mode(retry: bool, stateless: bool) { let home = FixtureHome::for_profile( "tool-followup", "openai", @@ -176,6 +182,7 @@ fn run_tool_followup(retry: bool) { }}]}), ); let mut bridges = tool_host(&workspace, durable.clone()); + bridges.allow_stateless_pin = stateless; bridges.auth_host_factory = Some(home.auth_factory(transport.clone())); let runner = AgentRunner::from_file( PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("rss/agent/main.rss"), @@ -196,6 +203,24 @@ fn run_tool_followup(retry: bool) { "input": {"message": "read note.txt"}, "config": {"max_retries": 2}, "limits": {"max_turns": 8, "max_tool_calls": 8} }))).expect("actual tool loop decision")); + if !stateless { + assert_eq!(decision["kind"], "run.failed"); + assert_eq!(decision["error"]["code"], "pinned_tool_missing"); + assert_eq!(durable.starts.load(Ordering::SeqCst), 0); + assert_eq!(durable.commits.load(Ordering::SeqCst), 0); + let sent = transport.take_sent(); + assert_eq!( + sent.len(), + 1, + "no provider followup after missing-pin denial" + ); + assert_eq!(sent[0].url, "https://api.openai.com/v1/chat/completions"); + assert_eq!( + sent[0].headers.get("authorization"), + Some(&format!("Bearer {ACCESS}")) + ); + return; + } assert_eq!( decision["kind"], json!("run.completed"), @@ -297,3 +322,8 @@ fn authenticated_tool_read_reaches_real_provider_followup() { fn rate_limited_followup_keeps_tool_result_without_reexecution() { run_tool_followup(true); } + +#[test] +fn missing_pin_without_native_stateless_opt_in_fails_before_tool() { + run_tool_followup_with_mode(false, false); +} diff --git a/tests/storage_tests.rs b/tests/storage_tests.rs index f17756c..21473ed 100644 --- a/tests/storage_tests.rs +++ b/tests/storage_tests.rs @@ -21,6 +21,7 @@ const STORAGE_FILES: &[&str] = &[ "approvals.rss", "compactions.rss", "jobs.rss", + "plugins.rss", "admission.rss", "load.rss", "existence.rss", @@ -736,6 +737,12 @@ fn storage_rss_contract_files_are_present_and_use_generic_capabilities() { "recovery_records", "run_retention", "jobs", + "plugin_graph_locks", + "plugin_package_refs", + "plugin_activation", + "plugin_activation_history", + "plugin_graph_refs", + "plugin_control_guard", ] { assert!(schema.contains(table), "schema.rss missing table {table}"); } @@ -779,7 +786,7 @@ fn production_storage_commands_return_sqlite_results_and_preserve_idempotency() let migration = run_storage(&runner, db_name, "migrate-1", "migrate", json!({}), 1); assert_eq!(migration["ok"], json!(true)); - assert_eq!(result_data(&migration)["schema_version"], json!(4)); + assert_eq!(result_data(&migration)["schema_version"], json!(5)); let session_create = run_storage( &runner, @@ -1711,9 +1718,9 @@ fn migrations_are_transactional_idempotent_and_upgrade_from_released_versions() let runner = storage_runner(&root); let first = run_storage(&runner, db_name, "migrate-1", "migrate", json!({}), 1); - assert_eq!(result_data(&first)["schema_version"], json!(4)); + assert_eq!(result_data(&first)["schema_version"], json!(5)); let second = run_storage(&runner, db_name, "migrate-2", "migrate", json!({}), 2); - assert_eq!(result_data(&second)["schema_version"], json!(4)); + assert_eq!(result_data(&second)["schema_version"], json!(5)); // A released v1 database upgrades to v3 without re-running v1. The // crafter builds a real v1 schema by executing the production schema @@ -1722,7 +1729,7 @@ fn migrations_are_transactional_idempotent_and_upgrade_from_released_versions() let v1_crafter = released_v1_runner(&root); run_raw_sql(&v1_crafter, v1_db); let upgraded = run_storage(&runner, v1_db, "migrate-upgrade", "migrate", json!({}), 3); - assert_eq!(result_data(&upgraded)["schema_version"], json!(4)); + assert_eq!(result_data(&upgraded)["schema_version"], json!(5)); let run_created = run_storage( &runner, v1_db, diff --git a/tests/support/pinned_process_fixture.rs b/tests/support/pinned_process_fixture.rs new file mode 100644 index 0000000..3abfd5f --- /dev/null +++ b/tests/support/pinned_process_fixture.rs @@ -0,0 +1,319 @@ +use rustscript_agent::{ + AgentConfig, AgentHostBridges, AgentRunner, RunCancellation, + capabilities::{ + AllowAllApproval, CancellationFlag, CapabilityLifecycle, CapabilityOwner, DurableStarted, + DurableToolLifecycle, LifecycleClock, LifecycleError, LifecycleLimits, ProcessCapability, + ProcessLimits, SystemClock, UuidIssuer, + }, +}; +use rustscript_vm::Value as VmValue; +use serde_json::{Value, json}; +use std::{ + collections::HashMap, + fs, + path::PathBuf, + sync::{Arc, Mutex}, + time::Duration, +}; + +#[derive(Default)] +struct Durable { + results: Mutex>, + interruptions: Mutex>, + parents: Mutex>, +} +impl DurableToolLifecycle for Durable { + fn parent_arguments( + &self, + run: &str, + call: &str, + tool: &str, + ) -> Result, LifecycleError> { + self.parents + .lock() + .unwrap() + .get(&(run.into(), call.into(), tool.into())) + .cloned() + .map(Some) + .ok_or(LifecycleError::MissingParent) + } + fn assert_active_run(&self, _: &str) -> Result<(), LifecycleError> { + Ok(()) + } + fn prepare_parent(&self, _: &str, _: &str, _: &str) -> Result<(), LifecycleError> { + Ok(()) + } + fn replay_result(&self, _: &str, call: &str, _: &str) -> Result, LifecycleError> { + Ok(self.results.lock().unwrap().get(call).cloned()) + } + fn commit_started(&self, _: &DurableStarted) -> Result<(), LifecycleError> { + Ok(()) + } + fn commit_result(&self, call: &str, value: &Value) -> Result { + self.results + .lock() + .unwrap() + .insert(call.into(), value.clone()); + Ok(value.clone()) + } + fn interrupt(&self, call: &str) -> Result<(), LifecycleError> { + self.interruptions.lock().unwrap().push(call.into()); + Ok(()) + } +} +struct Cancel(RunCancellation); +impl CancellationFlag for Cancel { + fn is_cancelled(&self) -> bool { + self.0.requested().is_some() || self.0.deadline_passed() + } +} +#[derive(Default)] +struct Issuer(Mutex); +impl rustscript_agent::capabilities::TokenIssuer for Issuer { + fn issue(&self) -> String { + let token = UuidIssuer.issue(); + *self.0.lock().unwrap() = token.clone(); + token + } +} + +struct RootGuard(PathBuf); +impl Drop for RootGuard { + fn drop(&mut self) { + if self.0.exists() { + let removed = fs::remove_dir_all(&self.0); + if !std::thread::panicking() { + removed.unwrap(); + } + } + } +} + +pub struct NativeFixture { + pub root: PathBuf, + _root_guard: RootGuard, + pub host: AgentHostBridges, + pub processes: Arc, + pub runner: AgentRunner, + durable: Arc, + issuer: Arc, +} + +pub struct PreparedCall { + call: String, + tool: String, + pub arguments: Value, +} + +impl NativeFixture { + pub fn new(run: &str) -> Self { + let root = PathBuf::from(std::env::var_os("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()) + .join(format!("native-process-{}", uuid::Uuid::new_v4())); + fs::create_dir(&root).unwrap(); + let root_guard = RootGuard(root.clone()); + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).unwrap(); + // Compile before starting the original lifecycle clock. + let runner = AgentRunner::from_source(r#" +fn text(context: map, key: string) -> string { + let value: string = context[key]; + value +} +pub fn run(context: map) -> map { + agent_runtime::invoke_pinned_tool({ + tool_name: text(context, "tool_name"), call_id: text(context, "call_id"), + canonical_arguments: text(context, "canonical_arguments"), canonical_input: text(context, "canonical_input"), + run_id: text(context, "run_id"), registry_identity: text(context, "registry_identity"), + argument_digest: text(context, "argument_digest"), risk_class: "execute", summary: text(context, "tool_name"), + untrusted_graph_id: "" + }) +} +"#, AgentConfig::default()).unwrap(); + let cancellation = RunCancellation::with_timeout(Duration::from_secs(30)); + let owner = CapabilityOwner::new("process-fixture", "session", run).unwrap(); + let durable = Arc::new(Durable::default()); + let issuer = Arc::new(Issuer::default()); + let lifecycle = Arc::new( + CapabilityLifecycle::builder() + .owner(owner.clone()) + .registry_identity( + rustscript_agent::bundled_tool_registry() + .unwrap() + .identity(), + ) + .workspace(&root) + .limits(LifecycleLimits { + max_tool_calls: 32, + max_output_bytes: 65536, + max_summary_bytes: 256, + }) + .deadline_ms(SystemClock.now_ms() + 30000) + .clock(Arc::new(SystemClock)) + .tokens(issuer.clone()) + .durable(durable.clone()) + .approval(Arc::new(AllowAllApproval)) + .cancellation(Arc::new(Cancel(cancellation.clone()))) + .build() + .unwrap(), + ); + let processes = Arc::new( + ProcessCapability::new( + lifecycle.as_ref().clone(), + owner.clone(), + ProcessLimits::default(), + ) + .unwrap(), + ); + let host = AgentHostBridges { + lifecycle: Some(lifecycle), + capability_owner: Some(owner), + cancellation: Some(cancellation), + processes: Some(processes.clone()), + // This direct SDK fixture has no persistent admission service. + allow_stateless_pin: true, + ..AgentHostBridges::default() + }; + Self { + root, + _root_guard: root_guard, + host, + processes, + runner, + durable, + issuer, + } + } + pub fn interrupt_current_child(&self) -> Arc { + let lifecycle = self.host.lifecycle.as_ref().unwrap().clone(); + let issuer = self.issuer.clone(); + Arc::new(move || { + let token = issuer.0.lock().unwrap().clone(); + drop(lifecycle.lease(&token).unwrap()); + }) + } + pub fn parent_call(&self, call: &str, tool: &str, original_arguments: Value) -> PreparedCall { + let run = self + .host + .capability_owner + .as_ref() + .unwrap() + .run() + .to_string(); + let key = (run, call.to_string(), tool.to_string()); + let mut parents = self.durable.parents.lock().unwrap(); + if let Some(original) = parents.get(&key) { + assert_eq!(original, &original_arguments, "parent facts are immutable"); + } else { + parents.insert(key, original_arguments.clone()); + } + PreparedCall { + call: call.into(), + tool: tool.into(), + arguments: original_arguments, + } + } + pub fn invoke(&self, prepared: PreparedCall) -> Value { + self.invoke_with(self.host.clone(), prepared) + } + pub fn invoke_with(&self, host: AgentHostBridges, prepared: PreparedCall) -> Value { + let PreparedCall { + call, + tool, + arguments: args, + } = prepared; + let identity = rustscript_agent::bundled_tool_registry() + .unwrap() + .identity() + .to_string(); + let run = host.capability_owner.as_ref().unwrap().run().to_string(); + let input = json!({"run_id": run, "registry_identity": identity, "admitted_registry_identity": identity, + "registry": rustscript_agent::bundled_tool_registry().unwrap().snapshot().schemas(), + "call": {"id": call, "name": tool, "arguments": args}}); + let canonical = args.to_string(); + let request = json!({"tool_name": tool, "call_id": call, "canonical_arguments": canonical, + "argument_digest": canonical, "canonical_input": input.to_string(), "run_id": run, + "registry_identity": identity, "risk_class": "execute", "summary": tool}); + let value = self + .runner + .clone() + .with_host(host) + .run_with_context(to_vm(&request)) + .unwrap(); + let outer = from_vm(&value); + if let Some(result) = outer["canonical_result"].as_str().filter(|s| !s.is_empty()) { + serde_json::from_str(result).unwrap() + } else { + outer + } + } + pub fn start(&self, call: &str) -> String { + let prepared = self.parent_call( + call, + "terminal", + json!({"argv": ["/bin/cat"], "background": true}), + ); + let value = self.invoke(prepared); + assert_eq!(value["ok"], true, "start result: {value}"); + value["data"]["process_id"] + .as_str() + .unwrap_or_else(|| panic!("canonical start result lacks process_id: {value}")) + .into() + } + pub fn interrupted(&self, call: &str) -> bool { + self.durable + .interruptions + .lock() + .unwrap() + .iter() + .any(|id| id == call) + } +} +impl Drop for NativeFixture { + fn drop(&mut self) { + self.processes.shutdown_all(); + self.host + .lifecycle + .as_ref() + .unwrap() + .recover_open_tokens() + .unwrap(); + let removed = fs::remove_dir_all(&self.root); + if !std::thread::panicking() { + removed.unwrap(); + } + } +} +fn to_vm(value: &Value) -> VmValue { + match value { + Value::Null => VmValue::Null, + Value::Bool(v) => VmValue::Bool(*v), + Value::Number(v) => VmValue::Int(v.as_i64().unwrap()), + Value::String(v) => VmValue::string(v), + Value::Array(v) => VmValue::Array(Arc::new(v.iter().map(to_vm).collect())), + Value::Object(v) => VmValue::map( + v.iter() + .map(|(k, v)| (VmValue::string(k), to_vm(v))) + .collect(), + ), + } +} +fn from_vm(value: &VmValue) -> Value { + match value { + VmValue::Null => Value::Null, + VmValue::Bool(v) => json!(v), + VmValue::Int(v) => json!(v), + VmValue::String(v) => json!(v.to_string()), + VmValue::Map(v) => Value::Object( + v.iter() + .map(|(k, v)| { + let VmValue::String(k) = k else { + panic!("string key") + }; + (k.to_string(), from_vm(v)) + }) + .collect(), + ), + VmValue::Array(v) => Value::Array(v.iter().map(from_vm).collect()), + _ => panic!("unexpected result value"), + } +} diff --git a/tests/support/signed_pinned_variants.rs b/tests/support/signed_pinned_variants.rs new file mode 100644 index 0000000..eab80c1 --- /dev/null +++ b/tests/support/signed_pinned_variants.rs @@ -0,0 +1,107 @@ +//! Minimal signed-package preparation fixture for the six-variant acceptance. +//! Reuses original RSS bytes; never changes host policy or tool business logic. + +use std::fs; +use std::path::PathBuf; + +use ring::rand::SystemRandom; +use ring::signature::{Ed25519KeyPair, KeyPair}; +use rustscript_agent::plugins::{ + GraphLock, HOST_RUNTIME, InstallAuthorization, PackageRequirement, PackageStore, PublisherKey, + ResolveRequest, +}; + +pub fn signed_search_variant() -> (PackageStore, GraphLock) { + let root = PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").expect("assigned temp")) + .join(format!("six-search-{}", uuid::Uuid::new_v4())); + let package = root.join("package-a"); + fs::create_dir_all(&package).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for dir in [&root, &package] { + fs::set_permissions(dir, fs::Permissions::from_mode(0o700)).unwrap(); + } + } + let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap(); + let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap(); + let authorization = InstallAuthorization { + admin: true, + accepted_publishers: vec![PublisherKey { + publisher: "org.rustscript".to_string(), + verifying_key: pair.public_key().as_ref().to_vec(), + }], + unsigned_dev_space: None, + }; + let store = PackageStore::open(root.join("packages")).unwrap(); + fs::write( + package.join("manifest.yaml"), + r#"format: rustscript.plugin/v1 +id: org.rustscript.tools.varianta +version: 1.0.0 +kind: tool +runtime: ">=0.1.0, <0.2.0" +contract: agent.tool/v1 +host_abi: agent.cap/v1 +entry: + path: run.rss + export: run +dependencies: [] +capabilities: + - workspace.read + - clock.read +effects: + contract: agent.effects/v1 + entries: + run: + direct: + - kind: workspace.fs.read + scope: bound_workspace + recovery: none + - kind: clock.read + scope: bound_run + recovery: none + delegated: [] +"#, + ) + .unwrap(); + // Exact shipped leaf/types: do not trim overflow branches to evade charging. + fs::write( + package.join("types.rss"), + include_bytes!("../../rss/tools/types.rss"), + ) + .unwrap(); + fs::write( + package.join("leaf.rss"), + include_bytes!("../../rss/tools/search_files.rss"), + ) + .unwrap(); + fs::write( + package.join("run.rss"), + r#"use self::leaf::{invoke}; +use self::types as types; + +pub fn run(context: map) -> map { + let mut changed: map = context; + let mut arguments: map = types::map_map(context, "arguments"); + arguments.pattern = "alpha.txt"; + changed.arguments = arguments; + invoke(changed) +} +"#, + ) + .unwrap(); + let signature = pair.sign(&store.signature_payload(&package).unwrap()); + fs::write(package.join("signature.ed25519"), signature.as_ref()).unwrap(); + store.install(&package, &authorization).unwrap(); + let lock = store + .resolve(&ResolveRequest { + roots: vec![PackageRequirement { + id: "org.rustscript.tools.varianta".to_string(), + version: "1.0.0".to_string(), + }], + host_runtime: HOST_RUNTIME.to_string(), + }) + .unwrap(); + (store, lock) +} diff --git a/tests/support/signed_tool_effects.rs b/tests/support/signed_tool_effects.rs new file mode 100644 index 0000000..025c715 --- /dev/null +++ b/tests/support/signed_tool_effects.rs @@ -0,0 +1,148 @@ +//! Signed wrappers vary inputs while retaining the shipped business/type bytes. +use std::{fs, path::Path, sync::Arc}; + +use ring::{ + rand::SystemRandom, + signature::{Ed25519KeyPair, KeyPair}, +}; +use rustscript_agent::plugins::{ + GraphLock, HOST_RUNTIME, InstallAuthorization, PackageArtifactRef, PackageRequirement, + PackageStore, PublisherKey, ResolveRequest, +}; + +pub fn artifact_refs(lock: &GraphLock) -> Vec { + lock.packages + .iter() + .map(|p| PackageArtifactRef { + package_id: p.id.clone(), + version: p.version.clone(), + kind: p.kind.clone(), + artifact_id: p.artifact_id.clone(), + }) + .collect() +} + +pub fn install_pair( + root: &Path, + tool: &str, + overflow: bool, +) -> (Arc, GraphLock, GraphLock) { + let store = Arc::new(PackageStore::open(root.join("packages")).unwrap()); + let key = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap(); + let pair = Ed25519KeyPair::from_pkcs8(key.as_ref()).unwrap(); + let auth = InstallAuthorization { + admin: true, + accepted_publishers: vec![PublisherKey { + publisher: "org.rustscript".into(), + verifying_key: pair.public_key().as_ref().to_vec(), + }], + unsigned_dev_space: None, + }; + let leaf: &[u8] = match tool { + "search_files" => include_bytes!("../../rss/tools/search_files.rss"), + "write_file" => include_bytes!("../../rss/tools/write_file.rss"), + "patch" => include_bytes!("../../rss/tools/patch.rss"), + "terminal" => include_bytes!("../../rss/tools/terminal.rss"), + "process" => include_bytes!("../../rss/tools/process.rss"), + _ => panic!("unexpected tool"), + }; + let mut locks = Vec::new(); + for variant in ["a", "b"] { + let id = format!("org.rustscript.tools.effect{variant}"); + let package = root.join(format!("package-{variant}")); + fs::create_dir_all(&package).unwrap(); + let (capabilities, effects) = if matches!(tool, "terminal" | "process") { + ( + " - process.spawn\n - clock.read\n", + " - kind: process.spawn\n scope: bound_process\n recovery: none\n", + ) + } else if tool == "search_files" { + ( + " - workspace.read\n - clock.read\n", + " - kind: workspace.fs.read\n scope: bound_workspace\n recovery: none\n", + ) + } else { + ( + " - workspace.read\n - workspace.write\n - clock.read\n", + " - kind: workspace.fs.read\n scope: bound_workspace\n recovery: none\n - kind: workspace.fs.write\n scope: bound_workspace\n recovery: none\n", + ) + }; + fs::write(package.join("manifest.yaml"), format!("format: rustscript.plugin/v1\nid: {id}\nversion: 1.0.0\nkind: tool\nruntime: \">=0.1.0, <0.2.0\"\ncontract: agent.tool/v1\nhost_abi: agent.cap/v1\nentry:\n path: run.rss\n export: run\ndependencies: []\ncapabilities:\n{capabilities}effects:\n contract: agent.effects/v1\n entries:\n run:\n direct:\n{effects} - kind: clock.read\n scope: bound_run\n recovery: none\n delegated: []\n")).unwrap(); + fs::write(package.join("leaf.rss"), leaf).unwrap(); + fs::write( + package.join("types.rss"), + include_bytes!("../../rss/tools/types.rss"), + ) + .unwrap(); + let marker = variant.to_ascii_uppercase(); + let change = match tool { + "search_files" if overflow => "arguments.pattern = \"*\";".to_string(), + "search_files" => format!("arguments.pattern = \"{variant}*.txt\";"), + "write_file" => format!("arguments.content = \"{marker}-write\\n\";"), + "patch" => format!("arguments.new_string = \"{marker}-patch\";"), + "terminal" if overflow => format!( + "arguments.argv = [\"/bin/printf\", \"{marker}-{}\\n\"];", + "x".repeat(1200) + ), + "terminal" => format!("arguments.argv = [\"/bin/printf\", \"{marker}-terminal\\n\"];"), + "process" if overflow => format!( + "if types::map_string(arguments, \"action\", \"\") == \"write\" {{ arguments.data = \"{marker}-{}\\n\"; }}", + "x".repeat(1200) + ), + "process" => format!( + "if types::map_string(arguments, \"action\", \"\") == \"write\" {{ arguments.data = \"{marker}-stdin\\n\"; }}" + ), + _ => unreachable!(), + }; + let extra_import = if tool == "process" { + fs::write( + package.join("terminal.rss"), + include_bytes!("../../rss/tools/terminal.rss"), + ) + .unwrap(); + "use self::terminal as terminal;\n" + } else { + "" + }; + let invoke = if tool == "process" { + "if types::map_string(prepare, \"name\", \"\") == \"terminal\" => { terminal::invoke(changed) } else => { leaf::invoke(changed) }" + } else { + "leaf::invoke(changed)" + }; + let config = if overflow && matches!(tool, "terminal" | "process") { + "changed.config = { max_output_bytes: 800, max_stream_bytes: 4096 };" + } else if overflow { + "changed.config = { max_tool_output_bytes: 512 };" + } else { + "" + }; + let source = format!( + "use self::leaf as leaf;\nuse self::types as types;\n{extra_import}pub fn run(context: map) -> map {{\n let mut changed: map = context;\n let prepare: map = types::map_map(context, \"prepare\");\n let mut arguments: map = types::map_map(context, \"arguments\");\n {change}\n changed.arguments = arguments;\n {config}\n {invoke}\n}}\n" + ); + fs::write(package.join("run.rss"), source).unwrap(); + let signature = pair.sign(&store.signature_payload(&package).unwrap()); + fs::write(package.join("signature.ed25519"), signature.as_ref()).unwrap(); + store.install(&package, &auth).unwrap(); + let lock = store + .resolve(&ResolveRequest { + roots: vec![PackageRequirement { + id: id.clone(), + version: "1.0.0".into(), + }], + host_runtime: HOST_RUNTIME.into(), + }) + .unwrap(); + let prepared = store.prepare(&lock).unwrap(); + assert_eq!(prepared.captured_source(&id, "leaf.rss").unwrap(), leaf); + assert_eq!( + prepared.captured_source(&id, "types.rss").unwrap(), + include_bytes!("../../rss/tools/types.rss") + ); + assert_eq!( + prepared.trust(&id), + Some(rustscript_agent::plugins::PackageTrust::AcceptedPublisher) + ); + locks.push(lock); + } + (store, locks.remove(0), locks.remove(0)) +} diff --git a/tests/tools_pin_invoke_tracer.rs b/tests/tools_pin_invoke_tracer.rs new file mode 100644 index 0000000..75f7b19 --- /dev/null +++ b/tests/tools_pin_invoke_tracer.rs @@ -0,0 +1,906 @@ +//! Vertical tracer: admission pins a retained tool program, a typed child +//! bridge invokes it through the existing capability lease, and `read_file` +//! bytes come from that program. Activating another graph does not retarget +//! the already pinned run. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use ring::rand::SystemRandom; +use ring::signature::{Ed25519KeyPair, KeyPair}; +use rustscript_agent::capabilities::{ + ApprovalGate, CancellationFlag, CapabilityLifecycle, CapabilityOwner, CapabilityRisk, + DurableStarted, DurableToolLifecycle, FilesystemCapability, FilesystemLimits, LifecycleError, + LifecycleLimits, PrepareMetadata, SystemClock, UuidIssuer, +}; +use rustscript_agent::plugins::{ + InstallAuthorization, PackageArtifactRef, PackageRequirement, PackageStore, PluginControlStore, + PublisherKey, ResolveRequest, +}; +use rustscript_agent::{AgentConfig, AgentHostBridges, AgentRunner, GatewayPersistence}; +use rustscript_vm::Value as VmValue; +use serde_json::{Value, json}; + +fn temp_root(label: &str) -> PathBuf { + let base = + PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").expect("assigned test temp")); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let dir = base.join(format!( + "pin-tracer-{}-{}-{}", + label, + std::process::id(), + nanos + )); + fs::create_dir(&dir).unwrap_or_else(|err| panic!("create {}: {err}", dir.display())); + dir +} + +struct Signer { + pair: Ed25519KeyPair, + key: PublisherKey, +} + +fn signer() -> Signer { + let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap(); + let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap(); + let key = PublisherKey { + publisher: "org.rustscript".to_string(), + verifying_key: pair.public_key().as_ref().to_vec(), + }; + Signer { pair, key } +} + +fn auth(signer: &Signer) -> InstallAuthorization { + InstallAuthorization { + admin: true, + accepted_publishers: vec![signer.key.clone()], + unsigned_dev_space: None, + } +} + +fn tool_source(offset: i64) -> String { + format!( + r#" +use bytes; +fn map_string(value: map, key: string, fallback: string) -> string {{ + let mut result: string = fallback; + if value.has(key) {{ + if type(value[key]) == "string" {{ + let coerced: string = value[key]; + result = coerced; + }} + }} + result +}} + +fn map_map(value: map, key: string) -> map {{ + let mut result: map = {{}}; + if value.has(key) {{ + if type(value[key]) == "map" {{ + let coerced: map = value[key]; + result = coerced; + }} + }} + result +}} + +fn map_int(value: map, key: string, fallback: int) -> int {{ + let mut result: int = fallback; + if value.has(key) {{ + if type(value[key]) == "int" {{ + let coerced: int = value[key]; + result = coerced; + }} + }} + result +}} + +fn map_bool(value: map, key: string, fallback: bool) -> bool {{ + let mut result: bool = fallback; + if value.has(key) {{ + if type(value[key]) == "bool" {{ + let coerced: bool = value[key]; + result = coerced; + }} + }} + result +}} + +pub fn run(context: map) -> map {{ + let prepared: map = agent_runtime::tool_prepare(map_map(context, "prepare")); + if map_bool(prepared, "ok", false) == false => {{ + prepared + }} else => {{ + let token: string = map_string(prepared, "execution_token", ""); + let arguments: map = map_map(context, "arguments"); + let path: string = map_string(arguments, "path", ""); + let meta: map = cap::fs_metadata(token, path); + if map_bool(meta, "ok", false) == false => {{ + meta + }} else => {{ + let file_len: int = map_int(meta, "len", 0); + let offset: int = {offset}; + let limit: int = file_len - offset; + let read: map = cap::fs_read_range(token, path, offset, limit); + if map_bool(read, "ok", false) == false => {{ + read + }} else => {{ + let payload: bytes = read.bytes; + let text: string = bytes::to_utf8(payload); + let result: map = {{ ok: true, content: text, data: {{}}, error: null, truncated: false, artifacts: [] }}; + let committed: map = agent_runtime::tool_commit(token, result); + if map_string(committed, "kind", "") == "committed" => {{ + map_map(committed, "result") + }} else => {{ + committed + }} + }} + }} + }} +}} +"# + ) +} + +fn manifest(id: &str) -> String { + format!( + r#"format: rustscript.plugin/v1 +id: {id} +version: 1.0.0 +kind: tool +runtime: ">=0.1.0, <0.2.0" +contract: agent.tool/v1 +host_abi: agent.cap/v1 +entry: + path: run.rss + export: run +dependencies: [] +capabilities: + - workspace.read +effects: + contract: agent.effects/v1 + entries: + run: + direct: + - kind: workspace.fs.read + scope: bound_workspace + recovery: none + delegated: [] +"# + ) +} + +fn install_tool(store: &PackageStore, signer: &Signer, dir: &Path, id: &str, offset: i64) { + fs::create_dir_all(dir).unwrap(); + fs::write(dir.join("manifest.yaml"), manifest(id)).unwrap(); + fs::write(dir.join("run.rss"), tool_source(offset)).unwrap(); + let payload = store.signature_payload(dir).unwrap(); + let signature = signer.pair.sign(&payload); + fs::write(dir.join("signature.ed25519"), signature.as_ref()).unwrap(); + store.install(dir, &auth(signer)).unwrap(); +} + +struct MemoryDurable; + +impl DurableToolLifecycle for MemoryDurable { + fn parent_arguments( + &self, + run_id: &str, + call_id: &str, + tool_name: &str, + ) -> Result, LifecycleError> { + assert_eq!(run_id, "run-pin-a"); + assert_eq!(tool_name, "read_file"); + assert!(matches!( + call_id, + "call-read-1" | "call-read-2" | "call-legal" + )); + Ok(Some(json!({"path": "note.txt"}))) + } + fn assert_active_run(&self, _run_id: &str) -> Result<(), LifecycleError> { + Ok(()) + } + fn prepare_parent( + &self, + _run_id: &str, + _call_id: &str, + _tool_name: &str, + ) -> Result<(), LifecycleError> { + Ok(()) + } + fn replay_result( + &self, + _run_id: &str, + _call_id: &str, + _tool_name: &str, + ) -> Result, LifecycleError> { + Ok(None) + } + fn commit_started(&self, _record: &DurableStarted) -> Result<(), LifecycleError> { + Ok(()) + } + fn commit_result(&self, _call_id: &str, result: &Value) -> Result { + Ok(result.clone()) + } + fn interrupt(&self, _call_id: &str) -> Result<(), LifecycleError> { + Ok(()) + } +} + +struct AllowRead; + +impl ApprovalGate for AllowRead { + fn authorize(&self, metadata: &PrepareMetadata) -> Result { + Ok(metadata.risk_class) + } +} + +struct NoCancel; + +impl CancellationFlag for NoCancel { + fn is_cancelled(&self) -> bool { + false + } +} + +fn vm_text(value: &VmValue, key: &str) -> String { + let VmValue::Map(entries) = value else { + panic!("tool result must be a map, got {value:?}"); + }; + entries + .iter() + .find(|(entry_key, _)| matches!(entry_key, VmValue::String(text) if text.as_str() == key)) + .and_then(|(_, entry)| match entry { + VmValue::String(text) => Some(text.to_string()), + _ => None, + }) + .unwrap_or_else(|| panic!("missing string field {key} in {value:?}")) +} + +#[test] +fn pinned_read_file_bytes_stay_on_program_a_after_activation_of_b() { + let root = temp_root("read"); + let store = PackageStore::open(root.join("packages")).unwrap(); + let signer = signer(); + install_tool( + &store, + &signer, + &root.join("pkg-a"), + "org.rustscript.tools.reada", + 0, + ); + install_tool( + &store, + &signer, + &root.join("pkg-b"), + "org.rustscript.tools.readb", + 1, + ); + let lock_a = store + .resolve(&ResolveRequest { + roots: vec![PackageRequirement { + id: "org.rustscript.tools.reada".to_string(), + version: "1.0.0".to_string(), + }], + host_runtime: "0.1.0".to_string(), + }) + .unwrap(); + let lock_b = store + .resolve(&ResolveRequest { + roots: vec![PackageRequirement { + id: "org.rustscript.tools.readb".to_string(), + version: "1.0.0".to_string(), + }], + host_runtime: "0.1.0".to_string(), + }) + .unwrap(); + let prepared_a = store.prepare(&lock_a).unwrap(); + let program_a = prepared_a + .program("org.rustscript.tools.reada") + .expect("prepare must retain program A"); + assert!(prepared_a.program("org.rustscript.tools.readb").is_none()); + let _prepared_b = store.prepare(&lock_b).unwrap(); + assert!( + _prepared_b.program("org.rustscript.tools.readb").is_some(), + "prepare must retain program B" + ); + + let db = root.join("state.db"); + let persistence = + GatewayPersistence::open(&rustscript_agent::AgentGatewayConfig::default(), &db).unwrap(); + persistence.load().unwrap(); + let control = PluginControlStore::new(&persistence); + let refs_a: Vec = lock_a + .packages + .iter() + .map(|package| PackageArtifactRef { + package_id: package.id.clone(), + version: package.version.clone(), + kind: package.kind.clone(), + artifact_id: package.artifact_id.clone(), + }) + .collect(); + let refs_b: Vec = lock_b + .packages + .iter() + .map(|package| PackageArtifactRef { + package_id: package.id.clone(), + version: package.version.clone(), + kind: package.kind.clone(), + artifact_id: package.artifact_id.clone(), + }) + .collect(); + control.store_lock(&lock_a).unwrap(); + control.store_lock(&lock_b).unwrap(); + control.activate(&lock_a.graph_id, 0, &refs_a).unwrap(); + + let workspace = root.join("workspace"); + fs::create_dir(&workspace).unwrap(); + let file_bytes = b"pin-tracer-bytes-from-A-not-B"; + fs::write(workspace.join("note.txt"), file_bytes).unwrap(); + + let caller = json!({ + "session_id": "session-pin", + "session_new": 1, + "profile": "gateway", + "platform": "api_server", + "account_id": "session-pin", + "model": "m", + "provider": "p", + "system_prompt": "", + "session_metadata_json": "{}", + "run_id": "run-pin-a", + "parent_run_id": "", + "input_json": "{\"text\":\"read note\"}", + "message_id": "message-pin-a", + "message_run_id": "run-pin-a", + "script_hash": "s", + "idempotency_scope": "", + "idempotency_key": "", + "request_hash": "", + "workspace_identity_json": "", + "event_id": "event-pin-a", + "now_ms": 50, + "expires_at_ms": 0, + "tool_graph_id": lock_b.graph_id, + "graph_id": lock_b.graph_id + }); + let pinned = rustscript_agent::plugins::admit_pinned_tool_run( + &persistence, + &store, + &caller, + &rustscript_agent::plugins::PinnedAdmitContext { + registry_identity: "pin-tracer-registry".to_string(), + workspace: workspace.clone(), + }, + ) + .expect("native admission must pin the active program"); + assert_eq!(pinned.graph_id, lock_a.graph_id); + assert_ne!(pinned.graph_id, lock_b.graph_id); + assert!(pinned.shares_program(&program_a) || pinned.entry == "run"); + + let owner = CapabilityOwner::new("gateway", "session-pin", "run-pin-a").unwrap(); + let deadline_ms = 9_000_000_000_000u64; + let lifecycle = CapabilityLifecycle::builder() + .owner(owner.clone()) + .registry_identity("pin-tracer-registry".to_string()) + .workspace(workspace.clone()) + .limits(LifecycleLimits { + max_tool_calls: 8, + max_output_bytes: 4096, + max_summary_bytes: 256, + }) + .deadline_ms(deadline_ms) + .clock(Arc::new(SystemClock)) + .tokens(Arc::new(UuidIssuer)) + .durable(Arc::new(MemoryDurable)) + .approval(Arc::new(AllowRead)) + .cancellation(Arc::new(NoCancel)) + .generation(1) + .build() + .unwrap(); + let filesystem = Arc::new( + FilesystemCapability::new( + lifecycle.clone(), + owner.clone(), + FilesystemLimits { + max_read_bytes: 4096, + max_write_bytes: 4096, + max_list_entries: 16, + }, + ) + .unwrap(), + ); + let cancellation = rustscript_agent::RunCancellation::with_timeout(Duration::from_secs(30)); + let host = AgentHostBridges { + provider: None, + auth_host_factory: None, + cancellation: Some(cancellation.clone()), + sleeps: Default::default(), + skip_sleep: true, + metrics: None, + lifecycle: Some(Arc::new(lifecycle)), + capability_owner: Some(owner), + filesystem: Some(filesystem), + processes: None, + artifacts: None, + control_hook: None, + pinned_tool: Some(pinned.program_handle()), + allow_stateless_pin: false, + }; + let harness = AgentRunner::from_source( + r#" +pub fn run(context: map) -> map { + agent_runtime::invoke_pinned_tool({ + tool_name: "read_file", + call_id: "call-read-1", + canonical_arguments: "{\"path\":\"note.txt\"}", + canonical_input: "", + run_id: "run-pin-a", + registry_identity: "pin-tracer-registry", + argument_digest: "{\"path\":\"note.txt\"}", + risk_class: "read", + summary: "read note", + untrusted_graph_id: "forged-graph-b" + }) +} +"#, + AgentConfig::default(), + ) + .expect("harness must compile against the typed bridge"); + let first = harness + .clone() + .with_host(host.clone()) + .run_with_context(VmValue::map(vec![])) + .expect("pinned read"); + let text = vm_text(&first, "text"); + assert_eq!( + text.as_bytes(), + file_bytes, + "read bytes must be the file contents produced by program A" + ); + + control + .activate(&lock_b.graph_id, pinned.generation, &refs_b) + .unwrap(); + let second = AgentRunner::from_source( + r#" +pub fn run(context: map) -> map { + agent_runtime::invoke_pinned_tool({ + tool_name: "read_file", + call_id: "call-read-2", + canonical_arguments: "{\"path\":\"note.txt\"}", + canonical_input: "", + run_id: "run-pin-a", + registry_identity: "pin-tracer-registry", + argument_digest: "{\"path\":\"note.txt\"}", + risk_class: "read", + summary: "read note again", + untrusted_graph_id: "forged-graph-b" + }) +} +"#, + AgentConfig::default(), + ) + .expect("second harness must compile") + .with_host(host) + .run_with_context(VmValue::map(vec![])) + .expect("old run still pinned"); + assert_eq!( + vm_text(&second, "text").as_bytes(), + file_bytes, + "activating B must not retarget the run pinned to A" + ); + let tail = &file_bytes[1..]; + assert_ne!(vm_text(&second, "text").as_bytes(), tail); + + let _ = fs::remove_dir_all(&root); +} + +fn caller_json(run_id: &str, key: &str, hash: &str) -> Value { + json!({ + "session_id": "session-pin", + "session_new": 1, + "profile": "gateway", + "platform": "api_server", + "account_id": "session-pin", + "model": "m", + "provider": "p", + "system_prompt": "", + "session_metadata_json": "{}", + "run_id": run_id, + "parent_run_id": "", + "input_json": "{\"text\":\"read note\"}", + "message_id": format!("message-{run_id}"), + "message_run_id": run_id, + "script_hash": "s", + "idempotency_scope": "pin", + "idempotency_key": key, + "request_hash": hash, + "workspace_identity_json": "", + "event_id": format!("event-{run_id}"), + "now_ms": 50, + "expires_at_ms": 0 + }) +} + +fn harness_source(call_id: &str) -> String { + format!( + r#" +pub fn run(context: map) -> map {{ + agent_runtime::invoke_pinned_tool({{ + tool_name: "read_file", + call_id: "{call_id}", + canonical_arguments: "{{\"path\":\"note.txt\"}}", + canonical_input: "", + run_id: "run-pin-a", + registry_identity: "pin-tracer-registry", + argument_digest: "{{\"path\":\"note.txt\"}}", + risk_class: "read", + summary: "read note", + untrusted_graph_id: "forged-graph-b" + }}) +}} +"# + ) +} + +#[test] +fn pinned_handle_rejects_other_owner_without_started_effect() { + let root = temp_root("bind"); + let store = PackageStore::open(root.join("packages")).unwrap(); + let signer = signer(); + install_tool( + &store, + &signer, + &root.join("pkg-a"), + "org.rustscript.tools.reada", + 0, + ); + let lock_a = store + .resolve(&ResolveRequest { + roots: vec![PackageRequirement { + id: "org.rustscript.tools.reada".to_string(), + version: "1.0.0".to_string(), + }], + host_runtime: "0.1.0".to_string(), + }) + .unwrap(); + let db = root.join("state.db"); + let persistence = + GatewayPersistence::open(&rustscript_agent::AgentGatewayConfig::default(), &db).unwrap(); + persistence.load().unwrap(); + let control = PluginControlStore::new(&persistence); + let refs_a: Vec = lock_a + .packages + .iter() + .map(|package| PackageArtifactRef { + package_id: package.id.clone(), + version: package.version.clone(), + kind: package.kind.clone(), + artifact_id: package.artifact_id.clone(), + }) + .collect(); + control.store_lock(&lock_a).unwrap(); + control.activate(&lock_a.graph_id, 0, &refs_a).unwrap(); + let workspace = root.join("workspace"); + fs::create_dir(&workspace).unwrap(); + fs::write(workspace.join("note.txt"), b"untouched").unwrap(); + let pinned = rustscript_agent::plugins::admit_pinned_tool_run( + &persistence, + &store, + &caller_json("run-pin-a", "", ""), + &rustscript_agent::plugins::PinnedAdmitContext { + registry_identity: "pin-tracer-registry".to_string(), + workspace: workspace.clone(), + }, + ) + .expect("admit A"); + let durable_count = Arc::new(AtomicUsize::new(0)); + let owner = CapabilityOwner::new("gateway", "session-pin", "run-other").unwrap(); + let lifecycle = CapabilityLifecycle::builder() + .owner(owner.clone()) + .registry_identity("pin-tracer-registry".to_string()) + .workspace(workspace.clone()) + .limits(LifecycleLimits { + max_tool_calls: 8, + max_output_bytes: 4096, + max_summary_bytes: 256, + }) + .deadline_ms(9_000_000_000_000) + .clock(Arc::new(SystemClock)) + .tokens(Arc::new(UuidIssuer)) + .durable(Arc::new(CountingDurableShared(Arc::clone(&durable_count)))) + .approval(Arc::new(AllowRead)) + .cancellation(Arc::new(NoCancel)) + .generation(1) + .build() + .unwrap(); + let cancellation = rustscript_agent::RunCancellation::with_timeout(Duration::from_secs(30)); + let host = AgentHostBridges { + provider: None, + auth_host_factory: None, + cancellation: Some(cancellation), + sleeps: Default::default(), + skip_sleep: true, + metrics: None, + lifecycle: Some(Arc::new(lifecycle)), + capability_owner: Some(owner), + filesystem: None, + processes: None, + artifacts: None, + control_hook: None, + pinned_tool: Some(pinned.program_handle()), + allow_stateless_pin: false, + }; + let rejected = AgentRunner::from_source(&harness_source("call-other"), AgentConfig::default()) + .unwrap() + .with_host(host) + .run_with_context(VmValue::map(vec![])) + .expect("mismatch is a tool result, not a runner failure"); + assert_eq!(vm_text(&rejected, "code"), "pinned_binding_mismatch"); + assert_eq!(durable_count.load(Ordering::SeqCst), 0); + let legal_owner = CapabilityOwner::new("gateway", "session-pin", "run-pin-a").unwrap(); + let legal = CapabilityLifecycle::builder() + .owner(legal_owner.clone()) + .registry_identity("pin-tracer-registry".to_string()) + .workspace(workspace.clone()) + .limits(LifecycleLimits { + max_tool_calls: 8, + max_output_bytes: 4096, + max_summary_bytes: 256, + }) + .deadline_ms(9_000_000_000_000) + .clock(Arc::new(SystemClock)) + .tokens(Arc::new(UuidIssuer)) + .durable(Arc::new(MemoryDurable)) + .approval(Arc::new(AllowRead)) + .cancellation(Arc::new(NoCancel)) + .generation(1) + .build() + .unwrap(); + let filesystem = Arc::new( + FilesystemCapability::new( + legal.clone(), + legal_owner.clone(), + FilesystemLimits { + max_read_bytes: 4096, + max_write_bytes: 4096, + max_list_entries: 16, + }, + ) + .unwrap(), + ); + let legal_host = AgentHostBridges { + provider: None, + auth_host_factory: None, + cancellation: Some(rustscript_agent::RunCancellation::with_timeout( + Duration::from_secs(30), + )), + sleeps: Default::default(), + skip_sleep: true, + metrics: None, + lifecycle: Some(Arc::new(legal)), + capability_owner: Some(legal_owner), + filesystem: Some(filesystem), + processes: None, + artifacts: None, + control_hook: None, + pinned_tool: Some(pinned.program_handle()), + allow_stateless_pin: false, + }; + let sibling = AgentRunner::from_source(&harness_source("call-legal"), AgentConfig::default()) + .unwrap() + .with_host(legal_host) + .run_with_context(VmValue::map(vec![])) + .expect("legal owner on the same pin must still run"); + assert_eq!(vm_text(&sibling, "text"), "untouched"); + let _ = fs::remove_dir_all(&root); +} + +struct CountingDurableShared(Arc); + +impl DurableToolLifecycle for CountingDurableShared { + fn assert_active_run(&self, _run_id: &str) -> Result<(), LifecycleError> { + Ok(()) + } + fn prepare_parent( + &self, + _run_id: &str, + _call_id: &str, + _tool_name: &str, + ) -> Result<(), LifecycleError> { + Ok(()) + } + fn replay_result( + &self, + _run_id: &str, + _call_id: &str, + _tool_name: &str, + ) -> Result, LifecycleError> { + Ok(None) + } + fn commit_started(&self, _record: &DurableStarted) -> Result<(), LifecycleError> { + self.0.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + fn commit_result(&self, _call_id: &str, result: &Value) -> Result { + Ok(result.clone()) + } + fn interrupt(&self, _call_id: &str) -> Result<(), LifecycleError> { + Ok(()) + } +} + +fn activate( + control: &PluginControlStore, + lock: &rustscript_agent::plugins::GraphLock, + generation: u64, +) { + let refs: Vec = lock + .packages + .iter() + .map(|package| PackageArtifactRef { + package_id: package.id.clone(), + version: package.version.clone(), + kind: package.kind.clone(), + artifact_id: package.artifact_id.clone(), + }) + .collect(); + control.store_lock(lock).unwrap(); + control.activate(&lock.graph_id, generation, &refs).unwrap(); +} + +#[test] +fn pinned_replay_keeps_program_a_after_b_activation() { + let root = temp_root("replay"); + let store = PackageStore::open(root.join("packages")).unwrap(); + let signer = signer(); + install_tool( + &store, + &signer, + &root.join("pkg-a"), + "org.rustscript.tools.reada", + 0, + ); + install_tool( + &store, + &signer, + &root.join("pkg-b"), + "org.rustscript.tools.readb", + 1, + ); + let lock_a = store + .resolve(&ResolveRequest { + roots: vec![PackageRequirement { + id: "org.rustscript.tools.reada".to_string(), + version: "1.0.0".to_string(), + }], + host_runtime: "0.1.0".to_string(), + }) + .unwrap(); + let lock_b = store + .resolve(&ResolveRequest { + roots: vec![PackageRequirement { + id: "org.rustscript.tools.readb".to_string(), + version: "1.0.0".to_string(), + }], + host_runtime: "0.1.0".to_string(), + }) + .unwrap(); + let db = root.join("state.db"); + let persistence = + GatewayPersistence::open(&rustscript_agent::AgentGatewayConfig::default(), &db).unwrap(); + persistence.load().unwrap(); + let control = PluginControlStore::new(&persistence); + activate(&control, &lock_a, 0); + let workspace = root.join("workspace"); + fs::create_dir(&workspace).unwrap(); + let context = rustscript_agent::plugins::PinnedAdmitContext { + registry_identity: "pin-tracer-registry".to_string(), + workspace: workspace.clone(), + }; + let first = rustscript_agent::plugins::admit_pinned_tool_run( + &persistence, + &store, + &caller_json("run-replay", "same-key", "hash-a"), + &context, + ) + .expect("first admit"); + activate(&control, &lock_b, 1); + let second = rustscript_agent::plugins::admit_pinned_tool_run( + &persistence, + &store, + &caller_json("run-replay", "same-key", "hash-a"), + &context, + ) + .expect("replay must not require the new active graph"); + assert_eq!(second.graph_id, first.graph_id); + assert_eq!(second.ref_id, first.ref_id); + assert_eq!(second.generation, first.generation); + assert_eq!( + second.program_handle().entry(), + first.program_handle().entry() + ); + let _ = fs::remove_dir_all(&root); +} + +#[test] +fn library_only_graph_is_not_a_tool_pin() { + let root = temp_root("library"); + let store = PackageStore::open(root.join("packages")).unwrap(); + let signer = signer(); + let dir = root.join("pkg-lib"); + fs::create_dir_all(&dir).unwrap(); + fs::write( + dir.join("manifest.yaml"), + r#"format: rustscript.plugin/v1 +id: org.rustscript.tools.lib +version: 1.0.0 +kind: library +runtime: ">=0.1.0, <0.2.0" +contract: agent.library/v1 +host_abi: agent.cap/v1 +entry: + path: run.rss + export: run +dependencies: [] +capabilities: [] +effects: + contract: agent.effects/v1 + entries: + run: + direct: [] + delegated: [] +"#, + ) + .unwrap(); + fs::write(dir.join("run.rss"), "pub fn run() -> string { \"lib\" }\n").unwrap(); + let payload = store.signature_payload(&dir).unwrap(); + fs::write( + dir.join("signature.ed25519"), + signer.pair.sign(&payload).as_ref(), + ) + .unwrap(); + store.install(&dir, &auth(&signer)).unwrap(); + let lock = store + .resolve(&ResolveRequest { + roots: vec![PackageRequirement { + id: "org.rustscript.tools.lib".to_string(), + version: "1.0.0".to_string(), + }], + host_runtime: "0.1.0".to_string(), + }) + .unwrap(); + let db = root.join("state.db"); + let persistence = + GatewayPersistence::open(&rustscript_agent::AgentGatewayConfig::default(), &db).unwrap(); + persistence.load().unwrap(); + let control = PluginControlStore::new(&persistence); + activate(&control, &lock, 0); + let workspace = root.join("workspace"); + fs::create_dir(&workspace).unwrap(); + let error = match rustscript_agent::plugins::admit_pinned_tool_run( + &persistence, + &store, + &caller_json("run-lib", "", ""), + &rustscript_agent::plugins::PinnedAdmitContext { + registry_identity: "pin-tracer-registry".to_string(), + workspace, + }, + ) { + Err(error) => error, + Ok(_) => panic!("library graph must not become a tool pin"), + }; + assert!( + error.to_string().contains("no tool package"), + "unexpected library admit error: {error}" + ); + let _ = fs::remove_dir_all(&root); +} diff --git a/tests/tools_pin_production_ab.rs b/tests/tools_pin_production_ab.rs new file mode 100644 index 0000000..698d55d --- /dev/null +++ b/tests/tools_pin_production_ab.rs @@ -0,0 +1,1585 @@ +//! Production admission must pin the active signed graph, not the embedded default. +//! +//! The same caller arguments produce different file bytes when graph A or B is +//! active. A request field naming the other graph is ignored. An already +//! admitted run keeps A's program after B is activated and after the service +//! is reopened against a fresh package store. + +use std::fs; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use axum::body::{Body, to_bytes}; +use axum::http::{Request, StatusCode}; +use hyper_util::rt::TokioExecutor; +use ring::rand::SystemRandom; +use ring::signature::{Ed25519KeyPair, KeyPair}; +use rustscript_agent::plugins::{ + GraphLock, HOST_RUNTIME, InstallAuthorization, PackageArtifactRef, PackageRequirement, + PackageStore, PluginControlStore, PublisherKey, ResolveRequest, +}; +use rustscript_agent::{ + AdmitRunRequest, AgentGatewayConfig, AgentGatewayState, ScriptedProvider, + build_agent_gateway_app, +}; +use serde_json::{Value, json}; + +fn temp_root(label: &str) -> PathBuf { + let base = std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").expect("assigned test temp"); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + let dir = PathBuf::from(base).join(format!( + "pin-prod-{}-{}-{}", + label, + std::process::id(), + nanos + )); + fs::create_dir_all(&dir).unwrap_or_else(|err| panic!("create {}: {err}", dir.display())); + dir +} + +struct Signer { + pair: Ed25519KeyPair, + key: PublisherKey, +} + +fn signer() -> Signer { + let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap(); + let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap(); + let key = PublisherKey { + publisher: "org.rustscript".to_string(), + verifying_key: pair.public_key().as_ref().to_vec(), + }; + Signer { pair, key } +} + +fn auth(signer: &Signer) -> InstallAuthorization { + InstallAuthorization { + admin: true, + accepted_publishers: vec![signer.key.clone()], + unsigned_dev_space: None, + } +} + +fn tool_source(offset: i64) -> String { + format!( + r#" +fn map_string(value: map, key: string, fallback: string) -> string {{ + let mut result: string = fallback; + if value.has(key) {{ + if type(value[key]) == "string" {{ + let coerced: string = value[key]; + result = coerced; + }} + }} + result +}} + +fn map_map(value: map, key: string) -> map {{ + let mut result: map = {{}}; + if value.has(key) {{ + if type(value[key]) == "map" {{ + let coerced: map = value[key]; + result = coerced; + }} + }} + result +}} + +fn map_int(value: map, key: string, fallback: int) -> int {{ + let mut result: int = fallback; + if value.has(key) {{ + if type(value[key]) == "int" {{ + let coerced: int = value[key]; + result = coerced; + }} + }} + result +}} + +fn map_bool(value: map, key: string, fallback: bool) -> bool {{ + let mut result: bool = fallback; + if value.has(key) {{ + if type(value[key]) == "bool" {{ + let coerced: bool = value[key]; + result = coerced; + }} + }} + result +}} + +pub fn run(context: map) -> map {{ + let prepared: map = agent_runtime::tool_prepare(map_map(context, "prepare")); + if map_bool(prepared, "ok", false) == false => {{ + prepared + }} else => {{ + let token: string = map_string(prepared, "execution_token", ""); + let arguments: map = map_map(context, "arguments"); + let path: string = map_string(arguments, "path", ""); + let meta: map = cap::fs_metadata(token, path); + if map_bool(meta, "ok", false) == false => {{ + meta + }} else => {{ + let file_len: int = map_int(meta, "len", 0); + let offset: int = {offset}; + let limit: int = file_len - offset; + let read: map = cap::fs_read_range(token, path, offset, limit); + if map_bool(read, "ok", false) == false => {{ + read + }} else => {{ + let payload: bytes = read.bytes; + let text: string = bytes::to_utf8(payload); + let result: map = {{ ok: true, content: text, data: {{}}, error: null, truncated: false, artifacts: [] }}; + let committed: map = agent_runtime::tool_commit(token, result); + if map_string(committed, "kind", "") == "committed" => {{ + map_map(committed, "result") + }} else if map_bool(committed, "ok", false) == false => {{ + committed + }} else => {{ + result + }} + }} + }} + }} +}} +"# + ) +} + +fn manifest(id: &str) -> String { + format!( + r#"format: rustscript.plugin/v1 +id: {id} +version: 1.0.0 +kind: tool +runtime: ">=0.1.0, <0.2.0" +contract: agent.tool/v1 +host_abi: agent.cap/v1 +entry: + path: run.rss + export: run +dependencies: [] +capabilities: + - workspace.read +effects: + contract: agent.effects/v1 + entries: + run: + direct: + - kind: workspace.fs.read + scope: bound_workspace + recovery: none + delegated: [] +"# + ) +} + +fn install_tool(store: &PackageStore, signer: &Signer, dir: &Path, id: &str, offset: i64) { + fs::create_dir_all(dir).unwrap(); + fs::write(dir.join("manifest.yaml"), manifest(id)).unwrap(); + fs::write(dir.join("run.rss"), tool_source(offset)).unwrap(); + let payload = store.signature_payload(dir).unwrap(); + let signature = signer.pair.sign(&payload); + fs::write(dir.join("signature.ed25519"), signature.as_ref()).unwrap(); + store.install(dir, &auth(signer)).unwrap(); +} + +fn resolve_tool(store: &PackageStore, id: &str) -> GraphLock { + store + .resolve(&ResolveRequest { + roots: vec![PackageRequirement { + id: id.to_string(), + version: "1.0.0".to_string(), + }], + host_runtime: HOST_RUNTIME.to_string(), + }) + .unwrap_or_else(|error| panic!("resolve {id}: {error}")) +} + +fn artifact_refs(lock: &GraphLock) -> Vec { + lock.packages + .iter() + .map(|package| PackageArtifactRef { + package_id: package.id.clone(), + version: package.version.clone(), + kind: package.kind.clone(), + artifact_id: package.artifact_id.clone(), + }) + .collect() +} + +fn workspace_home(root: &Path) -> (PathBuf, PathBuf) { + let home = root.join("workspace-home"); + let workspace = root.join("workspace"); + fs::create_dir_all(&home).unwrap(); + fs::create_dir_all(&workspace).unwrap(); + let config = format!( + "version: 1\nmodel:\n provider: local-test\n model: synthetic\nproviders:\n local-test:\n protocol: codex-responses\n base_url: https://127.0.0.1:1\nworkspaces:\n allowed_roots:\n - '{}'\n default: '{}'\n named:\n lifecycle: '{}'\n", + workspace.display(), + workspace.display(), + workspace.display() + ); + fs::write(home.join("config.yaml"), config).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for path in [root, &home, &workspace] { + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); + } + fs::set_permissions(home.join("config.yaml"), fs::Permissions::from_mode(0o600)).unwrap(); + } + fs::write(workspace.join("note.txt"), b"AB-marker-bytes\n").unwrap(); + (home, workspace) +} + +fn tool_response(call_id: &str) -> Value { + json!({ + "text": "", + "tool_calls": [{ + "id": call_id, + "name": "read_file", + "arguments": {"path": "note.txt"} + }], + "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + "reasoning": "", + "stop_reason": "tool_calls" + }) +} + +fn text_response(text: &str) -> Value { + json!({ + "text": text, + "tool_calls": [], + "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + "reasoning": "", + "stop_reason": "stop" + }) +} + +fn queue_read(provider: &ScriptedProvider, call_id: &str) { + provider.push_ok(tool_response(call_id)); + provider.push_ok(text_response("done")); +} + +fn observed_text( + service: &rustscript_agent::AgentService, + run_id: &str, + session_id: &str, +) -> String { + let events = serde_json::to_string(&service.run_events(run_id)).unwrap_or_default(); + let messages = serde_json::to_string(&service.session_messages(session_id)).unwrap_or_default(); + format!("{events}\n{messages}") +} + +fn open_gateway(db: &Path, home: &Path, store: Arc) -> AgentGatewayState { + let state = AgentGatewayState::with_sqlite_path(AgentGatewayConfig::default(), db) + .expect("sqlite gateway") + .with_workspace_home(home); + state + .service() + .install_validated_tool_store(store) + .expect("validated tool store"); + state +} + +#[tokio::test(flavor = "multi_thread")] +async fn production_signed_a_survives_activation_of_b_and_reopen() { + let root = temp_root("ab"); + let signer = signer(); + let store = Arc::new(PackageStore::open(root.join("packages")).unwrap()); + install_tool( + &store, + &signer, + &root.join("pkg-a"), + "org.rustscript.tools.proda", + 0, + ); + install_tool( + &store, + &signer, + &root.join("pkg-b"), + "org.rustscript.tools.prodb", + 1, + ); + let lock_a = resolve_tool(&store, "org.rustscript.tools.proda"); + let lock_b = resolve_tool(&store, "org.rustscript.tools.prodb"); + let (home, workspace) = workspace_home(&root); + let db = root.join("state.db"); + let state = open_gateway(&db, &home, Arc::clone(&store)); + let persistence = state.persistence().expect("sqlite persistence"); + let control = PluginControlStore::new(persistence.as_ref()); + control.store_lock(&lock_a).unwrap(); + control.store_lock(&lock_b).unwrap(); + let activated = control + .activate(&lock_a.graph_id, 0, &artifact_refs(&lock_a)) + .unwrap(); + assert_ne!( + activated.generation, 0, + "bootstrap CAS must not leave generation 0" + ); + + let provider = ScriptedProvider::new(); + queue_read(&provider, "call-a"); + state + .service() + .inject_provider_host(Arc::new(provider.clone())); + let admitted = state + .service() + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({ + "message": "read note", + "graph_id": lock_b.graph_id, + "tool_graph_id": lock_b.graph_id + }), + idempotency_key: Some("signed-a-key".to_string()), + idempotency_hash: Some("fnv64:0000000000000001".to_string()), + platform: "tools_pin_production_ab".to_string(), + ..AdmitRunRequest::default() + }, + Some("name:lifecycle".to_string()), + ) + .await + .expect("admit A"); + let original_receipt = control.pin_for_run(&admitted.run_id).unwrap(); + assert_eq!(original_receipt["graph_id"], lock_a.graph_id); + assert_eq!(original_receipt["generation"], activated.generation); + state.service().evict_run_handle(&admitted.run_id); + let next = control + .activate( + &lock_b.graph_id, + activated.generation, + &artifact_refs(&lock_b), + ) + .unwrap(); + assert_ne!(next.graph_id, lock_a.graph_id); + + state + .service() + .clone() + .run_worker(admitted.run_id.clone(), "ignored".to_string()) + .await; + let restored = observed_text( + state.service().as_ref(), + &admitted.run_id, + &admitted.session_id, + ); + assert!( + restored.contains("AB-marker-bytes"), + "restored run must execute signed A, not the default or B: {restored}" + ); + assert!( + !restored.contains("B-marker-bytes\n") || restored.contains("AB-marker-bytes"), + "A bytes must remain distinguishable: {restored}" + ); + + queue_read(&provider, "call-b"); + state + .service() + .inject_provider_host(Arc::new(provider.clone())); + let admitted_b = state + .service() + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({ + "message": "read note", + "graph_id": lock_a.graph_id + }), + platform: "tools_pin_production_ab".to_string(), + ..AdmitRunRequest::default() + }, + Some("name:lifecycle".to_string()), + ) + .await + .expect("admit B"); + state + .service() + .clone() + .run_worker(admitted_b.run_id.clone(), "ignored".to_string()) + .await; + let current = observed_text( + state.service().as_ref(), + &admitted_b.run_id, + &admitted_b.session_id, + ); + assert!( + current.contains("B-marker-bytes"), + "new run must execute signed B: {current}" + ); + assert!( + !current.contains("AB-marker-bytes"), + "B must not emit A's offset: {current}" + ); + + let replay_request = AdmitRunRequest { + input: json!({ "message":"read note", "graph_id":lock_b.graph_id, "tool_graph_id":lock_b.graph_id }), + idempotency_key: Some("signed-a-key".to_string()), + idempotency_hash: Some("fnv64:0000000000000001".to_string()), + platform: "tools_pin_production_ab".to_string(), + ..Default::default() + }; + let before_replay = state.service().run_events(&admitted.run_id).len(); + let replay = state + .service() + .admit_external_with_workspace_selector( + replay_request.clone(), + Some("name:lifecycle".to_string()), + ) + .await + .unwrap(); + assert!(replay.replayed); + assert_eq!(replay.run_id, admitted.run_id); + assert_eq!( + control.pin_for_run(&admitted.run_id).unwrap(), + original_receipt + ); + assert_eq!( + state.service().run_events(&admitted.run_id).len(), + before_replay + ); + let receipt_b = control.pin_for_run(&admitted_b.run_id).unwrap(); + drop(state); + drop(persistence); + // Reopen the original installed artifacts with a cold verification cache. + // Damage unrelated active B: restoring A must read only A's required capture. + fs::write( + root.join("packages/artifacts") + .join(&lock_b.packages[0].artifact_id) + .join("sources/run.rss"), + "damaged unrelated B", + ) + .unwrap(); + let fresh = Arc::new(PackageStore::open(root.join("packages")).unwrap()); + fresh.bind_policy(&auth(&signer)); + let reopened = open_gateway(&db, &home, Arc::clone(&fresh)); + let persistence = reopened.persistence().unwrap(); + let control = PluginControlStore::new(persistence.as_ref()); + assert_eq!( + control.pin_for_run(&admitted.run_id).unwrap(), + original_receipt + ); + let event_count = reopened.service().run_events(&admitted.run_id).len(); + let replayed = reopened + .service() + .admit_external_with_workspace_selector( + replay_request.clone(), + Some("name:lifecycle".to_string()), + ) + .await + .unwrap(); + assert!(replayed.replayed); + assert_eq!(replayed.run_id, admitted.run_id); + assert_eq!( + control.pin_for_run(&admitted.run_id).unwrap(), + original_receipt + ); + assert_eq!( + reopened.service().run_events(&admitted.run_id).len(), + event_count + ); + // Execute the recovered actual A Program in a fresh Native adapter with + // fixture-owned original parent facts, rather than asserting old history. + let binding = rustscript_agent::plugins::restore_owned_pin( + persistence.as_ref(), + &fresh, + &admitted.run_id, + ) + .unwrap(); + let durable = Arc::new(ParentFacts { + arguments: json!({"path":"note.txt"}), + started: 0.into(), + committed: 0.into(), + }); + let result = invoke_original( + rustscript_agent::AgentHostBridges { + pinned_tool: Some(binding), + ..Default::default() + }, + &original_receipt, + &workspace, + json!({"path":"note.txt"}), + Arc::clone(&durable), + ); + assert_eq!(result["ok"], true, "cold original A must execute: {result}"); + assert_eq!(result["content"], "AB-marker-bytes\n"); + assert_eq!(durable.started.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!( + durable.committed.load(std::sync::atomic::Ordering::SeqCst), + 1 + ); + let mut displayed = rustscript_agent::plugins::admit_pinned_tool_run( + persistence.as_ref(), &fresh, + &json!({"idempotency_scope":"api:chat", "idempotency_key":"signed-a-key", "request_hash":"fnv64:0000000000000001", "workspace_identity_json":json!({"request_selector_present":true,"request_selector":"name:lifecycle"}).to_string()}), + &rustscript_agent::plugins::PinnedAdmitContext { registry_identity:"ignored-replay-context".to_string(), workspace:workspace.clone() }, + ).unwrap(); + displayed.graph_id = lock_b.graph_id.clone(); + displayed.generation = next.generation; + displayed.ref_id = receipt_b["ref_id"].as_str().unwrap().to_string(); + displayed.run_id = admitted_b.run_id.clone(); + displayed.session_id = admitted_b.session_id.clone(); + displayed.profile = receipt_b["profile"].as_str().unwrap().to_string(); + displayed.entry = "forged-entry".to_string(); + let counts = Arc::new(ParentFacts { + arguments: json!({"path":"note.txt"}), + started: 0.into(), + committed: 0.into(), + }); + let rejected = invoke_original( + rustscript_agent::AgentHostBridges { + pinned_tool: Some(displayed.program_handle()), + ..Default::default() + }, + &receipt_b, + &workspace, + json!({"path":"note.txt"}), + Arc::clone(&counts), + ); + assert_eq!(rejected["ok"], false, "display edits cannot relabel A as B"); + assert_eq!(counts.started.load(std::sync::atomic::Ordering::SeqCst), 0); + let original = invoke_original( + rustscript_agent::AgentHostBridges { + pinned_tool: Some(displayed.program_handle()), + ..Default::default() + }, + &original_receipt, + &workspace, + json!({"path":"note.txt"}), + Arc::clone(&counts), + ); + assert_eq!( + original["content"], "AB-marker-bytes\n", + "display edits cannot alter original A binding" + ); + let source_a = root + .join("packages/artifacts") + .join(&lock_a.packages[0].artifact_id) + .join("sources/run.rss"); + let saved_source = fs::read(&source_a).unwrap(); + for replacement in [ + None, + Some("pub fn run(context: map) -> map { { ok: true } }"), + ] { + match replacement { + None => fs::remove_file(&source_a).unwrap(), + Some(source) => fs::write(&source_a, source).unwrap(), + } + let cold = Arc::new(PackageStore::open(root.join("packages")).unwrap()); + cold.bind_policy(&auth(&signer)); + reopened + .service() + .install_validated_tool_store(cold) + .unwrap(); + assert!( + reopened + .service() + .admit_external_with_workspace_selector( + replay_request.clone(), + Some("name:lifecycle".to_string()) + ) + .await + .is_err(), + "missing/substituted A must fail closed" + ); + assert_eq!( + reopened.service().run_events(&admitted.run_id).len(), + event_count + ); + fs::write(&source_a, &saved_source).unwrap(); + } + let restored_store = Arc::new(PackageStore::open(root.join("packages")).unwrap()); + restored_store.bind_policy(&auth(&signer)); + reopened + .service() + .install_validated_tool_store(restored_store) + .unwrap(); + println!( + "PRODUCTION_COLD_RECOVERY {}", + json!({"original_receipt":original_receipt,"restored_result":result,"started":durable.started.load(std::sync::atomic::Ordering::SeqCst),"committed":durable.committed.load(std::sync::atomic::Ordering::SeqCst)}) + ); + // Replacing a ref for the same owner cannot replace its original receipt. + let original_ref = rustscript_agent::plugins::GraphRetention { + graph_id: lock_a.graph_id.clone(), + owner_kind: "run".to_string(), + owner_id: admitted.run_id.clone(), + ref_id: original_receipt["ref_id"].as_str().unwrap().to_string(), + }; + control.release(&original_ref).unwrap(); + control + .retain(&rustscript_agent::plugins::GraphRetention { + ref_id: "replacement-ref".to_string(), + ..original_ref + }) + .unwrap(); + let rejected = reopened + .service() + .admit_external_with_workspace_selector(replay_request, Some("name:lifecycle".to_string())) + .await; + assert!( + rejected.is_err(), + "released original ref must reject service replay" + ); + assert_eq!( + reopened.service().run_events(&admitted.run_id).len(), + event_count + ); +} + +/// Session deletion releases only its own run refs in the original RSS cascade. +/// Completion alone retains a pin; unrelated sessions, owner kinds and active +/// deployment each independently protect their graph from collection. +#[tokio::test(flavor = "multi_thread")] +async fn session_delete_releases_native_run_refs_without_collecting_other_owners() { + use rustscript_agent::plugins::GraphRetention; + + let root = temp_root("session-retention"); + let signer = signer(); + let store = Arc::new(PackageStore::open(root.join("packages")).unwrap()); + for (label, offset) in [("reta", 0), ("retb", 1)] { + install_tool( + &store, + &signer, + &root.join(label), + &format!("org.rustscript.tools.{label}"), + offset, + ); + } + let a = resolve_tool(&store, "org.rustscript.tools.reta"); + let b = resolve_tool(&store, "org.rustscript.tools.retb"); + let (home, _) = workspace_home(&root); + let state = open_gateway(&root.join("state.db"), &home, Arc::clone(&store)); + let persistence = state.persistence().unwrap(); + let control = PluginControlStore::new(persistence.as_ref()); + control.store_lock(&a).unwrap(); + control.store_lock(&b).unwrap(); + let active = control + .activate(&a.graph_id, 0, &artifact_refs(&a)) + .unwrap(); + let provider = ScriptedProvider::new(); + state + .service() + .inject_provider_host(Arc::new(provider.clone())); + + // Two completed runs in the deleted session, plus a still-live A run in + // another session. Every pin is produced by normal Native admission. + let mut completed = Vec::::new(); + for call in ["ret-first", "ret-second"] { + queue_read(&provider, call); + state + .service() + .inject_provider_host(Arc::new(provider.clone())); + let run = state + .service() + .admit_external_with_workspace_selector( + AdmitRunRequest { + session_id: completed.first().map(|run| run.session_id.clone()), + input: json!({"message":"read note"}), + ..Default::default() + }, + Some("name:lifecycle".to_string()), + ) + .await + .unwrap(); + state + .service() + .clone() + .run_worker(run.run_id.clone(), "ignored".to_string()) + .await; + assert!( + observed_text(state.service().as_ref(), &run.run_id, &run.session_id) + .contains("AB-marker-bytes") + ); + assert_eq!( + control.pin_for_run(&run.run_id).unwrap()["graph_id"], + a.graph_id + ); + assert!( + state + .service() + .run_events(&run.run_id) + .iter() + .any(|event| event["event"] == "tool.completed") + ); + completed.push(run); + } + let live = state + .service() + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"read note"}), + ..Default::default() + }, + Some("name:lifecycle".to_string()), + ) + .await + .unwrap(); + assert_ne!(live.session_id, completed[0].session_id); + let live_receipt = control.pin_for_run(&live.run_id).unwrap(); + assert_eq!(live_receipt["graph_id"], a.graph_id); + assert_eq!(control.retained(&a.graph_id).unwrap().len(), 3); + + // Same owner id, different owner kind: session deletion must preserve it. + let checkpoint = GraphRetention { + graph_id: a.graph_id.clone(), + owner_kind: "checkpoint".to_string(), + owner_id: completed[0].run_id.clone(), + ref_id: "ret-checkpoint".to_string(), + }; + control.retain(&checkpoint).unwrap(); + control + .activate(&b.graph_id, active.generation, &artifact_refs(&b)) + .unwrap(); + assert!(control.collect_unreferenced().unwrap().is_empty()); + assert!(control.load_lock(&a.graph_id).is_ok()); + + persistence + .session_delete(&completed[0].session_id) + .unwrap(); + assert_eq!( + persistence + .session_delete(&completed[0].session_id) + .unwrap_err() + .code, + "session_not_found" + ); + for run in &completed { + assert!( + persistence.run_get(&run.run_id).unwrap()["rows"] + .as_array() + .unwrap() + .is_empty() + ); + let missing = control.pin_for_run(&run.run_id).unwrap(); + assert_eq!(missing["pinned"], false, "deleted pin receipt must be gone"); + assert_eq!(missing["ref_id"], ""); + assert!( + rustscript_agent::plugins::restore_owned_pin(persistence.as_ref(), &store, &run.run_id) + .is_err() + ); + } + assert!( + persistence + .message_list(&completed[0].session_id, 0) + .unwrap()["rows"] + .as_array() + .unwrap() + .is_empty() + ); + let after_first_delete = control.retained(&a.graph_id).unwrap(); + assert!(after_first_delete.contains(&checkpoint)); + assert_eq!(control.pin_for_run(&live.run_id).unwrap(), live_receipt); + assert!(control.collect_unreferenced().unwrap().is_empty()); + + // Isolate the other session's run as the only legitimate A owner. It can + // still execute the original A after B activation and the first deletion. + control.release(&checkpoint).unwrap(); + assert!(control.collect_unreferenced().unwrap().is_empty()); + queue_read(&provider, "ret-live"); + state + .service() + .inject_provider_host(Arc::new(provider.clone())); + state + .service() + .clone() + .run_worker(live.run_id.clone(), "ignored".to_string()) + .await; + let live_observed = observed_text(state.service().as_ref(), &live.run_id, &live.session_id); + assert!( + live_observed.contains("AB-marker-bytes"), + "live A result: {live_observed}" + ); + assert_eq!(control.pin_for_run(&live.run_id).unwrap(), live_receipt); + + // Independently isolate the other owner kind, with no surviving A run. + control.retain(&checkpoint).unwrap(); + persistence.session_delete(&live.session_id).unwrap(); + assert_eq!(control.pin_for_run(&live.run_id).unwrap()["pinned"], false); + assert!(control.retained(&a.graph_id).unwrap().contains(&checkpoint)); + assert!(control.collect_unreferenced().unwrap().is_empty()); + control.release(&checkpoint).unwrap(); + let collected = control.collect_unreferenced().unwrap(); + assert_eq!( + collected, + vec![a.graph_id.clone()], + "RET-A-001: deleted sessions must no longer pin old A" + ); + assert!(control.load_lock(&a.graph_id).is_err()); + assert!(control.retained(&a.graph_id).unwrap().is_empty()); + assert_eq!( + after_first_delete.len(), + 2, + "only live A run and checkpoint may survive the first deletion" + ); + assert_eq!( + control.load_lock(&b.graph_id).unwrap().lock.graph_id, + b.graph_id + ); + assert_eq!(control.activation().unwrap().graph_id, b.graph_id); + assert!( + control.retained(&b.graph_id).unwrap().is_empty(), + "active B alone protects its lock" + ); + + // B remains executable after A has actually been collected. + queue_read(&provider, "ret-b"); + state + .service() + .inject_provider_host(Arc::new(provider.clone())); + let run_b = state + .service() + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"read note"}), + ..Default::default() + }, + Some("name:lifecycle".to_string()), + ) + .await + .unwrap(); + state + .service() + .clone() + .run_worker(run_b.run_id.clone(), "ignored".to_string()) + .await; + let result_b = observed_text(state.service().as_ref(), &run_b.run_id, &run_b.session_id); + assert!(result_b.contains("B-marker-bytes")); + assert!(!result_b.contains("AB-marker-bytes")); + + // Empty sessions have neither Native receipts nor refs. The session row + // result, not either preceding delete result, determines existence. + persistence + .session_create(&json!({ + "id":"ret-empty", "profile":"default", "platform":"test", + "account_id":"empty", "chat_id":"empty", "thread_id":"", "user_id":"", + "generation":1, "system_prompt":"", "model":"test", "provider":"test", + "toolset_hash":"", "metadata_json":"{}", "title":"", "end_reason":"", "now_ms":1, + })) + .unwrap(); + persistence + .session_delete("ret-empty") + .expect("empty existing session must delete successfully"); + assert_eq!( + persistence.session_delete("ret-empty").unwrap_err().code, + "session_not_found" + ); + assert_eq!( + control.pin_for_run(&run_b.run_id).unwrap()["graph_id"], + b.graph_id + ); + println!( + "RET_A_001 collected_old_a=true completed_runs=2 other_session_executed_a=true checkpoint_preserved=true active_b_executed=true deleted_receipts_absent=true empty_session_delete=true" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn http_signed_graphs_ignore_request_graph_and_differ() { + let root = temp_root("http"); + let signer = signer(); + let store = Arc::new(PackageStore::open(root.join("packages")).unwrap()); + install_tool( + &store, + &signer, + &root.join("pkg-a"), + "org.rustscript.tools.httpa", + 0, + ); + install_tool( + &store, + &signer, + &root.join("pkg-b"), + "org.rustscript.tools.httpb", + 1, + ); + let lock_a = resolve_tool(&store, "org.rustscript.tools.httpa"); + let lock_b = resolve_tool(&store, "org.rustscript.tools.httpb"); + let (home, _workspace) = workspace_home(&root); + let db = root.join("state.db"); + let state = open_gateway(&db, &home, Arc::clone(&store)); + let persistence = state.persistence().expect("sqlite persistence"); + let control = PluginControlStore::new(persistence.as_ref()); + control.store_lock(&lock_a).unwrap(); + control.store_lock(&lock_b).unwrap(); + control + .activate(&lock_a.graph_id, 0, &artifact_refs(&lock_a)) + .unwrap(); + let provider = ScriptedProvider::new(); + queue_read(&provider, "http-a"); + queue_read(&provider, "http-b"); + state.service().inject_provider_host(Arc::new(provider)); + let router = build_agent_gateway_app(state.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let app = format!("http://{}", listener.local_addr().unwrap()); + let (shutdown, shutdown_rx) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + axum::serve(listener, router) + .with_graceful_shutdown(async { + let _ = shutdown_rx.await; + }) + .await + }); + + let (status, created) = json_request( + &app, + "POST", + "/api/sessions", + json!({"source": "yahu", "model": "local-agent"}), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{created}"); + let session_id = created["session"]["id"] + .as_str() + .expect("session") + .to_string(); + + let (status, run_a) = json_request( + &app, + "POST", + "/v1/runs", + json!({ + "session_id": session_id, + "input": {"message": "read note", "graph_id": lock_b.graph_id}, + "workspace": "name:lifecycle", + "graph_id": lock_b.graph_id, + "tool_graph_id": lock_b.graph_id + }), + ) + .await; + assert_eq!(status, StatusCode::ACCEPTED, "{run_a}"); + let run_a_id = run_a["run_id"].as_str().expect("run a").to_string(); + let events_a = sse_body(&app, &run_a_id).await; + assert!(events_a.contains("tool.completed"), "tool must commit"); + let (_, messages_a) = json_request( + &app, + "GET", + &format!("/api/sessions/{session_id}/messages"), + Value::Null, + ) + .await; + let body_a = messages_a.to_string(); + assert!( + body_a.contains("AB-marker-bytes"), + "HTTP run must emit signed A despite request graph B: {body_a}" + ); + + let provider_b = ScriptedProvider::new(); + queue_read(&provider_b, "http-b"); + state.service().inject_provider_host(Arc::new(provider_b)); + let active = control.activation().unwrap(); + control + .activate(&lock_b.graph_id, active.generation, &artifact_refs(&lock_b)) + .unwrap(); + let (status, run_b) = json_request( + &app, + "POST", + "/v1/runs", + json!({ + "session_id": session_id, + "input": {"message": "read note", "graph_id": lock_a.graph_id}, + "workspace": "name:lifecycle", + "graph_id": lock_a.graph_id + }), + ) + .await; + assert_eq!(status, StatusCode::ACCEPTED, "{run_b}"); + let run_b_id = run_b["run_id"].as_str().expect("run b").to_string(); + let events_b = sse_body(&app, &run_b_id).await; + assert!(events_b.contains("tool.completed"), "tool must commit"); + let (_, messages_b) = json_request( + &app, + "GET", + &format!("/api/sessions/{session_id}/messages"), + Value::Null, + ) + .await; + let body_b = messages_b["data"] + .as_array() + .expect("messages") + .iter() + .filter(|message| message["run_id"] == run_b_id) + .map(Value::to_string) + .collect::>() + .join( + " +", + ); + assert!( + body_b.contains("B-marker-bytes"), + "HTTP run must emit signed B: {body_b}" + ); + assert!( + !body_b.contains("AB-marker-bytes"), + "request graph A must not select A while B is active: {body_b}" + ); + println!( + "PRODUCTION_HTTP {}", + json!({"run_a":run_a_id,"messages_a":messages_a,"run_b":run_b_id,"messages_b":messages_b}) + ); + shutdown.send(()).unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(2), server) + .await + .unwrap() + .unwrap() + .unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn production_arguments_json_matches_map_and_malformed_has_no_started() { + let root = temp_root("arguments-json"); + let (home, _) = workspace_home(&root); + let state = + AgentGatewayState::with_sqlite_path(AgentGatewayConfig::default(), root.join("state.db")) + .unwrap() + .with_workspace_home(&home); + let mut outputs = Vec::new(); + for (label, call) in [ + ( + "map", + json!({"id":"arg-call", "name":"read_file", "arguments":{"path":"note.txt"}}), + ), + ( + "json", + json!({"id":"arg-call", "name":"read_file", "arguments_json":"{\"path\":\"note.txt\"}"}), + ), + ( + "malformed", + json!({"id":"arg-call", "name":"read_file", "arguments_json":"{bad"}), + ), + ] { + let provider = ScriptedProvider::new(); + provider.push_ok(json!({"text":"", "tool_calls":[call], "usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}, "reasoning":"", "stop_reason":"tool_calls"})); + provider.push_ok(text_response("done")); + state.service().inject_provider_host(Arc::new(provider)); + let admitted = state + .service() + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"read note"}), + ..Default::default() + }, + Some("name:lifecycle".to_string()), + ) + .await + .unwrap(); + state + .service() + .clone() + .run_worker(admitted.run_id.clone(), "ignored".to_string()) + .await; + let events = serde_json::to_value(state.service().run_events(&admitted.run_id)).unwrap(); + let started = events + .as_array() + .unwrap() + .iter() + .filter(|event| event["event"] == "tool.started") + .count(); + let messages = + serde_json::to_value(state.service().session_messages(&admitted.session_id)).unwrap(); + if label == "malformed" { + assert_eq!( + started, 0, + "malformed payload must have no effect: {events}" + ); + assert!(!messages.to_string().contains("AB-marker-bytes")); + } else { + assert_eq!(started, 1, "valid {label} must execute: {events}"); + let content = messages + .as_array() + .unwrap() + .iter() + .flat_map(|message| message["content"].as_array().into_iter().flatten()) + .find(|block| block["type"] == "tool_result") + .expect("canonical tool result") + .clone(); + assert!( + content.to_string().contains("AB-marker-bytes"), + "{label}: {content}" + ); + outputs.push(content); + } + } + assert_eq!( + outputs[0], outputs[1], + "legacy form must preserve canonical result" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn no_workspace_pin_restores_without_granting_path_authority() { + let root = temp_root("no-workspace"); + let (home, workspace) = workspace_home(&root); + let config_path = home.join("config.yaml"); + let config = fs::read_to_string(&config_path) + .unwrap() + .replace(&format!(" default: '{}'\n", workspace.display()), ""); + fs::write(&config_path, config).unwrap(); + let state = + AgentGatewayState::with_sqlite_path(AgentGatewayConfig::default(), root.join("state.db")) + .unwrap() + .with_workspace_home(&home); + let provider = ScriptedProvider::new(); + queue_read(&provider, "no-workspace-call"); + state.service().inject_provider_host(Arc::new(provider)); + let admitted = state + .service() + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"read note"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + state.service().evict_run_handle(&admitted.run_id); + state + .service() + .clone() + .run_worker(admitted.run_id.clone(), "ignored".to_string()) + .await; + let events = serde_json::to_string(&state.service().run_events(&admitted.run_id)).unwrap(); + assert!( + !events.contains("tool_pin_unavailable"), + "NoAuthority is a valid original binding: {events}" + ); + assert!( + events.contains("workspace_unavailable"), + "native IO still needs real workspace authority: {events}" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn unkeyed_pin_receipt_does_not_enter_public_idempotency_namespace() { + let root = temp_root("receipt-namespace"); + let (home, _) = workspace_home(&root); + let db = root.join("state.db"); + let state = AgentGatewayState::with_sqlite_path(AgentGatewayConfig::default(), &db) + .unwrap() + .with_workspace_home(&home); + let original = state + .service() + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"first"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + drop(state); + let reopened = AgentGatewayState::with_sqlite_path(AgentGatewayConfig::default(), &db) + .unwrap() + .with_workspace_home(&home); + let new = reopened + .service() + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"second"}), + idempotency_key: Some(original.run_id.clone()), + idempotency_hash: Some("fnv64:0000000000000002".to_string()), + ..Default::default() + }, + None, + ) + .await + .expect("Native receipt scope must not consume a public key"); + assert!(!new.replayed); + assert_ne!(new.run_id, original.run_id); +} + +#[tokio::test(flavor = "multi_thread")] +async fn prepared_default_admission_keeps_original_250ms_budget() { + let root = temp_root("admission-budget"); + let (home, _) = workspace_home(&root); + let config = AgentGatewayConfig { + run_timeout: std::time::Duration::from_millis(250), + ..Default::default() + }; + let state = AgentGatewayState::with_sqlite_path(config, root.join("state.db")) + .unwrap() + .with_workspace_home(&home); + state + .service() + .materialize_cached_runner() + .expect("prepare Native tools with the existing runner setup path"); + let admitted = state + .service() + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"read note"}), + ..Default::default() + }, + Some("name:lifecycle".to_string()), + ) + .await + .expect("prepared default must fit original admission budget"); + let handle = state.service().handle(&admitted.run_id).unwrap(); + let deadline = handle.cancellation().deadline_instant().unwrap(); + assert!( + deadline.saturating_duration_since(std::time::Instant::now()) + <= std::time::Duration::from_millis(250) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn activation_race_reprepares_inside_original_admission_budget() { + let root = temp_root("activation-race"); + let signer = signer(); + let store = Arc::new(PackageStore::open(root.join("packages")).unwrap()); + install_tool( + &store, + &signer, + &root.join("pkg-a"), + "org.rustscript.tools.racea", + 0, + ); + install_tool( + &store, + &signer, + &root.join("pkg-b"), + "org.rustscript.tools.raceb", + 1, + ); + let a = resolve_tool(&store, "org.rustscript.tools.racea"); + let b = resolve_tool(&store, "org.rustscript.tools.raceb"); + let (home, _) = workspace_home(&root); + let state = open_gateway(&root.join("state.db"), &home, store); + let persistence = state.persistence().unwrap(); + let control = PluginControlStore::new(persistence.as_ref()); + control.store_lock(&a).unwrap(); + control.store_lock(&b).unwrap(); + let active = control + .activate(&a.graph_id, 0, &artifact_refs(&a)) + .unwrap(); + let weak = Arc::downgrade(&persistence); + let b_raced = b.clone(); + let attempted = Arc::new(std::sync::Mutex::new(Value::Null)); + let recorded = Arc::clone(&attempted); + persistence.inject_admission_precommit_hook(move |payload| { + *recorded.lock().unwrap() = payload.clone(); + let persistence = weak.upgrade().unwrap(); + PluginControlStore::new(persistence.as_ref()) + .activate( + &b_raced.graph_id, + active.generation, + &artifact_refs(&b_raced), + ) + .unwrap(); + }); + let admitted = state + .service() + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"read note"}), + ..Default::default() + }, + Some("name:lifecycle".to_string()), + ) + .await + .expect("activation drift must reprepare, with no partial admission"); + let receipt = control.pin_for_run(&admitted.run_id).unwrap(); + assert_eq!(receipt["graph_id"], b.graph_id); + assert!(control.retained(&a.graph_id).unwrap().is_empty()); + assert_eq!(control.retained(&b.graph_id).unwrap().len(), 1); + let stale = attempted.lock().unwrap(); + if stale["run_id"] != admitted.run_id { + assert!( + persistence + .run_get(stale["run_id"].as_str().unwrap()) + .unwrap()["rows"] + .as_array() + .unwrap() + .is_empty() + ); + } + if stale["session_id"] != admitted.session_id { + assert!( + persistence + .session_get(stale["session_id"].as_str().unwrap()) + .unwrap()["rows"] + .as_array() + .unwrap() + .is_empty() + ); + } + assert_eq!( + state.service().session_messages(&admitted.session_id).len(), + 1 + ); + assert_eq!( + state + .service() + .run_events(&admitted.run_id) + .iter() + .filter(|event| event["event"] == "run.started") + .count(), + 1 + ); +} + +struct ParentFacts { + arguments: Value, + started: std::sync::atomic::AtomicUsize, + committed: std::sync::atomic::AtomicUsize, +} + +impl rustscript_agent::capabilities::DurableToolLifecycle for ParentFacts { + fn assert_active_run( + &self, + _: &str, + ) -> Result<(), rustscript_agent::capabilities::LifecycleError> { + Ok(()) + } + fn prepare_parent( + &self, + _: &str, + call: &str, + name: &str, + ) -> Result<(), rustscript_agent::capabilities::LifecycleError> { + if call == "original-call" && name == "read_file" { + Ok(()) + } else { + Err(rustscript_agent::capabilities::LifecycleError::MissingParent) + } + } + fn parent_arguments( + &self, + _: &str, + call: &str, + name: &str, + ) -> Result, rustscript_agent::capabilities::LifecycleError> { + self.prepare_parent("", call, name)?; + Ok(Some(self.arguments.clone())) + } + fn replay_result( + &self, + _: &str, + _: &str, + _: &str, + ) -> Result, rustscript_agent::capabilities::LifecycleError> { + Ok(None) + } + fn commit_started( + &self, + _: &rustscript_agent::capabilities::DurableStarted, + ) -> Result<(), rustscript_agent::capabilities::LifecycleError> { + self.started + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(()) + } + fn commit_result( + &self, + _: &str, + result: &Value, + ) -> Result { + self.committed + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(result.clone()) + } + fn interrupt(&self, _: &str) -> Result<(), rustscript_agent::capabilities::LifecycleError> { + Ok(()) + } +} + +struct NoCancel; +impl rustscript_agent::capabilities::CancellationFlag for NoCancel { + fn is_cancelled(&self) -> bool { + false + } +} + +fn invoke_original( + mut host: rustscript_agent::AgentHostBridges, + receipt: &Value, + workspace: &Path, + arguments: Value, + durable: Arc, +) -> Value { + use rustscript_agent::capabilities::*; + let owner = CapabilityOwner::new( + receipt["profile"].as_str().unwrap(), + receipt["session_id"].as_str().unwrap(), + receipt["run_id"].as_str().unwrap(), + ) + .unwrap(); + let lifecycle = CapabilityLifecycle::builder() + .owner(owner.clone()) + .registry_identity(receipt["registry_identity"].as_str().unwrap()) + .workspace(workspace) + .limits(LifecycleLimits { + max_tool_calls: 8, + max_output_bytes: 4096, + max_summary_bytes: 256, + }) + .deadline_ms(9_000_000_000_000) + .clock(Arc::new(SystemClock)) + .tokens(Arc::new(UuidIssuer)) + .durable(durable) + .approval(Arc::new(AllowAllApproval)) + .cancellation(Arc::new(NoCancel)) + .generation(1) + .build() + .unwrap(); + let fs = Arc::new( + FilesystemCapability::new( + lifecycle.clone(), + owner.clone(), + FilesystemLimits { + max_read_bytes: 4096, + max_write_bytes: 4096, + max_list_entries: 16, + }, + ) + .unwrap(), + ); + let canonical = arguments.to_string(); + let quoted = serde_json::to_string(&canonical).unwrap(); + let source = format!( + r#"pub fn run(context: map) -> map {{ + agent_runtime::invoke_pinned_tool({{ + tool_name: "read_file", call_id: "original-call", + canonical_arguments: {quoted}, argument_digest: {quoted}, canonical_input: "", + run_id: "", registry_identity: "", risk_class: "read", summary: "read note", untrusted_graph_id: "" + }}) + }}"# + ); + host.lifecycle = Some(Arc::new(lifecycle)); + host.capability_owner = Some(owner); + host.filesystem = Some(fs); + host.allow_stateless_pin = false; + let result = rustscript_agent::AgentRunner::from_source( + &source, + rustscript_agent::AgentConfig::default(), + ) + .unwrap() + .with_host(host) + .run_with_context(rustscript_vm::Value::map(vec![])) + .unwrap(); + let rustscript_vm::Value::Map(entries) = result else { + panic!("result map"); + }; + let mut object = serde_json::Map::new(); + for (key, value) in entries.iter() { + if let rustscript_vm::Value::String(key) = key { + let value = match value { + rustscript_vm::Value::String(value) => json!(value.as_str()), + rustscript_vm::Value::Bool(value) => json!(value), + _ => Value::Null, + }; + object.insert(key.to_string(), value); + } + } + Value::Object(object) +} + +#[tokio::test(flavor = "multi_thread")] +async fn synchronized_argument_forgery_rejected_before_started() { + let root = temp_root("parent-args"); + let signer = signer(); + let store = Arc::new(PackageStore::open(root.join("packages")).unwrap()); + install_tool( + &store, + &signer, + &root.join("pkg-a"), + "org.rustscript.tools.parentargs", + 0, + ); + let lock = resolve_tool(&store, "org.rustscript.tools.parentargs"); + let (home, workspace) = workspace_home(&root); + fs::write(workspace.join("other.txt"), "forged-other-bytes").unwrap(); + let state = open_gateway(&root.join("state.db"), &home, Arc::clone(&store)); + let persistence = state.persistence().unwrap(); + let control = PluginControlStore::new(persistence.as_ref()); + control.store_lock(&lock).unwrap(); + control + .activate(&lock.graph_id, 0, &artifact_refs(&lock)) + .unwrap(); + let admitted = state + .service() + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"read note"}), + ..Default::default() + }, + Some("name:lifecycle".to_string()), + ) + .await + .unwrap(); + let receipt = control.pin_for_run(&admitted.run_id).unwrap(); + let binding = rustscript_agent::plugins::restore_owned_pin( + persistence.as_ref(), + &store, + &admitted.run_id, + ) + .unwrap(); + let durable = Arc::new(ParentFacts { + arguments: json!({"path":"note.txt"}), + started: 0.into(), + committed: 0.into(), + }); + assert_eq!(durable.arguments, json!({"path":"note.txt"})); + let result = invoke_original( + rustscript_agent::AgentHostBridges { + pinned_tool: Some(binding), + ..Default::default() + }, + &receipt, + &workspace, + json!({"path":"other.txt"}), + Arc::clone(&durable), + ); + assert_eq!( + result["ok"], false, + "all transport claims were forged together: {result}" + ); + assert_eq!(durable.started.load(std::sync::atomic::Ordering::SeqCst), 0); + assert_eq!( + durable.committed.load(std::sync::atomic::Ordering::SeqCst), + 0 + ); +} + +async fn json_request(app: &str, method: &str, uri: &str, body: Value) -> (StatusCode, Value) { + let client = + hyper_util::client::legacy::Client::builder(TokioExecutor::new()).build_http::(); + let response = client + .request( + Request::builder() + .method(method) + .uri(format!("{app}{uri}")) + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&body).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let bytes = to_bytes(Body::new(response.into_body()), 1024 * 1024) + .await + .unwrap(); + let parsed = serde_json::from_slice(&bytes) + .unwrap_or(json!({"raw": String::from_utf8_lossy(&bytes).to_string()})); + (status, parsed) +} + +async fn sse_body(app: &str, run_id: &str) -> String { + let client = + hyper_util::client::legacy::Client::builder(TokioExecutor::new()).build_http::(); + let response = client + .request( + Request::builder() + .method("GET") + .uri(format!("{app}/v1/runs/{run_id}/events")) + .header("accept", "text/event-stream") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let bytes = to_bytes(Body::new(response.into_body()), 2 * 1024 * 1024) + .await + .unwrap(); + String::from_utf8_lossy(&bytes).into_owned() +} diff --git a/tests/tools_pin_six_variants.rs b/tests/tools_pin_six_variants.rs new file mode 100644 index 0000000..422192a --- /dev/null +++ b/tests/tools_pin_six_variants.rs @@ -0,0 +1,720 @@ +//! Signed original-leaf preparation and real Native service effect proofs. + +use rustscript_agent::plugins::PluginControlStore; +use rustscript_agent::{AdmitRunRequest, AgentGatewayConfig, AgentGatewayState, ScriptedProvider}; +use serde_json::{Value, json}; +use std::{fs, path::PathBuf, sync::Arc}; + +#[path = "support/signed_tool_effects.rs"] +mod signed_tool_effects; + +#[path = "support/signed_pinned_variants.rs"] +mod signed_pinned_variants; + +#[test] +fn signed_search_variant_can_prepare_the_unchanged_production_leaf() { + let (store, lock) = signed_pinned_variants::signed_search_variant(); + let prepared = store.prepare(&lock).unwrap_or_else(|error| { + panic!("signed search variant must prepare the original production leaf: {error:?}") + }); + let package_id = "org.rustscript.tools.varianta"; + assert!(prepared.program(package_id).is_some()); + assert_eq!( + prepared.captured_source(package_id, "leaf.rss").unwrap(), + include_bytes!("../rss/tools/search_files.rss") + ); +} + +struct Effects { + root: PathBuf, + workspace: PathBuf, + state: AgentGatewayState, + a: rustscript_agent::plugins::GraphLock, + b: rustscript_agent::plugins::GraphLock, + generation: u64, +} + +impl Effects { + fn new(tool: &str, overflow: bool) -> Self { + let root = PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()) + .join(format!("effects-{}", uuid::Uuid::new_v4())); + let workspace = root.join("workspace"); + let home = root.join("home"); + fs::create_dir_all(&workspace).unwrap(); + fs::create_dir_all(&home).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for path in [&root, &workspace, &home] { + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); + } + } + fs::write(home.join("config.yaml"), format!("version: 1\nmodel:\n provider: local-test\n model: synthetic\nproviders:\n local-test:\n protocol: codex-responses\n base_url: https://127.0.0.1:1\nworkspaces:\n allowed_roots:\n - '{}'\n default: '{}'\napprovals:\n read: allow\n write: allow\n process: allow\n", workspace.display(), workspace.display())).unwrap(); + let (store, a, b) = signed_tool_effects::install_pair(&root, tool, overflow); + let state = AgentGatewayState::with_sqlite_path( + AgentGatewayConfig::default(), + root.join("acceptance.sqlite"), + ) + .unwrap() + .with_workspace_home(&home); + state.service().install_validated_tool_store(store).unwrap(); + let persistence = state.persistence().unwrap(); + let control = PluginControlStore::new(persistence.as_ref()); + control.store_lock(&a).unwrap(); + control.store_lock(&b).unwrap(); + let generation = control + .activate(&a.graph_id, 0, &signed_tool_effects::artifact_refs(&a)) + .unwrap() + .generation; + Self { + root, + workspace, + state, + a, + b, + generation, + } + } + + async fn admit(&self) -> rustscript_agent::AdmittedRun { + self.state + .service() + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"perform the supplied tool call"}), + ..Default::default() + }, + None, + ) + .await + .unwrap() + } + + fn activate_b(&mut self) { + let persistence = self.state.persistence().unwrap(); + let control = PluginControlStore::new(persistence.as_ref()); + self.generation = control + .activate( + &self.b.graph_id, + self.generation, + &signed_tool_effects::artifact_refs(&self.b), + ) + .unwrap() + .generation; + } + + async fn execute( + &self, + run: &rustscript_agent::AdmittedRun, + provider: Arc, + calls: usize, + ) -> Vec { + self.execute_expected(run, provider, calls, &[]).await + } + + async fn execute_expected( + &self, + run: &rustscript_agent::AdmittedRun, + provider: Arc, + calls: usize, + failed: &[&str], + ) -> Vec { + self.state.service().inject_provider_host(provider); + self.state + .service() + .clone() + .run_worker(run.run_id.clone(), "ignored".into()) + .await; + let events = self.state.service().run_events(&run.run_id); + for event in [ + "tool.requested", + "tool.started", + "tool.output", + "tool.completed", + ] { + assert_eq!( + events.iter().filter(|e| e["event"] == event).count(), + if event == "tool.completed" { + calls - failed.len() + } else { + calls + }, + "{event}: {events:?}" + ); + } + assert!( + events + .iter() + .filter(|e| e["event"] == "tool.completed") + .all(|e| e["data"]["ok"] == true), + "{events:?}" + ); + let failures: Vec<_> = events + .iter() + .filter(|e| e["event"] == "tool.failed") + .map(|e| e["data"]["tool_call_id"].as_str().unwrap()) + .collect(); + assert_eq!(failures, failed); + let persistence = self.state.persistence().unwrap(); + let durable = persistence + .event_replay( + &json!({"run_id":run.run_id,"after_seq":0,"max_events":512,"max_bytes":2097152}), + ) + .unwrap(); + let columns = durable["columns"].as_array().unwrap(); + let kind = columns.iter().position(|c| c == "event_type").unwrap(); + let rows = durable["rows"].as_array().unwrap(); + let payload = columns.iter().position(|c| c == "payload_json").unwrap(); + for name in [ + "tool.requested", + "tool.started", + "tool.output", + "tool.completed", + "tool.failed", + "model.requested", + "model.completed", + ] { + let live: Vec<_> = events + .iter() + .filter(|e| e["event"] == name) + .map(|e| e["data"].clone()) + .collect(); + let persisted: Vec = rows + .iter() + .filter(|r| r[kind] == name) + .map(|r| serde_json::from_str(r[payload].as_str().unwrap()).unwrap()) + .collect(); + assert_eq!( + live, persisted, + "durable {name} must retain exact Native facts" + ); + } + let reviews = events + .iter() + .filter(|e| e["event"] == "tool.started" && e["data"]["risk_class"] != "read") + .count(); + for name in ["model.requested", "model.completed"] { + assert_eq!( + events.iter().filter(|e| e["event"] == name).count(), + calls + 1 + reviews + ); + } + for event in [ + "tool.requested", + "tool.started", + "tool.output", + "tool.completed", + ] { + assert_eq!( + rows.iter().filter(|r| r[kind] == event).count(), + if event == "tool.completed" { + calls - failed.len() + } else { + calls + }, + "durable {event}: {durable}" + ); + } + let messages = + serde_json::to_value(self.state.service().session_messages(&run.session_id)).unwrap(); + let results: Vec<_> = messages + .as_array() + .unwrap() + .iter() + .flat_map(|m| m["content"].as_array().into_iter().flatten()) + .filter(|b| b["type"] == "tool_result") + .cloned() + .collect(); + assert_eq!(results.len(), calls, "{messages}"); + let stored_messages = persistence.message_list(&run.session_id, 0).unwrap(); + let content_column = stored_messages["columns"] + .as_array() + .unwrap() + .iter() + .position(|c| c == "content_json") + .unwrap(); + let stored_results: Vec = stored_messages["rows"] + .as_array() + .unwrap() + .iter() + .flat_map(|row| { + serde_json::from_str::>(row[content_column].as_str().unwrap()).unwrap() + }) + .filter(|block| block["type"] == "tool_result") + .collect(); + assert_eq!( + stored_results, results, + "durable canonical blocks must equal public results" + ); + println!( + "NATIVE_EFFECT {}", + json!({"root":self.root,"run":run.run_id,"results":results,"events":events}) + ); + results + } + + fn assert_original_arguments(&self, run: &rustscript_agent::AdmittedRun, expected: &Value) { + let events = self.state.service().run_events(&run.run_id); + let started: Vec<_> = events + .iter() + .filter(|e| e["event"] == "tool.started") + .collect(); + assert_eq!(started.len(), 1); + let original: Value = + serde_json::from_str(started[0]["data"]["argument_digest"].as_str().unwrap()).unwrap(); + assert_eq!( + &original, expected, + "Native parent facts preserve caller arguments before signed wrapper variation" + ); + } + + fn assert_pin(&self, run: &rustscript_agent::AdmittedRun, a: bool) { + let persistence = self.state.persistence().unwrap(); + let receipt = PluginControlStore::new(persistence.as_ref()) + .pin_for_run(&run.run_id) + .unwrap(); + assert_eq!( + receipt["graph_id"], + if a { + &self.a.graph_id + } else { + &self.b.graph_id + } + .as_str() + ); + } +} + +fn response(call: Option) -> Value { + json!({"text":if call.is_some() { "" } else { "done" },"tool_calls":call.into_iter().collect::>(),"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2},"reasoning":"","stop_reason":"stop"}) +} + +fn one_call(tool: &str, arguments: &Value) -> Arc { + let provider = Arc::new(ScriptedProvider::new()); + provider.push_ok(response(Some( + json!({"id":"effect-call","name":tool,"arguments":arguments}), + ))); + if matches!(tool, "write_file" | "patch" | "terminal") { + let mut approval = response(None); + approval["text"] = json!( + r#"{"outcome":"allow","risk_level":"low","user_authorization":"unknown","rationale":"bounded fixture approval"}"# + ); + provider.push_ok(approval); + } + provider.push_ok(response(None)); + provider +} + +#[tokio::test(flavor = "multi_thread")] +async fn signed_search_lists_real_files_and_keeps_admitted_a_after_b_activation() { + let mut f = Effects::new("search_files", false); + fs::write(f.workspace.join("alpha.txt"), "alpha").unwrap(); + fs::write(f.workspace.join("beta.txt"), "beta").unwrap(); + let arguments = json!({"pattern":"*.txt","target":"files"}); + let a = f.admit().await; + f.activate_b(); + let out_a = f.execute(&a, one_call("search_files", &arguments), 1).await; + f.assert_original_arguments(&a, &arguments); + f.assert_pin(&a, true); + assert!(out_a[0].to_string().contains("alpha.txt"), "{out_a:?}"); + assert!(!out_a[0].to_string().contains("beta.txt")); + let b = f.admit().await; + let out_b = f.execute(&b, one_call("search_files", &arguments), 1).await; + f.assert_original_arguments(&b, &arguments); + f.assert_pin(&b, false); + assert!(out_b[0].to_string().contains("beta.txt"), "{out_b:?}"); + assert!(!out_b[0].to_string().contains("alpha.txt")); +} + +#[tokio::test(flavor = "multi_thread")] +async fn signed_write_changes_real_bytes_for_a_and_b() { + let mut f = Effects::new("write_file", false); + let args = json!({"path":"written.txt","content":"caller-content"}); + let a = f.admit().await; + f.activate_b(); + f.execute(&a, one_call("write_file", &args), 1).await; + f.assert_original_arguments(&a, &args); + f.assert_pin(&a, true); + assert_eq!( + fs::read(f.workspace.join("written.txt")).unwrap(), + b"A-write\n" + ); + fs::remove_file(f.workspace.join("written.txt")).unwrap(); + let b = f.admit().await; + f.execute(&b, one_call("write_file", &args), 1).await; + f.assert_original_arguments(&b, &args); + f.assert_pin(&b, false); + assert_eq!( + fs::read(f.workspace.join("written.txt")).unwrap(), + b"B-write\n" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn signed_patch_changes_real_bytes_for_a_and_b() { + let mut f = Effects::new("patch", false); + fs::write(f.workspace.join("patch.txt"), b"prefix ORIGINAL suffix\n").unwrap(); + let args = json!({"path":"patch.txt","old_string":"ORIGINAL","new_string":"caller-content"}); + let a = f.admit().await; + f.activate_b(); + f.execute(&a, one_call("patch", &args), 1).await; + f.assert_original_arguments(&a, &args); + f.assert_pin(&a, true); + assert_eq!( + fs::read(f.workspace.join("patch.txt")).unwrap(), + b"prefix A-patch suffix\n" + ); + fs::write(f.workspace.join("patch.txt"), b"prefix ORIGINAL suffix\n").unwrap(); + let b = f.admit().await; + f.execute(&b, one_call("patch", &args), 1).await; + f.assert_original_arguments(&b, &args); + f.assert_pin(&b, false); + assert_eq!( + fs::read(f.workspace.join("patch.txt")).unwrap(), + b"prefix B-patch suffix\n" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn signed_terminal_returns_actual_bin_stdout_for_a_and_b() { + let mut f = Effects::new("terminal", false); + let args = json!({"argv":["/bin/printf","caller-content"],"timeout_ms":250}); + let a = f.admit().await; + f.activate_b(); + let out_a = f.execute(&a, one_call("terminal", &args), 1).await; + f.assert_original_arguments(&a, &args); + f.assert_pin(&a, true); + assert_eq!(out_a[0]["result"]["stdout"], "A-terminal\n"); + assert_eq!(out_a[0]["result"]["exit_code"], 0); + let b = f.admit().await; + let out_b = f.execute(&b, one_call("terminal", &args), 1).await; + f.assert_original_arguments(&b, &args); + f.assert_pin(&b, false); + assert_eq!(out_b[0]["result"]["stdout"], "B-terminal\n"); + assert_eq!(out_b[0]["result"]["exit_code"], 0); +} + +#[tokio::test(flavor = "multi_thread")] +async fn signed_search_large_result_reaches_bounded_native_publication() { + let f = Effects::new("search_files", true); + for index in 0..20 { + fs::write( + f.workspace + .join(format!("file-{index:02}-{}.txt", "x".repeat(70))), + "real-file", + ) + .unwrap(); + } + let run = f.admit().await; + let results = f + .execute( + &run, + one_call("search_files", &json!({"pattern":"*","target":"files"})), + 1, + ) + .await; + assert_eq!(results[0]["is_error"], false); + assert!( + results[0]["content"] + .as_str() + .unwrap() + .starts_with("artifact "), + "{results:?}" + ); + assert!( + f.state + .service() + .run_events(&run.run_id) + .iter() + .any(|e| e["event"] == "tool.completed" && e["data"]["truncated"] == true) + ); +} + +// This provider emits calls only. Every process id is recovered from Native's +// preceding result in this same run; no foreign or invented handle is supplied. +#[derive(Default)] +struct ProcessCalls { + step: std::sync::atomic::AtomicUsize, + handle: std::sync::Mutex>, + requests: std::sync::Mutex>, + processes: Option>, + pids: std::sync::Mutex>, +} + +fn process_id(value: &Value) -> Option { + match value { + Value::Object(object) => object + .get("process_id") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| object.values().find_map(process_id)), + Value::Array(array) => array.iter().rev().find_map(process_id), + Value::String(text) => serde_json::from_str::(text) + .ok() + .as_ref() + .and_then(process_id), + _ => None, + } +} + +impl rustscript_agent::AgentProviderHost for ProcessCalls { + fn call(&self, request: &Value, _: &rustscript_agent::RunCancellation) -> Value { + self.requests.lock().unwrap().push(request.clone()); + // DurableProvider strips its private step key before transport. + // Classify the real tool-free Guardian request by its system message. + assert!(request.get("_provider_step_key").is_none()); + let system = &request["messages"][0]; + if system + .pointer("/content/0/text") + .and_then(Value::as_str) + .or_else(|| system["content"].as_str()) + .is_some_and(|text| { + text.starts_with("You are Guardian, reviewing one proposed tool action.") + }) + { + let mut approval = response(None); + approval["text"] = json!( + r#"{"outcome":"allow","risk_level":"low","user_authorization":"unknown","rationale":"bounded fixture approval"}"# + ); + return json!({"ok":true,"response":approval,"error":{}}); + } + let step = self.step.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + + if step == 1 { + *self.pids.lock().unwrap() = self.processes.as_ref().unwrap().live_pids(); + assert_eq!(self.pids.lock().unwrap().len(), 1); + } + let call = if step == 0 { + Some( + json!({"id":"spawn","name":"terminal","arguments":{"argv":["/bin/cat"],"background":true}}), + ) + } else if step <= 6 { + let mut handle = self.handle.lock().unwrap(); + if handle.is_none() { + *handle = Some( + process_id(request) + .expect("Native terminal result contains its actual process id"), + ); + } + let (action, extra) = match step { + 1 => ("write", json!({"data":"caller-stdin\n"})), + 2 => ("wait", json!({"timeout_ms":50})), + 3 => ("poll", json!({})), + 4 => ("log", json!({"offset":0,"limit":4096})), + 5 => ("kill", json!({})), + 6 => ("poll", json!({})), + _ => unreachable!(), + }; + let mut arguments = extra; + arguments["action"] = json!(action); + arguments["process_id"] = json!(handle.as_ref().unwrap()); + Some(json!({"id":format!("process-{step}"),"name":"process","arguments":arguments})) + } else { + None + }; + json!({"ok":true,"response":response(call),"error":{}}) + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn signed_process_uses_each_runs_native_child_stdin_poll_log_and_kill() { + let mut f = Effects::new("process", false); + let a = f.admit().await; + f.activate_b(); + let provider_a = process_provider(&f, &a); + let out_a = f + .execute_expected(&a, provider_a.clone(), 7, &["process-6"]) + .await; + f.assert_pin(&a, true); + assert_eq!(out_a[1]["result"]["wrote_bytes"], 8); + for block in &out_a[2..5] { + assert_eq!(block["result"]["stdout"], "A-stdin\n"); + } + assert_eq!(out_a[4]["result"]["stdout"], "A-stdin\n", "{out_a:?}"); + assert_eq!(out_a[5]["result"]["status"], "signaled"); + assert_eq!(out_a[5]["result"]["signal"], 9); + assert_eq!(out_a[6]["is_error"], true); + assert_eq!(out_a[6]["error"]["code"], "cancelled"); + assert_process_gone(&provider_a); + let b = f.admit().await; + let provider_b = process_provider(&f, &b); + let out_b = f + .execute_expected(&b, provider_b.clone(), 7, &["process-6"]) + .await; + f.assert_pin(&b, false); + assert_eq!(out_b[1]["result"]["wrote_bytes"], 8); + for block in &out_b[2..5] { + assert_eq!(block["result"]["stdout"], "B-stdin\n"); + } + assert_eq!(out_b[4]["result"]["stdout"], "B-stdin\n", "{out_b:?}"); + assert_eq!(out_b[5]["result"]["status"], "signaled"); + assert_eq!(out_b[5]["result"]["signal"], 9); + assert_eq!(out_b[6]["is_error"], true); + assert_eq!(out_b[6]["error"]["code"], "cancelled"); + assert_process_gone(&provider_b); + let actions_a = assert_process_parent_arguments(&f, &a, &handle_from(&provider_a)); + let actions_b = assert_process_parent_arguments(&f, &b, &handle_from(&provider_b)); + assert_eq!( + actions_a, actions_b, + "compare public action payloads after projecting each run's own identity" + ); + let handle_a = provider_a.handle.lock().unwrap().clone().unwrap(); + let handle_b = provider_b.handle.lock().unwrap().clone().unwrap(); + assert_ne!( + handle_a, handle_b, + "opaque Native identities are run-owned and may differ" + ); + println!("NATIVE_PROCESS_IDS {}", json!({"a":handle_a,"b":handle_b})); +} + +fn process_provider(f: &Effects, run: &rustscript_agent::AdmittedRun) -> Arc { + Arc::new(ProcessCalls { + processes: f + .state + .service() + .capability_host_bridges(&run.run_id) + .unwrap() + .unwrap() + .processes, + ..Default::default() + }) +} + +fn assert_process_gone(provider: &ProcessCalls) { + let pids = provider.pids.lock().unwrap(); + assert_eq!(pids.len(), 1); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + for pid in pids.iter() { + let path = PathBuf::from(format!("/proc/{pid}")); + while path.exists() && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!( + !path.exists(), + "owned child {pid} must disappear after kill" + ); + } + assert!(provider.processes.as_ref().unwrap().live_pids().is_empty()); +} + +fn handle_from(provider: &ProcessCalls) -> String { + provider.handle.lock().unwrap().clone().unwrap() +} + +fn assert_process_parent_arguments( + f: &Effects, + run: &rustscript_agent::AdmittedRun, + handle: &str, +) -> Vec { + let events = f.state.service().run_events(&run.run_id); + let mut arguments: Vec = events + .iter() + .filter(|e| e["event"] == "tool.started") + .map(|e| serde_json::from_str(e["data"]["argument_digest"].as_str().unwrap()).unwrap()) + .collect(); + assert_eq!(arguments.len(), 7); + assert_eq!(arguments[0], json!({"argv":["/bin/cat"],"background":true})); + let expected = [ + json!({"action":"write","data":"caller-stdin\n"}), + json!({"action":"wait","timeout_ms":50}), + json!({"action":"poll"}), + json!({"action":"log","offset":0,"limit":4096}), + json!({"action":"kill"}), + json!({"action":"poll"}), + ]; + for (actual, expected) in arguments[1..].iter_mut().zip(expected) { + assert_eq!(actual["process_id"], handle); + actual.as_object_mut().unwrap().remove("process_id"); + assert_eq!(*actual, expected); + } + arguments +} + +fn assert_native_overflow( + f: &Effects, + run: &rustscript_agent::AdmittedRun, + block: &Value, + artifacts: &rustscript_agent::capabilities::ArtifactCapability, + stdout: &str, +) { + assert_eq!(block["is_error"], false, "{block}"); + assert_eq!(block["truncated"], true, "{block}"); + let id = block["artifact"]["id"].as_str().unwrap(); + let (bytes, metadata) = artifacts + .stored(id) + .expect("committed result artifact remains visible"); + assert_eq!(bytes, format!("stdout:\n{stdout}stderr:\n").into_bytes()); + assert_eq!(metadata["run"], run.run_id); + assert_eq!(metadata["call_id"], block["tool_call_id"]); + assert_eq!(metadata.as_object().unwrap().len(), 3); + assert!(metadata["owner"].as_str().is_some_and(|s| !s.is_empty())); + assert_eq!(block["result"]["overflow_encoding"], "labeled-utf8"); + assert_eq!(block["result"]["overflow_stdout_bytes"], stdout.len()); + assert_eq!(block["result"]["overflow_stderr_bytes"], 0); + let completed = f + .state + .service() + .run_events(&run.run_id) + .into_iter() + .find(|e| { + e["event"] == "tool.completed" && e["data"]["tool_call_id"] == block["tool_call_id"] + }) + .unwrap(); + assert_eq!(completed["data"]["artifacts"], json!([id])); + assert_eq!(completed["data"]["truncated"], true); + println!( + "NATIVE_BOUNDED_PROCESS_OVERFLOW {}", + json!({"run":run.run_id,"call":block["tool_call_id"],"id":id,"bytes":bytes.len(),"metadata":metadata}) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn signed_terminal_overflow_publishes_real_labeled_result_artifact() { + let f = Effects::new("terminal", true); + let run = f.admit().await; + let artifacts = f + .state + .service() + .capability_host_bridges(&run.run_id) + .unwrap() + .unwrap() + .artifacts + .unwrap(); + let args = json!({"argv":["/bin/printf","caller-content"],"timeout_ms":250}); + let results = f.execute(&run, one_call("terminal", &args), 1).await; + f.assert_original_arguments(&run, &args); + assert_native_overflow( + &f, + &run, + &results[0], + &artifacts, + &format!("A-{}\n", "x".repeat(1200)), + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn signed_process_overflow_publishes_real_child_output_as_bounded_artifacts() { + let f = Effects::new("process", true); + let run = f.admit().await; + let artifacts = f + .state + .service() + .capability_host_bridges(&run.run_id) + .unwrap() + .unwrap() + .artifacts + .unwrap(); + let provider = process_provider(&f, &run); + let results = f + .execute_expected(&run, provider.clone(), 7, &["process-6"]) + .await; + assert_process_parent_arguments(&f, &run, &handle_from(&provider)); + assert_native_overflow( + &f, + &run, + &results[4], + &artifacts, + &format!("A-{}\n", "x".repeat(1200)), + ); + assert_process_gone(&provider); +} diff --git a/tests/tools_pinned_commit_outcome.rs b/tests/tools_pinned_commit_outcome.rs new file mode 100644 index 0000000..1e57da9 --- /dev/null +++ b/tests/tools_pinned_commit_outcome.rs @@ -0,0 +1,616 @@ +//! Native commit/replay authority regressions, with real file capabilities. +use ring::{ + rand::SystemRandom, + signature::{Ed25519KeyPair, KeyPair}, +}; +use rustscript_agent::capabilities::{ + ApprovalGate, CancellationFlag, CapabilityLifecycle, CapabilityOwner, CapabilityRisk, + DurableStarted, DurableToolLifecycle, FilesystemCapability, FilesystemLimits, LifecycleError, + LifecycleLimits, PrepareMetadata, SystemClock, UuidIssuer, +}; +use rustscript_agent::plugins::{ + InstallAuthorization, PackageArtifactRef, PackageRequirement, PackageStore, PluginControlStore, + PublisherKey, ResolveRequest, +}; +use rustscript_agent::{AgentConfig, AgentHostBridges, AgentRunner, GatewayPersistence}; +use rustscript_vm::Value as VmValue; +use serde_json::{Value, json}; +use std::{ + collections::HashMap, + fs, + path::PathBuf, + sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; +fn temp_root(label: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos(); + temp_root_at(label, nanos) +} + +fn temp_root_at(label: &str, nanos: u128) -> PathBuf { + // Clock readings can repeat; identity must remain unique within this process. + static NEXT_ROOT: AtomicUsize = AtomicUsize::new(0); + let sequence = NEXT_ROOT + .try_update(Ordering::Relaxed, Ordering::Relaxed, |value| { + value.checked_add(1) + }) + .expect("temp root sequence exhausted"); + let base = + PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").expect("assigned test temp")); + let dir = base.join(format!( + "pin-tracer-{}-{}-{}-{}", + label, + std::process::id(), + nanos, + sequence + )); + fs::create_dir(&dir).unwrap_or_else(|err| panic!("create {}: {err}", dir.display())); + dir +} + +#[test] +fn temp_roots_with_same_label_and_timestamp_are_distinct() { + struct Root(PathBuf); + impl Drop for Root { + fn drop(&mut self) { + fs::remove_dir(&self.0).unwrap(); + } + } + + let nanos = 123_456_789; + let first = Root(temp_root_at("outcome", nanos)); + let second = Root(temp_root_at("outcome", nanos)); + assert_ne!(first.0, second.0); + assert!(first.0.is_dir()); + assert!(second.0.is_dir()); +} + +struct Signer { + pair: Ed25519KeyPair, + key: PublisherKey, +} + +fn signer() -> Signer { + let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap(); + let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap(); + let key = PublisherKey { + publisher: "org.rustscript".to_string(), + verifying_key: pair.public_key().as_ref().to_vec(), + }; + Signer { pair, key } +} + +fn auth(signer: &Signer) -> InstallAuthorization { + InstallAuthorization { + admin: true, + accepted_publishers: vec![signer.key.clone()], + unsigned_dev_space: None, + } +} +fn manifest(id: &str) -> String { + format!( + r#"format: rustscript.plugin/v1 +id: {id} +version: 1.0.0 +kind: tool +runtime: ">=0.1.0, <0.2.0" +contract: agent.tool/v1 +host_abi: agent.cap/v1 +entry: + path: run.rss + export: run +dependencies: [] +capabilities: + - workspace.read +effects: + contract: agent.effects/v1 + entries: + run: + direct: + - kind: workspace.fs.read + scope: bound_workspace + recovery: none + delegated: [] +"# + ) +} +struct AllowRead; + +impl ApprovalGate for AllowRead { + fn authorize(&self, metadata: &PrepareMetadata) -> Result { + Ok(metadata.risk_class) + } +} + +struct NoCancel; + +impl CancellationFlag for NoCancel { + fn is_cancelled(&self) -> bool { + false + } +} +fn activate( + control: &PluginControlStore, + lock: &rustscript_agent::plugins::GraphLock, + generation: u64, +) { + let refs: Vec = lock + .packages + .iter() + .map(|package| PackageArtifactRef { + package_id: package.id.clone(), + version: package.version.clone(), + kind: package.kind.clone(), + artifact_id: package.artifact_id.clone(), + }) + .collect(); + control.store_lock(lock).unwrap(); + control.activate(&lock.graph_id, generation, &refs).unwrap(); +} + +#[derive(Default)] +struct RecordingState { + started: Vec, + commits: Vec, + interrupts: usize, + results: HashMap, +} +#[derive(Default)] +struct RecordingDurable { + state: Mutex, + fail_commit: bool, + original_arguments: Value, +} +impl DurableToolLifecycle for RecordingDurable { + fn assert_active_run(&self, run: &str) -> Result<(), LifecycleError> { + assert_eq!(run, "run-outcome"); + Ok(()) + } + fn prepare_parent(&self, run: &str, call: &str, tool: &str) -> Result<(), LifecycleError> { + assert_eq!((run, call, tool), ("run-outcome", "call-1", "read_file")); + Ok(()) + } + fn parent_arguments( + &self, + run: &str, + call: &str, + tool: &str, + ) -> Result, LifecycleError> { + self.prepare_parent(run, call, tool)?; + Ok(Some(self.original_arguments.clone())) + } + fn replay_result( + &self, + _run: &str, + call: &str, + _tool: &str, + ) -> Result, LifecycleError> { + Ok(self.state.lock().unwrap().results.get(call).cloned()) + } + fn commit_started(&self, record: &DurableStarted) -> Result<(), LifecycleError> { + self.state.lock().unwrap().started.push(record.clone()); + Ok(()) + } + fn commit_result(&self, call: &str, result: &Value) -> Result { + let mut state = self.state.lock().unwrap(); + state.commits.push(result.clone()); + if self.fail_commit { + return Err(LifecycleError::ResultCommitFailed( + "injected result commit failure".to_string(), + )); + } + state.results.insert(call.to_string(), result.clone()); + Ok(result.clone()) + } + fn interrupt(&self, _call: &str) -> Result<(), LifecycleError> { + self.state.lock().unwrap().interrupts += 1; + Ok(()) + } +} +struct Fixture { + root: PathBuf, + host: AgentHostBridges, + durable: Arc, + harness: AgentRunner, +} +impl Fixture { + fn new(source: &str, fail_commit: bool) -> Self { + AgentRunner::from_source(source, AgentConfig::default()) + .unwrap_or_else(|error| panic!("fixture source did not compile: {error:?}")); + let root = temp_root("outcome"); + let store = PackageStore::open(root.join("packages")).unwrap(); + let signer = signer(); + let dir = root.join("package"); + fs::create_dir(&dir).unwrap(); + fs::write( + dir.join("manifest.yaml"), + manifest("org.rustscript.tools.outcome"), + ) + .unwrap(); + fs::write(dir.join("run.rss"), source).unwrap(); + let payload = store.signature_payload(&dir).unwrap(); + fs::write( + dir.join("signature.ed25519"), + signer.pair.sign(&payload).as_ref(), + ) + .unwrap(); + store.install(&dir, &auth(&signer)).unwrap(); + let lock = store + .resolve(&ResolveRequest { + roots: vec![PackageRequirement { + id: "org.rustscript.tools.outcome".to_string(), + version: "1.0.0".to_string(), + }], + host_runtime: "0.1.0".to_string(), + }) + .unwrap(); + let persistence = GatewayPersistence::open( + &rustscript_agent::AgentGatewayConfig::default(), + &root.join("gateway.sqlite"), + ) + .unwrap(); + persistence.load().unwrap(); + activate(&PluginControlStore::new(&persistence), &lock, 0); + let workspace = root.join("workspace"); + fs::create_dir(&workspace).unwrap(); + fs::write(workspace.join("note.txt"), "native-file-content").unwrap(); + let caller = json!({"session_id":"session-outcome","session_new":1,"profile":"gateway","platform":"api_server","account_id":"session-outcome","model":"m","provider":"p","system_prompt":"","session_metadata_json":"{}","run_id":"run-outcome","parent_run_id":"","input_json":"{\"text\":\"read\"}","message_id":"message-outcome","message_run_id":"run-outcome","script_hash":"s","idempotency_scope":"","idempotency_key":"","request_hash":"","workspace_identity_json":"","event_id":"event-outcome","now_ms":50,"expires_at_ms":0}); + let pinned = rustscript_agent::plugins::admit_pinned_tool_run( + &persistence, + &store, + &caller, + &rustscript_agent::plugins::PinnedAdmitContext { + registry_identity: "outcome-registry".to_string(), + workspace: workspace.clone(), + }, + ) + .unwrap(); + let durable = Arc::new(RecordingDurable { + fail_commit, + original_arguments: json!({"path": "note.txt"}), + ..Default::default() + }); + let owner = CapabilityOwner::new("gateway", "session-outcome", "run-outcome").unwrap(); + let lifecycle = CapabilityLifecycle::builder() + .owner(owner.clone()) + .registry_identity("outcome-registry".to_string()) + .workspace(workspace) + .limits(LifecycleLimits { + max_tool_calls: 8, + max_output_bytes: 4096, + max_summary_bytes: 256, + }) + .deadline_ms(9_000_000_000_000) + .clock(Arc::new(SystemClock)) + .tokens(Arc::new(UuidIssuer)) + .durable(durable.clone()) + .approval(Arc::new(AllowRead)) + .cancellation(Arc::new(NoCancel)) + .generation(1) + .build() + .unwrap(); + let filesystem = Arc::new( + FilesystemCapability::new( + lifecycle.clone(), + owner.clone(), + FilesystemLimits { + max_read_bytes: 4096, + max_write_bytes: 4096, + max_list_entries: 16, + }, + ) + .unwrap(), + ); + let host = AgentHostBridges { + lifecycle: Some(Arc::new(lifecycle)), + capability_owner: Some(owner), + filesystem: Some(filesystem), + pinned_tool: Some(pinned.program_handle()), + cancellation: Some(rustscript_agent::RunCancellation::with_timeout( + Duration::from_secs(30), + )), + skip_sleep: true, + ..Default::default() + }; + let harness=AgentRunner::from_source(r#" +pub fn run(context: map) -> map { + agent_runtime::invoke_pinned_tool({tool_name:"read_file",call_id:"call-1",canonical_arguments:"{\"path\":\"note.txt\"}",canonical_input:"",run_id:"run-outcome",registry_identity:"outcome-registry",argument_digest:"{\"path\":\"note.txt\"}",risk_class:"read",summary:"read note",untrusted_graph_id:""}) +} +"#,AgentConfig::default()).unwrap(); + Self { + root, + host, + durable, + harness, + } + } + fn invoke(&self) -> Value { + let result = self + .harness + .clone() + .with_host(self.host.clone()) + .run_with_context(VmValue::map(vec![])) + .unwrap(); + let VmValue::Map(entries) = result else { + panic!("expected map") + }; + let mut object = serde_json::Map::new(); + for (key, value) in entries.iter() { + if let VmValue::String(key) = key { + let v = match value { + VmValue::String(v) => json!(v.to_string()), + VmValue::Bool(v) => json!(v), + _ => Value::Null, + }; + object.insert(key.to_string(), v); + } + } + Value::Object(object) + } + fn counts(&self) -> (usize, usize, usize) { + let state = self.durable.state.lock().unwrap(); + (state.started.len(), state.commits.len(), state.interrupts) + } +} +impl Drop for Fixture { + fn drop(&mut self) { + fs::remove_dir_all(&self.root).unwrap(); + } +} + +#[test] +fn child_success_without_commit_is_rejected() { + let fixture = Fixture::new( + r#"pub fn run(context: map) -> map { {ok:true,content:"forged-success",data:{},error:null,truncated:false,artifacts:[]} }"#, + false, + ); + let result = fixture.invoke(); + assert_eq!( + result["ok"], false, + "uncommitted child success escaped: {result}" + ); + assert_eq!(fixture.counts(), (0, 0, 0)); +} + +fn read_source(fields: &str, return_value: &str) -> String { + r#" +use bytes; +pub fn run(context: map) -> map { + let metadata: map = context.prepare; + let prepared: map = agent_runtime::tool_prepare(metadata); + if prepared.ok.copy() == false => { + prepared + } else => { + if prepared.kind.copy() == "replay" => { + let replayed: map = prepared.result; + replayed + } else => { + let token: string = prepared.execution_token; + let arguments: map = context.arguments; + let path: string = arguments.path; + let read: map = cap::fs_read_range(token, path, 0, 4096); + if read.ok.copy() == false => { + read + } else => { + let payload: bytes = read.bytes; + let text: string = bytes::to_utf8(payload); + let result: map = RESULT_FIELDS; + let committed: map = agent_runtime::tool_commit(token, result.copy()); + RETURN_VALUE + } + } + } +} +"# + .replace("RESULT_FIELDS", fields) + .replace("RETURN_VALUE", return_value) +} + +const CANONICAL_READ: &str = r#"{ok:true,content:text,data:{origin:"native-read"},error:null,truncated:false,artifacts:["artifact:test"]}"#; + +fn canonical(result: &Value) -> Value { + serde_json::from_str(result["canonical_result"].as_str().unwrap()).unwrap() +} + +#[test] +fn native_read_commits_original_canonical_result_once() { + let fixture = Fixture::new(&read_source(CANONICAL_READ, "committed"), false); + let result = fixture.invoke(); + assert_eq!(result["ok"], true, "{result}"); + assert_eq!(result["text"], "native-file-content"); + assert_eq!(fixture.counts(), (1, 1, 0)); + let state = fixture.durable.state.lock().unwrap(); + assert_eq!(canonical(&result), state.commits[0]); + assert_eq!(canonical(&result)["data"], json!({"origin":"native-read"})); + assert_eq!(canonical(&result)["truncated"], false); + assert_eq!(canonical(&result)["artifacts"], json!(["artifact:test"])); +} + +#[test] +fn empty_file_commit_is_success() { + let fixture = Fixture::new(&read_source(CANONICAL_READ, "committed"), false); + fs::write(fixture.root.join("workspace/note.txt"), "").unwrap(); + let result = fixture.invoke(); + assert_eq!(result["ok"], true, "{result}"); + assert_eq!(result["text"], ""); + assert_eq!(canonical(&result)["content"], ""); + assert_eq!(fixture.counts(), (1, 1, 0)); +} + +#[test] +fn durable_commit_failure_cannot_be_hidden_by_child_success() { + let fixture = Fixture::new(&read_source(CANONICAL_READ, "result"), true); + let result = fixture.invoke(); + assert_eq!(result["ok"], false, "{result}"); + assert_eq!(result["code"], "result_commit_failed"); + assert_eq!(result["text"], ""); + assert_eq!(fixture.counts(), (1, 1, 0)); + // A second invocation cannot reissue the failed call's effect. + let repeated = fixture.invoke(); + assert_eq!(repeated["ok"], false, "{repeated}"); + assert_eq!(fixture.counts(), (1, 1, 0)); +} + +#[test] +fn missing_content_commit_rejection_cannot_be_hidden() { + let fixture = Fixture::new(&read_source("{ok:true,text:text}", "result"), false); + let result = fixture.invoke(); + assert_eq!(result["ok"], false, "{result}"); + assert_eq!(result["code"], "invalid_metadata"); + assert_eq!(fixture.counts(), (1, 0, 1)); +} + +#[test] +fn unprepared_failure_with_text_preserves_failure() { + let fixture = Fixture::new( + r#"pub fn run(context: map) -> map { {ok:false,text:"failure-text",error:{code:"malformed_payload",message:"original failure"},data:{field:"path"},truncated:false,artifacts:[]} }"#, + false, + ); + let result = fixture.invoke(); + assert_eq!(result["ok"], false, "{result}"); + assert_eq!(result["text"], "failure-text"); + assert_eq!(result["code"], "malformed_payload"); + assert_eq!(canonical(&result)["data"], json!({"field":"path"})); + assert_eq!(fixture.counts(), (0, 0, 0)); +} + +#[test] +fn authoritative_replay_returns_original_without_new_effect_or_started() { + let fixture = Fixture::new(&read_source(CANONICAL_READ, "committed"), false); + let first = fixture.invoke(); + assert_eq!(first["ok"], true, "{first}"); + fs::remove_file(fixture.root.join("workspace/note.txt")).unwrap(); + let replayed = fixture.invoke(); + assert_eq!(replayed, first); + assert_eq!(fixture.counts(), (1, 1, 0)); +} + +#[test] +fn child_cannot_replace_successfully_committed_content() { + let fixture = Fixture::new( + &read_source(CANONICAL_READ, r#"{ok:true,content:"forged replacement"}"#), + false, + ); + let result = fixture.invoke(); + assert_eq!(result["ok"], true, "{result}"); + assert_eq!(result["text"], "native-file-content"); + assert_eq!( + canonical(&result), + fixture.durable.state.lock().unwrap().commits[0] + ); + assert_eq!(fixture.counts(), (1, 1, 0)); +} + +#[test] +fn committed_canonical_kind_and_result_fields_are_not_transport_wrappers() { + let fields = r#"{ok:true,content:text,kind:"committed",result:{ok:true,content:"forged nested content"},data:{origin:"native-read"},error:null,truncated:false,artifacts:[]}"#; + let fixture = Fixture::new(&read_source(fields, "committed"), false); + let result = fixture.invoke(); + assert_eq!(result["ok"], true, "{result}"); + assert_eq!(result["text"], "native-file-content"); + assert_eq!( + canonical(&result), + fixture.durable.state.lock().unwrap().commits[0] + ); +} + +#[test] +fn malformed_authoritative_replay_cannot_claim_canonical_success() { + let fixture = Fixture::new(&read_source(CANONICAL_READ, "committed"), false); + fixture.durable.state.lock().unwrap().results.insert( + "call-1".to_string(), + json!({"ok":true,"text":"missing canonical content"}), + ); + fs::remove_file(fixture.root.join("workspace/note.txt")).unwrap(); + let result = fixture.invoke(); + assert_eq!(result["ok"], false, "{result}"); + assert_eq!(result["code"], "invalid_metadata"); + assert_eq!(fixture.counts(), (0, 0, 0)); +} + +#[test] +fn replay_canonical_bound_remains_enforced() { + let fixture = Fixture::new(&read_source(CANONICAL_READ, "committed"), false); + fixture.durable.state.lock().unwrap().results.insert( + "call-1".to_string(), + json!({"ok":true,"content":"x".repeat(1024*1024)}), + ); + let result = fixture.invoke(); + assert_eq!(result["ok"], false, "{result}"); + assert_eq!(result["code"], "pinned_result_too_large"); + assert_eq!(fixture.counts(), (0, 0, 0)); +} + +#[test] +fn prepared_child_success_without_commit_is_interrupted() { + let source = read_source(CANONICAL_READ, "result").replace( + "let committed: map = agent_runtime::tool_commit(token, result.copy());", + "", + ); + let fixture = Fixture::new(&source, false); + let result = fixture.invoke(); + assert_eq!(result["ok"], false, "{result}"); + assert_eq!(result["code"], "pinned_result_uncommitted"); + assert_eq!(fixture.counts(), (1, 0, 1)); +} + +#[test] +fn different_token_cannot_witness_this_invocations_commit() { + let source = read_source(CANONICAL_READ, "result").replace( + "agent_runtime::tool_commit(token, result.copy())", + "agent_runtime::tool_commit(\"forged-token\", result.copy())", + ); + let fixture = Fixture::new(&source, false); + let result = fixture.invoke(); + assert_eq!(result["ok"], false, "{result}"); + assert_eq!(fixture.counts(), (1, 0, 1)); +} + +#[test] +fn committed_failure_and_its_replay_preserve_original_canonical_error() { + let fields = r#"{ok:false,content:text,data:{origin:"native-read"},error:{code:"fixture_failure",message:"original committed error"},truncated:true,artifacts:["artifact:test"]}"#; + let fixture = Fixture::new(&read_source(fields, "committed"), false); + let first = fixture.invoke(); + assert_eq!(first["ok"], false, "{first}"); + assert_eq!(first["text"], "native-file-content"); + assert_eq!(first["code"], "fixture_failure"); + assert_eq!(first["message"], "original committed error"); + assert_eq!( + canonical(&first), + fixture.durable.state.lock().unwrap().commits[0] + ); + fs::remove_file(fixture.root.join("workspace/note.txt")).unwrap(); + assert_eq!(fixture.invoke(), first); + assert_eq!(fixture.counts(), (1, 1, 0)); +} + +#[test] +fn repeated_prepare_does_not_issue_another_started_or_replace_the_token() { + let source=read_source(CANONICAL_READ,"committed") + .replace("let metadata: map = context.prepare;","let metadata: map = context.prepare.copy();") + .replace("let token: string = prepared.execution_token;","let token: string = prepared.execution_token; let repeated: map = agent_runtime::tool_prepare(context.prepare.copy());"); + let fixture = Fixture::new(&source, false); + let result = fixture.invoke(); + assert_eq!(result["ok"], true, "{result}"); + assert_eq!(result["text"], "native-file-content"); + assert_eq!(fixture.counts(), (1, 1, 0)); +} + +#[test] +fn fabricated_commit_and_replay_envelopes_are_not_native_evidence() { + for kind in ["committed", "replay"] { + let source = format!( + r#"pub fn run(context: map) -> map {{ {{ok:true,kind:"{kind}",content:"forged",result:{{ok:true,content:"forged"}}}} }}"# + ); + let fixture = Fixture::new(&source, false); + let result = fixture.invoke(); + assert_eq!(result["ok"], false, "{result}"); + assert_eq!(fixture.counts(), (0, 0, 0)); + } +} diff --git a/tests/tools_pinned_process_lifecycle.rs b/tests/tools_pinned_process_lifecycle.rs new file mode 100644 index 0000000..c94f16c --- /dev/null +++ b/tests/tools_pinned_process_lifecycle.rs @@ -0,0 +1,578 @@ +//! Production Main -> retained Native dispatcher -> fresh child VM process calls. +#[path = "support/pinned_process_fixture.rs"] +mod pinned_process_fixture; +use std::{ + fs, + os::unix::fs::PermissionsExt, + path::PathBuf, + sync::{Arc, Mutex, Weak}, + time::{Duration, Instant}, +}; + +use rustscript_agent::{ + AgentGatewayConfig, AgentGatewayState, AgentHostBridges, AgentProviderHost, RunCancellation, + capabilities::ProcessCapability, + service::{AdmitRunRequest, AgentService}, +}; +use serde_json::{Value, json}; + +struct Fixture { + root: PathBuf, + state: Option, +} + +impl Fixture { + fn new() -> Self { + let root = PathBuf::from(std::env::var_os("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()) + .join(format!("pinned-process-{}", uuid::Uuid::new_v4())); + fs::create_dir(&root).unwrap(); + let mut fixture = Self { + root: root.clone(), + state: None, + }; + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).unwrap(); + let home = root.join("home"); + let workspace = root.join("workspace"); + fs::create_dir(&home).unwrap(); + fs::create_dir(&workspace).unwrap(); + fs::set_permissions(&home, fs::Permissions::from_mode(0o700)).unwrap(); + fs::write(home.join("config.yaml"), format!( + "version: 1\nmodel:\n provider: local-test\n model: synthetic\nproviders:\n local-test:\n protocol: codex-responses\n base_url: https://127.0.0.1:1\nworkspaces:\n allowed_roots:\n - '{}'\n default: '{}'\n named:\n project: '{}'\n", workspace.display(), workspace.display(), workspace.display() + )).unwrap(); + fs::set_permissions(home.join("config.yaml"), fs::Permissions::from_mode(0o600)).unwrap(); + let state = AgentGatewayState::with_agent_file_and_sqlite( + AgentGatewayConfig::default(), + rustscript_agent::bundled_agent_main_path(), + root.join("gateway.sqlite3"), + ) + .unwrap() + .with_workspace_home(home); + fixture.state = Some(state); + fixture + } + + fn service(&self) -> Arc { + self.state.as_ref().unwrap().service() + } + + fn cleanup(mut self) { + let state = self.state.take().unwrap(); + state.persistence().unwrap().shutdown(); + drop(state); + fs::remove_dir_all(&self.root).unwrap(); + assert!(!self.root.exists()); + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + if let Some(state) = self.state.take() { + state.service().shutdown_capability_host(); + state.persistence().unwrap().shutdown(); + drop(state); + } + if self.root.exists() { + let removed = fs::remove_dir_all(&self.root); + if !std::thread::panicking() { + removed.unwrap(); + } + } + } +} + +fn response(calls: Value, text: &str) -> Value { + json!({"ok": true, "response": { + "text": text, "tool_calls": calls, + "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + "reasoning": "", "stop_reason": if text.is_empty() {"tool_calls"} else {"stop"} + }, "error": {}}) +} + +fn tool(id: &str, name: &str, arguments: Value) -> Value { + response( + json!([{"id": id, "name": name, "arguments": arguments}]), + "", + ) +} + +fn result(request: &Value, call_id: &str) -> Value { + let block = request["messages"] + .as_array() + .unwrap() + .iter() + .flat_map(|message| message["content"].as_array().into_iter().flatten()) + .find(|block| block["type"] == "tool_result" && block["tool_call_id"] == call_id) + .unwrap_or_else(|| panic!("missing {call_id} result")); + json!({"ok": block["is_error"] == false, "data": block["result"], "content": block["content"], "error": block["error"]}) +} + +#[derive(Default)] +struct Sequence { + step: usize, + handle: String, + host: Option, + results: Vec<(String, Value)>, + poll_deadline: Option, + polls: usize, +} + +struct InteractiveProvider { + service: Weak, + run_id: Mutex, + sequence: Mutex, + pause_before_write: Option>, +} + +#[derive(Default)] +struct Pause { + entered: std::sync::atomic::AtomicBool, + released: Mutex, + cv: std::sync::Condvar, +} +impl Pause { + fn release(&self) { + *self.released.lock().unwrap() = true; + self.cv.notify_all(); + } + fn hold(&self) { + self.entered + .store(true, std::sync::atomic::Ordering::Release); + let deadline = Instant::now() + Duration::from_secs(2); + let mut released = self.released.lock().unwrap(); + while !*released { + let remaining = deadline.saturating_duration_since(Instant::now()); + assert!(!remaining.is_zero(), "owned provider pause deadline"); + released = self.cv.wait_timeout(released, remaining).unwrap().0; + } + } +} +struct ReleasePause(Arc); +impl Drop for ReleasePause { + fn drop(&mut self) { + self.0.release(); + } +} + +impl InteractiveProvider { + fn new(service: &Arc) -> Arc { + Arc::new(Self { + service: Arc::downgrade(service), + run_id: Mutex::new(String::new()), + sequence: Mutex::new(Sequence::default()), + pause_before_write: None, + }) + } +} + +impl AgentProviderHost for InteractiveProvider { + fn call(&self, request: &Value, cancellation: &RunCancellation) -> Value { + if request + .pointer("/messages/0/content/0/text") + .and_then(Value::as_str) + .is_some_and(|text| text.starts_with("You are Guardian,")) + { + return response( + json!([]), + r#"{"outcome":"allow","risk_level":"low","user_authorization":"unknown","rationale":"bounded owned process fixture"}"#, + ); + } + let service = self.service.upgrade().unwrap(); + let run_id = self.run_id.lock().unwrap().clone(); + let host = service.capability_host_bridges(&run_id).unwrap().unwrap(); + let mut seq = self.sequence.lock().unwrap(); + if let Some(original) = &seq.host { + assert!(Arc::ptr_eq( + original.lifecycle.as_ref().unwrap(), + host.lifecycle.as_ref().unwrap() + )); + assert!(Arc::ptr_eq( + original.processes.as_ref().unwrap(), + host.processes.as_ref().unwrap() + )); + assert_eq!(original.capability_owner, host.capability_owner); + assert_eq!( + original.cancellation.as_ref().unwrap().deadline_instant(), + cancellation.deadline_instant() + ); + } else { + seq.host = Some(host); + } + let next = match seq.step { + 0 => tool( + "start", + "terminal", + json!({"argv": ["/bin/cat"], "background": true, "timeout_ms": 30000}), + ), + 1 => { + let value = result(request, "start"); + assert_eq!(value["ok"], true, "terminal result: {value}"); + seq.handle = value["data"]["process_id"].as_str().unwrap().to_string(); + seq.results.push(("start".into(), value)); + tool( + "poll-before", + "process", + json!({"action": "poll", "process_id": seq.handle}), + ) + } + 2 => { + if let Some(pause) = &self.pause_before_write { + pause.hold(); + } + let value = result(request, "poll-before"); + assert_eq!(value["ok"], true, "poll: {value}"); + assert_eq!(value["data"]["status"], "running", "poll: {value}"); + seq.results.push(("poll-before".into(), value)); + tool( + "write", + "process", + json!({"action": "write", "process_id": seq.handle, "data": "PINNED_CHILD_ECHO\n", "timeout_ms": 2000}), + ) + } + 3 => { + let value = result(request, "write"); + assert_eq!(value["ok"], true, "write: {value}"); + assert_eq!(value["data"]["wrote_bytes"], "PINNED_CHILD_ECHO\n".len()); + seq.results.push(("write".into(), value)); + seq.poll_deadline = Some(Instant::now() + Duration::from_secs(2)); + tool( + "poll-echo-0", + "process", + json!({"action": "poll", "process_id": seq.handle}), + ) + } + 4 => { + let id = format!("poll-echo-{}", seq.polls); + let value = result(request, &id); + assert_eq!(value["ok"], true, "echo poll: {value}"); + if value["data"]["stdout"] != "PINNED_CHILD_ECHO\n" { + assert!( + Instant::now() < seq.poll_deadline.unwrap(), + "missing real stdout: {value}" + ); + seq.polls += 1; + std::thread::sleep(Duration::from_millis(5)); + return tool( + &format!("poll-echo-{}", seq.polls), + "process", + json!({"action": "poll", "process_id": seq.handle}), + ); + } + seq.results.push((id, value)); + tool( + "kill", + "process", + json!({"action": "kill", "process_id": seq.handle}), + ) + } + 5 => { + let value = result(request, "kill"); + assert_eq!(value["ok"], true, "kill: {value}"); + assert_eq!(value["data"]["status"], "signaled", "kill: {value}"); + seq.results.push(("kill".into(), value)); + // `poll` deliberately reports a killed process as cancelled; + // `log` observes the same terminal snapshot without cancelling Main. + tool( + "poll-exited", + "process", + json!({"action": "log", "process_id": seq.handle}), + ) + } + 6 => { + let value = result(request, "poll-exited"); + assert_eq!( + value["data"]["status"], "signaled", + "exit confirmation: {value}" + ); + seq.results.push(("poll-exited".into(), value)); + response(json!([]), "real process lifecycle complete") + } + _ => panic!("unexpected business continuation"), + }; + seq.step += 1; + next + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn production_pinned_terminal_process_cross_call_poll_write_kill() { + let fixture = Fixture::new(); + let service = fixture.service(); + let provider = InteractiveProvider::new(&service); + service.inject_provider_host(provider.clone()); + let admitted = service.admit_external_with_workspace_selector( + AdmitRunRequest { input: json!("I authorize /bin/cat in the project workspace, then process poll, write PINNED_CHILD_ECHO newline, poll stdout, kill, and confirm exit."), platform: "http".into(), ..AdmitRunRequest::default() }, + Some("name:project".into()) + ).await.unwrap(); + *provider.run_id.lock().unwrap() = admitted.run_id.clone(); + let worker = service + .clone() + .run_worker(admitted.run_id.clone(), String::new()); + tokio::time::timeout(Duration::from_secs(8), worker) + .await + .expect("owned worker completion"); + let events = service.run_events(&admitted.run_id); + assert!( + events.iter().any(|event| event["event"] == "run.completed"), + "terminal names: {:?}", + events + .iter() + .map(|event| &event["event"]) + .collect::>() + ); + assert_eq!(service.process_owner_count(&admitted.run_id), 0); + assert!(service.capability_host_closed(&admitted.run_id)); + let sequence = provider.sequence.lock().unwrap(); + assert_eq!(sequence.step, 7); + let process: &Arc = + sequence.host.as_ref().unwrap().processes.as_ref().unwrap(); + assert_eq!(process.table_len(), 0); + assert_eq!(process.active_stdin_workers(), 0); + for (id, _) in &sequence.results { + assert!( + events.iter().any(|event| matches!( + event["event"].as_str(), + Some("tool.completed" | "tool.failed") + ) && event["data"]["tool_call_id"] == id.as_str()), + "missing durable lifecycle for {id}" + ); + } + drop(sequence); + drop(provider); + drop(service); + fixture.cleanup(); +} + +#[test] +fn child_registration_failure_reaps_only_its_process_and_preserves_siblings() { + use pinned_process_fixture::NativeFixture; + let fixture = NativeFixture::new("first-run"); + let other = NativeFixture::new("other-run"); + let mut forged = fixture.parent_call( + "forged-parent", + "terminal", + json!({"argv": ["/bin/cat"], "background": true}), + ); + forged.arguments = json!({"argv": ["/bin/sh", "-c", "exit 0"], "background": true}); + let rejected = fixture.invoke(forged); + assert_eq!(rejected["code"], "pinned_call_mismatch", "{rejected}"); + assert_eq!(fixture.processes.table_len(), 0); + let sibling = fixture.start("sibling"); + let separate = other.start("separate"); + assert_eq!(fixture.processes.table_len(), 1); + let marker = fixture.root.join("unpublished.pid"); + let pid = Arc::new(Mutex::new(None)); + let interrupt_child = fixture.interrupt_current_child(); + fixture.processes.set_after_os_spawn_hook(Arc::new({ + let marker = marker.clone(); + let pid = pid.clone(); + move || { + let deadline = Instant::now() + Duration::from_secs(2); + let child = loop { + if let Ok(text) = fs::read_to_string(&marker) + && let Ok(child) = text.parse::() + { + break child; + } + assert!(Instant::now() < deadline, "owned child PID protocol"); + std::thread::sleep(Duration::from_millis(5)); + }; + *pid.lock().unwrap() = Some(child); + // Drop precisely the current child's real execution lease. + // Its committed sibling retains the original run generation. + interrupt_child(); + } + })); + let unpublished = fixture.parent_call( + "unpublished", + "terminal", + json!({ + "argv": ["/bin/sh", "-c", "printf '%s' \"$$\" > unpublished.pid; exec /bin/cat"], + "background": true + }), + ); + let failed = fixture.invoke(unpublished); + assert_eq!( + failed["ok"], false, + "registration must fail closed: {failed}" + ); + let child = pid.lock().unwrap().expect("actual owned child PID"); + assert!( + !PathBuf::from(format!("/proc/{child}")).exists(), + "unpublished child must be reaped" + ); + assert_eq!(fixture.processes.table_len(), 1); + assert!(fixture.interrupted("unpublished")); + for (owner, handle) in [(&fixture, &sibling), (&other, &separate)] { + let write = owner.parent_call("write-after-failure", "process", json!({"action": "write", "process_id": handle, "data": "SURVIVING_OWNER\n", "timeout_ms": 2000})); + let written = owner.invoke(write); + assert_eq!(written["ok"], true, "legal sibling write: {written}"); + let deadline = Instant::now() + Duration::from_secs(2); + let mut ordinal = 0; + loop { + let poll = owner.parent_call( + &format!("survivor-poll-{ordinal}"), + "process", + json!({"action": "poll", "process_id": handle}), + ); + let polled = owner.invoke(poll); + assert_eq!(polled["ok"], true, "legal sibling poll: {polled}"); + if polled["data"]["stdout"] == "SURVIVING_OWNER\n" { + break; + } + assert!(Instant::now() < deadline, "surviving child stdout"); + ordinal += 1; + std::thread::sleep(Duration::from_millis(5)); + } + } + let foreign_call = other.parent_call( + "foreign-process", + "process", + json!({"action": "write", "process_id": sibling, "data": "FORBIDDEN", "timeout_ms": 2000}), + ); + let foreign = other.invoke(foreign_call); + assert_eq!(foreign["ok"], false, "other-run handle must fail"); + assert_eq!(foreign["error"]["code"], "process_not_found"); + fixture + .host + .cancellation + .as_ref() + .unwrap() + .request(rustscript_agent::CancellationReason::Requested); + fixture.processes.shutdown_all(); + assert_eq!(fixture.processes.table_len(), 0); + let write = other.parent_call("other-after-cancel", "process", json!({"action": "write", "process_id": separate, "data": "STILL_LIVE\n", "timeout_ms": 2000})); + let written = other.invoke(write); + assert_eq!( + written["ok"], true, + "separate run after original cancellation: {written}" + ); + assert_eq!(other.processes.table_len(), 1); +} + +async fn wait_for(mut predicate: impl FnMut() -> bool) { + let deadline = Instant::now() + Duration::from_secs(8); + while !predicate() { + assert!(Instant::now() < deadline, "owned operation readiness"); + tokio::time::sleep(Duration::from_millis(5)).await; + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn stopping_one_production_run_preserves_other_live_process_continuations() { + let fixture = Fixture::new(); + let service = fixture.service(); + let first = rustscript_agent::ScriptedProvider::new(); + first.push_envelope(tool( + "first-start", + "terminal", + json!({"argv": ["/bin/cat"], "background": true}), + )); + first.push_envelope(response(json!([]), r#"{"outcome":"allow","risk_level":"low","user_authorization":"unknown","rationale":"bounded fixture"}"#)); + first.push_hang(); + service.inject_provider_host(Arc::new(first.clone())); + let first_run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!( + "I authorize an owned background /bin/cat in project; I will stop it." + ), + platform: "http".into(), + ..AdmitRunRequest::default() + }, + Some("name:project".into()), + ) + .await + .unwrap(); + let first_worker = tokio::spawn( + service + .clone() + .run_worker(first_run.run_id.clone(), String::new()), + ); + wait_for(|| first.call_count() == 3 && service.process_owner_count(&first_run.run_id) == 1) + .await; + let first_pids = service.process_owner_pids(&first_run.run_id); + assert_eq!(first_pids.len(), 1); + + let pause = Arc::new(Pause::default()); + let release = ReleasePause(pause.clone()); + let second = Arc::new(InteractiveProvider { + service: Arc::downgrade(&service), + run_id: Mutex::new(String::new()), + sequence: Mutex::new(Sequence::default()), + pause_before_write: Some(pause.clone()), + }); + service.inject_provider_host(second.clone()); + let second_run = service.admit_external_with_workspace_selector( + AdmitRunRequest { input: json!("I authorize /bin/cat, poll, write PINNED_CHILD_ECHO newline, poll stdout, kill and confirm exit."), platform: "http".into(), ..AdmitRunRequest::default() }, + Some("name:project".into()) + ).await.unwrap(); + *second.run_id.lock().unwrap() = second_run.run_id.clone(); + let second_worker = tokio::spawn( + service + .clone() + .run_worker(second_run.run_id.clone(), String::new()), + ); + wait_for(|| pause.entered.load(std::sync::atomic::Ordering::Acquire)).await; + let second_pids = service.process_owner_pids(&second_run.run_id); + assert_eq!(second_pids.len(), 1); + assert_ne!(first_pids, second_pids); + assert_eq!(service.stop(&first_run.run_id).as_deref(), Some("stopping")); + let cleanup_deadline = Instant::now() + Duration::from_secs(2); + tokio::time::timeout_at(cleanup_deadline.into(), first_worker) + .await + .unwrap() + .unwrap(); + assert_eq!(service.process_owner_count(&first_run.run_id), 0); + assert!( + first_pids + .iter() + .all(|pid| !PathBuf::from(format!("/proc/{pid}")).exists()) + ); + assert_eq!(service.process_owner_count(&second_run.run_id), 1); + assert!( + second_pids + .iter() + .all(|pid| PathBuf::from(format!("/proc/{pid}")).exists()) + ); + assert!( + service + .run_events(&first_run.run_id) + .iter() + .any(|event| event["event"] == "run.cancelled") + ); + drop(release); + tokio::time::timeout(Duration::from_secs(8), second_worker) + .await + .unwrap() + .unwrap(); + assert_eq!(second.sequence.lock().unwrap().step, 7); + assert!( + service + .run_events(&second_run.run_id) + .iter() + .any(|event| event["event"] == "run.completed") + ); + assert_eq!(service.process_owner_count(&second_run.run_id), 0); + assert!( + second_pids + .iter() + .all(|pid| !PathBuf::from(format!("/proc/{pid}")).exists()) + ); + drop(second); + drop(service); + fixture.cleanup(); +} + +#[tokio::test] +async fn joining_one_storage_worker_keeps_the_separate_repository_usable() { + let first = Fixture::new(); + let second = Fixture::new(); + let owned = first.state.as_ref().unwrap().persistence().unwrap(); + let sibling = second.state.as_ref().unwrap().persistence().unwrap(); + owned.shutdown(); + assert!(owned.load().is_err()); + assert!(sibling.load().is_ok()); + first.cleanup(); + assert!(sibling.load().is_ok()); + second.cleanup(); +} From fb610b13181b0507382c13c78b543c6d45fed256 Mon Sep 17 00:00:00 2001 From: fffonion Date: Sun, 18 Oct 2026 21:14:44 +0800 Subject: [PATCH 02/21] fix(plugins): backtrack resolver choices within fixed work limits --- src/plugins/resolve.rs | 283 ++++++++++++++------ tests/plugin_graph_tests.rs | 509 ++++++++++++++++++++++++++++++++++++ 2 files changed, 708 insertions(+), 84 deletions(-) diff --git a/src/plugins/resolve.rs b/src/plugins/resolve.rs index c5695b4..a75ce56 100644 --- a/src/plugins/resolve.rs +++ b/src/plugins/resolve.rs @@ -386,19 +386,44 @@ fn validate_origin(origin: &EffectOrigin) -> Result<(), PluginError> { Ok(()) } +#[derive(Clone)] struct Bound { from: String, raw: String, requirement: VersionReq, } +#[derive(Clone)] +struct Pending { + id: String, + raw: String, + from: String, + path: Vec, +} + +#[derive(Clone, Default)] +struct Selection<'a> { + selected: BTreeMap, + bounds: BTreeMap>, + pending: Vec, + edges: usize, +} + struct ResolveCtx<'a> { by_id: &'a BTreeMap<&'a str, Vec<&'a InstalledPackage>>, host: &'a Version, + // Search work belongs to the whole request, never to a rollback snapshot. decisions: usize, - selected: BTreeMap, - bounds: BTreeMap>, - stack: Vec, +} + +impl ResolveCtx<'_> { + fn charge(&mut self) -> Result<(), PluginError> { + if self.decisions == MAX_DECISIONS { + return Err(budget("decision_nodes")); + } + self.decisions += 1; + Ok(()) + } } pub fn resolve_installed( @@ -418,13 +443,29 @@ pub fn resolve_installed( by_id: &by_id, host: &host, decisions: 0, - selected: BTreeMap::new(), - bounds: BTreeMap::new(), - stack: Vec::new(), }; - for root in &request.roots { - visit(&mut ctx, root.id.as_str(), &root.version, "root")?; + if request.roots.len() > MAX_DECISIONS { + return Err(budget("decision_nodes")); } + let mut roots = request.roots.iter().collect::>(); + roots.sort_by(|left, right| (&left.id, &left.version).cmp(&(&right.id, &right.version))); + let mut selection = Selection::default(); + for root in roots.into_iter().rev() { + ctx.charge()?; + selection.pending.push(Pending { + id: root.id.clone(), + raw: root.version.clone(), + from: "root".to_string(), + path: Vec::new(), + }); + } + let ctx = search(&mut ctx, selection).map_err(|error| { + if ctx.decisions == MAX_DECISIONS { + budget("decision_nodes") + } else { + error + } + })?; if ctx.selected.len() > MAX_PACKAGES { return Err(budget("packages")); } @@ -517,90 +558,164 @@ pub fn resolve_installed( Ok(lock) } -fn visit(ctx: &mut ResolveCtx<'_>, id: &str, raw: &str, from: &str) -> Result<(), PluginError> { - if ctx.stack.iter().any(|item| item == id) { - let mut chain = ctx.stack.clone(); - chain.push(id.to_string()); - return Err(PluginError { - kind: PluginErrorKind::Cycle, - chain, - }); - } - let requirement = - VersionReq::parse(raw).map_err(|_| PluginError::new(PluginErrorKind::UnknownField, raw))?; - ctx.bounds.entry(id.to_string()).or_default().push(Bound { - from: from.to_string(), - raw: raw.to_string(), - requirement, - }); - if let Some(existing) = ctx.selected.get(id) { - if !ctx.bounds[id].iter().all(|bound| { - bound - .requirement - .matches(&existing.manifest.parsed_version().unwrap()) - }) { - return Err(conflict(id, &ctx.bounds[id])); +// Each recursive frame owns one newly selected package (at most MAX_PACKAGES). +// The pending continuation includes later roots as well as sibling dependencies. +// Snapshots contain only bounded graph state; the global work meter is not cloned. +fn search<'a>( + ctx: &mut ResolveCtx<'a>, + mut state: Selection<'a>, +) -> Result, PluginError> { + while let Some(task) = state.pending.pop() { + ctx.charge()?; + let Pending { + id, + raw, + from, + mut path, + } = task; + if path.contains(&id) { + path.push(id); + return Err(PluginError { + kind: PluginErrorKind::Cycle, + chain: path, + }); } - return Ok(()); - } - let candidates = ctx - .by_id - .get(id) - .ok_or_else(|| PluginError::new(PluginErrorKind::MissingDependency, id))?; - if candidates.len() > MAX_VERSIONS { - return Err(budget("versions_per_package")); - } - ctx.decisions = ctx.decisions.saturating_add(candidates.len()); - if ctx.decisions > MAX_DECISIONS { - return Err(budget("decision_nodes")); - } - let mut versions = BTreeSet::new(); - for candidate in candidates { - if !versions.insert(candidate.manifest.version.clone()) { + if path.len() == MAX_DEPTH { + return Err(budget("depth")); + } + let requirement = VersionReq::parse(&raw) + .map_err(|_| PluginError::new(PluginErrorKind::UnknownField, &raw))?; + let bounds = state.bounds.entry(id.clone()).or_default(); + if !bounds + .iter() + .any(|bound| bound.from == from && bound.raw == raw) + { + bounds.push(Bound { + from, + raw, + requirement: requirement.clone(), + }); + } + path.push(id.clone()); + if let Some(existing) = state.selected.get(&id).copied() { + ctx.charge()?; + let version = existing.manifest.parsed_version()?; + if !requirement.matches(&version) { + return Err(conflict(&id, &state.bounds[&id])); + } + // Revisit children along this path: selection alone cannot prove + // absence of a cycle or a too-deep route through a shared DAG. + enqueue(ctx, &mut state, existing, &path)?; + continue; + } + let candidates = ctx + .by_id + .get(id.as_str()) + .ok_or_else(|| PluginError::new(PluginErrorKind::MissingDependency, &id))?; + if candidates.len() > MAX_VERSIONS { + return Err(budget("versions_per_package")); + } + let mut versions = BTreeSet::new(); + let mut matching = Vec::new(); + let mut host_mismatch = false; + for candidate in candidates { + ctx.charge()?; + if !versions.insert(candidate.manifest.version.clone()) { + return Err(PluginError::new( + PluginErrorKind::Ambiguous, + format!("{id} {}", candidate.manifest.version), + )); + } + let version = candidate.manifest.parsed_version()?; + let compatible = candidate.manifest.runtime_matches(ctx.host)?; + host_mismatch |= !compatible; + let mut matches = compatible; + for bound in &state.bounds[&id] { + ctx.charge()?; + matches &= bound.requirement.matches(&version); + } + if matches { + matching.push((version, *candidate)); + } + } + if matching.is_empty() { + if state.bounds[&id].len() > 1 { + return Err(conflict(&id, &state.bounds[&id])); + } return Err(PluginError::new( - PluginErrorKind::Ambiguous, - format!("{id} {}", candidate.manifest.version), + if host_mismatch { + PluginErrorKind::ContractIncompatible + } else { + PluginErrorKind::MissingDependency + }, + &id, )); } - } - let mut matching = candidates - .iter() - .copied() - .filter(|candidate| { - let version = candidate.manifest.parsed_version().ok(); - version.is_some() - && ctx.bounds[id] - .iter() - .all(|bound| bound.requirement.matches(version.as_ref().unwrap())) - && candidate - .manifest - .runtime_matches(ctx.host) - .unwrap_or(false) - }) - .collect::>(); - if matching.is_empty() { - let host_mismatch = candidates - .iter() - .any(|candidate| candidate.manifest.runtime_matches(ctx.host).ok() == Some(false)); - if host_mismatch && ctx.bounds[id].len() == 1 { - return Err(PluginError::new(PluginErrorKind::ContractIncompatible, id)); + matching.sort_by(|left, right| { + right + .0 + .cmp(&left.0) + .then_with(|| left.1.artifact_id.cmp(&right.1.artifact_id)) + }); + if state.selected.len() == MAX_PACKAGES { + return Err(budget("packages")); } - if ctx.bounds[id].len() > 1 { - return Err(conflict(id, &ctx.bounds[id])); + let mut first_failure = None; + for (_, candidate) in matching { + ctx.charge()?; + let result = if state.edges + candidate.manifest.dependencies.len() > MAX_EDGES { + Err(budget("edges")) + } else { + let mut branch = state.clone(); + branch.selected.insert(id.clone(), candidate); + branch.edges += candidate.manifest.dependencies.len(); + enqueue(ctx, &mut branch, candidate, &path).and_then(|()| search(ctx, branch)) + }; + match result { + Ok(solution) => return Ok(solution), + Err(error) => { + // Metadata errors fail closed. Only graph-specific failures + // permit another candidate; exhausted work stops all branches. + if ctx.decisions == MAX_DECISIONS + || !matches!( + error.kind, + PluginErrorKind::Conflict + | PluginErrorKind::MissingDependency + | PluginErrorKind::ContractIncompatible + | PluginErrorKind::Cycle + | PluginErrorKind::BudgetExceeded + ) + { + return Err(error); + } + first_failure.get_or_insert(error); + } + } } - return Err(PluginError::new(PluginErrorKind::MissingDependency, id)); - } - matching.sort_by_key(|candidate| candidate.manifest.parsed_version().ok()); - let chosen = *matching.last().unwrap(); - if ctx.stack.len() + 1 > MAX_DEPTH { - return Err(budget("depth")); + return Err(first_failure.expect("nonempty candidate list")); } - ctx.selected.insert(id.to_string(), chosen); - ctx.stack.push(id.to_string()); - for dependency in &chosen.manifest.dependencies { - visit(ctx, &dependency.id, &dependency.version, id)?; + Ok(state) +} + +fn enqueue( + ctx: &mut ResolveCtx<'_>, + state: &mut Selection<'_>, + package: &InstalledPackage, + path: &[String], +) -> Result<(), PluginError> { + let mut dependencies = package.manifest.dependencies.iter().collect::>(); + dependencies.sort_by(|left, right| { + (&left.id, &left.alias, &left.version).cmp(&(&right.id, &right.alias, &right.version)) + }); + for dependency in dependencies.into_iter().rev() { + ctx.charge()?; + state.pending.push(Pending { + id: dependency.id.clone(), + raw: dependency.version.clone(), + from: package.manifest.id.clone(), + path: path.to_vec(), + }); } - ctx.stack.pop(); Ok(()) } diff --git a/tests/plugin_graph_tests.rs b/tests/plugin_graph_tests.rs index 450870d..f30a5f9 100644 --- a/tests/plugin_graph_tests.rs +++ b/tests/plugin_graph_tests.rs @@ -275,6 +275,103 @@ fn missing_conflict_and_cycle_return_minimal_chains() { assert_kind(cycle.unwrap_err(), PluginErrorKind::Cycle); } +#[test] +fn shared_constraint_backtracks_across_later_roots() { + let root = temp_dir("shared-backtrack"); + let store = PackageStore::open(root.join("store")).unwrap(); + let signer = signer(); + for version in ["1.0.0", "2.0.0"] { + write_signed( + &store, + &signer, + &root.join(version), + &library_manifest("org.rustscript.lib.shared", version, "length"), + &[( + "length.rss", + "pub fn length(value: string) -> int { value.length }\n", + )], + ); + } + for (name, requirement) in [("a", ">=1.0.0, <3.0.0"), ("b", "^1.0.0")] { + write_signed( + &store, + &signer, + &root.join(name), + &tool_manifest( + &format!("org.rustscript.tools.{name}"), + "1.0.0", + &format!( + " - alias: shared\n id: org.rustscript.lib.shared\n version: \"{requirement}\"\n" + ), + " direct: []\n delegated: []\n", + "", + ), + &[("invoke.rss", "pub fn invoke(value: int) -> int { value }\n")], + ); + } + let request = ResolveRequest { + roots: ["a", "b"] + .iter() + .map(|name| PackageRequirement { + id: format!("org.rustscript.tools.{name}"), + version: "1.0.0".to_string(), + }) + .collect(), + host_runtime: "0.1.0".to_string(), + }; + let lock = store.resolve(&request); + assert!( + lock.is_ok(), + "shared constraints have a complete solution: {lock:?}" + ); + let lock = lock.unwrap(); + assert_eq!(lock.packages.len(), 3); + let shared = lock + .packages + .iter() + .find(|package| package.id == "org.rustscript.lib.shared") + .unwrap(); + assert_eq!(shared.version, "1.0.0"); + assert_eq!(lock.edges.len(), 2); + for edge in &lock.edges { + assert_eq!(edge.to, shared.id); + assert_eq!(edge.to_version, shared.version); + assert_eq!(edge.to_artifact_id, shared.artifact_id); + } + let mut packages = store.load_installed().unwrap(); + let only_a = ResolveRequest { + roots: vec![request.roots[0].clone()], + ..request.clone() + }; + let greedy = resolve_installed(&packages, &only_a).unwrap(); + assert_eq!( + greedy + .packages + .iter() + .find(|package| package.id == shared.id) + .unwrap() + .version, + "2.0.0" + ); + for reverse_packages in [false, true] { + if reverse_packages { + packages.reverse(); + } + for reverse_roots in [false, true] { + let mut permuted = request.clone(); + if reverse_roots { + permuted.roots.reverse(); + } + assert_eq!( + resolve_installed(&packages, &permuted) + .unwrap() + .semantic_yaml(), + lock.semantic_yaml() + ); + } + } +} + #[test] fn duplicate_version_candidates_are_ambiguous() { let manifest = parse_plugin_manifest(&library_manifest( @@ -801,6 +898,418 @@ fn signed_completion_protocol_is_role_fenced_and_generic_artifacts_stay_denied() } } +fn graph_package(name: &str, version: &str, deps: &[(&str, &str)]) -> InstalledPackage { + let dependencies = deps.iter().enumerate().map(|(index, (target, requirement))| { + format!(" - alias: dep{index}\n id: org.rustscript.tools.{target}\n version: \"{requirement}\"\n") + }).collect::(); + let manifest = parse_plugin_manifest(&tool_manifest( + &format!("org.rustscript.tools.{name}"), + version, + &dependencies, + " direct: []\n delegated: []\n", + "", + )) + .unwrap(); + InstalledPackage::new( + &format!("pkg-{name}-{version}"), + manifest, + BTreeMap::from([( + "invoke.rss".to_string(), + b"pub fn invoke(value: int) -> int { value }\n".to_vec(), + )]), + ) +} + +fn graph_request(names: &[&str]) -> ResolveRequest { + ResolveRequest { + roots: names + .iter() + .map(|name| PackageRequirement { + id: format!("org.rustscript.tools.{name}"), + version: "*".to_string(), + }) + .collect(), + host_runtime: "0.1.0".to_string(), + } +} + +#[test] +fn parent_candidate_backtracks_without_stale_dependencies_or_bounds() { + let mut packages = vec![ + graph_package("a", "2.0.0", &[("c", "^2"), ("stale", "*")]), + graph_package("a", "1.0.0", &[("c", "^1")]), + graph_package("b", "1.0.0", &[("c", "^1")]), + graph_package("c", "1.0.0", &[]), + graph_package("c", "2.0.0", &[]), + graph_package("stale", "1.0.0", &[("orphan", "*")]), + graph_package("orphan", "1.0.0", &[]), + ]; + for package in &mut packages { + let (effects, capabilities) = match package.manifest.id.as_str() { + "org.rustscript.tools.c" => ( + " direct:\n - kind: clock.read\n scope: bound_run\n recovery: none\n delegated: []\n", + " - clock.read\n", + ), + "org.rustscript.tools.a" | "org.rustscript.tools.b" => ( + " direct: []\n delegated:\n - dependency: dep0\n entry: invoke\n allow:\n - kind: clock.read\n scope: bound_run\n recovery: none\n", + "", + ), + "org.rustscript.tools.stale" => ( + " direct:\n - kind: workspace.fs.write\n scope: bound_workspace\n recovery: checkpoint\n delegated: []\n", + " - workspace.write\n", + ), + _ => continue, + }; + let deps = package + .manifest + .dependencies + .iter() + .map(|dependency| { + format!( + " - alias: {}\n id: {}\n version: \"{}\"\n", + dependency.alias, dependency.id, dependency.version, + ) + }) + .collect::(); + package.manifest = parse_plugin_manifest(&tool_manifest( + &package.manifest.id, + &package.manifest.version, + &deps, + effects, + capabilities, + )) + .unwrap(); + } + let request = graph_request(&["a", "b"]); + let high = resolve_installed(&packages, &graph_request(&["a"])).unwrap(); + assert_eq!(high.packages.len(), 4); + assert!(high.semantic_yaml().contains("workspace.fs.write")); + let lock = resolve_installed(&packages, &request).unwrap(); + assert_eq!( + lock.packages + .iter() + .map(|package| (package.id.as_str(), package.version.as_str())) + .collect::>(), + vec![ + ("org.rustscript.tools.a", "1.0.0"), + ("org.rustscript.tools.b", "1.0.0"), + ("org.rustscript.tools.c", "1.0.0"), + ] + ); + assert_eq!(lock.edges.len(), 2); + assert_eq!(lock.closure.len(), 3); + assert!(lock.closure.iter().all(|entry| entry.effects.len() == 1)); + for entry in &lock.closure { + let effect = &entry.effects[0]; + assert_eq!(effect.kind, "clock.read"); + assert_eq!(effect.scope, "bound_run"); + assert_eq!(effect.recovery, "none"); + assert_eq!(effect.via.callee_package, "org.rustscript.tools.c"); + assert_eq!(effect.via.callee_version, "1.0.0"); + assert_eq!(effect.via.caller_package, entry.package); + assert_eq!( + effect.via.relation, + if entry.package == "org.rustscript.tools.c" { + "direct" + } else { + "plugin" + } + ); + } + assert!(!lock.semantic_yaml().contains("workspace.fs.write")); + assert!(!lock.semantic_yaml().contains("stale")); + assert!(!lock.semantic_yaml().contains("orphan")); + let mut permuted = packages.clone(); + permuted.reverse(); + for package in &mut permuted { + package.manifest.dependencies.reverse(); + } + assert_eq!( + resolve_installed(&permuted, &graph_request(&["b", "a"])) + .unwrap() + .semantic_yaml(), + lock.semantic_yaml() + ); +} + +#[test] +fn work_budget_is_not_refunded_when_later_roots_reject_candidates() { + let mut packages = Vec::new(); + let names = (0..12) + .map(|index| format!("a{index:02}")) + .collect::>(); + for name in &names { + for version in ["1.0.0", "2.0.0"] { + packages.push(graph_package(name, version, &[])); + } + } + packages.push(graph_package("z", "1.0.0", &[("a00", "^3")])); + let mut roots = names.iter().map(String::as_str).collect::>(); + roots.push("z"); + let error = resolve_installed(&packages, &graph_request(&roots)).unwrap_err(); + assert_eq!(error.kind, PluginErrorKind::BudgetExceeded); + assert_eq!(error.chain, vec!["decision_nodes"]); +} + +#[test] +fn exhausted_work_reports_budget_even_at_a_conflict_boundary() { + let packages = vec![ + graph_package("a", "1.0.0", &[]), + graph_package("b", "1.0.0", &[]), + ]; + // 1364 enqueues + two four-unit first visits + 1361 two-unit + // selected checks + a final two-unit conflicting check = 4096. + let mut request = graph_request(&["a"]); + request.roots.extend(graph_request(&vec!["b"; 1362]).roots); + request.roots.push(PackageRequirement { + id: "org.rustscript.tools.b".to_string(), + version: "^2".to_string(), + }); + let error = resolve_installed(&packages, &request).unwrap_err(); + assert_eq!(error.kind, PluginErrorKind::BudgetExceeded); + assert_eq!(error.chain, vec!["decision_nodes"]); +} + +#[test] +fn unsatisfiable_shared_constraints_keep_both_requirements_in_the_chain() { + let packages = vec![ + graph_package("a", "2.0.0", &[("c", "^2")]), + graph_package("a", "1.0.0", &[("c", "^1")]), + graph_package("b", "1.0.0", &[("c", "^3")]), + graph_package("c", "1.0.0", &[]), + graph_package("c", "2.0.0", &[]), + graph_package("c", "3.0.0", &[]), + ]; + let error = resolve_installed(&packages, &graph_request(&["a", "b"])).unwrap_err(); + assert_eq!(error.kind, PluginErrorKind::Conflict); + assert_eq!( + error.chain, + vec![ + "org.rustscript.tools.a requires org.rustscript.tools.c ^2", + "org.rustscript.tools.b requires org.rustscript.tools.c ^3", + ] + ); +} + +#[test] +fn shared_nodes_are_rechecked_for_depth_on_every_access_path() { + for prefix_len in [14, 15] { + let mut packages = vec![ + graph_package("a", "1.0.0", &[("tail", "*")]), + graph_package("tail", "1.0.0", &[]), + ]; + for index in 0..prefix_len { + let target = if index + 1 == prefix_len { + "a".to_string() + } else { + format!("z{:02}", index + 1) + }; + packages.push(graph_package( + &format!("z{index:02}"), + "1.0.0", + &[(&target, "*")], + )); + } + let result = resolve_installed(&packages, &graph_request(&["a", "z00"])); + if prefix_len == 14 { + assert_eq!(result.unwrap().packages.len(), 16); + } else { + let error = result.unwrap_err(); + assert_eq!(error.kind, PluginErrorKind::BudgetExceeded); + assert_eq!(error.chain, vec!["depth"]); + } + } +} + +#[test] +fn structural_candidate_failures_can_use_a_legal_parent_alternative() { + for failure in ["missing", "cycle", "depth", "edges", "packages"] { + let mut packages = vec![graph_package("a", "1.0.0", &[])]; + let high = match failure { + "missing" => graph_package("a", "2.0.0", &[("absent", "*")]), + "cycle" => { + packages.push(graph_package("b", "1.0.0", &[("a", "*")])); + graph_package("a", "2.0.0", &[("b", "*")]) + } + "depth" => { + for index in 0..16 { + let next = format!("d{:02}", index + 1); + let deps = if index == 15 { + vec![] + } else { + vec![(next.as_str(), "*")] + }; + packages.push(graph_package(&format!("d{index:02}"), "1.0.0", &deps)); + } + graph_package("a", "2.0.0", &[("d00", "*")]) + } + "edges" => { + packages.push(graph_package("leaf", "1.0.0", &[])); + graph_package("a", "2.0.0", &vec![("leaf", "*"); 257]) + } + "packages" => { + let names = (0..64) + .map(|index| format!("p{index:02}")) + .collect::>(); + for name in &names { + packages.push(graph_package(name, "1.0.0", &[])); + } + let deps = names + .iter() + .map(|name| (name.as_str(), "*")) + .collect::>(); + graph_package("a", "2.0.0", &deps) + } + _ => unreachable!(), + }; + packages.push(high); + let request = graph_request(&["a"]); + let lock = resolve_installed(&packages, &request).unwrap(); + assert_eq!(lock.packages.len(), 1, "{failure}"); + assert_eq!(lock.packages[0].version, "1.0.0", "{failure}"); + assert!(lock.edges.is_empty(), "{failure}"); + assert_eq!(lock.closure.len(), 1, "{failure}"); + // Without the alternative, preserve the actual structural diagnostic. + packages.retain(|package| { + !(package.manifest.id == "org.rustscript.tools.a" + && package.manifest.version == "1.0.0") + }); + let error = resolve_installed(&packages, &request).unwrap_err(); + match failure { + "missing" => assert_kind(error, PluginErrorKind::MissingDependency), + "cycle" => { + assert_eq!(error.kind, PluginErrorKind::Cycle); + assert_eq!( + error.chain, + vec![ + "org.rustscript.tools.a", + "org.rustscript.tools.b", + "org.rustscript.tools.a" + ] + ); + } + bound => { + assert_eq!(error.kind, PluginErrorKind::BudgetExceeded); + assert_eq!(error.chain, vec![bound]); + } + } + } +} + +#[test] +fn package_edge_and_version_limits_accept_the_boundary() { + let names = (0..65) + .map(|index| format!("p{index:02}")) + .collect::>(); + let mut packages = names + .iter() + .map(|name| graph_package(name, "1.0.0", &[])) + .collect::>(); + let roots = names.iter().map(String::as_str).collect::>(); + assert_eq!( + resolve_installed(&packages, &graph_request(&roots[..64])) + .unwrap() + .packages + .len(), + 64 + ); + let error = resolve_installed(&packages, &graph_request(&roots)).unwrap_err(); + assert_eq!(error.kind, PluginErrorKind::BudgetExceeded); + assert_eq!(error.chain, vec!["packages"]); + for count in [256, 257] { + let graph = vec![ + graph_package("a", "1.0.0", &vec![("leaf", "*"); count]), + graph_package("leaf", "1.0.0", &[]), + ]; + let result = resolve_installed(&graph, &graph_request(&["a"])); + if count == 256 { + assert_eq!(result.unwrap().edges.len(), 256); + } else { + let error = result.unwrap_err(); + assert_eq!(error.kind, PluginErrorKind::BudgetExceeded); + assert_eq!(error.chain, vec!["edges"]); + } + } + packages = (0..32) + .map(|index| graph_package("a", &format!("1.0.{index}"), &[])) + .collect(); + assert_eq!( + resolve_installed(&packages, &graph_request(&["a"])) + .unwrap() + .packages[0] + .version, + "1.0.31" + ); +} + +#[test] +fn repeated_selected_checks_share_the_same_finite_work_budget() { + let packages = vec![graph_package("a", "1.0.0", &[])]; + assert!(resolve_installed(&packages, &graph_request(&vec!["a"; 1364])).is_ok()); + let error = resolve_installed(&packages, &graph_request(&vec!["a"; 1365])).unwrap_err(); + assert_eq!(error.kind, PluginErrorKind::BudgetExceeded); + assert_eq!(error.chain, vec!["decision_nodes"]); +} + +#[test] +fn host_compatibility_and_duplicate_metadata_remain_fail_closed() { + let low = graph_package("a", "1.0.0", &[]); + let mut high = graph_package("a", "2.0.0", &[]); + high.manifest = parse_plugin_manifest( + &tool_manifest( + "org.rustscript.tools.a", + "2.0.0", + "", + " direct: []\n delegated: []\n", + "", + ) + .replace(">=0.1.0, <0.2.0", ">=0.2.0, <0.3.0"), + ) + .unwrap(); + assert_eq!( + resolve_installed(&[low.clone(), high.clone()], &graph_request(&["a"])) + .unwrap() + .packages[0] + .version, + "1.0.0" + ); + assert_kind( + resolve_installed(&[high], &graph_request(&["a"])).unwrap_err(), + PluginErrorKind::ContractIncompatible, + ); + let duplicate = low.clone(); + let packages = vec![ + graph_package("parent", "1.0.0", &[]), + graph_package("parent", "2.0.0", &[("a", "*")]), + low, + duplicate, + ]; + assert_kind( + resolve_installed(&packages, &graph_request(&["parent"])).unwrap_err(), + PluginErrorKind::Ambiguous, + ); +} + +#[test] +fn effect_authority_failure_does_not_fallback_to_a_pure_candidate() { + let mut high = graph_package("a", "2.0.0", &[]); + high.manifest = parse_plugin_manifest(&tool_manifest( + "org.rustscript.tools.a", "2.0.0", + " - alias: writer\n id: org.rustscript.tools.writer\n version: \"*\"\n", + " direct: []\n delegated:\n - dependency: writer\n entry: invoke\n allow: []\n", "", + )).unwrap(); + let mut writer = graph_package("writer", "1.0.0", &[]); + writer.manifest = parse_plugin_manifest(&tool_manifest( + "org.rustscript.tools.writer", "1.0.0", "", + " direct:\n - kind: workspace.fs.write\n scope: bound_workspace\n recovery: checkpoint\n delegated: []\n", " - workspace.write\n", + )).unwrap(); + let packages = vec![graph_package("a", "1.0.0", &[]), high, writer]; + assert_kind( + resolve_installed(&packages, &graph_request(&["a"])).unwrap_err(), + PluginErrorKind::UndeclaredTransitiveEffect, + ); +} + #[test] fn resolver_budget_is_a_typed_error() { let mut packages = Vec::new(); From d83dbaf1f74b0f58b664486b51c7b011e478a44d Mon Sep 17 00:00:00 2001 From: fffonion Date: Sun, 18 Oct 2026 21:32:05 +0800 Subject: [PATCH 03/21] test(auth): fence TLS fixture admission on native pump teardown --- src/auth/native_https.rs | 466 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 446 insertions(+), 20 deletions(-) diff --git a/src/auth/native_https.rs b/src/auth/native_https.rs index 8f9744b..02ac7f4 100644 --- a/src/auth/native_https.rs +++ b/src/auth/native_https.rs @@ -100,6 +100,8 @@ fn busy() -> OAuthError { /// Explicitly injected HTTPS transport; it never uses proxy settings or follows redirects. pub struct NativeHttpsTransport { tls: Arc, + #[cfg(test)] + pump_probe: Option, } impl NativeHttpsTransport { @@ -117,6 +119,8 @@ impl NativeHttpsTransport { .with_root_certificates(roots) .with_no_client_auth(), ), + #[cfg(test)] + pump_probe: None, } } @@ -300,6 +304,7 @@ fn start_native_stream( tls: &Arc, request: &PreparedHttpsRequest, context: &OAuthIoContext, + #[cfg(test)] pump_probe: Option, ) -> Result { let validated = validate_request(request, context)?; let permit = Arc::clone(REQUESTS.get_or_init(|| Arc::new(Semaphore::new(MAX_ACTIVE_REQUESTS)))) @@ -319,7 +324,7 @@ fn start_native_stream( let owned_context = context.clone(); let tls = Arc::clone(tls); let (sender, receiver) = mpsc::sync_channel(1); - let task = runtime.spawn(async move { + let pump = async move { let result = tokio::time::timeout_at( owned_context.deadline().into(), pump_response(tls.clone(), validated, &sender, &owned_context), @@ -330,7 +335,10 @@ fn start_native_stream( let _ = send_message(&sender, PumpMessage::Error(error), &owned_context).await; } drop(permit); - }); + }; + #[cfg(test)] + let pump = tests::WitnessedPump::new(pump, pump_probe); + let task = runtime.spawn(pump); let mut receiver = Some(receiver); let head = loop { @@ -557,7 +565,13 @@ impl BoundedHttpsTransport for NativeHttpsTransport { request: &PreparedHttpsRequest, context: &OAuthIoContext, ) -> Result { - let mut stream = start_native_stream(&self.tls, request, context)?; + let mut stream = start_native_stream( + &self.tls, + request, + context, + #[cfg(test)] + self.pump_probe.clone(), + )?; let status = stream.head().status; let headers = stream.head().headers.clone(); let mut body = Vec::new(); @@ -582,7 +596,13 @@ impl BoundedHttpsTransport for NativeHttpsTransport { request: &PreparedHttpsRequest, context: &OAuthIoContext, ) -> Result, OAuthError> { - Ok(Box::new(start_native_stream(&self.tls, request, context)?)) + Ok(Box::new(start_native_stream( + &self.tls, + request, + context, + #[cfg(test)] + self.pump_probe.clone(), + )?)) } } @@ -939,6 +959,332 @@ mod tests { (url, roots, rx, thread) } + // Request-local instrumentation: no recorder or lock is shared by requests. + #[derive(Clone)] + pub(super) struct PumpProbe { + completed: mpsc::Sender<()>, + gate: Option>, + } + + struct PumpGate { + released: std::sync::Mutex, + changed: std::sync::Condvar, + entered: mpsc::Sender<()>, + deadline: Instant, + armed: AtomicBool, + waker: std::sync::Mutex>, + } + + impl PumpGate { + fn pause(&self) { + if self.armed.load(Ordering::Acquire) { + let _ = self.entered.send(()); + let released = self.released.lock().unwrap_or_else(|e| e.into_inner()); + let _ = self + .changed + .wait_timeout_while( + released, + self.deadline.saturating_duration_since(Instant::now()), + |released| !*released, + ) + .unwrap_or_else(|e| e.into_inner()); + } + } + } + + struct PumpCompletion(mpsc::Receiver<()>); + + impl PumpCompletion { + fn wait(&self, deadline: Instant) -> Result<(), mpsc::RecvTimeoutError> { + let remaining = deadline + .checked_duration_since(Instant::now()) + .ok_or(mpsc::RecvTimeoutError::Timeout)?; + self.0.recv_timeout(remaining)?; + if Instant::now() > deadline { + return Err(mpsc::RecvTimeoutError::Timeout); + } + Ok(()) + } + } + + struct PumpControl { + gate: Arc, + entered: mpsc::Receiver<()>, + } + + impl PumpControl { + fn arm(&self) { + self.gate.armed.store(true, Ordering::Release); + if let Some(waker) = self + .gate + .waker + .lock() + .unwrap_or_else(|e| e.into_inner()) + .as_ref() + { + waker.wake_by_ref(); + } + } + + fn release(&self) { + let mut released = self.gate.released.lock().unwrap_or_else(|e| e.into_inner()); + *released = true; + self.gate.changed.notify_all(); + } + } + + impl Drop for PumpControl { + fn drop(&mut self) { + // This also releases an unentered gate before scoped-thread joins. + self.release(); + } + } + + impl PumpProbe { + fn new() -> (Self, PumpCompletion) { + let (completed, receiver) = mpsc::channel(); + ( + Self { + completed, + gate: None, + }, + PumpCompletion(receiver), + ) + } + + fn controlled(deadline: Instant) -> (Self, PumpCompletion, PumpControl) { + let (mut probe, completion) = Self::new(); + let (entered, receiver) = mpsc::channel(); + let gate = Arc::new(PumpGate { + released: std::sync::Mutex::new(false), + changed: std::sync::Condvar::new(), + entered, + deadline, + armed: AtomicBool::new(false), + waker: std::sync::Mutex::new(None), + }); + probe.gate = Some(Arc::clone(&gate)); + ( + probe, + completion, + PumpControl { + gate, + entered: receiver, + }, + ) + } + } + + pub(super) struct WitnessedPump { + inner: Option>>, + probe: Option, + } + + impl WitnessedPump { + pub(super) fn new(inner: F, probe: Option) -> Self { + Self { + inner: Some(Box::pin(inner)), + probe, + } + } + } + + impl> Future for WitnessedPump { + type Output = (); + + fn poll(mut self: Pin<&mut Self>, cx: &mut std::task::Context<'_>) -> std::task::Poll<()> { + if let Some(gate) = self.probe.as_ref().and_then(|probe| probe.gate.as_ref()) { + *gate.waker.lock().unwrap_or_else(|e| e.into_inner()) = Some(cx.waker().clone()); + gate.pause(); + } + self.inner.as_mut().unwrap().as_mut().poll(cx) + } + } + + impl Drop for WitnessedPump { + fn drop(&mut self) { + if let Some(gate) = self.probe.as_ref().and_then(|probe| probe.gate.as_ref()) { + gate.pause(); + } + // Declare this guard before explicit inner destruction: publication + // follows destruction even if an inner destructor unwinds. Never + // depend on the wrapper fields' implicit drop order. + struct Publish(Option); + impl Drop for Publish { + fn drop(&mut self) { + if let Some(probe) = self.0.take() { + let _ = probe.completed.send(()); + } + } + } + let _publish = Publish(self.probe.take()); + drop(self.inner.take()); + } + } + + fn observed_transport( + transport: &NativeHttpsTransport, + probe: PumpProbe, + ) -> NativeHttpsTransport { + NativeHttpsTransport { + tls: Arc::clone(&transport.tls), + pump_probe: Some(probe), + } + } + + // A local resource-bearing future checks the wrapper's teardown boundary; + // transport/admission regressions below still use real native TLS pumps. + struct TeardownFuture { + permit: Option, + completion: Arc>, + pending_at_drop: Arc, + poll_mode: u8, + panic_on_drop: bool, + } + + impl Future for TeardownFuture { + type Output = (); + + fn poll(self: Pin<&mut Self>, _: &mut std::task::Context<'_>) -> std::task::Poll<()> { + match self.poll_mode { + 0 => std::task::Poll::Ready(()), + 1 => std::task::Poll::Pending, + _ => panic!("controlled pump poll unwind"), + } + } + } + + impl Drop for TeardownFuture { + fn drop(&mut self) { + self.pending_at_drop.store( + self.completion.lock().unwrap().0.try_recv() == Err(mpsc::TryRecvError::Empty), + Ordering::Release, + ); + drop(self.permit.take()); + if self.panic_on_drop { + panic!("controlled inner destructor unwind"); + } + } + } + + #[test] + fn native_pump_witness_ready_abort_before_poll_and_poll_unwind() { + for mode in 0..3 { + let deadline = Instant::now() + Duration::from_secs(2); + let permits = Arc::new(Semaphore::new(1)); + let (probe, completion) = PumpProbe::new(); + let completion = Arc::new(std::sync::Mutex::new(completion)); + let pending_at_drop = Arc::new(AtomicBool::new(false)); + let inner = TeardownFuture { + permit: Some(Arc::clone(&permits).try_acquire_owned().unwrap()), + completion: Arc::clone(&completion), + pending_at_drop: Arc::clone(&pending_at_drop), + poll_mode: mode, + panic_on_drop: false, + }; + let runtime = tokio::runtime::Builder::new_current_thread() + .build() + .unwrap(); + let task = runtime.spawn(WitnessedPump::new(inner, Some(probe))); + if mode == 1 { + // Current-thread runtime has never been entered: no first poll. + task.abort(); + } + let result = runtime.block_on(task); + match mode { + 0 => assert!(result.is_ok()), + 1 => assert!(result.unwrap_err().is_cancelled()), + _ => assert!(result.unwrap_err().is_panic()), + } + completion.lock().unwrap().wait(deadline).unwrap(); + assert!(pending_at_drop.load(Ordering::Acquire)); + assert_eq!(permits.available_permits(), 1); + } + } + + #[test] + fn native_pump_witness_inner_drop_unwind_keeps_completion_after_destruction() { + let deadline = Instant::now() + Duration::from_secs(2); + let permits = Arc::new(Semaphore::new(1)); + let (probe, completion) = PumpProbe::new(); + let completion = Arc::new(std::sync::Mutex::new(completion)); + let pending_at_drop = Arc::new(AtomicBool::new(false)); + let inner = TeardownFuture { + permit: Some(Arc::clone(&permits).try_acquire_owned().unwrap()), + completion: Arc::clone(&completion), + pending_at_drop: Arc::clone(&pending_at_drop), + poll_mode: 1, + panic_on_drop: true, + }; + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + drop(WitnessedPump::new(inner, Some(probe))); + })); + assert_eq!( + outcome.unwrap_err().downcast_ref::<&str>(), + Some(&"controlled inner destructor unwind") + ); + completion.lock().unwrap().wait(deadline).unwrap(); + assert!(pending_at_drop.load(Ordering::Acquire)); + assert_eq!(permits.available_permits(), 1); + } + + #[test] + fn native_pump_witness_wait_failure_unwind_releases_unentered_gate() { + let deadline = Instant::now() + Duration::from_secs(2); + let (probe, completion, control) = PumpProbe::controlled(deadline); + control.arm(); + let future = WitnessedPump::new(std::future::pending::<()>(), Some(probe)); + assert_eq!( + completion.wait(Instant::now()), + Err(mpsc::RecvTimeoutError::Timeout) + ); + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + // Deliberate failure before spawn/entry: control must release first. + let _future = future; + let _control = control; + panic!("primary observer unwind"); + })); + assert_eq!( + outcome.unwrap_err().downcast_ref::<&str>(), + Some(&"primary observer unwind") + ); + completion.wait(deadline).unwrap(); + let (probe, abandoned) = PumpProbe::new(); + drop(abandoned); + let abandoned_future = WitnessedPump::new(std::future::pending::<()>(), Some(probe)); + // A disconnected observer during cleanup must preserve the primary + // unwind payload, with no send unwrap or second panic in Drop. + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _future = abandoned_future; + panic!("primary despite observer cancellation"); + })); + assert_eq!( + outcome.unwrap_err().downcast_ref::<&str>(), + Some(&"primary despite observer cancellation") + ); + let (probe, disconnected) = PumpProbe::new(); + drop(probe); + assert_eq!( + disconnected.wait(deadline), + Err(mpsc::RecvTimeoutError::Disconnected) + ); + } + + #[test] + fn native_pump_witness_error_destroys_real_tls_pump() { + let _admission = read_native_request_admission(); + let (url, roots, _events, server) = scripted_server(Reply::ChunkedOversize); + let (probe, completion) = PumpProbe::new(); + let transport = observed_transport(&NativeHttpsTransport::with_roots(roots), probe); + let (io, _) = context(Duration::from_secs(3)); + assert_eq!( + transport.send(&request(url), &io).unwrap_err().code(), + "response_too_large" + ); + completion.wait(io.deadline()).unwrap(); + server.join().unwrap(); + } + // Only this finite admission scenario uses the fixture: 32 originals, // one cancellation replacement, then one request after all permits return. struct AdmissionServerSetup { @@ -1976,6 +2322,15 @@ mod tests { #[test] fn caps_concurrent_tls_requests_and_releases_admission_on_cancel() { + admission_cancel_fixture(false); + } + + #[test] + fn native_pump_teardown_before_caller_join_preserves_single_replacement() { + admission_cancel_fixture(true); + } + + fn admission_cancel_fixture(teardown_before_join: bool) { let _admission = write_native_request_admission(); const ACTIVE: usize = 32; assert_eq!(ACTIVE, MAX_ACTIVE_REQUESTS); @@ -1985,10 +2340,21 @@ mod tests { let mut cancellations = AdmissionClients::default(); let mut clients = Vec::with_capacity(ACTIVE); let mut starts = Vec::with_capacity(ACTIVE); - for _ in 0..ACTIVE { - let transport = Arc::clone(&transport); + let mut completions = Vec::with_capacity(ACTIVE); + let mut pump_control = None; + for index in 0..ACTIVE { let url = url.clone(); let (client_context, cancel) = context(Duration::from_secs(6)); + let (probe, completion) = if index == 0 { + let (probe, completion, control) = + PumpProbe::controlled(client_context.deadline()); + pump_control = Some(control); + (probe, completion) + } else { + PumpProbe::new() + }; + completions.push(completion); + let transport = observed_transport(&transport, probe); cancellations.0.push(cancel); let (start, ready) = mpsc::channel(); starts.push(start); @@ -2027,27 +2393,65 @@ mod tests { }; assert_busy(); + let pump_control = pump_control.unwrap(); + pump_control.arm(); + pump_control + .entered + .recv_timeout(Duration::from_secs(2)) + .unwrap(); cancellations.0[0].0.store(true, Ordering::Release); - assert_eq!( - clients.remove(0).join().unwrap().unwrap_err().code(), - "cancelled" - ); - server.next_phase(); - let replacement_transport = Arc::clone(&transport); - let replacement_url = url.clone(); + // Capture before either ordering's observations: never renew the + // old replacement window after native completion or phase enqueue. + let replacement_deadline = Instant::now() + Duration::from_secs(3); let (replacement_context, cancel) = context(Duration::from_secs(6)); cancellations.0.push(cancel); + let cancelled_client = clients.remove(0); + if teardown_before_join { + server.next_phase(); + pump_control.release(); + completions.remove(0).wait(replacement_deadline).unwrap(); + assert_eq!(REQUESTS.get().unwrap().available_permits(), 1); + assert_eq!( + cancelled_client.join().unwrap().unwrap_err().code(), + "cancelled" + ); + } else { + assert_eq!( + cancelled_client.join().unwrap().unwrap_err().code(), + "cancelled" + ); + server.next_phase(); + assert_eq!(REQUESTS.get().unwrap().available_permits(), 0); + assert_eq!(completions[0].0.try_recv(), Err(mpsc::TryRecvError::Empty)); + pump_control.release(); + completions.remove(0).wait(replacement_deadline).unwrap(); + } + assert_eq!(REQUESTS.get().unwrap().available_permits(), 1); + assert!(clients.iter().all(|client| !client.is_finished())); + let (replacement_probe, replacement_completion) = PumpProbe::new(); + completions.push(replacement_completion); + let replacement_transport = observed_transport(&transport, replacement_probe); + let replacement_url = url.clone(); let replacement = scope.spawn(move || { replacement_transport.send(&request(replacement_url), &replacement_context) }); server .received - .recv_timeout(Duration::from_secs(3)) + .recv_timeout(replacement_deadline.saturating_duration_since(Instant::now())) .unwrap(); assert_eq!( - server.phases.recv_timeout(Duration::from_secs(2)).unwrap(), + server + .phases + .recv_timeout( + replacement_deadline + .saturating_duration_since(Instant::now()) + .min(Duration::from_secs(2)) + ) + .unwrap(), 1 ); + assert_eq!(clients.len(), ACTIVE - 1); + assert!(clients.iter().all(|client| !client.is_finished())); assert_busy(); server.release_responses(); @@ -2055,21 +2459,33 @@ mod tests { for client in clients { assert_eq!(client.join().unwrap().unwrap().status, 200); } - server.next_phase(); let (release_context, _) = context(Duration::from_secs(2)); + let final_deadline = release_context.deadline(); + server.next_phase(); + for completion in completions { + completion.wait(final_deadline).unwrap(); + } + assert_eq!(REQUESTS.get().unwrap().available_permits(), ACTIVE); + let (final_probe, final_completion) = PumpProbe::new(); + let final_transport = observed_transport(&transport, final_probe); assert_eq!( - transport + final_transport .send(&request(url), &release_context) .unwrap() .status, 200 ); + final_completion.wait(final_deadline).unwrap(); + assert_eq!(REQUESTS.get().unwrap().available_permits(), ACTIVE); server .received - .recv_timeout(Duration::from_secs(2)) + .recv_timeout(final_deadline.saturating_duration_since(Instant::now())) .unwrap(); assert_eq!( - server.phases.recv_timeout(Duration::from_secs(2)).unwrap(), + server + .phases + .recv_timeout(final_deadline.saturating_duration_since(Instant::now())) + .unwrap(), 2 ); // 32 originals + replacement + final, no notification remains. @@ -2152,11 +2568,15 @@ mod tests { let (setup, url, roots) = AdmissionServerSetup::new(); let address = setup.listener.local_addr().unwrap(); let transport = NativeHttpsTransport::with_roots(roots); + let (io, cancel) = context(Duration::from_secs(6)); + let deadline = io.deadline(); + let (probe, native_completion, control) = PumpProbe::controlled(deadline); + let transport = observed_transport(&transport, probe); let (completed, completion) = mpsc::channel(); let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { std::thread::scope(|scope| { - let (io, cancel) = context(Duration::from_secs(6)); let _cancellations = AdmissionClients(vec![cancel]); + let control = control; let server = setup.start(); scope.spawn(move || { let result = transport.send(&request(url), &io); @@ -2166,6 +2586,11 @@ mod tests { .received .recv_timeout(Duration::from_secs(5)) .unwrap(); + control.arm(); + control + .entered + .recv_timeout(deadline.saturating_duration_since(Instant::now())) + .unwrap(); panic!("controlled admission scenario unwind"); }); })); @@ -2174,6 +2599,7 @@ mod tests { Some(&"controlled admission scenario unwind") ); assert!(completion.try_recv().unwrap().is_err()); + native_completion.wait(deadline).unwrap(); assert!( std::net::TcpStream::connect_timeout(&address, Duration::from_millis(250)).is_err() ); From 5f3f72f55bc46d3a7863e068db5b5c5b38030f25 Mon Sep 17 00:00:00 2001 From: fffonion Date: Sun, 18 Oct 2026 23:45:50 +0800 Subject: [PATCH 04/21] fix(gateway): synchronize persistence gate predicates --- src/gateway/store.rs | 322 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 313 insertions(+), 9 deletions(-) diff --git a/src/gateway/store.rs b/src/gateway/store.rs index f376a48..fea7cbe 100644 --- a/src/gateway/store.rs +++ b/src/gateway/store.rs @@ -138,6 +138,62 @@ struct PersistBlockState { released: AtomicBool, entered_cvar: Condvar, released_cvar: Condvar, + #[cfg(test)] + entered_schedule: PersistBlockSchedule, + #[cfg(test)] + released_schedule: PersistBlockSchedule, +} + +#[cfg(test)] +#[derive(Default)] +struct PersistBlockSchedule { + before_wait: Mutex, mpsc::Receiver<()>)>>, + signal_started: Mutex>>, + signalled: Mutex>>, +} + +#[cfg(test)] +impl PersistBlockSchedule { + fn before_wait(&self) { + if let Some((arrived, resume)) = self.before_wait.lock().unwrap().take() { + let _ = arrived.send(()); + let _ = resume.recv(); + } + } + + fn signal_started(&self) { + if let Some(started) = self.signal_started.lock().unwrap().take() { + let _ = started.send(()); + } + } + + fn signalled(&self) { + if let Some(signalled) = self.signalled.lock().unwrap().take() { + let _ = signalled.send(()); + } + } +} + +impl PersistBlockState { + fn enter_and_wait(&self) { + #[cfg(test)] + self.entered_schedule.signal_started(); + // The predicate check and its transition share this instance mutex; + // notification cannot slip between a false check and parking. + let mut guard = self.mutex.lock().expect("persist block lock"); + self.entered.store(true, Ordering::SeqCst); + self.entered_cvar.notify_all(); + #[cfg(test)] + self.entered_schedule.signalled(); + while !self.released.load(Ordering::SeqCst) { + #[cfg(test)] + self.released_schedule.before_wait(); + guard = self + .released_cvar + .wait(guard) + .expect("persist block release wait"); + } + } } impl PersistBlockGuard { @@ -145,6 +201,8 @@ impl PersistBlockGuard { pub fn wait_entered(&self) { let mut guard = self.inner.mutex.lock().expect("persist block lock"); while !self.inner.entered.load(Ordering::SeqCst) { + #[cfg(test)] + self.inner.entered_schedule.before_wait(); guard = self .inner .entered_cvar @@ -155,8 +213,19 @@ impl PersistBlockGuard { /// Unblocks the waiting storage command. pub fn release(&self) { + #[cfg(test)] + self.inner.released_schedule.signal_started(); + // Release is also used during unwind. Recover this fixture's predicate + // mutex so a poisoned lock cannot add a second cleanup panic. + let _guard = self + .inner + .mutex + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); self.inner.released.store(true, Ordering::SeqCst); self.inner.released_cvar.notify_all(); + #[cfg(test)] + self.inner.released_schedule.signalled(); } } @@ -602,15 +671,7 @@ impl GatewayPersistence { .expect("persist block lock") .take() { - block.entered.store(true, Ordering::SeqCst); - block.entered_cvar.notify_all(); - let mut guard = block.mutex.lock().expect("persist block lock"); - while !block.released.load(Ordering::SeqCst) { - guard = block - .released_cvar - .wait(guard) - .expect("persist block release wait"); - } + block.enter_and_wait(); } if self .fail_next @@ -921,6 +982,10 @@ impl GatewayPersistence { released: AtomicBool::new(false), entered_cvar: Condvar::new(), released_cvar: Condvar::new(), + #[cfg(test)] + entered_schedule: PersistBlockSchedule::default(), + #[cfg(test)] + released_schedule: PersistBlockSchedule::default(), }); *self.persist_block.lock().expect("persist block lock") = Some(Arc::clone(&inner)); PersistBlockGuard { inner } @@ -1883,6 +1948,245 @@ pub(crate) fn append_message( message } +#[cfg(test)] +mod persist_block_tests { + use super::*; + use std::sync::atomic::AtomicUsize; + use std::thread::JoinHandle; + + const BOUND: Duration = Duration::from_secs(10); + + fn block() -> Arc { + Arc::new(PersistBlockGuard { + inner: Arc::new(PersistBlockState { + mutex: Mutex::new(()), + entered: AtomicBool::new(false), + released: AtomicBool::new(false), + entered_cvar: Condvar::new(), + released_cvar: Condvar::new(), + entered_schedule: PersistBlockSchedule::default(), + released_schedule: PersistBlockSchedule::default(), + }), + }) + } + + // Rescue only after saving the observation. Drop also releases controls + // and joins the exact native owners if an earlier assertion unwinds. + struct Owners { + block: Arc, + resume: Option>, + threads: Vec>, + joined: Arc, + failed: Arc, + } + + impl Owners { + fn new(block: Arc, resume: mpsc::Sender<()>) -> Self { + Self { + block, + resume: Some(resume), + threads: Vec::new(), + joined: Arc::default(), + failed: Arc::default(), + } + } + + fn resume(&mut self) { + if let Some(resume) = self.resume.take() { + let _ = resume.send(()); + } + } + } + + impl Drop for Owners { + fn drop(&mut self) { + self.resume(); + { + let _guard = self + .block + .inner + .mutex + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + self.block.inner.entered.store(true, Ordering::SeqCst); + self.block.inner.released.store(true, Ordering::SeqCst); + self.block.inner.entered_cvar.notify_all(); + self.block.inner.released_cvar.notify_all(); + } + for thread in self.threads.drain(..) { + if thread.join().is_err() { + self.failed.store(true, Ordering::SeqCst); + } + self.joined.fetch_add(1, Ordering::SeqCst); + } + } + } + + #[test] + fn signals_before_waits_are_observed_without_another_notification() { + let block = block(); + block.release(); + block.release(); + block.inner.enter_and_wait(); + block.wait_entered(); + assert!(block.inner.entered.load(Ordering::SeqCst)); + assert!(block.inner.released.load(Ordering::SeqCst)); + } + + #[test] + fn release_recovers_a_poisoned_predicate_mutex_during_unwind() { + struct ReleaseOnDrop<'a>(&'a PersistBlockGuard); + impl Drop for ReleaseOnDrop<'_> { + fn drop(&mut self) { + self.0.release(); + } + } + + let block = block(); + let poisoned = std::panic::catch_unwind(|| { + let _guard = block.inner.mutex.lock().unwrap(); + std::panic::panic_any(37_u8); + }); + assert_eq!(poisoned.unwrap_err().downcast_ref::(), Some(&37)); + assert!(block.inner.mutex.is_poisoned()); + // Check the fallible control outside unwind first, so a regression + // reports an ordinary assertion rather than a double-panic abort. + let released = std::panic::catch_unwind(|| block.release()); + assert!( + released.is_ok(), + "release must retain its non-panicking cleanup contract" + ); + let unwound = std::panic::catch_unwind(|| { + let _release = ReleaseOnDrop(&block); + std::panic::panic_any(73_u8); + }); + assert_eq!(unwound.unwrap_err().downcast_ref::(), Some(&73)); + assert!(block.inner.released.load(Ordering::SeqCst)); + } + + #[test] + fn controlled_owner_cleanup_joins_and_preserves_the_first_unwind() { + for cleanup_panic in [false, true] { + let block = block(); + let (arrived, at_wait) = mpsc::channel(); + let (resume, resumed) = mpsc::channel(); + *block.inner.entered_schedule.before_wait.lock().unwrap() = Some((arrived, resumed)); + let mut owners = Owners::new(Arc::clone(&block), resume); + let joined = Arc::clone(&owners.joined); + let failed = Arc::clone(&owners.failed); + owners.threads.push(std::thread::spawn(move || { + block.wait_entered(); + if cleanup_panic { + std::panic::panic_any(91_u8); + } + })); + at_wait + .recv_timeout(BOUND) + .expect("original waiter is held before parking"); + let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(move || { + let _owners = owners; + std::panic::panic_any(73_u8); + })); + assert_eq!(unwound.unwrap_err().downcast_ref::(), Some(&73)); + assert_eq!(joined.load(Ordering::SeqCst), 1); + assert_eq!(failed.load(Ordering::SeqCst), cleanup_panic); + } + } + + #[test] + fn released_notification_cannot_pass_a_waiter_holding_the_predicate_mutex() { + let block = block(); + let (arrived, at_wait) = mpsc::channel(); + let (resume, resumed) = mpsc::channel(); + let (started, signal_started) = mpsc::channel(); + let (signalled, signal_published) = mpsc::channel(); + *block.inner.released_schedule.before_wait.lock().unwrap() = Some((arrived, resumed)); + *block.inner.released_schedule.signal_started.lock().unwrap() = Some(started); + *block.inner.released_schedule.signalled.lock().unwrap() = Some(signalled); + let mut owners = Owners::new(Arc::clone(&block), resume); + let joined = Arc::clone(&owners.joined); + let failed = Arc::clone(&owners.failed); + let (done, completion) = mpsc::channel(); + let command = Arc::clone(&block); + owners.threads.push(std::thread::spawn(move || { + command.inner.enter_and_wait(); + let _ = done.send(()); + })); + at_wait + .recv_timeout(BOUND) + .expect("command holds predicate mutex after checking false"); + let releaser = Arc::clone(&block); + owners + .threads + .push(std::thread::spawn(move || releaser.release())); + signal_started + .recv_timeout(BOUND) + .expect("real release started"); + let published_before_wait = signal_published + .recv_timeout(Duration::from_secs(1)) + .is_ok(); + owners.resume(); + let completed = completion.recv_timeout(BOUND).is_ok(); + drop(owners); + assert!( + completed, + "release notification lost; published_before_wait={published_before_wait}; original owners joined" + ); + assert_eq!(joined.load(Ordering::SeqCst), 2); + assert!(!failed.load(Ordering::SeqCst)); + } + + #[test] + fn entered_notification_cannot_pass_a_waiter_holding_the_predicate_mutex() { + let block = block(); + // Entry is the only wait under test in this slice. + block.release(); + let (arrived, at_wait) = mpsc::channel(); + let (resume, resumed) = mpsc::channel(); + let (started, signal_started) = mpsc::channel(); + let (signalled, signal_published) = mpsc::channel(); + *block.inner.entered_schedule.before_wait.lock().unwrap() = Some((arrived, resumed)); + *block.inner.entered_schedule.signal_started.lock().unwrap() = Some(started); + *block.inner.entered_schedule.signalled.lock().unwrap() = Some(signalled); + let mut owners = Owners::new(Arc::clone(&block), resume); + let joined = Arc::clone(&owners.joined); + let failed = Arc::clone(&owners.failed); + let (done, completion) = mpsc::channel(); + let waiter = Arc::clone(&block); + owners.threads.push(std::thread::spawn(move || { + waiter.wait_entered(); + let _ = done.send(()); + })); + at_wait + .recv_timeout(BOUND) + .expect("waiter holds predicate mutex after checking false"); + let producer = Arc::clone(&block); + owners + .threads + .push(std::thread::spawn(move || producer.inner.enter_and_wait())); + signal_started + .recv_timeout(BOUND) + .expect("real producer started"); + // The old producer publishes while the waiter still owns the mutex. + // A repaired producer must wait for that mutex. This timeout selects + // the schedule only; the saved completion below determines RED. + let published_before_wait = signal_published + .recv_timeout(Duration::from_secs(1)) + .is_ok(); + owners.resume(); + let completed = completion.recv_timeout(BOUND).is_ok(); + // On RED, the only original notification has already been lost. + // A rescue notification retires the original waiter before asserting. + drop(owners); + assert!( + completed, + "entry notification lost; published_before_wait={published_before_wait}; original owners joined" + ); + assert_eq!(joined.load(Ordering::SeqCst), 2); + assert!(!failed.load(Ordering::SeqCst)); + } +} + #[cfg(test)] mod storage_shutdown_tests { use super::*; From ad9e4705ef63988e1bd3f2b16ffeff302de869c4 Mon Sep 17 00:00:00 2001 From: fffonion Date: Mon, 19 Oct 2026 05:41:06 +0800 Subject: [PATCH 05/21] feat(effects): complete managed synchronous publication and recovery --- rss/agent/main.rss | 8 + rss/storage/admission.rss | 10 + rss/storage/effects.rss | 190 ++ rss/storage/events.rss | 2 +- rss/storage/main.rss | 39 +- rss/storage/schema.rss | 16 +- rss/storage/sessions.rss | 10 + schemas/canonical/managed-effect.schema.json | 666 +++++ .../canonical/managed-workspace.schema.json | 51 + schemas/managed-effect.schema.yaml | 666 +++++ schemas/managed-workspace.schema.yaml | 51 + src/bin/rustscript-agent-gateway.rs | 4 + src/capabilities/filesystem.rs | 70 +- src/capabilities/lifecycle.rs | 10 + src/capabilities/process.rs | 213 +- src/capabilities/vm_io/confined_fs.rs | 7 + src/config.rs | 67 + src/effects/broker.rs | 1980 ++++++++++++++ src/effects/journal.rs | 695 +++++ src/effects/managed_authority_tests.rs | 393 +++ src/effects/mod.rs | 23 + src/effects/retention.rs | 369 +++ src/effects/tree_store.rs | 85 +- src/effects/types.rs | 18 +- src/events.rs | 16 + src/gateway/managed_storage_budget_tests.rs | 228 ++ src/gateway/mod.rs | 23 +- src/gateway/store.rs | 520 +++- src/plugins/mod.rs | 2 + src/plugins/pin.rs | 21 + src/plugins/prepare.rs | 40 + src/plugins/schema.rs | 47 +- src/plugins/yaml.rs | 14 +- src/runtime/agent_host.rs | 241 +- src/runtime/host_types.rs | 24 +- src/runtime/native_tools.rs | 1 + src/service.rs | 336 ++- src/service/managed_effects.rs | 610 +++++ src/service/managed_effects_tests.rs | 478 ++++ src/service/managed_protocol_tests.rs | 629 +++++ .../authority_matrix.rs | 310 +++ tests/effect_commit_recovery/budget_matrix.rs | 133 + tests/effect_commit_recovery/io_matrix.rs | 406 +++ tests/effect_commit_recovery/schema_matrix.rs | 134 + .../effect_commit_recovery/startup_matrix.rs | 206 ++ tests/effect_commit_recovery_tests.rs | 2354 +++++++++++++++++ 46 files changed, 12335 insertions(+), 81 deletions(-) create mode 100644 rss/storage/effects.rss create mode 100644 schemas/canonical/managed-effect.schema.json create mode 100644 schemas/canonical/managed-workspace.schema.json create mode 100644 schemas/managed-effect.schema.yaml create mode 100644 schemas/managed-workspace.schema.yaml create mode 100644 src/effects/broker.rs create mode 100644 src/effects/journal.rs create mode 100644 src/effects/managed_authority_tests.rs create mode 100644 src/effects/retention.rs create mode 100644 src/gateway/managed_storage_budget_tests.rs create mode 100644 src/service/managed_effects.rs create mode 100644 src/service/managed_effects_tests.rs create mode 100644 src/service/managed_protocol_tests.rs create mode 100644 tests/effect_commit_recovery/authority_matrix.rs create mode 100644 tests/effect_commit_recovery/budget_matrix.rs create mode 100644 tests/effect_commit_recovery/io_matrix.rs create mode 100644 tests/effect_commit_recovery/schema_matrix.rs create mode 100644 tests/effect_commit_recovery/startup_matrix.rs create mode 100644 tests/effect_commit_recovery_tests.rs diff --git a/rss/agent/main.rss b/rss/agent/main.rss index 80a390d..582592c 100644 --- a/rss/agent/main.rss +++ b/rss/agent/main.rss @@ -595,6 +595,14 @@ fn execute_pinned_dispatch(context: map, dispatch_input: map, facts: map) -> map let decoded: map = json::decode(canonical_result); if decoded.has("ok") { result = tools::result_envelope(call, decoded); + if invoked.has("managed_publication") { + let publication: map = ctx_map(invoked,"managed_publication"); + if ctx_string(publication,"format","") == "agent.managed-effect/v1" { + result.managed_publication = publication; + } else { + result = tools::approval_failure(dispatch_input,"managed_outcome_invalid","Native managed publication format is invalid"); + } + } } } result diff --git a/rss/storage/admission.rss b/rss/storage/admission.rss index 31d5526..a2aafd8 100644 --- a/rss/storage/admission.rss +++ b/rss/storage/admission.rss @@ -4,6 +4,7 @@ // single durable commit. A failure leaves no partial admission state on // disk, so a restart can never resurrect a half-admitted run. use json; +use self::effects as effects; use sqlite; fn sql_text(value: string) -> SqliteValue { @@ -257,6 +258,15 @@ pub fn storage_admission_create(db_id: resource, payload_json limits: sql_limits(1000, 4194304) }; if input.tool_graph_id.copy() != "" { + if raw_payload.has("managed_binding_json") { + let managed_json: string = raw_payload["managed_binding_json"].copy(); + let managed_statements: array = effects::admission_statements(managed_json); + let mut managed_index: int = 0; + while managed_index < managed_statements.length { + statements[statements.length] = managed_statements[managed_index].copy(); + managed_index = managed_index + 1; + } + } let pin_statements: array = plugins::storage_admission_pin_statements(input.tool_graph_id.copy(), input.tool_graph_generation.copy(), input.tool_ref_id.copy(), input.run_id.copy(), input.session_id.copy(), input.tool_pin_now_ms.copy()); let mut pin_index: int = 0; while pin_index < pin_statements.length { diff --git a/rss/storage/effects.rss b/rss/storage/effects.rss new file mode 100644 index 0000000..8cb0825 --- /dev/null +++ b/rss/storage/effects.rss @@ -0,0 +1,190 @@ +// Native-owned managed effect metadata. Fixed SQL only; no guest forwarding. +use json; +use sqlite; + +struct ManagedObjectIdInput {store_id:string,object_id:string} +pub fn object_metadata(db:resource,payload:string)->map{ + let input:ManagedObjectIdInput=json::decode::(payload); + let result:SqliteQueryResult=sqlite::query(&db,"SELECT record_json FROM managed_objects WHERE store_id=? AND object_id=?",[text(input.store_id),text(input.object_id)],limits());ok(result) +} +pub fn object_census(db:resource,payload:string)->map{ + let statements:array = [ + {sql:"INSERT INTO managed_effect_guard(id) SELECT 2 WHERE (SELECT COUNT(*) FROM managed_objects)>128",params:[],query:false,limits:limits()}, + {sql:"SELECT record_json FROM managed_objects ORDER BY object_id LIMIT 128",params:[],query:true,limits:limits()} + ]; + let results:array = sqlite::transaction(&db,statements);let last:SqliteTransactionResult=results[1].copy();let query:SqliteQueryResult=last.query;ok(query) +} +pub fn object_refs(db:resource,payload:string)->map{ + let input:ManagedObjectIdInput=json::decode::(payload); + let result:SqliteQueryResult=sqlite::query(&db,"WITH target AS(SELECT ? AS store_id,? AS object_id) SELECT m.store_id,m.object_id,'registered_object' AS reference_kind,m.object_id AS reference_id FROM managed_objects m,target t WHERE m.store_id=t.store_id AND m.object_id=t.object_id UNION ALL SELECT w.store_id,t.object_id,'seed',w.workspace_id FROM managed_workspaces w,target t WHERE w.store_id=t.store_id AND w.seed_object_id=t.object_id UNION ALL SELECT b.store_id,t.object_id,'branch_head',b.branch_id FROM managed_branches b,target t WHERE b.store_id=t.store_id AND b.head_object_id=t.object_id UNION ALL SELECT b.store_id,t.object_id,'history',h.workspace_id||':'||h.head_version FROM managed_head_history h JOIN managed_branches b ON b.workspace_id=h.workspace_id AND b.branch_id=h.branch_id,target t WHERE b.store_id=t.store_id AND h.head_object_id=t.object_id UNION ALL SELECT t.store_id,t.object_id,'call_base',c.effect_id FROM managed_effect_calls c,target t WHERE json_extract(c.identity_json,'$.workspace.store_id')=t.store_id AND json_extract(c.identity_json,'$.expected_head')=t.object_id UNION ALL SELECT t.store_id,t.object_id,json_extract(o.value,'$.kind'),c.effect_id FROM managed_effect_calls c,target t,json_each(json_array(json_object('kind','prepared_candidate','id',json_extract(c.record_json,'$.prepared.candidate_root.object_id')),json_object('kind','prepared_result','id',json_extract(c.record_json,'$.prepared.canonical_result.object_id')),json_object('kind','prepared_conflict','id',json_extract(c.record_json,'$.prepared.conflict_result.object_id')),json_object('kind','selected_result','id',json_extract(c.record_json,'$.outcome.canonical_result_ref.object_id')),json_object('kind','intent_details','id',json_extract(c.identity_json,'$.argument_identity.details.object_id')))) o WHERE json_extract(c.identity_json,'$.workspace.store_id')=t.store_id AND json_extract(o.value,'$.id')=t.object_id LIMIT 513",[text(input.store_id),text(input.object_id)],limits());ok(result) +} + +struct ManagedEffectListInput {workspace_id:string,after_effect_id:string,limit:int} +struct ManagedEffectIdInput {effect_id:string} +pub fn list(db:resource,payload:string)->map{ + let input:ManagedEffectListInput=json::decode::(payload); + let result:SqliteQueryResult=sqlite::query(&db,"SELECT effect_id,run_id,call_id,CASE state WHEN 'intent' THEN 'indeterminate' WHEN 'prepared' THEN 'staged' ELSE state END AS state,workspace_id,branch_id,json_extract(identity_json,'$.epoch') AS epoch,json_extract(record_json,'$.prepared.prepared_at_ms') AS prepared_at_ms,(SELECT created_at_ms FROM messages WHERE id=json_extract(managed_effect_calls.record_json,'$.outcome.message.id')) AS decided_at_ms FROM managed_effect_calls WHERE workspace_id=? AND effect_id>? ORDER BY effect_id LIMIT ?",[text(input.workspace_id),text(input.after_effect_id),integer(input.limit)],limits());ok(result) +} +pub fn list_has_more(db:resource,payload:string)->map{ + let input:ManagedEffectListInput=json::decode::(payload); + let result:SqliteQueryResult=sqlite::query(&db,"SELECT effect_id FROM managed_effect_calls WHERE workspace_id=? AND effect_id>? ORDER BY effect_id LIMIT 1",[text(input.workspace_id),text(input.after_effect_id)],limits());ok(result) +} +pub fn by_id(db:resource,payload:string)->map{ + let input:ManagedEffectIdInput=json::decode::(payload); + let result:SqliteQueryResult=sqlite::query(&db,"SELECT record_json FROM managed_effect_calls WHERE effect_id=?",[text(input.effect_id)],limits());ok(result) +} + +fn text(value: string) -> SqliteValue { { kind: "text", int_value: null, float_value: null, text_value: value, blob_value: null } } +fn integer(value: int) -> SqliteValue { { kind: "int", int_value: value, float_value: null, text_value: null, blob_value: null } } +fn limits() -> SqliteLimits { { max_connections: 16, max_statements: 128, max_rows: 512, max_columns: 16, max_result_bytes: 1048576, max_statement_bytes: 1048576, max_parameters: 128, max_parameter_bytes: 1048576, max_pending_operations: 32, max_transaction_ms: 5000, busy_timeout_ms: 5000 } } +fn ok(result: SqliteQueryResult) -> map { { ok: true, code: "ok", message: "", result: { columns: result.columns, rows: result.rows, truncated: result.truncated } } } +struct ManagedStorageInput { record_json: string, prepared_json: string, workspace_json: string, projections_json: string, effect_id: string, receipt_id: string, commit_id: string, run_id: string, call_id: string, store_id: string, profile: string, source_device: string, source_inode: string, workspace_id: string, branch_id: string, now_ms: int, max_events: int, cut: string } + +pub fn migration_count() -> int { 10 } + +pub fn migration(index: int) -> string { if index == 0 => { "CREATE TABLE managed_objects (store_id TEXT NOT NULL, object_id TEXT NOT NULL, schema_version INTEGER NOT NULL CHECK(schema_version=1), kind TEXT NOT NULL CHECK(kind IN ('tree','canonical_result','intent_details')), record_json TEXT NOT NULL CHECK(json_valid(record_json)), readiness TEXT NOT NULL CHECK(readiness='synced'), PRIMARY KEY(store_id,object_id))" } else => { if index == 1 => { "CREATE TABLE managed_workspaces (workspace_id TEXT PRIMARY KEY, store_id TEXT NOT NULL, profile TEXT NOT NULL, source_device TEXT NOT NULL, source_inode TEXT NOT NULL, schema_version INTEGER NOT NULL CHECK(schema_version=1), seed_object_id TEXT NOT NULL, record_json TEXT NOT NULL CHECK(json_valid(record_json)), UNIQUE(store_id,profile,source_device,source_inode), FOREIGN KEY(store_id,seed_object_id) REFERENCES managed_objects(store_id,object_id))" } else => { if index == 2 => { "CREATE TABLE managed_branches (workspace_id TEXT NOT NULL REFERENCES managed_workspaces(workspace_id), branch_id TEXT NOT NULL, epoch INTEGER NOT NULL CHECK(epoch>0), head_object_id TEXT NOT NULL, head_version INTEGER NOT NULL CHECK(head_version>=1), store_id TEXT NOT NULL, schema_version INTEGER NOT NULL CHECK(schema_version=1), PRIMARY KEY(workspace_id,branch_id), FOREIGN KEY(store_id,head_object_id) REFERENCES managed_objects(store_id,object_id))" } else => { if index == 3 => { "CREATE TABLE managed_run_bindings (run_id TEXT PRIMARY KEY REFERENCES runs(id), profile TEXT NOT NULL, session_id TEXT NOT NULL REFERENCES sessions(id), workspace_id TEXT NOT NULL, branch_id TEXT NOT NULL, epoch INTEGER NOT NULL CHECK(epoch>0), graph_ref_id TEXT NOT NULL, original_deadline_ms INTEGER NOT NULL, schema_version INTEGER NOT NULL CHECK(schema_version=1), FOREIGN KEY(workspace_id,branch_id) REFERENCES managed_branches(workspace_id,branch_id))" } else => { if index == 4 => { "CREATE TABLE managed_effect_calls (effect_id TEXT PRIMARY KEY, run_id TEXT NOT NULL REFERENCES runs(id), call_id TEXT NOT NULL, workspace_id TEXT NOT NULL, branch_id TEXT NOT NULL, state TEXT NOT NULL CHECK(state IN ('intent','prepared','committed','read_only','not_published','conflict','indeterminate')), schema_version INTEGER NOT NULL CHECK(schema_version=1), identity_json TEXT NOT NULL CHECK(json_valid(identity_json)), record_json TEXT NOT NULL CHECK(json_valid(record_json)), commit_id TEXT, observed_head TEXT, observed_version INTEGER, UNIQUE(run_id,call_id), FOREIGN KEY(workspace_id,branch_id) REFERENCES managed_branches(workspace_id,branch_id))" } else => { if index == 5 => { "CREATE TABLE managed_effect_receipts (effect_id TEXT PRIMARY KEY REFERENCES managed_effect_calls(effect_id), receipt_id TEXT NOT NULL UNIQUE, prepared_record_json TEXT NOT NULL CHECK(json_valid(prepared_record_json)), outcome_json TEXT CHECK(outcome_json IS NULL OR json_valid(outcome_json)), schema_version INTEGER NOT NULL CHECK(schema_version=1))" } else => { if index == 6 => { "CREATE TABLE managed_head_history (workspace_id TEXT NOT NULL, branch_id TEXT NOT NULL, head_version INTEGER NOT NULL, epoch INTEGER NOT NULL, effect_id TEXT UNIQUE REFERENCES managed_effect_calls(effect_id), previous_head TEXT, head_object_id TEXT NOT NULL, schema_version INTEGER NOT NULL CHECK(schema_version=1), PRIMARY KEY(workspace_id,branch_id,head_version), FOREIGN KEY(workspace_id,branch_id) REFERENCES managed_branches(workspace_id,branch_id))" } else => { if index == 7 => { "CREATE TABLE managed_effect_guard (id INTEGER PRIMARY KEY CHECK(id=1))" } else => { if index == 8 => { "CREATE INDEX managed_effect_recovery ON managed_effect_calls(workspace_id,branch_id,state,effect_id)" } else => { if index == 9 => { "CREATE INDEX managed_effect_owner ON managed_effect_calls(run_id,call_id,effect_id)" } else => { "" } } } } } } } } } } } + +pub fn workspace_get(db: resource, payload: string) -> map { let input: ManagedStorageInput = json::decode::(payload); ok(sqlite::query(&db, "SELECT record_json FROM managed_workspaces WHERE store_id=? AND profile=? AND source_device=? AND source_inode=?", [text(input.store_id.copy()), text(input.profile.copy()), text(input.source_device.copy()), text(input.source_inode.copy())], limits())) } + +pub fn head(db: resource, payload: string) -> map { let input: ManagedStorageInput = json::decode::(payload); ok(sqlite::query(&db, "SELECT json_object('workspace_id',workspace_id,'branch_id',branch_id,'epoch',epoch,'head_object_id',head_object_id,'head_version',head_version,'store_id',store_id,'schema_version',schema_version) AS record_json FROM managed_branches WHERE workspace_id=? AND branch_id=?", [text(input.workspace_id.copy()), text(input.branch_id.copy())], limits())) } + +pub fn get(db: resource, payload: string) -> map { let input: ManagedStorageInput = json::decode::(payload); ok(sqlite::query(&db, "SELECT record_json FROM managed_effect_calls WHERE run_id=? AND call_id=? AND json_extract(identity_json,'$.owner.profile')=?", [text(input.run_id.copy()), text(input.call_id.copy()), text(input.profile.copy())], limits())) } + +pub fn workspace_register(db: resource, payload: string) -> map { + let input: ManagedStorageInput = json::decode::(payload); + let statements: array = [ +{ sql: "INSERT INTO managed_objects(store_id,object_id,schema_version,kind,record_json,readiness) SELECT json_extract(value,'$.store_id'),json_extract(value,'$.object_id'),1,json_extract(value,'$.object_kind'),value,'synced' FROM json_each(json_array(json_extract(?,'$.seed_root'))) WHERE value IS NOT NULL ON CONFLICT(store_id,object_id) DO NOTHING", params: [text(input.workspace_json.copy())], query: false, limits: limits() }, +{ sql: "INSERT INTO managed_workspaces(workspace_id,store_id,profile,source_device,source_inode,schema_version,seed_object_id,record_json) SELECT json_extract(?,'$.identity.workspace_id'),json_extract(?,'$.identity.store_id'),json_extract(?,'$.profile'),json_extract(?,'$.identity.source_identity.device_decimal'),json_extract(?,'$.identity.source_identity.inode_decimal'),1,json_extract(?,'$.seed_root.object_id'),? ON CONFLICT(store_id,profile,source_device,source_inode) DO NOTHING", params: [text(input.workspace_json.copy()), text(input.workspace_json.copy()), text(input.workspace_json.copy()), text(input.workspace_json.copy()), text(input.workspace_json.copy()), text(input.workspace_json.copy()), text(input.workspace_json.copy())], query: false, limits: limits() }, +{ sql: "INSERT INTO managed_effect_guard(id) SELECT 2 WHERE NOT EXISTS (SELECT 1 FROM managed_workspaces WHERE store_id=? AND profile=? AND source_device=? AND source_inode=? AND json_extract(record_json,'$.max_entries')=json_extract(?,'$.max_entries') AND json_extract(record_json,'$.max_bytes')=json_extract(?,'$.max_bytes') AND json_extract(record_json,'$.max_depth')=json_extract(?,'$.max_depth'))", params: [text(input.store_id.copy()), text(input.profile.copy()), text(input.source_device.copy()), text(input.source_inode.copy()), text(input.workspace_json.copy()), text(input.workspace_json.copy()), text(input.workspace_json.copy())], query: false, limits: limits() }, +{ sql: "INSERT INTO managed_branches(workspace_id,branch_id,epoch,head_object_id,head_version,store_id,schema_version) SELECT workspace_id,json_extract(record_json,'$.identity.branch_id'),1,seed_object_id,1,store_id,1 FROM managed_workspaces WHERE store_id=? AND profile=? AND source_device=? AND source_inode=? ON CONFLICT(workspace_id,branch_id) DO NOTHING", params: [text(input.store_id.copy()), text(input.profile.copy()), text(input.source_device.copy()), text(input.source_inode.copy())], query: false, limits: limits() }, +{ sql: "INSERT INTO managed_head_history(workspace_id,branch_id,head_version,epoch,effect_id,previous_head,head_object_id,schema_version) SELECT workspace_id,branch_id,1,1,NULL,NULL,head_object_id,1 FROM managed_branches WHERE workspace_id=(SELECT workspace_id FROM managed_workspaces WHERE store_id=? AND profile=? AND source_device=? AND source_inode=?) ON CONFLICT(workspace_id,branch_id,head_version) DO NOTHING", params: [text(input.store_id.copy()), text(input.profile.copy()), text(input.source_device.copy()), text(input.source_inode.copy())], query: false, limits: limits() }, +{ sql: "SELECT record_json FROM managed_workspaces WHERE store_id=? AND profile=? AND source_device=? AND source_inode=?", params: [text(input.store_id.copy()), text(input.profile.copy()), text(input.source_device.copy()), text(input.source_inode.copy())], query: true, limits: limits() } +]; + let result: array = sqlite::transaction(&db, statements); + let last: SqliteTransactionResult = result[result.length - 1].copy(); + let query: SqliteQueryResult = last.query; + ok(query) +} + +pub fn admission_statements(payload: string) -> array { + let input: ManagedStorageInput = json::decode::(payload); + [ +{ sql: "INSERT INTO managed_run_bindings(run_id,profile,session_id,workspace_id,branch_id,epoch,graph_ref_id,original_deadline_ms,schema_version) SELECT ?,?,json_extract(?,'$.session_id'),?,?,1,json_extract(?,'$.graph_ref_id'),json_extract(?,'$.original_deadline_ms'),1 WHERE ?<>''", params: [text(input.run_id.copy()), text(input.profile.copy()), text(input.record_json.copy()), text(input.workspace_id.copy()), text(input.branch_id.copy()), text(input.record_json.copy()), text(input.record_json.copy()), text(input.workspace_id.copy())], query: false, limits: limits() } + ] +} + +pub fn begin(db: resource, payload: string) -> map { + let input: ManagedStorageInput = json::decode::(payload.copy()); + let existing:SqliteQueryResult=sqlite::query(&db,"SELECT record_json FROM managed_effect_calls WHERE effect_id=? AND run_id=? AND call_id=? AND identity_json=json_extract(?,'$.identity')",[text(input.effect_id),text(input.run_id),text(input.call_id),text(input.record_json)],limits()); + let inspected:SqliteQueryResult=existing.copy();let count:int=inspected.rows.length;let truncated:bool=inspected.truncated; + if count == 1 && truncated == false => {ok(existing)} else => {begin_new(db,payload)} +} +fn begin_new(db: resource, payload: string) -> map { + let input: ManagedStorageInput = json::decode::(payload); + let statements: array = [ +{ sql: "INSERT INTO managed_objects(store_id,object_id,schema_version,kind,record_json,readiness) SELECT json_extract(value,'$.store_id'),json_extract(value,'$.object_id'),1,json_extract(value,'$.object_kind'),value,'synced' FROM json_each(json_array(json_extract(?,'$.identity.argument_identity.details'))) WHERE value IS NOT NULL ON CONFLICT(store_id,object_id) DO NOTHING", params: [text(input.record_json.copy())], query: false, limits: limits() }, +{ sql: "INSERT INTO managed_effect_guard(id) SELECT 2 WHERE json_extract(?,'$.identity.argument_identity.kind')='object' AND NOT EXISTS(SELECT 1 FROM managed_objects WHERE store_id=json_extract(?,'$.identity.argument_identity.details.store_id') AND object_id=json_extract(?,'$.identity.argument_identity.details.object_id') AND kind='intent_details' AND record_json=json_extract(?,'$.identity.argument_identity.details'))", params: [text(input.record_json.copy()),text(input.record_json.copy()),text(input.record_json.copy()),text(input.record_json.copy())], query: false, limits: limits() }, +{ sql: "INSERT INTO managed_effect_guard(id) SELECT 2 WHERE NOT EXISTS (SELECT 1 FROM managed_run_bindings b JOIN runs r ON r.id=b.run_id WHERE b.run_id=? AND r.status IN ('running','queued') AND b.workspace_id=json_extract(?,'$.identity.workspace.workspace_id') AND b.branch_id=json_extract(?,'$.identity.workspace.branch_id') AND b.graph_ref_id=json_extract(?,'$.identity.code.graph_ref_id') AND ? = sqlite::transaction(&db, statements); + let last: SqliteTransactionResult = result[result.length - 1].copy(); + let query: SqliteQueryResult = last.query; + ok(query) +} + +pub fn prepare(db: resource, payload: string) -> map { + let input: ManagedStorageInput = json::decode::(payload.copy()); + let existing:SqliteQueryResult=sqlite::query(&db,"SELECT c.record_json FROM managed_effect_calls c JOIN managed_effect_receipts p ON p.effect_id=c.effect_id WHERE c.effect_id=? AND p.receipt_id=? AND p.prepared_record_json=?",[text(input.effect_id),text(input.receipt_id),text(input.prepared_json)],limits()); + let inspected:SqliteQueryResult=existing.copy();let count:int=inspected.rows.length;let truncated:bool=inspected.truncated; + if count == 1 && truncated == false => {ok(existing)} else => {prepare_new(db,payload)} +} +fn prepare_new(db: resource, payload: string) -> map { + let input: ManagedStorageInput = json::decode::(payload); + let statements: array = [ +{ sql: "INSERT INTO managed_effect_guard(id) SELECT 2 WHERE NOT EXISTS (SELECT 1 FROM managed_effect_calls WHERE effect_id=? AND state='intent' AND identity_json=json_extract(?,'$.identity'))", params: [text(input.effect_id.copy()), text(input.prepared_json.copy())], query: false, limits: limits() }, +{ sql: "INSERT INTO managed_objects(store_id,object_id,schema_version,kind,record_json,readiness) SELECT json_extract(value,'$.store_id'),json_extract(value,'$.object_id'),1,json_extract(value,'$.object_kind'),value,'synced' FROM json_each(json_array(json_extract(?,'$.candidate_root'),json_extract(?,'$.canonical_result'),json_extract(?,'$.conflict_result'))) WHERE value IS NOT NULL ON CONFLICT(store_id,object_id) DO NOTHING", params: [text(input.prepared_json.copy()), text(input.prepared_json.copy()), text(input.prepared_json.copy())], query: false, limits: limits() }, +{ sql: "INSERT INTO managed_effect_guard(id) SELECT 2 WHERE ? = 'PrepareAfterObjectRegistry'", params: [text(input.cut.copy())], query: false, limits: limits() }, +{ sql: "INSERT INTO managed_effect_receipts(effect_id,receipt_id,prepared_record_json,schema_version) VALUES (?,?,?,1)", params: [text(input.effect_id.copy()), text(input.receipt_id.copy()), text(input.prepared_json.copy())], query: false, limits: limits() }, +{ sql: "UPDATE managed_effect_calls SET state='prepared',record_json=json_set(record_json,'$.state','staged','$.prepared',json(?)) WHERE effect_id=?", params: [text(input.prepared_json.copy()), text(input.effect_id.copy())], query: false, limits: limits() }, +{ sql: "SELECT record_json FROM managed_effect_calls WHERE effect_id=?", params: [text(input.effect_id.copy())], query: true, limits: limits() } +]; + let result: array = sqlite::transaction(&db, statements); + let last: SqliteTransactionResult = result[result.length - 1].copy(); + let query: SqliteQueryResult = last.query; + ok(query) +} + +pub fn commit(db: resource, payload: string) -> map { + let input: ManagedStorageInput = json::decode::(payload.copy()); + // Final rows are immutable and retained. Exact same-attempt acknowledgement + // recovery is a read, never a new head decision or another event insertion. + let existing:SqliteQueryResult=sqlite::query(&db,"SELECT c.record_json FROM managed_effect_calls c JOIN managed_effect_receipts p ON p.effect_id=c.effect_id WHERE c.effect_id=? AND c.run_id=? AND c.workspace_id=? AND c.branch_id=? AND c.commit_id=? AND p.receipt_id=? AND p.prepared_record_json=? AND c.state IN ('committed','read_only','not_published','conflict') AND json_extract(c.record_json,'$.outcome') IS NOT NULL",[text(input.effect_id),text(input.run_id),text(input.workspace_id),text(input.branch_id),text(input.commit_id),text(input.receipt_id),text(input.prepared_json)],limits()); + let inspected:SqliteQueryResult=existing.copy(); + let row_count:int=inspected.rows.length; + let truncated:bool=inspected.truncated; + if row_count == 1 && truncated == false => { ok(existing) } else => { commit_new(db,payload) } +} + +fn commit_new(db: resource, payload: string) -> map { + let input: ManagedStorageInput = json::decode::(payload); + let statements: array = [ +{ sql: "INSERT INTO managed_effect_guard(id) SELECT 2 WHERE NOT EXISTS (SELECT 1 FROM managed_effect_calls c JOIN managed_effect_receipts p ON p.effect_id=c.effect_id JOIN runs r ON r.id=c.run_id JOIN managed_run_bindings b ON b.run_id=c.run_id JOIN plugin_graph_refs g ON g.owner_id=c.run_id AND g.ref_id=b.graph_ref_id AND g.owner_kind='run' WHERE c.effect_id=? AND c.state='prepared' AND c.identity_json=json_extract(?,'$.identity') AND p.prepared_record_json=? AND r.status IN ('queued','running') AND ?1", params: [text(input.effect_id.copy())], query: false, limits: limits() }, +{ sql: "INSERT INTO managed_head_history(workspace_id,branch_id,head_version,epoch,effect_id,previous_head,head_object_id,schema_version) SELECT b.workspace_id,b.branch_id,b.head_version,b.epoch,c.effect_id,c.observed_head,b.head_object_id,1 FROM managed_effect_calls c JOIN managed_branches b ON b.workspace_id=c.workspace_id AND b.branch_id=c.branch_id WHERE c.effect_id=? AND c.state='committed' AND c.commit_id=?", params: [text(input.effect_id.copy()), text(input.commit_id.copy())], query: false, limits: limits() }, +{ sql: "INSERT INTO managed_effect_guard(id) SELECT 2 WHERE ? = 'CommitAfterHead'", params: [text(input.cut.copy())], query: false, limits: limits() }, +{ sql: "UPDATE managed_effect_calls SET record_json=json_set(record_json,'$.state',state,'$.outcome',json_extract(?,CASE WHEN state='conflict' THEN '$.conflict.outcome' ELSE '$.normal.outcome' END),'$.outcome.disposition',state,'$.outcome.publication.status',state,'$.outcome.publication.observed_head',observed_head,'$.outcome.publication.observed_head_version',observed_version) WHERE effect_id=? AND commit_id=?", params: [text(input.projections_json.copy()), text(input.effect_id.copy()), text(input.commit_id.copy())], query: false, limits: limits() }, +{ sql: "UPDATE managed_effect_receipts SET outcome_json=(SELECT json_extract(record_json,'$.outcome') FROM managed_effect_calls WHERE effect_id=?) WHERE effect_id=?", params: [text(input.effect_id.copy()), text(input.effect_id.copy())], query: false, limits: limits() }, +{ sql: "INSERT INTO managed_effect_guard(id) SELECT 2 WHERE ? = 'CommitAfterReceipt'", params: [text(input.cut.copy())], query: false, limits: limits() }, +{ sql: "INSERT INTO run_events(run_id,seq,event_id,event_type,payload_json,created_at_ms) SELECT c.run_id,json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.output' ELSE '$.normal.output' END||'.seq'),json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.output' ELSE '$.normal.output' END||'.event_id'),json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.output' ELSE '$.normal.output' END||'.event_type'),json_patch(json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.output' ELSE '$.normal.output' END||'.payload_json'),CASE WHEN json_extract(c.record_json,'$.outcome.publication') IS NULL THEN '{}' ELSE json_object('managed_publication',json_extract(c.record_json,'$.outcome.publication')) END),? FROM managed_effect_calls c WHERE c.effect_id=? AND c.commit_id=?", params: [text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.projections_json.copy()), integer(input.now_ms.copy()), text(input.effect_id.copy()), text(input.commit_id.copy())], query: false, limits: limits() }, +{ sql: "INSERT INTO managed_effect_guard(id) SELECT 2 WHERE ? = 'CommitAfterFirstEvent'", params: [text(input.cut.copy())], query: false, limits: limits() }, +{ sql: "INSERT INTO run_events(run_id,seq,event_id,event_type,payload_json,created_at_ms) SELECT c.run_id,json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.terminal' ELSE '$.normal.terminal' END||'.seq'),json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.terminal' ELSE '$.normal.terminal' END||'.event_id'),json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.terminal' ELSE '$.normal.terminal' END||'.event_type'),json_patch(json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.terminal' ELSE '$.normal.terminal' END||'.payload_json'),CASE WHEN json_extract(c.record_json,'$.outcome.publication') IS NULL THEN '{}' ELSE json_object('managed_publication',json_extract(c.record_json,'$.outcome.publication')) END),? FROM managed_effect_calls c WHERE c.effect_id=? AND c.commit_id=?", params: [text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.projections_json.copy()), integer(input.now_ms.copy()), text(input.effect_id.copy()), text(input.commit_id.copy())], query: false, limits: limits() }, +{ sql: "INSERT INTO messages(id,session_id,role,content_json,created_at_ms,run_id,name,tool_call_id,parent_message_id,token_estimate,metadata_json,ordinal,finish_reason) SELECT json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.message.id' ELSE '$.normal.message.id' END),json_extract(c.identity_json,'$.owner.session_id'),'user',json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.message.content_json' ELSE '$.normal.message.content_json' END),?,c.run_id,json_extract(c.identity_json,'$.tool_name'),c.call_id,json_extract(?,'$.normal.message.parent_message_id'),0,json_extract(c.record_json,'$.outcome.publication'),json_extract(?,'$.normal.message.ordinal'),'' FROM managed_effect_calls c WHERE effect_id=? AND commit_id=?", params: [text(input.projections_json.copy()), text(input.projections_json.copy()), integer(input.now_ms.copy()), text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.effect_id.copy()), text(input.commit_id.copy())], query: false, limits: limits() }, +{ sql: "INSERT INTO managed_effect_guard(id) SELECT 2 WHERE ? = 'CommitAfterMessage'", params: [text(input.cut.copy())], query: false, limits: limits() }, +{ sql: "UPDATE sessions SET last_message_seq=MAX(last_message_seq,COALESCE((SELECT MAX(ordinal) FROM messages WHERE session_id=sessions.id),0)),updated_at_ms=? WHERE id=(SELECT session_id FROM runs WHERE id=?)", params: [integer(input.now_ms.copy()), text(input.run_id.copy())], query: false, limits: limits() }, +{ sql: "UPDATE runs SET updated_at_ms=? WHERE id=?", params: [integer(input.now_ms.copy()), text(input.run_id.copy())], query: false, limits: limits() }, +{ sql: "DELETE FROM run_events WHERE run_id=? AND seq NOT IN (SELECT seq FROM run_events WHERE run_id=? ORDER BY seq DESC LIMIT ?)", params: [text(input.run_id.copy()), text(input.run_id.copy()), integer(input.max_events.copy())], query: false, limits: limits() }, +{ sql: "INSERT INTO run_retention(run_id,first_seq,high_water_seq,updated_at_ms) SELECT ?,COALESCE(MIN(seq),0),COALESCE(MAX(seq),0),? FROM run_events WHERE run_id=? ON CONFLICT(run_id) DO UPDATE SET first_seq=excluded.first_seq,high_water_seq=MAX(run_retention.high_water_seq,excluded.high_water_seq),updated_at_ms=excluded.updated_at_ms", params: [text(input.run_id.copy()), integer(input.now_ms.copy()), text(input.run_id.copy())], query: false, limits: limits() }, +{ sql: "SELECT record_json FROM managed_effect_calls WHERE effect_id=?", params: [text(input.effect_id.copy())], query: true, limits: limits() } +]; + let result: array = sqlite::transaction(&db, statements); + let last: SqliteTransactionResult = result[result.length - 1].copy(); + let query: SqliteQueryResult = last.query; + ok(query) +} + +pub fn recover_one(db: resource, payload: string) -> map { + let input: ManagedStorageInput = json::decode::(payload); + let statements: array = [ +{ sql: "UPDATE managed_effect_calls SET state=CASE WHEN state='intent' THEN 'indeterminate' ELSE state END,commit_id=?,record_json=json_set(record_json,'$.state',CASE WHEN state='prepared' THEN 'staged' ELSE 'indeterminate' END,'$.recovery',json_extract(?,'$.normal.recovery')) WHERE effect_id=? AND state IN ('intent','prepared','indeterminate') AND json_extract(record_json,'$.recovery') IS NULL", params: [text(input.commit_id.copy()), text(input.projections_json.copy()), text(input.effect_id.copy())], query: false, limits: limits() }, +{ sql: "INSERT INTO run_events(run_id,seq,event_id,event_type,payload_json,created_at_ms) SELECT c.run_id,json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.output' ELSE '$.normal.output' END||'.seq'),json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.output' ELSE '$.normal.output' END||'.event_id'),json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.output' ELSE '$.normal.output' END||'.event_type'),json_patch(json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.output' ELSE '$.normal.output' END||'.payload_json'),CASE WHEN json_extract(c.record_json,'$.outcome.publication') IS NULL THEN '{}' ELSE json_object('managed_publication',json_extract(c.record_json,'$.outcome.publication')) END),? FROM managed_effect_calls c WHERE c.effect_id=? AND c.commit_id=?", params: [text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.projections_json.copy()), integer(input.now_ms.copy()), text(input.effect_id.copy()), text(input.commit_id.copy())], query: false, limits: limits() }, +{ sql: "INSERT INTO run_events(run_id,seq,event_id,event_type,payload_json,created_at_ms) SELECT c.run_id,json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.terminal' ELSE '$.normal.terminal' END||'.seq'),json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.terminal' ELSE '$.normal.terminal' END||'.event_id'),json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.terminal' ELSE '$.normal.terminal' END||'.event_type'),json_patch(json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.terminal' ELSE '$.normal.terminal' END||'.payload_json'),CASE WHEN json_extract(c.record_json,'$.outcome.publication') IS NULL THEN '{}' ELSE json_object('managed_publication',json_extract(c.record_json,'$.outcome.publication')) END),? FROM managed_effect_calls c WHERE c.effect_id=? AND c.commit_id=?", params: [text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.projections_json.copy()), integer(input.now_ms.copy()), text(input.effect_id.copy()), text(input.commit_id.copy())], query: false, limits: limits() }, +{ sql: "INSERT INTO messages(id,session_id,role,content_json,created_at_ms,run_id,name,tool_call_id,parent_message_id,token_estimate,metadata_json,ordinal,finish_reason) SELECT json_extract(?,'$.normal.message.id'),json_extract(c.identity_json,'$.owner.session_id'),'user',json_extract(?,'$.normal.message.content_json'),?,c.run_id,json_extract(c.identity_json,'$.tool_name'),c.call_id,json_extract(?,'$.normal.message.parent_message_id'),0,'{}',json_extract(?,'$.normal.message.ordinal'),'' FROM managed_effect_calls c WHERE c.effect_id=? AND c.commit_id=?", params: [text(input.projections_json.copy()), text(input.projections_json.copy()), integer(input.now_ms.copy()), text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.effect_id.copy()), text(input.commit_id.copy())], query: false, limits: limits() }, +{ sql: "UPDATE runs SET updated_at_ms=? WHERE id=?", params: [integer(input.now_ms.copy()), text(input.run_id.copy())], query: false, limits: limits() }, +{ sql: "DELETE FROM run_events WHERE run_id=? AND seq NOT IN (SELECT seq FROM run_events WHERE run_id=? ORDER BY seq DESC LIMIT ?)", params: [text(input.run_id.copy()), text(input.run_id.copy()), integer(input.max_events.copy())], query: false, limits: limits() }, +{ sql: "INSERT INTO run_retention(run_id,first_seq,high_water_seq,updated_at_ms) SELECT ?,COALESCE(MIN(seq),0),COALESCE(MAX(seq),0),? FROM run_events WHERE run_id=? ON CONFLICT(run_id) DO UPDATE SET first_seq=excluded.first_seq,high_water_seq=MAX(run_retention.high_water_seq,excluded.high_water_seq),updated_at_ms=excluded.updated_at_ms", params: [text(input.run_id.copy()), integer(input.now_ms.copy()), text(input.run_id.copy())], query: false, limits: limits() }, +{ sql: "SELECT record_json FROM managed_effect_calls WHERE effect_id=?", params: [text(input.effect_id.copy())], query: true, limits: limits() } +]; + let result: array = sqlite::transaction(&db, statements); + let last: SqliteTransactionResult = result[result.length - 1].copy(); + let query: SqliteQueryResult = last.query; + ok(query) +} + +pub fn recovery_context(db: resource, payload: string) -> map { + let input: ManagedStorageInput = json::decode::(payload); + ok(sqlite::query(&db, "SELECT json_object('next_seq',MAX(COALESCE((SELECT high_water_seq FROM run_retention WHERE run_id=r.id),0),COALESCE((SELECT MAX(seq) FROM run_events WHERE run_id=r.id),0))+1,'ordinal',COALESCE((SELECT MAX(ordinal) FROM messages WHERE session_id=r.session_id),0)+1,'parent',COALESCE((SELECT id FROM messages m WHERE m.run_id=r.id AND m.role='assistant' AND EXISTS (SELECT 1 FROM json_each(m.content_json) b WHERE json_extract(b.value,'$.type')='tool_call' AND json_extract(b.value,'$.tool_call_id')=?)),'')) AS record_json FROM runs r WHERE r.id=?", [text(input.call_id.copy()),text(input.run_id.copy())], limits())) +} + +pub fn pending(db: resource, payload: string) -> map { let input: ManagedStorageInput = json::decode::(payload); ok(sqlite::query(&db, "SELECT record_json FROM managed_effect_calls WHERE state IN ('intent','prepared','indeterminate') AND json_extract(record_json,'$.recovery') IS NULL ORDER BY effect_id LIMIT 128", [], limits())) } diff --git a/rss/storage/events.rss b/rss/storage/events.rss index 703ada9..0097276 100644 --- a/rss/storage/events.rss +++ b/rss/storage/events.rss @@ -367,7 +367,7 @@ pub fn storage_effect_reconcile(db_id: resource, payload_json }; let statements: array = [ { - sql: "INSERT OR IGNORE INTO run_events (run_id, seq, event_id, event_type, payload_json, created_at_ms) SELECT pending.run_id, COALESCE((SELECT MAX(events.seq) FROM run_events events WHERE events.run_id = pending.run_id), 0) + pending.rn, substr('recovery-effect:' || pending.run_id || ':' || pending.tool_call_id, 1, 128), 'tool.failed', CAST(json_object('status', 'failed', 'error_code', 'interrupted_effect', 'tool_call_id', pending.tool_call_id, 'reason', 'interrupted_effect') AS TEXT), ? FROM (SELECT grouped.run_id AS run_id, grouped.tool_call_id AS tool_call_id, ROW_NUMBER() OVER (PARTITION BY grouped.run_id ORDER BY grouped.min_seq) AS rn FROM (SELECT events.run_id AS run_id, json_extract(events.payload_json, '$.tool_call_id') AS tool_call_id, MIN(events.seq) AS min_seq FROM run_events events WHERE events.event_type IN ('tool.requested', 'tool.started') AND json_extract(events.payload_json, '$.tool_call_id') IS NOT NULL AND json_extract(events.payload_json, '$.tool_call_id') != '' AND NOT EXISTS (SELECT 1 FROM run_events done WHERE done.run_id = events.run_id AND done.event_type IN ('tool.output', 'tool.completed', 'tool.failed') AND json_extract(done.payload_json, '$.tool_call_id') = json_extract(events.payload_json, '$.tool_call_id')) GROUP BY events.run_id, json_extract(events.payload_json, '$.tool_call_id') LIMIT ?) grouped) pending", + sql: "INSERT OR IGNORE INTO run_events (run_id, seq, event_id, event_type, payload_json, created_at_ms) SELECT pending.run_id, COALESCE((SELECT MAX(events.seq) FROM run_events events WHERE events.run_id = pending.run_id), 0) + pending.rn, substr('recovery-effect:' || pending.run_id || ':' || pending.tool_call_id, 1, 128), 'tool.failed', CAST(json_object('status', 'failed', 'error_code', 'interrupted_effect', 'tool_call_id', pending.tool_call_id, 'reason', 'interrupted_effect') AS TEXT), ? FROM (SELECT grouped.run_id AS run_id, grouped.tool_call_id AS tool_call_id, ROW_NUMBER() OVER (PARTITION BY grouped.run_id ORDER BY grouped.min_seq) AS rn FROM (SELECT events.run_id AS run_id, json_extract(events.payload_json, '$.tool_call_id') AS tool_call_id, MIN(events.seq) AS min_seq FROM run_events events WHERE events.event_type IN ('tool.requested', 'tool.started') AND NOT EXISTS (SELECT 1 FROM managed_effect_calls managed WHERE managed.run_id=events.run_id AND managed.call_id=json_extract(events.payload_json, '$.tool_call_id')) AND json_extract(events.payload_json, '$.tool_call_id') IS NOT NULL AND json_extract(events.payload_json, '$.tool_call_id') != '' AND NOT EXISTS (SELECT 1 FROM run_events done WHERE done.run_id = events.run_id AND done.event_type IN ('tool.output', 'tool.completed', 'tool.failed') AND json_extract(done.payload_json, '$.tool_call_id') = json_extract(events.payload_json, '$.tool_call_id')) GROUP BY events.run_id, json_extract(events.payload_json, '$.tool_call_id') LIMIT ?) grouped) pending", params: [sql_int(input.now_ms.copy()), sql_int(limit.copy())], query: false, limits: sql_limits(1000, 4194304) diff --git a/rss/storage/main.rss b/rss/storage/main.rss index acd2e08..5b18585 100644 --- a/rss/storage/main.rss +++ b/rss/storage/main.rss @@ -34,6 +34,7 @@ use self::approvals as approvals; use self::compactions as compactions; use self::jobs as jobs; use self::plugins as plugins; +use self::effects as effects; use self::admission as admission; use self::load as load; @@ -516,12 +517,16 @@ fn storage_dispatch(db_id: resource, command: StorageCommand) } else if command.op == "admission.replay" { result = storage_unwrap(request_id, op, admission::storage_admission_replay(db_id, command.payload_json)); } else if command.op == "session.delete" { + if sessions::storage_session_managed_retained(db_id,command.payload_json.copy()) { + result=storage_error(request_id,op,"managed_effect_retained","session has retained managed effect bindings"); + }else{ let delete_results: array = sessions::storage_session_delete(db_id, command.payload_json); - if storage_nth_rows_affected(delete_results, 2) == 0 { + if storage_nth_rows_affected(delete_results, 3) == 0 { result = storage_error(request_id, op, "session_not_found", "session does not exist"); } else { result = storage_array_ok(request_id, op, delete_results); } + } } else if command.op == "load.all" { result = storage_unwrap(request_id, op, load::storage_load_all(db_id, command.payload_json)); } else if command.op == "run.link_child" { @@ -644,6 +649,38 @@ fn storage_dispatch(db_id: resource, command: StorageCommand) result = storage_unwrap(request_id, op, jobs::storage_job_get(db_id, command.payload_json, command.max_rows, command.max_bytes)); } else if command.op == "job.list" { result = storage_unwrap(request_id, op, jobs::storage_job_list(db_id, command.max_rows, command.max_bytes)); + } else if command.op == "effect.workspace_get" { + result = storage_unwrap(request_id, op, effects::workspace_get(db_id, command.payload_json)); + } else if command.op == "effect.workspace_register" { + result = storage_unwrap(request_id, op, effects::workspace_register(db_id, command.payload_json)); + } else if command.op == "effect.head" { + result = storage_unwrap(request_id, op, effects::head(db_id, command.payload_json)); + } else if command.op == "effect.get" { + result = storage_unwrap(request_id, op, effects::get(db_id, command.payload_json)); + } else if command.op == "effect.by_id" { + result=storage_unwrap(request_id,op,effects::by_id(db_id,command.payload_json)); + } else if command.op == "effect.object_census" { + result=storage_unwrap(request_id,op,effects::object_census(db_id,command.payload_json)); + } else if command.op == "effect.object_metadata" { + result=storage_unwrap(request_id,op,effects::object_metadata(db_id,command.payload_json)); + } else if command.op == "effect.object_refs" { + result=storage_unwrap(request_id,op,effects::object_refs(db_id,command.payload_json)); + } else if command.op == "effect.list" { + result=storage_unwrap(request_id,op,effects::list(db_id,command.payload_json)); + } else if command.op == "effect.list_has_more" { + result=storage_unwrap(request_id,op,effects::list_has_more(db_id,command.payload_json)); + } else if command.op == "effect.begin" { + result = storage_unwrap(request_id, op, effects::begin(db_id, command.payload_json)); + } else if command.op == "effect.prepare" { + result = storage_unwrap(request_id, op, effects::prepare(db_id, command.payload_json)); + } else if command.op == "effect.commit" { + result = storage_unwrap(request_id, op, effects::commit(db_id, command.payload_json)); + } else if command.op == "effect.pending" { + result = storage_unwrap(request_id, op, effects::pending(db_id, command.payload_json)); + } else if command.op == "effect.recovery_context" { + result = storage_unwrap(request_id, op, effects::recovery_context(db_id, command.payload_json)); + } else if command.op == "effect.recover_one" { + result = storage_unwrap(request_id, op, effects::recover_one(db_id, command.payload_json)); } else if command.op == "plugin.lock_put" { result = storage_unwrap(request_id, op, plugins::storage_plugin_lock_put(db_id, command.payload_json)); } else if command.op == "plugin.lock_get" { diff --git a/rss/storage/schema.rss b/rss/storage/schema.rss index 289d268..458e53e 100644 --- a/rss/storage/schema.rss +++ b/rss/storage/schema.rss @@ -144,15 +144,17 @@ fn index_statement(statement_index: int) -> string { } } +use self::effects as effects; + pub fn schema_migration_count() -> int { - 5 + 6 } pub fn schema_migration_version(migration_index: int) -> int { if migration_index == 0 => { 1 } else => { if migration_index == 1 => { 2 } else => { if migration_index == 2 => { 3 } else => { - if migration_index == 3 => { 4 } else => { 5 } + if migration_index == 3 => { 4 } else => { if migration_index == 4 => { 5 } else => { 6 } } } } } @@ -171,7 +173,7 @@ pub fn schema_migration_name(migration_index: int) -> string { if migration_index == 3 => { "agent-storage-v4-jobs-and-message-links" } else => { - "agent-storage-v5-plugin-control" + if migration_index == 4 => { "agent-storage-v5-plugin-control" } else => { "agent-storage-v6-managed-effects" } } } } @@ -191,7 +193,7 @@ pub fn schema_migration_checksum(migration_index: int) -> string { if migration_index == 3 => { "rss-storage-v4-jobs-message-links" } else => { - "rss-storage-v5-plugin-control" + if migration_index == 4 => { "rss-storage-v5-plugin-control" } else => { "rss-storage-v6-managed-effects" } } } } @@ -202,7 +204,7 @@ pub fn schema_migration_statement_count(migration_index: int) -> int { if migration_index == 0 => { 11 } else => { if migration_index == 1 => { 12 } else => { if migration_index == 2 => { 1 } else => { - if migration_index == 3 => { 5 } else => { 6 } + if migration_index == 3 => { 5 } else => { if migration_index == 4 => { 6 } else => { effects::migration_count() } } } } } @@ -210,7 +212,7 @@ pub fn schema_migration_statement_count(migration_index: int) -> int { /// The schema version a fully migrated database reaches. pub fn schema_current_version() -> int { - 5 + 6 } fn table_migration_statement(statement_index: int) -> string { @@ -274,7 +276,7 @@ pub fn schema_migration_statement(migration_index: int, statement_index: int) -> if migration_index == 3 => { jobs_migration_statement(statement_index) } else => { - plugin_control_migration_statement(statement_index) + if migration_index == 4 => { plugin_control_migration_statement(statement_index) } else => { effects::migration(statement_index) } } } } diff --git a/rss/storage/sessions.rss b/rss/storage/sessions.rss index 8e4fc2a..3e1b3ae 100644 --- a/rss/storage/sessions.rss +++ b/rss/storage/sessions.rss @@ -178,9 +178,19 @@ pub fn storage_session_workspace_bind(db_id: resource, payloa /// predicates need those runs to still exist. The session row delete is /// statement 2, so `rows_affected` of that result tells the caller whether /// the session existed (0 -> session_not_found). +pub fn storage_session_managed_retained(db_id: resource,payload_json:string)->bool { + let input:SessionIdInput=json::decode::(payload_json); + let found:SqliteQueryResult=sqlite::query(&db_id,"SELECT run_id FROM managed_run_bindings WHERE session_id=? LIMIT 1",[sql_text(input.session_id)],sql_limits(1,4096)); + found.rows.length > 0 +} + pub fn storage_session_delete(db_id: resource, payload_json: string) -> array { let input: SessionIdInput = json::decode::(payload_json); let statements: array = [ + { + sql:"INSERT INTO managed_effect_guard(id) SELECT 2 WHERE EXISTS(SELECT 1 FROM managed_run_bindings WHERE session_id=?)", + params:[sql_text(input.session_id.copy())],query:false,limits:sql_limits(1,4096) + }, { sql: "DELETE FROM plugin_graph_refs WHERE owner_kind = 'run' AND owner_id IN (SELECT id FROM runs WHERE session_id = ?)", params: [sql_text(input.session_id.copy())], diff --git a/schemas/canonical/managed-effect.schema.json b/schemas/canonical/managed-effect.schema.json new file mode 100644 index 0000000..1653aec --- /dev/null +++ b/schemas/canonical/managed-effect.schema.json @@ -0,0 +1,666 @@ +{ + "$defs": { + "arguments": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "canonical_arguments": { + "maxLength": 1048576, + "minLength": 1, + "type": "string" + }, + "kind": { + "const": "inline" + } + }, + "required": [ + "kind", + "canonical_arguments" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "details": { + "$ref": "#/$defs/object" + }, + "kind": { + "const": "object" + } + }, + "required": [ + "kind", + "details" + ], + "type": "object" + } + ] + }, + "code": { + "additionalProperties": false, + "properties": { + "activation_generation": { + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "artifact_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "effect_contract_version": { + "const": 1 + }, + "export": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "graph_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "graph_ref_id": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "package_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "registry_identity": { + "maxLength": 256, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "graph_id", + "activation_generation", + "graph_ref_id", + "package_id", + "artifact_id", + "export", + "registry_identity", + "effect_contract_version" + ], + "type": "object" + }, + "directory_identity": { + "additionalProperties": false, + "properties": { + "device_decimal": { + "pattern": "^(0|[1-9][0-9]{0,19})$", + "type": "string" + }, + "inode_decimal": { + "pattern": "^(0|[1-9][0-9]{0,19})$", + "type": "string" + } + }, + "required": [ + "device_decimal", + "inode_decimal" + ], + "type": "object" + }, + "event": { + "additionalProperties": false, + "properties": { + "event_id": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "seq": { + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "event_id", + "seq" + ], + "type": "object" + }, + "identity": { + "additionalProperties": false, + "properties": { + "approval_basis": { + "enum": [ + "read_without_attestation", + "guardian_attested" + ] + }, + "approved_risk": { + "enum": [ + "read", + "write", + "execute" + ] + }, + "argument_identity": { + "$ref": "#/$defs/arguments" + }, + "authorization_generation": { + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, + "call_id": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "code": { + "$ref": "#/$defs/code" + }, + "epoch": { + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, + "expected_head": { + "pattern": "^tree-[0-9a-f]{32}$", + "type": "string" + }, + "expected_head_version": { + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, + "original_deadline_ms": { + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, + "owner": { + "$ref": "#/$defs/owner" + }, + "tool_name": { + "enum": [ + "read_file", + "search_files", + "write_file", + "patch", + "terminal" + ] + }, + "workspace": { + "$ref": "#/$defs/workspace_identity" + } + }, + "required": [ + "owner", + "call_id", + "tool_name", + "argument_identity", + "code", + "workspace", + "epoch", + "expected_head", + "expected_head_version", + "original_deadline_ms", + "authorization_generation", + "approved_risk", + "approval_basis" + ], + "type": "object" + }, + "message": { + "additionalProperties": false, + "properties": { + "id": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "ordinal": { + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "id", + "ordinal" + ], + "type": "object" + }, + "object": { + "additionalProperties": false, + "properties": { + "byte_count": { + "pattern": "^(0|[1-9][0-9]{0,19})$", + "type": "string" + }, + "entry_count": { + "pattern": "^(0|[1-9][0-9]{0,19})$", + "type": "string" + }, + "object_format": { + "enum": [ + "agent.managed-tree/v1", + "agent.canonical-tool-result/v1", + "agent.intent-details/v1" + ] + }, + "object_id": { + "pattern": "^(tree|result|intent)-[0-9a-f]{32}$", + "type": "string" + }, + "object_kind": { + "enum": [ + "tree", + "canonical_result", + "intent_details" + ] + }, + "store_id": { + "pattern": "^store-[0-9a-f]{32}$", + "type": "string" + } + }, + "required": [ + "store_id", + "object_id", + "object_kind", + "byte_count", + "entry_count", + "object_format" + ], + "type": "object" + }, + "outcome": { + "additionalProperties": false, + "properties": { + "canonical_result_ref": { + "$ref": "#/$defs/object" + }, + "disposition": { + "enum": [ + "read_only", + "committed", + "conflict", + "not_published" + ] + }, + "message": { + "$ref": "#/$defs/message" + }, + "output_event": { + "$ref": "#/$defs/event" + }, + "publication": { + "$ref": "#/$defs/publication" + }, + "replayed": { + "type": "boolean" + }, + "terminal_event": { + "$ref": "#/$defs/event" + } + }, + "required": [ + "disposition", + "publication", + "canonical_result_ref", + "output_event", + "terminal_event", + "message", + "replayed" + ], + "type": "object" + }, + "owner": { + "additionalProperties": false, + "properties": { + "profile": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "run_id": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "session_id": { + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "profile", + "session_id", + "run_id" + ], + "type": "object" + }, + "prepared": { + "additionalProperties": false, + "properties": { + "candidate_root": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/object" + } + ] + }, + "canonical_result": { + "$ref": "#/$defs/object" + }, + "conflict_result": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/object" + } + ] + }, + "effect_id": { + "pattern": "^effect-[0-9a-f]{32}$", + "type": "string" + }, + "external_effects": { + "enum": [ + "none_claimed", + "outside_tree_not_reverted" + ] + }, + "format": { + "const": "agent.managed-effect/v1" + }, + "identity": { + "$ref": "#/$defs/identity" + }, + "observation_ok": { + "type": "boolean" + }, + "operation_class": { + "enum": [ + "read_only", + "tree_write" + ] + }, + "parent_root": { + "$ref": "#/$defs/object" + }, + "prepared_at_ms": { + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, + "schemaVersion": { + "const": 1 + }, + "writer_state": { + "enum": [ + "settled", + "no_process" + ] + } + }, + "required": [ + "format", + "schemaVersion", + "effect_id", + "identity", + "parent_root", + "candidate_root", + "canonical_result", + "conflict_result", + "writer_state", + "operation_class", + "prepared_at_ms", + "observation_ok", + "external_effects" + ], + "type": "object" + }, + "publication": { + "additionalProperties": false, + "properties": { + "base_head": { + "pattern": "^tree-[0-9a-f]{32}$", + "type": "string" + }, + "base_head_version": { + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, + "branch_id": { + "pattern": "^br-[0-9a-f]{32}$", + "type": "string" + }, + "candidate_head": { + "anyOf": [ + { + "type": "null" + }, + { + "pattern": "^tree-[0-9a-f]{32}$", + "type": "string" + } + ] + }, + "effect_id": { + "pattern": "^effect-[0-9a-f]{32}$", + "type": "string" + }, + "epoch": { + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, + "format": { + "const": "agent.managed-effect/v1" + }, + "observed_head": { + "pattern": "^tree-[0-9a-f]{32}$", + "type": "string" + }, + "observed_head_version": { + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, + "status": { + "enum": [ + "read_only", + "committed", + "staged", + "conflict", + "not_published", + "indeterminate" + ] + }, + "workspace_id": { + "pattern": "^ws-[0-9a-f]{32}$", + "type": "string" + } + }, + "required": [ + "format", + "effect_id", + "workspace_id", + "branch_id", + "epoch", + "base_head", + "base_head_version", + "candidate_head", + "observed_head", + "observed_head_version", + "status" + ], + "type": "object" + }, + "record": { + "additionalProperties": false, + "properties": { + "effect_id": { + "pattern": "^effect-[0-9a-f]{32}$", + "type": "string" + }, + "format": { + "const": "agent.managed-effect/v1" + }, + "identity": { + "$ref": "#/$defs/identity" + }, + "outcome": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/outcome" + } + ] + }, + "prepared": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/prepared" + } + ] + }, + "recovery": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/recovery" + } + ] + }, + "schemaVersion": { + "const": 1 + }, + "state": { + "enum": [ + "read_only", + "committed", + "staged", + "conflict", + "not_published", + "indeterminate" + ] + } + }, + "required": [ + "format", + "effect_id", + "schemaVersion", + "identity", + "state", + "prepared", + "outcome", + "recovery" + ], + "type": "object" + }, + "recovery": { + "additionalProperties": false, + "properties": { + "canonical_failure_json": { + "additionalProperties": false, + "properties": { + "json": { + "maxLength": 4096, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "json" + ], + "type": "object" + }, + "code": { + "enum": [ + "effect_staged", + "effect_indeterminate" + ] + }, + "message": { + "$ref": "#/$defs/message" + }, + "output_event": { + "$ref": "#/$defs/event" + }, + "publication": { + "$ref": "#/$defs/publication" + }, + "terminal_event": { + "$ref": "#/$defs/event" + } + }, + "required": [ + "code", + "publication", + "canonical_failure_json", + "output_event", + "terminal_event", + "message" + ], + "type": "object" + }, + "workspace_identity": { + "additionalProperties": false, + "properties": { + "branch_id": { + "pattern": "^br-[0-9a-f]{32}$", + "type": "string" + }, + "source_identity": { + "$ref": "#/$defs/directory_identity" + }, + "store_id": { + "pattern": "^store-[0-9a-f]{32}$", + "type": "string" + }, + "workspace_id": { + "pattern": "^ws-[0-9a-f]{32}$", + "type": "string" + } + }, + "required": [ + "store_id", + "workspace_id", + "branch_id", + "source_identity" + ], + "type": "object" + } + }, + "$id": "https://schemas.rustscript.local/v1/managed-effect.schema.yaml", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "oneOf": [ + { + "$ref": "#/$defs/record" + }, + { + "$ref": "#/$defs/prepared" + } + ], + "title": "Managed effect audit records; identifiers grant no authority" +} diff --git a/schemas/canonical/managed-workspace.schema.json b/schemas/canonical/managed-workspace.schema.json new file mode 100644 index 0000000..f9f7e4c --- /dev/null +++ b/schemas/canonical/managed-workspace.schema.json @@ -0,0 +1,51 @@ +{ + "$id": "https://schemas.rustscript.local/v1/managed-workspace.schema.yaml", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "format": { + "const": "agent.managed-workspace/v1" + }, + "identity": { + "$ref": "managed-effect.schema.yaml#/$defs/workspace_identity" + }, + "max_bytes": { + "maximum": 67108864, + "minimum": 1, + "type": "integer" + }, + "max_depth": { + "maximum": 32, + "minimum": 1, + "type": "integer" + }, + "max_entries": { + "maximum": 1000000, + "minimum": 1, + "type": "integer" + }, + "profile": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "schemaVersion": { + "const": 1 + }, + "seed_root": { + "$ref": "managed-effect.schema.yaml#/$defs/object" + } + }, + "required": [ + "format", + "schemaVersion", + "identity", + "profile", + "seed_root", + "max_entries", + "max_bytes", + "max_depth" + ], + "title": "Configured managed workspace audit record", + "type": "object" +} diff --git a/schemas/managed-effect.schema.yaml b/schemas/managed-effect.schema.yaml new file mode 100644 index 0000000..e74e399 --- /dev/null +++ b/schemas/managed-effect.schema.yaml @@ -0,0 +1,666 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.rustscript.local/v1/managed-effect.schema.yaml", + "title": "Managed effect audit records; identifiers grant no authority", + "oneOf": [ + { + "$ref": "#/$defs/record" + }, + { + "$ref": "#/$defs/prepared" + } + ], + "$defs": { + "owner": { + "type": "object", + "additionalProperties": false, + "required": [ + "profile", + "session_id", + "run_id" + ], + "properties": { + "profile": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "session_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "run_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + } + }, + "directory_identity": { + "type": "object", + "additionalProperties": false, + "required": [ + "device_decimal", + "inode_decimal" + ], + "properties": { + "device_decimal": { + "type": "string", + "pattern": "^(0|[1-9][0-9]{0,19})$" + }, + "inode_decimal": { + "type": "string", + "pattern": "^(0|[1-9][0-9]{0,19})$" + } + } + }, + "workspace_identity": { + "type": "object", + "additionalProperties": false, + "required": [ + "store_id", + "workspace_id", + "branch_id", + "source_identity" + ], + "properties": { + "store_id": { + "type": "string", + "pattern": "^store-[0-9a-f]{32}$" + }, + "workspace_id": { + "type": "string", + "pattern": "^ws-[0-9a-f]{32}$" + }, + "branch_id": { + "type": "string", + "pattern": "^br-[0-9a-f]{32}$" + }, + "source_identity": { + "$ref": "#/$defs/directory_identity" + } + } + }, + "object": { + "type": "object", + "additionalProperties": false, + "required": [ + "store_id", + "object_id", + "object_kind", + "byte_count", + "entry_count", + "object_format" + ], + "properties": { + "store_id": { + "type": "string", + "pattern": "^store-[0-9a-f]{32}$" + }, + "object_id": { + "type": "string", + "pattern": "^(tree|result|intent)-[0-9a-f]{32}$" + }, + "object_kind": { + "enum": [ + "tree", + "canonical_result", + "intent_details" + ] + }, + "byte_count": { + "type": "string", + "pattern": "^(0|[1-9][0-9]{0,19})$" + }, + "entry_count": { + "type": "string", + "pattern": "^(0|[1-9][0-9]{0,19})$" + }, + "object_format": { + "enum": [ + "agent.managed-tree/v1", + "agent.canonical-tool-result/v1", + "agent.intent-details/v1" + ] + } + } + }, + "code": { + "type": "object", + "additionalProperties": false, + "required": [ + "graph_id", + "activation_generation", + "graph_ref_id", + "package_id", + "artifact_id", + "export", + "registry_identity", + "effect_contract_version" + ], + "properties": { + "graph_id": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "activation_generation": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "graph_ref_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "package_id": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "artifact_id": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "export": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "registry_identity": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "effect_contract_version": { + "const": 1 + } + } + }, + "arguments": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "canonical_arguments" + ], + "properties": { + "kind": { + "const": "inline" + }, + "canonical_arguments": { + "type": "string", + "minLength": 1, + "maxLength": 1048576 + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "details" + ], + "properties": { + "kind": { + "const": "object" + }, + "details": { + "$ref": "#/$defs/object" + } + } + } + ] + }, + "identity": { + "type": "object", + "additionalProperties": false, + "required": [ + "owner", + "call_id", + "tool_name", + "argument_identity", + "code", + "workspace", + "epoch", + "expected_head", + "expected_head_version", + "original_deadline_ms", + "authorization_generation", + "approved_risk", + "approval_basis" + ], + "properties": { + "owner": { + "$ref": "#/$defs/owner" + }, + "call_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "tool_name": { + "enum": [ + "read_file", + "search_files", + "write_file", + "patch", + "terminal" + ] + }, + "argument_identity": { + "$ref": "#/$defs/arguments" + }, + "code": { + "$ref": "#/$defs/code" + }, + "workspace": { + "$ref": "#/$defs/workspace_identity" + }, + "epoch": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "expected_head": { + "type": "string", + "pattern": "^tree-[0-9a-f]{32}$" + }, + "expected_head_version": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "original_deadline_ms": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "authorization_generation": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "approved_risk": { + "enum": [ + "read", + "write", + "execute" + ] + }, + "approval_basis": { + "enum": [ + "read_without_attestation", + "guardian_attested" + ] + } + } + }, + "prepared": { + "type": "object", + "additionalProperties": false, + "required": [ + "format", + "schemaVersion", + "effect_id", + "identity", + "parent_root", + "candidate_root", + "canonical_result", + "conflict_result", + "writer_state", + "operation_class", + "prepared_at_ms", + "observation_ok", + "external_effects" + ], + "properties": { + "format": { + "const": "agent.managed-effect/v1" + }, + "schemaVersion": { + "const": 1 + }, + "effect_id": { + "type": "string", + "pattern": "^effect-[0-9a-f]{32}$" + }, + "identity": { + "$ref": "#/$defs/identity" + }, + "parent_root": { + "$ref": "#/$defs/object" + }, + "candidate_root": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/object" + } + ] + }, + "canonical_result": { + "$ref": "#/$defs/object" + }, + "conflict_result": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/object" + } + ] + }, + "writer_state": { + "enum": [ + "settled", + "no_process" + ] + }, + "operation_class": { + "enum": [ + "read_only", + "tree_write" + ] + }, + "prepared_at_ms": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "observation_ok": { + "type": "boolean" + }, + "external_effects": { + "enum": [ + "none_claimed", + "outside_tree_not_reverted" + ] + } + } + }, + "publication": { + "type": "object", + "additionalProperties": false, + "required": [ + "format", + "effect_id", + "workspace_id", + "branch_id", + "epoch", + "base_head", + "base_head_version", + "candidate_head", + "observed_head", + "observed_head_version", + "status" + ], + "properties": { + "format": { + "const": "agent.managed-effect/v1" + }, + "effect_id": { + "type": "string", + "pattern": "^effect-[0-9a-f]{32}$" + }, + "workspace_id": { + "type": "string", + "pattern": "^ws-[0-9a-f]{32}$" + }, + "branch_id": { + "type": "string", + "pattern": "^br-[0-9a-f]{32}$" + }, + "epoch": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "base_head": { + "type": "string", + "pattern": "^tree-[0-9a-f]{32}$" + }, + "base_head_version": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "candidate_head": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "string", + "pattern": "^tree-[0-9a-f]{32}$" + } + ] + }, + "observed_head": { + "type": "string", + "pattern": "^tree-[0-9a-f]{32}$" + }, + "observed_head_version": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "status": { + "enum": [ + "read_only", + "committed", + "staged", + "conflict", + "not_published", + "indeterminate" + ] + } + } + }, + "event": { + "type": "object", + "additionalProperties": false, + "required": [ + "event_id", + "seq" + ], + "properties": { + "event_id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "seq": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + } + }, + "message": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "ordinal" + ], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "ordinal": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + } + }, + "outcome": { + "type": "object", + "additionalProperties": false, + "required": [ + "disposition", + "publication", + "canonical_result_ref", + "output_event", + "terminal_event", + "message", + "replayed" + ], + "properties": { + "disposition": { + "enum": [ + "read_only", + "committed", + "conflict", + "not_published" + ] + }, + "publication": { + "$ref": "#/$defs/publication" + }, + "canonical_result_ref": { + "$ref": "#/$defs/object" + }, + "output_event": { + "$ref": "#/$defs/event" + }, + "terminal_event": { + "$ref": "#/$defs/event" + }, + "message": { + "$ref": "#/$defs/message" + }, + "replayed": { + "type": "boolean" + } + } + }, + "recovery": { + "type": "object", + "additionalProperties": false, + "required": [ + "code", + "publication", + "canonical_failure_json", + "output_event", + "terminal_event", + "message" + ], + "properties": { + "code": { + "enum": [ + "effect_staged", + "effect_indeterminate" + ] + }, + "publication": { + "$ref": "#/$defs/publication" + }, + "canonical_failure_json": { + "type": "object", + "additionalProperties": false, + "required": [ + "json" + ], + "properties": { + "json": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + } + } + }, + "output_event": { + "$ref": "#/$defs/event" + }, + "terminal_event": { + "$ref": "#/$defs/event" + }, + "message": { + "$ref": "#/$defs/message" + } + } + }, + "record": { + "type": "object", + "additionalProperties": false, + "required": [ + "format", + "effect_id", + "schemaVersion", + "identity", + "state", + "prepared", + "outcome", + "recovery" + ], + "properties": { + "format": { + "const": "agent.managed-effect/v1" + }, + "effect_id": { + "type": "string", + "pattern": "^effect-[0-9a-f]{32}$" + }, + "schemaVersion": { + "const": 1 + }, + "identity": { + "$ref": "#/$defs/identity" + }, + "state": { + "enum": [ + "read_only", + "committed", + "staged", + "conflict", + "not_published", + "indeterminate" + ] + }, + "prepared": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/prepared" + } + ] + }, + "outcome": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/outcome" + } + ] + }, + "recovery": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/recovery" + } + ] + } + } + } + } +} diff --git a/schemas/managed-workspace.schema.yaml b/schemas/managed-workspace.schema.yaml new file mode 100644 index 0000000..98ce58e --- /dev/null +++ b/schemas/managed-workspace.schema.yaml @@ -0,0 +1,51 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.rustscript.local/v1/managed-workspace.schema.yaml", + "title": "Configured managed workspace audit record", + "type": "object", + "additionalProperties": false, + "required": [ + "format", + "schemaVersion", + "identity", + "profile", + "seed_root", + "max_entries", + "max_bytes", + "max_depth" + ], + "properties": { + "format": { + "const": "agent.managed-workspace/v1" + }, + "schemaVersion": { + "const": 1 + }, + "identity": { + "$ref": "managed-effect.schema.yaml#/$defs/workspace_identity" + }, + "profile": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "seed_root": { + "$ref": "managed-effect.schema.yaml#/$defs/object" + }, + "max_entries": { + "type": "integer", + "minimum": 1, + "maximum": 1000000 + }, + "max_bytes": { + "type": "integer", + "minimum": 1, + "maximum": 67108864 + }, + "max_depth": { + "type": "integer", + "minimum": 1, + "maximum": 32 + } + } +} diff --git a/src/bin/rustscript-agent-gateway.rs b/src/bin/rustscript-agent-gateway.rs index 7f3f711..1fee705 100644 --- a/src/bin/rustscript-agent-gateway.rs +++ b/src/bin/rustscript-agent-gateway.rs @@ -164,6 +164,10 @@ async fn main() -> Result<(), Box> { let source = source_env_value("RUSTSCRIPT_AGENT_SOURCE", "PD_EDGE_AGENT_SOURCE"); let legacy_script = source_env_value("RUSTSCRIPT_AGENT_SCRIPT", "PD_EDGE_AGENT_SCRIPT"); let state_db = env_value("RUSTSCRIPT_AGENT_STATE_DB", "PD_EDGE_AGENT_STATE_DB")?; + config.managed_workspace = std::env::var_os("RUSTSCRIPT_AGENT_MANAGED_WORKSPACE") + .map(rustscript_agent::config::ManagedWorkspaceConfig::for_workspace) + .transpose() + .map_err(std::io::Error::other)?; let mut selection = serde_json::Map::new(); selection.insert( "config_present".to_string(), diff --git a/src/capabilities/filesystem.rs b/src/capabilities/filesystem.rs index 9e2c270..26cafaa 100644 --- a/src/capabilities/filesystem.rs +++ b/src/capabilities/filesystem.rs @@ -93,6 +93,7 @@ pub struct FilesystemCapability { root: Arc, frozen: Arc, workspace: Option>, + managed: Option>, cas_locks: Arc>>>>, before_write: Arc>>, after_publish: Arc>>, @@ -133,6 +134,7 @@ impl FilesystemCapability { root: Arc::new(root), frozen: Arc::new(frozen), workspace: None, + managed: None, cas_locks: Arc::new(Mutex::new(HashMap::new())), before_write: Arc::new(Mutex::new(None)), after_publish: Arc::new(Mutex::new(None)), @@ -176,12 +178,29 @@ impl FilesystemCapability { root: Arc::new(root), frozen: Arc::new(frozen), workspace: Some(workspace), + managed: None, cas_locks: Arc::new(Mutex::new(HashMap::new())), before_write: Arc::new(Mutex::new(None)), after_publish: Arc::new(Mutex::new(None)), }) } + pub(crate) fn new_managed( + lifecycle: CapabilityLifecycle, + owner: CapabilityOwner, + limits: FilesystemLimits, + view: Arc, + workspace: Arc, + managed: std::sync::Weak, + ) -> Result { + let mut capability = Self::new_adopted(lifecycle, owner, limits, workspace)?; + let root = view.adopt(view.limits()).map_err(map_fs_error)?; + capability.frozen = Arc::new(FrozenDir::adopt(&root)?); + capability.root = Arc::new(root); + capability.managed = Some(managed); + Ok(capability) + } + /// Installs a production-neutral pre-publish hook for tests. /// /// The hook runs after authorization and the write-size bound, immediately @@ -208,6 +227,7 @@ impl FilesystemCapability { /// Stats a workspace-relative path without following a leaf symlink. pub fn metadata(&self, token: &str, path: &str) -> Result { let _claims = self.authorize(token, CapabilityRisk::Read)?; + let _operation = self.enter_managed_operation(&_claims, CapabilityRisk::Read)?; let meta = deny_symlink(self.root.metadata(path).map_err(map_fs_error)?)?; Ok(FsMetadata { file_type: file_type_name(meta.file_type()), @@ -224,6 +244,7 @@ impl FilesystemCapability { limit: usize, ) -> Result { let _claims = self.authorize(token, CapabilityRisk::Read)?; + let _operation = self.enter_managed_operation(&_claims, CapabilityRisk::Read)?; if limit == 0 { return Err(CapabilityError::new( "invalid_request", @@ -268,6 +289,7 @@ impl FilesystemCapability { limit: usize, ) -> Result { let _claims = self.authorize(token, CapabilityRisk::Read)?; + let _operation = self.enter_managed_operation(&_claims, CapabilityRisk::Read)?; if limit == 0 { return Err(CapabilityError::new( "invalid_request", @@ -319,6 +341,7 @@ impl FilesystemCapability { bytes: &[u8], ) -> Result { let _claims = self.authorize(token, CapabilityRisk::Write)?; + let _operation = self.enter_managed_operation(&_claims, CapabilityRisk::Write)?; if bytes.len() > self.limits.max_write_bytes { return Err(CapabilityError::new( "budget_exceeded", @@ -338,7 +361,17 @@ impl FilesystemCapability { if expected_hash != "*" { self.validate_expected_hash(path, expected_hash)?; } - match self.root.write_file(path, bytes) { + let publication = if let Some(managed) = &self.managed { + let call = managed.upgrade().ok_or_else(|| { + CapabilityError::new("managed_call_unavailable", "managed call unavailable") + })?; + call.accounted_write(token, path, bytes, || { + self.root.write_file(path, bytes).map_err(Box::new) + })? + } else { + self.root.write_file(path, bytes).map_err(Box::new) + }; + match publication { Ok(publication) => { if self.after_publish_forces_indeterminate(path, bytes) { return Err(publication_indeterminate_error()); @@ -368,7 +401,7 @@ impl FilesystemCapability { ConfinedPublicationState::Indeterminate { .. } => { Err(publication_indeterminate_error()) } - ConfinedPublicationState::NotPublished => Err(map_fs_error(error)), + ConfinedPublicationState::NotPublished => Err(map_fs_error(*error)), }, } } @@ -430,15 +463,44 @@ impl FilesystemCapability { .clone() } + fn enter_managed_operation( + &self, + claims: &TokenClaims, + risk: CapabilityRisk, + ) -> Result, CapabilityError> { + self.managed + .as_ref() + .map(|managed| { + managed + .upgrade() + .ok_or_else(|| { + CapabilityError::new("managed_call_unavailable", "managed call unavailable") + })? + .enter_operation(claims, risk) + .map_err(|error| CapabilityError::new("managed_operation_denied", error)) + }) + .transpose() + } fn authorize(&self, token: &str, risk: CapabilityRisk) -> Result { if let Some(workspace) = &self.workspace { workspace.check().map_err(|_| { CapabilityError::new("workspace_denied", "workspace is unavailable") })?; } - self.lifecycle + let claims = self + .lifecycle .authorize(&self.owner, token, risk) - .map_err(CapabilityError::from) + .map_err(CapabilityError::from)?; + if let Some(managed) = &self.managed { + managed + .upgrade() + .ok_or_else(|| { + CapabilityError::new("managed_call_unavailable", "managed call unavailable") + })? + .authorize(&claims, risk) + .map_err(|e| CapabilityError::new("managed_operation_denied", e))?; + } + Ok(claims) } fn after_publish_forces_indeterminate(&self, path: &str, bytes: &[u8]) -> bool { diff --git a/src/capabilities/lifecycle.rs b/src/capabilities/lifecycle.rs index 242195d..567d4a9 100644 --- a/src/capabilities/lifecycle.rs +++ b/src/capabilities/lifecycle.rs @@ -733,6 +733,16 @@ impl CapabilityLifecycle { self.inner.clock.monotonic_ms() } + pub(crate) fn original_deadline_ms(&self) -> u64 { + self.inner.deadline_ms + } + pub(crate) fn ensure_generation(&self, generation: u64) -> Result<(), LifecycleError> { + if self.inner.generation.load(Ordering::SeqCst) != generation { + return Err(LifecycleError::Interrupted); + } + Ok(()) + } + pub fn commit( &self, owner: &CapabilityOwner, diff --git a/src/capabilities/process.rs b/src/capabilities/process.rs index 69be16f..92ae0ef 100644 --- a/src/capabilities/process.rs +++ b/src/capabilities/process.rs @@ -140,12 +140,21 @@ impl TokenOwnedResource for ProcessReaper { } } +#[derive(Default)] +struct ManagedWriterRegistry { + attempts: usize, + handles: Vec, + settled: bool, +} + struct ProcessInner { lifecycle: CapabilityLifecycle, owner: CapabilityOwner, host_limits: ProcessLimits, root: ConfinedFsRoot, workspace: Option>, + managed: Option>, + managed_writers: Mutex, table: Mutex>, closing: AtomicBool, generation: AtomicU64, @@ -213,6 +222,8 @@ impl ProcessCapability { host_limits, root, workspace: None, + managed: None, + managed_writers: Mutex::new(ManagedWriterRegistry::default()), table: Mutex::new(HashMap::new()), closing: AtomicBool::new(false), generation: AtomicU64::new(1), @@ -266,6 +277,8 @@ impl ProcessCapability { host_limits, root, workspace: Some(workspace), + managed: None, + managed_writers: Mutex::new(ManagedWriterRegistry::default()), table: Mutex::new(HashMap::new()), closing: AtomicBool::new(false), generation: AtomicU64::new(1), @@ -279,6 +292,127 @@ impl ProcessCapability { }) } + pub(crate) fn new_managed( + lifecycle: CapabilityLifecycle, + owner: CapabilityOwner, + limits: ProcessLimits, + view: Arc, + workspace: Arc, + managed: std::sync::Weak, + ) -> Result { + let mut capability = Self::new_adopted(lifecycle, owner, limits, workspace)?; + let inner = Arc::get_mut(&mut capability.inner).ok_or_else(|| { + CapabilityError::new( + "managed_view_unavailable", + "new process owner unexpectedly shared", + ) + })?; + inner.root = view + .adopt(view.limits()) + .map_err(|e| CapabilityError::new("managed_view_unavailable", e.to_string()))?; + inner.managed = Some(managed); + Ok(capability) + } + + pub(crate) fn settle_managed_call( + &self, + token: &str, + ) -> Result { + use crate::capabilities::vm_io::ProcessStatus; + self.inner + .lifecycle + .authorize(&self.inner.owner, token, CapabilityRisk::Read) + .map_err(CapabilityError::from)?; + if self.inner.managed.is_none() { + return Err(CapabilityError::new( + "managed_writer_required", + "managed writer owner required", + )); + } + self.inner.closing.store(true, Ordering::SeqCst); + self.inner.generation.fetch_add(1, Ordering::SeqCst); + let (attempts, owned, settled) = { + let writers = self + .inner + .managed_writers + .lock() + .unwrap_or_else(|p| p.into_inner()); + (writers.attempts, writers.handles.clone(), writers.settled) + }; + if settled { + return Ok(crate::effects::ManagedWriterState::Settled); + } + if attempts != owned.len() { + return Err(CapabilityError::new( + "managed_writer_indeterminate", + "Native spawn attempt has no positively settled owned handle", + )); + } + for handle in &owned { + if !matches!( + handle.terminal_status(), + Some(ProcessStatus::Exited { .. } | ProcessStatus::Signaled { .. }) + ) { + return Err(CapabilityError::new( + "managed_writer_indeterminate", + "owned child has no known terminal status", + )); + } + #[cfg(feature = "workspace-io-fault")] + if self.managed_settle_fault(crate::effects::ManagedEffectFault::WriterReap) { + return Err(CapabilityError::new( + "managed_writer_indeterminate", + "managed owned child reap: EIO", + )); + } + handle.reap().map_err(map_process_error)?; + #[cfg(feature = "workspace-io-fault")] + if self.managed_settle_fault(crate::effects::ManagedEffectFault::WriterDrain) { + return Err(CapabilityError::new( + "managed_writer_indeterminate", + "managed owned child drain: EIO", + )); + } + handle.shutdown().map_err(map_process_error)?; + } + if self.active_stdin_workers() != 0 { + return Err(CapabilityError::new( + "managed_writer_indeterminate", + "owned stdin workers remain", + )); + } + self.inner + .table + .lock() + .unwrap_or_else(|p| p.into_inner()) + .clear(); + let retired = { + let mut writers = self + .inner + .managed_writers + .lock() + .unwrap_or_else(|p| p.into_inner()); + writers.settled = attempts > 0; + std::mem::take(&mut writers.handles) + }; + drop(retired); + Ok(if attempts == 0 { + crate::effects::ManagedWriterState::NoProcess + } else { + crate::effects::ManagedWriterState::Settled + }) + } + + #[cfg(feature = "workspace-io-fault")] + fn managed_settle_fault(&self, fault: crate::effects::ManagedEffectFault) -> bool { + self.inner + .managed + .as_ref() + .and_then(std::sync::Weak::upgrade) + .and_then(|call| call.binding().ok()) + .is_some_and(|binding| binding.runtime.take_fault(fault)) + } + /// Spawns argv with a confined workspace cwd. pub fn spawn( &self, @@ -302,6 +436,18 @@ impl ProcessCapability { stdin: Option<&[u8]>, ) -> Result { let claims = self.authorize(token, CapabilityRisk::Execute)?; + let _operation = self.enter_managed_operation(token)?; + if self.is_closing() { + return Err(closing_error()); + } + if self.inner.managed.is_some() + && (claims.tool_name != "terminal" || !limits.close_after_initial) + { + return Err(CapabilityError::new( + "managed_open_effect_unsupported", + "managed process must close after initial foreground execution", + )); + } if self.is_closing() { return Err(closing_error()); } @@ -355,7 +501,29 @@ impl ProcessCapability { return Err(closing_error()); } let fence = self.inner.generation.load(Ordering::SeqCst); + if self.inner.managed.is_some() { + let mut writers = self + .inner + .managed_writers + .lock() + .unwrap_or_else(|p| p.into_inner()); + if writers.attempts >= 32 { + return Err(CapabilityError::new( + "budget_exceeded", + "managed owned spawn bound exceeded", + )); + } + writers.attempts += 1; + } let process = BoundedProcess::spawn(request).map_err(map_process_error)?; + if self.inner.managed.is_some() { + self.inner + .managed_writers + .lock() + .unwrap_or_else(|p| p.into_inner()) + .handles + .push(process.lifecycle_handle()); + } fire_hook(&self.inner.after_os_spawn_hook); let handle = process.lifecycle_handle(); let write_handle = handle.clone(); @@ -433,6 +601,7 @@ impl ProcessCapability { cursor: u64, limit: usize, ) -> Result { + let _operation = self.enter_managed_operation(token)?; if limit == 0 { return Err(CapabilityError::new( "invalid_request", @@ -471,6 +640,7 @@ impl ProcessCapability { handle: &str, timeout_ms: Option, ) -> Result { + let _operation = self.enter_managed_operation(token)?; let timeout_ms = timeout_ms.map(|ms| ms.min(self.inner.host_limits.timeout_ms)); let wait_deadline = timeout_ms.map(|ms| Instant::now() + Duration::from_millis(ms)); loop { @@ -575,6 +745,7 @@ impl ProcessCapability { cursor: u64, limit: usize, ) -> Result { + let _operation = self.enter_managed_operation(token)?; if limit == 0 { return Err(CapabilityError::new( "invalid_request", @@ -593,6 +764,7 @@ impl ProcessCapability { bytes: &[u8], timeout_ms: Option, ) -> Result { + let _operation = self.enter_managed_operation(token)?; let owned = self.lookup(token, handle)?; if bytes.len() > self.inner.host_limits.stdin_limit { return Err(CapabilityError::new( @@ -613,6 +785,7 @@ impl ProcessCapability { /// Closes child stdin. pub fn close_stdin(&self, token: &str, handle: &str) -> Result<(), CapabilityError> { + let _operation = self.enter_managed_operation(token)?; let owned = self.lookup(token, handle)?; match owned.handle.close_stdin() { Ok(()) @@ -624,6 +797,7 @@ impl ProcessCapability { /// Kills the process tree bound to `handle`. pub fn kill(&self, token: &str, handle: &str) -> Result<(), CapabilityError> { + let _operation = self.enter_managed_operation(token)?; let owned = self.lookup(token, handle)?; terminate_owned(&owned); Ok(()) @@ -676,6 +850,29 @@ impl ProcessCapability { self.inner.closing.load(Ordering::SeqCst) } + fn enter_managed_operation( + &self, + token: &str, + ) -> Result, CapabilityError> { + self.inner + .managed + .as_ref() + .map(|managed| { + let claims = self + .inner + .lifecycle + .authorize(&self.inner.owner, token, CapabilityRisk::Read) + .map_err(CapabilityError::from)?; + managed + .upgrade() + .ok_or_else(|| { + CapabilityError::new("managed_call_unavailable", "managed call unavailable") + })? + .enter_operation(&claims, CapabilityRisk::Read) + .map_err(|error| CapabilityError::new("managed_operation_denied", error)) + }) + .transpose() + } fn authorize(&self, token: &str, risk: CapabilityRisk) -> Result { if let Some(workspace) = &self.inner.workspace { workspace.check().map_err(|_| { @@ -687,7 +884,21 @@ impl ProcessCapability { .lifecycle .authorize(&self.inner.owner, token, risk) { - Ok(claims) => Ok(claims), + Ok(claims) => { + if let Some(managed) = &self.inner.managed { + managed + .upgrade() + .ok_or_else(|| { + CapabilityError::new( + "managed_call_unavailable", + "managed call unavailable", + ) + })? + .authorize(&claims, risk) + .map_err(|e| CapabilityError::new("managed_operation_denied", e))?; + } + Ok(claims) + } Err(error) => Err(CapabilityError::from(error)), } } diff --git a/src/capabilities/vm_io/confined_fs.rs b/src/capabilities/vm_io/confined_fs.rs index 9368201..8db6de9 100644 --- a/src/capabilities/vm_io/confined_fs.rs +++ b/src/capabilities/vm_io/confined_fs.rs @@ -1008,6 +1008,13 @@ pub struct ConfinedFsRoot { } impl ConfinedFsRoot { + /// Binding evidence only; this value grants no filesystem access. + #[cfg(unix)] + pub(crate) fn binding_identity(&self) -> Result<(u64, u64), ConfinedFsError> { + self.ensure_bound("fs::root")?; + Ok((self.root_identity.device, self.root_identity.inode)) + } + /// Opens an existing directory as a retained root capability. pub fn new(path: impl AsRef) -> Result { Self::with_limits(path, ConfinedFsLimits::default()) diff --git a/src/config.rs b/src/config.rs index 3568707..dd8f6ee 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1656,6 +1656,67 @@ fn validate_positive_bounded(value: usize, max: usize, name: &str) -> Result<(), Ok(()) } +/// Host-selected source workspace for persistent synchronous managed effects. +#[derive(Clone, Debug)] +pub struct ManagedWorkspaceConfig { + pub source_root: PathBuf, + pub quota: crate::effects::ResourceQuota, + pub retention: ManagedRetentionLimits, +} + +impl ManagedWorkspaceConfig { + /// Trusted startup configuration only; a current Native grant is still + /// required before importing or querying the configured source. + pub fn for_workspace(source_root: impl Into) -> Result { + let config = Self { + source_root: source_root.into(), + quota: crate::effects::ResourceQuota::new( + crate::effects::MAX_TREE_ENTRIES, + crate::effects::MAX_TREE_BYTES, + crate::effects::MAX_TREE_DEPTH, + ) + .map_err(|e| e.to_string())?, + retention: ManagedRetentionLimits::default(), + }; + config.validate()?; + Ok(config) + } + pub fn validate(&self) -> Result<(), String> { + validate_absolute_directory(&self.source_root, "managed_workspace.source_root")?; + canonical_workspace_root(&self.source_root) + .map_err(|_| "managed source must be an existing absolute directory")?; + if self.source_root.to_str().is_none() { + return Err("managed source must be UTF-8".into()); + } + if self.retention.max_objects == 0 + || self.retention.max_objects > 128 + || self.retention.max_bytes == 0 + || self.retention.max_bytes > 512 * 1024 * 1024 + { + return Err( + "managed retention must be positive and within the object/byte ceilings".into(), + ); + } + Ok(()) + } +} + +/// Hard admission limits; retained evidence is never evicted to admit a call. +#[derive(Clone, Copy, Debug)] +pub struct ManagedRetentionLimits { + pub max_objects: usize, + pub max_bytes: u64, +} + +impl Default for ManagedRetentionLimits { + fn default() -> Self { + Self { + max_objects: 128, + max_bytes: 512 * 1024 * 1024, + } + } +} + /// Validated configuration shared by the gateway, AgentService, and runner. #[derive(Clone, Debug)] pub struct AgentGatewayConfig { @@ -1706,11 +1767,16 @@ pub struct AgentGatewayConfig { /// binary starts the Telegram poller alongside the API server on the /// same AgentService/store. pub telegram: Option, + /// Explicit trusted startup opt-in. Direct mode remains the default. + pub managed_workspace: Option, } impl AgentGatewayConfig { /// Validates that every lifecycle bound is positive. pub fn validate(&self) -> Result<(), String> { + if let Some(managed) = &self.managed_workspace { + managed.validate()?; + } if self.max_body_bytes == 0 { return Err("max_body_bytes must be positive".to_string()); } @@ -1906,6 +1972,7 @@ impl Default for AgentGatewayConfig { approvals: ApprovalSettings::default(), fuel: Some(10_000_000), telegram: None, + managed_workspace: None, } } } diff --git a/src/effects/broker.rs b/src/effects/broker.rs new file mode 100644 index 0000000..1604a7d --- /dev/null +++ b/src/effects/broker.rs @@ -0,0 +1,1980 @@ +//! Host-private authority, immutable object preparation and per-call fences. +//! Managed cwd mapping does not isolate arbitrary program effects outside the tree. +use super::retention::{ + ManagedCallReservation, ManagedObjectLease, ManagedObjectLeaseRegistry, ManagedObjectTicket, +}; +use super::{ + journal::*, + tree_store, + types::{EffectCancellation, EffectDeadline}, +}; +use crate::{ + capabilities::vm_io::ConfinedFsRoot, + capabilities::{ + CapabilityOwner, CapabilityRisk, FilesystemCapability, FilesystemLimits, ProcessCapability, + ProcessLimits, TokenClaims, lifecycle::CapabilityLifecycle, + }, + config::ManagedWorkspaceConfig, + gateway::store::GatewayPersistence, + runtime::{RunCancellation, rss_runner::WorkspaceAuthority}, +}; +use parking_lot::Mutex; +use serde_json::{Value, json}; +use std::{ + collections::HashMap, + fs::{self, File, OpenOptions}, + io::{Read, Write}, + os::{ + fd::{AsRawFd, FromRawFd}, + unix::fs::{FileExt, MetadataExt, OpenOptionsExt, PermissionsExt}, + }, + path::{Path, PathBuf}, + sync::{ + Arc, Weak, + atomic::{AtomicBool, Ordering}, + }, +}; +#[cfg(all(test, feature = "workspace-io-fault"))] +#[path = "managed_authority_tests.rs"] +mod managed_authority_tests; + +#[derive(Clone)] +pub(crate) struct ManagedEntryContract { + pub(crate) trust: crate::plugins::PackageTrust, + pub(crate) effects: Vec, + pub(crate) single_builtin_graph: bool, +} + +#[derive(Clone)] +pub(crate) struct ManagedEffectRequirement { + pub(crate) kind: String, + pub(crate) scope: String, + pub(crate) recovery: String, +} + +#[cfg(test)] +type ManagedNativeWriteObserver = Arc) + Send + Sync>; +pub(crate) struct ManagedEffectRuntime { + #[cfg(test)] + pub(crate) canonical_observation_fixture: Mutex>, + #[cfg(test)] + native_write_observer: Mutex>, + #[cfg(test)] + native_ready_observer: Mutex>, + #[cfg(test)] + close_after_native_spawn: AtomicBool, + #[cfg(feature = "workspace-io-fault")] + fault: Mutex>, + #[cfg(feature = "workspace-io-fault")] + prepare_gate: Mutex>>, + config: ManagedWorkspaceConfig, + pub(crate) repository: ManagedEffectRepository, + root: ConfinedFsRoot, + directories: Vec, + path: PathBuf, + store_id: String, + _lock: File, + marker: File, + marker_identity: (u64, u64), + leases: Arc, + initialized: Mutex>, +} + +/// Per-store, one-shot deterministic managed protocol cuts. +#[cfg(feature = "workspace-io-fault")] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ManagedEffectFault { + BeforeIntent, + BeginAfterFirstEvent, + AfterIntent, + AfterOperationBeforePrepare, + AfterObjectsSynced, + PrepareAfterObjectRegistry, + AfterPrepared, + CommitAfterDecision, + CommitAfterHead, + CommitAfterReceipt, + CommitAfterFirstEvent, + CommitAfterMessage, + AfterDurableBeforeLive, + ResultPartialWrite, + ResultFileFsync, + TreeRename, + SealedMetadata, + TreeDirectoryFsync, + OwnedCleanup, + ObjectReferenceUnavailable, + WriterReap, + WriterDrain, +} + +#[cfg(feature = "workspace-io-fault")] +impl ManagedEffectRuntime { + pub(crate) fn arm_fault(&self, fault: ManagedEffectFault) { + *self.fault.lock() = Some(fault); + } + pub(crate) fn take_fault(&self, fault: ManagedEffectFault) -> bool { + let mut armed = self.fault.lock(); + if *armed == Some(fault) { + armed.take(); + true + } else { + false + } + } + pub(crate) fn take_sql_fault(&self) -> String { + use ManagedEffectFault::*; + let mut armed = self.fault.lock(); + match *armed { + Some( + cut @ (CommitAfterDecision + | CommitAfterHead + | CommitAfterReceipt + | CommitAfterFirstEvent + | CommitAfterMessage), + ) => { + armed.take(); + format!("{cut:?}") + } + _ => String::new(), + } + } +} + +#[cfg(feature = "workspace-io-fault")] +struct ManagedPrepareGate { + state: std::sync::Mutex<(usize, bool, bool)>, + cv: std::sync::Condvar, + deadline: std::time::Instant, + call_id: Option, +} +#[cfg(feature = "workspace-io-fault")] +pub struct ManagedPrepareGateGuard { + gate: Arc, +} +#[cfg(feature = "workspace-io-fault")] +impl ManagedEffectRuntime { + pub(crate) fn arm_prepare_gate(&self, call_id: Option<&str>) -> ManagedPrepareGateGuard { + let gate = Arc::new(ManagedPrepareGate { + state: std::sync::Mutex::new((0, false, false)), + cv: std::sync::Condvar::new(), + deadline: std::time::Instant::now() + std::time::Duration::from_secs(20), + call_id: call_id.map(str::to_owned), + }); + *self.prepare_gate.lock() = Some(Arc::clone(&gate)); + ManagedPrepareGateGuard { gate } + } +} +#[cfg(feature = "workspace-io-fault")] +impl ManagedPrepareGateGuard { + pub fn wait_arrived(&self, count: usize) -> Result<(), String> { + let mut state = self + .gate + .state + .lock() + .map_err(|_| "managed fixture gate poisoned")?; + while state.0 < count && !state.2 { + let remaining = self + .gate + .deadline + .saturating_duration_since(std::time::Instant::now()); + if remaining.is_zero() { + return Err("managed fixture arrival deadline".into()); + } + state = self + .gate + .cv + .wait_timeout(state, remaining) + .map_err(|_| "managed fixture gate poisoned")? + .0; + } + if state.2 { + Err("managed fixture gate aborted".into()) + } else { + Ok(()) + } + } + pub fn release(&self) { + let mut state = self.gate.state.lock().unwrap_or_else(|p| p.into_inner()); + state.1 = true; + self.gate.cv.notify_all(); + } +} +#[cfg(feature = "workspace-io-fault")] +impl Drop for ManagedPrepareGateGuard { + fn drop(&mut self) { + let mut state = self.gate.state.lock().unwrap_or_else(|p| p.into_inner()); + state.2 = true; + self.gate.cv.notify_all(); + } +} +#[cfg(feature = "workspace-io-fault")] +impl ManagedPrepareGate { + fn arrive(&self, deadline: std::time::Instant) -> Result<(), String> { + let deadline = deadline.min(self.deadline); + let mut state = self + .state + .lock() + .map_err(|_| "managed fixture gate poisoned")?; + if state.1 { + return Ok(()); + } + if state.2 { + return Err("managed fixture gate aborted".into()); + } + state.0 += 1; + self.cv.notify_all(); + while !state.1 && !state.2 { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + if remaining.is_zero() { + return Err("managed fixture preparation deadline".into()); + } + state = self + .cv + .wait_timeout(state, remaining) + .map_err(|_| "managed fixture gate poisoned")? + .0; + } + if state.2 && !state.1 { + Err("managed fixture gate aborted".into()) + } else { + Ok(()) + } + } +} + +#[cfg(all(test, feature = "workspace-io-fault"))] +mod managed_native_tests { + use super::*; + use crate::{AdmitRunRequest, AgentGatewayConfig, AgentGatewayState, ScriptedProvider}; + #[tokio::test(flavor = "multi_thread")] + async fn real_rss_write_remains_inflight_and_closing_denies_new_native_operations() { + let root = PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()).join( + format!("managed-inflight-{}", uuid::Uuid::new_v4().simple()), + ); + let workspace = root.join("workspace"); + let home = root.join("home"); + fs::create_dir_all(&workspace).unwrap(); + fs::create_dir_all(&home).unwrap(); + for path in [&root, &workspace, &home] { + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); + } + fs::write(home.join("config.yaml"),format!("version: 1\nmodel:\n provider: local-test\n model: synthetic\nproviders:\n local-test:\n protocol: codex-responses\n base_url: https://127.0.0.1:1\nworkspaces:\n allowed_roots:\n - '{}'\n default: '{}'\n",workspace.display(),workspace.display())).unwrap(); + let mut config = AgentGatewayConfig { + managed_workspace: Some(ManagedWorkspaceConfig { + source_root: workspace.clone(), + quota: super::super::ResourceQuota::new(1000, 1048576, 16).unwrap(), + retention: crate::config::ManagedRetentionLimits::default(), + }), + ..Default::default() + }; + config.approvals.write = "allow".into(); + let state = + AgentGatewayState::with_sqlite_path(config.clone(), root.join("gateway.sqlite")) + .unwrap() + .with_workspace_home(&home); + let service = state.service(); + let runtime = service.managed_runtime_for_test().unwrap(); + let gate = Arc::new(ManagedPrepareGate { + state: std::sync::Mutex::new((0, false, false)), + cv: std::sync::Condvar::new(), + deadline: std::time::Instant::now() + std::time::Duration::from_secs(20), + call_id: None, + }); + let release = ManagedPrepareGateGuard { + gate: Arc::clone(&gate), + }; + let (tx, rx) = std::sync::mpsc::sync_channel(1); + *runtime.native_write_observer.lock() = Some(Arc::new(move |call| { + tx.send(Arc::clone(&call)).unwrap(); + gate.arrive(call.binding().unwrap().cancel.deadline_instant().unwrap()) + .unwrap(); + })); + let provider = Arc::new(ScriptedProvider::new()); + provider.push_ok(json!({"text":"","tool_calls":[{"id":"inflight","name":"write_file","arguments":{"path":"during.txt","content":"actual original RSS bytes"}}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2},"reasoning":"","stop_reason":"stop"})); + service.inject_provider_host(provider); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"write under an observed Native operation"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + let worker = tokio::spawn( + service + .clone() + .run_worker(run.run_id.clone(), "ignored".into()), + ); + let call = rx.recv_timeout(std::time::Duration::from_secs(20)).unwrap(); + release.wait_arrived(1).unwrap(); + let active = call.active_native_operations(); + call.fence_for_prepare().unwrap(); + let adapter = call.adapters.lock().clone().unwrap(); + let late = adapter + .filesystem + .write_atomic(&adapter.token, "late.txt", "*", b"denied"); + release.release(); + worker.await.unwrap(); + drop(release); + let view = fs::read_dir(root.join(".gateway.sqlite.effects/views")) + .unwrap() + .next() + .unwrap() + .unwrap() + .path(); + assert_eq!( + fs::read(view.join("during.txt")).unwrap(), + b"actual original RSS bytes" + ); + assert!(!view.join("late.txt").exists()); + assert!(late.is_err()); + assert!(!workspace.join("during.txt").exists()); + assert_eq!( + active, 1, + "an admitted actual Native write must remain counted until its IO returns" + ); + assert_eq!(call.active_native_operations(), 0); + let request = ManagedEffectGetRequest { + owner: call.identity.owner.clone(), + call_id: call.identity.call_id.clone(), + }; + drop(adapter); + drop(call); + drop(runtime); + service.shutdown().await.unwrap(); + drop(service); + drop(state); + let reopened = AgentGatewayState::with_sqlite_path(config, root.join("gateway.sqlite")) + .unwrap() + .with_workspace_home(&home); + assert!(matches!( + reopened.service().replay_managed_call(request).unwrap(), + ManagedCallReplay::Indeterminate { .. } + )); + reopened.service().shutdown().await.unwrap(); + } + #[tokio::test(flavor = "multi_thread")] + async fn actual_spawn_closed_before_table_insertion_still_requires_owned_writer_settlement() { + let root = PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()).join( + format!("managed-owned-child-{}", uuid::Uuid::new_v4().simple()), + ); + let workspace = root.join("workspace"); + let home = root.join("home"); + fs::create_dir_all(&workspace).unwrap(); + fs::create_dir_all(&home).unwrap(); + for path in [&root, &workspace, &home] { + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); + } + fs::write(home.join("config.yaml"),format!("version: 1\nmodel:\n provider: local-test\n model: synthetic\nproviders:\n local-test:\n protocol: codex-responses\n base_url: https://127.0.0.1:1\nworkspaces:\n allowed_roots:\n - '{}'\n default: '{}'\n",workspace.display(),workspace.display())).unwrap(); + let mut config = AgentGatewayConfig { + managed_workspace: Some(ManagedWorkspaceConfig { + source_root: workspace, + quota: super::super::ResourceQuota::new(1000, 1048576, 16).unwrap(), + retention: crate::config::ManagedRetentionLimits::default(), + }), + ..Default::default() + }; + config.approvals.process = "allow".into(); + let state = + AgentGatewayState::with_sqlite_path(config.clone(), root.join("gateway.sqlite")) + .unwrap() + .with_workspace_home(&home); + let service = state.service(); + service + .managed_runtime_for_test() + .unwrap() + .close_after_native_spawn + .store(true, Ordering::SeqCst); + let provider = Arc::new(ScriptedProvider::new()); + provider.push_ok(json!({"text":"","tool_calls":[{"id":"spawn","name":"terminal","arguments":{"argv":["/bin/sh","-c","exit 0"],"timeout_ms":1000}}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2},"reasoning":"","stop_reason":"stop"})); + provider.push_ok(json!({"text":"done","tool_calls":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2},"reasoning":"","stop_reason":"stop"})); + service.inject_provider_host(provider); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"actual child close fence"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + service + .clone() + .run_worker(run.run_id.clone(), "ignored".into()) + .await; + let request = ManagedEffectGetRequest { + owner: ManagedOwnerScope { + profile: "gateway".into(), + session_id: run.session_id.clone(), + run_id: run.run_id.clone(), + }, + call_id: "spawn".into(), + }; + let record = state + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(); + assert_eq!( + record.prepared.as_ref().unwrap().writer_state, + ManagedWriterState::Settled, + "an actual spawned child cannot be certified by an empty process table" + ); + assert_eq!( + record.outcome.as_ref().unwrap().disposition, + ManagedPublicationStatus::NotPublished + ); + service.shutdown().await.unwrap(); + drop(service); + drop(state); + let reopened = AgentGatewayState::with_sqlite_path(config, root.join("gateway.sqlite")) + .unwrap() + .with_workspace_home(&home); + assert_eq!( + reopened + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(), + record + ); + reopened.service().shutdown().await.unwrap(); + } +} + +fn error(value: impl std::fmt::Debug) -> String { + format!("{value:?}") +} +pub(crate) fn fresh(prefix: &str) -> String { + format!("{prefix}-{}", uuid::Uuid::new_v4().simple()) +} +fn private(path: &Path) -> Result<(), String> { + let meta = fs::symlink_metadata(path).map_err(error)?; + if !meta.is_dir() || meta.uid() != unsafe { libc::geteuid() } || meta.mode() & 0o022 != 0 { + return Err("managed store directory ownership/mode/type invalid".into()); + } + Ok(()) +} +fn file_identity(file: &File) -> Result<(u64, u64), String> { + let meta = file.metadata().map_err(error)?; + if !meta.is_file() + || meta.uid() != unsafe { libc::geteuid() } + || meta.mode() & 0o022 != 0 + || meta.nlink() != 1 + { + return Err("managed store file ownership/mode/type invalid".into()); + } + Ok((meta.dev(), meta.ino())) +} +fn open_private_file(path: &Path, create: bool) -> Result { + OpenOptions::new() + .read(true) + .write(create) + .create_new(create) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(path) + .map_err(|e| format!("managed private file open (create={create}): {e}")) +} +impl ManagedEffectRuntime { + pub(crate) fn open( + config: ManagedWorkspaceConfig, + database: &Path, + persistence: Arc, + ) -> Result, String> { + config.validate()?; + if database == Path::new(":memory:") || !database.is_absolute() { + return Err("managed workspace requires an absolute persistent database".into()); + } + let source = fs::canonicalize(&config.source_root).map_err(error)?; + if !config.source_root.is_absolute() || source == Path::new("/") { + return Err("managed source must be an absolute non-root directory".into()); + } + let parent = database.parent().ok_or("database parent missing")?; + let filename = database + .file_name() + .ok_or("database filename missing")? + .to_str() + .ok_or("database filename invalid")?; + let path = parent.join(format!(".{filename}.effects")); + let packages = parent.join(format!(".{filename}.tool-packages")); + for excluded in [database, path.as_path(), packages.as_path()] { + if excluded.starts_with(&source) || source.starts_with(excluded) { + return Err("managed source overlaps persistent state".into()); + } + } + let created = match fs::create_dir(&path) { + Ok(()) => true, + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => false, + Err(e) => return Err(error(e)), + }; + if created { + fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).map_err(error)?; + } + private(&path)?; + let root = ConfinedFsRoot::new(&path).map_err(error)?; + let lockpath = path.join("lease"); + let lock = if created { + open_private_file(&lockpath, true)? + } else { + OpenOptions::new() + .read(true) + .write(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(&lockpath) + .map_err(error)? + }; + file_identity(&lock)?; + if unsafe { libc::flock(lock.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 { + return Err("managed_store_in_use".into()); + } + let markerpath = path.join("store-id"); + let mut marker = open_private_file(&markerpath, created)?; + let store_id = if created { + let id = fresh("store"); + marker.write_all(id.as_bytes()).map_err(error)?; + marker.sync_all().map_err(error)?; + id + } else { + let mut id = String::new(); + Read::by_ref(&mut marker) + .take(128) + .read_to_string(&mut id) + .map_err(error)?; + id + }; + validate_id("store", &store_id)?; + let mut directories = Vec::new(); + for name in ["trees", "results", "details", "views", "staging"] { + let child = path.join(name); + if created { + fs::create_dir(&child).map_err(error)?; + fs::set_permissions(&child, fs::Permissions::from_mode(0o700)).map_err(error)?; + } + private(&child)?; + tree_store::fsync_dir(&child).map_err(error)?; + directories.push(root.subroot(name, root.limits()).map_err(error)?); + } + tree_store::fsync_dir(&path).map_err(error)?; + if created { + tree_store::fsync_dir(parent).map_err(error)?; + } + let marker_identity = file_identity(&marker)?; + let leases = + ManagedObjectLeaseRegistry::open(&config, &root, persistence.as_ref(), &store_id)?; + Ok(Arc::new(Self { + #[cfg(test)] + canonical_observation_fixture: Mutex::new(None), + #[cfg(test)] + native_write_observer: Mutex::new(None), + #[cfg(test)] + native_ready_observer: Mutex::new(None), + #[cfg(test)] + close_after_native_spawn: AtomicBool::new(false), + #[cfg(feature = "workspace-io-fault")] + fault: Mutex::new(None), + #[cfg(feature = "workspace-io-fault")] + prepare_gate: Mutex::new(None), + config, + repository: ManagedEffectRepository::new(persistence), + root, + directories, + path, + store_id, + _lock: lock, + marker, + marker_identity, + leases, + initialized: Mutex::new(None), + })) + } + pub(crate) fn check(&self) -> Result<(), String> { + if !self.root.revalidate_binding() { + return Err("managed store root replaced".into()); + } + if self + .directories + .iter() + .any(|directory| !directory.revalidate_binding()) + { + return Err("managed store object namespace replaced".into()); + } + let marker = open_private_file(&self.path.join("store-id"), false)?; + if file_identity(&marker)? != self.marker_identity + || file_identity(&self.marker)? != self.marker_identity + { + return Err("managed store marker replaced".into()); + } + Ok(()) + } + fn prepare_tree( + &self, + source: &ConfinedFsRoot, + cancel: &RunCancellation, + seal: bool, + reservation: Option<&ManagedCallReservation>, + ) -> Result { + self.check()?; + if cancel.requested().is_some() { + return Err("managed effect cancelled".into()); + } + let deadline = EffectDeadline::at( + cancel + .deadline_instant() + .ok_or("managed deadline missing")?, + ); + let mut ticket = match reservation { + Some(reservation) => reservation.take_object(self.config.quota.max_bytes)?, + None => self.leases.single(self.config.quota.max_bytes)?, + }; + let id = fresh("tree"); + ticket.start(&id)?; + let staging = self.path.join("staging").join(&id); + let original = cancel.clone(); + let cancellation = EffectCancellation::linked(move || { + original.requested().is_some() || original.deadline_passed() + }); + let copied = tree_store::copy_tree_from_fd( + &source.duplicate_descriptor().map_err(error)?, + &staging, + &self.config.quota, + &cancellation, + &deadline, + seal, + ) + .map_err(error)?; + let final_path = self.path.join("trees").join(&id); + #[cfg(feature = "workspace-io-fault")] + if reservation.is_some() && self.take_fault(ManagedEffectFault::TreeRename) { + return Err("managed IO rename: EIO".into()); + } + fs::rename(&staging, &final_path) + .map_err(|e| format!("managed sealed tree rename: {e}"))?; + #[cfg(feature = "workspace-io-fault")] + if reservation.is_some() && self.take_fault(ManagedEffectFault::SealedMetadata) { + return Err("managed IO sealed metadata: EIO".into()); + } + if seal { + tree_store::chmod_dir(&final_path, 0o555).map_err(error)?; + tree_store::fsync_dir(&final_path).map_err(error)?; + } + tree_store::fsync_dir(&self.path.join("staging")).map_err(error)?; + #[cfg(feature = "workspace-io-fault")] + if reservation.is_some() && self.take_fault(ManagedEffectFault::TreeDirectoryFsync) { + return Err("managed IO post-rename directory fsync: EIO".into()); + } + tree_store::fsync_dir(&self.path.join("trees")).map_err(error)?; + let root = self + .root + .subroot(&format!("trees/{id}"), self.root.limits()) + .map_err(error)?; + if !root.revalidate_binding() { + return Err("managed secure reopen failed".into()); + } + self.check()?; + let lease = ticket.seal(copied.bytes)?; + Ok(NativePreparedObject { + reference: ManagedObjectRef { + store_id: self.store_id.clone(), + object_id: id, + object_kind: ManagedObjectKind::Tree, + byte_count: copied.bytes.to_string(), + entry_count: copied.entries.to_string(), + object_format: "agent.managed-tree/v1".into(), + }, + _lease: lease, + handle: ManagedReadyHandle::Tree(root), + }) + } + fn result_object( + &self, + result: &CanonicalToolResultJson, + budget: usize, + reservation: &ManagedCallReservation, + ) -> Result { + self.blob_object( + ManagedObjectKind::CanonicalResult, + result.as_str().as_bytes(), + budget, + reservation, + ) + } + fn blob_object( + &self, + kind: ManagedObjectKind, + bytes: &[u8], + budget: usize, + reservation: &ManagedCallReservation, + ) -> Result { + self.check()?; + if bytes.len() > budget { + return Err("managed canonical output exceeds original limit".into()); + } + let (prefix, namespace, format) = match kind { + ManagedObjectKind::CanonicalResult => { + ("result", "results", "agent.canonical-tool-result/v1") + } + ManagedObjectKind::IntentDetails => ("intent", "details", "agent.intent-details/v1"), + ManagedObjectKind::Tree => return Err("managed blob kind invalid".into()), + }; + let mut ticket = reservation.take_object(bytes.len() as u64)?; + let id = fresh(prefix); + let staging = self.path.join("staging").join(&id); + ticket.start(&id)?; + let mut file = open_private_file(&staging, true)?; + #[cfg(feature = "workspace-io-fault")] + if kind == ManagedObjectKind::CanonicalResult + && self.take_fault(ManagedEffectFault::ResultPartialWrite) + { + file.write_all(&bytes[..bytes.len() / 2]).map_err(error)?; + return Err("managed IO partial canonical result write: EIO".into()); + } + file.write_all(bytes).map_err(error)?; + #[cfg(feature = "workspace-io-fault")] + if kind == ManagedObjectKind::CanonicalResult + && self.take_fault(ManagedEffectFault::ResultFileFsync) + { + return Err("managed IO canonical result file fsync: EIO".into()); + } + file.sync_all().map_err(error)?; + file.set_permissions(fs::Permissions::from_mode(0o400)) + .map_err(error)?; + file.sync_all().map_err(error)?; + fs::rename(&staging, self.path.join(namespace).join(&id)).map_err(error)?; + tree_store::fsync_dir(&self.path.join("staging")).map_err(error)?; + tree_store::fsync_dir(&self.path.join(namespace)).map_err(error)?; + let reopened = open_private_file(&self.path.join(namespace).join(&id), false)?; + if file_identity(&file)? != file_identity(&reopened)? { + return Err("managed result identity changed".into()); + } + self.check()?; + let lease = ticket.seal(bytes.len() as u64)?; + Ok(NativePreparedObject { + reference: ManagedObjectRef { + store_id: self.store_id.clone(), + object_id: id, + object_kind: kind, + byte_count: bytes.len().to_string(), + entry_count: "1".into(), + object_format: format.into(), + }, + _lease: lease, + handle: ManagedReadyHandle::Blob(reopened), + }) + } + fn open_result(&self, object: &ManagedObjectRef) -> Result<(File, u64), String> { + self.open_blob(object, ManagedObjectKind::CanonicalResult) + } + fn open_blob( + &self, + object: &ManagedObjectRef, + kind: ManagedObjectKind, + ) -> Result<(File, u64), String> { + let (prefix, namespace, format) = match kind { + ManagedObjectKind::CanonicalResult => { + ("result", "results", "agent.canonical-tool-result/v1") + } + ManagedObjectKind::IntentDetails => ("intent", "details", "agent.intent-details/v1"), + ManagedObjectKind::Tree => return Err("managed blob kind invalid".into()), + }; + self.check()?; + validate_id(prefix, &object.object_id)?; + if object.store_id != self.store_id + || object.object_kind != kind + || object.object_format != format + || object.entry_count != "1" + { + return Err("managed_object_unavailable".into()); + } + let directory = self.root.open_directory(namespace).map_err(error)?; + let name = std::ffi::CString::new(object.object_id.as_bytes()).map_err(error)?; + let fd = unsafe { + libc::openat( + directory.as_raw_fd(), + name.as_ptr(), + libc::O_RDONLY | libc::O_NOFOLLOW | libc::O_CLOEXEC, + ) + }; + if fd < 0 { + return Err("managed_object_unavailable".into()); + } + let file = unsafe { File::from_raw_fd(fd) }; + file_identity(&file)?; + let size = object.byte_count.parse::().map_err(error)?; + let metadata = file.metadata().map_err(error)?; + if size > crate::config::MAX_TOOL_OUTPUT_BYTES as u64 + || metadata.len() != size + || metadata.mode() & 0o222 != 0 + { + return Err("managed result size/mode invalid".into()); + } + self.check()?; + Ok((file, size)) + } + fn original_arguments(&self, identity: &ManagedArgumentIdentity) -> Result { + match identity { + ManagedArgumentIdentity::Inline { + canonical_arguments, + } => Ok(canonical_arguments.clone()), + ManagedArgumentIdentity::Object { details } => { + let (mut file, size) = self.open_blob(details, ManagedObjectKind::IntentDetails)?; + let mut bytes = Vec::new(); + Read::by_ref(&mut file) + .take(size + 1) + .read_to_end(&mut bytes) + .map_err(error)?; + if bytes.len() as u64 != size { + return Err("managed intent details changed".into()); + } + self.check()?; + String::from_utf8(bytes) + .map_err(|_| "managed intent details encoding invalid".into()) + } + } + } + pub(crate) fn read_result_chunk( + &self, + object: &ManagedObjectRef, + offset: u64, + limit: usize, + ) -> Result { + if limit == 0 || limit > 131072 { + return Err("managed result chunk limit must be 1..=131072".into()); + } + let (file, size) = self.open_result(object)?; + if offset > size { + return Err("managed result offset exceeds total bytes".into()); + } + let want = usize::try_from((size - offset).min(limit as u64)).map_err(error)?; + let mut bytes = vec![0; want]; + let mut received = 0; + while received < want { + let count = file + .read_at(&mut bytes[received..], offset + received as u64) + .map_err(error)?; + if count == 0 { + return Err("managed immutable result unexpectedly shortened".into()); + } + received += count; + } + self.check()?; + file_identity(&file)?; + let next_offset = offset + received as u64; + Ok(ManagedResultChunk { + bytes, + next_offset, + eof: next_offset == size, + total_bytes: size, + }) + } + pub(crate) fn read_result(&self, object: &ManagedObjectRef) -> Result { + let (mut file, size) = self.open_result(object)?; + let mut bytes = Vec::new(); + Read::by_ref(&mut file) + .take(size + 1) + .read_to_end(&mut bytes) + .map_err(error)?; + let result: Value = serde_json::from_slice(&bytes).map_err(error)?; + CanonicalToolResultJson::from_value(&result)?; + self.check()?; + Ok(result) + } + pub(crate) fn authorize_query( + &self, + authority: &WorkspaceAuthority, + identity: &ManagedWorkspaceIdentity, + ) -> Result<(), String> { + authority.check().map_err(error)?; + self.check()?; + if identity.store_id != self.store_id + || fs::canonicalize(&self.config.source_root).map_err(error)? + != Path::new(authority.canonical_path()) + { + return Err("managed query unavailable".into()); + } + let (device, inode) = authority + .grant() + .held() + .root() + .binding_identity() + .map_err(error)?; + if device.to_string() != identity.source_identity.device_decimal + || inode.to_string() != identity.source_identity.inode_decimal + { + return Err("managed source replaced".into()); + } + Ok(()) + } + + pub(crate) fn authorize_workspace_query( + &self, + authority: &WorkspaceAuthority, + profile: &str, + workspace_id: &str, + ) -> Result<(), String> { + validate_id("ws", workspace_id)?; + let (device, inode) = authority + .grant() + .held() + .root() + .binding_identity() + .map_err(error)?; + let source = DirectoryIdentity { + device_decimal: device.to_string(), + inode_decimal: inode.to_string(), + }; + let record = self + .repository + .workspace(&self.store_id, profile, &source)? + .ok_or("managed configured workspace unavailable")?; + if record.identity.workspace_id != workspace_id { + return Err("managed configured workspace mismatch".into()); + } + self.authorize_query(authority, &record.identity) + } + + pub(crate) fn verify_tree(&self, tree: &str) -> Result<(), String> { + self.check()?; + validate_id("tree", tree)?; + self.root + .subroot(&format!("trees/{tree}"), self.root.limits()) + .map_err(error)?; + Ok(()) + } + + pub(crate) fn prepare_run_binding( + self: &Arc, + authority: Arc, + owner: ManagedOwnerScope, + code: ManagedCodeIdentity, + contract: ManagedEntryContract, + cancel: RunCancellation, + ) -> Result, String> { + authority.check().map_err(error)?; + self.check()?; + if !contract.single_builtin_graph + || contract.trust != crate::plugins::PackageTrust::EmbeddingOwnedBuiltin + { + return Err("managed graph must be captured embedding-owned single builtin".into()); + } + let configured = fs::canonicalize(&self.config.source_root).map_err(error)?; + if configured != Path::new(authority.canonical_path()) { + return Err("managed_workspace_mismatch".into()); + } + let source = authority.grant(); + let (device, inode) = source.held().root().binding_identity().map_err(error)?; + let configured_root = ConfinedFsRoot::new(&configured).map_err(error)?; + if configured_root.binding_identity().map_err(error)? != (device, inode) { + return Err("managed source binding mismatch".into()); + } + let identity = DirectoryIdentity { + device_decimal: device.to_string(), + inode_decimal: inode.to_string(), + }; + let cached = self.initialized.lock().clone(); + let workspace = match cached { + Some(record) => record, + None => { + let existing = + self.repository + .workspace(&self.store_id, &owner.profile, &identity)?; + let record = match existing { + Some(record) => record, + None => { + let seed = self.prepare_tree(source.held().root(), &cancel, true, None)?; + let record = ManagedWorkspaceRecord { + format: WORKSPACE_FORMAT.into(), + schema_version: 1, + identity: ManagedWorkspaceIdentity { + store_id: self.store_id.clone(), + workspace_id: fresh("ws"), + branch_id: fresh("br"), + source_identity: identity.clone(), + }, + profile: owner.profile.clone(), + seed_root: seed.reference.clone(), + max_entries: self.config.quota.max_entries, + max_bytes: self.config.quota.max_bytes, + max_depth: self.config.quota.max_depth, + }; + self.repository.command_with_cancellation("effect.workspace_register",&json!({"store_id":self.store_id,"profile":owner.profile,"source_device":identity.device_decimal,"source_inode":identity.inode_decimal,"workspace_json":serde_json::to_string(&record).map_err(error)?}),&cancel)?; + self.repository + .workspace(&self.store_id, &owner.profile, &identity)? + .ok_or("workspace registration missing")? + } + }; + *self.initialized.lock() = Some(record.clone()); + record + } + }; + if workspace.identity.source_identity != identity { + return Err("managed source identity replaced".into()); + } + if workspace.max_entries != self.config.quota.max_entries + || workspace.max_bytes != self.config.quota.max_bytes + || workspace.max_depth != self.config.quota.max_depth + { + return Err("managed configured quota differs from durable workspace".into()); + } + validate_id("tree", &workspace.seed_root.object_id)?; + self.root + .subroot( + &format!("trees/{}", workspace.seed_root.object_id), + self.root.limits(), + ) + .map_err(error)?; + authority.check().map_err(error)?; + Ok(Arc::new(ManagedRunBinding { + runtime: Arc::clone(self), + authority, + owner, + code, + contract, + cancel, + workspace, + calls: Mutex::new(HashMap::new()), + admitted_limits: Mutex::new(None), + })) + } +} +pub(crate) fn validate_id(prefix: &str, id: &str) -> Result<(), String> { + if id.strip_prefix(&format!("{prefix}-")).is_none_or(|rest| { + rest.len() != 32 + || !rest + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + }) { + Err("managed identifier malformed".into()) + } else { + Ok(()) + } +} +#[derive(Clone, Copy, PartialEq)] +struct ManagedAdapterLimits { + filesystem: FilesystemLimits, + process: ProcessLimits, + output_budget: usize, +} +pub(crate) struct ManagedRunBinding { + pub(crate) runtime: Arc, + pub(crate) authority: Arc, + pub(crate) owner: ManagedOwnerScope, + pub(crate) code: ManagedCodeIdentity, + contract: ManagedEntryContract, + pub(crate) cancel: RunCancellation, + pub(crate) workspace: ManagedWorkspaceRecord, + calls: Mutex>>, + admitted_limits: Mutex>, +} +pub(crate) struct ManagedLiveReplay { + pub(crate) outcome: ManagedCommitOutcome, + pub(crate) result: Value, +} +impl ManagedRunBinding { + pub(crate) fn matches_contract(&self, contract: Option<&ManagedEntryContract>) -> bool { + contract.is_some_and(|contract| { + contract.trust == self.contract.trust + && contract.single_builtin_graph == self.contract.single_builtin_graph + && contract.effects.len() == self.contract.effects.len() + && contract + .effects + .iter() + .zip(&self.contract.effects) + .all(|(left, right)| { + left.kind == right.kind + && left.scope == right.scope + && left.recovery == right.recovery + }) + }) + } + pub(crate) fn replay_live( + &self, + call_id: &str, + tool_name: &str, + arguments: Option<&Value>, + canonical: Option<&str>, + ) -> Result, String> { + self.check()?; + let Some(record) = self.runtime.repository.get(&ManagedEffectGetRequest { + owner: self.owner.clone(), + call_id: call_id.into(), + })? + else { + return Ok(None); + }; + let identity = &record.identity; + if identity.owner != self.owner + || identity.call_id != call_id + || identity.tool_name != tool_name + || identity.code != self.code + || identity.workspace != self.workspace.identity + || identity.authorization_generation != self.authority.generation() + { + return Err("managed replay identity mismatch".into()); + } + let head = self.runtime.repository.head(&self.workspace.identity)?; + if identity.epoch != head.epoch { + return Err("managed replay epoch mismatch".into()); + } + let original = self + .runtime + .original_arguments(&identity.argument_identity)?; + if canonical.is_some_and(|canonical| canonical != original) { + return Err("managed replay argument identity mismatch".into()); + } + if let Some(arguments) = arguments { + let parsed: Value = serde_json::from_str(&original) + .map_err(|_| "managed original arguments corrupt")?; + if &parsed != arguments { + return Err("managed replay original parent mismatch".into()); + } + } + let Some(outcome) = record.outcome else { + return Err(if record.prepared.is_some() { + "managed_effect_staged" + } else { + "managed_effect_indeterminate" + } + .into()); + }; + if !matches!( + outcome.disposition, + ManagedPublicationStatus::Committed + | ManagedPublicationStatus::ReadOnly + | ManagedPublicationStatus::NotPublished + | ManagedPublicationStatus::Conflict + ) { + return Err("managed replay final disposition invalid".into()); + } + self.runtime.verify_tree( + outcome + .publication + .candidate_head + .as_deref() + .unwrap_or(&outcome.publication.base_head), + )?; + let result = self.runtime.read_result(&outcome.canonical_result_ref)?; + self.check()?; + Ok(Some(ManagedLiveReplay { outcome, result })) + } + pub(crate) fn bind_admitted_limits( + &self, + filesystem: FilesystemLimits, + process: ProcessLimits, + output_budget: usize, + ) -> Result<(), String> { + let limits = ManagedAdapterLimits { + filesystem, + process, + output_budget, + }; + let mut retained = self.admitted_limits.lock(); + if retained.as_ref().is_some_and(|old| *old != limits) { + return Err("managed admitted ceilings changed".into()); + } + *retained = Some(limits); + Ok(()) + } + pub(crate) fn check(&self) -> Result<(), String> { + self.authority.check().map_err(error)?; + self.runtime.check()?; + if self.cancel.requested().is_some() || self.cancel.deadline_passed() { + return Err("managed original deadline/cancellation".into()); + } + Ok(()) + } + pub(crate) fn call(&self, id: &str) -> Option> { + self.calls.lock().get(id).cloned() + } + pub(crate) fn register( + self: &Arc, + call_id: &str, + tool_name: &str, + arguments: &str, + risk: &str, + deadline_ms: u64, + ) -> Result, String> { + self.check()?; + if self.calls.lock().contains_key(call_id) { + return Err("managed exact call already registered".into()); + } + let required = match tool_name { + "read_file" | "search_files" => "workspace.fs.read", + "write_file" | "patch" => "workspace.fs.write", + "terminal" => "process.spawn", + "process" => return Err("managed_open_effect_unsupported".into()), + _ => return Err("managed tool unsupported".into()), + }; + let scope = if required == "process.spawn" { + "bound_process" + } else { + "bound_workspace" + }; + if !self.contract.effects.iter().any(|effect| { + effect.kind == required + && effect.scope == scope + && (effect.recovery == "none" + || (required == "workspace.fs.write" && effect.recovery == "checkpoint")) + }) { + return Err("managed undeclared effect".into()); + } + let args: Value = serde_json::from_str(arguments).map_err(error)?; + if tool_name == "terminal" && args.get("background").and_then(Value::as_bool) == Some(true) + { + return Err("managed_open_effect_unsupported".into()); + } + let head = self.runtime.repository.head(&self.workspace.identity)?; + let parent = self + .runtime + .repository + .object_metadata(&ManagedObjectReferencesRequest { + store_id: self.workspace.identity.store_id.clone(), + object_id: head.head_object_id.clone(), + })? + .ok_or("managed parent object metadata missing")?; + if parent.object_kind != ManagedObjectKind::Tree + || parent.object_format != "agent.managed-tree/v1" + { + return Err("managed parent object kind/format invalid".into()); + } + let limits = self + .admitted_limits + .lock() + .as_ref() + .copied() + .ok_or("managed admitted ceilings missing")?; + let writable = matches!(tool_name, "write_file" | "patch" | "terminal"); + let multiplier = if writable { 2 } else { 1 }; + let large = serde_json::to_vec(&ManagedArgumentIdentity::Inline { + canonical_arguments: arguments.into(), + }) + .map_err(error)? + .len() + > 4096; + if arguments.len() > crate::config::MAX_TOOL_OUTPUT_BYTES { + return Err("managed original arguments exceed bounded detail storage".into()); + } + let detail_bytes = if large { arguments.len() as u64 } else { 0 }; + let bytes = self + .runtime + .config + .quota + .max_bytes + .checked_mul(multiplier) + .and_then(|bytes| { + bytes.checked_add((limits.output_budget as u64).checked_mul(multiplier)?) + }) + .and_then(|bytes| bytes.checked_add(detail_bytes)) + .ok_or("managed reservation overflow")?; + let reservation = self + .runtime + .leases + .reserve((if writable { 4 } else { 2 }) + usize::from(large), bytes)?; + let detail = if large { + Some(self.runtime.blob_object( + ManagedObjectKind::IntentDetails, + arguments.as_bytes(), + crate::config::MAX_TOOL_OUTPUT_BYTES, + &reservation, + )?) + } else { + None + }; + let argument_identity = match &detail { + Some(object) => ManagedArgumentIdentity::Object { + details: object.reference.clone(), + }, + None => ManagedArgumentIdentity::Inline { + canonical_arguments: arguments.into(), + }, + }; + let identity = ManagedCallIdentity { + owner: self.owner.clone(), + call_id: call_id.into(), + tool_name: tool_name.into(), + argument_identity, + code: self.code.clone(), + workspace: self.workspace.identity.clone(), + epoch: head.epoch, + expected_head: head.head_object_id, + expected_head_version: head.head_version, + original_deadline_ms: deadline_ms, + authorization_generation: self.authority.generation(), + approved_risk: risk.into(), + approval_basis: if risk == "read" { + ManagedApprovalBasis::ReadWithoutAttestation + } else { + ManagedApprovalBasis::GuardianAttested + }, + }; + let call = Arc::new(ManagedCallContext { + binding: Arc::downgrade(self), + identity, + effect_id: fresh("effect"), + receipt_id: fresh("receipt"), + phase: Mutex::new(CallPhase::Registered), + adapters: Mutex::new(None), + prepared: Mutex::new(None), + outcome: Mutex::new(None), + closing: AtomicBool::new(false), + native_operations: Mutex::new(0), + operation_drained: parking_lot::Condvar::new(), + reservation, + parent, + original_arguments: arguments.into(), + _detail_object: detail, + }); + self.calls.lock().insert(call_id.into(), Arc::clone(&call)); + Ok(call) + } +} +#[derive(Clone, Copy, PartialEq)] +enum CallPhase { + Registered, + Executing, + Preparing, + Prepared, + Final, + Unknown, +} +struct NativePreparedObject { + reference: ManagedObjectRef, + _lease: ManagedObjectLease, + handle: ManagedReadyHandle, +} +enum ManagedReadyHandle { + Tree(ConfinedFsRoot), + Blob(File), +} +pub(crate) enum ManagedViewRetention { + Reclaimed, + Retained, +} +pub(crate) struct ManagedAdapters { + lifecycle: CapabilityLifecycle, + pub(crate) filesystem: Arc, + pub(crate) processes: Arc, + root: Arc, + work: tree_store::WorkRoot, + token: String, + deadline: EffectDeadline, + output_budget: usize, + view_id: String, + view_charge: Mutex>, +} +pub(crate) struct PreparedEffectWitness { + pub(crate) record: ManagedPreparedRecord, + pub(crate) original: Value, + pub(crate) conflict: Value, + objects: Vec, +} +impl PreparedEffectWitness { + pub(crate) fn check_ready(&self, runtime: &ManagedEffectRuntime) -> Result<(), String> { + for object in &self.objects { + match &object.handle { + ManagedReadyHandle::Tree(root) => { + if !root.revalidate_binding() { + return Err("managed prepared tree identity changed".into()); + } + } + ManagedReadyHandle::Blob(file) => { + let (current, _) = runtime + .open_blob(&object.reference, object.reference.object_kind.clone())?; + if file_identity(file)? != file_identity(¤t)? { + return Err("managed prepared result identity changed".into()); + } + } + } + } + Ok(()) + } +} +/// Unforgeable started binding; no public constructor or deserializer. +pub struct ManagedStartedWitness { + pub(crate) call: Arc, + pub(crate) started: crate::capabilities::DurableStarted, +} +/// Unforgeable prepared execution binding; audit DTOs cannot construct this. +pub struct ManagedFinalWitness { + pub(crate) call: Arc, + pub(crate) prepared: Arc, +} +pub(crate) struct ManagedCallContext { + binding: Weak, + pub(crate) identity: ManagedCallIdentity, + pub(crate) effect_id: String, + pub(crate) receipt_id: String, + phase: Mutex, + adapters: Mutex>>, + prepared: Mutex>>, + closing: AtomicBool, + outcome: Mutex>, + native_operations: Mutex, + operation_drained: parking_lot::Condvar, + reservation: Arc, + parent: ManagedObjectRef, + original_arguments: String, + _detail_object: Option, +} +pub(crate) struct ManagedOperationEvidence { + call: Arc, +} +impl Drop for ManagedOperationEvidence { + fn drop(&mut self) { + let mut active = self.call.native_operations.lock(); + *active -= 1; + if *active == 0 { + self.call.operation_drained.notify_all(); + } + } +} +impl ManagedCallContext { + pub(crate) fn check_current_generation(&self) -> Result<(), String> { + let adapter = self + .adapters + .lock() + .clone() + .ok_or("managed view unavailable")?; + adapter + .lifecycle + .ensure_generation(self.identity.authorization_generation) + .map_err(error) + } + pub(crate) fn cleanup_final_view(&self) -> ManagedViewRetention { + if *self.phase.lock() != CallPhase::Final || *self.native_operations.lock() != 0 { + return ManagedViewRetention::Retained; + } + if !self.outcome.lock().as_ref().is_some_and(|outcome| { + matches!( + outcome.disposition, + ManagedPublicationStatus::Committed + | ManagedPublicationStatus::ReadOnly + | ManagedPublicationStatus::Conflict + ) + }) { + return ManagedViewRetention::Retained; + } + let Some(adapter) = self.adapters.lock().clone() else { + return ManagedViewRetention::Retained; + }; + let Ok(binding) = self.binding() else { + return ManagedViewRetention::Retained; + }; + let Some(ticket) = adapter.view_charge.lock().take() else { + return ManagedViewRetention::Retained; + }; + let request = ManagedObjectReferencesRequest { + store_id: binding.runtime.store_id.clone(), + object_id: adapter.view_id.clone(), + }; + #[cfg(feature = "workspace-io-fault")] + if binding + .runtime + .take_fault(ManagedEffectFault::ObjectReferenceUnavailable) + { + binding + .runtime + .repository + .inject_reference_transport_failure(); + } + let can_reclaim = ticket.sole_known_lease() + && binding + .runtime + .repository + .object_references(&request) + .is_ok_and(|references| references.is_empty()) + && binding.runtime.check().is_ok() + && adapter.root.revalidate_binding(); + if !can_reclaim { + *adapter.view_charge.lock() = Some(ticket); + return ManagedViewRetention::Retained; + } + #[cfg(feature = "workspace-io-fault")] + if binding.runtime.take_fault(ManagedEffectFault::OwnedCleanup) { + *adapter.view_charge.lock() = Some(ticket); + return ManagedViewRetention::Retained; + } + if tree_store::remove_owned_tree(adapter.work.path()).is_err() { + *adapter.view_charge.lock() = Some(ticket); + return ManagedViewRetention::Retained; + } + if ticket.retire().is_ok() { + ManagedViewRetention::Reclaimed + } else { + ManagedViewRetention::Retained + } + } + #[cfg(all(test, feature = "workspace-io-fault"))] + fn active_native_operations(&self) -> usize { + *self.native_operations.lock() + } + fn fence_for_prepare(&self) -> Result<(), String> { + let _active = self.native_operations.lock(); + let mut phase = self.phase.lock(); + if *phase != CallPhase::Executing { + return Err("managed preparation already fenced".into()); + } + *phase = CallPhase::Preparing; + self.closing.store(true, Ordering::Release); + Ok(()) + } + fn wait_operations_drained(&self, deadline: std::time::Instant) -> Result<(), String> { + let mut active = self.native_operations.lock(); + while *active != 0 { + if std::time::Instant::now() >= deadline { + return Err("managed operation drain reached original deadline".into()); + } + self.operation_drained.wait_until(&mut active, deadline); + } + Ok(()) + } + pub(crate) fn enter_operation( + self: &Arc, + claims: &TokenClaims, + risk: CapabilityRisk, + ) -> Result { + self.authorize(claims, risk)?; + let mut active = self.native_operations.lock(); + if self.closing.load(Ordering::Acquire) || *self.phase.lock() != CallPhase::Executing { + return Err("managed call closing".into()); + } + if *active >= 32 { + return Err("managed operation capacity exceeded".into()); + } + *active += 1; + Ok(ManagedOperationEvidence { + call: Arc::clone(self), + }) + } + pub(crate) fn record_outcome(&self, outcome: &ManagedCommitOutcome) { + *self.outcome.lock() = Some(outcome.clone()); + } + pub(crate) fn publication(&self) -> Option { + self.outcome + .lock() + .as_ref() + .map(|outcome| outcome.publication.clone()) + } + pub(crate) fn binding(&self) -> Result, String> { + self.binding + .upgrade() + .ok_or("managed binding unavailable".into()) + } + pub(crate) fn record(&self) -> ManagedEffectRecord { + ManagedEffectRecord { + format: MANAGED_FORMAT.into(), + effect_id: self.effect_id.clone(), + schema_version: 1, + identity: self.identity.clone(), + state: ManagedPublicationStatus::Indeterminate, + prepared: None, + outcome: None, + recovery: None, + } + } + pub(crate) fn validate_started( + &self, + started: &crate::capabilities::DurableStarted, + ) -> Result<(), String> { + if started.argument_digest + != self + .binding()? + .runtime + .original_arguments(&self.identity.argument_identity)? + || started.tool_name != self.identity.tool_name + || started.registry_identity != self.identity.code.registry_identity + || started.risk_class.as_str() != self.identity.approved_risk + { + return Err("managed started facts mismatch".into()); + } + Ok(()) + } + pub(crate) fn bind_execution( + self: &Arc, + token: &str, + claims: &TokenClaims, + lifecycle: CapabilityLifecycle, + owner: CapabilityOwner, + ) -> Result<(), String> { + let binding = self.binding()?; + binding.check()?; + let limits = binding + .admitted_limits + .lock() + .as_ref() + .copied() + .ok_or("managed admitted ceilings missing")?; + #[cfg(feature = "workspace-io-fault")] + if binding.runtime.take_fault(ManagedEffectFault::AfterIntent) { + return Err("managed fixture cut after durable intent".into()); + } + if claims.call_id != self.identity.call_id + || claims.owner.run() != self.identity.owner.run_id + || claims.deadline_ms != self.identity.original_deadline_ms + || claims.generation != self.identity.authorization_generation + { + return Err("managed token mismatch".into()); + } + let (head, version) = ( + &self.identity.expected_head, + self.identity.expected_head_version, + ); + let _ = version; + validate_id("tree", head)?; + let source = binding + .runtime + .root + .subroot(&format!("trees/{head}"), binding.runtime.root.limits()) + .map_err(error)?; + let id = fresh("view"); + let path = binding.runtime.path.join("views").join(&id); + let mut view_charge = self + .reservation + .take_object(binding.runtime.config.quota.max_bytes)?; + view_charge.start(&id)?; + let original = binding.cancel.clone(); + let cancellation = EffectCancellation::linked(move || { + original.requested().is_some() || original.deadline_passed() + }); + let copied = tree_store::copy_tree_from_fd( + &source.duplicate_descriptor().map_err(error)?, + &path, + &binding.runtime.config.quota, + &cancellation, + &EffectDeadline::at(claims.deadline), + false, + ) + .map_err(error)?; + let work = tree_store::WorkRoot::open( + path, + binding.runtime.config.quota, + tree_store::Usage { + entries: copied.entries, + bytes: copied.bytes, + }, + ) + .map_err(error)?; + let root = Arc::new( + binding + .runtime + .root + .subroot(&format!("views/{id}"), binding.runtime.root.limits()) + .map_err(error)?, + ); + let filesystem = Arc::new( + FilesystemCapability::new_managed( + lifecycle.clone(), + owner.clone(), + limits.filesystem, + Arc::clone(&root), + binding.authority.grant(), + Arc::downgrade(self), + ) + .map_err(error)?, + ); + #[cfg(test)] + if let Some(observer) = binding.runtime.native_write_observer.lock().clone() { + let call = Arc::downgrade(self); + filesystem.inject_before_write(Arc::new(move |_, _| { + let call = call.upgrade().ok_or_else(|| { + crate::capabilities::CapabilityError::new( + "managed_call_unavailable", + "managed call unavailable", + ) + })?; + observer(call); + Ok(()) + })); + } + let processes = Arc::new( + ProcessCapability::new_managed( + lifecycle.clone(), + owner, + limits.process, + Arc::clone(&root), + binding.authority.grant(), + Arc::downgrade(self), + ) + .map_err(error)?, + ); + #[cfg(test)] + if binding + .runtime + .close_after_native_spawn + .swap(false, Ordering::SeqCst) + { + let process_owner = Arc::clone(&processes); + processes.set_after_os_spawn_hook(Arc::new(move || process_owner.shutdown_all())); + } + *self.adapters.lock() = Some(Arc::new(ManagedAdapters { + lifecycle, + filesystem, + processes, + root, + work, + token: token.into(), + deadline: EffectDeadline::at(claims.deadline), + output_budget: claims.output_budget, + view_id: id, + view_charge: Mutex::new(Some(view_charge)), + })); + *self.phase.lock() = CallPhase::Executing; + #[cfg(test)] + if let Some(observer) = binding.runtime.native_ready_observer.lock().clone() { + observer(Arc::clone(self)); + } + Ok(()) + } + pub(crate) fn adapters(&self, token: &str) -> Result, String> { + let adapter = self + .adapters + .lock() + .clone() + .ok_or("managed view unavailable")?; + if adapter.token != token { + return Err("managed sibling token denied".into()); + } + Ok(adapter) + } + pub(crate) fn authorize( + &self, + claims: &TokenClaims, + risk: CapabilityRisk, + ) -> Result<(), String> { + let binding = self.binding()?; + binding.check()?; + if self.closing.load(Ordering::Acquire) + || *self.phase.lock() != CallPhase::Executing + || claims.owner.profile() != self.identity.owner.profile + || claims.owner.session() != self.identity.owner.session_id + || claims.owner.run() != self.identity.owner.run_id + || claims.call_id != self.identity.call_id + || claims.tool_name != self.identity.tool_name + || claims.argument_digest != self.original_arguments + || claims.registry_identity != self.identity.code.registry_identity + || claims.generation != self.identity.authorization_generation + || claims.deadline_ms != self.identity.original_deadline_ms + || claims.workspace != Path::new(binding.authority.canonical_path()) + || claims.risk_ceiling.as_str() != self.identity.approved_risk + || self.identity.code != binding.code + || self.identity.workspace != binding.workspace.identity + { + return Err("managed call closing or identity mismatch".into()); + } + let expected = match risk { + CapabilityRisk::Read => "read", + CapabilityRisk::Write => "write", + CapabilityRisk::Execute => "execute", + }; + if expected != "read" && self.identity.approved_risk != expected { + return Err("managed operation ceiling mismatch".into()); + } + Ok(()) + } + pub(crate) fn accounted_write( + &self, + token: &str, + path: &str, + bytes: &[u8], + publish: impl FnOnce() -> Result, + ) -> Result, crate::capabilities::CapabilityError> { + let binding = self.binding().map_err(|e| { + crate::capabilities::CapabilityError::new("managed_call_unavailable", e) + })?; + let adapter = self.adapters(token).map_err(|e| { + crate::capabilities::CapabilityError::new("managed_call_unavailable", e) + })?; + let original = binding.cancel.clone(); + let cancellation = EffectCancellation::linked(move || { + original.requested().is_some() || original.deadline_passed() + }); + adapter + .work + .with_accounted_atomic_write(path, bytes, &cancellation, &adapter.deadline, publish) + .map_err(|e| { + crate::capabilities::CapabilityError::new("budget_exceeded", e.to_string()) + }) + } + pub(crate) fn prepare_result( + &self, + token: &str, + result: &Value, + ) -> Result, String> { + let binding = self.binding()?; + binding.check()?; + self.fence_for_prepare()?; + self.wait_operations_drained( + binding + .cancel + .deadline_instant() + .ok_or("managed original deadline missing")?, + )?; + let adapter = self.adapters(token)?; + let writer = adapter + .processes + .settle_managed_call(token) + .map_err(error)?; + #[cfg(feature = "workspace-io-fault")] + if binding + .runtime + .take_fault(ManagedEffectFault::AfterOperationBeforePrepare) + { + return Err("managed fixture cut after actual operation".into()); + } + #[cfg(test)] + let fixture_result = binding.runtime.canonical_observation_fixture.lock().take(); + #[cfg(test)] + let result = fixture_result.as_ref().unwrap_or(result); + let canonical = CanonicalToolResultJson::from_value(result)?; + let parent = self.parent.clone(); + let writable = matches!( + self.identity.tool_name.as_str(), + "write_file" | "patch" | "terminal" + ); + let ok = result["ok"] == true; + let candidate = if writable && ok { + Some(binding.runtime.prepare_tree( + &adapter.root, + &binding.cancel, + true, + Some(&self.reservation), + )?) + } else { + None + }; + let original = + binding + .runtime + .result_object(&canonical, adapter.output_budget, &self.reservation)?; + let conflict = json!({"ok":false,"content":"","data":{},"error":{"code":"effect_head_conflict","message":"managed branch head changed; original observation and candidate remain staged"},"truncated":false,"artifacts":[]}); + let conflict_ref = if candidate.is_some() { + Some(binding.runtime.result_object( + &CanonicalToolResultJson::from_value(&conflict)?, + adapter.output_budget, + &self.reservation, + )?) + } else { + None + }; + let record = ManagedPreparedRecord { + format: MANAGED_FORMAT.into(), + schema_version: 1, + effect_id: self.effect_id.clone(), + identity: self.identity.clone(), + parent_root: parent, + candidate_root: candidate.as_ref().map(|object| object.reference.clone()), + canonical_result: original.reference.clone(), + conflict_result: conflict_ref.as_ref().map(|object| object.reference.clone()), + writer_state: writer, + operation_class: if writable { + ManagedOperationClass::TreeWrite + } else { + ManagedOperationClass::ReadOnly + }, + prepared_at_ms: crate::domain::timestamp(), + observation_ok: ok, + external_effects: if self.identity.tool_name == "terminal" { + ManagedExternalEffects::OutsideTreeNotReverted + } else { + ManagedExternalEffects::NoneClaimed + }, + }; + self.reservation.release_unspent(); + let mut objects = vec![original]; + if let Some(candidate) = candidate { + objects.push(candidate); + } + if let Some(conflict) = conflict_ref { + objects.push(conflict); + } + #[cfg(feature = "workspace-io-fault")] + if binding + .runtime + .take_fault(ManagedEffectFault::AfterObjectsSynced) + { + return Err("managed fixture cut after immutable objects synced".into()); + } + let cut = { + #[cfg(feature = "workspace-io-fault")] + { + if binding + .runtime + .take_fault(ManagedEffectFault::PrepareAfterObjectRegistry) + { + "PrepareAfterObjectRegistry" + } else { + "" + } + } + #[cfg(not(feature = "workspace-io-fault"))] + { + "" + } + }; + binding.runtime.repository.command_with_cancellation("effect.prepare",&json!({"effect_id":self.effect_id,"receipt_id":self.receipt_id,"prepared_json":serde_json::to_string(&record).map_err(error)?,"cut":cut}),&binding.cancel)?; + let witness = Arc::new(PreparedEffectWitness { + record, + original: serde_json::from_str(canonical.as_str()).map_err(error)?, + conflict, + objects, + }); + *self.prepared.lock() = Some(Arc::clone(&witness)); + *self.phase.lock() = CallPhase::Prepared; + #[cfg(feature = "workspace-io-fault")] + if binding + .runtime + .take_fault(ManagedEffectFault::AfterPrepared) + { + return Err("managed fixture cut after durable preparation".into()); + } + #[cfg(feature = "workspace-io-fault")] + { + let gate = binding.runtime.prepare_gate.lock().clone(); + if let Some(gate) = gate + && gate + .call_id + .as_deref() + .is_none_or(|id| id == self.identity.call_id) + { + gate.arrive( + binding + .cancel + .deadline_instant() + .ok_or("managed original deadline missing")?, + )?; + } + } + Ok(witness) + } + pub(crate) fn witness(&self) -> Result, String> { + self.prepared + .lock() + .clone() + .ok_or("managed prepared witness unavailable".into()) + } + pub(crate) fn interrupt_unpublished(&self) { + let mut phase = self.phase.lock(); + if *phase != CallPhase::Final { + *phase = CallPhase::Unknown; + self.closing.store(true, Ordering::Release); + } + drop(phase); + // Only unused admission capacity is released. Every started ticket, + // physical candidate and durable reference keeps its separate charge. + self.reservation.release_unspent(); + } + pub(crate) fn finalize(&self, known: bool) { + *self.phase.lock() = if known { + CallPhase::Final + } else { + CallPhase::Unknown + }; + self.reservation.release_unspent(); + } +} diff --git a/src/effects/journal.rs b/src/effects/journal.rs new file mode 100644 index 0000000..c49a9de --- /dev/null +++ b/src/effects/journal.rs @@ -0,0 +1,695 @@ +//! Named managed-effect protocol over the formal RSS storage worker. +use crate::gateway::store::{GatewayPersistence, StorageError}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::sync::Arc; + +pub const MANAGED_FORMAT: &str = "agent.managed-effect/v1"; +pub const WORKSPACE_FORMAT: &str = "agent.managed-workspace/v1"; +pub const METADATA_BYTES: usize = 1024 * 1024; + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedOwnerScope { + pub profile: String, + pub session_id: String, + pub run_id: String, +} +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DirectoryIdentity { + pub device_decimal: String, + pub inode_decimal: String, +} +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedWorkspaceIdentity { + pub store_id: String, + pub workspace_id: String, + pub branch_id: String, + pub source_identity: DirectoryIdentity, +} +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedCodeIdentity { + pub graph_id: String, + pub activation_generation: u64, + pub graph_ref_id: String, + pub package_id: String, + pub artifact_id: String, + pub export: String, + pub registry_identity: String, + pub effect_contract_version: u32, +} +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ManagedObjectKind { + Tree, + CanonicalResult, + IntentDetails, +} +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedObjectRef { + pub store_id: String, + pub object_id: String, + pub object_kind: ManagedObjectKind, + pub byte_count: String, + pub entry_count: String, + pub object_format: String, +} +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum ManagedArgumentIdentity { + Inline { canonical_arguments: String }, + Object { details: ManagedObjectRef }, +} +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ManagedApprovalBasis { + ReadWithoutAttestation, + GuardianAttested, +} +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedCallIdentity { + pub owner: ManagedOwnerScope, + pub call_id: String, + pub tool_name: String, + pub argument_identity: ManagedArgumentIdentity, + pub code: ManagedCodeIdentity, + pub workspace: ManagedWorkspaceIdentity, + pub epoch: u64, + pub expected_head: String, + pub expected_head_version: u64, + pub original_deadline_ms: u64, + pub authorization_generation: u64, + pub approved_risk: String, + pub approval_basis: ManagedApprovalBasis, +} +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ManagedOperationClass { + ReadOnly, + TreeWrite, +} +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ManagedWriterState { + Settled, + NoProcess, +} +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ManagedExternalEffects { + NoneClaimed, + OutsideTreeNotReverted, +} +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedPreparedRecord { + pub format: String, + #[serde(rename = "schemaVersion")] + pub schema_version: u32, + pub effect_id: String, + pub identity: ManagedCallIdentity, + pub parent_root: ManagedObjectRef, + pub candidate_root: Option, + pub canonical_result: ManagedObjectRef, + pub conflict_result: Option, + pub writer_state: ManagedWriterState, + pub operation_class: ManagedOperationClass, + pub prepared_at_ms: u64, + pub observation_ok: bool, + pub external_effects: ManagedExternalEffects, +} +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ManagedPublicationStatus { + ReadOnly, + Committed, + Staged, + Conflict, + NotPublished, + Indeterminate, +} +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedPublicationRef { + pub format: String, + pub effect_id: String, + pub workspace_id: String, + pub branch_id: String, + pub epoch: u64, + pub base_head: String, + pub base_head_version: u64, + pub candidate_head: Option, + pub observed_head: String, + pub observed_head_version: u64, + pub status: ManagedPublicationStatus, +} +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedEventRef { + pub event_id: String, + pub seq: u64, +} +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedMessageRef { + pub id: String, + pub ordinal: i64, +} +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedCommitOutcome { + pub disposition: ManagedPublicationStatus, + pub publication: ManagedPublicationRef, + pub canonical_result_ref: ManagedObjectRef, + pub output_event: ManagedEventRef, + pub terminal_event: ManagedEventRef, + pub message: ManagedMessageRef, + pub replayed: bool, +} +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedWorkspaceRecord { + pub format: String, + #[serde(rename = "schemaVersion")] + pub schema_version: u32, + pub identity: ManagedWorkspaceIdentity, + pub profile: String, + pub seed_root: ManagedObjectRef, + pub max_entries: u64, + pub max_bytes: u64, + pub max_depth: u32, +} +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedWorkspaceHead { + pub workspace_id: String, + pub branch_id: String, + pub epoch: u64, + pub head_object_id: String, + pub head_version: u64, + pub store_id: String, + pub schema_version: u32, +} +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedEffectGetRequest { + pub owner: ManagedOwnerScope, + pub call_id: String, +} +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedEffectListRequest { + pub workspace_id: String, + pub after_effect_id: Option, + pub limit: usize, +} +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedEffectSummary { + pub effect_id: String, + pub run_id: String, + pub call_id: String, + pub state: ManagedPublicationStatus, + pub workspace_id: String, + pub branch_id: String, + pub epoch: u64, + pub prepared_at_ms: Option, + pub decided_at_ms: Option, +} +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedEffectPage { + pub entries: Vec, + pub next_cursor: Option, + pub has_more: bool, +} +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedResultReadRequest { + pub effect_id: String, + pub offset: u64, + pub limit: usize, +} +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedResultChunk { + pub bytes: Vec, + pub next_offset: u64, + pub eof: bool, + pub total_bytes: u64, +} +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ManagedObjectReferenceKind { + RegisteredObject, + Seed, + BranchHead, + History, + CallBase, + PreparedCandidate, + PreparedResult, + PreparedConflict, + SelectedResult, + IntentDetails, +} +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedObjectReferencesRequest { + pub store_id: String, + pub object_id: String, +} +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedObjectReference { + pub store_id: String, + pub object_id: String, + pub reference_kind: ManagedObjectReferenceKind, + pub reference_id: String, +} +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CanonicalToolResultJson { + json: String, +} +impl CanonicalToolResultJson { + pub fn as_str(&self) -> &str { + &self.json + } + pub(crate) fn from_value(value: &Value) -> Result { + crate::capabilities::lifecycle::validate_canonical_result(value) + .map_err(|e| e.code().to_string())?; + if value.as_object().is_none_or(|fields| { + fields.keys().any(|key| { + !matches!( + key.as_str(), + "ok" | "content" | "data" | "error" | "truncated" | "artifacts" + ) + }) + }) { + return Err("guest result fields denied".into()); + } + // RSS map carriers omit null fields. Canonicalize once through the + // existing observation decoder before sealing bytes, so the live result, + // immutable receipt and typed durable facade have the same envelope. + let result = + crate::service::canonical_tool_result(value).map_err(|e| e.code().to_string())?; + let canonical = serde_json::to_value(result).map_err(|e| e.to_string())?; + Ok(Self { + json: serde_json::to_string(&canonical).map_err(|e| e.to_string())?, + }) + } +} +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedRecoveryObservation { + pub code: String, + pub publication: ManagedPublicationRef, + pub canonical_failure_json: CanonicalToolResultJson, + pub output_event: ManagedEventRef, + pub terminal_event: ManagedEventRef, + pub message: ManagedMessageRef, +} +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedEffectRecord { + pub format: String, + pub effect_id: String, + #[serde(rename = "schemaVersion")] + pub schema_version: u32, + pub identity: ManagedCallIdentity, + pub state: ManagedPublicationStatus, + pub prepared: Option, + pub outcome: Option, + pub recovery: Option, +} +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum ManagedCallReplay { + Final { + outcome: Box, + canonical_result: CanonicalToolResultJson, + }, + Staged { + record: Box, + }, + Indeterminate { + record: Box, + }, + Unavailable { + code: String, + }, +} + +#[derive(Clone)] +pub(crate) struct ManagedEffectRepository { + persistence: Arc, +} +impl ManagedEffectRepository { + pub(crate) fn new(persistence: Arc) -> Self { + Self { persistence } + } + pub(crate) fn command(&self, op: &str, payload: &Value) -> Result { + self.command_inner(op, payload, None) + } + pub(crate) fn command_with_cancellation( + &self, + op: &str, + payload: &Value, + cancellation: &crate::RunCancellation, + ) -> Result { + self.command_inner(op, payload, Some(cancellation)) + } + fn command_inner( + &self, + op: &str, + payload: &Value, + cancellation: Option<&crate::RunCancellation>, + ) -> Result { + for field in ["record_json", "prepared_json", "workspace_json"] { + if let Some(text) = payload + .get(field) + .and_then(Value::as_str) + .filter(|text| !text.is_empty()) + { + managed_document(text).map_err(|error| { + #[cfg(feature = "workspace-io-fault")] + eprintln!("managed document validation rejected {field}: {error}"); + error + })?; + } + } + if let Some(cancellation) = cancellation { + self.persistence + .managed_effect_command_with_cancellation(op, payload, cancellation) + } else { + self.persistence.managed_effect_command(op, payload) + } + .map_err(|e| e.to_string()) + } + pub(crate) fn workspace( + &self, + store_id: &str, + profile: &str, + source: &DirectoryIdentity, + ) -> Result, String> { + let result = self.command("effect.workspace_get", &json!({"store_id":store_id,"profile":profile,"source_device":source.device_decimal,"source_inode":source.inode_decimal}))?; + decode_record(&result) + } + pub(crate) fn head( + &self, + workspace: &ManagedWorkspaceIdentity, + ) -> Result { + let result = self.command( + "effect.head", + &json!({"workspace_id":workspace.workspace_id,"branch_id":workspace.branch_id}), + )?; + decode_record(&result)?.ok_or_else(|| "managed workspace head missing".into()) + } + pub(crate) fn get( + &self, + request: &ManagedEffectGetRequest, + ) -> Result, String> { + decode_record(&self.command("effect.get", &json!(request))?) + } + #[cfg(feature = "workspace-io-fault")] + pub(crate) fn inject_reference_transport_failure(&self) { + self.persistence.inject_persist_failure(); + } + pub(crate) fn object_references( + &self, + request: &ManagedObjectReferencesRequest, + ) -> Result, String> { + self.persistence + .managed_object_references(request) + .map_err(|e| e.to_string()) + } + pub(crate) fn object_metadata( + &self, + request: &ManagedObjectReferencesRequest, + ) -> Result, String> { + self.persistence + .managed_object_metadata(request) + .map_err(|e| e.to_string()) + } +} +pub(crate) fn decode_effect_summaries( + value: &Value, + limit: usize, +) -> Result, String> { + const COLUMNS: [&str; 9] = [ + "effect_id", + "run_id", + "call_id", + "state", + "workspace_id", + "branch_id", + "epoch", + "prepared_at_ms", + "decided_at_ms", + ]; + if value.get("columns") != Some(&json!(COLUMNS)) + || value.get("truncated") != Some(&json!(false)) + { + return Err("managed summary layout/truncation invalid".into()); + } + let rows = value + .get("rows") + .and_then(Value::as_array) + .ok_or("managed summary rows missing")?; + if limit == 0 || limit > 128 || rows.len() > limit { + return Err("managed summary page exceeds bound".into()); + } + let mut entries = Vec::with_capacity(rows.len()); + for row in rows { + let cells = row + .as_array() + .ok_or("managed summary row must be an array")?; + if cells.len() != COLUMNS.len() + || serde_json::to_vec(row) + .map_err(|_| "managed summary serialization failed")? + .len() + > 4096 + { + return Err("managed summary cell/byte bound invalid".into()); + } + let record: serde_json::Map = COLUMNS + .iter() + .zip(cells) + .map(|(key, value)| (key.to_string(), value.clone())) + .collect(); + let summary: ManagedEffectSummary = serde_json::from_value(Value::Object(record)) + .map_err(|_| "managed summary cell types invalid")?; + super::broker::validate_id("effect", &summary.effect_id)?; + super::broker::validate_id("ws", &summary.workspace_id)?; + super::broker::validate_id("br", &summary.branch_id)?; + if summary.epoch == 0 { + return Err("managed summary epoch invalid".into()); + } + entries.push(summary); + } + Ok(entries) +} + +pub(crate) fn decode_record Deserialize<'de>>( + value: &Value, +) -> Result, String> { + let columns = value + .get("columns") + .and_then(Value::as_array) + .ok_or("managed query columns missing")?; + if columns != &[json!("record_json")] || value.get("truncated") != Some(&json!(false)) { + return Err("managed query layout/truncation invalid".into()); + } + let rows = value + .get("rows") + .and_then(Value::as_array) + .ok_or("managed rows missing")?; + if rows.len() > 1 { + return Err("managed exact query returned multiple rows".into()); + } + rows.first() + .map(|row| { + let cells = row.as_array().ok_or("managed row is not an array")?; + if cells.len() != 1 { + return Err("managed cell count invalid".into()); + } + let text = cells[0].as_str().ok_or("managed record must be text")?; + if text.len() > METADATA_BYTES { + return Err("managed record exceeds bound".into()); + } + let value = managed_document(text)?; + serde_json::from_value(value).map_err(|_| "managed named record decoding failed".into()) + }) + .transpose() +} +pub(crate) fn storage_error(message: impl Into) -> StorageError { + StorageError { + code: "managed_effect_invalid".into(), + message: message.into(), + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ManagedRecoveryContext { + next_seq: u64, + ordinal: i64, + parent: String, +} + +pub fn parse_managed_effect_yaml(text: &str) -> Result { + let value = managed_document(text)?; + crate::plugins::validate_managed_effect(&value) + .map_err(|_| "managed effect schema rejected")?; + serde_json::from_value(value).map_err(|_| "managed named effect decoding failed".into()) +} + +pub fn parse_managed_workspace_yaml(text: &str) -> Result { + let value = managed_document(text)?; + crate::plugins::validate_managed_workspace(&value) + .map_err(|_| "managed workspace schema rejected")?; + serde_json::from_value(value).map_err(|_| "managed named workspace decoding failed".into()) +} + +fn managed_document(text: &str) -> Result { + if text.len() > METADATA_BYTES { + return Err("managed metadata exceeds 1 MiB".into()); + } + let value = crate::plugins::strict_managed_yaml_to_json(text) + .map_err(|_| "managed raw document rejected")?; + match value.get("format").and_then(Value::as_str) { + Some(MANAGED_FORMAT) => { + crate::plugins::validate_managed_effect(&value).map_err(|e| e.to_string())? + } + Some(WORKSPACE_FORMAT) => { + crate::plugins::validate_managed_workspace(&value).map_err(|e| e.to_string())? + } + Some(_) => return Err("managed format unsupported".into()), + None if value.get("identity").is_some() => return Err("managed format missing".into()), + None => {} + } + Ok(value) +} + +/// Startup uses only the formal SDK. No filesystem result object is fabricated. +pub(crate) fn recover_managed_effects(persistence: &GatewayPersistence) -> Result<(), String> { + use crate::domain::{durable_message_id, durable_tool_event_id}; + for _ in 0..1024 { + let page = persistence + .managed_effect_command("effect.pending", &json!({})) + .map_err(|e| e.to_string())?; + if page.get("columns") != Some(&json!(["record_json"])) + || page.get("truncated") != Some(&json!(false)) + { + return Err("managed recovery page layout/truncation invalid".into()); + } + let rows = page + .get("rows") + .and_then(Value::as_array) + .ok_or("managed recovery rows missing")?; + if rows.len() > 128 { + return Err("managed recovery page exceeds bound".into()); + } + if rows.is_empty() { + return Ok(()); + } + for row in rows { + let cells = row + .as_array() + .ok_or("managed recovery cell layout invalid")?; + if cells.len() != 1 { + return Err("managed recovery column count invalid".into()); + } + let record: ManagedEffectRecord = serde_json::from_str( + cells[0] + .as_str() + .ok_or("managed recovery record must be text")?, + ) + .map_err(|e| e.to_string())?; + let owner = &record.identity.owner; + let call = &record.identity.call_id; + let value = persistence + .managed_effect_command( + "effect.recovery_context", + &json!({"run_id":owner.run_id,"call_id":call}), + ) + .map_err(|e| e.to_string())?; + let context: ManagedRecoveryContext = + decode_record(&value)?.ok_or("managed recovery context missing")?; + if context.parent.is_empty() { + return Err("managed recovery parent missing".into()); + } + let staged = record.prepared.is_some(); + let code = if staged { + "effect_staged" + } else { + "effect_indeterminate" + }; + let publication = ManagedPublicationRef { + format: MANAGED_FORMAT.into(), + effect_id: record.effect_id.clone(), + workspace_id: record.identity.workspace.workspace_id.clone(), + branch_id: record.identity.workspace.branch_id.clone(), + epoch: record.identity.epoch, + base_head: record.identity.expected_head.clone(), + base_head_version: record.identity.expected_head_version, + candidate_head: record.prepared.as_ref().and_then(|prepared| { + prepared + .candidate_root + .as_ref() + .map(|object| object.object_id.clone()) + }), + observed_head: record.identity.expected_head.clone(), + observed_head_version: record.identity.expected_head_version, + status: if staged { + ManagedPublicationStatus::Staged + } else { + ManagedPublicationStatus::Indeterminate + }, + }; + let failure = crate::tool_result::ToolResult::failure( + code, + "managed effect interrupted; old execution will not resume", + ); + let canonical = CanonicalToolResultJson::from_value( + &serde_json::to_value(&failure).map_err(|e| e.to_string())?, + )?; + if canonical.as_str().len() > 4096 { + return Err("managed recovery failure exceeds bound".into()); + } + let content = crate::service::tool_result_content_json(call, &failure); + let output = ManagedEventRef { + event_id: durable_tool_event_id(&owner.run_id, call, "tool.output"), + seq: context.next_seq, + }; + let terminal = ManagedEventRef { + event_id: durable_tool_event_id(&owner.run_id, call, "tool.failed"), + seq: context.next_seq + 1, + }; + let message = ManagedMessageRef { + id: durable_message_id(&owner.run_id, "result", call), + ordinal: context.ordinal, + }; + let recovery = ManagedRecoveryObservation { + code: code.into(), + publication: publication.clone(), + canonical_failure_json: canonical, + output_event: output.clone(), + terminal_event: terminal.clone(), + message: message.clone(), + }; + let data = json!({"tool_call_id":call,"ok":false,"truncated":false,"error_code":code,"managed_publication":publication}); + let projections = json!({"normal":{"recovery":recovery, + "output":{"event_id":output.event_id,"event_type":"tool.output","seq":output.seq,"payload_json":data.to_string()}, + "terminal":{"event_id":terminal.event_id,"event_type":"tool.failed","seq":terminal.seq,"payload_json":data.to_string()}, + "message":{"id":message.id,"ordinal":message.ordinal,"content_json":content.to_string(),"parent_message_id":context.parent}}}); + persistence.managed_effect_command("effect.recover_one",&json!({"effect_id":record.effect_id,"run_id":owner.run_id,"call_id":call,"commit_id":format!("recovery:{}",record.effect_id),"projections_json":projections.to_string()})).map_err(|e|e.to_string())?; + } + } + Err("managed recovery work bound reached; startup unavailable with pending records".into()) +} diff --git a/src/effects/managed_authority_tests.rs b/src/effects/managed_authority_tests.rs new file mode 100644 index 0000000..88a3321 --- /dev/null +++ b/src/effects/managed_authority_tests.rs @@ -0,0 +1,393 @@ +use super::*; +use crate::{AdmitRunRequest, AgentGatewayConfig, AgentGatewayState, ScriptedProvider}; + +#[tokio::test(flavor = "multi_thread")] +async fn original_native_token_rejects_sibling_and_changed_claims_then_same_vm_writes_authorized_bytes() + { + let root = PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()).join(format!( + "managed-authority-{}", + uuid::Uuid::new_v4().simple() + )); + let workspace = root.join("workspace"); + let home = root.join("home"); + fs::create_dir_all(&workspace).unwrap(); + fs::create_dir_all(&home).unwrap(); + for path in [&root, &workspace, &home] { + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); + } + fs::write(home.join("config.yaml"),format!("version: 1\nmodel:\n provider: local-test\n model: synthetic\nproviders:\n local-test:\n protocol: codex-responses\n base_url: https://127.0.0.1:1\nworkspaces:\n allowed_roots:\n - '{}'\n default: '{}'\n",workspace.display(),workspace.display())).unwrap(); + let mut config = AgentGatewayConfig { + managed_workspace: Some(ManagedWorkspaceConfig { + source_root: workspace, + quota: super::super::ResourceQuota::new(1000, 1048576, 16).unwrap(), + retention: crate::config::ManagedRetentionLimits::default(), + }), + ..Default::default() + }; + config.approvals.write = "allow".into(); + let state = AgentGatewayState::with_sqlite_path(config, root.join("gateway.sqlite")) + .unwrap() + .with_workspace_home(&home); + let service = state.service(); + let runtime = service.managed_runtime_for_test().unwrap(); + let weak = Arc::downgrade(&service); + let checked = Arc::new(AtomicBool::new(false)); + let checked_in = Arc::clone(&checked); + *runtime.native_write_observer.lock() = Some(Arc::new(move |call| { + let service = weak.upgrade().unwrap(); + let host = service + .capability_host_bridges(&call.identity.owner.run_id) + .unwrap() + .unwrap(); + let lifecycle = host.lifecycle.unwrap(); + let owner = host.capability_owner.unwrap(); + let adapter = call.adapters.lock().clone().unwrap(); + let original = lifecycle + .authorize(&owner, &adapter.token, CapabilityRisk::Write) + .unwrap(); + assert!(call.adapters("sibling-token-not-authority").is_err()); + assert!( + adapter + .filesystem + .write_atomic("sibling-token-not-authority", "denied.txt", "*", b"denied") + .is_err() + ); + let mut variants = Vec::new(); + let mut wrong = original.clone(); + wrong.owner = CapabilityOwner::new("gateway", "different-session", owner.run()).unwrap(); + variants.push(("owner", wrong)); + let mut wrong = original.clone(); + wrong.call_id = "other-call".into(); + variants.push(("call", wrong)); + let mut wrong = original.clone(); + wrong.tool_name = "terminal".into(); + variants.push(("tool", wrong)); + let mut wrong = original.clone(); + wrong.argument_digest = "{}".into(); + variants.push(("arguments", wrong)); + let mut wrong = original.clone(); + wrong.registry_identity = "wrong-registry".into(); + variants.push(("registry", wrong)); + let mut wrong = original.clone(); + wrong.generation += 1; + variants.push(("generation", wrong)); + let mut wrong = original.clone(); + wrong.deadline_ms += 1; + variants.push(("deadline", wrong)); + let mut wrong = original.clone(); + wrong.workspace = PathBuf::from("/not-the-admitted-workspace"); + variants.push(("workspace", wrong)); + let mut wrong = original.clone(); + wrong.risk_ceiling = CapabilityRisk::Execute; + variants.push(("risk", wrong)); + for (field, claims) in variants { + assert!( + call.authorize(&claims, CapabilityRisk::Write).is_err(), + "changed {field} must fail before Native IO" + ); + } + let binding = call.binding().unwrap(); + for trust in [ + crate::plugins::PackageTrust::AcceptedPublisher, + crate::plugins::PackageTrust::DevUnsigned, + ] { + let mut contract = binding.contract.clone(); + contract.trust = trust; + assert!( + binding + .runtime + .prepare_run_binding( + Arc::clone(&binding.authority), + binding.owner.clone(), + binding.code.clone(), + contract, + binding.cancel.clone() + ) + .is_err(), + "a signature or development trust cannot mint managed authority" + ); + } + let mut graph = binding.contract.clone(); + graph.single_builtin_graph = false; + assert!( + binding + .runtime + .prepare_run_binding( + Arc::clone(&binding.authority), + binding.owner.clone(), + binding.code.clone(), + graph, + binding.cancel.clone() + ) + .is_err() + ); + let mut pure = binding.contract.clone(); + pure.effects.clear(); + let pure = binding + .runtime + .prepare_run_binding( + Arc::clone(&binding.authority), + binding.owner.clone(), + binding.code.clone(), + pure, + binding.cancel.clone(), + ) + .unwrap(); + pure.bind_admitted_limits( + FilesystemLimits::default(), + ProcessLimits::default(), + original.output_budget, + ) + .unwrap(); + assert!( + pure.register( + "undeclared", + "write_file", + "{}", + "write", + original.deadline_ms + ) + .is_err() + ); + drop(pure); + call.authorize(&original, CapabilityRisk::Write).unwrap(); + checked_in.store(true, Ordering::SeqCst); + })); + let provider = Arc::new(ScriptedProvider::new()); + provider.push_ok(json!({"text":"","tool_calls":[{"id":"native","name":"write_file","arguments":{"path":"allowed.txt","content":"authorized original RSS bytes"}}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2},"reasoning":"","stop_reason":"stop"})); + provider.push_ok(json!({"text":"done","tool_calls":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2},"reasoning":"","stop_reason":"stop"})); + service.inject_provider_host(provider); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"Native exact claims"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + service + .clone() + .run_worker(run.run_id.clone(), "ignored".into()) + .await; + assert!( + checked.load(Ordering::SeqCst), + "real RSS Native write must reach the claim checks" + ); + let request = ManagedEffectGetRequest { + owner: ManagedOwnerScope { + profile: "gateway".into(), + session_id: run.session_id, + run_id: run.run_id, + }, + call_id: "native".into(), + }; + let record = state + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(); + let candidate = &record.prepared.unwrap().candidate_root.unwrap().object_id; + assert_eq!( + fs::read( + root.join(".gateway.sqlite.effects/trees") + .join(candidate) + .join("allowed.txt") + ) + .unwrap(), + b"authorized original RSS bytes" + ); + assert!( + !root + .join(".gateway.sqlite.effects/trees") + .join(candidate) + .join("denied.txt") + .exists() + ); + drop(runtime); + service.shutdown().await.unwrap(); +} + +fn native_fixture() -> (PathBuf, AgentGatewayState) { + let root = PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()).join(format!( + "managed-native-ready-{}", + uuid::Uuid::new_v4().simple() + )); + let workspace = root.join("workspace"); + let home = root.join("home"); + fs::create_dir_all(&workspace).unwrap(); + fs::create_dir(&home).unwrap(); + for path in [&root, &workspace, &home] { + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); + } + fs::write(home.join("config.yaml"),format!("version: 1\nmodel:\n provider: local-test\n model: synthetic\nproviders:\n local-test:\n protocol: codex-responses\n base_url: https://127.0.0.1:1\nworkspaces:\n allowed_roots:\n - '{}'\n default: '{}'\n",workspace.display(),workspace.display())).unwrap(); + let mut config = AgentGatewayConfig { + managed_workspace: Some(ManagedWorkspaceConfig { + source_root: workspace, + quota: super::super::ResourceQuota::new(1000, 1048576, 16).unwrap(), + retention: crate::config::ManagedRetentionLimits::default(), + }), + ..Default::default() + }; + config.approvals.write = "allow".into(); + config.approvals.process = "allow".into(); + let state = AgentGatewayState::with_sqlite_path(config, root.join("gateway.sqlite")) + .unwrap() + .with_workspace_home(&home); + (root, state) +} +async fn run_native_fixture(service: &Arc, call: Value) -> crate::AdmittedRun { + let provider = Arc::new(ScriptedProvider::new()); + provider.push_ok(json!({"text":"","tool_calls":[call],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2},"reasoning":"","stop_reason":"stop"})); + provider.push_ok(json!({"text":"done","tool_calls":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2},"reasoning":"","stop_reason":"stop"})); + service.inject_provider_host(provider); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"Native ready boundary"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + service + .clone() + .run_worker(run.run_id.clone(), "ignored".into()) + .await; + run +} +#[tokio::test(flavor = "multi_thread")] +async fn extra_native_descriptor_lease_retains_actual_final_view_even_with_empty_sdk_references() { + let (_root, state) = native_fixture(); + let service = state.service(); + let runtime = service.managed_runtime_for_test().unwrap(); + let held = Arc::new(Mutex::new(None)); + let owner = Arc::clone(&held); + *runtime.native_ready_observer.lock() = Some(Arc::new(move |call| { + let adapter = call.adapters.lock().clone().unwrap(); + let binding = call.binding().unwrap(); + let lease = binding + .runtime + .leases + .lease_existing_for_test(&adapter.view_id) + .unwrap(); + *owner.lock() = Some(( + lease, + Arc::clone(&adapter.root), + adapter.work.path().to_owned(), + Arc::clone(&call), + )); + })); + let _run=run_native_fixture(&service,json!({"id":"lease-held","name":"write_file","arguments":{"path":"retained.txt","content":"original operation"}})).await; + let retained = held + .lock() + .take() + .expect("real Native second descriptor lease"); + let call = Arc::clone(&retained.3); + let adapter = call.adapters.lock().clone().unwrap(); + assert!( + runtime + .repository + .object_references(&ManagedObjectReferencesRequest { + store_id: runtime.store_id.clone(), + object_id: adapter.view_id.clone() + }) + .unwrap() + .is_empty() + ); + assert!(*call.phase.lock() == CallPhase::Final); + assert_eq!(*call.native_operations.lock(), 0); + assert!(retained.2.join("retained.txt").is_file()); + assert!(matches!( + call.cleanup_final_view(), + ManagedViewRetention::Retained + )); + assert!( + adapter + .view_charge + .lock() + .as_ref() + .is_some_and(|ticket| !ticket.sole_known_lease()) + ); + assert!(retained.2.is_dir()); + drop(retained); + drop(adapter); + drop(call); + drop(runtime); + service.shutdown().await.unwrap(); +} +#[tokio::test(flavor = "multi_thread")] +async fn valid_original_terminal_authority_rejects_native_close_after_initial_bypass_before_spawn_then_foreground_succeeds() + { + let (root, state) = native_fixture(); + let service = state.service(); + let runtime = service.managed_runtime_for_test().unwrap(); + let sentinel = root.join("native-bypass-sentinel"); + let forbidden = sentinel.clone(); + let checked = Arc::new(AtomicBool::new(false)); + let observed = Arc::clone(&checked); + let weak = Arc::downgrade(&service); + *runtime.native_ready_observer.lock() = Some(Arc::new(move |call| { + let adapter = call.adapters.lock().clone().unwrap(); + let service = weak.upgrade().unwrap(); + let host = service + .capability_host_bridges(&call.identity.owner.run_id) + .unwrap() + .unwrap(); + let owner = host.capability_owner.unwrap(); + let claims = adapter + .lifecycle + .authorize(&owner, &adapter.token, CapabilityRisk::Execute) + .unwrap(); + call.authorize(&claims, CapabilityRisk::Execute).unwrap(); + let denied = adapter.processes.spawn( + &adapter.token, + &[ + "/bin/sh".into(), + "-c".into(), + format!("printf x >> '{}'", forbidden.display()), + ], + ".", + &[], + ProcessLimits { + close_after_initial: false, + ..ProcessLimits::default() + }, + ); + assert_eq!( + denied.unwrap_err().code(), + "managed_open_effect_unsupported" + ); + assert!(!forbidden.exists()); + call.authorize(&claims, CapabilityRisk::Execute).unwrap(); + observed.store(true, Ordering::SeqCst); + })); + let run=run_native_fixture(&service,json!({"id":"foreground","name":"terminal","arguments":{"argv":["/bin/true"],"timeout_ms":1000}})).await; + assert!(checked.load(Ordering::SeqCst)); + assert!(!sentinel.exists()); + let replay = service + .replay_managed_call(ManagedEffectGetRequest { + owner: ManagedOwnerScope { + profile: "gateway".into(), + session_id: run.session_id, + run_id: run.run_id, + }, + call_id: "foreground".into(), + }) + .unwrap(); + let super::super::ManagedCallReplay::Final { + canonical_result, .. + } = replay + else { + panic!("authorized foreground result absent") + }; + assert_eq!( + serde_json::from_str::(canonical_result.as_str()).unwrap()["ok"], + true + ); + drop(runtime); + service.shutdown().await.unwrap(); +} diff --git a/src/effects/mod.rs b/src/effects/mod.rs index d7c742d..cb8e644 100644 --- a/src/effects/mod.rs +++ b/src/effects/mod.rs @@ -2,14 +2,37 @@ //! //! This module does not integrate plugins, OCI bundles, or a control plane. +pub(crate) mod broker; mod checkpoint; +pub(crate) mod journal; +mod retention; mod tree_store; mod types; mod workspace; +#[cfg(feature = "workspace-io-fault")] +pub use broker::{ManagedEffectFault, ManagedPrepareGateGuard}; +pub use broker::{ManagedFinalWitness, ManagedStartedWitness}; #[doc(hidden)] pub use checkpoint::pre_conversion_yaml_error; pub use checkpoint::{CheckpointRecord, RestoredBranch, parse_checkpoint_yaml}; +pub use journal::{ + CanonicalToolResultJson, DirectoryIdentity, ManagedApprovalBasis, ManagedArgumentIdentity, + ManagedCallIdentity, ManagedCallReplay, ManagedCodeIdentity, ManagedCommitOutcome, + ManagedEffectGetRequest, ManagedEffectRecord, ManagedEventRef, ManagedExternalEffects, + ManagedMessageRef, ManagedObjectKind, ManagedObjectRef, ManagedOperationClass, + ManagedOwnerScope, ManagedPreparedRecord, ManagedPublicationRef, ManagedPublicationStatus, + ManagedRecoveryObservation, ManagedWorkspaceHead, ManagedWorkspaceIdentity, + ManagedWorkspaceRecord, ManagedWriterState, parse_managed_effect_yaml, + parse_managed_workspace_yaml, +}; +pub use journal::{ + ManagedEffectListRequest, ManagedEffectPage, ManagedEffectSummary, ManagedResultChunk, + ManagedResultReadRequest, +}; +pub use journal::{ + ManagedObjectReference, ManagedObjectReferenceKind, ManagedObjectReferencesRequest, +}; pub use types::{ BranchEpoch, BranchId, CheckpointId, CheckpointRequest, EffectCancellation, EffectDeadline, EffectError, EffectErrorKind, EffectFailure, ExternalEffectNotice, ImportConsistency, diff --git a/src/effects/retention.rs b/src/effects/retention.rs new file mode 100644 index 0000000..b57b78e --- /dev/null +++ b/src/effects/retention.rs @@ -0,0 +1,369 @@ +//! Native reservations and leases. No lease lock spans filesystem or SDK IO. +use super::{ + EffectCancellation, EffectDeadline, broker::validate_id, journal::ManagedObjectKind, tree_store, +}; +use crate::{ + capabilities::vm_io::{ConfinedFsRoot, confined_fs::EnumerationBudget}, + config::ManagedWorkspaceConfig, + gateway::store::GatewayPersistence, +}; +use parking_lot::Mutex; +use std::{ + collections::BTreeMap, + sync::Arc, + time::{Duration, Instant}, +}; + +#[derive(Clone, Copy, Default)] +struct Charge { + objects: usize, + bytes: u64, +} +struct Entry { + bytes: u64, + leases: usize, + unknown: bool, +} +struct State { + used: Charge, + entries: BTreeMap, +} +pub(crate) struct ManagedObjectLeaseRegistry { + max: Charge, + state: Mutex, +} +pub(crate) struct ManagedCallReservation { + registry: Arc, + remaining: Mutex, +} +pub(crate) struct ManagedObjectTicket { + registry: Arc, + bytes: u64, + id: Option, + active: bool, +} +pub(crate) struct ManagedObjectLease { + registry: Arc, + id: String, +} + +impl ManagedObjectLeaseRegistry { + #[cfg(test)] + pub(crate) fn lease_existing_for_test( + self: &Arc, + id: &str, + ) -> Result { + let mut state = self.state.lock(); + let entry = state + .entries + .get_mut(id) + .ok_or("Native object lease target missing")?; + entry.leases += 1; + Ok(ManagedObjectLease { + registry: Arc::clone(self), + id: id.into(), + }) + } + pub(crate) fn open( + config: &ManagedWorkspaceConfig, + root: &ConfinedFsRoot, + persistence: &GatewayPersistence, + store_id: &str, + ) -> Result, String> { + let deadline = EffectDeadline::at(Instant::now() + Duration::from_secs(5)); + let cancellation = EffectCancellation::default(); + let mut entries: BTreeMap = BTreeMap::new(); + for entry in root + .enumerate_with_budget( + "", + EnumerationBudget { + max_entries: 16, + max_name_bytes: 255, + }, + ) + .map_err(|e| e.to_string())? + { + if ![ + "lease", "store-id", "trees", "results", "details", "views", "staging", + ] + .contains(&entry.name()) + { + return Err( + "managed store contains an unknown namespace; retained unchanged".into(), + ); + } + } + for namespace in ["trees", "results", "details", "views", "staging"] { + deadline.check().map_err(|e| e.to_string())?; + let observed = root + .enumerate_with_budget( + namespace, + EnumerationBudget { + max_entries: config.retention.max_objects + 2, + max_name_bytes: 255, + }, + ) + .map_err(|e| e.to_string())?; + for item in observed { + let id = item + .name_os() + .to_str() + .ok_or("managed census name must be UTF-8")?; + let prefix = match namespace { + "trees" => "tree", + "results" => "result", + "details" => "intent", + "views" => "view", + _ => id.split_once('-').ok_or("managed staging name invalid")?.0, + }; + validate_id(prefix, id)?; + if entries.contains_key(id) { + return Err("managed object appears in multiple namespaces".into()); + } + let metadata = item.metadata(); + let bytes = if namespace == "views" { + if !metadata.is_dir() { + return Err("managed view has wrong kind".into()); + } + config.quota.max_bytes + } else if metadata.is_dir() { + let directory = root + .subroot(&format!("{namespace}/{id}"), root.limits()) + .map_err(|e| e.to_string())?; + tree_store::scan_usage_from_fd( + &directory + .duplicate_descriptor() + .map_err(|e| e.to_string())?, + &config.quota, + &deadline, + &cancellation, + ) + .map_err(|e| e.to_string())? + .bytes + } else if metadata.is_file() && metadata.link_count() == 1 { + metadata.len() + } else { + return Err("managed census contains unsupported object".into()); + }; + entries.insert( + id.to_string(), + Entry { + bytes, + leases: 0, + unknown: true, + }, + ); + if entries.len() > config.retention.max_objects { + return Err("managed retained object capacity exceeded".into()); + } + } + } + for object in persistence + .managed_object_census() + .map_err(|e| e.to_string())? + { + if object.store_id != store_id { + return Err("managed store marker differs from durable registry".into()); + } + let prefix = match object.object_kind { + ManagedObjectKind::Tree => "tree", + ManagedObjectKind::CanonicalResult => "result", + ManagedObjectKind::IntentDetails => "intent", + }; + validate_id(prefix, &object.object_id)?; + let bytes = object + .byte_count + .parse::() + .map_err(|_| "managed census byte count invalid")?; + if let Some(entry) = entries.get_mut(&object.object_id) { + if entry.bytes != bytes { + return Err("managed object size differs from durable registry".into()); + } + entry.unknown = false; + } else { + entries.insert( + object.object_id, + Entry { + bytes, + leases: 0, + unknown: true, + }, + ); + } + } + let used = Charge { + objects: entries.len(), + bytes: entries + .values() + .try_fold(0u64, |sum, entry| sum.checked_add(entry.bytes)) + .ok_or("managed census bytes overflow")?, + }; + if used.objects > config.retention.max_objects || used.bytes > config.retention.max_bytes { + return Err("managed retained capacity exceeded".into()); + } + Ok(Arc::new(Self { + max: Charge { + objects: config.retention.max_objects, + bytes: config.retention.max_bytes, + }, + state: Mutex::new(State { used, entries }), + })) + } + pub(crate) fn reserve( + self: &Arc, + objects: usize, + bytes: u64, + ) -> Result, String> { + let mut state = self.state.lock(); + let count = state + .used + .objects + .checked_add(objects) + .ok_or("managed reservation overflow")?; + let total = state + .used + .bytes + .checked_add(bytes) + .ok_or("managed reservation overflow")?; + if count > self.max.objects || total > self.max.bytes { + return Err("managed retention capacity exceeded".into()); + } + state.used = Charge { + objects: count, + bytes: total, + }; + Ok(Arc::new(ManagedCallReservation { + registry: Arc::clone(self), + remaining: Mutex::new(Charge { objects, bytes }), + })) + } + pub(crate) fn single(self: &Arc, bytes: u64) -> Result { + self.reserve(1, bytes)?.take_object(bytes) + } + fn release(&self, charge: Charge) { + let mut state = self.state.lock(); + state.used.objects -= charge.objects; + state.used.bytes -= charge.bytes; + } +} +impl ManagedCallReservation { + pub(crate) fn take_object(&self, bytes: u64) -> Result { + let mut remaining = self.remaining.lock(); + if remaining.objects == 0 || remaining.bytes < bytes { + return Err("managed object reservation exhausted".into()); + } + remaining.objects -= 1; + remaining.bytes -= bytes; + Ok(ManagedObjectTicket { + registry: Arc::clone(&self.registry), + bytes, + id: None, + active: true, + }) + } + pub(crate) fn release_unspent(&self) { + let remaining = std::mem::take(&mut *self.remaining.lock()); + self.registry.release(remaining); + } +} +impl Drop for ManagedCallReservation { + fn drop(&mut self) { + self.release_unspent(); + } +} +impl ManagedObjectTicket { + pub(crate) fn start(&mut self, id: &str) -> Result<(), String> { + if self.id.is_some() { + return Err("managed object ticket already used".into()); + } + let mut state = self.registry.state.lock(); + if state.entries.contains_key(id) { + return Err("managed object identity already retained".into()); + } + state.entries.insert( + id.into(), + Entry { + bytes: self.bytes, + leases: 1, + unknown: false, + }, + ); + self.id = Some(id.into()); + Ok(()) + } + pub(crate) fn seal(mut self, bytes: u64) -> Result { + if bytes > self.bytes { + return Err("managed object exceeded its reservation".into()); + } + let id = self.id.clone().ok_or("managed object has not started")?; + { + let mut state = self.registry.state.lock(); + state.used.bytes -= self.bytes - bytes; + let entry = state + .entries + .get_mut(&id) + .ok_or("managed object lease missing")?; + entry.bytes = bytes; + } + self.active = false; + Ok(ManagedObjectLease { + registry: Arc::clone(&self.registry), + id, + }) + } + pub(crate) fn sole_known_lease(&self) -> bool { + self.id + .as_ref() + .and_then(|id| { + self.registry + .state + .lock() + .entries + .get(id) + .map(|entry| entry.leases == 1 && !entry.unknown) + }) + .unwrap_or(false) + } + pub(crate) fn retire(mut self) -> Result<(), String> { + let id = self.id.as_ref().ok_or("managed object has not started")?; + let mut state = self.registry.state.lock(); + let entry = state + .entries + .get(id) + .ok_or("managed object lease missing")?; + if entry.leases != 1 || entry.unknown { + return Err("managed object lease is not exclusively settled".into()); + } + state.entries.remove(id); + state.used.objects -= 1; + state.used.bytes -= self.bytes; + self.active = false; + Ok(()) + } +} +impl Drop for ManagedObjectTicket { + fn drop(&mut self) { + if !self.active { + return; + } + if let Some(id) = &self.id { + let mut state = self.registry.state.lock(); + if let Some(entry) = state.entries.get_mut(id) { + entry.unknown = true; + entry.leases -= 1; + } + } else { + self.registry.release(Charge { + objects: 1, + bytes: self.bytes, + }); + } + } +} +impl Drop for ManagedObjectLease { + fn drop(&mut self) { + if let Some(entry) = self.registry.state.lock().entries.get_mut(&self.id) { + entry.leases -= 1; + } + } +} diff --git a/src/effects/tree_store.rs b/src/effects/tree_store.rs index 140ade5..138e125 100644 --- a/src/effects/tree_store.rs +++ b/src/effects/tree_store.rs @@ -151,6 +151,58 @@ impl WorkRoot { &self.path } + pub(crate) fn with_accounted_atomic_write( + &self, + rel: &str, + bytes: &[u8], + cancellation: &EffectCancellation, + deadline: &EffectDeadline, + publish: impl FnOnce() -> Result, + ) -> Result, EffectError> { + let _mutation = self.mutation.lock(); + let components = validate_relative(rel)?; + self.ensure_depth(&components)?; + let parent = self.open_parent(&components)?; + let name = components.last().expect("validated relative path"); + let plan = scan(&self.dir, &self.quota, deadline, cancellation)?; + let old = match stat_at(&parent, name) { + Ok(stat) => { + reject_special(&stat, "managed write")?; + if file_kind(stat.st_mode) != FileKind::File || stat.st_nlink != 1 { + return Err(unsupported( + "managed write target is not an independent regular file", + )); + } + Some(file_len(stat)?) + } + Err(error) if error.kind() == EffectErrorKind::NotFound => None, + Err(error) => return Err(error), + }; + let entries = plan + .entries_count + .checked_add(u64::from(old.is_none())) + .ok_or_else(stale_usage)?; + let size = plan + .bytes + .checked_sub(old.unwrap_or(0)) + .and_then(|n| n.checked_add(bytes.len() as u64)) + .ok_or_else(stale_usage)?; + if entries > self.quota.max_entries || size > self.quota.max_bytes { + return Err(quota_error("managed atomic write exceeds aggregate quota")); + } + cancellation.poll_quiet()?; + deadline.check()?; + let result = publish(); + // Reconcile the actual publication, including an original IO result that + // reports a known publication after an fsync error. No second write. + let after = scan(&self.dir, &self.quota, deadline, cancellation)?; + *self.usage.lock() = Usage { + entries: after.entries_count, + bytes: after.bytes, + }; + Ok(result) + } + pub(crate) fn write_file(&self, rel: &str, bytes: &[u8]) -> Result<(), EffectError> { let _mutation = self.mutation.lock(); let components = validate_relative(rel)?; @@ -399,7 +451,34 @@ pub(crate) fn copy_tree( return Err(unsupported("import source is not a real directory")); } let source_dir = open_dir(source)?; - let mut plan = scan(&source_dir, quota, deadline, cancellation)?; + copy_tree_from_fd(&source_dir, dest, quota, cancellation, deadline, seal) +} + +pub(crate) fn scan_usage_from_fd( + root: &OwnedFd, + quota: &ResourceQuota, + deadline: &EffectDeadline, + cancellation: &EffectCancellation, +) -> Result { + let plan = scan(root, quota, deadline, cancellation)?; + Ok(Usage { + entries: plan.entries_count, + bytes: plan.bytes, + }) +} + +/// Copies from the retained authorized descriptor, never its display path. +pub(crate) fn copy_tree_from_fd( + source_dir: &OwnedFd, + dest: &Path, + quota: &ResourceQuota, + cancellation: &EffectCancellation, + deadline: &EffectDeadline, + seal: bool, +) -> Result { + deadline.check()?; + cancellation.poll_quiet()?; + let mut plan = scan(source_dir, quota, deadline, cancellation)?; plan.entries.sort_by(|left, right| left.rel.cmp(&right.rel)); fs::create_dir(dest).map_err(|error| io_error("create copy", error))?; let guard = RemoveGuard { @@ -407,8 +486,8 @@ pub(crate) fn copy_tree( armed: true, }; let dest_dir = open_dir(dest)?; - materialize(&source_dir, &dest_dir, &plan, cancellation, deadline, seal)?; - verify_unchanged(&source_dir, &dest_dir, &plan)?; + materialize(source_dir, &dest_dir, &plan, cancellation, deadline, seal)?; + verify_unchanged(source_dir, &dest_dir, &plan)?; fsync_fd(&dest_dir)?; guard.keep(); Ok(TreeCopy { diff --git a/src/effects/types.rs b/src/effects/types.rs index 4c82307..47e4a65 100644 --- a/src/effects/types.rs +++ b/src/effects/types.rs @@ -256,6 +256,7 @@ pub struct EffectCancellation { } struct CancelInner { + native_check: Option bool + Send + Sync>>, flag: AtomicBool, cancel_at: AtomicU64, checks: AtomicU64, @@ -273,8 +274,17 @@ impl fmt::Debug for EffectCancellation { impl EffectCancellation { pub fn new() -> Self { + Self::with_native_check(None) + } + + pub(crate) fn linked(check: impl Fn() -> bool + Send + Sync + 'static) -> Self { + Self::with_native_check(Some(Arc::new(check))) + } + + fn with_native_check(native_check: Option bool + Send + Sync>>) -> Self { Self { inner: Arc::new(CancelInner { + native_check, flag: AtomicBool::new(false), cancel_at: AtomicU64::new(u64::MAX), checks: AtomicU64::new(0), @@ -310,7 +320,13 @@ impl EffectCancellation { } pub(crate) fn poll_quiet(&self) -> Result<(), EffectError> { - if self.inner.flag.load(Ordering::SeqCst) { + if self.inner.flag.load(Ordering::SeqCst) + || self + .inner + .native_check + .as_ref() + .is_some_and(|check| check()) + { Err(EffectError::new( EffectErrorKind::Cancelled, "cancellation requested", diff --git a/src/events.rs b/src/events.rs index 0b68aa8..e80382c 100644 --- a/src/events.rs +++ b/src/events.rs @@ -90,6 +90,22 @@ pub enum EventCommitError { /// Durable-first event sink used by lifecycle committers. Implementations must /// not publish after the run has committed a terminal state. pub trait DurableEventCommitter: Send + Sync { + fn commit_managed_started( + &self, + _witness: &crate::effects::ManagedStartedWitness, + ) -> Result<(), EventCommitError> { + Err(EventCommitError::PersistFailed( + "managed_atomic_commit_unsupported".into(), + )) + } + fn commit_managed_result( + &self, + _witness: &crate::effects::ManagedFinalWitness, + ) -> Result { + Err(EventCommitError::PersistFailed( + "managed_atomic_commit_unsupported".into(), + )) + } fn is_terminal(&self) -> bool; fn stop_requested(&self) -> bool { false diff --git a/src/gateway/managed_storage_budget_tests.rs b/src/gateway/managed_storage_budget_tests.rs new file mode 100644 index 0000000..335b317 --- /dev/null +++ b/src/gateway/managed_storage_budget_tests.rs @@ -0,0 +1,228 @@ +use super::*; + +#[test] +fn managed_aggregate_sql_parameter_budget_precedes_sdk_enqueue_and_matches_fixed_rss() { + let source = include_str!("../../rss/storage/effects.rss"); + for op in [ + "workspace_get", + "workspace_register", + "head", + "get", + "begin", + "prepare", + "commit", + "pending", + "recover_one", + "recovery_context", + ] { + let function = source + .split(&format!("pub fn {op}(")) + .nth(1) + .unwrap() + .split("pub fn ") + .next() + .unwrap(); + let mut actual = std::collections::BTreeMap::new(); + for tail in function.split("text(input.").skip(1) { + let field = tail.split(['.', ')']).next().unwrap(); + *actual.entry(field).or_insert(0usize) += 1; + } + let declared = managed_parameter_bindings(&format!("effect.{op}")) + .iter() + .copied() + .collect::>(); + assert_eq!( + declared, actual, + "fixed RSS parameter accounting drift for {op}" + ); + } + let (_root, persistence) = persistence(); + let before = persistence.executed_storage_commands(); + // Private transport negative: encoded request fits, but repeated fixed SQL + // parameters exceed the aggregate contract. No SQL or DB bytes are authored. + let payload = json!({"workspace_json":"x".repeat(100_000)}); + assert!(serde_json::to_vec(&payload).unwrap().len() < crate::effects::journal::METADATA_BYTES); + let error = persistence + .managed_effect_command("effect.workspace_register", &payload) + .unwrap_err(); + assert_eq!(persistence.executed_storage_commands(), before); + assert!(error.message.contains("aggregate parameter")); + persistence.shutdown(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn managed_reopen_preserves_nonzero_formal_provider_usage() { + use crate::config::{ManagedRetentionLimits, ManagedWorkspaceConfig}; + let root = std::path::PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()) + .join(format!("managed-usage-{}", Uuid::new_v4().simple())); + let workspace = root.join("workspace"); + let home = root.join("home"); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::create_dir(&home).unwrap(); + std::fs::write(home.join("config.yaml"),format!("version: 1\nmodel:\n provider: local-test\n model: synthetic\nproviders:\n local-test:\n protocol: codex-responses\n base_url: https://127.0.0.1:1\nworkspaces:\n allowed_roots:\n - '{}'\n default: '{}'\n",workspace.display(),workspace.display())).unwrap(); + let config = AgentGatewayConfig { + managed_workspace: Some(ManagedWorkspaceConfig { + source_root: workspace, + quota: crate::effects::ResourceQuota::new(1000, 1048576, 16).unwrap(), + retention: ManagedRetentionLimits::default(), + }), + ..Default::default() + }; + let database = root.join("gateway.sqlite"); + let state = crate::AgentGatewayState::with_sqlite_path(config.clone(), &database) + .unwrap() + .with_workspace_home(&home); + let run = state + .service() + .admit_external_with_workspace_selector( + crate::AdmitRunRequest { + input: json!({"message":"retained usage audit"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + let persistence = state.persistence().unwrap(); + // Use the original formal RSS usage command on the existing SDK worker. + // These are explicit synthetic accounting facts, not reported model usage. + let usage=persistence.command_data("run.usage",&json!({"id":"managed-usage-fact","run_id":run.run_id,"provider":"synthetic","model":"synthetic","request_count":1,"input_tokens":17,"output_tokens":9,"reasoning_tokens":3,"cached_tokens":2,"now_ms":1})).unwrap(); + assert_eq!(usage["rows"][0][1], 17); + assert_eq!(usage["rows"][0][2], 9); + drop(persistence); + state.service().shutdown().await.unwrap(); + drop(state); + let reopened = crate::AgentGatewayState::with_sqlite_path(config, &database) + .unwrap() + .with_workspace_home(&home); + let persistence = reopened.persistence().unwrap(); + let row = persistence.run_get(&run.run_id).unwrap(); + for (name, count) in [("input_tokens", 17), ("output_tokens", 9)] { + let column = row["columns"] + .as_array() + .unwrap() + .iter() + .position(|value| value == name) + .unwrap(); + assert_eq!(row["rows"][0][column], count); + } + drop(persistence); + reopened.service().shutdown().await.unwrap(); +} +fn persistence() -> (std::path::PathBuf, GatewayPersistence) { + let root = std::path::PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()) + .join(format!("managed-budget-{}", Uuid::new_v4().simple())); + std::fs::create_dir(&root).unwrap(); + let persistence = + GatewayPersistence::open(&AgentGatewayConfig::default(), &root.join("state.sqlite")) + .unwrap(); + persistence.load().unwrap(); + (root, persistence) +} +#[test] +fn managed_pending_32_admissions_reject_33rd_before_sdk_and_recover_after_real_native_permit_drop() +{ + let (_root, persistence) = persistence(); + let mut permits = (0..32) + .map(|_| ManagedStoragePermit::acquire(&persistence.managed_pending).unwrap()) + .collect::>(); + let before = persistence.executed_storage_commands(); + assert!( + persistence + .managed_effect_command("effect.workspace_get", &json!({})) + .is_err(), + "all Native managed request admissions, including metadata queries, must obey the 32-pending bound" + ); + assert_eq!(persistence.executed_storage_commands(), before); + permits.pop(); + let result = persistence + .managed_effect_command("effect.workspace_get", &json!({})) + .unwrap(); + assert_eq!(result["rows"], json!([])); + drop(permits); + assert_eq!(persistence.managed_pending.load(Ordering::Acquire), 0); + persistence.shutdown(); +} +#[test] +fn managed_native_permit_is_owned_by_queued_request_until_worker_acknowledgement_even_without_receiver() + { + let (_root, persistence) = persistence(); + let (respond, receiver) = mpsc::channel(); + drop(receiver); + let permit = ManagedStoragePermit::acquire(&persistence.managed_pending).unwrap(); + persistence + .worker + .sender + .send(StorageRequest { + op: "load".into(), + payload: json!({}), + respond, + cancellation: Some(crate::RunCancellation::with_timeout(Duration::from_secs(5))), + _managed_permit: Some(permit), + }) + .unwrap(); + // The following SDK request is ordered after the dropped-receiver request. + // Its acknowledgement proves that the first owned request has settled. + persistence + .managed_effect_command("effect.workspace_get", &json!({})) + .unwrap(); + assert_eq!(persistence.managed_pending.load(Ordering::Acquire), 0); + persistence.shutdown(); +} +#[test] +fn original_native_cancellation_while_waiting_for_storage_admission_does_not_enqueue_or_release_early() + { + let (_root, persistence) = persistence(); + let persistence = Arc::new(persistence); + let block = persistence.inject_block_persist(); + let original = crate::RunCancellation::with_timeout(Duration::from_secs(5)); + let (owner, cancellation) = (Arc::clone(&persistence), original.clone()); + let worker = std::thread::spawn(move || { + owner.managed_effect_command_with_cancellation( + "effect.workspace_get", + &json!({}), + &cancellation, + ) + }); + block.wait_entered(); + let charged = persistence.managed_pending.load(Ordering::Acquire); + let before = persistence.executed_storage_commands(); + original.request(crate::CancellationReason::Requested); + block.release(); + let result = worker.join().unwrap(); + assert_eq!(charged, 1); + assert!(result.is_err()); + assert_eq!(persistence.executed_storage_commands(), before); + assert_eq!(persistence.managed_pending.load(Ordering::Acquire), 0); + persistence.shutdown(); +} +#[test] +fn original_native_cancellation_and_deadline_are_checked_before_storage_enqueue_without_renewal() { + let (_root, persistence) = persistence(); + let original = crate::RunCancellation::with_timeout(Duration::from_secs(5)); + let deadline = original.deadline_instant(); + original.request(crate::CancellationReason::Requested); + let before = persistence.executed_storage_commands(); + assert!( + persistence + .managed_effect_command_with_cancellation("effect.workspace_get", &json!({}), &original) + .is_err(), + "a Native caller cancellation cannot be replaced with a fresh storage timeout" + ); + assert_eq!(persistence.executed_storage_commands(), before); + assert_eq!(original.deadline_instant(), deadline); + let expired = crate::RunCancellation::with_deadline(Instant::now()); + assert!( + persistence + .managed_effect_command_with_cancellation("effect.workspace_get", &json!({}), &expired) + .is_err() + ); + assert_eq!(persistence.executed_storage_commands(), before); + let active = crate::RunCancellation::with_timeout(Duration::from_secs(5)); + let deadline = active.deadline_instant(); + persistence + .managed_effect_command_with_cancellation("effect.workspace_get", &json!({}), &active) + .unwrap(); + assert_eq!(active.deadline_instant(), deadline); + persistence.shutdown(); +} diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 1e6d580..a2ca591 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -374,23 +374,12 @@ impl AgentGatewayState { .map_err(|error| format!("compile RSS agent source: {error}"))?; let http_config = config.http.clone(); let metrics = Arc::new(Metrics::default()); - let persistence = Arc::new( - store::GatewayPersistence::open_with_metrics( - &config, - path.as_ref(), - Arc::clone(&metrics), - ) - .map_err(|error| format!("open gateway SQLite state: {error}"))?, - ); - let loaded_store = persistence - .load() - .map_err(|error| format!("load gateway SQLite state: {error}"))?; - let store = Arc::new(RwLock::new(loaded_store)); + let (store, persistence) = open_gateway_store(&config, Some(path.as_ref()), &metrics)?; let agent_source = Some(Arc::new(source)); let service = Arc::new(AgentService::new( Arc::new(config), Arc::clone(&store), - Some(persistence), + persistence, agent_source.clone(), http_config.clone(), Arc::clone(&metrics), @@ -550,6 +539,14 @@ fn open_gateway_store( sqlite_path: Option<&FsPath>, metrics: &Arc, ) -> Result { + if config.managed_workspace.is_some() + && sqlite_path.is_none_or(|path| !path.is_absolute() || path == FsPath::new(":memory:")) + { + return Err( + "managed workspace requires an absolute persistent database; persistence is mandatory" + .into(), + ); + } match sqlite_path { Some(path) => { let persistence = Arc::new( diff --git a/src/gateway/store.rs b/src/gateway/store.rs index fea7cbe..4f5e7db 100644 --- a/src/gateway/store.rs +++ b/src/gateway/store.rs @@ -103,6 +103,9 @@ struct ApprovalExpireHook { } pub struct GatewayPersistence { + #[cfg(all(test, feature = "workspace-io-fault"))] + drop_next_managed_commit_ack: Arc, + managed_pending: Arc, worker: StorageWorker, max_events: i64, broadcast_capacity: usize, @@ -379,11 +382,30 @@ impl Drop for PluginActivateReceiptAttempt { } } +struct ManagedStoragePermit(Arc); +impl ManagedStoragePermit { + fn acquire(pending: &Arc) -> Result { + pending + .try_update(Ordering::AcqRel, Ordering::Acquire, |count| { + (count < 32).then_some(count + 1) + }) + .map_err(|_| "managed storage pending-operation bound exceeded".to_string())?; + Ok(Self(Arc::clone(pending))) + } +} +impl Drop for ManagedStoragePermit { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::AcqRel); + } +} + /// One serialized storage request for the dedicated worker thread. struct StorageRequest { op: String, payload: Value, respond: Sender>, + cancellation: Option, + _managed_permit: Option, } /// The dedicated storage worker: owns the compiled RSS program and executes @@ -430,7 +452,17 @@ impl StorageRunner { }) } - fn command(&self, op: &str, payload: &Value) -> Result { + fn command( + &self, + op: &str, + payload: &Value, + cancellation: Option<&crate::RunCancellation>, + ) -> Result { + if cancellation + .is_some_and(|cancel| cancel.requested().is_some() || cancel.deadline_passed()) + { + return Err("managed storage original deadline/cancellation".into()); + } #[cfg(test)] self.executed_commands.fetch_add(1, Ordering::Relaxed); let input = VmValue::map(vec![ @@ -458,10 +490,14 @@ impl StorageRunner { VmValue::string(payload.to_string()), ), ]); - let result = self - .runner - .run_with_context(input) - .map_err(|error| format!("run RSS storage operation {op}: {error}"))?; + let execution = if let Some(cancellation) = cancellation { + self.runner + .run_with_context_and_events(input, &mut StorageNoEvents, cancellation) + } else { + self.runner.run_with_context(input) + }; + let result = + execution.map_err(|error| format!("run RSS storage operation {op}: {error}"))?; let VmValue::Map(result) = result else { return Err(format!( "RSS storage operation {op} returned a non-map result" @@ -471,17 +507,43 @@ impl StorageRunner { } } +struct StorageNoEvents; +impl crate::RunEventSink for StorageNoEvents { + fn deliver(&mut self, _value: VmValue) -> Result<(), crate::RunDeliveryError> { + Err(crate::RunDeliveryError::Rejected { + code: "managed_storage_event_denied", + message: "storage command cannot emit live events".into(), + }) + } +} + /// Runs the worker loop: execute one request at a time on this dedicated /// thread, then answer. A dropped sender (or shutdown signal) ends the loop. fn storage_worker_loop( runner: StorageRunner, requests: Receiver, shutdown: Receiver<()>, + _drop_next_managed_commit_ack: Arc, ) { loop { match requests.recv_timeout(Duration::from_millis(200)) { - Ok(request) => { - let result = runner.command(&request.op, &request.payload); + Ok(mut request) => { + let result = + runner.command(&request.op, &request.payload, request.cancellation.as_ref()); + // The SDK operation has settled; acknowledge only after its + // Native admission charge is released, even if the receiver left. + drop(request._managed_permit.take()); + #[cfg(all(test, feature = "workspace-io-fault"))] + if request.op == "effect.commit" + && result + .as_ref() + .is_ok_and(|value| value.get("ok") == Some(&Value::Bool(true))) + && _drop_next_managed_commit_ack.swap(false, Ordering::AcqRel) + { + // Discard the real successful SDK reply by closing its + // response sender. The durable transaction is not undone. + continue; + } let _ = request.respond.send(result); } Err(RecvTimeoutError::Timeout) => { @@ -569,6 +631,69 @@ impl CompactionContextSnapshot { } } +// Fixed RSS text-parameter occurrences. Count all mutually exclusive resend +// branches conservatively; do not depend on a storage prequery for admission. +// The paired source-catalog test requires an explicit update on RSS changes. +fn managed_parameter_bindings(op: &str) -> &'static [(&'static str, usize)] { + match op { + "effect.workspace_get" => &[ + ("store_id", 1), + ("profile", 1), + ("source_device", 1), + ("source_inode", 1), + ], + "effect.workspace_register" => &[ + ("workspace_json", 11), + ("store_id", 4), + ("profile", 4), + ("source_device", 4), + ("source_inode", 4), + ], + "effect.head" => &[("workspace_id", 1), ("branch_id", 1)], + "effect.get" => &[("run_id", 1), ("call_id", 1), ("profile", 1)], + "effect.begin" => &[ + ("effect_id", 6), + ("run_id", 8), + ("call_id", 2), + ("record_json", 12), + ("workspace_id", 1), + ("branch_id", 1), + ("commit_id", 3), + ("projections_json", 8), + ("cut", 1), + ], + "effect.prepare" => &[ + ("effect_id", 5), + ("receipt_id", 2), + ("prepared_json", 7), + ("cut", 1), + ], + "effect.commit" => &[ + ("effect_id", 13), + ("run_id", 7), + ("workspace_id", 2), + ("branch_id", 2), + ("commit_id", 8), + ("receipt_id", 1), + ("prepared_json", 9), + ("cut", 5), + ("projections_json", 13), + ], + "effect.recover_one" => &[ + ("commit_id", 4), + ("projections_json", 13), + ("effect_id", 5), + ("run_id", 5), + ], + "effect.recovery_context" => &[("call_id", 1), ("run_id", 1)], + _ => &[], + } +} + +#[cfg(test)] +#[path = "managed_storage_budget_tests.rs"] +mod managed_storage_budget_tests; + impl GatewayPersistence { pub(crate) fn compaction_context_snapshot( &self, @@ -614,11 +739,16 @@ impl GatewayPersistence { let executed_commands = Arc::clone(&runner.executed_commands); let (sender, requests) = mpsc::channel::(); let (shutdown_sender, shutdown) = mpsc::channel::<()>(); + let drop_next_managed_commit_ack = Arc::new(AtomicBool::new(false)); + let worker_ack_fault = Arc::clone(&drop_next_managed_commit_ack); let thread = std::thread::Builder::new() .name("gateway-storage-worker".to_string()) - .spawn(move || storage_worker_loop(runner, requests, shutdown)) + .spawn(move || storage_worker_loop(runner, requests, shutdown, worker_ack_fault)) .map_err(|error| format!("spawn gateway storage worker: {error}"))?; Ok(Self { + #[cfg(all(test, feature = "workspace-io-fault"))] + drop_next_managed_commit_ack, + managed_pending: Arc::new(std::sync::atomic::AtomicUsize::new(0)), worker: StorageWorker { sender, shutdown: shutdown_sender, @@ -661,10 +791,44 @@ impl GatewayPersistence { self.worker.executed_commands.load(Ordering::Relaxed) } + #[cfg(all(test, feature = "workspace-io-fault"))] + pub(crate) fn inject_drop_managed_commit_ack(&self) { + self.drop_next_managed_commit_ack + .store(true, Ordering::Release); + } + #[cfg(all(test, feature = "workspace-io-fault"))] + pub(crate) fn managed_commit_ack_drop_armed(&self) -> bool { + self.drop_next_managed_commit_ack.load(Ordering::Acquire) + } + /// Runs one command on the dedicated storage worker and blocks (bounded) /// for the response. The worker thread executes the RSS program; caller /// threads never run storage code themselves. fn command(&self, op: &str, payload: &Value) -> Result { + self.command_with_cancellation(op, payload, None) + } + fn command_with_cancellation( + &self, + op: &str, + payload: &Value, + parent: Option<&crate::RunCancellation>, + ) -> Result { + let cancellation = op.starts_with("effect.").then(|| { + parent + .map(crate::RunCancellation::child) + .unwrap_or_else(|| crate::RunCancellation::with_timeout(Duration::from_secs(5))) + }); + if cancellation + .as_ref() + .is_some_and(|cancel| cancel.requested().is_some() || cancel.deadline_passed()) + { + return Err("managed storage original deadline/cancellation".into()); + } + let managed_permit = if cancellation.is_some() { + Some(ManagedStoragePermit::acquire(&self.managed_pending)?) + } else { + None + }; if let Some(block) = self .persist_block .lock() @@ -681,6 +845,12 @@ impl GatewayPersistence { .storage_op(crate::metrics::StorageOp::from_command(op), false); return Err("injected persist failure".to_string()); } + if cancellation + .as_ref() + .is_some_and(|cancel| cancel.requested().is_some() || cancel.deadline_passed()) + { + return Err("managed storage original deadline/cancellation".into()); + } let result = if self .worker .closed @@ -693,6 +863,8 @@ impl GatewayPersistence { op: op.to_string(), payload: payload.clone(), respond, + cancellation, + _managed_permit: managed_permit, }); match sent { Ok(()) => response @@ -735,6 +907,334 @@ impl GatewayPersistence { decode_command_data(result.get("data").cloned().unwrap_or(Value::Null)) } + fn command_data_with_cancellation( + &self, + op: &str, + payload: &Value, + cancellation: &crate::RunCancellation, + ) -> Result { + let result = self + .command_with_cancellation(op, payload, Some(cancellation)) + .map_err(|message| StorageError { + code: "storage_unavailable".into(), + message, + })?; + if result.get("ok") != Some(&Value::Bool(true)) { + return Err(StorageError { + code: result + .get("code") + .and_then(Value::as_str) + .unwrap_or("storage_error") + .into(), + message: result + .get("message") + .and_then(Value::as_str) + .unwrap_or("storage command failed") + .into(), + }); + } + decode_command_data(result.get("data").cloned().unwrap_or(Value::Null)) + } + pub(crate) fn managed_effect_command_with_cancellation( + &self, + op: &str, + payload: &Value, + cancellation: &crate::RunCancellation, + ) -> Result { + self.managed_effect_command_inner(op, payload, Some(cancellation)) + } + pub(crate) fn managed_effect_command( + &self, + op: &str, + payload: &Value, + ) -> Result { + self.managed_effect_command_inner(op, payload, None) + } + + fn managed_effect_command_inner( + &self, + op: &str, + payload: &Value, + cancellation: Option<&crate::RunCancellation>, + ) -> Result { + use crate::effects::journal::{METADATA_BYTES, storage_error}; + const OPS: &[&str] = &[ + "effect.workspace_get", + "effect.workspace_register", + "effect.head", + "effect.get", + "effect.begin", + "effect.prepare", + "effect.commit", + "effect.pending", + "effect.recover_one", + "effect.recovery_context", + ]; + if !OPS.contains(&op) { + return Err(storage_error("unsupported managed operation")); + } + let mut wire = json!({"record_json":"", "prepared_json":"", "workspace_json":"", "projections_json":"", + "effect_id":"", "receipt_id":"", "commit_id":"", "run_id":"", "call_id":"", "store_id":"", + "profile":"", "source_device":"", "source_inode":"", "workspace_id":"", "branch_id":"", + "now_ms":timestamp(), "max_events":self.max_events, "cut":""}); + let fields = payload + .as_object() + .ok_or_else(|| storage_error("managed command must be a record"))?; + for (key, value) in fields { + if key == "owner" { + wire["run_id"] = value["run_id"].clone(); + wire["profile"] = value["profile"].clone(); + } else if wire.get(key).is_some() { + wire[key] = value.clone(); + } else { + return Err(storage_error("unknown managed command field")); + } + } + if serde_json::to_vec(&wire) + .map_err(|e| storage_error(e.to_string()))? + .len() + > METADATA_BYTES + { + return Err(storage_error( + "managed command exceeds 1 MiB transport bound", + )); + } + let parameter_bytes = + managed_parameter_bindings(op) + .iter() + .try_fold(0usize, |total, (field, count)| { + let value = wire + .get(*field) + .and_then(Value::as_str) + .ok_or_else(|| storage_error("managed text parameter kind invalid"))?; + total + .checked_add( + value.len().checked_mul(*count).ok_or_else(|| { + storage_error("managed aggregate parameter overflow") + })?, + ) + .ok_or_else(|| storage_error("managed aggregate parameter overflow")) + })?; + if parameter_bytes > METADATA_BYTES { + return Err(storage_error( + "managed aggregate parameter bytes exceed 1 MiB bound", + )); + } + if let Some(cancellation) = cancellation { + self.command_data_with_cancellation(op, &wire, cancellation) + } else { + self.command_data(op, &wire) + } + } + + /// Trusted SDK metadata lookup. IDs never create workspace authority. + pub(crate) fn managed_object_census( + &self, + ) -> Result, StorageError> { + use crate::effects::journal::{decode_record, storage_error}; + let value = self.command_data("effect.object_census", &json!({}))?; + if value.get("columns") != Some(&json!(["record_json"])) + || value.get("truncated") != Some(&json!(false)) + { + return Err(storage_error("managed census layout/truncation invalid")); + } + let rows = value + .get("rows") + .and_then(Value::as_array) + .ok_or_else(|| storage_error("managed census rows missing"))?; + if rows.len() > 128 { + return Err(storage_error("managed census row bound exceeded")); + } + rows.iter() + .map(|row| { + decode_record::( + &json!({"columns":["record_json"],"rows":[row],"truncated":false}), + ) + .map_err(storage_error)? + .ok_or_else(|| storage_error("managed census object missing")) + }) + .collect() + } + pub fn managed_object_metadata( + &self, + request: &crate::effects::ManagedObjectReferencesRequest, + ) -> Result, StorageError> { + use crate::effects::journal::storage_error; + crate::effects::broker::validate_id("store", &request.store_id).map_err(storage_error)?; + let prefix = request + .object_id + .split_once('-') + .ok_or_else(|| storage_error("managed object identifier invalid"))? + .0; + if !["tree", "result", "intent"].contains(&prefix) { + return Err(storage_error("managed object kind prefix invalid")); + } + crate::effects::broker::validate_id(prefix, &request.object_id).map_err(storage_error)?; + let value = self.command_data("effect.object_metadata", &json!(request))?; + let object: Option = + crate::effects::journal::decode_record(&value).map_err(storage_error)?; + if object.as_ref().is_some_and(|object| { + object.store_id != request.store_id || object.object_id != request.object_id + }) { + return Err(storage_error("managed object metadata identity mismatch")); + } + Ok(object) + } + pub fn managed_object_references( + &self, + request: &crate::effects::ManagedObjectReferencesRequest, + ) -> Result, StorageError> { + use crate::effects::journal::storage_error; + crate::effects::broker::validate_id("store", &request.store_id).map_err(storage_error)?; + let prefix = request + .object_id + .split_once('-') + .ok_or_else(|| storage_error("managed object identifier invalid"))? + .0; + if !["tree", "result", "intent", "view"].contains(&prefix) { + return Err(storage_error("managed object kind prefix invalid")); + } + crate::effects::broker::validate_id(prefix, &request.object_id).map_err(storage_error)?; + let value = self.command_data("effect.object_refs", &json!(request))?; + const COLUMNS: [&str; 4] = ["store_id", "object_id", "reference_kind", "reference_id"]; + if value.get("columns") != Some(&json!(COLUMNS)) + || value.get("truncated") != Some(&json!(false)) + { + return Err(storage_error("managed reference layout/truncation invalid")); + } + let rows = value + .get("rows") + .and_then(Value::as_array) + .ok_or_else(|| storage_error("managed reference rows missing"))?; + if rows.len() > 512 { + return Err(storage_error("managed reference count exceeds bound")); + } + let mut references = Vec::with_capacity(rows.len()); + for row in rows { + let cells = row + .as_array() + .ok_or_else(|| storage_error("managed reference row invalid"))?; + if cells.len() != 4 + || serde_json::to_vec(row) + .map_err(|_| storage_error("managed reference serialization failed"))? + .len() + > 4096 + { + return Err(storage_error("managed reference cell/byte bound invalid")); + } + let record: serde_json::Map = COLUMNS + .iter() + .zip(cells) + .map(|(key, value)| (key.to_string(), value.clone())) + .collect(); + let reference: crate::effects::ManagedObjectReference = + serde_json::from_value(Value::Object(record)) + .map_err(|_| storage_error("managed reference cell types invalid"))?; + if reference.store_id != request.store_id || reference.object_id != request.object_id { + return Err(storage_error("managed reference identity mismatch")); + } + references.push(reference); + } + Ok(references) + } + + pub fn managed_effect_by_id( + &self, + effect_id: &str, + ) -> Result, StorageError> { + crate::effects::broker::validate_id("effect", effect_id) + .map_err(crate::effects::journal::storage_error)?; + let value = self.command_data("effect.by_id", &json!({"effect_id":effect_id}))?; + crate::effects::journal::decode_record(&value) + .map_err(crate::effects::journal::storage_error) + } + pub fn managed_effect_list( + &self, + request: &crate::effects::ManagedEffectListRequest, + ) -> Result { + use crate::effects::journal::{decode_effect_summaries, storage_error}; + crate::effects::broker::validate_id("ws", &request.workspace_id).map_err(storage_error)?; + if request.limit == 0 || request.limit > 128 { + return Err(storage_error("managed page limit must be 1..=128")); + } + if let Some(cursor) = &request.after_effect_id { + crate::effects::broker::validate_id("effect", cursor).map_err(storage_error)?; + } + let wire = json!({"workspace_id":request.workspace_id,"after_effect_id":request.after_effect_id.as_deref().unwrap_or(""),"limit":request.limit}); + let entries = + decode_effect_summaries(&self.command_data("effect.list", &wire)?, request.limit) + .map_err(storage_error)?; + let has_more = if let Some(last) = entries.last() { + let value=self.command_data("effect.list_has_more",&json!({"workspace_id":request.workspace_id,"after_effect_id":last.effect_id,"limit":1}))?; + if value.get("columns") != Some(&json!(["effect_id"])) + || value.get("truncated") != Some(&json!(false)) + { + return Err(storage_error("managed cursor query layout invalid")); + } + let rows = value + .get("rows") + .and_then(Value::as_array) + .ok_or_else(|| storage_error("managed cursor rows missing"))?; + if rows.len() > 1 { + return Err(storage_error("managed cursor query exceeded one row")); + } + if let Some(row) = rows.first() { + let cells = row + .as_array() + .ok_or_else(|| storage_error("managed cursor cells invalid"))?; + if cells.len() != 1 { + return Err(storage_error("managed cursor column count invalid")); + } + crate::effects::broker::validate_id( + "effect", + cells[0] + .as_str() + .ok_or_else(|| storage_error("managed cursor must be text"))?, + ) + .map_err(storage_error)?; + } + !rows.is_empty() + } else { + false + }; + let next_cursor = if has_more { + entries.last().map(|entry| entry.effect_id.clone()) + } else { + None + }; + Ok(crate::effects::ManagedEffectPage { + entries, + next_cursor, + has_more, + }) + } + + pub fn managed_effect_get( + &self, + request: &crate::effects::ManagedEffectGetRequest, + ) -> Result, StorageError> { + let value = self.managed_effect_command("effect.get", &json!(request))?; + let record: Option = + crate::effects::journal::decode_record(&value) + .map_err(crate::effects::journal::storage_error)?; + Ok(record.filter(|record| { + record.identity.owner == request.owner && record.identity.call_id == request.call_id + })) + } + + pub fn managed_workspace_head( + &self, + workspace: &crate::effects::ManagedWorkspaceIdentity, + ) -> Result { + let value = self.managed_effect_command( + "effect.head", + &json!({"workspace_id":workspace.workspace_id,"branch_id":workspace.branch_id}), + )?; + crate::effects::journal::decode_record(&value) + .map_err(crate::effects::journal::storage_error)? + .ok_or_else(|| crate::effects::journal::storage_error("managed head missing")) + } + pub(crate) fn plugin_control_command( &self, op: &str, @@ -1344,7 +1844,7 @@ impl GatewayPersistence { let migrated = self .command_data("migrate", &json!({})) .map_err(|error| format!("migrate gateway state: {error}"))?; - if migrated.get("schema_version") != Some(&json!(5)) { + if migrated.get("schema_version") != Some(&json!(6)) { return Err(format!( "gateway schema migrated to an unexpected version: {migrated}" )); @@ -1371,6 +1871,8 @@ impl GatewayPersistence { return Err("restart recovery did not converge".to_string()); } } + crate::effects::journal::recover_managed_effects(self) + .map_err(|e| format!("recover managed effects: {e}"))?; let mut remaining = 1i64; let mut effect_rounds = 0u32; while remaining > 0 { diff --git a/src/plugins/mod.rs b/src/plugins/mod.rs index a9b6280..86b352d 100644 --- a/src/plugins/mod.rs +++ b/src/plugins/mod.rs @@ -33,9 +33,11 @@ pub use resolve::{ PackageRequirement, ResolveRequest, ScopeBinding, resolve_installed, }; pub use schema::{canonical_schema_documents, schema_snapshot_drift}; +pub(crate) use schema::{validate_managed_effect, validate_managed_workspace}; pub(crate) use store::embedding_payload_bytes; pub use store::{ InstallAuthorization, InstalledPackage, PackageRevision, PackageStore, PackageTrust, PublisherKey, }; +pub(crate) use yaml::strict_managed_yaml_to_json; pub use yaml::strict_yaml_to_json; diff --git a/src/plugins/pin.rs b/src/plugins/pin.rs index 4e67678..1020f1b 100644 --- a/src/plugins/pin.rs +++ b/src/plugins/pin.rs @@ -300,6 +300,7 @@ fn recover_pinned( } struct CapturedTool { + managed_contract: Option, program: Arc, invoke_entry: String, export: String, @@ -326,6 +327,7 @@ fn capture_tool( .ok_or_else(|| PinnedToolAdmitError::new("captured manifest has no export"))? .to_string(); Ok(CapturedTool { + managed_contract: prepared.managed_contract(&package.id), registry: prepared .registry(&package.id) .ok_or_else(|| PinnedToolAdmitError::new("prepared graph dropped tool registry"))?, @@ -340,6 +342,7 @@ fn capture_tool( fn binding_from_capture(captured: CapturedTool, receipt: PinnedToolReceipt) -> PinnedToolAdmission { let binding = PinnedToolProgram::seal( PinnedToolCode { + managed_contract: captured.managed_contract, program: captured.program, entry: captured.invoke_entry, package_id: captured.package_id.clone(), @@ -372,6 +375,24 @@ pub(crate) struct PreparedProductionPin { captured: CapturedTool, } +impl PreparedProductionPin { + pub(crate) fn managed_contract(&self) -> Option { + self.captured.managed_contract.clone() + } + pub(crate) fn managed_code(&self, registry: &str) -> crate::effects::ManagedCodeIdentity { + crate::effects::ManagedCodeIdentity { + graph_id: self.graph_id.clone(), + activation_generation: self.generation, + graph_ref_id: self.ref_id.clone(), + package_id: self.captured.package_id.clone(), + artifact_id: self.captured.artifact_id.clone(), + export: self.captured.export.clone(), + registry_identity: registry.into(), + effect_contract_version: 1, + } + } +} + pub(crate) fn prepare_production_pin( persistence: &GatewayPersistence, store: &PackageStore, diff --git a/src/plugins/prepare.rs b/src/plugins/prepare.rs index 2810db4..45228ba 100644 --- a/src/plugins/prepare.rs +++ b/src/plugins/prepare.rs @@ -25,6 +25,7 @@ pub struct PreparedGraph { callables: BTreeMap, exports: BTreeMap, trust: BTreeMap, + managed_contracts: BTreeMap, programs: BTreeMap>, // Prepared with the captured code, never with a run or capability owner. registries: BTreeMap, String>>, @@ -61,6 +62,13 @@ impl PreparedGraph { self.trust.get(package_id).copied() } + pub(crate) fn managed_contract( + &self, + package_id: &str, + ) -> Option { + self.managed_contracts.get(package_id).cloned() + } + /// Code-scoped import binding only; no run, owner, token or grant is retained. pub(crate) fn registry( &self, @@ -127,10 +135,42 @@ impl PackageStore { callables: BTreeMap::new(), exports: BTreeMap::new(), trust: BTreeMap::new(), + managed_contracts: BTreeMap::new(), programs: BTreeMap::new(), registries: BTreeMap::new(), }; for package in &selected { + let effects = lock + .closure + .iter() + .find(|item| { + item.package == package.manifest.id + && item.entry == package.manifest.entry.export + }) + .map(|entry| { + entry + .effects + .iter() + .map(|effect| crate::effects::broker::ManagedEffectRequirement { + kind: effect.kind.clone(), + scope: effect.scope.clone(), + recovery: effect.recovery.clone(), + }) + .collect() + }) + .unwrap_or_default(); + prepared.managed_contracts.insert( + package.manifest.id.clone(), + crate::effects::broker::ManagedEntryContract { + trust: package.trust, + effects, + single_builtin_graph: lock.packages.len() == 1 + && lock.edges.is_empty() + && package.manifest.dependencies.is_empty() + && package.manifest.id + == crate::runtime::native_tools::EMBEDDING_PACKAGE_ID, + }, + ); for (path, bytes) in &package.sources { prepared .sources diff --git a/src/plugins/schema.rs b/src/plugins/schema.rs index de6a20e..a7ca19e 100644 --- a/src/plugins/schema.rs +++ b/src/plugins/schema.rs @@ -19,6 +19,8 @@ const LOCK_SCHEMA: &str = include_str!("../../schemas/plugin-graph-lock.schema.y const ACTIVATION_SCHEMA: &str = include_str!("../../schemas/plugin-activation.schema.yaml"); const GRAPH_REF_SCHEMA: &str = include_str!("../../schemas/plugin-graph-ref.schema.yaml"); const CHECKPOINT_SCHEMA: &str = include_str!("../../schemas/v1/checkpoint.schema.yaml"); +const MANAGED_EFFECT_SCHEMA: &str = include_str!("../../schemas/managed-effect.schema.yaml"); +const MANAGED_WORKSPACE_SCHEMA: &str = include_str!("../../schemas/managed-workspace.schema.yaml"); const SCHEMA_SOURCES: &[(&str, &str)] = &[ ("plugin-manifest.schema.yaml", MANIFEST_SCHEMA), @@ -27,6 +29,8 @@ const SCHEMA_SOURCES: &[(&str, &str)] = &[ ("plugin-activation.schema.yaml", ACTIVATION_SCHEMA), ("plugin-graph-ref.schema.yaml", GRAPH_REF_SCHEMA), ("checkpoint.schema.yaml", CHECKPOINT_SCHEMA), + ("managed-effect.schema.yaml", MANAGED_EFFECT_SCHEMA), + ("managed-workspace.schema.yaml", MANAGED_WORKSPACE_SCHEMA), ]; pub fn canonical_schema_documents() -> Result, PluginError> { @@ -52,6 +56,12 @@ pub fn canonical_schema_documents() -> Result, Pl documents.insert("plugin-graph-ref.schema.json", canonical) } "checkpoint.schema.json" => documents.insert("checkpoint.schema.json", canonical), + "managed-effect.schema.json" => { + documents.insert("managed-effect.schema.json", canonical) + } + "managed-workspace.schema.json" => { + documents.insert("managed-workspace.schema.json", canonical) + } _ => None, }; } @@ -78,15 +88,38 @@ pub(crate) fn validate_lock(value: &Value) -> Result<(), PluginError> { validate_named("plugin-graph-lock.schema.yaml", value) } +pub(crate) fn validate_managed_effect(value: &Value) -> Result<(), PluginError> { + validate_named("managed-effect.schema.yaml", value) +} + +pub(crate) fn validate_managed_workspace(value: &Value) -> Result<(), PluginError> { + validate_named("managed-workspace.schema.yaml", value) +} + fn validate_named(name: &str, value: &Value) -> Result<(), PluginError> { let schemas = bundled_values()?; let registry = registry(&schemas)?; let selected = schemas .get(name) .ok_or_else(|| PluginError::new(PluginErrorKind::ContractIncompatible, name))?; + let mut selected = selected.clone(); + if name == "managed-effect.schema.yaml" { + selected + .as_object_mut() + .expect("bundled schema object") + .remove("oneOf"); + selected["$ref"] = Value::String( + if value.get("prepared_at_ms").is_some() { + "#/$defs/prepared" + } else { + "#/$defs/record" + } + .into(), + ); + } let validator = jsonschema::options() .with_registry(®istry) - .build(selected) + .build(&selected) .map_err(|err| PluginError::new(PluginErrorKind::ContractIncompatible, err.to_string()))?; let errors: Vec<_> = validator.iter_errors(value).collect(); if errors.is_empty() { @@ -94,7 +127,11 @@ fn validate_named(name: &str, value: &Value) -> Result<(), PluginError> { } Err(PluginError::new( classify(&errors), - format!("{name} {}", errors[0]), + if name.starts_with("managed-") { + format!("{name} rejected at {}", errors[0].instance_path()) + } else { + format!("{name} {}", errors[0]) + }, )) } @@ -140,6 +177,12 @@ fn schema_uri(name: &str) -> &'static str { "https://schemas.rustscript.local/v1/plugin-graph-ref.schema.yaml" } "checkpoint.schema.yaml" => "https://rustscript.dev/schemas/v1/checkpoint.schema.yaml", + "managed-effect.schema.yaml" => { + "https://schemas.rustscript.local/v1/managed-effect.schema.yaml" + } + "managed-workspace.schema.yaml" => { + "https://schemas.rustscript.local/v1/managed-workspace.schema.yaml" + } _ => "https://schemas.rustscript.local/v1/unknown", } } diff --git a/src/plugins/yaml.rs b/src/plugins/yaml.rs index 726553b..b3dd72a 100644 --- a/src/plugins/yaml.rs +++ b/src/plugins/yaml.rs @@ -188,6 +188,16 @@ fn kind_label(kind: PluginErrorKind) -> &'static str { } pub fn strict_yaml_to_json(text: &str) -> Result { + strict_yaml_document(text, false) +} + +/// The managed protocol has explicit nullable named fields. Preserve all raw +/// duplicate/tag/alias checks without changing the plugin contract's null ban. +pub(crate) fn strict_managed_yaml_to_json(text: &str) -> Result { + strict_yaml_document(text, true) +} + +fn strict_yaml_document(text: &str, allow_nested_null: bool) -> Result { let mut loader = StrictLoader { stack: Vec::new(), error: None, @@ -224,7 +234,9 @@ pub fn strict_yaml_to_json(text: &str) -> Result { "null document", )); } - reject_nulls(&value)?; + if !allow_nested_null { + reject_nulls(&value)?; + } Ok(value) } diff --git a/src/runtime/agent_host.rs b/src/runtime/agent_host.rs index 615608c..f591d22 100644 --- a/src/runtime/agent_host.rs +++ b/src/runtime/agent_host.rs @@ -567,6 +567,8 @@ pub type AuthHostFactory = /// retained identity. #[derive(Clone)] pub struct PinnedToolProgram { + pub(crate) managed: Option>, + managed_contract: Option, program: Arc, entry: String, graph_id: String, @@ -586,6 +588,7 @@ pub struct PinnedToolProgram { #[derive(Clone)] pub(crate) struct PinnedToolCode { + pub(crate) managed_contract: Option, pub(crate) program: Arc, pub(crate) entry: String, pub(crate) package_id: String, @@ -610,6 +613,8 @@ pub(crate) struct PinnedToolReceipt { impl PinnedToolProgram { pub(crate) fn seal(code: PinnedToolCode, receipt: PinnedToolReceipt) -> Self { Self { + managed: None, + managed_contract: code.managed_contract, program: code.program, entry: code.entry, package_id: code.package_id, @@ -628,6 +633,22 @@ impl PinnedToolProgram { } } + #[cfg(test)] + pub(crate) fn with_native_code_identity_for_test( + &self, + code: crate::effects::ManagedCodeIdentity, + ) -> Self { + let mut changed = self.clone(); + changed.graph_id = code.graph_id; + changed.generation = code.activation_generation; + changed.ref_id = code.graph_ref_id; + changed.package_id = code.package_id; + changed.artifact_id = code.artifact_id; + changed.export = code.export; + changed.registry_identity = code.registry_identity; + changed + } + pub fn entry(&self) -> &str { &self.entry } @@ -654,6 +675,7 @@ impl PinnedToolProgram { /// One approved tool call. The child may not replace its identity fields. #[derive(Clone)] pub(crate) struct ExpectedCallGrant { + managed_call: Option>, pub run_id: String, pub call_id: String, pub tool_name: String, @@ -666,15 +688,17 @@ pub(crate) struct ExpectedCallGrant { /// Native-only evidence for one child invocation. Guest return values cannot /// populate this witness or borrow a sibling invocation's execution token. -#[derive(Default)] +#[derive(Clone, Default)] enum PinnedCallOutcome { #[default] Pending, + Preparing, + Closing, Executing { token: String, }, Complete { - result: JsonValue, + result: Arc, }, Failed { error: JsonValue, @@ -682,11 +706,26 @@ enum PinnedCallOutcome { } impl PinnedCallOutcome { - fn resolve(&self, child: &JsonValue) -> JsonValue { + fn resolve( + &self, + child: &JsonValue, + publication: Option, + ) -> JsonValue { match self { Self::Complete { result } => { match crate::capabilities::lifecycle::validate_canonical_result(result) { - Ok(()) => pinned_tool_result(result), + Ok(()) => { + if let Some(publication) = publication { + let mut outcome = pinned_tool_result_with_limit( + result, + crate::config::MAX_TOOL_OUTPUT_BYTES, + ); + outcome["managed_publication"] = json!(publication); + outcome + } else { + pinned_tool_result(result) + } + } Err(error) => { pinned_tool_result(&crate::capabilities::host::error_envelope(&error)) } @@ -695,12 +734,16 @@ impl PinnedCallOutcome { Self::Failed { error } if error.get("ok") == Some(&JsonValue::Bool(false)) => { pinned_tool_result(error) } - Self::Pending | Self::Executing { .. } + Self::Pending | Self::Preparing | Self::Closing | Self::Executing { .. } if child.get("ok") == Some(&JsonValue::Bool(false)) => { pinned_tool_result(child) } - Self::Pending | Self::Executing { .. } | Self::Failed { .. } => pinned_outcome( + Self::Pending + | Self::Preparing + | Self::Closing + | Self::Executing { .. } + | Self::Failed { .. } => pinned_outcome( false, "", "", @@ -896,7 +939,36 @@ impl AgentHostState { { return pinned_call_mismatch(); } - let envelope = tool_prepare(lifecycle, owner, parsed); + if let Some(outcome) = outcome.as_deref_mut() { + *outcome = PinnedCallOutcome::Preparing; + } + drop(outcome); + let mut envelope = tool_prepare(lifecycle, owner, parsed); + if let Some(call) = self + .expected_call + .as_ref() + .and_then(|grant| grant.managed_call.as_ref()) + && envelope.get("kind").and_then(JsonValue::as_str) == Some("execute") + && let Some(token) = envelope.get("execution_token").and_then(JsonValue::as_str) + { + let bound = lifecycle + .authorize(owner, token, crate::capabilities::CapabilityRisk::Read) + .map_err(|e| e.code().to_string()) + .and_then(|claims| { + call.bind_execution(token, &claims, lifecycle.as_ref().clone(), owner.clone()) + }); + if let Err(message) = bound { + if let Ok(lease) = lifecycle.lease(token) { + drop(lease); + } + envelope = typed_fail("managed_view_unavailable", &message); + } + } + let complete_result = envelope.get("result").cloned().map(Arc::new); + let mut outcome = self + .expected_call + .as_ref() + .map(|grant| grant.outcome.lock().unwrap_or_else(|p| p.into_inner())); if envelope.get("ok") == Some(&JsonValue::Bool(true)) && envelope.get("kind") == Some(&JsonValue::String("execute".to_string())) && let Some(token) = envelope.get("execution_token").and_then(JsonValue::as_str) @@ -914,10 +986,10 @@ impl AgentHostState { } else if let Some(outcome) = outcome.as_deref_mut() { *outcome = if envelope.get("ok") == Some(&JsonValue::Bool(true)) && envelope.get("kind").and_then(JsonValue::as_str) == Some("replay") - && let Some(result) = envelope.get("result") + && let Some(result) = complete_result.as_ref() { PinnedCallOutcome::Complete { - result: result.clone(), + result: Arc::clone(result), } } else { PinnedCallOutcome::Failed { @@ -1007,6 +1079,23 @@ impl AgentHostState { if expected.as_ref() != Some(&arguments) { return pinned_call_mismatch(); } + if let Some(binding) = &pinned.managed { + match binding.replay_live(&call_id, &tool_name, Some(&arguments), Some(&canonical)) { + Ok(Some(replay)) => { + let mut outcome = pinned_tool_result_with_limit( + &replay.result, + crate::config::MAX_TOOL_OUTPUT_BYTES, + ); + outcome["managed_publication"] = json!(replay.outcome.publication); + outcome["kind"] = json!("replay"); + return outcome; + } + Ok(None) => {} + Err(message) => { + return pinned_outcome(false, "", "", "managed_effect_denied", &message, ""); + } + } + } let risk_class = field("risk_class"); let summary = field("summary"); let canonical_input = field("canonical_input"); @@ -1069,6 +1158,30 @@ impl AgentHostState { }; let outcome = Arc::new(Mutex::new(PinnedCallOutcome::Pending)); let grant = ExpectedCallGrant { + managed_call: match pinned.managed.as_ref() { + Some(binding) => match binding.register( + &call_id, + &tool_name, + &canonical, + &risk_class, + self.lifecycle + .as_ref() + .map_or(0, |life| life.original_deadline_ms()), + ) { + Ok(call) => Some(call), + Err(message) => { + return pinned_outcome( + false, + "", + "", + "managed_effect_denied", + &message, + "", + ); + } + }, + None => None, + }, run_id: pinned.run_id.clone(), call_id, tool_name, @@ -1097,6 +1210,7 @@ impl AgentHostState { pinned_tool: None, allow_stateless_pin: false, }; + let managed_call = grant.managed_call.clone(); match super::rss_runner::invoke_retained_tool( Arc::clone(&pinned.program), &pinned.entry, @@ -1106,10 +1220,16 @@ impl AgentHostState { &pinned.registry, Some(grant), ) { - Ok(value) => outcome - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .resolve(&vm_value_to_json(&value)), + Ok(value) => { + let snapshot = outcome + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone(); + snapshot.resolve( + &vm_value_to_json(&value), + managed_call.as_ref().and_then(|call| call.publication()), + ) + } Err(error) => pinned_outcome(false, "", "", "pinned_tool_failed", &error, ""), } } @@ -1128,6 +1248,19 @@ impl AgentHostState { } fn binding_mismatch(&self, pinned: &PinnedToolProgram) -> Option { + if let Some(managed) = &pinned.managed + && (!managed.matches_contract(pinned.managed_contract.as_ref()) + || managed.code.graph_id != pinned.graph_id + || managed.code.activation_generation != pinned.generation + || managed.code.graph_ref_id != pinned.ref_id + || managed.code.package_id != pinned.package_id + || managed.code.artifact_id != pinned.artifact_id + || managed.code.export != pinned.export + || managed.code.registry_identity != pinned.registry_identity + || managed.code.effect_contract_version != 1) + { + return Some(pinned_binding_mismatch()); + } let Some(owner) = self.capability_owner.as_ref() else { return Some(pinned_binding_mismatch()); }; @@ -1222,7 +1355,27 @@ impl AgentHostState { { return pinned_call_mismatch(); } - let envelope = tool_commit(lifecycle, owner, token, result.clone()); + if let Some(outcome) = outcome.as_deref_mut() { + *outcome = PinnedCallOutcome::Closing; + } + drop(outcome); + let envelope = if let Some(call) = self + .expected_call + .as_ref() + .and_then(|grant| grant.managed_call.as_ref()) + { + match call.prepare_result(token, result) { + Ok(prepared) => tool_commit(lifecycle, owner, token, prepared.original.clone()), + Err(message) => typed_fail("managed_prepare_failed", &message), + } + } else { + tool_commit(lifecycle, owner, token, result.clone()) + }; + let complete_result = envelope.get("result").cloned().map(Arc::new); + let mut outcome = self + .expected_call + .as_ref() + .map(|grant| grant.outcome.lock().unwrap_or_else(|p| p.into_inner())); if let Some(outcome) = outcome.as_deref_mut() { *outcome = if envelope.get("ok") == Some(&JsonValue::Bool(true)) && envelope.get("kind").and_then(JsonValue::as_str) == Some("committed") @@ -1231,10 +1384,10 @@ impl AgentHostState { .expected_call .as_ref() .map(|grant| grant.call_id.as_str()) - && let Some(result) = envelope.get("result") + && let Some(result) = complete_result.as_ref() { PinnedCallOutcome::Complete { - result: result.clone(), + result: Arc::clone(result), } } else { PinnedCallOutcome::Failed { @@ -1256,6 +1409,34 @@ impl AgentHostState { envelope } + fn filesystem_for(&self, token: &str) -> Option> { + if let Some(call) = self + .expected_call + .as_ref() + .and_then(|grant| grant.managed_call.as_ref()) + { + return call + .adapters(token) + .ok() + .map(|adapter| Arc::clone(&adapter.filesystem)); + } + self.filesystem.clone() + } + + fn processes_for(&self, token: &str) -> Option> { + if let Some(call) = self + .expected_call + .as_ref() + .and_then(|grant| grant.managed_call.as_ref()) + { + return call + .adapters(token) + .ok() + .map(|adapter| Arc::clone(&adapter.processes)); + } + self.processes.clone() + } + fn missing_capability(name: &str) -> JsonValue { if matches!(name, "filesystem" | "process") { return capability_error_envelope(&CapabilityError::new( @@ -1270,7 +1451,7 @@ impl AgentHostState { } fn cap_fs_metadata(&self, token: String, path: String) -> JsonValue { - let Some(fs) = self.filesystem.as_ref() else { + let Some(fs) = self.filesystem_for(&token) else { return Self::missing_capability("filesystem"); }; match fs.metadata(&token, &path) { @@ -1285,7 +1466,7 @@ impl AgentHostState { } fn cap_fs_read_range(&self, token: String, path: String, offset: u64, limit: usize) -> Value { - let Some(fs) = self.filesystem.as_ref() else { + let Some(fs) = self.filesystem_for(&token) else { return json_to_vm_value(&Self::missing_capability("filesystem")); }; match fs.read_range(&token, &path, offset, limit) { @@ -1295,7 +1476,7 @@ impl AgentHostState { } fn cap_fs_list(&self, token: String, path: String, cursor: u64, limit: usize) -> JsonValue { - let Some(fs) = self.filesystem.as_ref() else { + let Some(fs) = self.filesystem_for(&token) else { return Self::missing_capability("filesystem"); }; match fs.list(&token, &path, cursor, limit) { @@ -1322,7 +1503,7 @@ impl AgentHostState { expected_hash: String, bytes: Vec, ) -> JsonValue { - let Some(fs) = self.filesystem.as_ref() else { + let Some(fs) = self.filesystem_for(&token) else { return Self::missing_capability("filesystem"); }; match fs.write_atomic(&token, &path, &expected_hash, &bytes) { @@ -1347,7 +1528,7 @@ impl AgentHostState { limits: ProcessLimits, stdin: Vec, ) -> JsonValue { - let Some(processes) = self.processes.as_ref() else { + let Some(processes) = self.processes_for(&token) else { return Self::missing_capability("process"); }; let stdin = if stdin.is_empty() { @@ -1367,7 +1548,7 @@ impl AgentHostState { } fn cap_process_poll(&self, token: String, handle: String, cursor: u64, limit: usize) -> Value { - let Some(processes) = self.processes.as_ref() else { + let Some(processes) = self.processes_for(&token) else { return json_to_vm_value(&Self::missing_capability("process")); }; match processes.poll(&token, &handle, cursor, limit) { @@ -1377,7 +1558,7 @@ impl AgentHostState { } fn cap_process_wait(&self, token: String, handle: String, timeout_ms: Option) -> Value { - let Some(processes) = self.processes.as_ref() else { + let Some(processes) = self.processes_for(&token) else { return json_to_vm_value(&Self::missing_capability("process")); }; match processes.wait(&token, &handle, timeout_ms) { @@ -1387,7 +1568,7 @@ impl AgentHostState { } fn cap_process_log(&self, token: String, handle: String, cursor: u64, limit: usize) -> Value { - let Some(processes) = self.processes.as_ref() else { + let Some(processes) = self.processes_for(&token) else { return json_to_vm_value(&Self::missing_capability("process")); }; match processes.log(&token, &handle, cursor, limit) { @@ -1403,7 +1584,7 @@ impl AgentHostState { bytes: Vec, timeout_ms: Option, ) -> JsonValue { - let Some(processes) = self.processes.as_ref() else { + let Some(processes) = self.processes_for(&token) else { return Self::missing_capability("process"); }; match processes.write_stdin(&token, &handle, &bytes, timeout_ms) { @@ -1415,7 +1596,7 @@ impl AgentHostState { } fn cap_process_close(&self, token: String, handle: String) -> JsonValue { - let Some(processes) = self.processes.as_ref() else { + let Some(processes) = self.processes_for(&token) else { return Self::missing_capability("process"); }; match processes.close_stdin(&token, &handle) { @@ -1425,7 +1606,7 @@ impl AgentHostState { } fn cap_process_kill(&self, token: String, handle: String) -> JsonValue { - let Some(processes) = self.processes.as_ref() else { + let Some(processes) = self.processes_for(&token) else { return Self::missing_capability("process"); }; match processes.kill(&token, &handle) { @@ -1888,6 +2069,10 @@ fn same_workspace(left: &std::path::Path, right: &std::path::Path) -> bool { const PINNED_CANONICAL_RESULT_MAX_BYTES: usize = 1024 * 1024; fn pinned_tool_result(value: &JsonValue) -> JsonValue { + pinned_tool_result_with_limit(value, PINNED_CANONICAL_RESULT_MAX_BYTES) +} + +fn pinned_tool_result_with_limit(value: &JsonValue, max_bytes: usize) -> JsonValue { let explicit_ok = value.get("ok").and_then(JsonValue::as_bool); let content = value .get("content") @@ -1922,7 +2107,7 @@ fn pinned_tool_result(value: &JsonValue) -> JsonValue { .unwrap_or(""); let kind = value.get("kind").and_then(JsonValue::as_str).unwrap_or(""); let canonical = match serde_json::to_string(value) { - Ok(encoded) if encoded.len() <= PINNED_CANONICAL_RESULT_MAX_BYTES => encoded, + Ok(encoded) if encoded.len() <= max_bytes => encoded, Ok(_) => { return pinned_outcome( false, diff --git a/src/runtime/host_types.rs b/src/runtime/host_types.rs index 568e6a2..471fe97 100644 --- a/src/runtime/host_types.rs +++ b/src/runtime/host_types.rs @@ -238,6 +238,24 @@ named_struct!( ] ); +named_struct!( + ManagedPublicationRef, + "ManagedPublicationRef", + [ + ("format", HostTypeSchema::String), + ("effect_id", HostTypeSchema::String), + ("workspace_id", HostTypeSchema::String), + ("branch_id", HostTypeSchema::String), + ("epoch", HostTypeSchema::Int), + ("base_head", HostTypeSchema::String), + ("base_head_version", HostTypeSchema::Int), + ("candidate_head", optional(HostTypeSchema::String)), + ("observed_head", HostTypeSchema::String), + ("observed_head_version", HostTypeSchema::Int), + ("status", HostTypeSchema::String) + ] +); + named_struct!( PinnedToolInvokeResult, "PinnedToolInvokeResult", @@ -248,7 +266,11 @@ named_struct!( ("kind", HostTypeSchema::String), ("code", HostTypeSchema::String), ("message", HostTypeSchema::String), - ("canonical_result", HostTypeSchema::String) + ("canonical_result", HostTypeSchema::String), + ( + "managed_publication", + optional(named::()) + ) ] ); diff --git a/src/runtime/native_tools.rs b/src/runtime/native_tools.rs index 70eedf2..6dbd9e4 100644 --- a/src/runtime/native_tools.rs +++ b/src/runtime/native_tools.rs @@ -72,6 +72,7 @@ impl NativeToolRuntime { ) -> PinnedToolProgram { PinnedToolProgram::seal( PinnedToolCode { + managed_contract: None, program: Arc::clone(&self.program), entry: self.entry.clone(), package_id: "embedding".to_string(), diff --git a/src/service.rs b/src/service.rs index 3906aca..14011ac 100644 --- a/src/service.rs +++ b/src/service.rs @@ -59,6 +59,7 @@ use crate::config::{ validate_request_hash, validate_visible_name, }; use crate::config_file::CompactionSettings; +mod managed_effects; #[cfg(test)] #[path = "service_preflight_tests.rs"] mod preflight_tests; @@ -871,6 +872,7 @@ struct AgentServiceInner { pending: Mutex>, halting: AtomicBool, janitor_stop: Notify, + janitor_task: Mutex>>, store_generation: AtomicU64, metrics: Arc, file_search_entered: Mutex>>, @@ -924,6 +926,7 @@ struct AgentServiceInner { crash_after_tool_commit: AtomicBool, provider_commit_crashed: AtomicBool, tool_packages: Mutex>>, + managed_effects: RwLock>>, stateless_tools: AtomicBool, } @@ -986,6 +989,7 @@ impl AgentService { pending: Mutex::new(HashMap::new()), halting: AtomicBool::new(false), janitor_stop: Notify::new(), + janitor_task: Mutex::new(None), store_generation: AtomicU64::new(0), metrics, file_search_entered: Mutex::new(None), @@ -1022,9 +1026,11 @@ impl AgentService { crash_after_tool_commit: AtomicBool::new(false), provider_commit_crashed: AtomicBool::new(false), tool_packages: Mutex::new(None), + managed_effects: RwLock::new(None), stateless_tools, }); - spawn_lifecycle_janitor(Arc::clone(&inner)); + let janitor = spawn_lifecycle_janitor(Arc::clone(&inner)); + *inner.janitor_task.lock().expect("janitor task") = Some(janitor); Self { inner } } @@ -1438,6 +1444,13 @@ impl AgentService { &self, sqlite_path: &Path, ) -> Result<(), crate::plugins::PluginError> { + self.install_managed_effect_runtime(sqlite_path) + .map_err(|message| { + crate::plugins::PluginError::new( + crate::plugins::PluginErrorKind::ContractIncompatible, + message, + ) + })?; if self.inner.persistence.is_none() || sqlite_path == Path::new(":memory:") { crate::runtime::native_tools::shared_embedded_runtime().map_err(|message| { crate::plugins::PluginError::new( @@ -1471,6 +1484,149 @@ impl AgentService { Ok(()) } + pub fn install_managed_effect_runtime(&self, sqlite_path: &Path) -> Result<(), String> { + let Some(config) = self.inner.config.managed_workspace.clone() else { + return Ok(()); + }; + let persistence = self + .inner + .persistence + .clone() + .ok_or("managed workspace requires persistence")?; + if self.inner.managed_effects.read().is_some() { + return Err("managed runtime already installed".into()); + } + let runtime = + crate::effects::broker::ManagedEffectRuntime::open(config, sqlite_path, persistence)?; + *self.inner.managed_effects.write() = Some(runtime); + Ok(()) + } + + #[cfg(feature = "workspace-io-fault")] + pub fn inject_managed_effect_fault( + &self, + fault: crate::effects::ManagedEffectFault, + ) -> Result<(), String> { + self.inner + .managed_effects + .read() + .as_ref() + .ok_or("managed runtime unavailable")? + .arm_fault(fault); + Ok(()) + } + + #[cfg(all(test, feature = "workspace-io-fault"))] + pub(crate) fn managed_runtime_for_test( + &self, + ) -> Option> { + self.inner.managed_effects.read().clone() + } + + #[cfg(feature = "workspace-io-fault")] + pub fn arm_managed_prepare_gate( + &self, + ) -> Result { + Ok(self + .inner + .managed_effects + .read() + .as_ref() + .ok_or("managed runtime unavailable")? + .arm_prepare_gate(None)) + } + + #[cfg(feature = "workspace-io-fault")] + pub fn arm_managed_call_prepare_gate( + &self, + call_id: &str, + ) -> Result { + Ok(self + .inner + .managed_effects + .read() + .as_ref() + .ok_or("managed runtime unavailable")? + .arm_prepare_gate(Some(call_id))) + } + + /// Fresh read-only policy authorization; never resumes the historical run. + pub fn query_managed_effects( + &self, + request: crate::effects::ManagedEffectListRequest, + ) -> Result { + managed_effects::query(self, request) + } + pub fn read_managed_result_chunk( + &self, + request: crate::effects::ManagedResultReadRequest, + ) -> Result { + managed_effects::read_chunk(self, request) + } + + pub fn replay_managed_call( + &self, + request: crate::effects::ManagedEffectGetRequest, + ) -> Result { + use crate::effects::{CanonicalToolResultJson, ManagedCallReplay}; + let runtime = self + .inner + .managed_effects + .read() + .clone() + .ok_or("managed query unavailable")?; + let cancellation = RunCancellation::with_timeout(Duration::from_secs(5)); + let source = self + .inner + .config + .managed_workspace + .as_ref() + .ok_or("managed query unavailable")? + .source_root + .to_str() + .ok_or("managed source invalid")?; + let (_, authority) = self + .resolve_workspace_admission(Some(source), None, &cancellation, false) + .map_err(|_| "managed query unavailable")?; + let authority = authority.ok_or("managed query unavailable")?; + let Some(record) = self + .inner + .persistence + .as_ref() + .ok_or("managed query unavailable")? + .managed_effect_get(&request) + .map_err(|e| e.to_string())? + else { + return Ok(ManagedCallReplay::Unavailable { + code: "managed_query_unavailable".into(), + }); + }; + runtime.authorize_query(&authority, &record.identity.workspace)?; + if let Some(outcome) = record.outcome { + runtime.verify_tree( + outcome + .publication + .candidate_head + .as_deref() + .unwrap_or(&outcome.publication.base_head), + )?; + let result = runtime.read_result(&outcome.canonical_result_ref)?; + return Ok(ManagedCallReplay::Final { + outcome: Box::new(outcome), + canonical_result: CanonicalToolResultJson::from_value(&result)?, + }); + } + Ok(if record.prepared.is_some() { + ManagedCallReplay::Staged { + record: Box::new(record), + } + } else { + ManagedCallReplay::Indeterminate { + record: Box::new(record), + } + }) + } + /// Trusted embedder/test hook. The store must already carry its validated /// policy. Request JSON cannot select this store. pub fn install_validated_tool_store( @@ -2374,6 +2530,11 @@ impl AgentService { tool_call_id: &str, name: &str, ) -> Result, EventCommitError> { + if let managed_effects::ManagedDurableReplay::Final(result) = + managed_effects::replay_durable(self, run_id, tool_call_id, name)? + { + return Ok(Some(result)); + } let store = self.inner.store.read(); let Some(run) = store.runs.get(run_id) else { return Err(EventCommitError::Terminal); @@ -3221,6 +3382,10 @@ impl AgentService { .durable(Arc::new(ServiceDurableLifecycle { store: Arc::clone(&self.inner.store), events: Arc::clone(&events), + managed: handle + .pinned_tool + .as_ref() + .and_then(|pin| pin.managed.as_ref().map(Arc::downgrade)), }) as Arc) .approval(Arc::new(GuardianApprovalGate::new( Arc::new(SystemClock), @@ -3232,7 +3397,25 @@ impl AgentService { .generation(workspace_authority.map_or(1, |authority| authority.generation())) .build() .map_err(|error| invalid_context_metadata(run_id, error.code()))?; - let filesystem = match (workspace.as_ref(), workspace_grant.as_ref()) { + if let Some(managed) = handle + .pinned_tool + .as_ref() + .and_then(|pin| pin.managed.as_ref()) + { + managed + .bind_admitted_limits(filesystem_limits, process_limits, output_cap) + .map_err(|error| invalid_context_metadata(run_id, &error))?; + } + let direct_workspace = if handle + .pinned_tool + .as_ref() + .is_some_and(|pin| pin.managed.is_some()) + { + None + } else { + workspace.as_ref() + }; + let filesystem = match (direct_workspace, workspace_grant.as_ref()) { (Some(_), Some(grant)) => Some(Arc::new( FilesystemCapability::new_adopted( lifecycle.clone(), @@ -3252,7 +3435,7 @@ impl AgentService { )), (None, _) => None, }; - let processes = match (workspace.as_ref(), workspace_grant.as_ref()) { + let processes = match (direct_workspace, workspace_grant.as_ref()) { (Some(_), Some(grant)) => Some(Arc::new( ProcessCapability::new_adopted( lifecycle.clone(), @@ -4901,7 +5084,38 @@ impl AgentService { } else { None }; - let payload = json!({ + let managed_binding = if let Some(runtime) = self.inner.managed_effects.read().clone() { + let authority = workspace_authority.clone().ok_or_else(|| { + AdmitError::Invalid( + "managed workspace requires current Native authority".into(), + ) + })?; + let pin = prepared_pin.as_ref().ok_or_else(|| { + AdmitError::Invalid("managed workspace requires persistent builtin pin".into()) + })?; + Some( + runtime + .prepare_run_binding( + authority, + crate::effects::ManagedOwnerScope { + profile: ADMISSION_SESSION_PROFILE.into(), + session_id: session_id.clone(), + run_id: run_id.clone(), + }, + pin.managed_code(snapshot.registry.identity()), + pin.managed_contract().ok_or_else(|| { + AdmitError::Invalid( + "managed captured effect contract missing".into(), + ) + })?, + cancellation.clone(), + ) + .map_err(AdmitError::Invalid)?, + ) + } else { + None + }; + let mut payload = json!({ "session_id": session_id, "session_new": if session_new { 1 } else { 0 }, "profile": ADMISSION_SESSION_PROFILE, @@ -4940,6 +5154,13 @@ impl AgentService { "tool_registry_identity": snapshot.registry.identity(), "tool_workspace": pinned_workspace.display().to_string(), }); + if let Some(binding) = &managed_binding { + let remaining = cancellation + .deadline_instant() + .ok_or_else(|| AdmitError::Invalid("managed original deadline missing".into()))? + .saturating_duration_since(Instant::now()); + payload["managed_binding_json"]=json!(json!({"record_json":json!({"session_id":session_id,"graph_ref_id":binding.code.graph_ref_id,"original_deadline_ms":timestamp().saturating_add(remaining.as_millis() as u64)}).to_string(),"prepared_json":"","workspace_json":"","projections_json":"","effect_id":"","receipt_id":"","commit_id":"","run_id":run_id,"call_id":"","store_id":binding.workspace.identity.store_id,"profile":ADMISSION_SESSION_PROFILE,"source_device":"","source_inode":"","workspace_id":binding.workspace.identity.workspace_id,"branch_id":binding.workspace.identity.branch_id,"now_ms":now,"max_events":self.inner.config.max_events_per_run,"cut":""}).to_string()); + } // Commit phase: fresh revalidation then atomic durable + in-memory // publication while holding the gate. Unrelated global generation @@ -5222,11 +5443,12 @@ impl AgentService { .map_err(|error| AdmitError::Persistence(error.to_string()))?, ); } - let admitted_pin = admitted_pin.take().ok_or_else(|| { + let mut admitted_pin = admitted_pin.take().ok_or_else(|| { AdmitError::Persistence( "tool pin was not sealed from the admission receipt".to_string(), ) })?; + admitted_pin.managed = managed_binding.clone(); let mut store = self.inner.store.write(); if store.runs.contains_key(&run_id) && let Some(existing) = store.runs.get(&run_id) @@ -5703,6 +5925,35 @@ impl AgentService { self.inner.janitor_stop.notify_one(); } + /// Stops the owned service/storage workers after tool workers have exited. + /// The caller must finish or cancel/join its `run_worker` tasks first. + pub async fn shutdown(&self) -> Result<(), String> { + self.stop_admission(); + if self + .inner + .runs + .lock() + .expect("runs lock") + .values() + .any(|run| run.occupancy.load(Ordering::Acquire)) + { + return Err( + "service workers still active; cancel and join them before shutdown".into(), + ); + } + self.shutdown_capability_host(); + let janitor = self.inner.janitor_task.lock().expect("janitor task").take(); + if let Some(janitor) = janitor { + janitor + .await + .map_err(|e| format!("join lifecycle janitor: {e}"))?; + } + if let Some(persistence) = &self.inner.persistence { + persistence.shutdown(); + } + Ok(()) + } + /// Marks a run terminal: records the terminal time for TTL retention, /// releases the capacity permit, and sets the atomic terminal flag the /// subscriber drop guard consults before any client-disconnect @@ -7500,6 +7751,7 @@ impl CancellationFlag for HandleCancelFlag { struct ServiceDurableLifecycle { store: Arc>, events: Arc, + managed: Option>, } impl DurableToolLifecycle for ServiceDurableLifecycle { @@ -7571,6 +7823,13 @@ impl DurableToolLifecycle for ServiceDurableLifecycle { call_id: &str, tool_name: &str, ) -> Result, LifecycleError> { + if let Some(binding) = self.managed.as_ref().and_then(Weak::upgrade) { + let arguments = self.parent_arguments(_run_id, call_id, tool_name)?; + return binding + .replay_live(call_id, tool_name, arguments.as_ref(), None) + .map(|replay| replay.map(|replay| replay.result)) + .map_err(LifecycleError::ResultCommitFailed); + } match self.events.replay_durable_tool_result(call_id, tool_name) { Ok(Some(result)) => Ok(Some( serde_json::to_value(&result).unwrap_or_else(|_| json!({})), @@ -7581,6 +7840,20 @@ impl DurableToolLifecycle for ServiceDurableLifecycle { } fn commit_started(&self, record: &DurableStarted) -> Result<(), LifecycleError> { + if let Some(binding) = self.managed.as_ref().and_then(Weak::upgrade) { + let call = binding.call(&record.call_id).ok_or_else(|| { + LifecycleError::StartedCommitFailed( + "managed exact call registration missing".into(), + ) + })?; + return self + .events + .commit_managed_started(&crate::effects::ManagedStartedWitness { + call, + started: record.clone(), + }) + .map_err(map_event_commit_error); + } let data = json!({ "tool_call_id": record.call_id, "name": record.tool_name, @@ -7604,6 +7877,21 @@ impl DurableToolLifecycle for ServiceDurableLifecycle { call_id: &str, result: &serde_json::Value, ) -> Result { + if let Some(binding) = self.managed.as_ref().and_then(Weak::upgrade) { + let call = binding.call(call_id).ok_or_else(|| { + LifecycleError::ResultCommitFailed("managed exact call registration missing".into()) + })?; + let prepared = call.witness().map_err(LifecycleError::ResultCommitFailed)?; + if prepared.original != *result { + return Err(LifecycleError::ResultCommitFailed( + "managed observation changed after preparation".into(), + )); + } + return self + .events + .commit_managed_result(&crate::effects::ManagedFinalWitness { call, prepared }) + .map_err(map_event_commit_error); + } let tool_result = canonical_tool_result(result)?; let event_type = if tool_result.ok { "tool.completed" @@ -7641,6 +7929,15 @@ impl DurableToolLifecycle for ServiceDurableLifecycle { } fn interrupt(&self, call_id: &str) -> Result<(), LifecycleError> { + if let Some(call) = self + .managed + .as_ref() + .and_then(Weak::upgrade) + .and_then(|binding| binding.call(call_id)) + { + call.interrupt_unpublished(); + return Ok(()); + } let tool_result = ToolResult::failure("interrupted_effect", "effect interrupted by restart"); self.events @@ -7667,7 +7964,7 @@ fn map_event_commit_error(error: EventCommitError) -> LifecycleError { } } -fn canonical_tool_result(result: &JsonValue) -> Result { +pub(crate) fn canonical_tool_result(result: &JsonValue) -> Result { let ok = match result.get("ok") { Some(JsonValue::Bool(ok)) => *ok, _ => { @@ -7749,6 +8046,18 @@ struct ServiceEventCommitter { } impl DurableEventCommitter for ServiceEventCommitter { + fn commit_managed_started( + &self, + witness: &crate::effects::ManagedStartedWitness, + ) -> Result<(), EventCommitError> { + managed_effects::commit_started(self, witness) + } + fn commit_managed_result( + &self, + witness: &crate::effects::ManagedFinalWitness, + ) -> Result { + managed_effects::commit_result(self, witness) + } fn is_terminal(&self) -> bool { self.handle .upgrade() @@ -7793,6 +8102,15 @@ impl DurableEventCommitter for ServiceEventCommitter { let Some(inner) = self.service.upgrade() else { return Err(EventCommitError::Terminal); }; + if inner.config.managed_workspace.is_some() { + // Full canonical-object IO and fresh read authorization never run + // under the event publication gate. + return AgentService { inner }.replay_durable_tool_result( + &self.run_id, + tool_call_id, + name, + ); + } let _serial = self.commit_gate.lock(); AgentService { inner }.replay_durable_tool_result(&self.run_id, tool_call_id, name) } @@ -8405,7 +8723,7 @@ fn apply_terminal( } } -fn tool_result_content_json(tool_call_id: &str, result: &ToolResult) -> JsonValue { +pub(crate) fn tool_result_content_json(tool_call_id: &str, result: &ToolResult) -> JsonValue { let (content, cut) = truncate_utf8_chars(&result.content, MAX_DURABLE_TEXT_CHARS); let truncated = result.truncated || cut; let error = result.error.as_ref().map(|error| { @@ -9018,7 +9336,7 @@ pub(crate) fn failed_payload(error: String) -> JsonValue { /// of `terminal_pending` runs is owned by the per-run retry loops spawned by /// [`AgentService::spawn_terminal_retry`], so exactly one janitor system /// exists. -fn spawn_lifecycle_janitor(inner: Arc) { +fn spawn_lifecycle_janitor(inner: Arc) -> tokio::task::JoinHandle<()> { let interval_duration = inner.config.janitor_interval; tokio::spawn(async move { let mut interval = tokio::time::interval(interval_duration); @@ -9067,7 +9385,7 @@ fn spawn_lifecycle_janitor(inner: Arc) { .retain(|run_id, _| !expired_run_ids.contains(run_id)); } } - }); + }) } #[cfg(test)] diff --git a/src/service/managed_effects.rs b/src/service/managed_effects.rs new file mode 100644 index 0000000..41cc312 --- /dev/null +++ b/src/service/managed_effects.rs @@ -0,0 +1,610 @@ +//! Joint managed metadata publication; all filesystem work precedes this module. +use super::*; +#[cfg(test)] +#[path = "managed_effects_tests.rs"] +mod tests; +use crate::effects::journal::*; +use crate::effects::{ManagedFinalWitness, ManagedStartedWitness}; + +fn query_context( + service: &AgentService, +) -> Result< + ( + Arc, + Arc, + ), + String, +> { + let runtime = service + .inner + .managed_effects + .read() + .clone() + .ok_or("managed query unavailable")?; + let cancellation = RunCancellation::with_timeout(Duration::from_secs(5)); + let source = service + .inner + .config + .managed_workspace + .as_ref() + .ok_or("managed query unavailable")? + .source_root + .to_str() + .ok_or("managed source invalid")?; + let (_, authority) = service + .resolve_workspace_admission(Some(source), None, &cancellation, false) + .map_err(|_| "managed query unavailable")?; + Ok((runtime, authority.ok_or("managed query unavailable")?)) +} +pub(super) fn query( + service: &AgentService, + request: ManagedEffectListRequest, +) -> Result { + if request.limit == 0 || request.limit > 128 { + return Err("managed page limit must be 1..=128".into()); + } + crate::effects::broker::validate_id("ws", &request.workspace_id)?; + if let Some(cursor) = &request.after_effect_id { + crate::effects::broker::validate_id("effect", cursor)?; + } + let (runtime, authority) = query_context(service)?; + runtime.authorize_workspace_query( + &authority, + ADMISSION_SESSION_PROFILE, + &request.workspace_id, + )?; + let page = service + .inner + .persistence + .as_ref() + .ok_or("managed query unavailable")? + .managed_effect_list(&request) + .map_err(|e| e.to_string())?; + runtime.authorize_workspace_query( + &authority, + ADMISSION_SESSION_PROFILE, + &request.workspace_id, + )?; + Ok(page) +} +pub(super) fn read_chunk( + service: &AgentService, + request: ManagedResultReadRequest, +) -> Result { + if request.limit == 0 || request.limit > 131072 { + return Err("managed result chunk limit must be 1..=131072".into()); + } + crate::effects::broker::validate_id("effect", &request.effect_id)?; + let (runtime, authority) = query_context(service)?; + let record = service + .inner + .persistence + .as_ref() + .ok_or("managed query unavailable")? + .managed_effect_by_id(&request.effect_id) + .map_err(|e| e.to_string())? + .ok_or("managed effect unavailable")?; + if record.identity.owner.profile != ADMISSION_SESSION_PROFILE { + return Err("managed effect unavailable".into()); + } + runtime.authorize_query(&authority, &record.identity.workspace)?; + let object = if let Some(outcome) = &record.outcome { + runtime.verify_tree( + outcome + .publication + .candidate_head + .as_deref() + .unwrap_or(&outcome.publication.base_head), + )?; + &outcome.canonical_result_ref + } else { + &record + .prepared + .as_ref() + .ok_or("managed result unavailable")? + .canonical_result + }; + let chunk = runtime.read_result_chunk(object, request.offset, request.limit)?; + runtime.authorize_query(&authority, &record.identity.workspace)?; + Ok(chunk) +} + +fn failed(message: impl ToString) -> EventCommitError { + EventCommitError::PersistFailed(message.to_string()) +} +pub(super) enum ManagedDurableReplay { + Unmanaged, + Final(ToolResult), +} +pub(super) fn replay_durable( + service: &AgentService, + run_id: &str, + call_id: &str, + name: &str, +) -> Result { + let Some(persistence) = service.inner.persistence.as_ref() else { + return Ok(ManagedDurableReplay::Unmanaged); + }; + let session_id = { + let store = service.inner.store.read(); + store + .runs + .get(run_id) + .ok_or(EventCommitError::Terminal)? + .session_id + .clone() + }; + let request = ManagedEffectGetRequest { + owner: ManagedOwnerScope { + profile: ADMISSION_SESSION_PROFILE.into(), + session_id, + run_id: run_id.into(), + }, + call_id: call_id.into(), + }; + let Some(record) = persistence.managed_effect_get(&request).map_err(failed)? else { + return Ok(ManagedDurableReplay::Unmanaged); + }; + if record.identity.tool_name != name { + return Err(EventCommitError::Corrupt( + "managed replay tool name mismatch".into(), + )); + } + match service.replay_managed_call(request).map_err(failed)? { + ManagedCallReplay::Final { + canonical_result, .. + } => { + let mut result: ToolResult = + serde_json::from_str(canonical_result.as_str()).map_err(failed)?; + result.replayed = true; + Ok(ManagedDurableReplay::Final(result)) + } + ManagedCallReplay::Staged { .. } => Err(failed("managed_effect_staged")), + ManagedCallReplay::Indeterminate { .. } => Err(failed("managed_effect_indeterminate")), + ManagedCallReplay::Unavailable { code } => Err(failed(code)), + } +} +fn eligible( + committer: &ServiceEventCommitter, + call: &crate::effects::broker::ManagedCallContext, +) -> Result<(), EventCommitError> { + if committer.is_terminal() { + return Err(EventCommitError::Terminal); + } + if committer.stop_requested() { + return Err(EventCommitError::Cancelled); + } + call.binding().map_err(failed)?.check().map_err(failed)?; + if call.identity.owner.run_id != committer.run_id { + return Err(EventCommitError::Corrupt("managed run mismatch".into())); + } + Ok(()) +} +fn event_draft(event: &GatewayEvent) -> JsonValue { + json!({"event_id":event.event_id,"event_type":event.event,"seq":event.seq,"payload_json":event.data.to_string()}) +} +fn apply( + committer: &ServiceEventCommitter, + events: &[GatewayEvent], + message: Option, +) { + let mut store = committer.store.write(); + if let Some(run) = store.runs.get_mut(&committer.run_id) { + for event in events { + if !run + .events + .iter() + .any(|stored| stored.event_id == event.event_id) + { + apply_event_locked(run, event, committer.max_events_per_run); + } + } + } + if let Some(message) = message + && let Some(session) = store.sessions.get_mut(&message.session_id) + && !session.messages.iter().any(|old| old.id == message.id) + { + session.messages.push(message); + session.view.message_count = session.messages.len(); + session.view.updated_at = timestamp(); + } + let sender = store + .runs + .get(&committer.run_id) + .and_then(|run| run.sender.clone()); + drop(store); + if let Some(sender) = sender { + for event in events { + let _ = sender.send(event.clone()); + } + } +} +#[derive(serde::Serialize)] +struct ManagedStartedAudit<'a> { + tool_call_id: &'a str, + name: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + argument_digest: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + managed_argument_identity: Option<&'a ManagedArgumentIdentity>, + registry_identity: &'a str, + risk_class: &'a str, + generation: u64, +} +pub(super) fn commit_started( + committer: &ServiceEventCommitter, + witness: &ManagedStartedWitness, +) -> Result<(), EventCommitError> { + let call = &witness.call; + let binding = call.binding().map_err(failed)?; + let record = &witness.started; + if record.run_id != call.identity.owner.run_id + || record.call_id != call.identity.call_id + || record.generation != call.identity.authorization_generation + { + return Err(EventCommitError::Corrupt( + "managed started identity mismatch".into(), + )); + } + call.validate_started(record).map_err(failed)?; + let inline = matches!( + &call.identity.argument_identity, + ManagedArgumentIdentity::Inline { .. } + ); + let data = serde_json::to_value(ManagedStartedAudit { + tool_call_id: &record.call_id, + name: &record.tool_name, + argument_digest: inline.then_some(record.argument_digest.as_str()), + managed_argument_identity: (!inline).then_some(&call.identity.argument_identity), + registry_identity: &record.registry_identity, + risk_class: record.risk_class.as_str(), + generation: record.generation, + }) + .map_err(failed)?; + let record_json = serde_json::to_string(&call.record()).map_err(failed)?; + #[cfg(feature = "workspace-io-fault")] + if binding + .runtime + .take_fault(crate::effects::ManagedEffectFault::BeforeIntent) + { + return Err(failed("managed fixture cut before durable intent")); + } + let cut = { + #[cfg(feature = "workspace-io-fault")] + { + if binding + .runtime + .take_fault(crate::effects::ManagedEffectFault::BeginAfterFirstEvent) + { + "BeginAfterFirstEvent" + } else { + "" + } + } + #[cfg(not(feature = "workspace-io-fault"))] + { + "" + } + }; + let _gate = committer.commit_gate.lock(); + eligible(committer, call)?; + let (first, second) = { + let store = committer.store.read(); + let run = store + .runs + .get(&committer.run_id) + .ok_or(EventCommitError::Terminal)?; + let mut first = event_candidate( + run, + "tool.requested", + data.clone(), + committer.max_event_bytes, + ); + first.event_id = + durable_tool_event_id(&committer.run_id, &record.call_id, "tool.requested"); + let mut second = event_candidate(run, "tool.started", data, committer.max_event_bytes); + second.event_id = durable_tool_event_id(&committer.run_id, &record.call_id, "tool.started"); + second.seq = first.seq + 1; + (first, second) + }; + let projection = + json!({"normal":{"output":event_draft(&first),"terminal":event_draft(&second)}}); + binding.runtime.repository.command_with_cancellation("effect.begin",&json!({"effect_id":call.effect_id,"run_id":committer.run_id,"call_id":record.call_id, + "workspace_id":call.identity.workspace.workspace_id,"branch_id":call.identity.workspace.branch_id,"record_json":record_json, + "commit_id":call.receipt_id,"projections_json":projection.to_string(),"max_events":committer.max_events_per_run,"cut":cut}),&binding.cancel).map_err(failed)?; + apply(committer, &[first, second], None); + Ok(()) +} +struct Projection { + events: [GatewayEvent; 2], + message: SessionMessage, + wire: JsonValue, +} +struct ProjectionReservation<'a> { + seq: u64, + ordinal: i64, + parent: &'a str, +} +fn projection( + committer: &ServiceEventCommitter, + call: &crate::effects::broker::ManagedCallContext, + prepared: &ManagedPreparedRecord, + result: &ToolResult, + reservation: &ProjectionReservation<'_>, + content: &JsonValue, + conflict: bool, +) -> Projection { + let ProjectionReservation { + seq, + ordinal, + parent, + } = *reservation; + let identity = &call.identity; + let status = if conflict { + ManagedPublicationStatus::Conflict + } else if !prepared.observation_ok { + ManagedPublicationStatus::NotPublished + } else if prepared.operation_class == ManagedOperationClass::ReadOnly { + ManagedPublicationStatus::ReadOnly + } else { + ManagedPublicationStatus::Committed + }; + let publication = ManagedPublicationRef { + format: MANAGED_FORMAT.into(), + effect_id: call.effect_id.clone(), + workspace_id: identity.workspace.workspace_id.clone(), + branch_id: identity.workspace.branch_id.clone(), + epoch: identity.epoch, + base_head: identity.expected_head.clone(), + base_head_version: identity.expected_head_version, + candidate_head: prepared + .candidate_root + .as_ref() + .map(|object| object.object_id.clone()), + observed_head: identity.expected_head.clone(), + observed_head_version: identity.expected_head_version, + status: status.clone(), + }; + let terminal = if result.ok { + "tool.completed" + } else { + "tool.failed" + }; + let mut data = json!({"tool_call_id":identity.call_id,"ok":result.ok,"truncated":result.truncated,"managed_publication":publication}); + if let Some(error) = &result.error { + data["error_code"] = json!(error.code); + } + if !result.artifacts.is_empty() { + data["artifacts"] = json!(result.artifacts); + } + let make_event = |name: &str, seq: u64| GatewayEvent { + event_id: durable_tool_event_id(&committer.run_id, &identity.call_id, name), + run_id: committer.run_id.clone(), + seq, + event: name.into(), + data: data.clone(), + timestamp: timestamp(), + }; + let events = [ + make_event("tool.output", seq), + make_event(terminal, seq + 1), + ]; + let message = SessionMessage { + id: durable_message_id(&committer.run_id, "result", &identity.call_id), + session_id: identity.owner.session_id.clone(), + role: "user".into(), + content: content.clone(), + created_at: timestamp(), + run_id: Some(committer.run_id.clone()), + finish_reason: None, + name: Some(identity.tool_name.clone()), + tool_call_id: Some(identity.call_id.clone()), + parent_message_id: Some(parent.into()), + token_estimate: None, + metadata: json!(publication), + ordinal: Some(ordinal), + }; + let selected = if conflict { + prepared + .conflict_result + .clone() + .unwrap_or_else(|| prepared.canonical_result.clone()) + } else { + prepared.canonical_result.clone() + }; + let outcome = ManagedCommitOutcome { + disposition: status, + publication, + canonical_result_ref: selected, + output_event: ManagedEventRef { + event_id: events[0].event_id.clone(), + seq, + }, + terminal_event: ManagedEventRef { + event_id: events[1].event_id.clone(), + seq: seq + 1, + }, + message: ManagedMessageRef { + id: message.id.clone(), + ordinal, + }, + replayed: false, + }; + let wire = json!({"output":event_draft(&events[0]),"terminal":event_draft(&events[1]),"outcome":outcome, + "message":{"id":message.id,"ordinal":ordinal,"content_json":content.to_string(),"parent_message_id":parent}}); + Projection { + events, + message, + wire, + } +} +fn managed_message_content( + call_id: &str, + result: &ToolResult, +) -> Result { + // Durable messages remain bounded, deliberately lossy projections. The + // immutable canonical object retains all data, errors and artifact refs. + const PROJECTION_BYTES: usize = 64 * 1024; + const PREVIEW_CHARS: usize = 4096; + let content = tool_result_content_json(call_id, result); + if serde_json::to_vec(&content).map_err(failed)?.len() <= PROJECTION_BYTES { + return Ok(content); + } + let mut preview = result.clone(); + preview.data = json!({}); + preview.truncated = true; + preview.content = truncate_utf8_chars(&preview.content, PREVIEW_CHARS).0; + if let Some(error) = preview.error.as_mut() { + error.message = truncate_utf8_chars(&error.message, PREVIEW_CHARS).0; + } + let content = tool_result_content_json(call_id, &preview); + if serde_json::to_vec(&content).map_err(failed)?.len() > PROJECTION_BYTES { + return Err(failed("managed message projection exceeds bound")); + } + Ok(content) +} + +pub(super) fn commit_result( + committer: &ServiceEventCommitter, + witness: &ManagedFinalWitness, +) -> Result { + let call = &witness.call; + let binding = call.binding().map_err(failed)?; + let observed = + canonical_tool_result(&witness.prepared.original).map_err(|e| failed(e.code()))?; + let conflict = + canonical_tool_result(&witness.prepared.conflict).map_err(|e| failed(e.code()))?; + let content = managed_message_content(&call.identity.call_id, &observed)?; + let conflict_content = managed_message_content(&call.identity.call_id, &conflict)?; + let prepared_json = serde_json::to_string(&witness.prepared.record).map_err(failed)?; + witness + .prepared + .check_ready(&binding.runtime) + .map_err(failed)?; + let _gate = committer.commit_gate.lock(); + eligible(committer, call)?; + call.check_current_generation().map_err(failed)?; + let (seq, ordinal, parent) = { + let store = committer.store.read(); + let run = store + .runs + .get(&committer.run_id) + .ok_or(EventCommitError::Terminal)?; + let session = store + .sessions + .get(&run.session_id) + .ok_or(EventCommitError::MissingParent)?; + let (parent, name) = + lookup_tool_call_parent(&store, &run.session_id, &call.identity.call_id) + .ok_or(EventCommitError::MissingParent)?; + if name != call.identity.tool_name { + return Err(EventCommitError::MissingParent); + } + ( + event_candidate(run, "tool.output", json!({}), committer.max_event_bytes).seq, + next_message_ordinal(session), + parent, + ) + }; + let normal = projection( + committer, + call, + &witness.prepared.record, + &observed, + &ProjectionReservation { + seq, + ordinal, + parent: &parent, + }, + &content, + false, + ); + let losing = projection( + committer, + call, + &witness.prepared.record, + &conflict, + &ProjectionReservation { + seq, + ordinal, + parent: &parent, + }, + &conflict_content, + true, + ); + let projections = json!({"normal":normal.wire,"conflict":losing.wire}); + let cut = { + #[cfg(feature = "workspace-io-fault")] + { + binding.runtime.take_sql_fault() + } + #[cfg(not(feature = "workspace-io-fault"))] + { + String::new() + } + }; + let request = json!({"effect_id":call.effect_id,"receipt_id":call.receipt_id,"commit_id":call.receipt_id,"run_id":committer.run_id,"cut":cut, + "workspace_id":call.identity.workspace.workspace_id,"branch_id":call.identity.workspace.branch_id,"prepared_json":prepared_json, + "projections_json":projections.to_string(),"max_events":committer.max_events_per_run}); + let outcome = match binding + .runtime + .repository + .command_with_cancellation("effect.commit", &request, &binding.cancel) + .and_then(|value| crate::effects::journal::decode_record::(&value)) + { + Ok(Some(record)) => record.outcome, + Ok(None) => None, + Err(_) => binding + .runtime + .repository + .get(&ManagedEffectGetRequest { + owner: call.identity.owner.clone(), + call_id: call.identity.call_id.clone(), + }) + .ok() + .flatten() + .and_then(|record| record.outcome), + }; + let Some(outcome) = outcome else { + call.finalize(false); + return Err(failed("managed_commit_outcome_unknown_or_staged")); + }; + call.record_outcome(&outcome); + #[cfg(feature = "workspace-io-fault")] + if binding + .runtime + .take_fault(crate::effects::ManagedEffectFault::AfterDurableBeforeLive) + { + call.finalize(true); + binding.cancel.request(CancellationReason::ResourceClosed); + return Err(failed( + "managed fixture durable commit acknowledged only on exact query; live apply suppressed", + )); + } + let is_conflict = outcome.disposition == ManagedPublicationStatus::Conflict; + let mut selected = if is_conflict { losing } else { normal }; + if outcome.output_event.event_id != selected.events[0].event_id + || outcome.terminal_event.event_id != selected.events[1].event_id + || outcome.message.id != selected.message.id + { + return Err(EventCommitError::Corrupt( + "managed outcome reservations differ".into(), + )); + } + selected.events[0].seq = outcome.output_event.seq; + selected.events[1].seq = outcome.terminal_event.seq; + for event in &mut selected.events { + event.data["managed_publication"] = json!(outcome.publication); + } + selected.message.ordinal = Some(outcome.message.ordinal); + selected.message.metadata = json!(outcome.publication); + apply(committer, &selected.events, Some(selected.message)); + call.finalize(true); + drop(_gate); + let _retention_disposition = call.cleanup_final_view(); + Ok(if is_conflict { + witness.prepared.conflict.clone() + } else { + witness.prepared.original.clone() + }) +} diff --git a/src/service/managed_effects_tests.rs b/src/service/managed_effects_tests.rs new file mode 100644 index 0000000..0b23767 --- /dev/null +++ b/src/service/managed_effects_tests.rs @@ -0,0 +1,478 @@ +//! Native live replay through the exact private retained worker program. +#[path = "managed_protocol_tests.rs"] +mod protocol; +use crate::config::{ManagedRetentionLimits, ManagedWorkspaceConfig}; +use crate::effects::ResourceQuota; +use crate::{AdmitRunRequest, AgentGatewayConfig, AgentGatewayState}; +use serde_json::{Value, json}; +use std::{fs, path::PathBuf, sync::Arc}; +fn response(call: Option) -> Value { + json!({"text":if call.is_some() { "" } else { "done" },"tool_calls":call.into_iter().collect::>(),"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2},"reasoning":"","stop_reason":"stop"}) +} + +fn fixture() -> (PathBuf, AgentGatewayState) { + fixture_config(|_| {}) +} +fn fixture_config(configure: impl FnOnce(&mut AgentGatewayConfig)) -> (PathBuf, AgentGatewayState) { + let root = PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()) + .join(format!("managed-case-{}", uuid::Uuid::new_v4().simple())); + let workspace = root.join("workspace"); + let home = root.join("home"); + fs::create_dir_all(&workspace).unwrap(); + fs::create_dir_all(&home).unwrap(); + use std::os::unix::fs::PermissionsExt; + for path in [&root, &workspace, &home] { + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); + } + fs::write(home.join("config.yaml"),format!("version: 1\nmodel:\n provider: local-test\n model: synthetic\nproviders:\n local-test:\n protocol: codex-responses\n base_url: https://127.0.0.1:1\nworkspaces:\n allowed_roots:\n - '{}'\n default: '{}'\n",workspace.display(),workspace.display())).unwrap(); + let mut config = AgentGatewayConfig { + managed_workspace: Some(ManagedWorkspaceConfig { + source_root: workspace, + quota: ResourceQuota::new(1000, 1048576, 16).unwrap(), + retention: ManagedRetentionLimits::default(), + }), + ..AgentGatewayConfig::default() + }; + config.approvals.write = "allow".into(); + config.approvals.process = "allow".into(); + configure(&mut config); + let state = AgentGatewayState::with_sqlite_path(config.clone(), root.join("gateway.sqlite")) + .unwrap() + .with_workspace_home(&home); + (root, state) +} +struct ExactReplayProvider { + persistence: Arc, + service: std::sync::Weak, + run_id: std::sync::Mutex, + call: Value, + step: std::sync::atomic::AtomicUsize, + replayed: std::sync::Mutex>, +} +fn native_json(value: &rustscript_vm::Value) -> Value { + use rustscript_vm::Value as V; + match value { + V::Null => Value::Null, + V::Bool(value) => json!(value), + V::Int(value) => json!(value), + V::String(value) => json!(value.as_str()), + V::Map(entries) => Value::Object( + entries + .iter() + .map(|(key, value)| { + let V::String(key) = key else { + panic!("Native result key must be text"); + }; + (key.as_str().to_string(), native_json(value)) + }) + .collect(), + ), + V::Array(values) => Value::Array(values.iter().map(native_json).collect()), + _ => panic!("unexpected non-JSON Native result"), + } +} +impl crate::AgentProviderHost for ExactReplayProvider { + fn call(&self, _request: &Value, cancellation: &crate::RunCancellation) -> Value { + if self.step.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 { + return json!({"ok":true,"response":response(Some(self.call.clone())),"error":{}}); + } + let service = self.service.upgrade().unwrap(); + let run_id = self.run_id.lock().unwrap().clone(); + let host = service.capability_host_bridges(&run_id).unwrap().unwrap(); + assert_eq!( + host.cancellation.as_ref().unwrap().deadline_instant(), + cancellation.deadline_instant() + ); + let mut host = host; + host.pinned_tool = service.handle(&run_id).unwrap().pinned_tool.clone(); + let owner = host.capability_owner.as_ref().unwrap(); + let request = crate::effects::ManagedEffectGetRequest { + owner: crate::effects::ManagedOwnerScope { + profile: owner.profile().into(), + session_id: owner.session().into(), + run_id: owner.run().into(), + }, + call_id: "once".into(), + }; + let record = self + .persistence + .managed_effect_get(&request) + .unwrap() + .unwrap(); + let crate::effects::ManagedArgumentIdentity::Inline { + canonical_arguments, + } = &record.identity.argument_identity + else { + panic!("small fixture arguments should remain inline"); + }; + let wire = json!({"tool_name":"terminal","call_id":"once","canonical_arguments":canonical_arguments,"canonical_input":json!({"call":self.call,"config":service.run_context(&run_id).unwrap().limits}).to_string(),"run_id":run_id,"registry_identity":host.lifecycle.as_ref().unwrap().registry_identity(),"argument_digest":canonical_arguments,"risk_class":"execute","summary":"terminal","untrusted_graph_id":""}); + let input = rustscript_vm::Value::map( + wire.as_object() + .unwrap() + .iter() + .map(|(key, value)| { + ( + rustscript_vm::Value::string(key), + rustscript_vm::Value::string(value.as_str().unwrap()), + ) + }) + .collect(), + ); + let source = r#"pub fn run(context:map)->map { + let tool_name:string=context["tool_name"];let call_id:string=context["call_id"];let canonical_arguments:string=context["canonical_arguments"];let canonical_input:string=context["canonical_input"];let run_id:string=context["run_id"];let registry_identity:string=context["registry_identity"];let argument_digest:string=context["argument_digest"];let risk_class:string=context["risk_class"];let summary:string=context["summary"];let untrusted_graph_id:string=context["untrusted_graph_id"]; + agent_runtime::invoke_pinned_tool({tool_name:tool_name,call_id:call_id,canonical_arguments:canonical_arguments,canonical_input:canonical_input,run_id:run_id,registry_identity:registry_identity,argument_digest:argument_digest,risk_class:risk_class,summary:summary,untrusted_graph_id:untrusted_graph_id}) + }"#; + for field in [ + "graph", + "artifact", + "export", + "activation-generation", + "graph-ref", + "package", + "registry", + ] { + let pin = host.pinned_tool.as_ref().unwrap(); + let mut code = pin.managed.as_ref().unwrap().code.clone(); + match field { + "graph" => code.graph_id.push_str("-wrong"), + "artifact" => code.artifact_id.push_str("-wrong"), + "export" => code.export.push_str("_wrong"), + "activation-generation" => code.activation_generation += 1, + "graph-ref" => code.graph_ref_id.push_str("-wrong"), + "package" => code.package_id.push_str("-wrong"), + "registry" => code.registry_identity.push_str("-wrong"), + _ => unreachable!(), + }; + let mut changed = host.clone(); + changed.pinned_tool = Some(pin.with_native_code_identity_for_test(code)); + let runner = crate::AgentRunner::from_source(source, crate::AgentConfig::default()) + .unwrap() + .with_host(changed); + let denied = native_json(&runner.run_with_context(input.clone()).unwrap()); + assert_eq!( + denied["ok"], false, + "wrong sealed Native {field} must not issue a replay/completion witness" + ); + assert!( + denied + .get("canonical_result") + .is_none_or(|value| value == "") + ); + } + let runner = crate::AgentRunner::from_source(source, crate::AgentConfig::default()) + .unwrap() + .with_host(host); + let result = runner.run_with_context(input).unwrap(); + *self.replayed.lock().unwrap() = Some(native_json(&result)); + json!({"ok":true,"response":response(None),"error":{}}) + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn live_exact_receipt_replay_uses_original_fenced_native_result_without_second_execution() { + let (root, state) = fixture(); + let service = state.service(); + let sentinel = root.join("once-sentinel"); + let provider = Arc::new(ExactReplayProvider { + persistence: state.persistence().unwrap(), + service: Arc::downgrade(&service), + run_id: std::sync::Mutex::new(String::new()), + call: json!({"id":"once","name":"terminal","arguments":{"argv":["/bin/sh","-c",format!("printf x >> '{}'; printf observed",sentinel.display())],"timeout_ms":1000}}), + step: std::sync::atomic::AtomicUsize::new(0), + replayed: std::sync::Mutex::new(None), + }); + service.inject_provider_host(provider.clone()); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"execute then exactly replay this Native call"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + *provider.run_id.lock().unwrap() = run.run_id.clone(); + service + .clone() + .run_worker(run.run_id.clone(), "ignored".into()) + .await; + let replayed = provider + .replayed + .lock() + .unwrap() + .clone() + .unwrap_or_else(|| { + panic!( + "Native replay probe absent; provider calls={}, codes={:?}", + provider.step.load(std::sync::atomic::Ordering::SeqCst), + service + .run_events(&run.run_id) + .iter() + .map(|event| ( + event["event"].clone(), + event["data"]["error_code"].clone(), + event["data"]["code"].clone() + )) + .collect::>() + ) + }); + assert_eq!( + replayed["ok"], true, + "committed exact replay must be original Native outcome; code={}, reason={}", + replayed["code"], replayed["message"] + ); + let request = crate::effects::ManagedEffectGetRequest { + owner: crate::effects::ManagedOwnerScope { + profile: "gateway".into(), + session_id: run.session_id.clone(), + run_id: run.run_id.clone(), + }, + call_id: "once".into(), + }; + let crate::effects::ManagedCallReplay::Final { + canonical_result, .. + } = service.replay_managed_call(request.clone()).unwrap() + else { + panic!("expected committed receipt"); + }; + assert_eq!( + replayed["canonical_result"].as_str().unwrap(), + canonical_result.as_str() + ); + assert_eq!(replayed["managed_publication"]["status"], "committed"); + assert_eq!(fs::read(&sentinel).unwrap(), b"x"); + let record = state + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(); + assert_eq!( + state + .persistence() + .unwrap() + .managed_workspace_head(&record.identity.workspace) + .unwrap() + .head_version, + 2 + ); + for event in [ + "tool.requested", + "tool.started", + "tool.output", + "tool.completed", + ] { + assert_eq!( + service + .run_events(&run.run_id) + .iter() + .filter(|value| value["event"] == event) + .count(), + 1 + ); + } + assert_eq!(provider.step.load(std::sync::atomic::Ordering::SeqCst), 2); + drop(service); + state.service().shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn service_durable_replay_reads_full_canonical_receipt_after_pruning_and_fresh_reopen() { + let (root, state) = fixture_config(|config| config.max_events_per_run = 8); + let service = state.service(); + service + .set_run_limits( + crate::config::RunLimits::new(64, 128, 4 * 1024 * 1024, root.join("workspace")) + .unwrap(), + ) + .unwrap(); + fs::write(root.join("workspace/prune.txt"), b"bounded").unwrap(); + let provider = Arc::new(crate::ScriptedProvider::new()); + provider.push_ok(response(Some(json!({"id":"large","name":"terminal","arguments":{"argv":["/bin/sh","-c","/usr/bin/python3 -c 'import sys; sys.stdout.write(chr(34)*350000)'; exit 7"],"timeout_ms":1000}})))); + for id in ["prune-one", "prune-two"] { + provider.push_ok(response(Some( + json!({"id":id,"name":"read_file","arguments":{"path":"prune.txt"}}), + ))); + } + provider.push_ok(response(None)); + service.inject_provider_host(provider); + let run=service.admit_external_with_workspace_selector(AdmitRunRequest{input:json!({"message":"retain exact receipt while pruning the observation events"}),..Default::default()},None).await.unwrap(); + service + .clone() + .run_worker(run.run_id.clone(), "ignored".into()) + .await; + assert!( + !service.run_events(&run.run_id).iter().any( + |event| event["event"] == "tool.output" && event["data"]["tool_call_id"] == "large" + ) + ); + let request = crate::effects::ManagedEffectGetRequest { + owner: crate::effects::ManagedOwnerScope { + profile: "gateway".into(), + session_id: run.session_id.clone(), + run_id: run.run_id.clone(), + }, + call_id: "large".into(), + }; + let crate::effects::ManagedCallReplay::Final { + canonical_result, .. + } = service.replay_managed_call(request).unwrap() + else { + panic!("expected final receipt"); + }; + let full: Value = serde_json::from_str(canonical_result.as_str()).unwrap(); + let replay = service + .replay_durable_tool_result(&run.run_id, "large", "terminal") + .unwrap() + .expect("a managed receipt must remain available after its events are pruned"); + let decoded = serde_json::to_value(&replay).unwrap(); + assert!( + decoded == full, + "the facade must not reconstruct the full result from the bounded message projection; differing fields={:?}", + full.as_object() + .unwrap() + .keys() + .chain(decoded.as_object().unwrap().keys()) + .filter(|key| full.get(*key) != decoded.get(*key)) + .map(|key| ( + key, + full.get(key).map(|v| serde_json::to_vec(v).unwrap().len()), + decoded + .get(key) + .map(|v| serde_json::to_vec(v).unwrap().len()) + )) + .collect::>() + ); + let config = (*service.inner.config).clone(); + service.shutdown().await.unwrap(); + drop(service); + drop(state); + let reopened = AgentGatewayState::with_sqlite_path(config, root.join("gateway.sqlite")) + .unwrap() + .with_workspace_home(root.join("home")); + let replay = reopened + .service() + .replay_durable_tool_result(&run.run_id, "large", "terminal") + .unwrap() + .expect("fresh facade must find the immutable receipt"); + assert!(serde_json::to_value(&replay).unwrap() == full); + assert!( + reopened + .service() + .replay_durable_tool_result(&run.run_id, "large", "write_file") + .is_err() + ); + reopened.service().shutdown().await.unwrap(); +} + +/// Authored Native observation fixture exercises legacy fields that no single +/// six-tool failure currently emits together; it creates no guest authority. +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn failed_multiartifact_native_observation_replays_every_field_after_pruning_and_fresh_reopen() + { + let (root, state) = fixture_config(|config| config.max_events_per_run = 8); + let service = state.service(); + fs::write(root.join("workspace/prune.txt"), b"observed").unwrap(); + let full = json!({"ok":false,"content":"partial observation retained","data":{"partial":"legacy diagnostic","nested":{"count":3}},"error":{"code":"fixture_observation_failed","message":"original failure detail"},"truncated":true,"artifacts":["artifact:first-native-ref","artifact:second-native-ref"]}); + *service + .managed_runtime_for_test() + .unwrap() + .canonical_observation_fixture + .lock() = Some(full.clone()); + let provider = Arc::new(crate::ScriptedProvider::new()); + provider.push_ok(response(Some(json!({"id":"failed","name":"write_file","arguments":{"path":"private.txt","content":"discard private mutation"}})))); + for id in ["prune-one", "prune-two"] { + provider.push_ok(response(Some( + json!({"id":id,"name":"read_file","arguments":{"path":"prune.txt"}}), + ))); + } + provider.push_ok(response(None)); + service.inject_provider_host(provider); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"retain a failed Native legacy observation"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + service + .clone() + .run_worker(run.run_id.clone(), "ignored".into()) + .await; + assert!( + !service + .run_events(&run.run_id) + .iter() + .any(|event| event["event"] == "tool.output" + && event["data"]["tool_call_id"] == "failed") + ); + let request = crate::effects::ManagedEffectGetRequest { + owner: crate::effects::ManagedOwnerScope { + profile: "gateway".into(), + session_id: run.session_id, + run_id: run.run_id.clone(), + }, + call_id: "failed".into(), + }; + let record = state + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(); + assert_eq!( + record.state, + crate::effects::ManagedPublicationStatus::NotPublished + ); + assert!(record.prepared.as_ref().unwrap().candidate_root.is_none()); + assert_eq!( + record + .prepared + .as_ref() + .unwrap() + .canonical_result + .object_kind, + crate::effects::ManagedObjectKind::CanonicalResult + ); + let replay = service + .replay_durable_tool_result(&run.run_id, "failed", "write_file") + .unwrap() + .unwrap(); + assert!(serde_json::to_value(replay).unwrap() == full); + let crate::effects::ManagedCallReplay::Final { + canonical_result, .. + } = service.replay_managed_call(request.clone()).unwrap() + else { + panic!("expected immutable failure receipt"); + }; + assert!(serde_json::from_str::(canonical_result.as_str()).unwrap() == full); + assert!(!root.join("workspace/private.txt").exists()); + let config = (*service.inner.config).clone(); + service.shutdown().await.unwrap(); + drop(service); + drop(state); + let reopened = AgentGatewayState::with_sqlite_path(config, root.join("gateway.sqlite")) + .unwrap() + .with_workspace_home(root.join("home")); + let replay = reopened + .service() + .replay_durable_tool_result(&run.run_id, "failed", "write_file") + .unwrap() + .unwrap(); + assert!(serde_json::to_value(replay).unwrap() == full); + assert_eq!( + reopened + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(), + record + ); + reopened.service().shutdown().await.unwrap(); +} diff --git a/src/service/managed_protocol_tests.rs b/src/service/managed_protocol_tests.rs new file mode 100644 index 0000000..586667a --- /dev/null +++ b/src/service/managed_protocol_tests.rs @@ -0,0 +1,629 @@ +use super::*; +#[cfg(feature = "workspace-io-fault")] +fn register_native_budget_intent( + binding: &Arc, + index: usize, + deadline_ms: u64, + max_events: usize, +) { + let call = binding + .register( + &format!("reference-budget-{index}"), + "read_file", + r#"{"path":"never-executed.txt"}"#, + "read", + deadline_ms, + ) + .unwrap(); + let record = call.record(); + let seq = (index % 128) * 2 + 1000; + let output = json!({"event_id":uuid::Uuid::new_v4().to_string(),"event_type":"tool.requested","seq":seq,"payload_json":"{}"}); + let terminal = json!({"event_id":uuid::Uuid::new_v4().to_string(),"event_type":"tool.started","seq":seq+1,"payload_json":"{}"}); + let projections = json!({"normal":{"output":output,"terminal":terminal}}); + binding.runtime.repository.command_with_cancellation("effect.begin",&json!({"effect_id":call.effect_id,"run_id":record.identity.owner.run_id,"call_id":record.identity.call_id,"workspace_id":record.identity.workspace.workspace_id,"branch_id":record.identity.workspace.branch_id,"record_json":serde_json::to_string(&record).unwrap(),"commit_id":call.receipt_id,"projections_json":projections.to_string(),"max_events":max_events}),&binding.cancel).unwrap(); + call.interrupt_unpublished(); + assert!(call.adapters("not-a-token").is_err()); +} +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn real_native_factory_and_formal_rss_512_reference_boundary_uses_sdk_truncation_to_reject_513_without_silent_loss() + { + let (root, state) = fixture(); + let service = state.service(); + service + .set_run_limits( + crate::config::RunLimits::new(64, 128, 4 * 1024 * 1024, root.join("workspace")) + .unwrap(), + ) + .unwrap(); + let persistence = state.persistence().unwrap(); + let mut total = 0usize; + let mut target = None; + let mut last = None; + while total < 508 { + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"Native metadata-only admitted intents"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + let host = service + .capability_host_bridges(&run.run_id) + .unwrap() + .unwrap(); + let deadline = host.lifecycle.as_ref().unwrap().original_deadline_ms(); + let handle = service.handle(&run.run_id).unwrap(); + let binding = handle + .pinned_tool + .as_ref() + .unwrap() + .managed + .as_ref() + .unwrap() + .clone(); + let head = persistence + .managed_workspace_head(&binding.workspace.identity) + .unwrap(); + let request = crate::effects::ManagedObjectReferencesRequest { + store_id: head.store_id, + object_id: head.head_object_id, + }; + if target.is_none() { + let initial = persistence.managed_object_references(&request).unwrap(); + assert_eq!(initial.len(), 4); + target = Some(request.clone()); + } + let batch = (508 - total).min(128); + for _ in 0..batch { + register_native_budget_intent( + &binding, + total, + deadline, + service.inner.config.max_events_per_run, + ); + total += 1; + } + last = Some((binding, deadline)); + } + assert_eq!(total, 508); + let request = target.unwrap(); + let exact = persistence + .managed_object_references(&request) + .expect("the actual SDK 512-row aggregate boundary must remain usable"); + assert_eq!(exact.len(), 512); + assert_eq!( + exact + .iter() + .filter(|reference| reference.reference_kind + == crate::effects::ManagedObjectReferenceKind::CallBase) + .count(), + 508 + ); + let (binding, deadline) = last.unwrap(); + register_native_budget_intent( + &binding, + total, + deadline, + service.inner.config.max_events_per_run, + ); + assert!( + persistence.managed_object_references(&request).is_err(), + "513 real Native references must never be reported as a complete 512-row set" + ); + drop(binding); + drop(persistence); + service.shutdown().await.unwrap(); +} +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn actual_sdk_commit_acknowledgement_loss_reads_exact_durable_outcome_without_second_process_or_publication() + { + let (root, state) = fixture(); + let service = state.service(); + let persistence = state.persistence().unwrap(); + persistence.inject_drop_managed_commit_ack(); + let sentinel = root.join("lost-ack-sentinel"); + let provider = Arc::new(crate::ScriptedProvider::new()); + provider.push_ok(response(Some(json!({"id":"lost-native-ack","name":"terminal","arguments":{"argv":["/bin/sh","-c",format!("printf x >> '{}'; printf durable",sentinel.display())],"timeout_ms":1000}})))); + provider.push_ok(response(None)); + service.inject_provider_host(provider); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"reconcile a lost SDK acknowledgement"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + service + .clone() + .run_worker(run.run_id.clone(), "ignored".into()) + .await; + assert!( + !persistence.managed_commit_ack_drop_armed(), + "fixture must discard the actual successful SDK commit response, not only suppress live apply" + ); + let request = crate::effects::ManagedEffectGetRequest { + owner: crate::effects::ManagedOwnerScope { + profile: "gateway".into(), + session_id: run.session_id.clone(), + run_id: run.run_id.clone(), + }, + call_id: "lost-native-ack".into(), + }; + let record = persistence.managed_effect_get(&request).unwrap().unwrap(); + let outcome = record + .outcome + .as_ref() + .expect("durable final outcome after transport loss"); + let head = persistence + .managed_workspace_head(&record.identity.workspace) + .unwrap(); + assert_eq!(head.head_version, 2); + assert_eq!(fs::read(&sentinel).unwrap(), b"x"); + let live = service.run_events(&run.run_id); + for event in [&outcome.output_event, &outcome.terminal_event] { + let found = live + .iter() + .filter(|value| value["event_id"] == event.event_id) + .collect::>(); + assert_eq!(found.len(), 1); + assert_eq!(found[0]["seq"], event.seq); + } + let crate::effects::ManagedCallReplay::Final { + canonical_result, .. + } = service.replay_managed_call(request.clone()).unwrap() + else { + panic!("lost acknowledgement was incorrectly assumed rolled back") + }; + let full: Value = serde_json::from_str(canonical_result.as_str()).unwrap(); + let facade = service + .replay_durable_tool_result(&run.run_id, "lost-native-ack", "terminal") + .unwrap() + .unwrap(); + assert!(serde_json::to_value(facade).unwrap() == full); + let messages = persistence.message_list(&run.session_id, 0).unwrap(); + let call_column = messages["columns"] + .as_array() + .unwrap() + .iter() + .position(|name| name == "tool_call_id") + .unwrap(); + assert_eq!( + messages["rows"] + .as_array() + .unwrap() + .iter() + .filter(|message| message[call_column] == "lost-native-ack") + .count(), + 1 + ); + assert_eq!( + persistence.managed_effect_get(&request).unwrap().unwrap(), + record + ); + assert_eq!( + persistence + .managed_workspace_head(&record.identity.workspace) + .unwrap(), + head + ); + let config = (*service.inner.config).clone(); + drop(persistence); + service.shutdown().await.unwrap(); + drop(service); + drop(state); + let reopened = AgentGatewayState::with_sqlite_path(config, root.join("gateway.sqlite")) + .unwrap() + .with_workspace_home(root.join("home")); + let service = reopened.service(); + assert!( + serde_json::to_value( + service + .replay_durable_tool_result(&run.run_id, "lost-native-ack", "terminal") + .unwrap() + .unwrap() + ) + .unwrap() + == full + ); + assert_eq!( + reopened + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(), + record + ); + assert_eq!( + reopened + .persistence() + .unwrap() + .managed_workspace_head(&record.identity.workspace) + .unwrap(), + head + ); + assert_eq!( + reopened + .persistence() + .unwrap() + .message_list(&run.session_id, 0) + .unwrap(), + messages + ); + assert_eq!(fs::read(&sentinel).unwrap(), b"x"); + service.shutdown().await.unwrap(); +} +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn managed_precommit_native_revocation_generation_and_original_deadline_never_resume_prepared_execution() + { + for cut in ["policy", "generation", "deadline"] { + let (root, state) = fixture_config(|config| { + if cut == "deadline" { + config.run_timeout = std::time::Duration::from_secs(10); + } + }); + let service = state.service(); + let gate = service + .arm_managed_call_prepare_gate("fenced-native") + .unwrap(); + let provider = Arc::new(crate::ScriptedProvider::new()); + provider.push_ok(response(Some(json!({"id":"fenced-native","name":"write_file","arguments":{"path":"never.txt","content":"private-only"}})))); + provider.push_ok(response(None)); + service.inject_provider_host(provider); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"prepare under original Native authority"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + let worker = tokio::spawn( + service + .clone() + .run_worker(run.run_id.clone(), "ignored".into()), + ); + let arrived = gate.wait_arrived(1); + if arrived.is_err() { + gate.release(); + worker.await.unwrap(); + panic!("owned Native precommit barrier absent: {arrived:?}"); + } + let request = crate::effects::ManagedEffectGetRequest { + owner: crate::effects::ManagedOwnerScope { + profile: "gateway".into(), + session_id: run.session_id, + run_id: run.run_id.clone(), + }, + call_id: "fenced-native".into(), + }; + let before = state + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(); + let handle = service.handle(&run.run_id).unwrap(); + match cut { + "policy" => handle.workspace_authority.as_ref().unwrap().revoke(), + "generation" => { + let host = service + .capability_host_bridges(&run.run_id) + .unwrap() + .unwrap(); + host.lifecycle.unwrap().recover_open_tokens().unwrap(); + } + "deadline" => {} + _ => unreachable!(), + }; + if cut != "deadline" { + gate.release(); + } + worker.await.unwrap(); + drop(gate); + if cut == "deadline" { + assert!( + handle.cancellation().deadline_passed(), + "the unchanged original deadline must actually have elapsed" + ); + } + drop(handle); + let after = state + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(); + assert!(after.outcome.is_none()); + assert_eq!(after.prepared, before.prepared); + assert_eq!( + after.identity.original_deadline_ms, + before.identity.original_deadline_ms + ); + assert_eq!( + state + .persistence() + .unwrap() + .managed_workspace_head(&before.identity.workspace) + .unwrap() + .head_version, + 1 + ); + assert!(!root.join("workspace/never.txt").exists()); + assert!( + matches!( + service.replay_managed_call(request).unwrap(), + crate::effects::ManagedCallReplay::Staged { .. } + ), + "fresh read authorization cannot resume the revoked or expired execution" + ); + service.shutdown().await.unwrap(); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn managed_transport_bytes_and_exact_row_decoders_reject_before_sdk_enqueue_without_coercion() +{ + let (_root, state) = fixture(); + let persistence = state.persistence().unwrap(); + let before = persistence.executed_storage_commands(); + for text in [ + "a".repeat(crate::effects::journal::METADATA_BYTES), + "\"".repeat(600000), + "界".repeat(350000), + ] { + assert!( + persistence + .managed_effect_command("effect.workspace_get", &json!({"record_json":text})) + .is_err() + ); + } + assert_eq!( + persistence.executed_storage_commands(), + before, + "oversized actual UTF-8/escaped transport must never enter the dedicated SDK worker" + ); + for value in [ + json!({"columns":["record_json"],"rows":[[1]],"truncated":false}), + json!({"columns":["wrong"],"rows":[],"truncated":false}), + json!({"columns":["record_json"],"rows":[],"truncated":true}), + json!({"columns":["record_json"],"rows":[["{}"],["{}"]],"truncated":false}), + ] { + assert!( + crate::effects::journal::decode_record::(&value) + .is_err() + ); + } + let source = include_str!("../../rss/storage/effects.rss"); + let mut statements = 0; + let mut maximum = 0; + let mut parameter_max = 0; + for line in source.lines() { + if line.contains("let statements:") { + statements = 0; + } + if line.trim_start().starts_with("{ sql:") { + statements += 1; + maximum = maximum.max(statements); + if let Some((_, tail)) = line.split_once("params: [") { + let params = tail.split_once("], query:").unwrap().0; + parameter_max = parameter_max + .max(params.matches("text(").count() + params.matches("integer(").count()); + } + } + } + assert!((1..=32).contains(&maximum)); + assert!((1..=128).contains(¶meter_max)); + assert!( + source.contains("max_rows: 512") + && source.contains("max_result_bytes: 1048576") + && source.contains("max_columns: 16") + && source.contains("max_transaction_ms: 5000") + ); + state.service().shutdown().await.unwrap(); +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn exact_begin_and_prepared_resends_preserve_staged_receipt_without_new_authority() { + let (root, state) = fixture(); + let service = state.service(); + service + .inject_managed_effect_fault(crate::effects::ManagedEffectFault::AfterPrepared) + .unwrap(); + let provider = Arc::new(crate::ScriptedProvider::new()); + provider.push_ok(response(Some(json!({"id":"staged","name":"write_file","arguments":{"path":"staged.txt","content":"staged evidence"}})))); + provider.push_ok(response(None)); + service.inject_provider_host(provider); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"formal phase resends"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + service + .clone() + .run_worker(run.run_id.clone(), "ignored".into()) + .await; + let request = crate::effects::ManagedEffectGetRequest { + owner: crate::effects::ManagedOwnerScope { + profile: "gateway".into(), + session_id: run.session_id.clone(), + run_id: run.run_id.clone(), + }, + call_id: "staged".into(), + }; + let persistence = state.persistence().unwrap(); + let before = persistence.managed_effect_get(&request).unwrap().unwrap(); + let call = service + .handle(&run.run_id) + .unwrap() + .pinned_tool + .as_ref() + .unwrap() + .managed + .as_ref() + .unwrap() + .call("staged") + .unwrap(); + let head = persistence + .managed_workspace_head(&before.identity.workspace) + .unwrap(); + let messages = persistence.message_list(&run.session_id, 0).unwrap(); + let begin = json!({"effect_id":call.effect_id,"run_id":run.run_id,"call_id":"staged","record_json":serde_json::to_string(&call.record()).unwrap()}); + let prepared = json!({"effect_id":call.effect_id,"receipt_id":call.receipt_id,"prepared_json":serde_json::to_string(before.prepared.as_ref().unwrap()).unwrap()}); + for (op, payload) in [("effect.begin", begin), ("effect.prepare", prepared)] { + let reply = persistence + .managed_effect_command(op, &payload) + .expect("exact immutable phase resend must return the existing staged classification"); + let record: crate::effects::ManagedEffectRecord = + crate::effects::journal::decode_record(&reply) + .unwrap() + .unwrap(); + assert_eq!(record, before); + } + assert_eq!( + persistence + .managed_workspace_head(&before.identity.workspace) + .unwrap(), + head + ); + assert_eq!( + persistence.message_list(&run.session_id, 0).unwrap(), + messages + ); + assert!(!root.join("workspace/staged.txt").exists()); + drop(call); + drop(persistence); + service.shutdown().await.unwrap(); +} + +#[test] +fn managed_formal_rss_parses_with_production_dialect() { + let parsed = rustscript_vm::parse_source_with_dialect( + include_str!("../../rss/storage/effects.rss"), + crate::runtime::module_snapshot::snapshot_parser_dialect(), + rustscript_vm::SharedParserOptions { + source_id: 0, + allow_implicit_externs: true, + allow_implicit_semicolons: false, + enforce_mutable_bindings: false, + import_scan_mode: true, + }, + ); + assert!( + parsed.is_ok(), + "formal RSS parser diagnostic: {:?}", + parsed.err() + ); + let compiled = crate::AgentRunner::from_source( + include_str!("../../rss/storage/effects.rss"), + crate::AgentConfig::default(), + ); + assert!( + compiled.is_ok(), + "formal RSS compile diagnostic: {:?}", + compiled.err() + ); +} +#[tokio::test(flavor = "multi_thread")] +async fn final_formal_commit_resend_returns_same_outcome_without_events_head_or_message_changes() { + let (root, state) = fixture(); + let service = state.service(); + let provider = Arc::new(crate::ScriptedProvider::new()); + provider.push_ok(response(Some( + json!({"id":"resend","name":"write_file","arguments":{"path":"once.txt","content":"once"}}), + ))); + provider.push_ok(response(None)); + service.inject_provider_host(provider); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"formal same-attempt resend"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + service + .clone() + .run_worker(run.run_id.clone(), "ignored".into()) + .await; + let request = crate::effects::ManagedEffectGetRequest { + owner: crate::effects::ManagedOwnerScope { + profile: "gateway".into(), + session_id: run.session_id.clone(), + run_id: run.run_id.clone(), + }, + call_id: "resend".into(), + }; + let persistence = state.persistence().unwrap(); + let before = persistence.managed_effect_get(&request).unwrap().unwrap(); + let call = service + .handle(&run.run_id) + .unwrap() + .pinned_tool + .as_ref() + .unwrap() + .managed + .as_ref() + .unwrap() + .call("resend") + .unwrap(); + let head = persistence + .managed_workspace_head(&before.identity.workspace) + .unwrap(); + let messages = persistence.message_list(&run.session_id, 0).unwrap(); + let events = service.run_events(&run.run_id); + let payload = json!({"effect_id":call.effect_id,"receipt_id":call.receipt_id,"commit_id":call.receipt_id,"run_id":run.run_id,"workspace_id":before.identity.workspace.workspace_id,"branch_id":before.identity.workspace.branch_id,"prepared_json":serde_json::to_string(before.prepared.as_ref().unwrap()).unwrap()}); + let returned=persistence.managed_effect_command("effect.commit",&payload).expect("same exact finalized attempt must return the original receipt, even after the run completed"); + let decoded: crate::effects::ManagedEffectRecord = + crate::effects::journal::decode_record(&returned) + .unwrap() + .unwrap(); + assert_eq!(decoded, before); + let mut wrong = payload.clone(); + wrong["receipt_id"] = json!("receipt-00000000000000000000000000000000"); + assert!( + persistence + .managed_effect_command("effect.commit", &wrong) + .is_err() + ); + assert_eq!( + persistence.managed_effect_get(&request).unwrap().unwrap(), + before + ); + assert_eq!( + persistence + .managed_workspace_head(&before.identity.workspace) + .unwrap(), + head + ); + assert_eq!( + persistence.message_list(&run.session_id, 0).unwrap(), + messages + ); + assert_eq!(service.run_events(&run.run_id), events); + drop(call); + drop(persistence); + service.shutdown().await.unwrap(); + drop(service); + drop(state); + assert!(root.join(".gateway.sqlite.effects").is_dir()); +} diff --git a/tests/effect_commit_recovery/authority_matrix.rs b/tests/effect_commit_recovery/authority_matrix.rs new file mode 100644 index 0000000..29cb606 --- /dev/null +++ b/tests/effect_commit_recovery/authority_matrix.rs @@ -0,0 +1,310 @@ +//! Additional production gates; each fixture owns its service and barrier. +use super::*; +#[tokio::test(flavor = "multi_thread")] +async fn managed_guardian_ask_retains_original_allow_deny_and_review_counts_without_reapproval_on_replay() + { + for allow in [true, false] { + let fixture = ManagedFixture::new(|config| { + config.approvals.write = "ask".into(); + config.approvals.process = "ask".into(); + }); + let provider = Arc::new(ScriptedProvider::new()); + let mut guardian = response(None); + guardian["text"]=json!(json!({"outcome":if allow{"allow"}else{"deny"},"risk_level":"low","user_authorization":"unknown","rationale":"bounded synthetic request within the authorized workspace"}).to_string()); + provider.push_ok(response(Some(json!({"id":"reviewed-write","name":"write_file","arguments":{"path":"reviewed.txt","content":"reviewed bytes"}})))); + provider.push_ok(guardian.clone()); + if allow { + provider.push_ok(response(Some(json!({"id":"reviewed-terminal","name":"terminal","arguments":{"argv":["/bin/cat","reviewed.txt"],"timeout_ms":1000}})))); + provider.push_ok(guardian); + provider.push_ok(response(None)); + } + fixture + .state + .service() + .inject_provider_host(provider.clone()); + let run = fixture + .state + .service() + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"write the file then read it through the terminal"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + fixture + .state + .service() + .run_worker(run.run_id.clone(), "ignored".into()) + .await; + assert_eq!( + provider.call_count(), + if allow { 5 } else { 2 }, + "one Guardian review per write/execute action, no synthetic approvals; events={:?}", + fixture + .state + .service() + .run_events(&run.run_id) + .iter() + .filter(|event| event["event"] != "model.requested") + .map(|event| ( + &event["event"], + &event["data"]["error_code"], + &event["data"]["error_message"], + &event["data"]["code"] + )) + .collect::>() + ); + if allow { + for call in ["reviewed-write", "reviewed-terminal"] { + let request = ManagedFixture::request(&run, call); + let record = fixture + .state + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(); + assert_eq!( + record.identity.approval_basis, + rustscript_agent::effects::ManagedApprovalBasis::GuardianAttested + ); + assert!(record.outcome.is_some()); + assert!(matches!( + fixture + .state + .service() + .replay_managed_call(request) + .unwrap(), + rustscript_agent::effects::ManagedCallReplay::Final { .. } + )); + } + assert_eq!(provider.call_count(), 5); + } else { + assert!( + fixture + .state + .persistence() + .unwrap() + .managed_effect_get(&ManagedFixture::request(&run, "reviewed-write")) + .unwrap() + .is_none() + ); + assert_eq!( + fs::read_dir(fixture.root.join(".gateway.sqlite.effects/views")) + .unwrap() + .count(), + 0 + ); + } + assert!(!fixture.root.join("workspace/reviewed.txt").exists()); + fixture.state.service().shutdown().await.unwrap(); + } +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn prepared_call_rechecks_current_source_store_marker_and_original_cancellation_before_linearization() + { + use rustscript_agent::effects::{ManagedCallReplay, ManagedPublicationStatus}; + for cut in ["source", "source-symlink", "store", "marker", "cancel"] { + let fixture = ManagedFixture::new(|_| {}); + let gate = fixture + .state + .service() + .arm_managed_call_prepare_gate("fenced") + .unwrap(); + let provider = Arc::new(ScriptedProvider::new()); + provider.push_ok(response(Some(json!({"id":"fenced","name":"write_file","arguments":{"path":"private.txt","content":"never-published"}})))); + provider.push_ok(response(None)); + fixture.state.service().inject_provider_host(provider); + let run = fixture + .state + .service() + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"recheck original authority"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + let worker = tokio::spawn( + fixture + .state + .service() + .run_worker(run.run_id.clone(), "ignored".into()), + ); + let arrival = gate.wait_arrived(1); + if arrival.is_err() { + gate.release(); + worker.await.unwrap(); + panic!("{cut}: preparation did not reach owned barrier: {arrival:?}"); + } + let request = ManagedFixture::request(&run, "fenced"); + let before = fixture + .state + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(); + assert_eq!(before.state, ManagedPublicationStatus::Staged); + let head = fixture + .state + .persistence() + .unwrap() + .managed_workspace_head(&before.identity.workspace) + .unwrap(); + let source = fixture.root.join("workspace"); + let store = fixture.root.join(".gateway.sqlite.effects"); + match cut { + "source" | "source-symlink" => { + fs::rename(&source, fixture.root.join("old-source")).unwrap(); + if cut == "source" { + fs::create_dir(&source).unwrap(); + } else { + std::os::unix::fs::symlink(fixture.root.join("old-source"), &source).unwrap(); + } + } + "store" => { + fs::rename(&store, fixture.root.join("old-store")).unwrap(); + fs::create_dir(&store).unwrap(); + } + "marker" => { + fs::rename(store.join("store-id"), store.join("old-marker")).unwrap(); + fs::write(store.join("store-id"), b"replaced-marker-not-authority").unwrap(); + } + "cancel" => { + fixture + .state + .service() + .handle(&run.run_id) + .unwrap() + .cancellation() + .request(rustscript_agent::CancellationReason::Requested); + } + _ => unreachable!(), + } + gate.release(); + worker.await.unwrap(); + drop(gate); + let after = fixture + .state + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(); + assert!( + after.outcome.is_none(), + "{cut}: current authority must be rechecked after preparation" + ); + assert_eq!(after.prepared, before.prepared); + assert_eq!( + after.identity.original_deadline_ms, + before.identity.original_deadline_ms + ); + assert_eq!( + fixture + .state + .persistence() + .unwrap() + .managed_workspace_head(&before.identity.workspace) + .unwrap(), + head + ); + assert!( + !fixture + .state + .service() + .run_events(&run.run_id) + .iter() + .any(|event| event["event"] == "tool.completed") + ); + match cut { + "source" | "source-symlink" => { + if cut == "source" { + fs::remove_dir(&source).unwrap(); + } else { + fs::remove_file(&source).unwrap(); + } + fs::rename(fixture.root.join("old-source"), &source).unwrap(); + } + "store" => { + fs::remove_dir(&store).unwrap(); + fs::rename(fixture.root.join("old-store"), &store).unwrap(); + } + "marker" => { + fs::remove_file(store.join("store-id")).unwrap(); + fs::rename(store.join("old-marker"), store.join("store-id")).unwrap(); + } + _ => {} + } + assert!(!source.join("private.txt").exists()); + let fixture = fixture.reopen().await; + assert!(matches!( + fixture + .state + .service() + .replay_managed_call(request) + .unwrap(), + ManagedCallReplay::Staged { .. } + )); + fixture.state.service().shutdown().await.unwrap(); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn managed_guardian_deny_preserves_business_review_and_prevents_intent_view_write_and_spawn() +{ + for tool in ["write_file", "terminal"] { + let fixture = ManagedFixture::new(|config| { + config.approvals.write = "deny".into(); + config.approvals.process = "deny".into(); + }); + let sentinel = fixture.root.join("denied-sentinel"); + let call = if tool == "write_file" { + json!({"id":"denied","name":tool,"arguments":{"path":"denied.txt","content":"denied"}}) + } else { + json!({"id":"denied","name":tool,"arguments":{"argv":["/bin/sh","-c",format!("printf x >> '{}'",sentinel.display())],"timeout_ms":1000}}) + }; + let run = fixture.calls(vec![call]).await; + assert!(!sentinel.exists()); + assert!(!fixture.root.join("workspace/denied.txt").exists()); + assert!( + fixture + .state + .persistence() + .unwrap() + .managed_effect_get(&ManagedFixture::request(&run, "denied")) + .unwrap() + .is_none() + ); + assert_eq!( + fs::read_dir(fixture.root.join(".gateway.sqlite.effects/views")) + .unwrap() + .count(), + 0 + ); + assert!( + !fixture + .state + .service() + .run_events(&run.run_id) + .iter() + .any(|event| event["event"] == "tool.started") + ); + let events = fixture.state.service().run_events(&run.run_id); + assert!( + events.iter().any(|event| event["event"] == "run.failed" + && event["data"]["error_message"] + == "configured approval policy denied this action"), + "retain the original RSS policy-denial observation" + ); + fixture.state.service().shutdown().await.unwrap(); + } +} diff --git a/tests/effect_commit_recovery/budget_matrix.rs b/tests/effect_commit_recovery/budget_matrix.rs new file mode 100644 index 0000000..a4dd21b --- /dev/null +++ b/tests/effect_commit_recovery/budget_matrix.rs @@ -0,0 +1,133 @@ +use super::*; +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn real_formal_128_row_page_and_exact_cursor_counts_preserve_all_retained_effects() { + use rustscript_agent::effects::{ManagedEffectFault, ManagedEffectListRequest}; + let fixture = ManagedFixture::new(|_| {}); + fs::write(fixture.root.join("workspace/page.txt"), b"bounded").unwrap(); + fixture + .state + .service() + .set_run_limits( + rustscript_agent::config::RunLimits::new( + 64, + 128, + 4 * 1024 * 1024, + fixture.root.join("workspace"), + ) + .unwrap(), + ) + .unwrap(); + let mut expected = std::collections::BTreeSet::new(); + let mut workspace = None; + for batch in 0..5 { + let calls=(0..25).map(|index|json!({"id":format!("page-{batch}-{index}"),"name":"read_file","arguments":{"path":"page.txt"}})).collect::>(); + let run = fixture.calls(calls).await; + for index in 0..25 { + let record = fixture + .state + .persistence() + .unwrap() + .managed_effect_get(&ManagedFixture::request( + &run, + &format!("page-{batch}-{index}"), + )) + .unwrap() + .expect("every selected original RSS call must create its own durable record"); + assert!( + record.outcome.is_some(), + "page fixture must use real final RSS observations" + ); + workspace = Some(record.identity.workspace.workspace_id.clone()); + assert!(expected.insert(record.effect_id)); + } + } + for index in 0..3 { + fixture + .state + .service() + .inject_managed_effect_fault(ManagedEffectFault::AfterIntent) + .unwrap(); + let id = format!("intent-{index}"); + let run = fixture + .calls(vec![ + json!({"id":id,"name":"read_file","arguments":{"path":"page.txt"}}), + ]) + .await; + let record = fixture + .state + .persistence() + .unwrap() + .managed_effect_get(&ManagedFixture::request(&run, &id)) + .unwrap() + .expect( + "remaining page entries are durable pre-execution intents, not fabricated rows", + ); + assert!(record.outcome.is_none()); + assert!(expected.insert(record.effect_id)); + } + assert_eq!(expected.len(), 128); + let request = ManagedEffectListRequest { + workspace_id: workspace.unwrap(), + after_effect_id: None, + limit: 128, + }; + let page = fixture + .state + .service() + .query_managed_effects(request.clone()) + .unwrap(); + assert_eq!(page.entries.len(), 128); + assert!(!page.has_more); + assert!(page.next_cursor.is_none()); + assert!(serde_json::to_vec(&page).unwrap().len() < 1024 * 1024); + assert_eq!( + page.entries + .iter() + .map(|entry| entry.effect_id.clone()) + .collect::>(), + expected + ); + let first = fixture + .state + .service() + .query_managed_effects(ManagedEffectListRequest { + limit: 127, + ..request.clone() + }) + .unwrap(); + assert_eq!(first.entries.len(), 127); + assert!(first.has_more); + let last = fixture + .state + .service() + .query_managed_effects(ManagedEffectListRequest { + after_effect_id: first.next_cursor.clone(), + ..request.clone() + }) + .unwrap(); + assert_eq!(last.entries.len(), 1); + assert!(!last.has_more); + let collected = first + .entries + .into_iter() + .chain(last.entries) + .map(|entry| entry.effect_id) + .collect::>(); + assert_eq!(collected, expected); + let fixture = fixture.reopen().await; + let page = fixture + .state + .service() + .query_managed_effects(request) + .unwrap(); + assert_eq!(page.entries.len(), 128); + assert_eq!( + page.entries + .into_iter() + .map(|entry| entry.effect_id) + .collect::>(), + expected + ); + fixture.state.service().shutdown().await.unwrap(); +} diff --git a/tests/effect_commit_recovery/io_matrix.rs b/tests/effect_commit_recovery/io_matrix.rs new file mode 100644 index 0000000..8d8cec3 --- /dev/null +++ b/tests/effect_commit_recovery/io_matrix.rs @@ -0,0 +1,406 @@ +use super::*; +#[tokio::test(flavor = "multi_thread")] +async fn failed_native_spawn_without_owned_terminal_evidence_remains_indeterminate_not_no_process() +{ + let fixture = ManagedFixture::new(|_| {}); + let run=fixture.calls(vec![json!({"id":"unknown-writer","name":"terminal","arguments":{"argv":["/nonexistent-managed-fixture-program"],"timeout_ms":1000}})]).await; + let request = ManagedFixture::request(&run, "unknown-writer"); + let record = fixture + .state + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(); + assert!( + record.prepared.is_none(), + "a Native spawn attempt without an owned terminal-status witness cannot certify NoProcess" + ); + assert!(record.outcome.is_none()); + assert_eq!( + fixture + .state + .persistence() + .unwrap() + .managed_workspace_head(&record.identity.workspace) + .unwrap() + .head_version, + 1 + ); + let fixture = fixture.reopen().await; + assert!(matches!( + fixture + .state + .service() + .replay_managed_call(request) + .unwrap(), + rustscript_agent::effects::ManagedCallReplay::Indeterminate { .. } + )); + fixture.state.service().shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn unavailable_durable_reference_lookup_retains_owned_view_despite_final_success() { + use rustscript_agent::effects::ManagedEffectFault; + let fixture = CutFixture::write(ManagedEffectFault::ObjectReferenceUnavailable).await; + let request = fixture.request(); + let record = fixture + .state + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(); + assert!(record.outcome.is_some()); + assert_eq!( + fs::read_dir(fixture.root.join(".gateway.sqlite.effects/views")) + .unwrap() + .count(), + 1, + "no durable reference answer means no deletion, even for a positively settled Native owner" + ); + fixture.state.service().shutdown().await.unwrap(); + let CutFixture { + root, + home, + config, + state, + .. + } = fixture; + drop(state); + let reopened = AgentGatewayState::with_sqlite_path(config, root.join("gateway.sqlite")) + .unwrap() + .with_workspace_home(home); + assert_eq!( + reopened + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(), + record + ); + assert_eq!( + fs::read_dir(root.join(".gateway.sqlite.effects/views")) + .unwrap() + .count(), + 1 + ); + reopened.service().shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn replaced_prepared_tree_or_result_cannot_become_a_committed_missing_object() { + for kind in ["tree", "result"] { + let fixture = ManagedFixture::new(|_| {}); + let gate = fixture + .state + .service() + .arm_managed_call_prepare_gate("ready") + .unwrap(); + let provider = Arc::new(ScriptedProvider::new()); + provider.push_ok(response(Some(json!({"id":"ready","name":"write_file","arguments":{"path":"ready.txt","content":"synced original bytes"}})))); + provider.push_ok(response(None)); + fixture.state.service().inject_provider_host(provider); + let run = fixture + .state + .service() + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"seal then retain exact Native object identity"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + let worker = tokio::spawn( + fixture + .state + .service() + .run_worker(run.run_id.clone(), "ignored".into()), + ); + let arrived = gate.wait_arrived(1); + if arrived.is_err() { + gate.release(); + worker.await.unwrap(); + panic!("prepared object barrier absent: {arrived:?}"); + } + let request = ManagedFixture::request(&run, "ready"); + let before = fixture + .state + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(); + let prepared = before.prepared.as_ref().unwrap(); + let path = if kind == "tree" { + fixture + .root + .join(".gateway.sqlite.effects/trees") + .join(&prepared.candidate_root.as_ref().unwrap().object_id) + } else { + fixture + .root + .join(".gateway.sqlite.effects/results") + .join(&prepared.canonical_result.object_id) + }; + let retained = path.with_extension("quarantine"); + fs::rename(&path, &retained).unwrap(); + if kind == "tree" { + fs::create_dir(&path).unwrap(); + } else { + fs::write(&path, b"replacement").unwrap(); + } + gate.release(); + worker.await.unwrap(); + drop(gate); + let after = fixture + .state + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(); + assert!( + after.outcome.is_none(), + "the exact prepared {kind} identity must be retained and checked before publication" + ); + assert_eq!( + fixture + .state + .persistence() + .unwrap() + .managed_workspace_head(&before.identity.workspace) + .unwrap() + .head_version, + 1 + ); + assert_eq!(after.prepared, before.prepared); + assert!(!fixture.root.join("workspace/ready.txt").exists()); + if kind == "tree" { + fs::remove_dir(&path).unwrap(); + } else { + fs::remove_file(&path).unwrap(); + } + fs::rename(retained, path).unwrap(); + let fixture = fixture.reopen().await; + assert!(matches!( + fixture + .state + .service() + .replay_managed_call(request) + .unwrap(), + rustscript_agent::effects::ManagedCallReplay::Staged { .. } + )); + fixture.state.service().shutdown().await.unwrap(); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn physical_ready_objects_survive_actual_prepare_sql_rollback_without_registry_or_receipt_publication() + { + use rustscript_agent::effects::{ + ManagedCallReplay, ManagedEffectFault, ManagedObjectReferencesRequest, + }; + let fixture = CutFixture::terminal(ManagedEffectFault::PrepareAfterObjectRegistry).await; + let request = fixture.request(); + let record = fixture + .state + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(); + assert!( + record.prepared.is_none(), + "a real SQL failure after object registration must roll back registry and receipt together" + ); + assert!(record.outcome.is_none()); + assert_eq!( + fs::read(fixture.root.join("external-sentinel")).unwrap(), + b"x" + ); + let store = fixture.root.join(".gateway.sqlite.effects"); + let mut orphan_ids = Vec::new(); + for namespace in ["trees", "results"] { + for object in fs::read_dir(store.join(namespace)).unwrap() { + let id = object.unwrap().file_name().into_string().unwrap(); + if id != record.identity.expected_head { + assert!( + fixture + .state + .persistence() + .unwrap() + .managed_object_metadata(&ManagedObjectReferencesRequest { + store_id: record.identity.workspace.store_id.clone(), + object_id: id.clone() + }) + .unwrap() + .is_none() + ); + orphan_ids.push((namespace, id)); + } + } + } + assert_eq!( + orphan_ids.len(), + 3, + "one candidate, original result and conflict result must physically precede the rejected SQL preparation" + ); + fixture.state.service().shutdown().await.unwrap(); + let CutFixture { + root, + home, + config, + state, + .. + } = fixture; + drop(state); + let reopened = AgentGatewayState::with_sqlite_path(config, root.join("gateway.sqlite")) + .unwrap() + .with_workspace_home(home); + assert!(matches!( + reopened.service().replay_managed_call(request).unwrap(), + ManagedCallReplay::Indeterminate { .. } + )); + for (namespace, id) in orphan_ids { + assert!( + root.join(".gateway.sqlite.effects") + .join(namespace) + .join(id) + .exists(), + "unregistered evidence must remain quarantined and charged after reopen" + ); + } + assert_eq!(fs::read(root.join("external-sentinel")).unwrap(), b"x"); + reopened.service().shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn managed_object_and_writer_io_faults_never_publish_and_reopen_without_reexecution() { + use rustscript_agent::effects::{ManagedCallReplay, ManagedEffectFault::*}; + for cut in [ + ResultPartialWrite, + ResultFileFsync, + TreeRename, + SealedMetadata, + TreeDirectoryFsync, + WriterReap, + WriterDrain, + ] { + let fixture = CutFixture::terminal(cut).await; + let request = fixture.request(); + assert_eq!( + fs::read(fixture.root.join("external-sentinel")).unwrap(), + b"x", + "the original terminal must have run exactly once before {cut:?}" + ); + let before = fixture + .state + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(); + assert!( + before.prepared.is_none(), + "{cut:?}: failed immutable IO or writer settlement cannot mint prepared evidence" + ); + assert!(before.outcome.is_none()); + let head = fixture + .state + .persistence() + .unwrap() + .managed_workspace_head(&before.identity.workspace) + .unwrap(); + assert_eq!(head.head_version, 1); + assert!( + !fixture + .state + .service() + .run_events(&fixture.run.run_id) + .iter() + .any(|event| event["event"] == "tool.completed") + ); + fixture.state.service().shutdown().await.unwrap(); + let CutFixture { + root, + home, + config, + state, + .. + } = fixture; + drop(state); + let reopened = AgentGatewayState::with_sqlite_path(config, root.join("gateway.sqlite")) + .unwrap() + .with_workspace_home(home); + assert!(matches!( + reopened.service().replay_managed_call(request).unwrap(), + ManagedCallReplay::Indeterminate { .. } + )); + assert_eq!( + reopened + .persistence() + .unwrap() + .managed_workspace_head(&before.identity.workspace) + .unwrap(), + head + ); + assert_eq!(fs::read(root.join("external-sentinel")).unwrap(), b"x"); + reopened.service().shutdown().await.unwrap(); + } +} +#[tokio::test(flavor = "multi_thread")] +async fn owned_cleanup_failure_retains_view_charge_and_exact_committed_reference_on_fresh_restart() +{ + use rustscript_agent::effects::ManagedEffectFault; + let fixture = CutFixture::write(ManagedEffectFault::OwnedCleanup).await; + let request = fixture.request(); + let record = fixture + .state + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(); + assert!(record.outcome.is_some()); + let views = fs::read_dir(fixture.root.join(".gateway.sqlite.effects/views")) + .unwrap() + .map(|item| item.unwrap().file_name()) + .collect::>(); + assert_eq!( + views.len(), + 1, + "cleanup failure must retain the view and reservation, not discard its charge" + ); + fixture.state.service().shutdown().await.unwrap(); + let CutFixture { + root, + home, + config, + state, + .. + } = fixture; + drop(state); + let reopened = AgentGatewayState::with_sqlite_path(config, root.join("gateway.sqlite")) + .unwrap() + .with_workspace_home(home); + assert_eq!( + reopened + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(), + record + ); + assert_eq!( + fs::read_dir(root.join(".gateway.sqlite.effects/views")) + .unwrap() + .count(), + 1 + ); + reopened.service().shutdown().await.unwrap(); +} diff --git a/tests/effect_commit_recovery/schema_matrix.rs b/tests/effect_commit_recovery/schema_matrix.rs new file mode 100644 index 0000000..eaa9ce4 --- /dev/null +++ b/tests/effect_commit_recovery/schema_matrix.rs @@ -0,0 +1,134 @@ +use super::*; +fn validator(name: &str) -> jsonschema::Validator { + let docs = rustscript_agent::plugins::canonical_schema_documents().unwrap(); + let mut registry = jsonschema::Registry::new(); + let mut selected = None; + for (filename, text) in docs { + let schema: Value = serde_json::from_str(&text).unwrap(); + jsonschema::draft202012::meta::validate(&schema).unwrap(); + let id = schema["$id"].as_str().unwrap().to_string(); + if filename == name { + selected = Some(schema.clone()); + } + registry = registry.add(id, schema).unwrap(); + } + let registry = registry.prepare().unwrap(); + jsonschema::options() + .with_registry(®istry) + .build(&selected.unwrap()) + .unwrap() +} +#[tokio::test(flavor = "multi_thread")] +async fn managed_live_producers_match_exact_yaml_validators_and_strict_parsers_for_positive_and_negative_documents() + { + use rustscript_agent::effects::{ + ManagedWorkspaceRecord, parse_managed_effect_yaml, parse_managed_workspace_yaml, + }; + let fixture = ManagedFixture::new(|_| {}); + let run=fixture.calls(vec![json!({"id":"schema","name":"write_file","arguments":{"path":"schema.txt","content":"real production consumer"}})]).await; + let record = fixture + .state + .persistence() + .unwrap() + .managed_effect_get(&ManagedFixture::request(&run, "schema")) + .unwrap() + .unwrap(); + let document = serde_json::to_value(&record).unwrap(); + let effect = validator("managed-effect.schema.json"); + assert!(effect.is_valid(&document)); + assert_eq!( + parse_managed_effect_yaml(&document.to_string()).unwrap(), + record + ); + assert!(effect.is_valid(&serde_json::to_value(record.prepared.as_ref().unwrap()).unwrap())); + let mut variants = Vec::new(); + for (field, value) in [ + ("format", json!("agent.managed-effect/v2")), + ("schemaVersion", json!(2)), + ("execution_token", json!("guest-ref")), + ("unexpected", json!(true)), + ] { + let mut wrong = document.clone(); + wrong[field] = value; + variants.push(wrong); + } + let mut wrong = document.clone(); + wrong["identity"]["code"]["authority"] = json!("guest"); + variants.push(wrong); + let mut wrong = document.clone(); + wrong["identity"]["expected_head_version"] = json!(0); + variants.push(wrong); + for wrong in variants { + assert!(!effect.is_valid(&wrong)); + assert!(parse_managed_effect_yaml(&wrong.to_string()).is_err()); + } + for raw in [ + document + .to_string() + .replacen('{', "{\"format\":\"agent.managed-effect/v1\",", 1), + format!("!authority {}", document), + "format: &alias agent.managed-effect/v1\nother: *alias\n".into(), + ] { + assert!(parse_managed_effect_yaml(&raw).is_err()); + } + let workspace = ManagedWorkspaceRecord { + format: "agent.managed-workspace/v1".into(), + schema_version: 1, + identity: record.identity.workspace.clone(), + profile: "gateway".into(), + seed_root: record.prepared.as_ref().unwrap().parent_root.clone(), + max_entries: 1000, + max_bytes: 1048576, + max_depth: 16, + }; + let value = serde_json::to_value(&workspace).unwrap(); + let schema = validator("managed-workspace.schema.json"); + assert!(schema.is_valid(&value)); + assert_eq!( + parse_managed_workspace_yaml(&value.to_string()).unwrap(), + workspace + ); + for field in ["schemaVersion", "format", "authority"] { + let mut wrong = value.clone(); + wrong[field] = json!("invalid"); + assert!(!schema.is_valid(&wrong)); + assert!(parse_managed_workspace_yaml(&wrong.to_string()).is_err()); + } + fixture.state.service().shutdown().await.unwrap(); +} +#[test] +fn managed_snapshot_mutations_fail_exact_drift_while_checkpoint_v1_is_unchanged() { + let root = PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()).join(format!( + "managed-schema-drift-{}", + uuid::Uuid::new_v4().simple() + )); + fs::create_dir(&root).unwrap(); + let docs = rustscript_agent::plugins::canonical_schema_documents().unwrap(); + for (name, text) in &docs { + fs::write(root.join(name), text).unwrap(); + } + rustscript_agent::plugins::schema_snapshot_drift(&root).unwrap(); + for name in [ + "managed-effect.schema.json", + "managed-workspace.schema.json", + ] { + let original = &docs[name]; + fs::write(root.join(name), format!("{original} \n")).unwrap(); + let error = rustscript_agent::plugins::schema_snapshot_drift(&root).unwrap_err(); + assert!( + error.contains(name), + "the changed managed snapshot itself must trigger drift" + ); + fs::write(root.join(name), original).unwrap(); + } + assert_eq!( + docs["checkpoint.schema.json"], + fs::read_to_string( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("schemas/canonical/checkpoint.schema.json") + ) + .unwrap() + ); + rustscript_agent::plugins::schema_snapshot_drift(&root).unwrap(); + fs::remove_dir_all(root).unwrap(); +} diff --git a/tests/effect_commit_recovery/startup_matrix.rs b/tests/effect_commit_recovery/startup_matrix.rs new file mode 100644 index 0000000..94a3889 --- /dev/null +++ b/tests/effect_commit_recovery/startup_matrix.rs @@ -0,0 +1,206 @@ +use super::*; +#[tokio::test(flavor = "multi_thread")] +async fn normal_managed_binary_reaches_listener_with_private_store_and_acknowledged_owned_shutdown() +{ + use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; + let fixture = ManagedFixture::new(|_| {}); + fixture.state.service().shutdown().await.unwrap(); + let ManagedFixture { + root, home, state, .. + } = fixture; + drop(state); + let executable = PathBuf::from(env!("CARGO_BIN_EXE_rustscript-agent-gateway")); + let mut command = tokio::process::Command::new(&executable); + command + .kill_on_drop(true) + .env_clear() + .current_dir(env!("CARGO_MANIFEST_DIR")) + .env("RUSTSCRIPT_AGENT_HOME", &home) + .env("RUSTSCRIPT_AGENT_ALLOW_ANONYMOUS", "1") + .env("RUSTSCRIPT_AGENT_GATEWAY_ADDR", "127.0.0.1:0") + .env("RUSTSCRIPT_AGENT_MANAGED_WORKSPACE", root.join("workspace")) + .env("RUSTSCRIPT_AGENT_STATE_DB", root.join("gateway.sqlite")) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::piped()); + for key in [ + "TMPDIR", + "TMP", + "TEMP", + "TEMPDIR", + "TEST_TMPDIR", + "RUSTSCRIPT_AGENT_TEST_TMP", + "RUSTSCRIPT_AGENT_TEST_TMPDIR", + "CEF_PATH", + ] { + command.env(key, std::env::var_os(key).unwrap()); + } + let mut child = command.spawn().unwrap(); + let pid = child.id().unwrap(); + let mut stderr = BufReader::new(child.stderr.take().unwrap()).lines(); + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(20); + let mut ready = None; + for _ in 0..16 { + match tokio::time::timeout_at(deadline, stderr.next_line()).await { + Ok(Ok(Some(line))) if line.contains("listening on http://127.0.0.1:") => { + ready = line + .rsplit_once("http://") + .and_then(|(_, address)| address.parse::().ok()); + break; + } + Ok(Ok(Some(_))) => {} + _ => break, + } + } + if ready.is_none() { + let _ = child.start_kill(); + let status = child.wait().await.unwrap(); + panic!("normal managed binary did not reach its listener; owned child status={status}"); + } + let owned = fs::read_link(format!("/proc/{pid}/exe")).is_ok_and(|path| path == executable) + && fs::read_link(format!("/proc/{pid}/cwd")) + .is_ok_and(|path| path == std::path::Path::new(env!("CARGO_MANIFEST_DIR"))); + let initialized = root.join(".gateway.sqlite.effects/store-id").is_file(); + let no_import = fs::read_dir(root.join(".gateway.sqlite.effects/trees")) + .is_ok_and(|entries| entries.count() == 0); + let served: Result = async { + let mut socket = + tokio::time::timeout_at(deadline, tokio::net::TcpStream::connect(ready.unwrap())) + .await + .map_err(|_| "connect deadline")? + .map_err(|_| "connect failed")?; + socket + .write_all( + b"GET /health/detailed HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n", + ) + .await + .map_err(|_| "request failed")?; + let mut prefix = [0; 512]; + let received = tokio::time::timeout_at(deadline, socket.read(&mut prefix)) + .await + .map_err(|_| "response deadline")? + .map_err(|_| "response failed")?; + Ok(prefix[..received].starts_with(b"HTTP/1.1 200")) + } + .await; + if owned { + unsafe { libc::kill(pid as i32, libc::SIGINT) }; + } else { + let _ = child.start_kill(); + } + let status = match tokio::time::timeout_at(deadline, child.wait()).await { + Ok(result) => result.unwrap(), + Err(_) => { + child.start_kill().unwrap(); + let status = child.wait().await.unwrap(); + panic!("owned binary exceeded shutdown acknowledgement bound: {status}"); + } + }; + drop(stderr); + assert!(owned); + assert!( + initialized && no_import, + "startup must create the private store without importing before current admission authority" + ); + assert_eq!(served, Ok(true)); + assert!(status.success(), "owned gateway exit status={status}"); + assert!(child.id().is_none()); +} + +#[test] +fn normal_gateway_binary_rejects_managed_without_persistence_before_binding_listener() { + let root = PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()) + .join(format!("managed-binary-{}", uuid::Uuid::new_v4().simple())); + let home = root.join("home"); + let workspace = root.join("workspace"); + fs::create_dir_all(&home).unwrap(); + fs::create_dir(&workspace).unwrap(); + for (source, database, expected) in [ + (workspace.as_os_str(), None, "persistence"), + (workspace.as_os_str(), Some(":memory:"), "persistent"), + ( + std::ffi::OsStr::new("relative"), + Some(":memory:"), + "absolute", + ), + ] { + let mut command = + std::process::Command::new(env!("CARGO_BIN_EXE_rustscript-agent-gateway")); + command + .env_clear() + .current_dir(env!("CARGO_MANIFEST_DIR")) + .env("RUSTSCRIPT_AGENT_HOME", &home) + .env("RUSTSCRIPT_AGENT_ALLOW_ANONYMOUS", "1") + .env("RUSTSCRIPT_AGENT_GATEWAY_ADDR", "127.0.0.1:0") + .env("RUSTSCRIPT_AGENT_MANAGED_WORKSPACE", source); + for key in [ + "TMPDIR", + "TMP", + "TEMP", + "TEMPDIR", + "TEST_TMPDIR", + "RUSTSCRIPT_AGENT_TEST_TMP", + "RUSTSCRIPT_AGENT_TEST_TMPDIR", + "CEF_PATH", + ] { + command.env(key, std::env::var_os(key).unwrap()); + } + if let Some(database) = database { + command.env("RUSTSCRIPT_AGENT_STATE_DB", database); + } + let output = command.output().unwrap(); + assert!(!output.status.success()); + let diagnostic = String::from_utf8(output.stderr).unwrap(); + assert!(!diagnostic.contains("listening on")); + assert!( + diagnostic.contains(expected), + "normal binary must reject the managed startup condition: {diagnostic}" + ); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn explicit_source_persistent_constructor_installs_managed_store_and_requires_current_authority() + { + let fixture = ManagedFixture::new(|_| {}); + fixture.state.service().shutdown().await.unwrap(); + let ManagedFixture { + root, + home, + config, + state, + } = fixture; + drop(state); + let source = + "pub fn run(context:map)->map {{kind:\"run.completed\",answer:\"constructor reached\"}}"; + let state = AgentGatewayState::with_agent_source_and_sqlite( + config, + source, + root.join("gateway.sqlite"), + ) + .unwrap() + .with_workspace_home(home); + let run = state + .service() + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"normal custom source persistent constructor"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + state + .service() + .run_worker(run.run_id.clone(), "ignored".into()) + .await; + assert!( + state + .service() + .run_events(&run.run_id) + .iter() + .any(|event| event["event"] == "run.completed") + ); + assert!(root.join(".gateway.sqlite.effects/store-id").is_file()); + state.service().shutdown().await.unwrap(); +} diff --git a/tests/effect_commit_recovery_tests.rs b/tests/effect_commit_recovery_tests.rs new file mode 100644 index 0000000..6ef04aa --- /dev/null +++ b/tests/effect_commit_recovery_tests.rs @@ -0,0 +1,2354 @@ +//! Production managed effects: real RSS leaves and fresh gateway recovery. +use rustscript_agent::config::{ManagedRetentionLimits, ManagedWorkspaceConfig}; +use rustscript_agent::effects::ResourceQuota; +use rustscript_agent::{AdmitRunRequest, AgentGatewayConfig, AgentGatewayState, ScriptedProvider}; +use serde_json::{Value, json}; +use std::{fs, path::PathBuf, sync::Arc}; +#[path = "effect_commit_recovery/authority_matrix.rs"] +mod authority_matrix; +#[cfg(feature = "workspace-io-fault")] +#[path = "effect_commit_recovery/budget_matrix.rs"] +mod budget_matrix; +#[cfg(feature = "workspace-io-fault")] +#[path = "effect_commit_recovery/io_matrix.rs"] +mod io_matrix; +#[path = "effect_commit_recovery/schema_matrix.rs"] +mod schema_matrix; +#[path = "effect_commit_recovery/startup_matrix.rs"] +mod startup_matrix; + +fn response(call: Option) -> Value { + json!({"text":if call.is_some() { "" } else { "done" },"tool_calls":call.into_iter().collect::>(),"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2},"reasoning":"","stop_reason":"stop"}) +} + +#[tokio::test(flavor = "multi_thread")] +async fn configured_managed_write_terminal_read_and_fresh_sdk_reopen() { + let root = PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()) + .join(format!("managed-{}", uuid::Uuid::new_v4().simple())); + let workspace = root.join("workspace"); + let home = root.join("home"); + fs::create_dir_all(&workspace).unwrap(); + fs::create_dir_all(&home).unwrap(); + { + use std::os::unix::fs::PermissionsExt; + for path in [&root, &workspace, &home] { + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); + } + } + fs::write(workspace.join("seed.txt"), b"immutable seed\n").unwrap(); + fs::write(home.join("config.yaml"), format!("version: 1\nmodel:\n provider: local-test\n model: synthetic\nproviders:\n local-test:\n protocol: codex-responses\n base_url: https://127.0.0.1:1\nworkspaces:\n allowed_roots:\n - '{}'\n default: '{}'\napprovals:\n read: allow\n write: allow\n process: allow\n", workspace.display(), workspace.display())).unwrap(); + let mut config = AgentGatewayConfig { + managed_workspace: Some(ManagedWorkspaceConfig { + source_root: workspace.clone(), + quota: ResourceQuota::new(1000, 1024 * 1024, 16).unwrap(), + retention: ManagedRetentionLimits::default(), + }), + ..AgentGatewayConfig::default() + }; + config.approvals.write = "allow".into(); + config.approvals.process = "allow".into(); + let database = root.join("gateway.sqlite"); + let state = AgentGatewayState::with_sqlite_path(config.clone(), &database) + .unwrap() + .with_workspace_home(&home); + let provider = Arc::new(ScriptedProvider::new()); + provider.push_ok(response(Some(json!({"id":"write-one","name":"write_file","arguments":{"path":"one.txt","content":"managed bytes\n"}})))); + provider.push_ok(response(Some(json!({"id":"read-one","name":"terminal","arguments":{"argv":["/bin/sh","-c","cat one.txt"],"timeout_ms":1000}})))); + provider.push_ok(response(None)); + state.service().inject_provider_host(provider); + let run = state + .service() + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"write then read the file"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + // Retained control-plane approval history is audit data, not a Native + // execution attestation and never a source of authority on reopen. + let persistence = state.persistence().unwrap(); + persistence.approval_request(&json!({"id":"retained-managed-approval","run_id":run.run_id,"session_id":run.session_id,"tool_call_id":"write-one","tool_name":"write_file","arguments_json":"{}","risk_class":"write","decision_scope":"","one_time":1,"requested_at_ms":1,"expires_at_ms":0})).unwrap(); + persistence.approval_resolve(&json!({"id":"retained-managed-approval","run_id":run.run_id,"state":"approved","resolver":"synthetic-history","decision_reason":"retain historical decision only","resolved_at_ms":2})).unwrap(); + drop(persistence); + state + .service() + .clone() + .run_worker(run.run_id.clone(), "ignored".into()) + .await; + let events = state.service().run_events(&run.run_id); + let facts: Vec<_> = events + .iter() + .map(|event| { + ( + event["event"].clone(), + event["data"]["error_code"].clone(), + event["data"]["code"].clone(), + ) + }) + .collect(); + eprintln!("event types and codes: {facts:?}"); + for name in [ + "tool.requested", + "tool.started", + "tool.output", + "tool.completed", + ] { + assert_eq!( + events.iter().filter(|event| event["event"] == name).count(), + 2, + "{name}" + ); + } + let messages = serde_json::to_value(state.service().session_messages(&run.session_id)).unwrap(); + let results: Vec<_> = messages + .as_array() + .unwrap() + .iter() + .flat_map(|message| message["content"].as_array().into_iter().flatten()) + .filter(|block| block["type"] == "tool_result") + .cloned() + .collect(); + assert_eq!(results.len(), 2); + assert_eq!(results[1]["result"]["stdout"], "managed bytes\n"); + assert!( + !workspace.join("one.txt").exists(), + "managed effects must leave the authorized source unchanged" + ); + assert_eq!( + fs::read(workspace.join("seed.txt")).unwrap(), + b"immutable seed\n" + ); + let request = rustscript_agent::effects::ManagedEffectGetRequest { + owner: rustscript_agent::effects::ManagedOwnerScope { + profile: "gateway".into(), + session_id: run.session_id.clone(), + run_id: run.run_id.clone(), + }, + call_id: "read-one".into(), + }; + let record = state + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .expect("managed receipt"); + let first_request = rustscript_agent::effects::ManagedEffectGetRequest { + call_id: "write-one".into(), + ..request.clone() + }; + let first = state + .persistence() + .unwrap() + .managed_effect_get(&first_request) + .unwrap() + .unwrap(); + let head = state + .persistence() + .unwrap() + .managed_workspace_head(&record.identity.workspace) + .unwrap(); + assert_eq!(head.head_version, 3); + assert_eq!( + record.identity.expected_head, + first + .prepared + .as_ref() + .unwrap() + .candidate_root + .as_ref() + .unwrap() + .object_id + ); + assert_eq!( + record.prepared.as_ref().unwrap().parent_root, + *first + .prepared + .as_ref() + .unwrap() + .candidate_root + .as_ref() + .unwrap(), + "the observed parent reference must retain its exact durable byte and entry metadata" + ); + let store_root = root.join(".gateway.sqlite.effects"); + assert_eq!( + fs::read( + store_root + .join("trees") + .join(&head.head_object_id) + .join("one.txt") + ) + .unwrap(), + b"managed bytes\n" + ); + assert!( + !store_root + .join("trees") + .join(&first.identity.expected_head) + .join("one.txt") + .exists() + ); + let replay = state + .service() + .replay_managed_call(request.clone()) + .unwrap(); + if let rustscript_agent::effects::ManagedCallReplay::Final { + canonical_result, .. + } = &replay + { + let full: Value = serde_json::from_str(canonical_result.as_str()).unwrap(); + assert_eq!(full["data"]["stdout"], "managed bytes\n"); + } else { + panic!("expected final replay"); + } + let before = state + .persistence() + .unwrap() + .message_list(&run.session_id, 0) + .unwrap(); + let managed_messages = |messages: Value| { + messages.as_array().unwrap().iter().filter(|message|message["tool_call_id"].as_str().is_some()).map(|message|json!({"id":message["id"],"role":message["role"],"run_id":message["run_id"],"name":message["name"],"tool_call_id":message["tool_call_id"],"parent_message_id":message["parent_message_id"],"content":message["content"],"metadata":message["metadata"],"ordinal":message["ordinal"],"finish_reason":message["finish_reason"],"token_estimate":message["token_estimate"]})).collect::>() + }; + let live_messages = managed_messages(messages); + let managed_events = |events: Vec| { + events + .into_iter() + .filter(|event| { + event["event"] + .as_str() + .is_some_and(|name| name.starts_with("tool.")) + }) + .map(|event| json!({"event":event["event"],"seq":event["seq"],"data":event["data"]})) + .collect::>() + }; + let persistence = state.persistence().unwrap(); + let approvals = persistence + .approval_list(&run.run_id, &run.session_id) + .unwrap(); + assert!( + approvals["rows"].as_array().unwrap().is_empty(), + "resolved approvals are excluded from the pending-only list" + ); + let approval = persistence + .approval_get("retained-managed-approval") + .unwrap(); + assert_eq!(approval["rows"].as_array().unwrap().len(), 1); + let state_column = approval["columns"] + .as_array() + .unwrap() + .iter() + .position(|value| value == "state") + .unwrap(); + assert_eq!(approval["rows"][0][state_column], "approved"); + let usage = |value: Value| { + let columns = value["columns"].as_array().unwrap(); + ["input_tokens", "output_tokens"].map(|name| { + value["rows"][0][columns.iter().position(|column| column == name).unwrap()] + .as_i64() + .unwrap() + }) + }; + let usage_before = usage(persistence.run_get(&run.run_id).unwrap()); + assert_eq!( + usage_before, + [0, 0], + "scripted provider bypasses the production usage recorder; nonzero formal usage is covered by managed_reopen_preserves_nonzero_formal_provider_usage" + ); + let cursor = events + .iter() + .map(|event| event["seq"].as_i64().unwrap()) + .max() + .unwrap(); + persistence + .delivery_advance(&run.session_id, "managed-reopen", cursor) + .unwrap(); + let delivery = persistence + .delivery_get(&run.session_id, "managed-reopen") + .unwrap(); + assert_eq!(delivery["rows"].as_array().unwrap().len(), 1); + let live_events = managed_events(events); + drop(persistence); + state.service().shutdown().await.unwrap(); + drop(state); + let reopened = AgentGatewayState::with_sqlite_path(config, &database) + .unwrap() + .with_workspace_home(&home); + let persistence = reopened.persistence().unwrap(); + assert_eq!( + usage(persistence.run_get(&run.run_id).unwrap()), + usage_before + ); + assert_eq!( + persistence + .approval_list(&run.run_id, &run.session_id) + .unwrap(), + approvals + ); + assert_eq!( + persistence + .approval_get("retained-managed-approval") + .unwrap(), + approval + ); + assert_eq!( + persistence + .delivery_get(&run.session_id, "managed-reopen") + .unwrap(), + delivery + ); + drop(persistence); + assert_eq!( + managed_messages( + serde_json::to_value(reopened.service().session_messages(&run.session_id)).unwrap() + ), + live_messages, + "managed live and durable message contracts must agree exactly" + ); + assert_eq!( + managed_events(reopened.service().run_events(&run.run_id)), + live_events, + "managed live and durable event projections must agree exactly" + ); + assert_eq!( + reopened + .persistence() + .unwrap() + .message_list(&run.session_id, 0) + .unwrap(), + before + ); + assert_eq!( + reopened + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(), + record + ); + assert_eq!( + reopened + .persistence() + .unwrap() + .managed_workspace_head(&record.identity.workspace) + .unwrap(), + head + ); + assert_eq!( + reopened.service().replay_managed_call(request).unwrap(), + replay + ); + reopened.service().shutdown().await.unwrap(); +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn intent_cut_never_issues_view_or_execution_and_reopens_indeterminate() { + use rustscript_agent::effects::{ManagedCallReplay, ManagedEffectFault}; + let fixture = CutFixture::write(ManagedEffectFault::AfterIntent).await; + let request = fixture.request(); + let record = fixture + .state + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(); + assert!( + record.prepared.is_none(), + "intent cut must prevent actual execution/preparation" + ); + assert!(record.outcome.is_none()); + assert_eq!( + fs::read_dir(fixture.root.join(".gateway.sqlite.effects/views")) + .unwrap() + .count(), + 0 + ); + let head = fixture + .state + .persistence() + .unwrap() + .managed_workspace_head(&record.identity.workspace) + .unwrap(); + assert_eq!(head.head_version, 1); + fixture.state.service().shutdown().await.unwrap(); + let CutFixture { + root, + home, + config, + state, + .. + } = fixture; + drop(state); + let reopened = AgentGatewayState::with_sqlite_path(config, root.join("gateway.sqlite")) + .unwrap() + .with_workspace_home(&home); + let after = reopened + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(); + assert_eq!( + after.identity.original_deadline_ms, + record.identity.original_deadline_ms + ); + assert_eq!( + after.recovery.as_ref().unwrap().code, + "effect_indeterminate" + ); + assert!(matches!( + reopened + .service() + .replay_managed_call(request.clone()) + .unwrap(), + ManagedCallReplay::Indeterminate { .. } + )); + let events = reopened.service().run_events(&request.owner.run_id); + assert_eq!( + events + .iter() + .filter(|event| event["event"] == "tool.started") + .count(), + 1 + ); + assert_eq!( + events + .iter() + .filter(|event| event["event"] == "tool.output") + .count(), + 1 + ); + assert_eq!( + events + .iter() + .filter(|event| event["event"] == "tool.failed") + .count(), + 1 + ); + reopened.service().shutdown().await.unwrap(); + drop(reopened); + let again = AgentGatewayState::with_sqlite_path( + AgentGatewayConfig::default(), + root.join("gateway.sqlite"), + ) + .unwrap(); + assert_eq!( + again + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(), + after + ); + again.service().shutdown().await.unwrap(); +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn joint_sql_faults_roll_back_head_receipt_events_and_message_on_fresh_reopen() { + use rustscript_agent::effects::ManagedEffectFault::*; + for cut in [ + CommitAfterDecision, + CommitAfterHead, + CommitAfterReceipt, + CommitAfterFirstEvent, + CommitAfterMessage, + ] { + let fixture = CutFixture::write(cut).await; + let request = fixture.request(); + let record = fixture + .state + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(); + assert!( + record.prepared.is_some(), + "{cut:?}: prepared objects must survive SQL rollback" + ); + assert!( + record.outcome.is_none(), + "{cut:?}: partial SQL must not finalize the receipt" + ); + let head = fixture + .state + .persistence() + .unwrap() + .managed_workspace_head(&record.identity.workspace) + .unwrap(); + assert_eq!(head.head_version, 1, "{cut:?}: head must roll back"); + assert_eq!(head.head_object_id, record.identity.expected_head); + let before = fixture.state.service().run_events(&request.owner.run_id); + assert_eq!( + before + .iter() + .filter(|event| event["event"] == "tool.output") + .count(), + 0, + "{cut:?}: no live partial output" + ); + fixture.state.service().shutdown().await.unwrap(); + let CutFixture { + root, + home, + config, + state, + .. + } = fixture; + drop(state); + let reopened = AgentGatewayState::with_sqlite_path(config, root.join("gateway.sqlite")) + .unwrap() + .with_workspace_home(&home); + let after = reopened + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(); + assert!(after.outcome.is_none()); + assert_eq!(after.prepared, record.prepared); + assert_eq!(after.recovery.as_ref().unwrap().code, "effect_staged"); + assert_eq!( + reopened + .persistence() + .unwrap() + .managed_workspace_head(&record.identity.workspace) + .unwrap(), + head + ); + let events = reopened.service().run_events(&request.owner.run_id); + assert_eq!( + events + .iter() + .filter(|event| event["event"] == "tool.output") + .count(), + 1 + ); + assert_eq!( + events + .iter() + .filter(|event| event["event"] == "tool.failed") + .count(), + 1 + ); + assert_eq!( + events + .iter() + .filter(|event| event["event"] == "tool.completed") + .count(), + 0 + ); + reopened.service().shutdown().await.unwrap(); + } +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn actual_terminal_before_prepared_loss_is_indeterminate_and_never_respawns() { + use rustscript_agent::effects::{ManagedCallReplay, ManagedEffectFault::*}; + for cut in [AfterOperationBeforePrepare, AfterObjectsSynced] { + let fixture = CutFixture::terminal(cut).await; + let request = fixture.request(); + assert_eq!( + fs::read(fixture.root.join("external-sentinel")).unwrap(), + b"x" + ); + let record = fixture + .state + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(); + assert!( + record.prepared.is_none(), + "{cut:?}: no durable prepared proof exists" + ); + assert!(record.outcome.is_none()); + let head = fixture + .state + .persistence() + .unwrap() + .managed_workspace_head(&record.identity.workspace) + .unwrap(); + assert_eq!(head.head_version, 1); + let sealed = fs::read_dir(fixture.root.join(".gateway.sqlite.effects/trees")) + .unwrap() + .count(); + if cut == AfterObjectsSynced { + assert!( + sealed >= 2, + "physical object must exist before SQL preparation" + ); + } + fixture.state.service().shutdown().await.unwrap(); + let CutFixture { + root, + home, + config, + state, + .. + } = fixture; + drop(state); + let reopened = AgentGatewayState::with_sqlite_path(config, root.join("gateway.sqlite")) + .unwrap() + .with_workspace_home(&home); + for _ in 0..2 { + assert!(matches!( + reopened + .service() + .replay_managed_call(request.clone()) + .unwrap(), + ManagedCallReplay::Indeterminate { .. } + )); + } + assert_eq!(fs::read(root.join("external-sentinel")).unwrap(), b"x"); + assert_eq!( + fs::read_dir(root.join(".gateway.sqlite.effects/trees")) + .unwrap() + .count(), + sealed + ); + assert_eq!( + reopened + .persistence() + .unwrap() + .managed_workspace_head(&record.identity.workspace) + .unwrap(), + head + ); + let after = reopened + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(); + assert_eq!( + after.recovery.as_ref().unwrap().code, + "effect_indeterminate" + ); + assert_eq!( + after.identity.original_deadline_ms, + record.identity.original_deadline_ms + ); + reopened.service().shutdown().await.unwrap(); + } +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn durable_commit_before_live_gap_replays_exact_receipt_without_terminal_retry() { + use rustscript_agent::effects::{ManagedCallReplay, ManagedEffectFault}; + let fixture = CutFixture::terminal(ManagedEffectFault::AfterDurableBeforeLive).await; + let request = fixture.request(); + assert_eq!( + fs::read(fixture.root.join("external-sentinel")).unwrap(), + b"x" + ); + let record = fixture + .state + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(); + assert!(record.outcome.is_some()); + let live = fixture.state.service().run_events(&request.owner.run_id); + assert_eq!( + live.iter() + .filter(|event| event["event"] == "tool.output") + .count(), + 0, + "post-durable cut must suppress this live apply" + ); + let head = fixture + .state + .persistence() + .unwrap() + .managed_workspace_head(&record.identity.workspace) + .unwrap(); + assert_eq!(head.head_version, 2); + fixture.state.service().shutdown().await.unwrap(); + let CutFixture { + root, + home, + config, + state, + .. + } = fixture; + drop(state); + let reopened = AgentGatewayState::with_sqlite_path(config, root.join("gateway.sqlite")) + .unwrap() + .with_workspace_home(&home); + assert_eq!( + reopened + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(), + record + ); + let first = reopened + .service() + .replay_managed_call(request.clone()) + .unwrap(); + assert!(matches!(first, ManagedCallReplay::Final { .. })); + assert_eq!( + reopened + .service() + .replay_managed_call(request.clone()) + .unwrap(), + first + ); + assert_eq!( + reopened + .persistence() + .unwrap() + .managed_workspace_head(&record.identity.workspace) + .unwrap(), + head + ); + assert_eq!(fs::read(root.join("external-sentinel")).unwrap(), b"x"); + let events = reopened.service().run_events(&request.owner.run_id); + assert_eq!( + events + .iter() + .filter(|event| event["event"] == "tool.started") + .count(), + 1 + ); + assert_eq!( + events + .iter() + .filter(|event| event["event"] == "tool.output") + .count(), + 1 + ); + assert_eq!( + events + .iter() + .filter(|event| event["event"] == "tool.completed") + .count(), + 1 + ); + assert_eq!( + events + .iter() + .filter(|event| event["event"] == "tool.failed") + .count(), + 0 + ); + reopened.service().shutdown().await.unwrap(); +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn managed_schema_parser_rejects_unknown_formats_versions_and_authority_fields() { + use rustscript_agent::effects::{ManagedEffectFault, parse_managed_effect_yaml}; + let fixture = CutFixture::write(ManagedEffectFault::AfterPrepared).await; + let queried = fixture + .state + .persistence() + .unwrap() + .managed_effect_get(&fixture.request()) + .unwrap(); + if queried.is_none() { + let messages = serde_json::to_value( + fixture + .state + .service() + .session_messages(&fixture.run.session_id), + ) + .unwrap(); + let errors: Vec<_> = messages + .as_array() + .unwrap() + .iter() + .flat_map(|m| m["content"].as_array().into_iter().flatten()) + .filter_map(|block| block.get("error")) + .collect(); + panic!("managed schema prevented intent: {errors:?}"); + } + let record = queried.unwrap(); + let document = serde_json::to_string(&record).unwrap(); + assert_eq!(parse_managed_effect_yaml(&document).unwrap(), record); + let mut value = serde_json::to_value(&record).unwrap(); + value["format"] = json!("agent.managed-effect/v2"); + assert!( + parse_managed_effect_yaml(&value.to_string()).is_err(), + "the production parser must enforce the exact YAML format contract" + ); + value = serde_json::to_value(&record).unwrap(); + value["schemaVersion"] = json!(2); + assert!(parse_managed_effect_yaml(&value.to_string()).is_err()); + value = serde_json::to_value(&record).unwrap(); + value["execution_token"] = json!("guest-fabricated"); + assert!(parse_managed_effect_yaml(&value.to_string()).is_err()); + let duplicate = document.replacen("{", "{\"format\":\"agent.managed-effect/v1\",", 1); + assert!(parse_managed_effect_yaml(&duplicate).is_err()); + fixture.state.service().shutdown().await.unwrap(); +} + +struct ManagedFixture { + root: PathBuf, + home: PathBuf, + config: AgentGatewayConfig, + state: AgentGatewayState, +} +impl ManagedFixture { + fn new(configure: impl FnOnce(&mut AgentGatewayConfig)) -> Self { + let root = PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()) + .join(format!("managed-case-{}", uuid::Uuid::new_v4().simple())); + let workspace = root.join("workspace"); + let home = root.join("home"); + fs::create_dir_all(&workspace).unwrap(); + fs::create_dir_all(&home).unwrap(); + use std::os::unix::fs::PermissionsExt; + for path in [&root, &workspace, &home] { + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); + } + fs::write(home.join("config.yaml"),format!("version: 1\nmodel:\n provider: local-test\n model: synthetic\nproviders:\n local-test:\n protocol: codex-responses\n base_url: https://127.0.0.1:1\nworkspaces:\n allowed_roots:\n - '{}'\n default: '{}'\n",workspace.display(),workspace.display())).unwrap(); + let mut config = AgentGatewayConfig { + managed_workspace: Some(ManagedWorkspaceConfig { + source_root: workspace, + quota: ResourceQuota::new(1000, 1048576, 16).unwrap(), + retention: ManagedRetentionLimits::default(), + }), + ..AgentGatewayConfig::default() + }; + config.approvals.write = "allow".into(); + config.approvals.process = "allow".into(); + configure(&mut config); + let state = + AgentGatewayState::with_sqlite_path(config.clone(), root.join("gateway.sqlite")) + .unwrap() + .with_workspace_home(&home); + Self { + root, + home, + config, + state, + } + } + async fn calls(&self, calls: Vec) -> rustscript_agent::AdmittedRun { + let provider = Arc::new(ScriptedProvider::new()); + for call in calls { + provider.push_ok(response(Some(call))); + } + provider.push_ok(response(None)); + self.state.service().inject_provider_host(provider); + let run = self + .state + .service() + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"execute the bounded fixture calls"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + self.state + .service() + .run_worker(run.run_id.clone(), "ignored".into()) + .await; + run + } + fn request( + run: &rustscript_agent::AdmittedRun, + call: &str, + ) -> rustscript_agent::effects::ManagedEffectGetRequest { + rustscript_agent::effects::ManagedEffectGetRequest { + owner: rustscript_agent::effects::ManagedOwnerScope { + profile: "gateway".into(), + session_id: run.session_id.clone(), + run_id: run.run_id.clone(), + }, + call_id: call.into(), + } + } + async fn reopen(self) -> Self { + self.state.service().shutdown().await.unwrap(); + let Self { + root, + home, + config, + state, + } = self; + drop(state); + let state = + AgentGatewayState::with_sqlite_path(config.clone(), root.join("gateway.sqlite")) + .unwrap() + .with_workspace_home(&home); + Self { + root, + home, + config, + state, + } + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn managed_native_write_reserves_aggregate_quota_before_file_publication() { + let fixture = ManagedFixture::new(|config| { + config.managed_workspace.as_mut().unwrap().quota = ResourceQuota::new(10, 8, 4).unwrap() + }); + let run=fixture.calls(vec![json!({"id":"quota-write","name":"write_file","arguments":{"path":"large.txt","content":"123456789"}})]).await; + let request = ManagedFixture::request(&run, "quota-write"); + let record = fixture + .state + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(); + assert!( + record.outcome.is_some(), + "a bounded Native quota denial must commit its actual failed observation" + ); + assert_eq!( + record.outcome.as_ref().unwrap().disposition, + rustscript_agent::effects::ManagedPublicationStatus::NotPublished + ); + let views = fs::read_dir(fixture.root.join(".gateway.sqlite.effects/views")) + .unwrap() + .map(|entry| entry.unwrap().path()) + .collect::>(); + assert_eq!(views.len(), 1); + assert!( + !views[0].join("large.txt").exists(), + "aggregate quota must reject before the original atomic write" + ); + let head = fixture + .state + .persistence() + .unwrap() + .managed_workspace_head(&record.identity.workspace) + .unwrap(); + assert_eq!(head.head_version, 1); + let reopened = fixture.reopen().await; + assert_eq!( + reopened + .state + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(), + record + ); + reopened.state.service().shutdown().await.unwrap(); +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn two_real_prepared_effects_cas_one_winner_and_persist_exact_conflict_projection() { + use rustscript_agent::effects::{ManagedCallReplay, ManagedPublicationStatus}; + let fixture = ManagedFixture::new(|_| {}); + let gate = fixture.state.service().arm_managed_prepare_gate().unwrap(); + let first = fixture + .state + .service() + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"write first candidate"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + let first_provider = Arc::new(ScriptedProvider::new()); + first_provider.push_ok(response(Some( + json!({"id":"race","name":"write_file","arguments":{"path":"race.txt","content":"first"}}), + ))); + first_provider.push_ok(response(None)); + fixture.state.service().inject_provider_host(first_provider); + let first_worker = tokio::spawn( + fixture + .state + .service() + .run_worker(first.run_id.clone(), "ignored".into()), + ); + let first_arrival = gate.wait_arrived(1); + if first_arrival.is_err() { + gate.release(); + first_worker.await.unwrap(); + panic!("first real preparation did not arrive: {first_arrival:?}"); + } + let second = fixture + .state + .service() + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"write second candidate"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + let second_provider = Arc::new(ScriptedProvider::new()); + second_provider.push_ok(response(Some( + json!({"id":"race","name":"write_file","arguments":{"path":"race.txt","content":"second"}}), + ))); + second_provider.push_ok(response(None)); + fixture + .state + .service() + .inject_provider_host(second_provider); + let second_worker = tokio::spawn( + fixture + .state + .service() + .run_worker(second.run_id.clone(), "ignored".into()), + ); + let arrivals = gate.wait_arrived(2); + gate.release(); + let (a, b) = tokio::join!(first_worker, second_worker); + a.unwrap(); + b.unwrap(); + assert!(arrivals.is_ok(), "{arrivals:?}"); + drop(gate); + let requests = [ + ManagedFixture::request(&first, "race"), + ManagedFixture::request(&second, "race"), + ]; + let records = requests + .iter() + .map(|request| { + fixture + .state + .persistence() + .unwrap() + .managed_effect_get(request) + .unwrap() + .unwrap() + }) + .collect::>(); + assert_eq!( + records[0].identity.expected_head, + records[1].identity.expected_head + ); + assert_eq!( + records[0].identity.expected_head_version, + records[1].identity.expected_head_version + ); + assert_eq!( + records + .iter() + .filter(|record| record.outcome.as_ref().unwrap().disposition + == ManagedPublicationStatus::Committed) + .count(), + 1 + ); + assert_eq!( + records + .iter() + .filter(|record| record.outcome.as_ref().unwrap().disposition + == ManagedPublicationStatus::Conflict) + .count(), + 1 + ); + for (request, record) in requests.iter().zip(&records) { + let candidate = record + .prepared + .as_ref() + .unwrap() + .candidate_root + .as_ref() + .unwrap(); + let bytes = fs::read( + fixture + .root + .join(".gateway.sqlite.effects/trees") + .join(&candidate.object_id) + .join("race.txt"), + ) + .unwrap(); + assert!(bytes == b"first" || bytes == b"second"); + let replay = fixture + .state + .service() + .replay_managed_call(request.clone()) + .unwrap(); + let ManagedCallReplay::Final { + canonical_result, .. + } = replay + else { + panic!("expected final Native result"); + }; + let result: Value = serde_json::from_str(canonical_result.as_str()).unwrap(); + if record.outcome.as_ref().unwrap().disposition == ManagedPublicationStatus::Conflict { + assert_eq!(result["error"]["code"], "effect_head_conflict"); + assert_eq!(result["ok"], false); + } + let events = fixture.state.service().run_events(&request.owner.run_id); + let live = events + .iter() + .find(|event| event["event"] == "tool.output") + .unwrap()["data"]["managed_publication"] + .clone(); + let durable=fixture.state.persistence().unwrap().event_replay(&json!({"run_id":request.owner.run_id,"after_seq":0,"max_events":128,"max_bytes":65536})).unwrap(); + let columns = durable["columns"].as_array().unwrap(); + let kind = columns.iter().position(|c| c == "event_type").unwrap(); + let payload = columns.iter().position(|c| c == "payload_json").unwrap(); + let output = durable["rows"] + .as_array() + .unwrap() + .iter() + .find(|row| row[kind] == "tool.output") + .unwrap(); + let stored: Value = serde_json::from_str(output[payload].as_str().unwrap()).unwrap(); + assert_eq!( + stored["managed_publication"], live, + "durable projection must match this-call Native conflict receipt" + ); + } + let later=fixture.calls(vec![json!({"id":"later","name":"write_file","arguments":{"path":"later.txt","content":"later"}})]).await; + let later_record = fixture + .state + .persistence() + .unwrap() + .managed_effect_get(&ManagedFixture::request(&later, "later")) + .unwrap() + .unwrap(); + assert_eq!(later_record.identity.expected_head_version, 2); + let reopened = fixture.reopen().await; + for (request, record) in requests.iter().zip(&records) { + assert_eq!( + reopened + .state + .persistence() + .unwrap() + .managed_effect_get(request) + .unwrap() + .unwrap(), + *record + ); + } + reopened.state.service().shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn terminal_mutations_feed_original_read_search_patch_and_failed_patch_keeps_parent() { + use rustscript_agent::effects::{ManagedCallReplay, ManagedPublicationStatus}; + let fixture = ManagedFixture::new(|_| {}); + fs::write(fixture.root.join("workspace/gone.txt"), b"seed-only").unwrap(); + let run=fixture.calls(vec![ + json!({"id":"shell","name":"terminal","arguments":{"argv":["/bin/sh","-c","printf 'first\\n' > temp.txt; mv temp.txt note.txt; rm gone.txt"],"timeout_ms":1000}}), + json!({"id":"read","name":"read_file","arguments":{"path":"note.txt"}}), + json!({"id":"search","name":"search_files","arguments":{"pattern":"*.txt","target":"files"}}), + json!({"id":"patch","name":"patch","arguments":{"path":"note.txt","old_string":"first","new_string":"second"}}), + json!({"id":"failed-patch","name":"patch","arguments":{"path":"note.txt","old_string":"absent","new_string":"must-not-publish"}}), + ]).await; + let records = ["shell", "read", "search", "patch", "failed-patch"] + .iter() + .map(|call| { + fixture + .state + .persistence() + .unwrap() + .managed_effect_get(&ManagedFixture::request(&run, call)) + .unwrap() + .unwrap_or_else(|| { + panic!( + "missing managed record {call}; codes: {:?}", + fixture + .state + .service() + .run_events(&run.run_id) + .iter() + .map(|event| ( + event["event"].clone(), + event["data"]["code"].clone(), + event["data"]["error"]["code"].clone() + )) + .collect::>() + ) + }) + }) + .collect::>(); + assert_eq!( + records[1].outcome.as_ref().unwrap().disposition, + ManagedPublicationStatus::ReadOnly + ); + assert_eq!( + records[2].outcome.as_ref().unwrap().disposition, + ManagedPublicationStatus::ReadOnly + ); + assert_eq!( + records[4].outcome.as_ref().unwrap().disposition, + ManagedPublicationStatus::NotPublished + ); + for call in ["read", "search"] { + let ManagedCallReplay::Final { + canonical_result, .. + } = fixture + .state + .service() + .replay_managed_call(ManagedFixture::request(&run, call)) + .unwrap() + else { + panic!("expected exact replay"); + }; + let full: Value = serde_json::from_str(canonical_result.as_str()).unwrap(); + assert_eq!(full["ok"], true); + if call == "read" { + assert!(full["content"].as_str().unwrap().contains("first")); + } else { + assert!( + full.to_string().contains("note.txt"), + "search counters: files={:?}, dirs={:?}, matches={:?}, truncated={:?}", + full["data"]["files_visited"], + full["data"]["dirs_visited"], + full["data"]["match_count"], + full["truncated"] + ); + assert!(!full.to_string().contains("gone.txt")); + } + } + let head = fixture + .state + .persistence() + .unwrap() + .managed_workspace_head(&records[0].identity.workspace) + .unwrap(); + assert_eq!(head.head_version, 3); + let trees = fixture.root.join(".gateway.sqlite.effects/trees"); + assert_eq!( + fs::read(trees.join(&head.head_object_id).join("note.txt")).unwrap(), + b"second\n" + ); + assert_eq!( + fs::read( + trees + .join( + &records[0] + .prepared + .as_ref() + .unwrap() + .candidate_root + .as_ref() + .unwrap() + .object_id + ) + .join("note.txt") + ) + .unwrap(), + b"first\n" + ); + assert_eq!( + fs::read( + trees + .join(&records[0].identity.expected_head) + .join("gone.txt") + ) + .unwrap(), + b"seed-only" + ); + assert_eq!( + fs::read(fixture.root.join("workspace/gone.txt")).unwrap(), + b"seed-only" + ); + assert!(!fixture.root.join("workspace/note.txt").exists()); + let reopened = fixture.reopen().await; + assert_eq!( + reopened + .state + .persistence() + .unwrap() + .managed_workspace_head(&records[0].identity.workspace) + .unwrap(), + head + ); + for record in &records { + assert_eq!( + reopened + .state + .persistence() + .unwrap() + .managed_effect_get(&ManagedFixture::request(&run, &record.identity.call_id)) + .unwrap() + .unwrap(), + *record + ); + } + reopened.state.service().shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn large_nonzero_terminal_observation_replays_full_result_after_event_pruning_and_reopen() { + use rustscript_agent::effects::ManagedCallReplay; + let fixture = ManagedFixture::new(|config| { + config.max_events_per_run = 8; + }); + fixture + .state + .service() + .set_run_limits( + rustscript_agent::config::RunLimits::new( + 64, + 128, + 4 * 1024 * 1024, + fixture.root.join("workspace"), + ) + .unwrap(), + ) + .unwrap(); + fs::write( + fixture.root.join("workspace/prune.txt"), + b"bounded observation", + ) + .unwrap(); + let run=fixture.calls(vec![json!({"id":"large","name":"terminal","arguments":{"argv":["/bin/sh","-c","/usr/bin/python3 -c 'import sys; sys.stdout.write(chr(34)*350000)'; exit 7"],"timeout_ms":1000}}),json!({"id":"prune-one","name":"read_file","arguments":{"path":"prune.txt"}}),json!({"id":"prune-two","name":"read_file","arguments":{"path":"prune.txt"}})]).await; + assert!( + !fixture + .state + .service() + .run_events(&run.run_id) + .iter() + .any( + |event| event["event"] == "tool.output" && event["data"]["tool_call_id"] == "large" + ), + "the large call event must actually be pruned before replay" + ); + let request = ManagedFixture::request(&run, "large"); + let record = fixture + .state + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(); + assert!( + record.outcome.is_some(), + "large full result must commit without occupying the bounded SQL payload; prepared={}, state={:?}; events={:?}", + record.prepared.is_some(), + record.state, + fixture + .state + .service() + .run_events(&run.run_id) + .iter() + .map(|event| ( + event["event"].clone(), + event["data"]["error_code"].clone(), + event["data"]["code"].clone() + )) + .collect::>() + ); + let replay = fixture + .state + .service() + .replay_managed_call(request.clone()) + .unwrap(); + let ManagedCallReplay::Final { + canonical_result, .. + } = &replay + else { + panic!("expected exact canonical result"); + }; + assert!(canonical_result.as_str().len() > 1024 * 1024); + let full: Value = serde_json::from_str(canonical_result.as_str()).unwrap(); + assert_eq!(full["ok"], true); + assert_eq!(full["data"]["exit_code"], 7); + assert_eq!(full["data"]["stdout"].as_str().unwrap().len(), 350000); + assert_eq!(full["truncated"], false); + let messages = fixture + .state + .persistence() + .unwrap() + .message_list(&run.session_id, 0) + .unwrap(); + assert!( + messages.to_string().len() < 128 * 1024, + "large data belongs in the immutable canonical result, not the message projection" + ); + let reopened = fixture.reopen().await; + assert_eq!( + reopened + .state + .service() + .replay_managed_call(request) + .unwrap(), + replay + ); + reopened.state.service().shutdown().await.unwrap(); +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn readonly_exact_base_observation_survives_concurrent_head_advance_without_publication() { + use rustscript_agent::effects::{ManagedCallReplay, ManagedPublicationStatus}; + let fixture = ManagedFixture::new(|_| {}); + fs::write(fixture.root.join("workspace/observe.txt"), b"before").unwrap(); + let gate = fixture + .state + .service() + .arm_managed_call_prepare_gate("observe") + .unwrap(); + let read = fixture + .state + .service() + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"observe base"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + let provider = Arc::new(ScriptedProvider::new()); + provider.push_ok(response(Some( + json!({"id":"observe","name":"read_file","arguments":{"path":"observe.txt"}}), + ))); + provider.push_ok(response(None)); + fixture.state.service().inject_provider_host(provider); + let reader = tokio::spawn( + fixture + .state + .service() + .run_worker(read.run_id.clone(), "ignored".into()), + ); + let arrival = gate.wait_arrived(1); + if arrival.is_err() { + gate.release(); + reader.await.unwrap(); + panic!("real read did not prepare: {arrival:?}"); + } + let write=fixture.calls(vec![json!({"id":"advance","name":"write_file","arguments":{"path":"observe.txt","content":"after"}})]).await; + let written = fixture + .state + .persistence() + .unwrap() + .managed_effect_get(&ManagedFixture::request(&write, "advance")) + .unwrap() + .unwrap(); + let advanced = fixture + .state + .persistence() + .unwrap() + .managed_workspace_head(&written.identity.workspace) + .unwrap(); + assert_eq!(advanced.head_version, 2); + gate.release(); + reader.await.unwrap(); + drop(gate); + let request = ManagedFixture::request(&read, "observe"); + let observation = fixture + .state + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(); + assert_eq!( + observation.outcome.as_ref().unwrap().disposition, + ManagedPublicationStatus::ReadOnly + ); + assert_eq!(observation.identity.expected_head_version, 1); + assert_eq!( + observation + .outcome + .as_ref() + .unwrap() + .publication + .observed_head, + advanced.head_object_id + ); + assert_eq!( + fixture + .state + .persistence() + .unwrap() + .managed_workspace_head(&written.identity.workspace) + .unwrap(), + advanced + ); + let replay = fixture + .state + .service() + .replay_managed_call(request.clone()) + .unwrap(); + let ManagedCallReplay::Final { + canonical_result, .. + } = &replay + else { + panic!("expected base observation"); + }; + let full: Value = serde_json::from_str(canonical_result.as_str()).unwrap(); + assert!(full["content"].as_str().unwrap().contains("before")); + assert!(!full["content"].as_str().unwrap().contains("after")); + let reopened = fixture.reopen().await; + assert_eq!( + reopened + .state + .service() + .replay_managed_call(request) + .unwrap(), + replay + ); + reopened.state.service().shutdown().await.unwrap(); +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn preintent_and_partial_begin_failures_leave_zero_operation_or_durable_pair() { + use rustscript_agent::effects::ManagedEffectFault; + for cut in [ + ManagedEffectFault::BeforeIntent, + ManagedEffectFault::BeginAfterFirstEvent, + ] { + let fixture = ManagedFixture::new(|_| {}); + fixture + .state + .service() + .inject_managed_effect_fault(cut) + .unwrap(); + let sentinel = fixture.root.join("external-sentinel"); + let command = format!("printf x >> '{}'", sentinel.display()); + let run=fixture.calls(vec![json!({"id":"never","name":"terminal","arguments":{"argv":["/bin/sh","-c",command],"timeout_ms":1000}})]).await; + let request = ManagedFixture::request(&run, "never"); + assert!( + fixture + .state + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .is_none(), + "begin failure must leave no durable intent: {cut:?}" + ); + assert!( + !sentinel.exists(), + "begin failure must not execute: {cut:?}" + ); + assert_eq!( + fs::read_dir(fixture.root.join(".gateway.sqlite.effects/views")) + .unwrap() + .count(), + 0 + ); + for name in [ + "tool.requested", + "tool.started", + "tool.output", + "tool.completed", + "tool.failed", + ] { + assert_eq!( + fixture + .state + .service() + .run_events(&run.run_id) + .iter() + .filter(|event| event["event"] == name) + .count(), + 0, + "partial managed pair survived: {cut:?} {name}" + ); + } + let reopened = fixture.reopen().await; + assert!( + reopened + .state + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .is_none() + ); + assert!(!sentinel.exists()); + reopened.state.service().shutdown().await.unwrap(); + } +} + +#[test] +fn managed_startup_configuration_is_opt_in_and_rejects_invalid_source_or_retention() { + let direct = AgentGatewayConfig::default(); + assert!(direct.managed_workspace.is_none()); + assert!(direct.validate().is_ok()); + let root = PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()) + .join(format!("managed-config-{}", uuid::Uuid::new_v4().simple())); + fs::create_dir(&root).unwrap(); + let mut configured = direct; + configured.managed_workspace = Some(ManagedWorkspaceConfig { + source_root: root, + quota: ResourceQuota::new(1000, 1048576, 16).unwrap(), + retention: ManagedRetentionLimits::default(), + }); + assert!(configured.validate().is_ok()); + let mut invalid = configured.clone(); + invalid.managed_workspace.as_mut().unwrap().source_root = PathBuf::from("relative"); + assert!( + invalid.validate().is_err(), + "relative managed source must fail before startup" + ); + let mut invalid = configured.clone(); + invalid + .managed_workspace + .as_mut() + .unwrap() + .retention + .max_objects = 0; + assert!( + invalid.validate().is_err(), + "zero object admission bound must fail before startup" + ); + let mut invalid = configured; + invalid + .managed_workspace + .as_mut() + .unwrap() + .retention + .max_bytes = 0; + assert!( + invalid.validate().is_err(), + "zero byte admission bound must fail before startup" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn managed_background_and_all_process_continuations_deny_before_native_effect() { + let mut calls = vec![ + json!({"id":"denied","name":"terminal","arguments":{"argv":["/bin/sh","-c","exit 0"],"background":true,"timeout_ms":1000}}), + ]; + for action in ["poll", "wait", "log", "write", "close", "kill"] { + let mut arguments = json!({"action":action,"process_id":"public-id-not-authority"}); + if action == "write" { + arguments["stdin"] = json!("not-delivered"); + } + calls.push(json!({"id":"denied","name":"process","arguments":arguments})); + } + assert_eq!(calls.len(), 7); + for call in calls { + let fixture = ManagedFixture::new(|_| {}); + let run = fixture.calls(vec![call]).await; + assert!( + fixture + .state + .persistence() + .unwrap() + .managed_effect_get(&ManagedFixture::request(&run, "denied")) + .unwrap() + .is_none() + ); + assert_eq!( + fs::read_dir(fixture.root.join(".gateway.sqlite.effects/views")) + .unwrap() + .count(), + 0 + ); + assert!( + !fixture + .state + .service() + .run_events(&run.run_id) + .iter() + .any(|event| event["event"] == "tool.started") + ); + fixture.state.service().shutdown().await.unwrap(); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn replay_requires_exact_owner_and_current_native_source_identity_and_never_reimports_missing_objects() + { + use rustscript_agent::effects::ManagedCallReplay; + let fixture = ManagedFixture::new(|_| {}); + let run=fixture.calls(vec![json!({"id":"write","name":"write_file","arguments":{"path":"proof.txt","content":"original"}})]).await; + let request = ManagedFixture::request(&run, "write"); + let record = fixture + .state + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(); + let head = fixture + .state + .persistence() + .unwrap() + .managed_workspace_head(&record.identity.workspace) + .unwrap(); + let mut wrong = request.clone(); + wrong.owner.session_id = "different-session".into(); + assert!(matches!( + fixture.state.service().replay_managed_call(wrong).unwrap(), + ManagedCallReplay::Unavailable { .. } + )); + let result = fixture.root.join(".gateway.sqlite.effects/results").join( + &record + .outcome + .as_ref() + .unwrap() + .canonical_result_ref + .object_id, + ); + fs::rename(&result, result.with_extension("quarantined")).unwrap(); + assert!( + fixture + .state + .service() + .replay_managed_call(request.clone()) + .is_err() + ); + assert!(!result.exists()); + assert_eq!( + fixture + .state + .persistence() + .unwrap() + .managed_workspace_head(&record.identity.workspace) + .unwrap(), + head + ); + fs::rename(result.with_extension("quarantined"), &result).unwrap(); + fs::rename( + fixture.root.join("workspace"), + fixture.root.join("old-workspace"), + ) + .unwrap(); + fs::create_dir(fixture.root.join("workspace")).unwrap(); + assert!( + fixture + .state + .service() + .replay_managed_call(request.clone()) + .is_err(), + "saved receipt and display path cannot authorize a replaced source inode" + ); + let reopened = fixture.reopen().await; + assert!( + reopened + .state + .service() + .replay_managed_call(request) + .is_err() + ); + assert_eq!( + reopened + .state + .persistence() + .unwrap() + .managed_workspace_head(&record.identity.workspace) + .unwrap(), + head + ); + assert!(!reopened.root.join("workspace/proof.txt").exists()); + reopened.state.service().shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn managed_history_blocks_legacy_session_delete_with_typed_error_and_exact_readback() { + let fixture = ManagedFixture::new(|_| {}); + let run=fixture.calls(vec![json!({"id":"retain","name":"write_file","arguments":{"path":"keep.txt","content":"retained"}})]).await; + let request = ManagedFixture::request(&run, "retain"); + let record = fixture + .state + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(); + let before = fixture + .state + .persistence() + .unwrap() + .message_list(&run.session_id, 0) + .unwrap(); + let error = fixture + .state + .persistence() + .unwrap() + .session_delete(&run.session_id) + .expect_err("retained effect prevents cascaded deletion"); + assert_eq!(error.code, "managed_effect_retained"); + assert_eq!( + fixture + .state + .persistence() + .unwrap() + .message_list(&run.session_id, 0) + .unwrap(), + before + ); + assert_eq!( + fixture + .state + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(), + record + ); + let reopened = fixture.reopen().await; + assert_eq!( + reopened + .state + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(), + record + ); + reopened.state.service().shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn real_guest_approval_storage_cannot_forward_any_effect_mutation_or_query() { + let root = PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()).join(format!( + "managed-guest-fence-{}", + uuid::Uuid::new_v4().simple() + )); + fs::create_dir(&root).unwrap(); + let source = r#" + pub fn run(context:map)->map { + let run_id:string=context["run_id"];let session_id:string=context["session_id"]; + let commit:map=agent::approval_storage("effect.commit",{run_id:run_id.copy(),session_id:session_id.copy(),effect_id:"guest-receipt"}); + let get:map=agent::approval_storage("effect.get",{run_id:run_id.copy(),session_id:session_id.copy(),call_id:"guest-call"}); + let recover:map=agent::approval_storage("effect.recover_one",{run_id:run_id.copy(),session_id:session_id.copy(),effect_id:"guest-receipt"}); + let a:map=commit["error"];let b:map=get["error"];let c:map=recover["error"]; + let ac:string=a["code"];let bc:string=b["code"];let cc:string=c["code"]; + {kind:"run.completed",answer:ac+";"+bc+";"+cc} + } + "#; + let state = AgentGatewayState::with_agent_source_and_sqlite( + AgentGatewayConfig::default(), + source, + root.join("gateway.sqlite"), + ) + .unwrap(); + let provider = Arc::new(ScriptedProvider::new()); + state.service().inject_provider_host(provider.clone()); + let run = state + .service() + .admit(AdmitRunRequest { + input: json!({"message":"guest storage fence"}), + ..Default::default() + }) + .await + .unwrap(); + state + .service() + .run_worker(run.run_id.clone(), "ignored".into()) + .await; + assert_eq!(provider.call_count(), 0); + let messages = state + .persistence() + .unwrap() + .message_list(&run.session_id, 0) + .unwrap(); + assert!(messages.to_string().contains( + "approval_operation_invalid;approval_operation_invalid;approval_operation_invalid" + )); + assert!( + !state + .service() + .run_events(&run.run_id) + .iter() + .any(|event| event["event"] == "tool.started") + ); + assert!(!root.join(".gateway.sqlite.effects").exists()); + state.service().shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn typed_effect_pages_and_exact_bounded_result_chunks_require_current_native_authority() { + use rustscript_agent::effects::{ + ManagedCallReplay, ManagedEffectListRequest, ManagedResultReadRequest, + }; + let fixture = ManagedFixture::new(|_| {}); + let run = fixture + .calls(vec![ + json!({"id":"one","name":"write_file","arguments":{"path":"one.txt","content":"one"}}), + json!({"id":"two","name":"write_file","arguments":{"path":"two.txt","content":"two"}}), + ]) + .await; + let records = ["one", "two"] + .iter() + .map(|call| { + fixture + .state + .persistence() + .unwrap() + .managed_effect_get(&ManagedFixture::request(&run, call)) + .unwrap() + .unwrap() + }) + .collect::>(); + let request = ManagedEffectListRequest { + workspace_id: records[0].identity.workspace.workspace_id.clone(), + after_effect_id: None, + limit: 1, + }; + let first = fixture + .state + .service() + .query_managed_effects(request.clone()) + .expect("formal scoped effect page must be implemented"); + assert_eq!(first.entries.len(), 1); + assert!(first.has_more); + assert!(first.next_cursor.is_some()); + let second = fixture + .state + .service() + .query_managed_effects(ManagedEffectListRequest { + after_effect_id: first.next_cursor.clone(), + ..request.clone() + }) + .unwrap(); + assert_eq!(second.entries.len(), 1); + assert!(!second.has_more); + assert!(second.next_cursor.is_none()); + let actual = [ + first.entries[0].effect_id.clone(), + second.entries[0].effect_id.clone(), + ] + .into_iter() + .collect::>(); + let expected = records + .iter() + .map(|record| record.effect_id.clone()) + .collect::>(); + assert_eq!(actual, expected); + for limit in [0, 129] { + assert!( + fixture + .state + .service() + .query_managed_effects(ManagedEffectListRequest { + limit, + ..request.clone() + }) + .is_err() + ); + } + assert!( + fixture + .state + .service() + .query_managed_effects(ManagedEffectListRequest { + after_effect_id: Some("../not-an-id".into()), + ..request.clone() + }) + .is_err() + ); + let mut bytes = Vec::new(); + let mut offset = 0; + for _ in 0..4096 { + let chunk = fixture + .state + .service() + .read_managed_result_chunk(ManagedResultReadRequest { + effect_id: records[0].effect_id.clone(), + offset, + limit: 17, + }) + .unwrap(); + assert!(chunk.bytes.len() <= 17); + assert_eq!(chunk.next_offset, offset + chunk.bytes.len() as u64); + assert!(serde_json::to_vec(&chunk).unwrap().len() < 1024 * 1024); + bytes.extend_from_slice(&chunk.bytes); + offset = chunk.next_offset; + if chunk.eof { + assert_eq!(offset, chunk.total_bytes); + break; + } + } + let ManagedCallReplay::Final { + canonical_result, .. + } = fixture + .state + .service() + .replay_managed_call(ManagedFixture::request(&run, "one")) + .unwrap() + else { + panic!("expected exact full result"); + }; + assert_eq!(bytes, canonical_result.as_str().as_bytes()); + for (offset, limit) in [(0, 0), (0, 131073), (bytes.len() as u64 + 1, 1)] { + assert!( + fixture + .state + .service() + .read_managed_result_chunk(ManagedResultReadRequest { + effect_id: records[0].effect_id.clone(), + offset, + limit + }) + .is_err() + ); + } + fs::rename( + fixture.root.join("workspace"), + fixture.root.join("previous-workspace"), + ) + .unwrap(); + fs::create_dir(fixture.root.join("workspace")).unwrap(); + assert!( + fixture + .state + .service() + .query_managed_effects(request) + .is_err() + ); + assert!( + fixture + .state + .service() + .read_managed_result_chunk(ManagedResultReadRequest { + effect_id: records[0].effect_id.clone(), + offset: 0, + limit: 17 + }) + .is_err() + ); + fixture.state.service().shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn durable_retention_capacity_survives_fresh_restart_and_denies_before_terminal_spawn() { + let fixture = ManagedFixture::new(|config| { + config + .managed_workspace + .as_mut() + .unwrap() + .retention + .max_objects = 5 + }); + let first=fixture.calls(vec![json!({"id":"kept","name":"write_file","arguments":{"path":"kept.txt","content":"retained"}})]).await; + let request = ManagedFixture::request(&first, "kept"); + let record = fixture + .state + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(); + assert!(record.outcome.is_some()); + assert_eq!( + fs::read_dir(fixture.root.join(".gateway.sqlite.effects/views")) + .unwrap() + .count(), + 0, + "a positively settled committed view is reclaimed only after both Native lease and exact durable reference checks" + ); + let references = fixture + .state + .persistence() + .unwrap() + .managed_object_references(&rustscript_agent::effects::ManagedObjectReferencesRequest { + store_id: record.identity.workspace.store_id.clone(), + object_id: record + .outcome + .as_ref() + .unwrap() + .canonical_result_ref + .object_id + .clone(), + }) + .unwrap(); + assert!(references.iter().any(|reference| reference.reference_kind + == rustscript_agent::effects::ManagedObjectReferenceKind::SelectedResult)); + let fixture = fixture.reopen().await; + let sentinel = fixture.root.join("must-not-execute"); + let second=fixture.calls(vec![json!({"id":"denied","name":"terminal","arguments":{"argv":["/bin/sh","-c",format!("printf x >> '{}'",sentinel.display())],"timeout_ms":1000}})]).await; + assert!( + !sentinel.exists(), + "durable capacity must be reserved before a new actual terminal operation, including after restart" + ); + assert!( + fixture + .state + .persistence() + .unwrap() + .managed_effect_get(&ManagedFixture::request(&second, "denied")) + .unwrap() + .is_none() + ); + assert_eq!( + fixture + .state + .persistence() + .unwrap() + .managed_workspace_head(&record.identity.workspace) + .unwrap() + .head_version, + 2 + ); + assert!(matches!( + fixture + .state + .service() + .replay_managed_call(request) + .unwrap(), + rustscript_agent::effects::ManagedCallReplay::Final { .. } + )); + fixture.state.service().shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn large_original_arguments_use_synced_intent_detail_object_without_expanding_sql_bounds() { + let fixture = ManagedFixture::new(|_| {}); + let content = "q".repeat(50000); + let run=fixture.calls(vec![json!({"id":"large-argument","name":"write_file","arguments":{"path":"large.txt","content":content}})]).await; + let request = ManagedFixture::request(&run, "large-argument"); + let record = fixture + .state + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .expect("large argument intent/receipt must be present"); + let rustscript_agent::effects::ManagedArgumentIdentity::Object { details } = + &record.identity.argument_identity + else { + panic!( + "large original argument facts require a synced intent detail object, not expanded SQL metadata" + ); + }; + assert_eq!( + details.object_kind, + rustscript_agent::effects::ManagedObjectKind::IntentDetails + ); + let bytes = fs::read( + fixture + .root + .join(".gateway.sqlite.effects/details") + .join(&details.object_id), + ) + .unwrap(); + let arguments: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(arguments["content"], content); + assert_eq!(details.byte_count, bytes.len().to_string()); + assert!(serde_json::to_vec(&record).unwrap().len() < 16384); + assert_eq!( + record.state, + rustscript_agent::effects::ManagedPublicationStatus::Committed + ); + let references = fixture + .state + .persistence() + .unwrap() + .managed_object_references(&rustscript_agent::effects::ManagedObjectReferencesRequest { + store_id: details.store_id.clone(), + object_id: details.object_id.clone(), + }) + .unwrap(); + assert!(references.iter().any(|reference| reference.reference_kind + == rustscript_agent::effects::ManagedObjectReferenceKind::IntentDetails)); + let before = fixture + .state + .service() + .replay_managed_call(request.clone()) + .unwrap(); + let reopened = fixture.reopen().await; + assert_eq!( + reopened + .state + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(), + record + ); + assert_eq!( + reopened + .state + .service() + .replay_managed_call(request) + .unwrap(), + before + ); + reopened.state.service().shutdown().await.unwrap(); +} + +#[cfg(feature = "workspace-io-fault")] +struct CutFixture { + root: PathBuf, + home: PathBuf, + config: AgentGatewayConfig, + state: AgentGatewayState, + run: rustscript_agent::AdmittedRun, +} + +#[cfg(feature = "workspace-io-fault")] +impl CutFixture { + async fn write(cut: rustscript_agent::effects::ManagedEffectFault) -> Self { + Self::run(cut, false).await + } + async fn terminal(cut: rustscript_agent::effects::ManagedEffectFault) -> Self { + Self::run(cut, true).await + } + async fn run(cut: rustscript_agent::effects::ManagedEffectFault, terminal: bool) -> Self { + let root = PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()) + .join(format!("managed-cut-{}", uuid::Uuid::new_v4().simple())); + let workspace = root.join("workspace"); + let home = root.join("home"); + fs::create_dir_all(&workspace).unwrap(); + fs::create_dir_all(&home).unwrap(); + use std::os::unix::fs::PermissionsExt; + for path in [&root, &workspace, &home] { + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); + } + fs::write(home.join("config.yaml"),format!("version: 1\nmodel:\n provider: local-test\n model: synthetic\nproviders:\n local-test:\n protocol: codex-responses\n base_url: https://127.0.0.1:1\nworkspaces:\n allowed_roots:\n - '{}'\n default: '{}'\n",workspace.display(),workspace.display())).unwrap(); + let mut config = AgentGatewayConfig { + managed_workspace: Some(ManagedWorkspaceConfig { + source_root: workspace, + quota: ResourceQuota::new(1000, 1048576, 16).unwrap(), + retention: ManagedRetentionLimits::default(), + }), + ..AgentGatewayConfig::default() + }; + config.approvals.write = "allow".into(); + config.approvals.process = "allow".into(); + let state = + AgentGatewayState::with_sqlite_path(config.clone(), root.join("gateway.sqlite")) + .unwrap() + .with_workspace_home(&home); + state.service().inject_managed_effect_fault(cut).unwrap(); + let provider = Arc::new(ScriptedProvider::new()); + let call = if terminal { + json!({"id":"cut-write","name":"terminal","arguments":{"argv":["/bin/sh","-c",format!("printf x >> '{}'; printf 'staged evidence\\n' > cut.txt",root.join("external-sentinel").display())],"timeout_ms":1000}}) + } else { + json!({"id":"cut-write","name":"write_file","arguments":{"path":"cut.txt","content":"staged evidence\n"}}) + }; + provider.push_ok(response(Some(call))); + provider.push_ok(response(None)); + state.service().inject_provider_host(provider); + let run = state + .service() + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"write fixture file"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + state + .service() + .run_worker(run.run_id.clone(), "ignored".into()) + .await; + Self { + root, + home, + config, + state, + run, + } + } + fn request(&self) -> rustscript_agent::effects::ManagedEffectGetRequest { + rustscript_agent::effects::ManagedEffectGetRequest { + owner: rustscript_agent::effects::ManagedOwnerScope { + profile: "gateway".into(), + session_id: self.run.session_id.clone(), + run_id: self.run.run_id.clone(), + }, + call_id: "cut-write".into(), + } + } +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn prepared_receipt_fresh_reopen_stays_staged_without_publication() { + use rustscript_agent::effects::{ManagedCallReplay, ManagedEffectFault}; + let fixture = CutFixture::write(ManagedEffectFault::AfterPrepared).await; + let request = fixture.request(); + let record = fixture + .state + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(); + assert!( + record.prepared.is_some(), + "the real prepared receipt must exist" + ); + assert!( + record.outcome.is_none(), + "a cut after preparation must prevent publication" + ); + let head = fixture + .state + .persistence() + .unwrap() + .managed_workspace_head(&record.identity.workspace) + .unwrap(); + assert_eq!(head.head_version, 1); + assert!(!fixture.root.join("workspace/cut.txt").exists()); + fixture.state.service().shutdown().await.unwrap(); + let CutFixture { + root, + home, + config, + state, + .. + } = fixture; + drop(state); + let reopened = AgentGatewayState::with_sqlite_path(config, root.join("gateway.sqlite")) + .unwrap() + .with_workspace_home(&home); + let after = reopened + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(); + assert_eq!(after.prepared, record.prepared); + assert_eq!( + after.identity.original_deadline_ms, + record.identity.original_deadline_ms + ); + assert!(after.outcome.is_none()); + assert!(matches!( + reopened + .service() + .replay_managed_call(request.clone()) + .unwrap(), + ManagedCallReplay::Staged { .. } + )); + assert_eq!( + reopened + .persistence() + .unwrap() + .managed_workspace_head(&record.identity.workspace) + .unwrap(), + head + ); + let events = reopened.service().run_events(&request.owner.run_id); + assert_eq!( + events + .iter() + .filter(|event| event["event"] == "tool.output") + .count(), + 1 + ); + assert_eq!( + events + .iter() + .filter(|event| event["event"] == "tool.failed") + .count(), + 1 + ); + reopened.service().shutdown().await.unwrap(); +} From e699c731792f71105d78c31de5464c0d83188249 Mon Sep 17 00:00:00 2001 From: fffonion Date: Mon, 19 Oct 2026 07:18:31 +0800 Subject: [PATCH 06/21] fix(effects): honor directory durability and invocation/startup budgets --- src/effects/broker.rs | 76 +++-- src/effects/journal.rs | 39 +-- src/effects/managed_authority_tests.rs | 11 +- src/effects/retention.rs | 86 +++-- src/effects/retention_tests.rs | 160 +++++++++ src/effects/tree_store.rs | 205 ++++++++++-- src/gateway/store.rs | 139 +++++++- src/runtime/rss_runner.rs | 6 + src/service.rs | 2 +- src/service/managed_effects.rs | 51 ++- src/service/managed_effects_tests.rs | 2 + src/service/managed_review_tests.rs | 442 +++++++++++++++++++++++++ 12 files changed, 1098 insertions(+), 121 deletions(-) create mode 100644 src/effects/retention_tests.rs create mode 100644 src/service/managed_review_tests.rs diff --git a/src/effects/broker.rs b/src/effects/broker.rs index 1604a7d..9c2072a 100644 --- a/src/effects/broker.rs +++ b/src/effects/broker.rs @@ -77,6 +77,7 @@ pub(crate) struct ManagedEffectRuntime { marker: File, marker_identity: (u64, u64), leases: Arc, + pub(crate) tree_sync: tree_store::DirectorySync, initialized: Mutex>, } @@ -566,8 +567,13 @@ impl ManagedEffectRuntime { tree_store::fsync_dir(parent).map_err(error)?; } let marker_identity = file_identity(&marker)?; - let leases = - ManagedObjectLeaseRegistry::open(&config, &root, persistence.as_ref(), &store_id)?; + let leases = ManagedObjectLeaseRegistry::open( + &config, + &root, + persistence.as_ref(), + &store_id, + &super::retention::StartupCensus::new(), + )?; Ok(Arc::new(Self { #[cfg(test)] canonical_observation_fixture: Mutex::new(None), @@ -591,6 +597,7 @@ impl ManagedEffectRuntime { marker, marker_identity, leases, + tree_sync: tree_store::DirectorySync::default(), initialized: Mutex::new(None), })) } @@ -640,13 +647,14 @@ impl ManagedEffectRuntime { let cancellation = EffectCancellation::linked(move || { original.requested().is_some() || original.deadline_passed() }); - let copied = tree_store::copy_tree_from_fd( + let copied = tree_store::copy_tree_from_fd_with_sync( &source.duplicate_descriptor().map_err(error)?, &staging, &self.config.quota, &cancellation, &deadline, seal, + &self.tree_sync, ) .map_err(error)?; let final_path = self.path.join("trees").join(&id); @@ -917,6 +925,7 @@ impl ManagedEffectRuntime { authority: &WorkspaceAuthority, profile: &str, workspace_id: &str, + cancellation: &RunCancellation, ) -> Result<(), String> { validate_id("ws", workspace_id)?; let (device, inode) = authority @@ -931,7 +940,7 @@ impl ManagedEffectRuntime { }; let record = self .repository - .workspace(&self.store_id, profile, &source)? + .workspace(&self.store_id, profile, &source, cancellation)? .ok_or("managed configured workspace unavailable")?; if record.identity.workspace_id != workspace_id { return Err("managed configured workspace mismatch".into()); @@ -981,9 +990,12 @@ impl ManagedEffectRuntime { let workspace = match cached { Some(record) => record, None => { - let existing = - self.repository - .workspace(&self.store_id, &owner.profile, &identity)?; + let existing = self.repository.workspace( + &self.store_id, + &owner.profile, + &identity, + &cancel, + )?; let record = match existing { Some(record) => record, None => { @@ -1005,7 +1017,7 @@ impl ManagedEffectRuntime { }; self.repository.command_with_cancellation("effect.workspace_register",&json!({"store_id":self.store_id,"profile":owner.profile,"source_device":identity.device_decimal,"source_inode":identity.inode_decimal,"workspace_json":serde_json::to_string(&record).map_err(error)?}),&cancel)?; self.repository - .workspace(&self.store_id, &owner.profile, &identity)? + .workspace(&self.store_id, &owner.profile, &identity, &cancel)? .ok_or("workspace registration missing")? } }; @@ -1077,6 +1089,20 @@ pub(crate) struct ManagedLiveReplay { pub(crate) result: Value, } impl ManagedRunBinding { + #[cfg(test)] + pub(crate) fn with_cancellation_for_test(&self, cancel: RunCancellation) -> Arc { + Arc::new(Self { + runtime: Arc::clone(&self.runtime), + authority: Arc::clone(&self.authority), + owner: self.owner.clone(), + code: self.code.clone(), + contract: self.contract.clone(), + cancel, + workspace: self.workspace.clone(), + calls: Mutex::new(HashMap::new()), + admitted_limits: Mutex::new(*self.admitted_limits.lock()), + }) + } pub(crate) fn matches_contract(&self, contract: Option<&ManagedEntryContract>) -> bool { contract.is_some_and(|contract| { contract.trust == self.contract.trust @@ -1101,10 +1127,13 @@ impl ManagedRunBinding { canonical: Option<&str>, ) -> Result, String> { self.check()?; - let Some(record) = self.runtime.repository.get(&ManagedEffectGetRequest { - owner: self.owner.clone(), - call_id: call_id.into(), - })? + let Some(record) = self.runtime.repository.get( + &ManagedEffectGetRequest { + owner: self.owner.clone(), + call_id: call_id.into(), + }, + &self.cancel, + )? else { return Ok(None); }; @@ -1118,7 +1147,10 @@ impl ManagedRunBinding { { return Err("managed replay identity mismatch".into()); } - let head = self.runtime.repository.head(&self.workspace.identity)?; + let head = self + .runtime + .repository + .head(&self.workspace.identity, &self.cancel)?; if identity.epoch != head.epoch { return Err("managed replay epoch mismatch".into()); } @@ -1229,14 +1261,20 @@ impl ManagedRunBinding { { return Err("managed_open_effect_unsupported".into()); } - let head = self.runtime.repository.head(&self.workspace.identity)?; + let head = self + .runtime + .repository + .head(&self.workspace.identity, &self.cancel)?; let parent = self .runtime .repository - .object_metadata(&ManagedObjectReferencesRequest { - store_id: self.workspace.identity.store_id.clone(), - object_id: head.head_object_id.clone(), - })? + .object_metadata( + &ManagedObjectReferencesRequest { + store_id: self.workspace.identity.store_id.clone(), + object_id: head.head_object_id.clone(), + }, + &self.cancel, + )? .ok_or("managed parent object metadata missing")?; if parent.object_kind != ManagedObjectKind::Tree || parent.object_format != "agent.managed-tree/v1" @@ -1487,7 +1525,7 @@ impl ManagedCallContext { && binding .runtime .repository - .object_references(&request) + .object_references(&request, &binding.cancel) .is_ok_and(|references| references.is_empty()) && binding.runtime.check().is_ok() && adapter.root.revalidate_binding(); diff --git a/src/effects/journal.rs b/src/effects/journal.rs index c49a9de..16a0efa 100644 --- a/src/effects/journal.rs +++ b/src/effects/journal.rs @@ -353,22 +353,11 @@ impl ManagedEffectRepository { pub(crate) fn new(persistence: Arc) -> Self { Self { persistence } } - pub(crate) fn command(&self, op: &str, payload: &Value) -> Result { - self.command_inner(op, payload, None) - } pub(crate) fn command_with_cancellation( &self, op: &str, payload: &Value, cancellation: &crate::RunCancellation, - ) -> Result { - self.command_inner(op, payload, Some(cancellation)) - } - fn command_inner( - &self, - op: &str, - payload: &Value, - cancellation: Option<&crate::RunCancellation>, ) -> Result { for field in ["record_json", "prepared_json", "workspace_json"] { if let Some(text) = payload @@ -383,38 +372,40 @@ impl ManagedEffectRepository { })?; } } - if let Some(cancellation) = cancellation { - self.persistence - .managed_effect_command_with_cancellation(op, payload, cancellation) - } else { - self.persistence.managed_effect_command(op, payload) - } - .map_err(|e| e.to_string()) + self.persistence + .managed_effect_command_with_cancellation(op, payload, cancellation) + .map_err(|e| e.to_string()) } pub(crate) fn workspace( &self, store_id: &str, profile: &str, source: &DirectoryIdentity, + cancellation: &crate::RunCancellation, ) -> Result, String> { - let result = self.command("effect.workspace_get", &json!({"store_id":store_id,"profile":profile,"source_device":source.device_decimal,"source_inode":source.inode_decimal}))?; + let result = self.command_with_cancellation("effect.workspace_get", &json!({"store_id":store_id,"profile":profile,"source_device":source.device_decimal,"source_inode":source.inode_decimal}), cancellation)?; decode_record(&result) } pub(crate) fn head( &self, workspace: &ManagedWorkspaceIdentity, + cancellation: &crate::RunCancellation, ) -> Result { - let result = self.command( + let result = self.command_with_cancellation( "effect.head", &json!({"workspace_id":workspace.workspace_id,"branch_id":workspace.branch_id}), + cancellation, )?; decode_record(&result)?.ok_or_else(|| "managed workspace head missing".into()) } pub(crate) fn get( &self, request: &ManagedEffectGetRequest, + cancellation: &crate::RunCancellation, ) -> Result, String> { - decode_record(&self.command("effect.get", &json!(request))?) + self.persistence + .managed_effect_get_with_cancellation(request, cancellation) + .map_err(|e| e.to_string()) } #[cfg(feature = "workspace-io-fault")] pub(crate) fn inject_reference_transport_failure(&self) { @@ -423,17 +414,19 @@ impl ManagedEffectRepository { pub(crate) fn object_references( &self, request: &ManagedObjectReferencesRequest, + cancellation: &crate::RunCancellation, ) -> Result, String> { self.persistence - .managed_object_references(request) + .managed_object_references_with_cancellation(request, cancellation) .map_err(|e| e.to_string()) } pub(crate) fn object_metadata( &self, request: &ManagedObjectReferencesRequest, + cancellation: &crate::RunCancellation, ) -> Result, String> { self.persistence - .managed_object_metadata(request) + .managed_object_metadata_with_cancellation(request, cancellation) .map_err(|e| e.to_string()) } } diff --git a/src/effects/managed_authority_tests.rs b/src/effects/managed_authority_tests.rs index 88a3321..de8f275 100644 --- a/src/effects/managed_authority_tests.rs +++ b/src/effects/managed_authority_tests.rs @@ -291,10 +291,13 @@ async fn extra_native_descriptor_lease_retains_actual_final_view_even_with_empty assert!( runtime .repository - .object_references(&ManagedObjectReferencesRequest { - store_id: runtime.store_id.clone(), - object_id: adapter.view_id.clone() - }) + .object_references( + &ManagedObjectReferencesRequest { + store_id: runtime.store_id.clone(), + object_id: adapter.view_id.clone() + }, + &call.binding().unwrap().cancel + ) .unwrap() .is_empty() ); diff --git a/src/effects/retention.rs b/src/effects/retention.rs index b57b78e..213c421 100644 --- a/src/effects/retention.rs +++ b/src/effects/retention.rs @@ -1,24 +1,55 @@ //! Native reservations and leases. No lease lock spans filesystem or SDK IO. -use super::{ - EffectCancellation, EffectDeadline, broker::validate_id, journal::ManagedObjectKind, tree_store, -}; +use super::{broker::validate_id, journal::ManagedObjectKind, tree_store}; use crate::{ capabilities::vm_io::{ConfinedFsRoot, confined_fs::EnumerationBudget}, config::ManagedWorkspaceConfig, gateway::store::GatewayPersistence, }; use parking_lot::Mutex; -use std::{ - collections::BTreeMap, - sync::Arc, - time::{Duration, Instant}, -}; +use std::{collections::BTreeMap, sync::Arc}; #[derive(Clone, Copy, Default)] struct Charge { objects: usize, bytes: u64, } + +#[cfg(test)] +use super::{EffectCancellation, EffectDeadline}; +#[cfg(test)] +use std::time::Instant; + +/// Startup-only traversal authority: configured work quotas, never a live call. +pub(super) struct StartupCensus { + #[cfg(test)] + deadline: EffectDeadline, + #[cfg(test)] + checks: std::sync::atomic::AtomicUsize, +} +impl StartupCensus { + #[cfg(test)] + pub(super) fn observed_work_for_test(&self) -> usize { + self.checks.load(std::sync::atomic::Ordering::Acquire) + } + pub(super) fn checkpoint(&self) { + #[cfg(test)] + self.checks + .fetch_add(1, std::sync::atomic::Ordering::AcqRel); + } + pub(super) fn new() -> Self { + Self { + #[cfg(test)] + deadline: EffectDeadline::at(Instant::now()), + #[cfg(test)] + checks: std::sync::atomic::AtomicUsize::new(0), + } + } +} + +#[cfg(test)] +#[path = "retention_tests.rs"] +mod tests; + struct Entry { bytes: u64, leases: usize, @@ -69,10 +100,10 @@ impl ManagedObjectLeaseRegistry { root: &ConfinedFsRoot, persistence: &GatewayPersistence, store_id: &str, + census: &StartupCensus, ) -> Result, String> { - let deadline = EffectDeadline::at(Instant::now() + Duration::from_secs(5)); - let cancellation = EffectCancellation::default(); let mut entries: BTreeMap = BTreeMap::new(); + let mut physical = BTreeMap::new(); for entry in root .enumerate_with_budget( "", @@ -94,7 +125,6 @@ impl ManagedObjectLeaseRegistry { } } for namespace in ["trees", "results", "details", "views", "staging"] { - deadline.check().map_err(|e| e.to_string())?; let observed = root .enumerate_with_budget( namespace, @@ -121,30 +151,35 @@ impl ManagedObjectLeaseRegistry { return Err("managed object appears in multiple namespaces".into()); } let metadata = item.metadata(); + let mut physical_entries = 1; let bytes = if namespace == "views" { if !metadata.is_dir() { return Err("managed view has wrong kind".into()); } config.quota.max_bytes - } else if metadata.is_dir() { + } else if metadata.is_dir() && prefix == "tree" { let directory = root .subroot(&format!("{namespace}/{id}"), root.limits()) .map_err(|e| e.to_string())?; - tree_store::scan_usage_from_fd( + let usage = tree_store::startup_census_usage_from_fd( &directory .duplicate_descriptor() .map_err(|e| e.to_string())?, &config.quota, - &deadline, - &cancellation, + census, ) - .map_err(|e| e.to_string())? - .bytes - } else if metadata.is_file() && metadata.link_count() == 1 { + .map_err(|e| e.to_string())?; + physical_entries = usage.entries; + usage.bytes + } else if metadata.is_file() + && metadata.link_count() == 1 + && ["result", "intent"].contains(&prefix) + { metadata.len() } else { return Err("managed census contains unsupported object".into()); }; + physical.insert(id.to_string(), (prefix.to_string(), physical_entries)); entries.insert( id.to_string(), Entry { @@ -176,7 +211,20 @@ impl ManagedObjectLeaseRegistry { .parse::() .map_err(|_| "managed census byte count invalid")?; if let Some(entry) = entries.get_mut(&object.object_id) { - if entry.bytes != bytes { + let expected_entries = object + .entry_count + .parse::() + .map_err(|_| "managed census entry count invalid")?; + let expected_format = match object.object_kind { + ManagedObjectKind::Tree => "agent.managed-tree/v1", + ManagedObjectKind::CanonicalResult => "agent.canonical-tool-result/v1", + ManagedObjectKind::IntentDetails => "agent.intent-details/v1", + }; + if entry.bytes != bytes + || physical.get(&object.object_id) + != Some(&(prefix.to_string(), expected_entries)) + || object.object_format != expected_format + { return Err("managed object size differs from durable registry".into()); } entry.unknown = false; diff --git a/src/effects/retention_tests.rs b/src/effects/retention_tests.rs new file mode 100644 index 0000000..652fda8 --- /dev/null +++ b/src/effects/retention_tests.rs @@ -0,0 +1,160 @@ +use super::*; +use crate::{AdmitRunRequest, AgentGatewayConfig, AgentGatewayState, effects::ResourceQuota}; +use std::{ + fs, + os::unix::fs::{PermissionsExt, symlink}, + sync::atomic::Ordering, +}; + +#[tokio::test(flavor = "multi_thread")] +async fn managed_fresh_startup_census_is_quota_bounded_not_incidental_clock_bounded() { + let path = std::path::PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()) + .join(format!("census-{}", uuid::Uuid::new_v4().simple())); + let source = path.join("source"); + let home = path.join("home"); + fs::create_dir_all(source.join("a/b/empty")).unwrap(); + fs::create_dir(&home).unwrap(); + for index in 0..64 { + fs::write(source.join(format!("a/b/f{index}")), b"retained-bytes").unwrap(); + } + fs::write(home.join("config.yaml"),format!("version: 1\nmodel:\n provider: local-test\n model: synthetic\nproviders:\n local-test:\n protocol: codex-responses\n base_url: https://127.0.0.1:1\nworkspaces:\n allowed_roots:\n - '{}'\n default: '{}'\n",source.display(),source.display())).unwrap(); + let managed = ManagedWorkspaceConfig { + source_root: source, + quota: ResourceQuota::new(1000, 1048576, 16).unwrap(), + retention: Default::default(), + }; + let config = AgentGatewayConfig { + managed_workspace: Some(managed.clone()), + ..Default::default() + }; + let database = path.join("gateway.sqlite"); + let state = AgentGatewayState::with_sqlite_path(config.clone(), &database) + .unwrap() + .with_workspace_home(&home); + let run = state + .service() + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: serde_json::json!({"message":"retain a nontrivial tree"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + drop(run); + state.service().shutdown().await.unwrap(); + drop(state); + let persistence = GatewayPersistence::open(&config, &database).unwrap(); + persistence.load().unwrap(); + let store = path.join(".gateway.sqlite.effects"); + let root = ConfinedFsRoot::new(&store).unwrap(); + let store_id = fs::read_to_string(store.join("store-id")).unwrap(); + let objects = persistence.managed_object_census().unwrap(); + assert_eq!(objects.len(), 1); + let tree = store.join("trees").join(&objects[0].object_id); + let mut census = StartupCensus::new(); + census.deadline = EffectDeadline::at(Instant::now()); + assert!(census.deadline.check().is_err()); + let reopened = + ManagedObjectLeaseRegistry::open(&managed, &root, &persistence, &store_id, &census); + assert!( + reopened.is_ok(), + "a complete within-quota retained tree must reopen even with an expired incidental startup clock: {:?}", + reopened.err() + ); + assert!( + census.checks.load(Ordering::Acquire) > 64, + "the fresh physical FD traversal must really run" + ); + // Live invocation scanning still requires its original finite deadline. + let descriptor = ConfinedFsRoot::new(&tree) + .unwrap() + .duplicate_descriptor() + .unwrap(); + assert!( + tree_store::copy_tree_from_fd( + &descriptor, + &path.join("expired-copy"), + &managed.quota, + &EffectCancellation::default(), + &census.deadline, + true, + ) + .is_err() + ); + for quota in [ + ResourceQuota::new(1, 1048576, 16).unwrap(), + ResourceQuota::new(1000, 1, 16).unwrap(), + ResourceQuota::new(1000, 1048576, 1).unwrap(), + ] { + let mut tighter = managed.clone(); + tighter.quota = quota; + assert!( + ManagedObjectLeaseRegistry::open(&tighter, &root, &persistence, &store_id, &census) + .is_err() + ); + } + let unknown = format!("tree-{}", uuid::Uuid::new_v4().simple()); + let unknown_path = store.join("trees").join(&unknown); + fs::create_dir(&unknown_path).unwrap(); + fs::write(unknown_path.join("orphan"), b"unknown").unwrap(); + let registry = + ManagedObjectLeaseRegistry::open(&managed, &root, &persistence, &store_id, &census) + .unwrap(); + assert!(registry.state.lock().entries[&unknown].unknown); + assert_eq!(registry.state.lock().used.objects, 2); + let charged = registry.state.lock().used.bytes; + drop(registry); + let mut tight = managed.clone(); + tight.retention.max_objects = 1; + assert!( + ManagedObjectLeaseRegistry::open(&tight, &root, &persistence, &store_id, &census).is_err() + ); + tight = managed.clone(); + tight.retention.max_bytes = charged - 1; + assert!( + ManagedObjectLeaseRegistry::open(&tight, &root, &persistence, &store_id, &census).is_err() + ); + symlink("/outside", unknown_path.join("link")).unwrap(); + assert!( + ManagedObjectLeaseRegistry::open(&managed, &root, &persistence, &store_id, &census) + .is_err() + ); + fs::remove_file(unknown_path.join("link")).unwrap(); + fs::write(unknown_path.join("bad."), b"bad").unwrap(); + assert!( + ManagedObjectLeaseRegistry::open(&managed, &root, &persistence, &store_id, &census) + .is_err() + ); + fs::remove_file(unknown_path.join("bad.")).unwrap(); + fs::hard_link(unknown_path.join("orphan"), path.join("outside-hardlink")).unwrap(); + assert!( + ManagedObjectLeaseRegistry::open(&managed, &root, &persistence, &store_id, &census) + .is_err() + ); + fs::remove_file(path.join("outside-hardlink")).unwrap(); + fs::set_permissions(tree.join("a/b"), fs::Permissions::from_mode(0o700)).unwrap(); + fs::write(tree.join("a/b/extra-empty"), b"").unwrap(); + assert!( + ManagedObjectLeaseRegistry::open(&managed, &root, &persistence, &store_id, &census) + .is_err(), + "entry-count mismatch is corruption even with identical byte totals" + ); + fs::remove_file(tree.join("a/b/extra-empty")).unwrap(); + let file = tree.join("a/b/f0"); + fs::set_permissions(&file, fs::Permissions::from_mode(0o600)).unwrap(); + fs::write(&file, b"corrupt-size").unwrap(); + assert!( + ManagedObjectLeaseRegistry::open(&managed, &root, &persistence, &store_id, &census) + .is_err(), + "physical/durable mismatch cannot be accepted" + ); + assert!(unknown_path.join("orphan").exists()); + assert_eq!( + fs::read(&file).unwrap(), + b"corrupt-size", + "census never deletes or repairs evidence" + ); + persistence.shutdown(); +} diff --git a/src/effects/tree_store.rs b/src/effects/tree_store.rs index 138e125..523622f 100644 --- a/src/effects/tree_store.rs +++ b/src/effects/tree_store.rs @@ -85,6 +85,27 @@ pub(crate) struct TreeCopy { pub(crate) bytes: u64, } +#[cfg(test)] +type DirectorySyncObserver = + std::sync::Arc Result<(), EffectError> + Send + Sync>; + +#[derive(Default)] +pub(crate) struct DirectorySync { + #[cfg(test)] + pub(crate) observer: Mutex>, +} + +impl DirectorySync { + fn sync(&self, _name: &str, directory: &OwnedFd) -> Result<(), EffectError> { + fsync_fd(directory)?; + #[cfg(test)] + if let Some(observer) = self.observer.lock().clone() { + observer(_name, directory)?; + } + Ok(()) + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum FileKind { File, @@ -454,27 +475,34 @@ pub(crate) fn copy_tree( copy_tree_from_fd(&source_dir, dest, quota, cancellation, deadline, seal) } -pub(crate) fn scan_usage_from_fd( - root: &OwnedFd, +/// Copies from the retained authorized descriptor, never its display path. +pub(crate) fn copy_tree_from_fd( + source_dir: &OwnedFd, + dest: &Path, quota: &ResourceQuota, - deadline: &EffectDeadline, cancellation: &EffectCancellation, -) -> Result { - let plan = scan(root, quota, deadline, cancellation)?; - Ok(Usage { - entries: plan.entries_count, - bytes: plan.bytes, - }) + deadline: &EffectDeadline, + seal: bool, +) -> Result { + copy_tree_from_fd_with_sync( + source_dir, + dest, + quota, + cancellation, + deadline, + seal, + &DirectorySync::default(), + ) } -/// Copies from the retained authorized descriptor, never its display path. -pub(crate) fn copy_tree_from_fd( +pub(crate) fn copy_tree_from_fd_with_sync( source_dir: &OwnedFd, dest: &Path, quota: &ResourceQuota, cancellation: &EffectCancellation, deadline: &EffectDeadline, seal: bool, + directory_sync: &DirectorySync, ) -> Result { deadline.check()?; cancellation.poll_quiet()?; @@ -486,7 +514,15 @@ pub(crate) fn copy_tree_from_fd( armed: true, }; let dest_dir = open_dir(dest)?; - materialize(source_dir, &dest_dir, &plan, cancellation, deadline, seal)?; + materialize( + source_dir, + &dest_dir, + &plan, + cancellation, + deadline, + seal, + directory_sync, + )?; verify_unchanged(source_dir, &dest_dir, &plan)?; fsync_fd(&dest_dir)?; guard.keep(); @@ -613,11 +649,52 @@ impl Drop for RemoveGuard { } } +// Only retained-store startup can construct this policy token. Live copy and +// mutation entry points continue to require a finite EffectDeadline. +enum ScanPolicy<'a> { + Invocation(&'a EffectDeadline, &'a EffectCancellation), + StartupCensus(&'a super::retention::StartupCensus), +} +impl ScanPolicy<'_> { + fn check(&self) -> Result<(), EffectError> { + match self { + Self::Invocation(deadline, cancellation) => { + deadline.check()?; + cancellation.poll_quiet() + } + Self::StartupCensus(census) => { + census.checkpoint(); + Ok(()) + } + } + } +} + +pub(super) fn startup_census_usage_from_fd( + root: &OwnedFd, + quota: &ResourceQuota, + census: &super::retention::StartupCensus, +) -> Result { + let plan = scan_with_policy(root, quota, &ScanPolicy::StartupCensus(census))?; + Ok(Usage { + entries: plan.entries_count, + bytes: plan.bytes, + }) +} + fn scan( root: &OwnedFd, quota: &ResourceQuota, deadline: &EffectDeadline, cancellation: &EffectCancellation, +) -> Result { + scan_with_policy(root, quota, &ScanPolicy::Invocation(deadline, cancellation)) +} + +fn scan_with_policy( + root: &OwnedFd, + quota: &ResourceQuota, + policy: &ScanPolicy<'_>, ) -> Result { let mut plan = Plan { entries: Vec::new(), @@ -625,7 +702,7 @@ fn scan( entries_count: 0, bytes: 0, }; - scan_dir(root, "", 0, quota, deadline, cancellation, &mut plan)?; + scan_dir(root, "", 0, quota, policy, &mut plan)?; for ((_, _), (nlink, seen)) in &plan.inodes { if *nlink > *seen { return Err(unsupported("external hardlink is not importable")); @@ -642,13 +719,20 @@ fn scan_dir( prefix: &str, depth: u32, quota: &ResourceQuota, - deadline: &EffectDeadline, - cancellation: &EffectCancellation, + policy: &ScanPolicy<'_>, plan: &mut Plan, ) -> Result<(), EffectError> { - deadline.check()?; - cancellation.poll_quiet()?; - for name in read_names(dir)? { + policy.check()?; + let names = match policy { + ScanPolicy::Invocation(..) => read_names(dir)?, + ScanPolicy::StartupCensus(census) => startup_census_names( + dir, + quota.max_entries.saturating_sub(plan.entries_count), + census, + )?, + }; + for name in names { + policy.check()?; let text = name .to_str() .ok_or_else(|| rejected("directory entry is not utf-8"))?; @@ -688,15 +772,7 @@ fn scan_dir( }, }); let child = open_child_dir(dir, text)?; - scan_dir( - &child, - &rel, - next_depth, - quota, - deadline, - cancellation, - plan, - )?; + scan_dir(&child, &rel, next_depth, quota, policy, plan)?; } FileKind::File => { let len = file_len(stat)?; @@ -739,6 +815,7 @@ fn materialize( cancellation: &EffectCancellation, deadline: &EffectDeadline, seal: bool, + directory_sync: &DirectorySync, ) -> Result<(), EffectError> { for entry in &plan.entries { deadline.check()?; @@ -757,7 +834,7 @@ fn materialize( } } if seal { - seal_directories(dest, plan)?; + seal_directories(dest, plan, directory_sync)?; } Ok(()) } @@ -796,8 +873,15 @@ fn copy_planned_file( Ok(()) } -fn seal_directories(dest: &OwnedFd, plan: &Plan) -> Result<(), EffectError> { - for entry in &plan.entries { +fn seal_directories( + dest: &OwnedFd, + plan: &Plan, + directory_sync: &DirectorySync, +) -> Result<(), EffectError> { + // The sorted plan places every ancestor before its descendants. Reverse it + // so each sealed child namespace and mode is durable before its parent. + // Mutable execution views have no immutable durability promise here. + for entry in plan.entries.iter().rev() { if !matches!(entry.kind, PlanKind::Dir) { continue; } @@ -809,6 +893,7 @@ fn seal_directories(dest: &OwnedFd, plan: &Plan) -> Result<(), EffectError> { .ok_or_else(|| inconsistent("directory name disappeared"))?; let created = open_child_dir(&parent, name)?; fchmod_fd(&created, 0o555)?; + directory_sync.sync(&entry.rel, &created)?; } Ok(()) } @@ -913,6 +998,13 @@ fn dir_is_empty(parent: &OwnedFd, name: &str) -> Result { } fn read_names(dir: &OwnedFd) -> Result, EffectError> { + read_names_inner(dir, None) +} + +fn read_names_inner( + dir: &OwnedFd, + startup: Option<(u64, &super::retention::StartupCensus)>, +) -> Result, EffectError> { let dup_fd = unsafe { libc::dup(dir.as_raw_fd()) }; if dup_fd < 0 { return Err(from_errno("dup", errno())); @@ -945,12 +1037,29 @@ fn read_names(dir: &OwnedFd) -> Result, EffectError> { if bytes == b"." || bytes == b".." { continue; } + if let Some((remaining, census)) = startup { + census.checkpoint(); + if names.len() as u64 >= remaining { + unsafe { libc::closedir(stream) }; + return Err(quota_error( + "startup directory enumeration exceeds remaining entry quota", + )); + } + } names.push(OsStr::from_bytes(bytes).to_os_string()); } names.sort(); Ok(names) } +fn startup_census_names( + dir: &OwnedFd, + remaining: u64, + census: &super::retention::StartupCensus, +) -> Result, EffectError> { + read_names_inner(dir, Some((remaining, census))) +} + fn open_dir(path: &Path) -> Result { let c_path = CString::new(path.as_os_str().as_bytes()) .map_err(|_| rejected("path contains an interior NUL"))?; @@ -1304,3 +1413,39 @@ fn inconsistent(message: impl Into) -> EffectError { fn stale_usage() -> EffectError { inconsistent("accounted usage does not match the destination") } + +#[cfg(test)] +mod managed_startup_enumeration_tests { + use super::*; + + #[test] + fn managed_startup_directory_names_stop_at_remaining_entry_work_budget() { + let path = PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()) + .join(format!("census-names-{}", uuid::Uuid::new_v4().simple())); + fs::create_dir(&path).unwrap(); + for index in 0..8 { + fs::write(path.join(format!("f{index}")), b"").unwrap(); + } + let directory = open_dir(&path).unwrap(); + let census = super::super::retention::StartupCensus::new(); + let result = startup_census_names(&directory, 1, &census); + assert!( + matches!(result, Err(error) if error.kind() == EffectErrorKind::QuotaExceeded), + "directory-name collection must stop inside the configured work budget" + ); + assert_eq!( + census.observed_work_for_test(), + 2, + "one permitted name and one over-budget witness, no full directory pre-collection" + ); + let census = super::super::retention::StartupCensus::new(); + assert_eq!( + startup_census_names(&directory, 8, &census).unwrap().len(), + 8 + ); + fs::create_dir(path.join("empty")).unwrap(); + let empty = open_dir(&path.join("empty")).unwrap(); + assert!(startup_census_names(&empty, 0, &census).unwrap().is_empty()); + fs::remove_dir_all(&path).unwrap(); + } +} diff --git a/src/gateway/store.rs b/src/gateway/store.rs index 4f5e7db..f638cd4 100644 --- a/src/gateway/store.rs +++ b/src/gateway/store.rs @@ -102,7 +102,16 @@ struct ApprovalExpireHook { completed: Box, } +fn managed_metadata_context() -> crate::RunCancellation { + crate::RunCancellation::with_timeout(Duration::from_secs(5)) +} + +#[cfg(test)] +type ManagedRequestHook = Arc; + pub struct GatewayPersistence { + #[cfg(test)] + pub(crate) managed_request_hook: Mutex>, #[cfg(all(test, feature = "workspace-io-fault"))] drop_next_managed_commit_ack: Arc, managed_pending: Arc, @@ -749,6 +758,8 @@ impl GatewayPersistence { #[cfg(all(test, feature = "workspace-io-fault"))] drop_next_managed_commit_ack, managed_pending: Arc::new(std::sync::atomic::AtomicUsize::new(0)), + #[cfg(test)] + managed_request_hook: Mutex::new(None), worker: StorageWorker { sender, shutdown: shutdown_sender, @@ -805,7 +816,10 @@ impl GatewayPersistence { /// for the response. The worker thread executes the RSS program; caller /// threads never run storage code themselves. fn command(&self, op: &str, payload: &Value) -> Result { - self.command_with_cancellation(op, payload, None) + // Independent trusted metadata/startup/maintenance operations own a + // finite context. Live effect callers use the explicit parent path. + let context = op.starts_with("effect.").then(managed_metadata_context); + self.command_with_cancellation(op, payload, context.as_ref()) } fn command_with_cancellation( &self, @@ -813,11 +827,26 @@ impl GatewayPersistence { payload: &Value, parent: Option<&crate::RunCancellation>, ) -> Result { - let cancellation = op.starts_with("effect.").then(|| { - parent - .map(crate::RunCancellation::child) - .unwrap_or_else(|| crate::RunCancellation::with_timeout(Duration::from_secs(5))) - }); + let cancellation = if op.starts_with("effect.") { + let parent = parent.ok_or("managed storage context missing")?; + if parent.deadline_instant().is_none() { + return Err("managed storage finite original deadline required".into()); + } + Some(parent.child()) + } else { + None + }; + #[cfg(test)] + if let Some(cancel) = &cancellation { + let hook = self + .managed_request_hook + .lock() + .expect("managed request hook") + .clone(); + if let Some(hook) = hook { + hook(op, cancel, false); + } + } if cancellation .as_ref() .is_some_and(|cancel| cancel.requested().is_some() || cancel.deadline_passed()) @@ -859,6 +888,23 @@ impl GatewayPersistence { Err("gateway storage worker is not running".to_string()) } else { let (respond, response) = mpsc::channel(); + let response_timeout = cancellation + .as_ref() + .and_then(crate::RunCancellation::remaining_deadline) + .map_or(STORAGE_COMMAND_TIMEOUT, |remaining| { + remaining.min(STORAGE_COMMAND_TIMEOUT) + }); + #[cfg(test)] + if let Some(cancel) = &cancellation { + let hook = self + .managed_request_hook + .lock() + .expect("managed request hook") + .clone(); + if let Some(hook) = hook { + hook(op, cancel, true); + } + } let sent = self.worker.sender.send(StorageRequest { op: op.to_string(), payload: payload.clone(), @@ -868,7 +914,7 @@ impl GatewayPersistence { }); match sent { Ok(()) => response - .recv_timeout(STORAGE_COMMAND_TIMEOUT) + .recv_timeout(response_timeout) .map_err(|error| match error { RecvTimeoutError::Timeout => "gateway storage worker timed out".to_string(), RecvTimeoutError::Disconnected => { @@ -1058,6 +1104,13 @@ impl GatewayPersistence { pub fn managed_object_metadata( &self, request: &crate::effects::ManagedObjectReferencesRequest, + ) -> Result, StorageError> { + self.managed_object_metadata_with_cancellation(request, &managed_metadata_context()) + } + pub(crate) fn managed_object_metadata_with_cancellation( + &self, + request: &crate::effects::ManagedObjectReferencesRequest, + cancellation: &crate::RunCancellation, ) -> Result, StorageError> { use crate::effects::journal::storage_error; crate::effects::broker::validate_id("store", &request.store_id).map_err(storage_error)?; @@ -1070,7 +1123,11 @@ impl GatewayPersistence { return Err(storage_error("managed object kind prefix invalid")); } crate::effects::broker::validate_id(prefix, &request.object_id).map_err(storage_error)?; - let value = self.command_data("effect.object_metadata", &json!(request))?; + let value = self.command_data_with_cancellation( + "effect.object_metadata", + &json!(request), + cancellation, + )?; let object: Option = crate::effects::journal::decode_record(&value).map_err(storage_error)?; if object.as_ref().is_some_and(|object| { @@ -1083,6 +1140,13 @@ impl GatewayPersistence { pub fn managed_object_references( &self, request: &crate::effects::ManagedObjectReferencesRequest, + ) -> Result, StorageError> { + self.managed_object_references_with_cancellation(request, &managed_metadata_context()) + } + pub(crate) fn managed_object_references_with_cancellation( + &self, + request: &crate::effects::ManagedObjectReferencesRequest, + cancellation: &crate::RunCancellation, ) -> Result, StorageError> { use crate::effects::journal::storage_error; crate::effects::broker::validate_id("store", &request.store_id).map_err(storage_error)?; @@ -1095,7 +1159,11 @@ impl GatewayPersistence { return Err(storage_error("managed object kind prefix invalid")); } crate::effects::broker::validate_id(prefix, &request.object_id).map_err(storage_error)?; - let value = self.command_data("effect.object_refs", &json!(request))?; + let value = self.command_data_with_cancellation( + "effect.object_refs", + &json!(request), + cancellation, + )?; const COLUMNS: [&str; 4] = ["store_id", "object_id", "reference_kind", "reference_id"]; if value.get("columns") != Some(&json!(COLUMNS)) || value.get("truncated") != Some(&json!(false)) @@ -1141,16 +1209,34 @@ impl GatewayPersistence { pub fn managed_effect_by_id( &self, effect_id: &str, + ) -> Result, StorageError> { + self.managed_effect_by_id_with_cancellation(effect_id, &managed_metadata_context()) + } + pub(crate) fn managed_effect_by_id_with_cancellation( + &self, + effect_id: &str, + cancellation: &crate::RunCancellation, ) -> Result, StorageError> { crate::effects::broker::validate_id("effect", effect_id) .map_err(crate::effects::journal::storage_error)?; - let value = self.command_data("effect.by_id", &json!({"effect_id":effect_id}))?; + let value = self.command_data_with_cancellation( + "effect.by_id", + &json!({"effect_id":effect_id}), + cancellation, + )?; crate::effects::journal::decode_record(&value) .map_err(crate::effects::journal::storage_error) } pub fn managed_effect_list( &self, request: &crate::effects::ManagedEffectListRequest, + ) -> Result { + self.managed_effect_list_with_cancellation(request, &managed_metadata_context()) + } + pub(crate) fn managed_effect_list_with_cancellation( + &self, + request: &crate::effects::ManagedEffectListRequest, + cancellation: &crate::RunCancellation, ) -> Result { use crate::effects::journal::{decode_effect_summaries, storage_error}; crate::effects::broker::validate_id("ws", &request.workspace_id).map_err(storage_error)?; @@ -1161,11 +1247,13 @@ impl GatewayPersistence { crate::effects::broker::validate_id("effect", cursor).map_err(storage_error)?; } let wire = json!({"workspace_id":request.workspace_id,"after_effect_id":request.after_effect_id.as_deref().unwrap_or(""),"limit":request.limit}); - let entries = - decode_effect_summaries(&self.command_data("effect.list", &wire)?, request.limit) - .map_err(storage_error)?; + let entries = decode_effect_summaries( + &self.command_data_with_cancellation("effect.list", &wire, cancellation)?, + request.limit, + ) + .map_err(storage_error)?; let has_more = if let Some(last) = entries.last() { - let value=self.command_data("effect.list_has_more",&json!({"workspace_id":request.workspace_id,"after_effect_id":last.effect_id,"limit":1}))?; + let value=self.command_data_with_cancellation("effect.list_has_more",&json!({"workspace_id":request.workspace_id,"after_effect_id":last.effect_id,"limit":1}), cancellation)?; if value.get("columns") != Some(&json!(["effect_id"])) || value.get("truncated") != Some(&json!(false)) { @@ -1213,7 +1301,18 @@ impl GatewayPersistence { &self, request: &crate::effects::ManagedEffectGetRequest, ) -> Result, StorageError> { - let value = self.managed_effect_command("effect.get", &json!(request))?; + self.managed_effect_get_with_cancellation(request, &managed_metadata_context()) + } + pub(crate) fn managed_effect_get_with_cancellation( + &self, + request: &crate::effects::ManagedEffectGetRequest, + cancellation: &crate::RunCancellation, + ) -> Result, StorageError> { + let value = self.managed_effect_command_with_cancellation( + "effect.get", + &json!(request), + cancellation, + )?; let record: Option = crate::effects::journal::decode_record(&value) .map_err(crate::effects::journal::storage_error)?; @@ -1226,9 +1325,17 @@ impl GatewayPersistence { &self, workspace: &crate::effects::ManagedWorkspaceIdentity, ) -> Result { - let value = self.managed_effect_command( + self.managed_workspace_head_with_cancellation(workspace, &managed_metadata_context()) + } + pub(crate) fn managed_workspace_head_with_cancellation( + &self, + workspace: &crate::effects::ManagedWorkspaceIdentity, + cancellation: &crate::RunCancellation, + ) -> Result { + let value = self.managed_effect_command_with_cancellation( "effect.head", &json!({"workspace_id":workspace.workspace_id,"branch_id":workspace.branch_id}), + cancellation, )?; crate::effects::journal::decode_record(&value) .map_err(crate::effects::journal::storage_error)? diff --git a/src/runtime/rss_runner.rs b/src/runtime/rss_runner.rs index 1803e7a..00402f5 100644 --- a/src/runtime/rss_runner.rs +++ b/src/runtime/rss_runner.rs @@ -845,6 +845,12 @@ impl RunCancellation { *self.inner.deadline.lock().expect("deadline lock") } + #[cfg(test)] + pub(crate) fn shares_original_clock(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.inner.deadline, &other.inner.deadline) + && Arc::ptr_eq(&self.inner.requested, &other.inner.requested) + } + pub fn remaining_deadline(&self) -> Option { self.deadline_instant() .map(|deadline| deadline.saturating_duration_since(Instant::now())) diff --git a/src/service.rs b/src/service.rs index 14011ac..0462f39 100644 --- a/src/service.rs +++ b/src/service.rs @@ -1594,7 +1594,7 @@ impl AgentService { .persistence .as_ref() .ok_or("managed query unavailable")? - .managed_effect_get(&request) + .managed_effect_get_with_cancellation(&request, &cancellation) .map_err(|e| e.to_string())? else { return Ok(ManagedCallReplay::Unavailable { diff --git a/src/service/managed_effects.rs b/src/service/managed_effects.rs index 41cc312..ecd9dfb 100644 --- a/src/service/managed_effects.rs +++ b/src/service/managed_effects.rs @@ -12,6 +12,7 @@ fn query_context( ( Arc, Arc, + RunCancellation, ), String, > { @@ -34,7 +35,11 @@ fn query_context( let (_, authority) = service .resolve_workspace_admission(Some(source), None, &cancellation, false) .map_err(|_| "managed query unavailable")?; - Ok((runtime, authority.ok_or("managed query unavailable")?)) + Ok(( + runtime, + authority.ok_or("managed query unavailable")?, + cancellation, + )) } pub(super) fn query( service: &AgentService, @@ -47,23 +52,25 @@ pub(super) fn query( if let Some(cursor) = &request.after_effect_id { crate::effects::broker::validate_id("effect", cursor)?; } - let (runtime, authority) = query_context(service)?; + let (runtime, authority, cancellation) = query_context(service)?; runtime.authorize_workspace_query( &authority, ADMISSION_SESSION_PROFILE, &request.workspace_id, + &cancellation, )?; let page = service .inner .persistence .as_ref() .ok_or("managed query unavailable")? - .managed_effect_list(&request) + .managed_effect_list_with_cancellation(&request, &cancellation) .map_err(|e| e.to_string())?; runtime.authorize_workspace_query( &authority, ADMISSION_SESSION_PROFILE, &request.workspace_id, + &cancellation, )?; Ok(page) } @@ -75,13 +82,13 @@ pub(super) fn read_chunk( return Err("managed result chunk limit must be 1..=131072".into()); } crate::effects::broker::validate_id("effect", &request.effect_id)?; - let (runtime, authority) = query_context(service)?; + let (runtime, authority, cancellation) = query_context(service)?; let record = service .inner .persistence .as_ref() .ok_or("managed query unavailable")? - .managed_effect_by_id(&request.effect_id) + .managed_effect_by_id_with_cancellation(&request.effect_id, &cancellation) .map_err(|e| e.to_string())? .ok_or("managed effect unavailable")?; if record.identity.owner.profile != ADMISSION_SESSION_PROFILE { @@ -122,6 +129,29 @@ pub(super) fn replay_durable( call_id: &str, name: &str, ) -> Result { + if let Some(binding) = service + .handle(run_id) + .filter(|handle| !handle.is_terminal()) + .and_then(|handle| { + handle + .pinned_tool + .as_ref() + .and_then(|pin| pin.managed.clone()) + }) + { + return match binding + .replay_live(call_id, name, None, None) + .map_err(failed)? + { + Some(replay) => { + let mut result: ToolResult = + serde_json::from_value(replay.result).map_err(failed)?; + result.replayed = true; + Ok(ManagedDurableReplay::Final(result)) + } + None => Ok(ManagedDurableReplay::Unmanaged), + }; + } let Some(persistence) = service.inner.persistence.as_ref() else { return Ok(ManagedDurableReplay::Unmanaged); }; @@ -557,10 +587,13 @@ pub(super) fn commit_result( Err(_) => binding .runtime .repository - .get(&ManagedEffectGetRequest { - owner: call.identity.owner.clone(), - call_id: call.identity.call_id.clone(), - }) + .get( + &ManagedEffectGetRequest { + owner: call.identity.owner.clone(), + call_id: call.identity.call_id.clone(), + }, + &binding.cancel, + ) .ok() .flatten() .and_then(|record| record.outcome), diff --git a/src/service/managed_effects_tests.rs b/src/service/managed_effects_tests.rs index 0b23767..2e3bfc8 100644 --- a/src/service/managed_effects_tests.rs +++ b/src/service/managed_effects_tests.rs @@ -1,6 +1,8 @@ //! Native live replay through the exact private retained worker program. #[path = "managed_protocol_tests.rs"] mod protocol; +#[path = "managed_review_tests.rs"] +mod review; use crate::config::{ManagedRetentionLimits, ManagedWorkspaceConfig}; use crate::effects::ResourceQuota; use crate::{AdmitRunRequest, AgentGatewayConfig, AgentGatewayState}; diff --git a/src/service/managed_review_tests.rs b/src/service/managed_review_tests.rs new file mode 100644 index 0000000..8e7534d --- /dev/null +++ b/src/service/managed_review_tests.rs @@ -0,0 +1,442 @@ +use super::*; +use crate::effects::{EffectError, EffectErrorKind, ManagedEffectGetRequest, ManagedOwnerScope}; +use std::os::{fd::AsRawFd, unix::fs::PermissionsExt}; + +#[tokio::test(flavor = "multi_thread")] +async fn live_register_lookup_keeps_original_clock_and_never_enqueues_after_cancel_or_expiry() { + let (_root, state) = fixture(); + let service = state.service(); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"original lookup context"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + let admitted = service + .handle(&run.run_id) + .unwrap() + .pinned_tool + .as_ref() + .unwrap() + .managed + .clone() + .unwrap(); + let persistence = state.persistence().unwrap(); + let mut results = Vec::new(); + for target in ["effect.head", "effect.object_metadata"] { + for expire in [false, true] { + let original = + crate::RunCancellation::with_timeout(std::time::Duration::from_millis(150)); + let binding = admitted.with_cancellation_for_test(original.clone()); + let observed = Arc::new(std::sync::Mutex::new(Vec::new())); + let capture = Arc::clone(&observed); + let clock = original.clone(); + *persistence.managed_request_hook.lock().unwrap() = + Some(Arc::new(move |op, cancel, enqueue| { + if op == target { + if !enqueue { + if expire { + std::thread::sleep(clock.remaining_deadline().unwrap()); + } else { + clock.request(crate::CancellationReason::Requested); + } + } + capture + .lock() + .unwrap() + .push((enqueue, cancel.shares_original_clock(&clock))); + } + })); + let result = binding.register("lookup", "read_file", r#"{"path":"none"}"#, "read", 1); + *persistence.managed_request_hook.lock().unwrap() = None; + let rows = observed.lock().unwrap().clone(); + results.push(( + target, + rows, + result.is_err(), + binding.call("lookup").is_none(), + )); + } + } + service.shutdown().await.unwrap(); + for (target, rows, denied, absent) in results { + assert_eq!( + rows, + [(false, true)], + "{target}: original context must reach request owner, with no enqueue after expiry/cancel" + ); + assert!(denied && absent); + } +} + +struct LiveClockReplayProvider { + binding: Arc, + step: std::sync::atomic::AtomicUsize, + replay: std::sync::Mutex>>, +} +impl crate::AgentProviderHost for LiveClockReplayProvider { + fn call(&self, _request: &Value, _cancellation: &crate::RunCancellation) -> Value { + let call = if self.step.fetch_add(1, std::sync::atomic::Ordering::AcqRel) == 0 { + Some( + json!({"id":"clock-write","name":"write_file","arguments":{"path":"clock.txt","content":"same"}}), + ) + } else { + *self.replay.lock().unwrap() = Some( + self.binding + .replay_live("clock-write", "write_file", None, None) + .map(|result| result.is_some()), + ); + None + }; + json!({"ok":true,"response":response(call),"error":{}}) + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn actual_live_repository_callers_share_the_admitted_clock_through_replay_and_cleanup() { + let (_root, state) = fixture(); + let service = state.service(); + let persistence = state.persistence().unwrap(); + let observed = Arc::new(std::sync::Mutex::new(Vec::new())); + let capture = Arc::clone(&observed); + *persistence.managed_request_hook.lock().unwrap() = + Some(Arc::new(move |op, cancel, enqueue| { + if enqueue { + capture + .lock() + .unwrap() + .push((op.to_string(), cancel.clone())); + } + })); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"audit all actual live SDK paths"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + let binding = service + .handle(&run.run_id) + .unwrap() + .pinned_tool + .as_ref() + .unwrap() + .managed + .clone() + .unwrap(); + let provider = Arc::new(LiveClockReplayProvider { + binding: Arc::clone(&binding), + step: std::sync::atomic::AtomicUsize::new(0), + replay: std::sync::Mutex::new(None), + }); + service.inject_provider_host(provider.clone()); + service + .clone() + .run_worker(run.run_id.clone(), "ignored".into()) + .await; + // The provider replay ran before terminal cleanup revoked the live grant. + let replay = provider.replay.lock().unwrap().take(); + *persistence.managed_request_hook.lock().unwrap() = None; + let rows = observed.lock().unwrap().clone(); + service.shutdown().await.unwrap(); + assert_eq!(replay, Some(Ok(true))); + assert!( + rows.iter() + .all(|(_, clock)| clock.shares_original_clock(&binding.cancel)) + ); + for required in [ + "effect.workspace_get", + "effect.workspace_register", + "effect.head", + "effect.object_metadata", + "effect.get", + "effect.begin", + "effect.prepare", + "effect.commit", + "effect.object_refs", + ] { + assert!( + rows.iter().any(|(op, _)| op == required), + "missing actual production caller {required}" + ); + } +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn expired_original_lost_commit_ack_does_not_query_with_new_authority_and_fresh_read_recovers_once() + { + let (root, state) = + fixture_config(|config| config.run_timeout = std::time::Duration::from_secs(10)); + let service = state.service(); + let persistence = state.persistence().unwrap(); + let provider = Arc::new(crate::ScriptedProvider::new()); + let sentinel = root.join("expired-ack-sentinel"); + provider.push_ok(response(Some(json!({"id":"expired-ack","name":"terminal","arguments":{"argv":["/bin/sh","-c",format!("printf x >> '{}'; printf durable", sentinel.display())],"timeout_ms":1000}})))); + provider.push_ok(response(None)); + service.inject_provider_host(provider); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"lost acknowledgement under original deadline"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + let binding = service + .handle(&run.run_id) + .unwrap() + .pinned_tool + .as_ref() + .unwrap() + .managed + .clone() + .unwrap(); + let original = binding.cancel.clone(); + let observed = Arc::new(std::sync::Mutex::new(Vec::new())); + let capture = Arc::clone(&observed); + let committed = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let after_commit = Arc::clone(&committed); + *persistence.managed_request_hook.lock().unwrap() = + Some(Arc::new(move |op, cancel, enqueue| { + if op == "effect.commit" && enqueue { + after_commit.store(true, std::sync::atomic::Ordering::Release); + } + if op == "effect.get" && after_commit.load(std::sync::atomic::Ordering::Acquire) { + if !enqueue { + std::thread::sleep(original.remaining_deadline().unwrap()); + } + capture + .lock() + .unwrap() + .push((enqueue, cancel.shares_original_clock(&original))); + } + })); + persistence.inject_drop_managed_commit_ack(); + service + .clone() + .run_worker(run.run_id.clone(), "ignored".into()) + .await; + *persistence.managed_request_hook.lock().unwrap() = None; + let rows = observed.lock().unwrap().clone(); + let request = ManagedEffectGetRequest { + owner: binding.owner.clone(), + call_id: "expired-ack".into(), + }; + let record = persistence.managed_effect_get(&request).unwrap().unwrap(); + let outcome = record.outcome.as_ref().unwrap(); + let head = persistence + .managed_workspace_head(&record.identity.workspace) + .unwrap(); + let replay = service.replay_managed_call(request.clone()).unwrap(); + let live = service.run_events(&run.run_id); + service.shutdown().await.unwrap(); + assert_eq!( + rows, + [(false, true)], + "lost ack must remain unknown in the expired live context; no additional SDK request" + ); + assert!(committed.load(std::sync::atomic::Ordering::Acquire)); + assert!(!persistence.managed_commit_ack_drop_armed()); + assert!( + live.iter() + .all(|event| event["event_id"] != outcome.output_event.event_id) + ); + assert!(matches!( + replay, + crate::effects::ManagedCallReplay::Final { .. } + )); + assert_eq!(head.head_version, 2); + assert_eq!(fs::read(&sentinel).unwrap(), b"x"); + drop(binding); + drop(persistence); + drop(service); + drop(state); + let config = AgentGatewayConfig { + managed_workspace: Some(ManagedWorkspaceConfig { + source_root: root.join("workspace"), + quota: ResourceQuota::new(1000, 1048576, 16).unwrap(), + retention: ManagedRetentionLimits::default(), + }), + ..Default::default() + }; + let reopened = AgentGatewayState::with_sqlite_path(config, root.join("gateway.sqlite")) + .unwrap() + .with_workspace_home(root.join("home")); + assert_eq!( + reopened + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(), + record + ); + assert_eq!(fs::read(&sentinel).unwrap(), b"x"); + reopened.service().shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn nested_directory_sync_is_bottom_up_for_seed_and_prepare_and_fault_denies_publication() { + for fault_phase in ["observe", "seed", "prepare"] { + let (root, state) = fixture(); + fs::create_dir_all(root.join("workspace/nested/deep")).unwrap(); + fs::create_dir(root.join("workspace/nested/empty")).unwrap(); + fs::write(root.join("workspace/nested/deep/run"), b"#!/bin/sh\n").unwrap(); + fs::set_permissions( + root.join("workspace/nested/deep/run"), + fs::Permissions::from_mode(0o755), + ) + .unwrap(); + let service = state.service(); + let runtime = service.inner.managed_effects.read().clone().unwrap(); + let observed = Arc::new(std::sync::Mutex::new(Vec::new())); + let capture = Arc::clone(&observed); + let fail = Arc::new(std::sync::atomic::AtomicBool::new(fault_phase == "seed")); + let inject = Arc::clone(&fail); + *runtime.tree_sync.observer.lock() = Some(Arc::new(move |name, fd| { + let mut stat = std::mem::MaybeUninit::::uninit(); + assert_eq!(unsafe { libc::fstat(fd.as_raw_fd(), stat.as_mut_ptr()) }, 0); + let stat = unsafe { stat.assume_init() }; + assert_eq!(stat.st_mode & libc::S_IFMT, libc::S_IFDIR); + assert_eq!(stat.st_mode & 0o777, 0o555); + capture.lock().unwrap().push(name.to_string()); + if name == "nested/deep" && inject.swap(false, std::sync::atomic::Ordering::AcqRel) { + return Err(EffectError::new( + EffectErrorKind::Io, + "instance nested directory sync fault", + )); + } + Ok(()) + })); + let admission = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"nested immutable durability"}), + ..Default::default() + }, + None, + ) + .await; + if fault_phase == "seed" { + let denied = admission.is_err(); + let census = state + .persistence() + .unwrap() + .managed_object_census() + .unwrap(); + service.shutdown().await.unwrap(); + assert!( + denied, + "a seed child-directory fsync failure must deny admission" + ); + assert!( + census.is_empty(), + "a failed seed cannot enter the synced registry" + ); + continue; + } + let run = admission.unwrap(); + let binding = service + .handle(&run.run_id) + .unwrap() + .pinned_tool + .as_ref() + .unwrap() + .managed + .clone() + .unwrap(); + let before = state + .persistence() + .unwrap() + .managed_workspace_head(&binding.workspace.identity) + .unwrap(); + fail.store( + fault_phase == "prepare", + std::sync::atomic::Ordering::Release, + ); + let provider = Arc::new(crate::ScriptedProvider::new()); + provider.push_ok(response(Some(json!({"id":"nested-sync","name":"write_file","arguments":{"path":"nested/deep/new","content":"private"}})))); + provider.push_ok(response(None)); + service.inject_provider_host(provider); + service + .clone() + .run_worker(run.run_id.clone(), "ignored".into()) + .await; + let persistence = state.persistence().unwrap(); + let after = persistence + .managed_workspace_head(&binding.workspace.identity) + .unwrap(); + let record = persistence + .managed_effect_get(&ManagedEffectGetRequest { + owner: ManagedOwnerScope { + profile: "gateway".into(), + session_id: run.session_id, + run_id: run.run_id, + }, + call_id: "nested-sync".into(), + }) + .unwrap() + .unwrap(); + let names = observed.lock().unwrap().clone(); + let census = persistence.managed_object_census().unwrap(); + service.shutdown().await.unwrap(); + if fault_phase == "observe" { + assert_eq!( + names, + [ + "nested/empty", + "nested/deep", + "nested", + "nested/empty", + "nested/deep", + "nested" + ], + "every created directory must really fsync after mode sealing, child before parent" + ); + assert_eq!(after.head_version, before.head_version + 1); + let tree = root + .join(".gateway.sqlite.effects/trees") + .join(&after.head_object_id); + assert_eq!( + fs::metadata(tree.join("nested/deep/run")) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o555 + ); + assert!(tree.join("nested/empty").is_dir()); + } else { + assert!(names.iter().filter(|name| *name == "nested/deep").count() >= 2); + assert_eq!(after, before); + assert!(record.prepared.is_none() && record.outcome.is_none()); + assert_eq!( + census.len(), + 1, + "failed candidate cannot be registered synced" + ); + assert!( + root.join(".gateway.sqlite.effects/trees") + .join(&before.head_object_id) + .exists() + ); + assert!( + fs::read_dir(root.join(".gateway.sqlite.effects/views")) + .unwrap() + .next() + .is_some(), + "unknown private outcome remains retained" + ); + } + } +} From 3f66a5b3ff2b563a54dbbc96c6d09fb0cbb7e9a6 Mon Sep 17 00:00:00 2001 From: fffonion Date: Mon, 19 Oct 2026 08:42:35 +0800 Subject: [PATCH 07/21] test: make managed workspace Native fixtures explicitly private --- src/effects/retention_tests.rs | 4 ++++ src/gateway/managed_storage_budget_tests.rs | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/src/effects/retention_tests.rs b/src/effects/retention_tests.rs index 652fda8..eff6178 100644 --- a/src/effects/retention_tests.rs +++ b/src/effects/retention_tests.rs @@ -14,10 +14,14 @@ async fn managed_fresh_startup_census_is_quota_bounded_not_incidental_clock_boun let home = path.join("home"); fs::create_dir_all(source.join("a/b/empty")).unwrap(); fs::create_dir(&home).unwrap(); + for directory in [&path, &home] { + fs::set_permissions(directory, fs::Permissions::from_mode(0o700)).unwrap(); + } for index in 0..64 { fs::write(source.join(format!("a/b/f{index}")), b"retained-bytes").unwrap(); } fs::write(home.join("config.yaml"),format!("version: 1\nmodel:\n provider: local-test\n model: synthetic\nproviders:\n local-test:\n protocol: codex-responses\n base_url: https://127.0.0.1:1\nworkspaces:\n allowed_roots:\n - '{}'\n default: '{}'\n",source.display(),source.display())).unwrap(); + fs::set_permissions(home.join("config.yaml"), fs::Permissions::from_mode(0o600)).unwrap(); let managed = ManagedWorkspaceConfig { source_root: source, quota: ResourceQuota::new(1000, 1048576, 16).unwrap(), diff --git a/src/gateway/managed_storage_budget_tests.rs b/src/gateway/managed_storage_budget_tests.rs index 335b317..abbb877 100644 --- a/src/gateway/managed_storage_budget_tests.rs +++ b/src/gateway/managed_storage_budget_tests.rs @@ -53,13 +53,22 @@ fn managed_aggregate_sql_parameter_budget_precedes_sdk_enqueue_and_matches_fixed #[tokio::test(flavor = "multi_thread")] async fn managed_reopen_preserves_nonzero_formal_provider_usage() { use crate::config::{ManagedRetentionLimits, ManagedWorkspaceConfig}; + use std::os::unix::fs::PermissionsExt; let root = std::path::PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()) .join(format!("managed-usage-{}", Uuid::new_v4().simple())); let workspace = root.join("workspace"); let home = root.join("home"); std::fs::create_dir_all(&workspace).unwrap(); std::fs::create_dir(&home).unwrap(); + for directory in [&root, &home] { + std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o700)).unwrap(); + } std::fs::write(home.join("config.yaml"),format!("version: 1\nmodel:\n provider: local-test\n model: synthetic\nproviders:\n local-test:\n protocol: codex-responses\n base_url: https://127.0.0.1:1\nworkspaces:\n allowed_roots:\n - '{}'\n default: '{}'\n",workspace.display(),workspace.display())).unwrap(); + std::fs::set_permissions( + home.join("config.yaml"), + std::fs::Permissions::from_mode(0o600), + ) + .unwrap(); let config = AgentGatewayConfig { managed_workspace: Some(ManagedWorkspaceConfig { source_root: workspace, From 9b9812da388d2fd265f9698d63ef387c0f197cee Mon Sep 17 00:00:00 2001 From: fffonion Date: Mon, 19 Oct 2026 09:17:24 +0800 Subject: [PATCH 08/21] docs: document managed workspace configuration --- docs/configuration.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/docs/configuration.md b/docs/configuration.md index 7ec5663..f6a2466 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -73,6 +73,36 @@ the library's `state.db` path, but the gateway's SQLite selection remains separate: `RUSTSCRIPT_AGENT_STATE_DB` chooses the gateway state file and an unset value keeps gateway state in memory. +### Managed workspace (primary-only) + +`RUSTSCRIPT_AGENT_MANAGED_WORKSPACE` is an OS filesystem path, read only by +`rustscript-agent-gateway` and passed to `ManagedWorkspaceConfig::for_workspace`. +It has **no deprecated alias** and is not a boolean flag. Unset means +`managed_workspace = None` (direct mode). When set, it must name an existing, +non-root directory using a non-empty absolute UTF-8 path with no NUL or +parent-directory components; invalid configuration fails startup. + +Managed mode requires `RUSTSCRIPT_AGENT_STATE_DB` to name an **absolute, +persistent database**; in-memory state is rejected. The runtime derives its +private object root as `/..effects`. +The source must not overlap the database, that object root, or the sibling +`..tool-packages` directory in either direction. + +The setting alone grants no workspace authority. Import and queries require a +current Native workspace grant admitting the configured source through +`workspaces.allowed_roots`. For the gateway binary, explicitly set +`RUSTSCRIPT_AGENT_HOME` to enable the workspace host, with loadable config/auth +files in a private, process-owned agent home (Unix mode `0700`). A managed run's +admitted canonical workspace must match the configured source. Embeddings +supply this authority through `with_workspace_home` or their trusted workspace +host factory. + +The source is imported as a seed; managed filesystem mutations use private +working trees without writing changes back to the source. This mechanism +covers managed synchronous local filesystem effects, not OS permission +isolation or rollback of external effects. Tree quotas and retained-object +limits are described with the native field below. + ### Gateway variables and deprecated aliases Every gateway `RUSTSCRIPT_AGENT_*` variable in the table has a deprecated @@ -184,6 +214,16 @@ above; every other field is set by embedding code. | `sqlite` | `SqlitePolicy` | core defaults, `max_statements = 1024` | — | | `approvals` | `ApprovalSettings` | `read = "allow"`, `write = "ask"`, `process = "ask"`; reviewer/model/fallback/required overrides unset (`None`) | Validated by `ApprovalSettings::validate`: `reviewer` must be `guardian` or `user` when set; `reviewer_model` must be non-empty, at most 128 characters, with no control characters or surrounding whitespace; `read = "ask"` is unsupported. | | `fuel` | `Option` | `Some(10_000_000)` | VM fuel budget; `None` disables the fuel cap. | +| `managed_workspace` | `Option` | `None` (direct mode) | Trusted native startup opt-in for persistent synchronous managed effects. `ManagedWorkspaceConfig::for_workspace(source_root)` supplies default quotas and validates the source; `AgentGatewayConfig::validate` revalidates it. Gateway construction requires an absolute persistent database, and the runtime rejects a root source or overlap with persistent state. Import and queries still require current Native workspace authority; see the primary-only environment setting above. | + +`ManagedWorkspaceConfig` contains `source_root: PathBuf`, `quota: ResourceQuota`, +and `retention: ManagedRetentionLimits`. `for_workspace` defaults to at most +1 000 000 tree entries, 64 MiB per tree, and depth 32. Native embeddings may +choose smaller positive bounds through `ResourceQuota::new`; these defaults +are also its hard ceilings. Retention defaults to 128 objects and 512 MiB; +both bounds must be positive and cannot exceed those ceilings. Retained +evidence is not evicted to admit a call. These are native configuration +fields, not additional environment variables or `config.yaml` settings. `RateLimitConfig` bounds: `enabled` (master switch), `ip_burst`, `account_burst`, `window`, `max_buckets`. `TelegramConfig` bounds: `bot_token` (redacted in From ac947e3034f72817ccb3cfb178d68c4305c22eb9 Mon Sep 17 00:00:00 2001 From: fffonion Date: Mon, 19 Oct 2026 09:45:14 +0800 Subject: [PATCH 09/21] test: derive managed gateway scratch env from assigned root --- .../effect_commit_recovery/startup_matrix.rs | 116 ++++++++++++++---- 1 file changed, 92 insertions(+), 24 deletions(-) diff --git a/tests/effect_commit_recovery/startup_matrix.rs b/tests/effect_commit_recovery/startup_matrix.rs index 94a3889..21ef2dc 100644 --- a/tests/effect_commit_recovery/startup_matrix.rs +++ b/tests/effect_commit_recovery/startup_matrix.rs @@ -1,4 +1,94 @@ use super::*; + +fn configure_gateway_child_scratch( + command: &mut std::process::Command, + read_env: impl Fn(&str) -> Option, +) { + let root = PathBuf::from( + read_env("RUSTSCRIPT_AGENT_TEST_TMP") + .expect("RUSTSCRIPT_AGENT_TEST_TMP must name the assigned scratch directory"), + ); + assert!( + root.is_absolute() && root.is_dir(), + "RUSTSCRIPT_AGENT_TEST_TMP must be an existing absolute scratch directory" + ); + for key in [ + "TMPDIR", + "TMP", + "TEMP", + "TEMPDIR", + "TEST_TMPDIR", + "RUSTSCRIPT_AGENT_TEST_TMP", + "RUSTSCRIPT_AGENT_TEST_TMPDIR", + ] { + command.env(key, &root); + } + if let Some(cef) = read_env("CEF_PATH") { + command.env("CEF_PATH", cef); + } +} + +#[test] +fn gateway_child_environment_uses_assigned_root_and_optional_cef_only() { + let root = PathBuf::from(std::env::var_os("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()); + for cef in [None, Some(root.join("cef").into_os_string())] { + let mut command = + std::process::Command::new(env!("CARGO_BIN_EXE_rustscript-agent-gateway")); + command.env_clear(); + configure_gateway_child_scratch(&mut command, |key| match key { + "RUSTSCRIPT_AGENT_TEST_TMP" => Some(root.clone().into_os_string()), + "CEF_PATH" => cef.clone(), + _ => panic!("unexpected ambient environment read: {key}"), + }); + let actual = command + .get_envs() + .collect::>(); + let mut expected = [ + "TMPDIR", + "TMP", + "TEMP", + "TEMPDIR", + "TEST_TMPDIR", + "RUSTSCRIPT_AGENT_TEST_TMP", + "RUSTSCRIPT_AGENT_TEST_TMPDIR", + ] + .map(|key| (std::ffi::OsStr::new(key), Some(root.as_os_str()))) + .into_iter() + .collect::>(); + if let Some(cef) = &cef { + expected.insert(std::ffi::OsStr::new("CEF_PATH"), Some(cef.as_os_str())); + } + assert_eq!(actual, expected); + } +} + +#[test] +fn gateway_child_environment_requires_an_assigned_directory() { + let root = PathBuf::from(std::env::var_os("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()); + for invalid in [ + None, + Some(std::ffi::OsString::new()), + Some(std::ffi::OsString::from("relative")), + Some( + root.join(uuid::Uuid::new_v4().simple().to_string()) + .into_os_string(), + ), + ] { + assert!( + std::panic::catch_unwind(|| { + let mut command = + std::process::Command::new(env!("CARGO_BIN_EXE_rustscript-agent-gateway")); + command.env_clear(); + configure_gateway_child_scratch(&mut command, |key| { + assert_eq!(key, "RUSTSCRIPT_AGENT_TEST_TMP"); + invalid.clone() + }); + }) + .is_err() + ); + } +} + #[tokio::test(flavor = "multi_thread")] async fn normal_managed_binary_reaches_listener_with_private_store_and_acknowledged_owned_shutdown() { @@ -22,18 +112,7 @@ async fn normal_managed_binary_reaches_listener_with_private_store_and_acknowled .env("RUSTSCRIPT_AGENT_STATE_DB", root.join("gateway.sqlite")) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::piped()); - for key in [ - "TMPDIR", - "TMP", - "TEMP", - "TEMPDIR", - "TEST_TMPDIR", - "RUSTSCRIPT_AGENT_TEST_TMP", - "RUSTSCRIPT_AGENT_TEST_TMPDIR", - "CEF_PATH", - ] { - command.env(key, std::env::var_os(key).unwrap()); - } + configure_gateway_child_scratch(command.as_std_mut(), |key| std::env::var_os(key)); let mut child = command.spawn().unwrap(); let pid = child.id().unwrap(); let mut stderr = BufReader::new(child.stderr.take().unwrap()).lines(); @@ -132,18 +211,7 @@ fn normal_gateway_binary_rejects_managed_without_persistence_before_binding_list .env("RUSTSCRIPT_AGENT_ALLOW_ANONYMOUS", "1") .env("RUSTSCRIPT_AGENT_GATEWAY_ADDR", "127.0.0.1:0") .env("RUSTSCRIPT_AGENT_MANAGED_WORKSPACE", source); - for key in [ - "TMPDIR", - "TMP", - "TEMP", - "TEMPDIR", - "TEST_TMPDIR", - "RUSTSCRIPT_AGENT_TEST_TMP", - "RUSTSCRIPT_AGENT_TEST_TMPDIR", - "CEF_PATH", - ] { - command.env(key, std::env::var_os(key).unwrap()); - } + configure_gateway_child_scratch(&mut command, |key| std::env::var_os(key)); if let Some(database) = database { command.env("RUSTSCRIPT_AGENT_STATE_DB", database); } From fce61118913ada8c0871099c57e81d0ad90cdb6c Mon Sep 17 00:00:00 2001 From: fffonion Date: Mon, 19 Oct 2026 13:43:31 +0800 Subject: [PATCH 10/21] Enforce captured managed authority at Native boundaries --- rss/tools/types.rss | 15 + src/capabilities/filesystem.rs | 24 +- src/capabilities/process.rs | 44 +- src/effects/authority.rs | 673 ++++++++++++++++++ src/effects/broker.rs | 140 ++-- src/effects/managed_authority_tests.rs | 429 ++++++++++- src/effects/mod.rs | 1 + src/plugins/pin.rs | 8 + src/plugins/prepare.rs | 205 ++++++ src/plugins/store.rs | 242 ++++++- src/runtime/agent_host.rs | 124 +++- src/runtime/native_tools.rs | 1 + src/runtime/rss_runner.rs | 60 ++ src/service.rs | 60 +- src/service/managed_protocol_tests.rs | 8 +- src/service/managed_review_tests.rs | 204 +++++- tests/fixtures/managed_builtin_3250_types.rss | 189 +++++ 17 files changed, 2319 insertions(+), 108 deletions(-) create mode 100644 src/effects/authority.rs create mode 100644 tests/fixtures/managed_builtin_3250_types.rss diff --git a/rss/tools/types.rss b/rss/tools/types.rss index c3bfb12..8081663 100644 --- a/rss/tools/types.rss +++ b/rss/tools/types.rss @@ -177,6 +177,21 @@ pub fn process_descriptor() -> map { ) } +// Private declaration data; Native primitives enforce the sealed class. +pub fn managed_authority_catalog(context: map) -> map { + { + declarations: [ + { tool_name: "read_file", operation: "workspace_read" }, + { tool_name: "search_files", operation: "workspace_read" }, + { tool_name: "write_file", operation: "workspace_write" }, + { tool_name: "patch", operation: "workspace_write" }, + { tool_name: "terminal", operation: "foreground_process" }, + { tool_name: "process", operation: "process_control" } + ], + descriptors: catalog() + } +} + pub fn catalog() -> array { let mut tools: array = []; tools[tools.length] = read_file_descriptor(); diff --git a/src/capabilities/filesystem.rs b/src/capabilities/filesystem.rs index 26cafaa..449c367 100644 --- a/src/capabilities/filesystem.rs +++ b/src/capabilities/filesystem.rs @@ -227,7 +227,10 @@ impl FilesystemCapability { /// Stats a workspace-relative path without following a leaf symlink. pub fn metadata(&self, token: &str, path: &str) -> Result { let _claims = self.authorize(token, CapabilityRisk::Read)?; - let _operation = self.enter_managed_operation(&_claims, CapabilityRisk::Read)?; + let _operation = self.enter_managed_operation( + &_claims, + crate::effects::authority::ManagedNativeOperation::FsRead, + )?; let meta = deny_symlink(self.root.metadata(path).map_err(map_fs_error)?)?; Ok(FsMetadata { file_type: file_type_name(meta.file_type()), @@ -244,7 +247,10 @@ impl FilesystemCapability { limit: usize, ) -> Result { let _claims = self.authorize(token, CapabilityRisk::Read)?; - let _operation = self.enter_managed_operation(&_claims, CapabilityRisk::Read)?; + let _operation = self.enter_managed_operation( + &_claims, + crate::effects::authority::ManagedNativeOperation::FsRead, + )?; if limit == 0 { return Err(CapabilityError::new( "invalid_request", @@ -289,7 +295,10 @@ impl FilesystemCapability { limit: usize, ) -> Result { let _claims = self.authorize(token, CapabilityRisk::Read)?; - let _operation = self.enter_managed_operation(&_claims, CapabilityRisk::Read)?; + let _operation = self.enter_managed_operation( + &_claims, + crate::effects::authority::ManagedNativeOperation::FsRead, + )?; if limit == 0 { return Err(CapabilityError::new( "invalid_request", @@ -341,7 +350,10 @@ impl FilesystemCapability { bytes: &[u8], ) -> Result { let _claims = self.authorize(token, CapabilityRisk::Write)?; - let _operation = self.enter_managed_operation(&_claims, CapabilityRisk::Write)?; + let _operation = self.enter_managed_operation( + &_claims, + crate::effects::authority::ManagedNativeOperation::FsWrite, + )?; if bytes.len() > self.limits.max_write_bytes { return Err(CapabilityError::new( "budget_exceeded", @@ -466,7 +478,7 @@ impl FilesystemCapability { fn enter_managed_operation( &self, claims: &TokenClaims, - risk: CapabilityRisk, + operation: crate::effects::authority::ManagedNativeOperation, ) -> Result, CapabilityError> { self.managed .as_ref() @@ -476,7 +488,7 @@ impl FilesystemCapability { .ok_or_else(|| { CapabilityError::new("managed_call_unavailable", "managed call unavailable") })? - .enter_operation(claims, risk) + .enter_operation(claims, operation) .map_err(|error| CapabilityError::new("managed_operation_denied", error)) }) .transpose() diff --git a/src/capabilities/process.rs b/src/capabilities/process.rs index 92ae0ef..43e3cef 100644 --- a/src/capabilities/process.rs +++ b/src/capabilities/process.rs @@ -436,13 +436,14 @@ impl ProcessCapability { stdin: Option<&[u8]>, ) -> Result { let claims = self.authorize(token, CapabilityRisk::Execute)?; - let _operation = self.enter_managed_operation(token)?; + let _operation = self.enter_managed_operation( + token, + crate::effects::authority::ManagedNativeOperation::ProcessSpawn, + )?; if self.is_closing() { return Err(closing_error()); } - if self.inner.managed.is_some() - && (claims.tool_name != "terminal" || !limits.close_after_initial) - { + if self.inner.managed.is_some() && !limits.close_after_initial { return Err(CapabilityError::new( "managed_open_effect_unsupported", "managed process must close after initial foreground execution", @@ -601,7 +602,10 @@ impl ProcessCapability { cursor: u64, limit: usize, ) -> Result { - let _operation = self.enter_managed_operation(token)?; + let _operation = self.enter_managed_operation( + token, + crate::effects::authority::ManagedNativeOperation::OwnedProcessUse, + )?; if limit == 0 { return Err(CapabilityError::new( "invalid_request", @@ -640,7 +644,10 @@ impl ProcessCapability { handle: &str, timeout_ms: Option, ) -> Result { - let _operation = self.enter_managed_operation(token)?; + let _operation = self.enter_managed_operation( + token, + crate::effects::authority::ManagedNativeOperation::OwnedProcessUse, + )?; let timeout_ms = timeout_ms.map(|ms| ms.min(self.inner.host_limits.timeout_ms)); let wait_deadline = timeout_ms.map(|ms| Instant::now() + Duration::from_millis(ms)); loop { @@ -745,7 +752,10 @@ impl ProcessCapability { cursor: u64, limit: usize, ) -> Result { - let _operation = self.enter_managed_operation(token)?; + let _operation = self.enter_managed_operation( + token, + crate::effects::authority::ManagedNativeOperation::OwnedProcessUse, + )?; if limit == 0 { return Err(CapabilityError::new( "invalid_request", @@ -764,7 +774,10 @@ impl ProcessCapability { bytes: &[u8], timeout_ms: Option, ) -> Result { - let _operation = self.enter_managed_operation(token)?; + let _operation = self.enter_managed_operation( + token, + crate::effects::authority::ManagedNativeOperation::OwnedProcessUse, + )?; let owned = self.lookup(token, handle)?; if bytes.len() > self.inner.host_limits.stdin_limit { return Err(CapabilityError::new( @@ -785,7 +798,10 @@ impl ProcessCapability { /// Closes child stdin. pub fn close_stdin(&self, token: &str, handle: &str) -> Result<(), CapabilityError> { - let _operation = self.enter_managed_operation(token)?; + let _operation = self.enter_managed_operation( + token, + crate::effects::authority::ManagedNativeOperation::OwnedProcessUse, + )?; let owned = self.lookup(token, handle)?; match owned.handle.close_stdin() { Ok(()) @@ -797,7 +813,10 @@ impl ProcessCapability { /// Kills the process tree bound to `handle`. pub fn kill(&self, token: &str, handle: &str) -> Result<(), CapabilityError> { - let _operation = self.enter_managed_operation(token)?; + let _operation = self.enter_managed_operation( + token, + crate::effects::authority::ManagedNativeOperation::OwnedProcessUse, + )?; let owned = self.lookup(token, handle)?; terminate_owned(&owned); Ok(()) @@ -853,6 +872,7 @@ impl ProcessCapability { fn enter_managed_operation( &self, token: &str, + operation: crate::effects::authority::ManagedNativeOperation, ) -> Result, CapabilityError> { self.inner .managed @@ -861,14 +881,14 @@ impl ProcessCapability { let claims = self .inner .lifecycle - .authorize(&self.inner.owner, token, CapabilityRisk::Read) + .authorize(&self.inner.owner, token, operation.required_risk()) .map_err(CapabilityError::from)?; managed .upgrade() .ok_or_else(|| { CapabilityError::new("managed_call_unavailable", "managed call unavailable") })? - .enter_operation(&claims, CapabilityRisk::Read) + .enter_operation(&claims, operation) .map_err(|error| CapabilityError::new("managed_operation_denied", error)) }) .transpose() diff --git a/src/effects/authority.rs b/src/effects/authority.rs new file mode 100644 index 0000000..ba2fa5e --- /dev/null +++ b/src/effects/authority.rs @@ -0,0 +1,673 @@ +//! Captured, bounded RSS declarations sealed into Native call authority. +//! No wire value, descriptor risk, or public tool name can construct authority. +use std::collections::BTreeMap; +use std::sync::Arc; + +use super::{ManagedCodeIdentity, ManagedExternalEffects}; +use crate::capabilities::CapabilityRisk; +use crate::registry::{ToolRegistry, ToolRegistrySnapshot}; +use crate::tool_schema::ToolDescriptor; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ManagedPolicyClass { + WorkspaceRead, + WorkspaceWrite, + ForegroundProcess, + ProcessControl, +} +#[derive(Clone, Copy, Debug)] +pub(crate) enum ManagedNativeOperation { + FsRead, + FsWrite, + ProcessSpawn, + OwnedProcessUse, +} +impl ManagedNativeOperation { + pub(crate) fn required_risk(self) -> CapabilityRisk { + match self { + Self::FsRead => CapabilityRisk::Read, + Self::FsWrite => CapabilityRisk::Write, + Self::ProcessSpawn | Self::OwnedProcessUse => CapabilityRisk::Execute, + } + } +} +impl ManagedPolicyClass { + fn parse(tag: &str) -> Result { + match tag { + "workspace_read" => Ok(Self::WorkspaceRead), + "workspace_write" => Ok(Self::WorkspaceWrite), + "foreground_process" => Ok(Self::ForegroundProcess), + "process_control" => Ok(Self::ProcessControl), + _ => Err("managed unknown operation declaration".into()), + } + } + pub(crate) fn expected_risk(self) -> CapabilityRisk { + match self { + Self::WorkspaceRead => CapabilityRisk::Read, + Self::WorkspaceWrite => CapabilityRisk::Write, + Self::ForegroundProcess | Self::ProcessControl => CapabilityRisk::Execute, + } + } + pub(crate) fn required_effect(self) -> Result<(&'static str, &'static str), String> { + match self { + Self::WorkspaceRead => Ok(("workspace.fs.read", "bound_workspace")), + Self::WorkspaceWrite => Ok(("workspace.fs.write", "bound_workspace")), + Self::ForegroundProcess => Ok(("process.spawn", "bound_process")), + Self::ProcessControl => Err("managed_open_effect_unsupported".into()), + } + } + pub(crate) fn writable(self) -> bool { + matches!(self, Self::WorkspaceWrite | Self::ForegroundProcess) + } + pub(crate) fn external_effects(self) -> ManagedExternalEffects { + match self { + Self::ForegroundProcess => ManagedExternalEffects::OutsideTreeNotReverted, + _ => ManagedExternalEffects::NoneClaimed, + } + } + fn allows_operation(self, operation: ManagedNativeOperation) -> bool { + matches!( + (self, operation), + ( + Self::WorkspaceRead | Self::WorkspaceWrite | Self::ForegroundProcess, + ManagedNativeOperation::FsRead + ) | (Self::WorkspaceWrite, ManagedNativeOperation::FsWrite) + | ( + Self::ForegroundProcess, + ManagedNativeOperation::ProcessSpawn | ManagedNativeOperation::OwnedProcessUse + ) + ) + } +} + +#[derive(Debug)] +pub(crate) struct ManagedToolPolicyCatalog { + package_id: String, + artifact_id: String, + export: String, + declarations: BTreeMap, + descriptors: ToolRegistrySnapshot, +} + +/// Bound the borrowed VM carrier without first constructing a JSON tree. +fn vm_json_bounds(value: &rustscript_vm::Value, limit: usize) -> Result<(), String> { + fn walk( + value: &rustscript_vm::Value, + depth: usize, + nodes: &mut usize, + bytes: &mut usize, + limit: usize, + ) -> Result<(), String> { + use rustscript_vm::Value; + if depth > 128 || *nodes >= 4096 { + return Err("managed metadata complexity".into()); + } + *nodes += 1; + let mut charge = |n: usize| -> Result<(), String> { + *bytes = bytes.checked_add(n).ok_or("managed metadata overflow")?; + if *bytes > limit { + return Err("managed metadata bytes".into()); + } + Ok(()) + }; + match value { + Value::Null => charge(4), + Value::Bool(_) => charge(5), + Value::Int(_) => charge(20), + Value::Float(n) if n.is_finite() => charge(32), + Value::String(text) => { + if text.len() > 65536 { + return Err("managed metadata string".into()); + } + let quoted = 2 + text + .chars() + .map(|c| match c { + '\"' | '\\' | '\n' | '\r' | '\t' | '\u{0008}' | '\u{000c}' => 2, + '\u{0000}'..='\u{001f}' => 6, + _ => c.len_utf8(), + }) + .sum::(); + charge(quoted) + } + Value::Array(values) => { + if values.len() > 4096 { + return Err("managed metadata array".into()); + } + charge(2 + values.len())?; + for item in values.iter() { + walk(item, depth + 1, nodes, bytes, limit)?; + } + Ok(()) + } + Value::Map(fields) => { + if fields.len() > 4096 { + return Err("managed metadata map".into()); + } + charge(2 + fields.len() * 2)?; + for (key, item) in fields.iter() { + if !matches!(key, Value::String(_)) { + return Err("managed metadata key".into()); + } + walk(key, depth + 1, nodes, bytes, limit)?; + walk(item, depth + 1, nodes, bytes, limit)?; + } + Ok(()) + } + _ => Err("managed metadata must be pure JSON data".into()), + } + } + walk(value, 0, &mut 0, &mut 0, limit) +} +fn vm_fields(value: &rustscript_vm::Value, names: &[&str]) -> Result<(), String> { + let rustscript_vm::Value::Map(fields) = value else { + return Err("managed catalog map required".into()); + }; + if fields.len()!=names.len() || fields.iter().any(|(key,_)| !matches!(key,rustscript_vm::Value::String(text) if names.contains(&text.as_str()))) {return Err("managed catalog fields".into());} + Ok(()) +} +fn vm_field<'a>( + value: &'a rustscript_vm::Value, + name: &str, +) -> Result<&'a rustscript_vm::Value, String> { + let rustscript_vm::Value::Map(fields) = value else { + return Err("managed catalog map required".into()); + }; + fields + .iter() + .find_map(|(key, value)| match key { + rustscript_vm::Value::String(text) if text.as_str() == name => Some(value), + _ => None, + }) + .ok_or_else(|| "managed catalog field required".into()) +} +fn vm_string(value: &rustscript_vm::Value, limit: usize) -> Result<&str, String> { + match value { + rustscript_vm::Value::String(text) if text.len() <= limit => Ok(text.as_str()), + _ => Err("managed catalog string bound/type".into()), + } +} +fn preflight_catalog(value: &rustscript_vm::Value) -> Result<(), String> { + use rustscript_vm::Value; + vm_fields(value, &["declarations", "descriptors"])?; + let (Value::Array(declarations), Value::Array(descriptors)) = ( + vm_field(value, "declarations")?, + vm_field(value, "descriptors")?, + ) else { + return Err("managed catalog arrays required".into()); + }; + if declarations.is_empty() || declarations.len() > 64 || descriptors.len() > 64 { + return Err("managed catalog entry bound".into()); + } + for declaration in declarations.iter() { + vm_fields(declaration, &["tool_name", "operation"])?; + vm_string(vm_field(declaration, "tool_name")?, 64)?; + ManagedPolicyClass::parse(vm_string(vm_field(declaration, "operation")?, 64)?)?; + } + for descriptor in descriptors.iter() { + vm_fields( + descriptor, + &["name", "description", "toolset", "risk_class", "schema"], + )?; + vm_string(vm_field(descriptor, "name")?, 64)?; + vm_string(vm_field(descriptor, "description")?, 4096)?; + vm_string(vm_field(descriptor, "toolset")?, 64)?; + vm_string(vm_field(descriptor, "risk_class")?, 64)?; + } + vm_json_bounds(value, super::journal::METADATA_BYTES)?; + for descriptor in descriptors.iter() { + vm_json_bounds(vm_field(descriptor, "schema")?, 65536)?; + } + Ok(()) +} + +/// Copy only a fully preflighted, bounded VM JSON carrier. +fn bounded_json(value: &rustscript_vm::Value) -> Result { + fn visit( + value: &rustscript_vm::Value, + depth: usize, + nodes: &mut usize, + bytes: &mut usize, + ) -> Result { + use rustscript_vm::Value; + if depth > 128 || *nodes >= 4096 { + return Err("managed metadata complexity".into()); + } + *nodes += 1; + let mut charge = |n: usize| -> Result<(), String> { + *bytes = bytes.checked_add(n).ok_or("managed metadata overflow")?; + if *bytes > super::journal::METADATA_BYTES { + return Err("managed metadata bytes".into()); + } + Ok(()) + }; + match value { + Value::Null => { + charge(4)?; + Ok(serde_json::Value::Null) + } + Value::Bool(b) => { + charge(5)?; + Ok((*b).into()) + } + Value::Int(n) => { + charge(20)?; + Ok((*n).into()) + } + Value::Float(n) => { + charge(32)?; + serde_json::Number::from_f64(*n) + .map(serde_json::Value::Number) + .ok_or("managed nonfinite number".into()) + } + Value::String(s) => { + if s.len() > 65536 { + return Err("managed metadata string".into()); + } + charge( + serde_json::to_string(s.as_ref()) + .map_err(|_| "managed string")? + .len(), + )?; + Ok(serde_json::Value::String(s.to_string())) + } + Value::Array(a) => { + if a.len() > 4096 { + return Err("managed metadata array".into()); + } + charge(2 + a.len())?; + let mut out = Vec::with_capacity(a.len()); + for v in a.iter() { + out.push(visit(v, depth + 1, nodes, bytes)?); + } + Ok(out.into()) + } + Value::Map(m) => { + if m.len() > 4096 { + return Err("managed metadata map".into()); + } + charge(2 + m.len() * 2)?; + let mut out = serde_json::Map::new(); + for (key, v) in m.iter() { + let serde_json::Value::String(key) = visit(key, depth + 1, nodes, bytes)? + else { + return Err("managed metadata key".into()); + }; + if out + .insert(key, visit(v, depth + 1, nodes, bytes)?) + .is_some() + { + return Err("managed duplicate key".into()); + } + } + Ok(out.into()) + } + _ => Err("managed metadata must be pure JSON data".into()), + } + } + visit(value, 0, &mut 0, &mut 0) +} +impl ManagedToolPolicyCatalog { + // Only the verified capture path consumes this parser. Synthetic parser + // fixtures do not authenticate a builtin or mint a run binding. + pub(crate) fn from_captured_value( + package_id: &str, + artifact_id: &str, + export: &str, + value: &rustscript_vm::Value, + ) -> Result, String> { + preflight_catalog(value)?; + let value = bounded_json(value)?; + let fields = value.as_object().ok_or("managed catalog map required")?; + if fields.len() != 2 { + return Err("managed catalog fields".into()); + } + let decls = fields + .get("declarations") + .and_then(serde_json::Value::as_array) + .ok_or("managed declarations required")?; + let descs = fields + .get("descriptors") + .and_then(serde_json::Value::as_array) + .ok_or("managed descriptors required")?; + if decls.is_empty() || decls.len() > 64 || descs.len() > 64 { + return Err("managed catalog entry bound".into()); + } + let mut declarations = BTreeMap::new(); + for declaration in decls { + let row = declaration + .as_object() + .ok_or("managed declaration map required")?; + if row.len() != 2 { + return Err("managed declaration fields".into()); + } + let name = row + .get("tool_name") + .and_then(serde_json::Value::as_str) + .ok_or("managed declaration name")?; + if name.len() > 64 { + return Err("managed declaration name bound".into()); + } + let tag = row + .get("operation") + .and_then(serde_json::Value::as_str) + .ok_or("managed declaration operation")?; + if declarations + .insert(name.into(), ManagedPolicyClass::parse(tag)?) + .is_some() + { + return Err("managed duplicate declaration".into()); + } + } + let mut descriptors = Vec::with_capacity(descs.len()); + for value in descs { + let row = value.as_object().ok_or("managed descriptor map")?; + if row.len() != 5 + || !["name", "description", "toolset", "risk_class", "schema"] + .iter() + .all(|key| row.contains_key(*key)) + { + return Err("managed descriptor fields".into()); + } + if row["description"].as_str().is_none_or(|s| s.len() > 4096) + || serde_json::to_vec(&row["schema"]) + .map_err(|_| "managed schema")? + .len() + > 65536 + { + return Err("managed descriptor bound".into()); + } + descriptors.push( + serde_json::from_value::(value.clone()) + .map_err(|_| "managed descriptor types")?, + ); + } + let descriptors = ToolRegistry::from_descriptors(descriptors) + .map_err(|_| "managed descriptor registry")? + .snapshot(); + if declarations.len() != descriptors.len() { + return Err("managed catalog coverage".into()); + } + for descriptor in descriptors.descriptors() { + let class = declarations + .get(&descriptor.name) + .ok_or("managed catalog coverage")?; + if class.expected_risk().as_str() != descriptor.risk_class { + return Err("managed captured risk mismatch".into()); + } + } + Ok(Arc::new(Self { + package_id: package_id.into(), + artifact_id: artifact_id.into(), + export: export.into(), + declarations, + descriptors, + })) + } + pub(crate) fn matches_code(&self, code: &ManagedCodeIdentity) -> bool { + self.package_id == code.package_id + && self.artifact_id == code.artifact_id + && self.export == code.export + } + pub(crate) fn seal( + self: &Arc, + code: &ManagedCodeIdentity, + snapshot: &ToolRegistrySnapshot, + name: &str, + risk: CapabilityRisk, + arguments: &str, + ) -> Result, String> { + if !self.matches_code(code) || code.registry_identity != snapshot.identity() { + return Err("managed catalog binding mismatch".into()); + } + let class = *self + .declarations + .get(name) + .ok_or("managed tool has no captured declaration")?; + let descriptor = self + .descriptors + .descriptor(name) + .ok_or("managed captured descriptor missing")?; + if snapshot.descriptor(name) != Some(descriptor) || class.expected_risk() != risk { + return Err("managed admitted descriptor/risk mismatch".into()); + } + class.required_effect()?; + let args: serde_json::Value = + serde_json::from_str(arguments).map_err(|_| "managed canonical arguments")?; + if class == ManagedPolicyClass::ForegroundProcess + && args.get("background").and_then(serde_json::Value::as_bool) == Some(true) + { + return Err("managed_open_effect_unsupported".into()); + } + Ok(Arc::new(SealedManagedCallPolicy { + catalog: Arc::clone(self), + code: code.clone(), + tool_name: name.into(), + class, + })) + } +} + +#[derive(Debug)] +pub(crate) struct SealedManagedCallPolicy { + catalog: Arc, + code: ManagedCodeIdentity, + tool_name: String, + class: ManagedPolicyClass, +} +impl SealedManagedCallPolicy { + pub(crate) fn expected_risk(&self) -> CapabilityRisk { + self.class.expected_risk() + } + pub(crate) fn class(&self) -> ManagedPolicyClass { + self.class + } + pub(crate) fn matches( + &self, + catalog: &Arc, + code: &ManagedCodeIdentity, + name: &str, + ) -> bool { + Arc::ptr_eq(&self.catalog, catalog) + && self.catalog.matches_code(code) + && &self.code == code + && self.tool_name == name + } + pub(crate) fn allows_operation(&self, operation: ManagedNativeOperation) -> bool { + self.class.allows_operation(operation) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{Value, json}; + fn synthetic() -> Value { + // Parser/class coverage only: this renamed fixture is not an + // authenticated embedding package or end-to-end builtin execution. + json!({"declarations":[{"tool_name":"renamed_reader","operation":"workspace_read"}],"descriptors":[{"name":"renamed_reader","description":"synthetic parser fixture","toolset":"coding","risk_class":"read","schema":{"type":"object"}}]}) + } + fn parse(v: &Value) -> Result, String> { + ManagedToolPolicyCatalog::from_captured_value( + "synthetic-package", + "synthetic-artifact", + "invoke", + &crate::domain::json_to_vm_value(v), + ) + } + #[test] + fn vm_catalog_bounds_precede_copying_oversized_rows() { + let mut value = synthetic(); + let mut descriptor = value["descriptors"][0].clone(); + descriptor["description"] = json!("x".repeat(60000)); + value["descriptors"] = json!(vec![descriptor; 65]); + assert_eq!(parse(&value).unwrap_err(), "managed catalog entry bound"); + } + + #[test] + fn synthetic_parser_is_closed_bounded_and_has_no_risk_or_name_defaults() { + let value = synthetic(); + assert!(parse(&value).is_ok()); + let mut variants = Vec::new(); + let mut wrong = value.clone(); + wrong.as_object_mut().unwrap().remove("declarations"); + variants.push(wrong); + let mut wrong = value.clone(); + wrong["declarations"][0]["extra"] = json!(true); + variants.push(wrong); + let mut wrong = value.clone(); + wrong["declarations"][0]["operation"] = json!("unknown"); + variants.push(wrong); + let mut wrong = value.clone(); + wrong["declarations"][0]["tool_name"] = json!("missing"); + variants.push(wrong); + let mut wrong = value.clone(); + wrong["declarations"] = json!([value["declarations"][0], value["declarations"][0]]); + variants.push(wrong); + let mut wrong = value.clone(); + wrong["descriptors"][0]["risk_class"] = json!("write"); + variants.push(wrong); + let mut wrong = value.clone(); + wrong["descriptors"][0]["extra"] = json!({}); + variants.push(wrong); + let mut wrong = value.clone(); + wrong["descriptors"][0]["description"] = json!("x".repeat(4097)); + variants.push(wrong); + let mut wrong = value.clone(); + wrong["declarations"][0]["tool_name"] = json!("x".repeat(65)); + variants.push(wrong); + let mut wrong = value.clone(); + wrong["descriptors"][0]["schema"] = json!({"description":"x".repeat(65537)}); + variants.push(wrong); + let mut wrong = value.clone(); + wrong["declarations"] = json!(vec![value["declarations"][0].clone(); 65]); + variants.push(wrong); + let mut metadata = value.clone(); + metadata["descriptors"]=json!((0..20).map(|i| json!({"name":format!("bounded_{i}"),"description":"metadata bound","toolset":"coding","risk_class":"read","schema":{"type":"object","description":"x".repeat(60000)}})).collect::>()); + metadata["declarations"] = json!( + (0..20) + .map(|i| json!({"tool_name":format!("bounded_{i}"),"operation":"workspace_read"})) + .collect::>() + ); + assert!(parse(&metadata).unwrap_err().contains("metadata bytes")); + for wrong in variants { + assert!(parse(&wrong).is_err()); + } + let mut depth = json!(true); + for _ in 0..129 { + depth = json!([depth]); + } + let mut wrong = value.clone(); + wrong["descriptors"][0]["schema"] = depth; + assert!(parse(&wrong).is_err()); + let mut wrong = value.clone(); + wrong["descriptors"][0]["schema"] = json!({"enum":vec![0;4097]}); + assert!(parse(&wrong).is_err()); + } + #[test] + fn synthetic_catalog_identity_and_admitted_snapshot_are_exact_extras_grant_nothing() { + let catalog = parse(&synthetic()).unwrap(); + let snapshot = catalog.descriptors.clone(); + let mut code = ManagedCodeIdentity { + graph_id: "graph-a".into(), + activation_generation: 1, + graph_ref_id: "ref-a".into(), + package_id: "synthetic-package".into(), + artifact_id: "synthetic-artifact".into(), + export: "invoke".into(), + registry_identity: snapshot.identity().into(), + effect_contract_version: 1, + }; + let policy = catalog + .seal( + &code, + &snapshot, + "renamed_reader", + CapabilityRisk::Read, + "{}", + ) + .unwrap(); + assert!(policy.allows_operation(ManagedNativeOperation::FsRead)); + assert!(!policy.allows_operation(ManagedNativeOperation::FsWrite)); + assert!(!policy.allows_operation(ManagedNativeOperation::ProcessSpawn)); + assert!( + catalog + .seal( + &code, + &snapshot, + "renamed_reader", + CapabilityRisk::Write, + "{}" + ) + .is_err() + ); + let mut wrong = code.clone(); + wrong.artifact_id = "other".into(); + assert!(!catalog.matches_code(&wrong)); + let mut wrong = code.clone(); + wrong.package_id = "other".into(); + assert!(!catalog.matches_code(&wrong)); + let mut wrong = code.clone(); + wrong.export = "other".into(); + assert!(!catalog.matches_code(&wrong)); + let mut wrong = code.clone(); + wrong.activation_generation += 1; + assert!(!policy.matches(&catalog, &wrong, "renamed_reader")); + let mut wrong = code.clone(); + wrong.graph_ref_id = "other".into(); + assert!(!policy.matches(&catalog, &wrong, "renamed_reader")); + let mut extra = snapshot.descriptors()[0].clone(); + extra.name = "extra_reader".into(); + let extended = ToolRegistry::from_descriptors([snapshot.descriptors()[0].clone(), extra]) + .unwrap() + .snapshot(); + assert!( + catalog + .seal( + &code, + &extended, + "renamed_reader", + CapabilityRisk::Read, + "{}" + ) + .is_err() + ); + code.registry_identity = extended.identity().into(); + assert!( + catalog + .seal( + &code, + &extended, + "renamed_reader", + CapabilityRisk::Read, + "{}" + ) + .is_ok() + ); + assert!( + catalog + .seal(&code, &extended, "extra_reader", CapabilityRisk::Read, "{}") + .is_err() + ); + assert!(policy.matches( + &catalog, + &ManagedCodeIdentity { + registry_identity: snapshot.identity().into(), + ..code + }, + "renamed_reader" + )); + assert!( + !ManagedPolicyClass::ForegroundProcess + .allows_operation(ManagedNativeOperation::FsWrite) + ); + assert!( + ManagedPolicyClass::ForegroundProcess + .allows_operation(ManagedNativeOperation::OwnedProcessUse) + ); + assert!( + ManagedPolicyClass::ProcessControl + .required_effect() + .is_err() + ); + } +} diff --git a/src/effects/broker.rs b/src/effects/broker.rs index 9c2072a..aec4800 100644 --- a/src/effects/broker.rs +++ b/src/effects/broker.rs @@ -1,5 +1,8 @@ //! Host-private authority, immutable object preparation and per-call fences. //! Managed cwd mapping does not isolate arbitrary program effects outside the tree. +use super::authority::{ + ManagedNativeOperation, ManagedPolicyClass, ManagedToolPolicyCatalog, SealedManagedCallPolicy, +}; use super::retention::{ ManagedCallReservation, ManagedObjectLease, ManagedObjectLeaseRegistry, ManagedObjectTicket, }; @@ -8,6 +11,7 @@ use super::{ tree_store, types::{EffectCancellation, EffectDeadline}, }; +use crate::registry::ToolRegistrySnapshot; use crate::{ capabilities::vm_io::ConfinedFsRoot, capabilities::{ @@ -963,8 +967,10 @@ impl ManagedEffectRuntime { owner: ManagedOwnerScope, code: ManagedCodeIdentity, contract: ManagedEntryContract, + captured_policy: (Arc, Arc), cancel: RunCancellation, ) -> Result, String> { + let (policy_catalog, registry_snapshot) = captured_policy; authority.check().map_err(error)?; self.check()?; if !contract.single_builtin_graph @@ -972,6 +978,11 @@ impl ManagedEffectRuntime { { return Err("managed graph must be captured embedding-owned single builtin".into()); } + if !policy_catalog.matches_code(&code) + || code.registry_identity != registry_snapshot.identity() + { + return Err("managed captured catalog/snapshot mismatch".into()); + } let configured = fs::canonicalize(&self.config.source_root).map_err(error)?; if configured != Path::new(authority.canonical_path()) { return Err("managed_workspace_mismatch".into()); @@ -1048,6 +1059,8 @@ impl ManagedEffectRuntime { owner, code, contract, + policy_catalog, + registry_snapshot, cancel, workspace, calls: Mutex::new(HashMap::new()), @@ -1079,6 +1092,8 @@ pub(crate) struct ManagedRunBinding { pub(crate) owner: ManagedOwnerScope, pub(crate) code: ManagedCodeIdentity, contract: ManagedEntryContract, + policy_catalog: Arc, + registry_snapshot: Arc, pub(crate) cancel: RunCancellation, pub(crate) workspace: ManagedWorkspaceRecord, calls: Mutex>>, @@ -1097,6 +1112,8 @@ impl ManagedRunBinding { owner: self.owner.clone(), code: self.code.clone(), contract: self.contract.clone(), + policy_catalog: Arc::clone(&self.policy_catalog), + registry_snapshot: Arc::clone(&self.registry_snapshot), cancel, workspace: self.workspace.clone(), calls: Mutex::new(HashMap::new()), @@ -1119,6 +1136,36 @@ impl ManagedRunBinding { }) }) } + pub(crate) fn matches_policy_catalog( + &self, + catalog: Option<&Arc>, + ) -> bool { + catalog.is_some_and(|catalog| { + Arc::ptr_eq(catalog, &self.policy_catalog) && catalog.matches_code(&self.code) + }) + } + pub(crate) fn seal_call_policy( + &self, + name: &str, + risk: CapabilityRisk, + arguments: &str, + ) -> Result, String> { + self.check()?; + let policy = + self.policy_catalog + .seal(&self.code, &self.registry_snapshot, name, risk, arguments)?; + let (kind, scope) = policy.class().required_effect()?; + if !self.contract.effects.iter().any(|effect| { + effect.kind == kind + && effect.scope == scope + && (effect.recovery == "none" + || (policy.class() == ManagedPolicyClass::WorkspaceWrite + && effect.recovery == "checkpoint")) + }) { + return Err("managed undeclared effect".into()); + } + Ok(policy) + } pub(crate) fn replay_live( &self, call_id: &str, @@ -1229,38 +1276,17 @@ impl ManagedRunBinding { call_id: &str, tool_name: &str, arguments: &str, - risk: &str, + policy: Arc, deadline_ms: u64, ) -> Result, String> { self.check()?; if self.calls.lock().contains_key(call_id) { return Err("managed exact call already registered".into()); } - let required = match tool_name { - "read_file" | "search_files" => "workspace.fs.read", - "write_file" | "patch" => "workspace.fs.write", - "terminal" => "process.spawn", - "process" => return Err("managed_open_effect_unsupported".into()), - _ => return Err("managed tool unsupported".into()), - }; - let scope = if required == "process.spawn" { - "bound_process" - } else { - "bound_workspace" - }; - if !self.contract.effects.iter().any(|effect| { - effect.kind == required - && effect.scope == scope - && (effect.recovery == "none" - || (required == "workspace.fs.write" && effect.recovery == "checkpoint")) - }) { - return Err("managed undeclared effect".into()); - } - let args: Value = serde_json::from_str(arguments).map_err(error)?; - if tool_name == "terminal" && args.get("background").and_then(Value::as_bool) == Some(true) - { - return Err("managed_open_effect_unsupported".into()); + if !policy.matches(&self.policy_catalog, &self.code, tool_name) { + return Err("managed policy binding mismatch".into()); } + let risk = policy.expected_risk().as_str(); let head = self .runtime .repository @@ -1287,7 +1313,7 @@ impl ManagedRunBinding { .as_ref() .copied() .ok_or("managed admitted ceilings missing")?; - let writable = matches!(tool_name, "write_file" | "patch" | "terminal"); + let writable = policy.class().writable(); let multiplier = if writable { 2 } else { 1 }; let large = serde_json::to_vec(&ManagedArgumentIdentity::Inline { canonical_arguments: arguments.into(), @@ -1353,6 +1379,7 @@ impl ManagedRunBinding { }; let call = Arc::new(ManagedCallContext { binding: Arc::downgrade(self), + policy, identity, effect_id: fresh("effect"), receipt_id: fresh("receipt"), @@ -1446,6 +1473,7 @@ pub struct ManagedFinalWitness { pub(crate) struct ManagedCallContext { binding: Weak, pub(crate) identity: ManagedCallIdentity, + pub(crate) policy: Arc, pub(crate) effect_id: String, pub(crate) receipt_id: String, phase: Mutex, @@ -1575,9 +1603,12 @@ impl ManagedCallContext { pub(crate) fn enter_operation( self: &Arc, claims: &TokenClaims, - risk: CapabilityRisk, + operation: ManagedNativeOperation, ) -> Result { - self.authorize(claims, risk)?; + if !self.policy.allows_operation(operation) { + return Err("managed primitive denied by captured class".into()); + } + self.authorize(claims, operation.required_risk())?; let mut active = self.native_operations.lock(); if self.closing.load(Ordering::Acquire) || *self.phase.lock() != CallPhase::Executing { return Err("managed call closing".into()); @@ -1627,6 +1658,7 @@ impl ManagedCallContext { .original_arguments(&self.identity.argument_identity)? || started.tool_name != self.identity.tool_name || started.registry_identity != self.identity.code.registry_identity + || started.risk_class != self.policy.expected_risk() || started.risk_class.as_str() != self.identity.approved_risk { return Err("managed started facts mismatch".into()); @@ -1652,13 +1684,13 @@ impl ManagedCallContext { if binding.runtime.take_fault(ManagedEffectFault::AfterIntent) { return Err("managed fixture cut after durable intent".into()); } - if claims.call_id != self.identity.call_id - || claims.owner.run() != self.identity.owner.run_id - || claims.deadline_ms != self.identity.original_deadline_ms - || claims.generation != self.identity.authorization_generation - { - return Err("managed token mismatch".into()); + self.check_token_binding(claims)?; + if owner != claims.owner { + return Err("managed token owner mismatch".into()); } + lifecycle + .ensure_generation(claims.generation) + .map_err(error)?; let (head, version) = ( &self.identity.expected_head, self.identity.expected_head_version, @@ -1764,7 +1796,9 @@ impl ManagedCallContext { })); *self.phase.lock() = CallPhase::Executing; #[cfg(test)] - if let Some(observer) = binding.runtime.native_ready_observer.lock().clone() { + let observer = binding.runtime.native_ready_observer.lock().clone(); + #[cfg(test)] + if let Some(observer) = observer { observer(Arc::clone(self)); } Ok(()) @@ -1785,10 +1819,23 @@ impl ManagedCallContext { claims: &TokenClaims, risk: CapabilityRisk, ) -> Result<(), String> { + self.check_token_binding(claims)?; + if self.closing.load(Ordering::Acquire) || *self.phase.lock() != CallPhase::Executing { + return Err("managed call closing".into()); + } + if risk != CapabilityRisk::Read && risk != self.policy.expected_risk() { + return Err("managed operation ceiling mismatch".into()); + } + Ok(()) + } + fn check_token_binding(&self, claims: &TokenClaims) -> Result<(), String> { let binding = self.binding()?; binding.check()?; - if self.closing.load(Ordering::Acquire) - || *self.phase.lock() != CallPhase::Executing + if !self.policy.matches( + &binding.policy_catalog, + &binding.code, + &self.identity.tool_name, + ) || claims.risk_ceiling != self.policy.expected_risk() || claims.owner.profile() != self.identity.owner.profile || claims.owner.session() != self.identity.owner.session_id || claims.owner.run() != self.identity.owner.run_id @@ -1805,14 +1852,6 @@ impl ManagedCallContext { { return Err("managed call closing or identity mismatch".into()); } - let expected = match risk { - CapabilityRisk::Read => "read", - CapabilityRisk::Write => "write", - CapabilityRisk::Execute => "execute", - }; - if expected != "read" && self.identity.approved_risk != expected { - return Err("managed operation ceiling mismatch".into()); - } Ok(()) } pub(crate) fn accounted_write( @@ -1871,10 +1910,7 @@ impl ManagedCallContext { let result = fixture_result.as_ref().unwrap_or(result); let canonical = CanonicalToolResultJson::from_value(result)?; let parent = self.parent.clone(); - let writable = matches!( - self.identity.tool_name.as_str(), - "write_file" | "patch" | "terminal" - ); + let writable = self.policy.class().writable(); let ok = result["ok"] == true; let candidate = if writable && ok { Some(binding.runtime.prepare_tree( @@ -1917,11 +1953,7 @@ impl ManagedCallContext { }, prepared_at_ms: crate::domain::timestamp(), observation_ok: ok, - external_effects: if self.identity.tool_name == "terminal" { - ManagedExternalEffects::OutsideTreeNotReverted - } else { - ManagedExternalEffects::NoneClaimed - }, + external_effects: self.policy.class().external_effects(), }; self.reservation.release_unspent(); let mut objects = vec![original]; diff --git a/src/effects/managed_authority_tests.rs b/src/effects/managed_authority_tests.rs index de8f275..be9b1d2 100644 --- a/src/effects/managed_authority_tests.rs +++ b/src/effects/managed_authority_tests.rs @@ -101,6 +101,10 @@ async fn original_native_token_rejects_sibling_and_changed_claims_then_same_vm_w binding.owner.clone(), binding.code.clone(), contract, + ( + Arc::clone(&binding.policy_catalog), + Arc::clone(&binding.registry_snapshot) + ), binding.cancel.clone() ) .is_err(), @@ -117,6 +121,10 @@ async fn original_native_token_rejects_sibling_and_changed_claims_then_same_vm_w binding.owner.clone(), binding.code.clone(), graph, + ( + Arc::clone(&binding.policy_catalog), + Arc::clone(&binding.registry_snapshot) + ), binding.cancel.clone() ) .is_err() @@ -130,6 +138,10 @@ async fn original_native_token_rejects_sibling_and_changed_claims_then_same_vm_w binding.owner.clone(), binding.code.clone(), pure, + ( + Arc::clone(&binding.policy_catalog), + Arc::clone(&binding.registry_snapshot), + ), binding.cancel.clone(), ) .unwrap(); @@ -140,14 +152,8 @@ async fn original_native_token_rejects_sibling_and_changed_claims_then_same_vm_w ) .unwrap(); assert!( - pure.register( - "undeclared", - "write_file", - "{}", - "write", - original.deadline_ms - ) - .is_err() + pure.seal_call_policy("write_file", CapabilityRisk::Write, "{}") + .is_err() ); drop(pure); call.authorize(&original, CapabilityRisk::Write).unwrap(); @@ -259,6 +265,398 @@ async fn run_native_fixture(service: &Arc, call: Value) -> .await; run } +#[tokio::test(flavor = "multi_thread")] +async fn fresh_host_rejects_wrong_risk_name_arguments_and_registry_then_same_vm_executes_original() +{ + let (root, state) = native_fixture(); + fs::write(root.join("workspace/seed.txt"), b"seed").unwrap(); + let service = state.service(); + let runtime = service.managed_runtime_for_test().unwrap(); + let weak = Arc::downgrade(&service); + let checked = Arc::new(AtomicBool::new(false)); + let seen = Arc::clone(&checked); + *runtime.native_ready_observer.lock() = Some(Arc::new(move |call| { + if seen.swap(true, Ordering::SeqCst) { + return; + } + let service = weak.upgrade().unwrap(); + let binding = call.binding().unwrap(); + assert!(binding.call("second").is_none()); + let mut host = service + .capability_host_bridges(&call.identity.owner.run_id) + .unwrap() + .unwrap(); + host.pinned_tool = service.retained_tool_for_test(&call.identity.owner.run_id); + let args = json!({"path":"second.txt","content":"same VM authorized original"}); + let canonical = args.to_string(); + let context = json!({"run":call.identity.owner.run_id,"registry":call.identity.code.registry_identity,"arguments":canonical,"input":json!({"call":{"id":"second","name":"write_file","arguments":args},"config":service.run_context(&call.identity.owner.run_id).unwrap().limits}).to_string()}); + let native = crate::runtime::agent_host::AgentHostState { + provider: Arc::new(ScriptedProvider::new()), + cancellation: host.cancellation.clone().unwrap(), + sleeps: Arc::clone(&host.sleeps), + skip_sleep: host.skip_sleep, + metrics: host.metrics.clone(), + lifecycle: host.lifecycle.clone(), + capability_owner: host.capability_owner.clone(), + filesystem: host.filesystem.clone(), + processes: host.processes.clone(), + artifacts: host.artifacts.clone(), + leases: Default::default(), + control_hook: None, + pinned_tool: host.pinned_tool.clone(), + allow_stateless_pin: false, + expected_call: None, + provider_arguments: Default::default(), + }; + let forged = json!({"tool_name":"write_file","call_id":"second","canonical_arguments":canonical,"canonical_input":context["input"],"run_id":call.identity.owner.run_id,"registry_identity":call.identity.code.registry_identity,"argument_digest":canonical,"risk_class":"read","summary":"original","untrusted_graph_id":"","operation":"workspace_read","permissions":["workspace_write","foreground_process"]}); + assert_eq!( + native.invoke_managed_boundary_for_test(&forged)["ok"], + false + ); + assert!(binding.call("second").is_none()); + let changed = json!({"run_id":call.identity.owner.run_id,"call_id":call.identity.call_id,"tool_name":call.identity.tool_name,"argument_digest":call.original_arguments,"registry_identity":call.identity.code.registry_identity,"risk_class":"write","summary":"probe"}); + assert_eq!( + native.changed_managed_child_prepare_for_test( + Arc::clone(&call), + &call.original_arguments, + &changed + )["code"], + "pinned_call_mismatch" + ); + let source = r#"pub fn run(context:map)->map { + let run:string=context["run"];let registry:string=context["registry"];let args:string=context["arguments"];let input:string=context["input"]; + let attest:map=agent_runtime::approval_attest({run_id:run,call_id:"second",tool_name:"write_file",argument_digest:args,registry_identity:registry,risk_class:"read",summary:"original"}); + let risk:map=agent_runtime::invoke_pinned_tool({tool_name:"write_file",call_id:"second",canonical_arguments:args,canonical_input:input,run_id:run,registry_identity:registry,argument_digest:args,risk_class:"read",summary:"original",untrusted_graph_id:""}); + let execute:map=agent_runtime::invoke_pinned_tool({tool_name:"write_file",call_id:"second",canonical_arguments:args,canonical_input:input,run_id:run,registry_identity:registry,argument_digest:args,risk_class:"execute",summary:"original",untrusted_graph_id:""}); + let name:map=agent_runtime::invoke_pinned_tool({tool_name:"read_file",call_id:"second",canonical_arguments:args,canonical_input:input,run_id:run,registry_identity:registry,argument_digest:args,risk_class:"write",summary:"original",untrusted_graph_id:""}); + let arguments:map=agent_runtime::invoke_pinned_tool({tool_name:"write_file",call_id:"second",canonical_arguments:"{}",canonical_input:input,run_id:run,registry_identity:registry,argument_digest:"{}",risk_class:"write",summary:"original",untrusted_graph_id:""}); + let changed_registry:map=agent_runtime::invoke_pinned_tool({tool_name:"write_file",call_id:"second",canonical_arguments:args,canonical_input:input,run_id:run,registry_identity:"changed",argument_digest:args,risk_class:"write",summary:"original",untrusted_graph_id:""}); + let approve:map=agent_runtime::approval_attest({run_id:run,call_id:"second",tool_name:"write_file",argument_digest:args,registry_identity:registry,risk_class:"write",summary:"original"}); + let original:map=agent_runtime::invoke_pinned_tool({tool_name:"write_file",call_id:"second",canonical_arguments:args,canonical_input:input,run_id:run,registry_identity:registry,argument_digest:args,risk_class:"write",summary:"original",untrusted_graph_id:""}); + {attest:attest,risk:risk,execute:execute,name:name,arguments:arguments,changed_registry:changed_registry,approve:approve,original:original} + }"#; + let runner = crate::AgentRunner::from_source(source, crate::AgentConfig::default()) + .unwrap() + .with_host(host); + let result = crate::domain::vm_value_to_json( + &runner + .run_with_context(crate::domain::json_to_vm_value(&context)) + .unwrap(), + ); + for key in [ + "attest", + "risk", + "execute", + "name", + "arguments", + "changed_registry", + ] { + assert_eq!(result[key]["ok"], false, "{key}"); + } + assert_eq!(result["approve"]["ok"], true); + assert_eq!(result["original"]["ok"], true); + })); + let provider = Arc::new(ScriptedProvider::new()); + provider.push_ok(json!({"text":"","tool_calls":[{"id":"first","name":"read_file","arguments":{"path":"seed.txt"}},{"id":"second","name":"write_file","arguments":{"path":"second.txt","content":"same VM authorized original"}}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2},"reasoning":"","stop_reason":"stop"})); + provider.push_ok(json!({"text":"done","tool_calls":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2},"reasoning":"","stop_reason":"stop"})); + service.inject_provider_host(provider); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"real Native parent pair"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + service + .clone() + .run_worker(run.run_id.clone(), "ignored".into()) + .await; + assert!(checked.load(Ordering::SeqCst)); + let record = state + .persistence() + .unwrap() + .managed_effect_get(&ManagedEffectGetRequest { + owner: ManagedOwnerScope { + profile: "gateway".into(), + session_id: run.session_id, + run_id: run.run_id, + }, + call_id: "second".into(), + }) + .unwrap() + .unwrap(); + let candidate = record.prepared.unwrap().candidate_root.unwrap(); + assert_eq!( + fs::read( + root.join(".gateway.sqlite.effects/trees") + .join(candidate.object_id) + .join("second.txt") + ) + .unwrap(), + b"same VM authorized original" + ); + drop(runtime); + service.shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn read_policy_denies_native_write_and_spawn_even_with_writable_export_union() { + let (root, state) = native_fixture(); + fs::write(root.join("workspace/seed.txt"), b"original read bytes").unwrap(); + let service = state.service(); + let runtime = service.managed_runtime_for_test().unwrap(); + let checked = Arc::new(AtomicBool::new(false)); + let observed = Arc::clone(&checked); + let weak = Arc::downgrade(&service); + let sentinel = root.join("native-spawn-denied"); + let denied_sentinel = sentinel.clone(); + *runtime.native_ready_observer.lock() = Some(Arc::new(move |call| { + let binding = call.binding().unwrap(); + assert!( + binding + .contract + .effects + .iter() + .any(|e| e.kind == "workspace.fs.write") + ); + assert!( + binding + .contract + .effects + .iter() + .any(|e| e.kind == "process.spawn") + ); + // Exercise actual Native reservation tickets, separately from token + // authorization. Detail objects are really written for the large cases. + for (name, risk, multiplier, objects) in [ + ("read_file", CapabilityRisk::Read, 1, 2), + ("write_file", CapabilityRisk::Write, 2, 4), + ("terminal", CapabilityRisk::Execute, 2, 4), + ] { + for large in [false, true] { + let text = if large { + "x".repeat(6000) + } else { + "small".into() + }; + let args = match risk { + CapabilityRisk::Read => json!({"path":text}), + CapabilityRisk::Write => json!({"path":"reservation.txt","content":text}), + CapabilityRisk::Execute => json!({"argv":["/bin/true"],"stdin":text}), + } + .to_string(); + let policy = binding.seal_call_policy(name, risk, &args).unwrap(); + let probe = binding + .register( + &format!("reservation-{name}-{large}"), + name, + &args, + policy, + call.identity.original_deadline_ms, + ) + .unwrap(); + assert_eq!(probe._detail_object.is_some(), large); + let limits = binding.admitted_limits.lock().unwrap(); + let bytes = binding.runtime.config.quota.max_bytes * multiplier + + (limits.output_budget as u64) * multiplier; + assert!(probe.reservation.take_object(bytes + 1).is_err()); + let mut tickets = vec![probe.reservation.take_object(bytes).unwrap()]; + assert!(probe.reservation.take_object(1).is_err()); + for _ in 1..objects { + tickets.push(probe.reservation.take_object(0).unwrap()); + } + assert!(probe.reservation.take_object(0).is_err()); + drop(tickets); + probe.interrupt_unpublished(); + } + } + assert!( + binding + .seal_call_policy( + "terminal", + CapabilityRisk::Execute, + r#"{"argv":["/bin/true"],"background":true}"# + ) + .is_err() + ); + assert!( + binding + .seal_call_policy( + "process", + CapabilityRisk::Execute, + r#"{"action":"poll","process_id":"not-an-owned-handle"}"# + ) + .is_err() + ); + assert!(binding.call("process").is_none()); + let adapter = call.adapters.lock().clone().unwrap(); + let host = weak + .upgrade() + .unwrap() + .capability_host_bridges(&call.identity.owner.run_id) + .unwrap() + .unwrap(); + let claims = host + .lifecycle + .unwrap() + .authorize( + &host.capability_owner.unwrap(), + &adapter.token, + CapabilityRisk::Read, + ) + .unwrap(); + assert!( + call.enter_operation(&claims, ManagedNativeOperation::FsWrite) + .is_err() + ); + assert!( + call.enter_operation(&claims, ManagedNativeOperation::ProcessSpawn) + .is_err() + ); + let active: Vec<_> = (0..32) + .map(|_| { + call.enter_operation(&claims, ManagedNativeOperation::FsRead) + .unwrap() + }) + .collect(); + assert!( + call.enter_operation(&claims, ManagedNativeOperation::FsRead) + .is_err() + ); + drop(active); + assert_eq!(*call.native_operations.lock(), 0); + let spawns = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counter = Arc::clone(&spawns); + adapter + .processes + .set_before_os_spawn_hook(Arc::new(move || { + counter.fetch_add(1, Ordering::SeqCst); + })); + assert!( + adapter + .filesystem + .write_atomic(&adapter.token, "denied.txt", "*", b"denied") + .is_err() + ); + assert!( + adapter + .processes + .spawn( + &adapter.token, + &[ + "/bin/sh".into(), + "-c".into(), + format!("printf denied > '{}'", denied_sentinel.display()) + ], + ".", + &[], + ProcessLimits { + close_after_initial: true, + ..Default::default() + } + ) + .is_err() + ); + assert!(!adapter.work.path().join("denied.txt").exists()); + assert!(!denied_sentinel.exists()); + assert_eq!(spawns.load(Ordering::SeqCst), 0); + assert_eq!( + adapter + .filesystem + .read_range(&adapter.token, "seed.txt", 0, 100) + .unwrap() + .bytes, + b"original read bytes" + ); + observed.store(true, Ordering::SeqCst); + })); + let run = run_native_fixture( + &service, + json!({"id":"read","name":"read_file","arguments":{"path":"seed.txt"}}), + ) + .await; + assert!(checked.load(Ordering::SeqCst)); + assert!(!sentinel.exists()); + let record = state + .persistence() + .unwrap() + .managed_effect_get(&ManagedEffectGetRequest { + owner: ManagedOwnerScope { + profile: "gateway".into(), + session_id: run.session_id, + run_id: run.run_id, + }, + call_id: "read".into(), + }) + .unwrap() + .unwrap(); + assert!(record.prepared.as_ref().unwrap().candidate_root.is_none()); + assert_eq!( + record.prepared.as_ref().unwrap().operation_class, + ManagedOperationClass::ReadOnly + ); + assert_eq!( + record.outcome.unwrap().disposition, + ManagedPublicationStatus::ReadOnly + ); + drop(runtime); + service.shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn captured_policy_rejects_forged_risk_before_reservation_then_original_rss_writes() { + let (root, state) = native_fixture(); + let service = state.service(); + let runtime = service.managed_runtime_for_test().unwrap(); + let checked = Arc::new(AtomicBool::new(false)); + let observed = Arc::clone(&checked); + *runtime.native_ready_observer.lock() = Some(Arc::new(move |call| { + let binding = call.binding().unwrap(); + assert!( + binding + .seal_call_policy( + &call.identity.tool_name, + CapabilityRisk::Read, + &call.original_arguments + ) + .is_err(), + "captured write class must reject read risk before any reservation" + ); + assert!(binding.call("forged-risk").is_none()); + observed.store(true, Ordering::SeqCst); + })); + let run = run_native_fixture(&service, json!({"id":"original","name":"write_file","arguments":{"path":"allowed.txt","content":"original authorized bytes"}})).await; + assert!(checked.load(Ordering::SeqCst)); + let record = state + .persistence() + .unwrap() + .managed_effect_get(&ManagedEffectGetRequest { + owner: ManagedOwnerScope { + profile: "gateway".into(), + session_id: run.session_id, + run_id: run.run_id, + }, + call_id: "original".into(), + }) + .unwrap() + .unwrap(); + let candidate = record.prepared.unwrap().candidate_root.unwrap(); + assert_eq!( + fs::read( + root.join(".gateway.sqlite.effects/trees") + .join(candidate.object_id) + .join("allowed.txt") + ) + .unwrap(), + b"original authorized bytes" + ); + drop(runtime); + service.shutdown().await.unwrap(); +} + #[tokio::test(flavor = "multi_thread")] async fn extra_native_descriptor_lease_retains_actual_final_view_even_with_empty_sdk_references() { let (_root, state) = native_fixture(); @@ -346,6 +744,20 @@ async fn valid_original_terminal_authority_rejects_native_close_after_initial_by .authorize(&owner, &adapter.token, CapabilityRisk::Execute) .unwrap(); call.authorize(&claims, CapabilityRisk::Execute).unwrap(); + let spawns = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let count = Arc::clone(&spawns); + adapter + .processes + .set_before_os_spawn_hook(Arc::new(move || { + count.fetch_add(1, Ordering::SeqCst); + })); + assert!( + adapter + .filesystem + .write_atomic(&adapter.token, "execute-fs-write-denied", "*", b"denied") + .is_err() + ); + assert!(!adapter.work.path().join("execute-fs-write-denied").exists()); let denied = adapter.processes.spawn( &adapter.token, &[ @@ -365,6 +777,7 @@ async fn valid_original_terminal_authority_rejects_native_close_after_initial_by "managed_open_effect_unsupported" ); assert!(!forbidden.exists()); + assert_eq!(spawns.load(Ordering::SeqCst), 0); call.authorize(&claims, CapabilityRisk::Execute).unwrap(); observed.store(true, Ordering::SeqCst); })); diff --git a/src/effects/mod.rs b/src/effects/mod.rs index cb8e644..435add3 100644 --- a/src/effects/mod.rs +++ b/src/effects/mod.rs @@ -2,6 +2,7 @@ //! //! This module does not integrate plugins, OCI bundles, or a control plane. +pub(crate) mod authority; pub(crate) mod broker; mod checkpoint; pub(crate) mod journal; diff --git a/src/plugins/pin.rs b/src/plugins/pin.rs index 1020f1b..350f7bb 100644 --- a/src/plugins/pin.rs +++ b/src/plugins/pin.rs @@ -300,6 +300,7 @@ fn recover_pinned( } struct CapturedTool { + managed_policy_catalog: Option>, managed_contract: Option, program: Arc, invoke_entry: String, @@ -328,6 +329,7 @@ fn capture_tool( .to_string(); Ok(CapturedTool { managed_contract: prepared.managed_contract(&package.id), + managed_policy_catalog: prepared.managed_policy_catalog(&package.id), registry: prepared .registry(&package.id) .ok_or_else(|| PinnedToolAdmitError::new("prepared graph dropped tool registry"))?, @@ -343,6 +345,7 @@ fn binding_from_capture(captured: CapturedTool, receipt: PinnedToolReceipt) -> P let binding = PinnedToolProgram::seal( PinnedToolCode { managed_contract: captured.managed_contract, + managed_policy_catalog: captured.managed_policy_catalog, program: captured.program, entry: captured.invoke_entry, package_id: captured.package_id.clone(), @@ -376,6 +379,11 @@ pub(crate) struct PreparedProductionPin { } impl PreparedProductionPin { + pub(crate) fn managed_policy_catalog( + &self, + ) -> Option> { + self.captured.managed_policy_catalog.clone() + } pub(crate) fn managed_contract(&self) -> Option { self.captured.managed_contract.clone() } diff --git a/src/plugins/prepare.rs b/src/plugins/prepare.rs index 45228ba..cd8861e 100644 --- a/src/plugins/prepare.rs +++ b/src/plugins/prepare.rs @@ -26,6 +26,8 @@ pub struct PreparedGraph { exports: BTreeMap, trust: BTreeMap, managed_contracts: BTreeMap, + managed_policy_catalogs: + BTreeMap>, programs: BTreeMap>, // Prepared with the captured code, never with a run or capability owner. registries: BTreeMap, String>>, @@ -69,6 +71,13 @@ impl PreparedGraph { self.managed_contracts.get(package_id).cloned() } + pub(crate) fn managed_policy_catalog( + &self, + package_id: &str, + ) -> Option> { + self.managed_policy_catalogs.get(package_id).cloned() + } + /// Code-scoped import binding only; no run, owner, token or grant is retained. pub(crate) fn registry( &self, @@ -136,10 +145,17 @@ impl PackageStore { exports: BTreeMap::new(), trust: BTreeMap::new(), managed_contracts: BTreeMap::new(), + managed_policy_catalogs: BTreeMap::new(), programs: BTreeMap::new(), registries: BTreeMap::new(), }; for package in &selected { + if package.trust == PackageTrust::EmbeddingOwnedBuiltin { + let catalog = capture_managed_policy_catalog(package, lock)?; + prepared + .managed_policy_catalogs + .insert(package.manifest.id.clone(), catalog); + } let effects = lock .closure .iter() @@ -211,6 +227,85 @@ impl PackageStore { } } +fn capture_managed_policy_catalog( + package: &InstalledPackage, + lock: &GraphLock, +) -> Result, PluginError> { + let invalid = || { + PluginError::new( + PluginErrorKind::ContractIncompatible, + "managed captured catalog", + ) + }; + if package.trust != PackageTrust::EmbeddingOwnedBuiltin + || !is_tool_role(package) + || package.manifest.id != crate::runtime::native_tools::EMBEDDING_PACKAGE_ID + || lock.packages.len() != 1 + || !lock.edges.is_empty() + || !package.manifest.dependencies.is_empty() + || package.manifest.effects.entries.len() != 1 + || package + .manifest + .effects + .entries + .get(&package.manifest.entry.export) + .is_none_or(|entry| !entry.delegated.is_empty()) + { + return Err(invalid()); + } + let rebuilt = super::resolve::resolve_installed( + std::slice::from_ref(package), + &super::resolve::ResolveRequest { + roots: vec![super::resolve::PackageRequirement { + id: package.manifest.id.clone(), + version: package.manifest.version.clone(), + }], + host_runtime: lock.host_runtime.clone(), + }, + )?; + if rebuilt.packages != lock.packages || rebuilt.closure != lock.closure { + return Err(invalid()); + } + let sources: Vec<_> = package + .sources + .iter() + .map(|(key, bytes)| { + std::str::from_utf8(bytes) + .map(|text| (key.as_str(), text)) + .map_err(|_| invalid()) + }) + .collect::>()?; + let compiled = closed_bundle::compile_bundled_closed_bundle( + "rss/tools/types.rss", + &sources, + ClosedBundleOptions { + limits: ClosedBundleLimits { + max_files: 64, + max_depth: 16, + max_file_bytes: 1024 * 1024, + max_total_bytes: 8 * 1024 * 1024, + }, + virtual_root: Path::new(VIRTUAL_ROOT), + host_roots: &host_roots(), + catalog: agent_host_catalog(), + dialect: snapshot_parser_dialect(), + entry_export: "managed_authority_catalog", + }, + ) + .map_err(|_| invalid())?; + let entry = compiled.entry_name.ok_or_else(invalid)?; + let value = + crate::runtime::rss_runner::eval_captured_pure_export(Arc::new(compiled.program), &entry) + .map_err(|_| invalid())?; + crate::effects::authority::ManagedToolPolicyCatalog::from_captured_value( + &package.manifest.id, + &package.artifact_id, + &package.manifest.entry.export, + &value, + ) + .map_err(|_| invalid()) +} + fn scan_package( package: &InstalledPackage, selected: &[&InstalledPackage], @@ -476,6 +571,116 @@ fn host_roots() -> BTreeSet { mod policy_tests { use super::*; + #[test] + fn captured_policy_closure_is_exact_and_role_and_capture_bound() { + let root = std::path::PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()) + .join(format!("policy-closure-{}", uuid::Uuid::new_v4().simple())); + let store = PackageStore::open(root).unwrap(); + store.bind_policy(&super::super::store::InstallAuthorization { + admin: false, + accepted_publishers: vec![], + unsigned_dev_space: None, + }); + let revision = store.install_embedding_owned().unwrap(); + let package = store.read_installed(&revision.artifact_id).unwrap(); + let lock = super::super::resolve::resolve_installed( + std::slice::from_ref(&package), + &super::super::resolve::ResolveRequest { + roots: vec![super::super::resolve::PackageRequirement { + id: package.manifest.id.clone(), + version: package.manifest.version.clone(), + }], + host_runtime: super::super::error::HOST_RUNTIME.into(), + }, + ) + .unwrap(); + let prepared = store.prepare(&lock).unwrap(); + let catalog = prepared + .managed_policy_catalog(&package.manifest.id) + .unwrap(); + assert!(Arc::ptr_eq( + &catalog, + &store + .prepare(&lock) + .unwrap() + .managed_policy_catalog(&package.manifest.id) + .unwrap() + )); + let mut variants = Vec::new(); + let mut wrong = lock.clone(); + wrong.closure.clear(); + variants.push(wrong); + let mut wrong = lock.clone(); + wrong.closure.push(wrong.closure[0].clone()); + variants.push(wrong); + let mut wrong = lock.clone(); + let duplicate = wrong.closure[0].effects[0].clone(); + wrong.closure[0].effects.push(duplicate); + variants.push(wrong); + let mut wrong = lock.clone(); + wrong.closure[0].effects[0].via.caller_package = "forged".into(); + variants.push(wrong); + let mut wrong = lock.clone(); + wrong.packages[0].artifact_id = "wrong-artifact".into(); + variants.push(wrong); + let mut wrong = lock.clone(); + wrong.packages.push(wrong.packages[0].clone()); + variants.push(wrong); + let mut wrong = lock.clone(); + wrong.edges.push(super::super::resolve::LockedEdge { + from: package.manifest.id.clone(), + from_entry: package.manifest.entry.export.clone(), + alias: "forged".into(), + to: package.manifest.id.clone(), + to_version: package.manifest.version.clone(), + to_artifact_id: package.artifact_id.clone(), + to_entry: package.manifest.entry.export.clone(), + relation: "delegated".into(), + version_requirement: "1.0.0".into(), + bindings: vec![], + }); + variants.push(wrong); + for wrong in variants { + assert!(capture_managed_policy_catalog(&package, &wrong).is_err()); + } + let mut dependency = package.clone(); + dependency + .manifest + .dependencies + .push(super::super::manifest::PluginDependency { + alias: "forged".into(), + id: package.manifest.id.clone(), + version: "1.0.0".into(), + }); + assert!(capture_managed_policy_catalog(&dependency, &lock).is_err()); + let mut delegated = package.clone(); + delegated + .manifest + .effects + .entries + .get_mut(&package.manifest.entry.export) + .unwrap() + .delegated + .push(super::super::manifest::DelegatedCall { + dependency: "forged".into(), + entry: package.manifest.entry.export.clone(), + allow: vec![], + }); + assert!(capture_managed_policy_catalog(&delegated, &lock).is_err()); + for trust in [PackageTrust::DevUnsigned, PackageTrust::AcceptedPublisher] { + let mut wrong = package.clone(); + wrong.trust = trust; + assert!(capture_managed_policy_catalog(&wrong, &lock).is_err()); + } + let mut wrong = package.clone(); + wrong.manifest.kind = "library".into(); + wrong.manifest.contract = "agent.library/v1".into(); + assert!(capture_managed_policy_catalog(&wrong, &lock).is_err()); + let mut wrong = package.clone(); + wrong.sources.remove("rss/tools/types.rss"); + assert!(capture_managed_policy_catalog(&wrong, &lock).is_err()); + } + #[test] fn completion_names_are_exact_and_embedding_trust_cannot_bypass_tool_role() { assert_eq!(charge_name("cap::artifact_put_result").unwrap(), None); diff --git a/src/plugins/store.rs b/src/plugins/store.rs index 08b0306..e865c78 100644 --- a/src/plugins/store.rs +++ b/src/plugins/store.rs @@ -82,6 +82,11 @@ impl InstalledPackage { } } +enum CapturedRevision { + Executable(InstalledPackage), + HistoricalBuiltin(PluginManifest), +} + pub struct PackageStore { root: PathBuf, artifacts: OwnedFd, @@ -206,14 +211,20 @@ impl PackageStore { } let payload = payload_bytes(&files); let artifact_id = payload_artifact_id(&payload); - if let Some(existing) = self.find_version(&manifest.id, &manifest.version)? { - if existing == artifact_id { - return self.revision_from_capture(&artifact_id); + // Only the Native current embedding is revision-addressed. Generic + // signed/dev installation retains the ID/version overwrite fence. + if list_dir(&self.artifacts)? + .iter() + .any(|name| name == &artifact_id) + { + let revision = self.revision_from_capture(&artifact_id)?; + if revision.trust != PackageTrust::EmbeddingOwnedBuiltin { + return Err(PluginError::new( + PluginErrorKind::PublisherUnauthorized, + "current embedding ledger trust", + )); } - return Err(PluginError::new( - PluginErrorKind::VersionOverwrite, - format!("{} {}", manifest.id, manifest.version), - )); + return Ok(revision); } self.write_artifact( &artifact_id, @@ -229,6 +240,48 @@ impl PackageStore { }) } + #[cfg(test)] + pub(crate) fn install_3250_revision_for_test(&self) -> GraphLock { + // Exact source fixture plus unchanged embedding bytes; no execution + // trust is recovered from this historical storage fixture. + let mut files = crate::runtime::native_tools::embedding_package_files().unwrap(); + files.insert( + "rss/tools/types.rss".into(), + include_bytes!("../../tests/fixtures/managed_builtin_3250_types.rss").to_vec(), + ); + let manifest = + parse_plugin_manifest(std::str::from_utf8(&files["manifest.yaml"]).unwrap()).unwrap(); + let payload = payload_bytes(&files); + let artifact_id = payload_artifact_id(&payload); + self.write_artifact( + &artifact_id, + &files, + PackageTrust::EmbeddingOwnedBuiltin, + &manifest.publisher, + &payload, + ) + .unwrap(); + let mut sources = files; + sources.remove("manifest.yaml"); + let old = InstalledPackage { + artifact_id, + trust: PackageTrust::EmbeddingOwnedBuiltin, + manifest, + sources, + }; + super::resolve::resolve_installed( + &[old], + &super::resolve::ResolveRequest { + roots: vec![super::resolve::PackageRequirement { + id: crate::runtime::native_tools::EMBEDDING_PACKAGE_ID.into(), + version: "1.0.0".into(), + }], + host_runtime: super::error::HOST_RUNTIME.into(), + }, + ) + .unwrap() + } + pub fn load_installed(&self) -> Result, PluginError> { reject_symlink_path(&self.root.join("artifacts"))?; reject_symlink_path(&self.root.join("ledger"))?; @@ -237,7 +290,10 @@ impl PackageStore { if name.ends_with(".tmp") { continue; } - packages.push(self.read_installed(&name)?); + match self.capture_revision(&name)? { + CapturedRevision::Executable(package) => packages.push(package), + CapturedRevision::HistoricalBuiltin(_) => {} + } } let mut verified = self.verified.lock().unwrap_or_else(|err| err.into_inner()); *verified = Some(packages.clone()); @@ -283,9 +339,16 @@ impl PackageStore { } fn find_version(&self, id: &str, version: &str) -> Result, PluginError> { - for package in self.load_installed()? { - if package.manifest.id == id && package.manifest.version == version { - return Ok(Some(package.artifact_id)); + for artifact in list_dir(&self.artifacts)? { + if artifact.ends_with(".tmp") { + continue; + } + let manifest = match self.capture_revision(&artifact)? { + CapturedRevision::Executable(package) => package.manifest, + CapturedRevision::HistoricalBuiltin(manifest) => manifest, + }; + if manifest.id == id && manifest.version == version { + return Ok(Some(artifact)); } } Ok(None) @@ -357,6 +420,40 @@ impl PackageStore { &self, artifact_id: &str, ) -> Result { + match self.capture_revision(artifact_id)? { + CapturedRevision::Executable(package) => Ok(package), + CapturedRevision::HistoricalBuiltin(_) => Err(PluginError::new( + PluginErrorKind::ContractIncompatible, + "historical builtin is non-executable", + )), + } + } + + pub(crate) fn graph_has_historical_builtin( + &self, + lock: &GraphLock, + ) -> Result { + let mut historical = false; + for package in &lock.packages { + match self.capture_revision(&package.artifact_id)? { + CapturedRevision::Executable(captured) if captured.manifest.id == package.id => {} + CapturedRevision::HistoricalBuiltin(manifest) + if package.id == manifest.id && package.version == manifest.version => + { + historical = true + } + _ => { + return Err(PluginError::new( + PluginErrorKind::ContractIncompatible, + "graph artifact identity", + )); + } + } + } + Ok(historical) + } + + fn capture_revision(&self, artifact_id: &str) -> Result { let captured = self.capture(artifact_id)?; let payload = payload_bytes(&captured.files); if payload_artifact_id(&payload) != artifact_id { @@ -393,6 +490,31 @@ impl PackageStore { )); } let policy = self.current_policy()?; + if ledger.trust == PackageTrust::EmbeddingOwnedBuiltin { + if captured.files.contains_key(SIGNATURE_FILE) + || manifest.id != crate::runtime::native_tools::EMBEDDING_PACKAGE_ID + || manifest.kind != "tool" + || manifest.contract != "agent.tool/v1" + || manifest.entry.path != "rss/tools/dispatch.rss" + || manifest.entry.export != "invoke_prepared" + { + return Err(PluginError::new( + PluginErrorKind::PublisherUnauthorized, + "builtin record role", + )); + } + if !crate::runtime::native_tools::embedding_payload_matches(&payload) { + for (path, bytes) in &captured.files { + if path.ends_with(".rss") && std::str::from_utf8(bytes).is_err() { + return Err(PluginError::new( + PluginErrorKind::CompileFailed, + "historical source encoding", + )); + } + } + return Ok(CapturedRevision::HistoricalBuiltin(manifest)); + } + } let trust = revalidate( &policy, &manifest, @@ -407,12 +529,12 @@ impl PackageStore { } sources.insert(path, bytes); } - Ok(InstalledPackage { + Ok(CapturedRevision::Executable(InstalledPackage { artifact_id: artifact_id.to_string(), trust, manifest, sources, - }) + })) } fn current_policy(&self) -> Result { @@ -1062,3 +1184,97 @@ fn capture_sources( } Ok(()) } + +#[cfg(test)] +mod historical_builtin_tests { + use super::*; + #[test] + fn historical_builtin_revision_coexists_without_execution_or_byte_replacement() { + let root = PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()) + .join(format!("legacy-builtin-{}", uuid::Uuid::new_v4().simple())); + let store = PackageStore::open(&root).unwrap(); + store.bind_policy(&InstallAuthorization { + admin: false, + accepted_publishers: vec![], + unsigned_dev_space: None, + }); + let mut old = crate::runtime::native_tools::embedding_package_files().unwrap(); + old.insert( + "rss/tools/types.rss".into(), + include_bytes!("../../tests/fixtures/managed_builtin_3250_types.rss").to_vec(), + ); + let payload = payload_bytes(&old); + let id = payload_artifact_id(&payload); + let manifest = + parse_plugin_manifest(std::str::from_utf8(&old["manifest.yaml"]).unwrap()).unwrap(); + store + .write_artifact( + &id, + &old, + PackageTrust::EmbeddingOwnedBuiltin, + &manifest.publisher, + &payload, + ) + .unwrap(); + let current = store + .install_embedding_owned() + .expect("a retained historical builtin must not block current installation"); + assert_ne!(id, current.artifact_id); + assert_eq!(store.capture(&id).unwrap().files, old); + assert!(store.read_installed(&id).is_err()); + assert!( + store + .verified_capture(&[(crate::runtime::native_tools::EMBEDDING_PACKAGE_ID, &id)]) + .is_err() + ); + let installed = store.load_installed().unwrap(); + assert_eq!(installed.len(), 1); + assert_eq!(installed[0].artifact_id, current.artifact_id); + assert_eq!( + store.install_embedding_owned().unwrap().artifact_id, + current.artifact_id + ); + let mut wrong = store.read_ledger(&id).unwrap(); + wrong.publisher = "different-publisher".into(); + fs::remove_file(root.join("ledger").join(&id)).unwrap(); + store + .write_ledger(&id, wrong.trust, &wrong.publisher, &wrong.payload) + .unwrap(); + assert!( + store.load_installed().is_err(), + "publisher corruption cannot be silently classified historical" + ); + let dev = root.join("dev"); + let package = dev.join("package"); + let mut files = crate::runtime::native_tools::embedding_package_files().unwrap(); + for (path, bytes) in &files { + let path = package.join(path); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, bytes).unwrap(); + } + let generic = PackageStore::open(root.join("generic-store")).unwrap(); + let auth = InstallAuthorization { + admin: true, + accepted_publishers: vec![], + unsigned_dev_space: Some(dev), + }; + assert_eq!( + generic.install(&package, &auth).unwrap().trust, + PackageTrust::DevUnsigned + ); + files + .get_mut("rss/tools/types.rss") + .unwrap() + .extend_from_slice(b"\n// distinct dev revision\n"); + fs::write( + package.join("rss/tools/types.rss"), + &files["rss/tools/types.rss"], + ) + .unwrap(); + assert_eq!( + generic.install(&package, &auth).unwrap_err().kind, + PluginErrorKind::VersionOverwrite + ); + assert_eq!(generic.load_installed().unwrap().len(), 1); + } +} diff --git a/src/runtime/agent_host.rs b/src/runtime/agent_host.rs index f591d22..0b9aed8 100644 --- a/src/runtime/agent_host.rs +++ b/src/runtime/agent_host.rs @@ -569,6 +569,7 @@ pub type AuthHostFactory = pub struct PinnedToolProgram { pub(crate) managed: Option>, managed_contract: Option, + managed_policy_catalog: Option>, program: Arc, entry: String, graph_id: String, @@ -589,6 +590,8 @@ pub struct PinnedToolProgram { #[derive(Clone)] pub(crate) struct PinnedToolCode { pub(crate) managed_contract: Option, + pub(crate) managed_policy_catalog: + Option>, pub(crate) program: Arc, pub(crate) entry: String, pub(crate) package_id: String, @@ -615,6 +618,7 @@ impl PinnedToolProgram { Self { managed: None, managed_contract: code.managed_contract, + managed_policy_catalog: code.managed_policy_catalog, program: code.program, entry: code.entry, package_id: code.package_id, @@ -676,6 +680,7 @@ impl PinnedToolProgram { #[derive(Clone)] pub(crate) struct ExpectedCallGrant { managed_call: Option>, + managed_policy: Option>, pub run_id: String, pub call_id: String, pub tool_name: String, @@ -764,6 +769,13 @@ impl ExpectedCallGrant { && self.registry_identity == metadata.registry_identity && self.risk_class == metadata.risk_class.as_str() && self.summary == metadata.summary + && self.managed_policy.as_ref().is_none_or(|policy| { + policy.expected_risk() == metadata.risk_class + && self + .managed_call + .as_ref() + .is_some_and(|call| Arc::ptr_eq(policy, &call.policy)) + }) } } @@ -907,6 +919,35 @@ impl AgentHostState { }) } + #[cfg(all(test, feature = "workspace-io-fault"))] + pub(crate) fn invoke_managed_boundary_for_test(&self, request: &JsonValue) -> JsonValue { + self.invoke_pinned_tool(request) + } + + #[cfg(all(test, feature = "workspace-io-fault"))] + pub(crate) fn changed_managed_child_prepare_for_test( + &self, + call: Arc, + canonical: &str, + changed: &JsonValue, + ) -> JsonValue { + let mut child = self.clone(); + child.pinned_tool = None; + child.expected_call = Some(ExpectedCallGrant { + managed_policy: Some(Arc::clone(&call.policy)), + run_id: call.identity.owner.run_id.clone(), + call_id: call.identity.call_id.clone(), + tool_name: call.identity.tool_name.clone(), + argument_digest: canonical.into(), + registry_identity: call.identity.code.registry_identity.clone(), + risk_class: call.policy.expected_risk().as_str().into(), + summary: "probe".into(), + outcome: Arc::new(Mutex::new(PinnedCallOutcome::Pending)), + managed_call: Some(call), + }); + child.capability_prepare(changed) + } + fn capability_prepare(&self, metadata: &JsonValue) -> JsonValue { let Some(lifecycle) = self.lifecycle.as_ref() else { return crate::capabilities::host::error_envelope(&LifecycleError::InvalidMetadata( @@ -952,7 +993,7 @@ impl AgentHostState { && let Some(token) = envelope.get("execution_token").and_then(JsonValue::as_str) { let bound = lifecycle - .authorize(owner, token, crate::capabilities::CapabilityRisk::Read) + .authorize(owner, token, call.policy.expected_risk()) .map_err(|e| e.code().to_string()) .and_then(|claims| { call.bind_execution(token, &claims, lifecycle.as_ref().clone(), owner.clone()) @@ -1098,6 +1139,12 @@ impl AgentHostState { } let risk_class = field("risk_class"); let summary = field("summary"); + let managed_policy = match parse_prepare_metadata(&json!({ + "run_id":pinned.run_id, "call_id":call_id, "tool_name":tool_name, "argument_digest":canonical, "registry_identity":pinned.registry_identity, "risk_class":risk_class, "summary":summary + })).map_err(|e| format!("{e:?}")).and_then(|metadata| self.checked_managed_policy(&metadata, Some(&arguments))) { + Ok(policy) => policy, + Err(message) => return pinned_outcome(false, "", "", "managed_effect_denied", &message, ""), + }; let canonical_input = field("canonical_input"); let context = if pinned.expects_dispatch_input { if canonical_input.is_empty() || json_object_rejected(&canonical_input) { @@ -1163,7 +1210,10 @@ impl AgentHostState { &call_id, &tool_name, &canonical, - &risk_class, + match managed_policy.as_ref() { + Some(policy) => Arc::clone(policy), + None => return pinned_binding_mismatch(), + }, self.lifecycle .as_ref() .map_or(0, |life| life.original_deadline_ms()), @@ -1182,6 +1232,7 @@ impl AgentHostState { }, None => None, }, + managed_policy, run_id: pinned.run_id.clone(), call_id, tool_name, @@ -1250,6 +1301,14 @@ impl AgentHostState { fn binding_mismatch(&self, pinned: &PinnedToolProgram) -> Option { if let Some(managed) = &pinned.managed && (!managed.matches_contract(pinned.managed_contract.as_ref()) + || !managed.matches_policy_catalog(pinned.managed_policy_catalog.as_ref()) + || managed.owner.run_id != pinned.run_id + || managed.owner.session_id != pinned.session_id + || managed.owner.profile != pinned.profile + || !same_workspace( + std::path::Path::new(managed.authority.canonical_path()), + &pinned.workspace, + ) || managed.code.graph_id != pinned.graph_id || managed.code.activation_generation != pinned.generation || managed.code.graph_ref_id != pinned.ref_id @@ -1308,6 +1367,60 @@ impl AgentHostState { } } + fn checked_managed_policy( + &self, + metadata: &crate::capabilities::PrepareMetadata, + parent: Option<&JsonValue>, + ) -> Result>, String> { + let Some(pinned) = self.pinned_tool.as_ref() else { + return Ok(None); + }; + let Some(binding) = pinned.managed.as_ref() else { + return Ok(None); + }; + if self.binding_mismatch(pinned).is_some() + || metadata.run_id != pinned.run_id + || metadata.registry_identity != pinned.registry_identity + { + return Err("managed pinned binding mismatch".into()); + } + if json_object_rejected(&metadata.argument_digest) { + return Err("managed canonical arguments invalid".into()); + } + let args: JsonValue = serde_json::from_str(&metadata.argument_digest) + .map_err(|_| "managed canonical arguments invalid")?; + let native_parent = match parent { + Some(value) => Some(value.clone()), + None => { + let lifecycle = self.lifecycle.as_ref().ok_or("managed lifecycle missing")?; + let owner = self + .capability_owner + .as_ref() + .ok_or("managed owner missing")?; + lifecycle + .parent_arguments(owner, &metadata.call_id, &metadata.tool_name) + .map_err(|_| "managed parent mismatch")? + .or_else(|| { + self.provider_arguments + .lock() + .unwrap_or_else(|e| e.into_inner()) + .get(&(metadata.call_id.clone(), metadata.tool_name.clone())) + .cloned() + }) + } + }; + if !args.is_object() || native_parent.as_ref() != Some(&args) { + return Err("managed parent arguments mismatch".into()); + } + binding + .seal_call_policy( + &metadata.tool_name, + metadata.risk_class, + &metadata.argument_digest, + ) + .map(Some) + } + fn capability_approval_attest(&self, metadata: &JsonValue) -> JsonValue { let Some(lifecycle) = self.lifecycle.as_ref() else { return crate::capabilities::host::error_envelope(&LifecycleError::InvalidMetadata( @@ -1319,8 +1432,11 @@ impl AgentHostState { "capability owner is not installed".to_string(), )); }; - let result = parse_prepare_metadata(metadata) - .and_then(|metadata| lifecycle.attest_approval(owner, metadata)); + let result = parse_prepare_metadata(metadata).and_then(|metadata| { + self.checked_managed_policy(&metadata, None) + .map_err(LifecycleError::InvalidMetadata)?; + lifecycle.attest_approval(owner, metadata) + }); match result { Ok(()) => json!({ "ok": true, "kind": "attested" }), Err(error) => crate::capabilities::host::error_envelope(&error), diff --git a/src/runtime/native_tools.rs b/src/runtime/native_tools.rs index 6dbd9e4..2318348 100644 --- a/src/runtime/native_tools.rs +++ b/src/runtime/native_tools.rs @@ -73,6 +73,7 @@ impl NativeToolRuntime { PinnedToolProgram::seal( PinnedToolCode { managed_contract: None, + managed_policy_catalog: None, program: Arc::clone(&self.program), entry: self.entry.clone(), package_id: "embedding".to_string(), diff --git a/src/runtime/rss_runner.rs b/src/runtime/rss_runner.rs index 00402f5..dee7be2 100644 --- a/src/runtime/rss_runner.rs +++ b/src/runtime/rss_runner.rs @@ -1678,6 +1678,44 @@ thread_local! { static PINNED_REGISTRY_BUILDS: std::cell::Cell = const { std::cell::Cell::new(0) }; } +/// Evaluate captured declaration code without an Agent host or ambient IO. +/// Root and invocation consume one existing default fuel counter. +pub(crate) fn eval_captured_pure_export( + program: Arc, + entry: &str, +) -> std::result::Result { + if !program.imports.is_empty() { + return Err("managed pure export imports denied".into()); + } + let mut registry = HostFunctionRegistry::empty(); + registry.set_capability_profile(CapabilityProfile::deny_all()); + for name in PINNED_PURE_BUILTINS { + registry.allow_builtin(name).map_err(|e| e.to_string())?; + } + let mut vm = Vm::try_new(program.as_ref().clone()).map_err(|e| e.to_string())?; + registry + .bind_vm_cached(&mut vm) + .map_err(|e| e.to_string())?; + vm.set_fuel(10_000_000); + if !matches!(vm.run(), Ok(VmStatus::Halted)) { + return Err("managed pure root did not halt".into()); + } + let callable = vm + .resolve_exported_callable(entry) + .map_err(|e| e.to_string())?; + let mut invocation = vm + .start_invocation(callable, vec![json_to_vm_value(&serde_json::json!({}))]) + .map_err(|e| e.to_string())?; + let mut cx = Context::from_waker(Waker::noop()); + match invocation + .poll_next_with_context(&mut cx) + .map_err(|e| e.to_string())? + { + InvocationPoll::Ready(Some(Ok(InvocationItem::Complete(value)))) => Ok(value), + _ => Err("managed pure invocation did not complete".into()), + } +} + /// Prepare immutable tool adapters against the original compile catalog. /// The sealed code retains this registry; each child binds fresh native state. pub(crate) fn build_pinned_registry( @@ -2980,3 +3018,25 @@ pub fn run(context: map) -> string { ); } } + +#[cfg(test)] +mod captured_pure_tests { + use super::*; + #[test] + fn captured_pure_vm_rejects_effects_events_and_fuel_exhaustion() { + let (program, _) = + compiled_source_program("pub fn run(context: map) -> map { { ok:true } }").unwrap(); + let value = eval_captured_pure_export(program, "run").unwrap(); + assert_eq!(vm_value_to_json(&value), serde_json::json!({"ok":true})); + for source in [ + "use stream; pub fn run(context: map) -> map { stream::emit(context); context }", + "use stream; stream::emit({}); pub fn run(context: map) -> map { context }", + "use cap; pub fn run(context: map) -> map { cap::clock_monotonic_ms(\"guest\"); context }", + "pub fn run(context: map) -> map { while true { } context }", + "while true { } pub fn run(context: map) -> map { context }", + ] { + let (program, _) = compiled_source_program(source).unwrap(); + assert!(eval_captured_pure_export(program, "run").is_err()); + } + } +} diff --git a/src/service.rs b/src/service.rs index 0462f39..a0e90f6 100644 --- a/src/service.rs +++ b/src/service.rs @@ -1349,9 +1349,19 @@ impl AgentService { let locked = control .load_lock(&active.graph_id) .map_err(|error| error.to_string())?; - store - .prepare(&locked.lock) - .map_err(|error| error.to_string())?; + // Initialization may prepare current code while retaining an old + // activation. Admission still rejects that historical active graph. + if store + .graph_has_historical_builtin(&locked.lock) + .map_err(|error| error.to_string())? + { + let current = Self::embedding_tool_graph(&store).map_err(|e| e.to_string())?; + store.prepare(¤t).map_err(|e| e.to_string())?; + } else { + store + .prepare(&locked.lock) + .map_err(|error| error.to_string())?; + } } Ok(()) } @@ -1675,18 +1685,23 @@ impl AgentService { if let Some(lock) = store.prepared_embedding_lock() { return Ok(lock); } - store.install_embedding_owned().map_err(|error| { + let current = store.install_embedding_owned().map_err(|error| { AdmitError::Persistence(format!("embedding bootstrap failed: {error}")) })?; - store - .resolve(&crate::plugins::ResolveRequest { + let captured = store + .read_installed(¤t.artifact_id) + .map_err(|error| AdmitError::Persistence(error.to_string()))?; + crate::plugins::resolve_installed( + &[captured], + &crate::plugins::ResolveRequest { roots: vec![crate::plugins::PackageRequirement { id: crate::runtime::native_tools::EMBEDDING_PACKAGE_ID.to_string(), version: "1.0.0".to_string(), }], host_runtime: crate::plugins::HOST_RUNTIME.to_string(), - }) - .map_err(|error| AdmitError::Persistence(format!("embedding resolve failed: {error}"))) + }, + ) + .map_err(|error| AdmitError::Persistence(format!("embedding resolve failed: {error}"))) } fn ensure_active_tool_graph( @@ -1775,6 +1790,14 @@ impl AgentService { *self.inner.date_source.write() = source; } + #[cfg(all(test, feature = "workspace-io-fault"))] + pub(crate) fn retained_tool_for_test( + &self, + run_id: &str, + ) -> Option { + self.handle(run_id)?.pinned_tool.clone() + } + /// Returns the immutable context captured at admission time. pub fn run_context(&self, run_id: &str) -> Option { if let Some(context) = self @@ -4014,7 +4037,18 @@ impl AgentService { .run_context(run_id) .map(Ok) .unwrap_or_else(|| self.resume_context(run_id))?; - let current_identity = self.inner.tool_registry.read().identity().to_string(); + // A live managed run retains its admitted snapshot and code; a future + // registry replacement cannot substitute authority under that run. + let current_identity = self + .handle(run_id) + .and_then(|handle| { + handle + .pinned_tool + .as_ref() + .and_then(|pin| pin.managed.as_ref()) + .map(|binding| binding.code.registry_identity.clone()) + }) + .unwrap_or_else(|| self.inner.tool_registry.read().identity().to_string()); verify_context_registry(&context, ¤t_identity)?; if let Some(snapshot) = self .inner @@ -5108,6 +5142,14 @@ impl AgentService { "managed captured effect contract missing".into(), ) })?, + ( + pin.managed_policy_catalog().ok_or_else(|| { + AdmitError::Invalid( + "managed captured policy catalog missing".into(), + ) + })?, + Arc::new(snapshot.registry.clone()), + ), cancellation.clone(), ) .map_err(AdmitError::Invalid)?, diff --git a/src/service/managed_protocol_tests.rs b/src/service/managed_protocol_tests.rs index 586667a..a5aaaa2 100644 --- a/src/service/managed_protocol_tests.rs +++ b/src/service/managed_protocol_tests.rs @@ -11,7 +11,13 @@ fn register_native_budget_intent( &format!("reference-budget-{index}"), "read_file", r#"{"path":"never-executed.txt"}"#, - "read", + binding + .seal_call_policy( + "read_file", + crate::capabilities::CapabilityRisk::Read, + r#"{"path":"never-executed.txt"}"#, + ) + .unwrap(), deadline_ms, ) .unwrap(); diff --git a/src/service/managed_review_tests.rs b/src/service/managed_review_tests.rs index 8e7534d..9be030f 100644 --- a/src/service/managed_review_tests.rs +++ b/src/service/managed_review_tests.rs @@ -51,7 +51,14 @@ async fn live_register_lookup_keeps_original_clock_and_never_enqueues_after_canc .push((enqueue, cancel.shares_original_clock(&clock))); } })); - let result = binding.register("lookup", "read_file", r#"{"path":"none"}"#, "read", 1); + let policy = binding + .seal_call_policy( + "read_file", + crate::capabilities::CapabilityRisk::Read, + r#"{"path":"none"}"#, + ) + .unwrap(); + let result = binding.register("lookup", "read_file", r#"{"path":"none"}"#, policy, 1); *persistence.managed_request_hook.lock().unwrap() = None; let rows = observed.lock().unwrap().clone(); results.push(( @@ -73,6 +80,201 @@ async fn live_register_lookup_keeps_original_clock_and_never_enqueues_after_canc } } +#[tokio::test(flavor = "multi_thread")] +async fn captured_a_survives_same_payload_b_activation_and_future_registry_change() { + let (root, state) = fixture(); + fs::write(root.join("workspace/original.txt"), b"captured A bytes").unwrap(); + let service = state.service(); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"retained A"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + let pin = service + .handle(&run.run_id) + .unwrap() + .pinned_tool + .clone() + .unwrap(); + let binding = pin.managed.as_ref().unwrap(); + let original_code = binding.code.clone(); + let original_clock = binding.cancel.deadline_instant(); + let original_snapshot = service.run_registry_snapshot(&run.run_id).unwrap(); + let store = service.tool_store().unwrap(); + let b = store + .resolve(&crate::plugins::ResolveRequest { + roots: vec![crate::plugins::PackageRequirement { + id: crate::runtime::native_tools::EMBEDDING_PACKAGE_ID.into(), + version: "1.0.0".into(), + }], + host_runtime: crate::plugins::HOST_RUNTIME.into(), + }) + .unwrap(); + assert_ne!(b.graph_id, original_code.graph_id); + let persistence = state.persistence().unwrap(); + let control = crate::plugins::PluginControlStore::new(&persistence); + control.store_lock(&b).unwrap(); + let refs: Vec<_> = b + .packages + .iter() + .map(|p| crate::plugins::PackageArtifactRef { + package_id: p.id.clone(), + version: p.version.clone(), + kind: p.kind.clone(), + artifact_id: p.artifact_id.clone(), + }) + .collect(); + control + .activate(&b.graph_id, original_code.activation_generation, &refs) + .unwrap(); + let mut descriptors = original_snapshot.descriptors().to_vec(); + let mut extra = descriptors[0].clone(); + extra.name = "future_extra_reader".into(); + descriptors.push(extra); + service + .set_tool_registry(crate::registry::ToolRegistry::from_descriptors(descriptors).unwrap()) + .unwrap(); + assert_ne!( + service.tool_registry_snapshot().identity(), + original_snapshot.identity() + ); + assert!( + binding + .seal_call_policy( + "read_file", + crate::capabilities::CapabilityRisk::Read, + r#"{"path":"original.txt"}"# + ) + .is_ok() + ); + assert!( + binding + .seal_call_policy( + "future_extra_reader", + crate::capabilities::CapabilityRisk::Read, + "{}" + ) + .is_err() + ); + let provider = Arc::new(crate::ScriptedProvider::new()); + provider.push_ok(response(Some( + json!({"id":"old-a","name":"read_file","arguments":{"path":"original.txt"}}), + ))); + provider.push_ok(response(None)); + service.inject_provider_host(provider); + service + .clone() + .run_worker(run.run_id.clone(), "ignored".into()) + .await; + let record = state + .persistence() + .unwrap() + .managed_effect_get(&ManagedEffectGetRequest { + owner: ManagedOwnerScope { + profile: "gateway".into(), + session_id: run.session_id.clone(), + run_id: run.run_id.clone(), + }, + call_id: "old-a".into(), + }) + .unwrap() + .unwrap_or_else(|| { + panic!( + "old A result absent: {:?}", + service + .run_events(&run.run_id) + .iter() + .map(|event| ( + &event["event"], + &event["data"]["code"], + &event["data"]["error_code"], + &event["data"]["message"] + )) + .collect::>() + ) + }); + assert_eq!(record.identity.code, original_code); + assert_eq!( + record.outcome.unwrap().disposition, + crate::effects::ManagedPublicationStatus::ReadOnly + ); + assert_eq!(binding.cancel.deadline_instant(), original_clock); + service.shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn historical_active_builtin_does_not_block_initialization_or_change_activation() { + let (root, state) = fixture(); + let config = state.service().inner.config.as_ref().clone(); + let service = state.service(); + let store = service.tool_store().unwrap(); + let old = store.install_3250_revision_for_test(); + let persistence = state.persistence().unwrap(); + let control = crate::plugins::PluginControlStore::new(&persistence); + let active = control.activation().unwrap(); + control.store_lock(&old).unwrap(); + let refs: Vec<_> = old + .packages + .iter() + .map(|p| crate::plugins::PackageArtifactRef { + package_id: p.id.clone(), + version: p.version.clone(), + kind: p.kind.clone(), + artifact_id: p.artifact_id.clone(), + }) + .collect(); + let historical = control + .activate(&old.graph_id, active.generation, &refs) + .unwrap(); + let old_bytes = fs::read( + root.join(".gateway.sqlite.tool-packages/artifacts") + .join(&old.packages[0].artifact_id) + .join("sources/rss/tools/types.rss"), + ) + .unwrap(); + assert!(store.prepare(&old).is_err()); + drop(store); + drop(persistence); + service.shutdown().await.unwrap(); + drop(service); + drop(state); + let reopened = AgentGatewayState::with_sqlite_path(config, root.join("gateway.sqlite")) + .unwrap() + .with_workspace_home(root.join("home")); + let persistence = reopened.persistence().unwrap(); + let control = crate::plugins::PluginControlStore::new(&persistence); + assert_eq!(control.activation().unwrap(), historical); + assert_eq!(control.package_refs(&old.graph_id).unwrap(), refs); + assert_eq!( + fs::read( + root.join(".gateway.sqlite.tool-packages/artifacts") + .join(&old.packages[0].artifact_id) + .join("sources/rss/tools/types.rss") + ) + .unwrap(), + old_bytes + ); + assert!( + reopened + .service() + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"historical fresh admission denied"}), + ..Default::default() + }, + None + ) + .await + .is_err() + ); + reopened.service().shutdown().await.unwrap(); +} + struct LiveClockReplayProvider { binding: Arc, step: std::sync::atomic::AtomicUsize, diff --git a/tests/fixtures/managed_builtin_3250_types.rss b/tests/fixtures/managed_builtin_3250_types.rss new file mode 100644 index 0000000..c3bfb12 --- /dev/null +++ b/tests/fixtures/managed_builtin_3250_types.rss @@ -0,0 +1,189 @@ +// Canonical model-facing tool descriptor contracts. +// Individual tool modules later own execute/validate; this module freezes the +// six current public names, descriptions, schemas, risk classes, and toolsets. + +pub fn map_string(value: map, key: string, fallback: string) -> string { + let mut result: string = fallback; + if value.has(key) { + if type(value[key]) == "string" { + let coerced: string = value[key]; + result = coerced; + } + } + result +} + +pub fn map_array(value: map, key: string) -> array { + let mut result: array = []; + if value.has(key) { + if type(value[key]) == "array" { + let coerced: array = value[key]; + result = coerced; + } + } + result +} + +pub fn map_map(value: map, key: string) -> map { + let mut result: map = {}; + if value.has(key) { + if type(value[key]) == "map" { + let coerced: map = value[key]; + result = coerced; + } + } + result +} + +pub fn descriptor_new( + name: string, + description: string, + toolset: string, + risk_class: string, + schema: map +) -> map { + { + name: name, + description: description, + toolset: toolset, + risk_class: risk_class, + schema: schema + } +} + +pub fn read_file_descriptor() -> map { + descriptor_new( + "read_file", + "Read bounded text from a workspace file", + "coding", + "read", + { + type: "object", + properties: { + path: { type: "string" }, + offset: { type: "integer", minimum: 1 }, + limit: { type: "integer", minimum: 1 } + }, + required: ["path"], + additionalProperties: false + } + ) +} + +pub fn search_files_descriptor() -> map { + descriptor_new( + "search_files", + "Search workspace files with bounded results", + "coding", + "read", + { + type: "object", + properties: { + pattern: { type: "string" }, + path: { type: "string" }, + target: { type: "string", enum: ["content", "files"] }, + file_glob: { type: "string" }, + limit: { type: "integer", minimum: 1 }, + offset: { type: "integer", minimum: 0 } + }, + required: ["pattern"], + additionalProperties: false + } + ) +} + +pub fn write_file_descriptor() -> map { + descriptor_new( + "write_file", + "Write complete workspace file contents", + "coding", + "write", + { + type: "object", + properties: { + path: { type: "string" }, + content: { type: "string" } + }, + required: ["path", "content"], + additionalProperties: false + } + ) +} + +pub fn patch_descriptor() -> map { + descriptor_new( + "patch", + "Apply a bounded workspace text patch", + "coding", + "write", + { + type: "object", + properties: { + path: { type: "string" }, + old_string: { type: "string" }, + new_string: { type: "string" }, + replace_all: { type: "boolean" } + }, + required: ["path", "old_string", "new_string"], + additionalProperties: false + } + ) +} + +pub fn terminal_descriptor() -> map { + descriptor_new( + "terminal", + "Run one bounded argv process", + "process", + "execute", + { + type: "object", + properties: { + argv: { type: "array", items: { type: "string" }, minItems: 1 }, + cwd: { type: "string" }, + timeout_ms: { type: "integer", minimum: 1 }, + max_output_bytes: { type: "integer", minimum: 1 }, + stdin: { type: "string" }, + background: { type: "boolean" } + }, + required: ["argv"], + additionalProperties: false + } + ) +} + +pub fn process_descriptor() -> map { + descriptor_new( + "process", + "Inspect one owned background process", + "process", + "execute", + { + type: "object", + properties: { + action: { + type: "string", + enum: ["poll", "wait", "log", "write", "close", "kill"] + }, + process_id: { type: "string" }, + data: { type: "string" }, + timeout_ms: { type: "integer", minimum: 1, maximum: 3600000 }, + offset: { type: "integer", minimum: 0 }, + limit: { type: "integer", minimum: 1 } + }, + required: ["action", "process_id"], + additionalProperties: false + } + ) +} + +pub fn catalog() -> array { + let mut tools: array = []; + tools[tools.length] = read_file_descriptor(); + tools[tools.length] = search_files_descriptor(); + tools[tools.length] = write_file_descriptor(); + tools[tools.length] = patch_descriptor(); + tools[tools.length] = terminal_descriptor(); + tools[tools.length] = process_descriptor(); + tools +} From d70223948bc86e5d4d47fced3985d3eb42c02b0f Mon Sep 17 00:00:00 2001 From: fffonion Date: Mon, 19 Oct 2026 17:29:18 +0800 Subject: [PATCH 11/21] test: pin the published SQLite SDK and cover legal triggers Update rustscript-vm and pd-host-function to b5066dd92fcd7c07df57e3ec2c29dbcf8c19c390 so one CREATE TRIGGER is accepted through the production AgentRunner policy. Multi-statement SQL stays rejected. --- Cargo.lock | 6 +- Cargo.toml | 4 +- tests/sqlite_sdk_contract_tests.rs | 273 +++++++++++++++++++++++++++++ 3 files changed, 278 insertions(+), 5 deletions(-) create mode 100644 tests/sqlite_sdk_contract_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 9ac032f..efc7a32 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -974,7 +974,7 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pd-host-function" version = "0.1.0" -source = "git+https://github.com/rustscript-lang/rustscript.git?rev=d9387f98df78dd88d2d1c04eebac4415f844efaf#d9387f98df78dd88d2d1c04eebac4415f844efaf" +source = "git+https://github.com/rustscript-lang/rustscript.git?rev=b5066dd92fcd7c07df57e3ec2c29dbcf8c19c390#b5066dd92fcd7c07df57e3ec2c29dbcf8c19c390" dependencies = [ "pd-host-schema", "proc-macro2", @@ -985,7 +985,7 @@ dependencies = [ [[package]] name = "pd-host-schema" version = "0.1.0" -source = "git+https://github.com/rustscript-lang/rustscript.git?rev=d9387f98df78dd88d2d1c04eebac4415f844efaf#d9387f98df78dd88d2d1c04eebac4415f844efaf" +source = "git+https://github.com/rustscript-lang/rustscript.git?rev=b5066dd92fcd7c07df57e3ec2c29dbcf8c19c390#b5066dd92fcd7c07df57e3ec2c29dbcf8c19c390" dependencies = [ "proc-macro2", "syn 2.0.119", @@ -994,7 +994,7 @@ dependencies = [ [[package]] name = "pd-vm" version = "0.1.0" -source = "git+https://github.com/rustscript-lang/rustscript.git?rev=d9387f98df78dd88d2d1c04eebac4415f844efaf#d9387f98df78dd88d2d1c04eebac4415f844efaf" +source = "git+https://github.com/rustscript-lang/rustscript.git?rev=b5066dd92fcd7c07df57e3ec2c29dbcf8c19c390#b5066dd92fcd7c07df57e3ec2c29dbcf8c19c390" dependencies = [ "base64 0.22.1", "futures-channel", diff --git a/Cargo.toml b/Cargo.toml index 8e84e04..a0c25e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,8 +25,8 @@ hyper = { version = "1", features = ["client", "http1"] } hyper-util = { version = "0.1", features = ["client-legacy", "http1", "tokio"] } parking_lot = "0.12" rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] } -rustscript-vm = { package = "pd-vm", git = "https://github.com/rustscript-lang/rustscript.git", rev = "d9387f98df78dd88d2d1c04eebac4415f844efaf", default-features = false, features = ["runtime", "http-client", "sqlite"] } -pd-host-function = { git = "https://github.com/rustscript-lang/rustscript.git", rev = "d9387f98df78dd88d2d1c04eebac4415f844efaf" } +rustscript-vm = { package = "pd-vm", git = "https://github.com/rustscript-lang/rustscript.git", rev = "b5066dd92fcd7c07df57e3ec2c29dbcf8c19c390", default-features = false, features = ["runtime", "http-client", "sqlite"] } +pd-host-function = { git = "https://github.com/rustscript-lang/rustscript.git", rev = "b5066dd92fcd7c07df57e3ec2c29dbcf8c19c390" } serde = { version = "1", features = ["derive"] } serde_json = "1" serde_yaml = "0.9" diff --git a/tests/sqlite_sdk_contract_tests.rs b/tests/sqlite_sdk_contract_tests.rs new file mode 100644 index 0000000..9cc57b0 --- /dev/null +++ b/tests/sqlite_sdk_contract_tests.rs @@ -0,0 +1,273 @@ +//! Formal Agent SQLite contract for the pinned SDK. +//! +//! These cases use the same `AgentConfig` construction as production +//! `GatewayPersistence`: gateway HTTP/SQLite policy and fuel, plus the +//! database root. They do not install a private runtime, open a Rust SQLite +//! connection, or read database bytes. Schema version 7 and checkpoint +//! restore are outside this contract. + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use rustscript_agent::{AgentConfig, AgentGatewayConfig, AgentRunner}; +use rustscript_vm::Value; + +const REVISION_TRIGGER: &str = "CREATE TRIGGER revision_insert AFTER INSERT ON messages BEGIN UPDATE revisions SET revision=revision+1; END"; +const TRIGGER_TAIL: &str = "CREATE TRIGGER revision_insert AFTER INSERT ON messages BEGIN UPDATE revisions SET revision=revision+1; END; DROP TABLE messages"; +const MULTIPLE_SELECTS: &str = "SELECT 1; SELECT 2"; + +fn private_root(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system clock should be after the Unix epoch") + .as_nanos(); + let root = + PathBuf::from(std::env::var_os("RUSTSCRIPT_AGENT_TEST_TMP").expect("required test root")) + .join(format!("{label}-{}-{nonce}", std::process::id())); + fs::create_dir_all(&root).expect("private fixture root"); + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).expect("private fixture mode"); + root +} + +fn persistence_config(root: &Path) -> AgentConfig { + let gateway = AgentGatewayConfig::default(); + assert!( + !gateway.sqlite.allow_unsafe_sql, + "production persistence policy must keep unsafe SQL disabled" + ); + AgentConfig { + http: gateway.http, + sqlite: gateway.sqlite, + fuel: gateway.fuel, + } + .with_sqlite_root(root) +} + +fn run_int(root: &Path, database: &str, source: &str) -> Result { + let runner = AgentRunner::from_source(source, persistence_config(root)) + .map_err(|error| format!("compile: {error}"))?; + match runner.run_with_context(Value::string(database)) { + Ok(Value::Int(value)) => Ok(value), + Ok(other) => Err(format!("unexpected result {other:?}")), + Err(error) => Err(error.to_string()), + } +} + +fn execute_trigger_source() -> String { + format!( + r#"use sqlite; +pub fn run(path: string) -> int {{ + let db = sqlite::open({{ path: path, mode: "read_write_create" }}); + sqlite::execute(&db, "CREATE TABLE messages (id INTEGER)", []); + sqlite::execute(&db, "CREATE TABLE revisions (revision INTEGER)", []); + sqlite::execute(&db, "INSERT INTO revisions VALUES (0)", []); + sqlite::execute(&db, "{REVISION_TRIGGER}", []); + sqlite::execute(&db, "INSERT INTO messages VALUES (1)", []); + let result = sqlite::query(&db, "SELECT revision FROM revisions", [], {{}}); + let revision = result.rows.copy()[0].cells.copy()[0].int_value; + sqlite::close(db); + revision +}} +"# + ) +} + +fn transaction_trigger_source() -> String { + format!( + r#"use sqlite; +pub fn run(path: string) -> int {{ + let db = sqlite::open({{ path: path, mode: "read_write_create" }}); + let results = sqlite::transaction(&db, [ + {{ sql: "CREATE TABLE messages (id INTEGER)", params: [], query: false, limits: {{}} }}, + {{ sql: "CREATE TABLE revisions (revision INTEGER)", params: [], query: false, limits: {{}} }}, + {{ sql: "INSERT INTO revisions VALUES (0)", params: [], query: false, limits: {{}} }}, + {{ sql: "{REVISION_TRIGGER}", params: [], query: false, limits: {{}} }}, + {{ sql: "INSERT INTO messages VALUES (1)", params: [], query: false, limits: {{}} }}, + {{ sql: "SELECT revision FROM revisions", params: [], query: true, limits: {{}} }} + ]); + let last: SqliteTransactionResult = results[5].copy(); + let queried: SqliteQueryResult = last.query; + let observed = queried.rows.copy()[0].cells.copy()[0].int_value; + let committed = sqlite::query(&db, "SELECT revision FROM revisions", [], {{}}); + let durable = committed.rows.copy()[0].cells.copy()[0].int_value; + sqlite::close(db); + if observed == durable => {{ observed }} else => {{ -1 }} +}} +"# + ) +} + +fn schema_source() -> &'static str { + r#"use sqlite; +pub fn run(path: string) -> int { + let db = sqlite::open({ path: path, mode: "read_write_create" }); + sqlite::execute(&db, "CREATE TABLE messages (id INTEGER)", []); + sqlite::execute(&db, "CREATE TABLE revisions (revision INTEGER)", []); + sqlite::execute(&db, "INSERT INTO revisions VALUES (0)", []); + sqlite::close(db); + 0 +} +"# +} + +fn execute_tail_source() -> String { + format!( + r#"use sqlite; +pub fn run(path: string) -> int {{ + let db = sqlite::open({{ path: path, mode: "read_write" }}); + sqlite::execute(&db, "{TRIGGER_TAIL}", []); + sqlite::close(db); + 1 +}} +"# + ) +} + +fn transaction_tail_source() -> String { + format!( + r#"use sqlite; +pub fn run(path: string) -> int {{ + let db = sqlite::open({{ path: path, mode: "read_write" }}); + sqlite::transaction(&db, [ + {{ sql: "{TRIGGER_TAIL}", params: [], query: false, limits: {{}} }} + ]); + sqlite::close(db); + 1 +}} +"# + ) +} + +fn residue_source() -> &'static str { + r#"use sqlite; +pub fn run(path: string) -> int { + let db = sqlite::open({ path: path, mode: "read_write" }); + let triggers = sqlite::query(&db, "SELECT count(*) FROM sqlite_schema WHERE type='trigger'", [], {}); + let rows = sqlite::query(&db, "SELECT count(*) FROM messages", [], {}); + let revision = sqlite::query(&db, "SELECT revision FROM revisions", [], {}); + let trigger_count = triggers.rows.copy()[0].cells.copy()[0].int_value; + let row_count = rows.rows.copy()[0].cells.copy()[0].int_value; + let revision_value = revision.rows.copy()[0].cells.copy()[0].int_value; + sqlite::close(db); + trigger_count * 100 + row_count * 10 + revision_value +} +"# +} + +fn query_selects_source() -> String { + format!( + r#"use sqlite; +pub fn run(path: string) -> int {{ + let db = sqlite::open({{ path: path, mode: "read_write_create" }}); + sqlite::query(&db, "{MULTIPLE_SELECTS}", [], {{}}); + sqlite::close(db); + 1 +}} +"# + ) +} + +fn transaction_selects_source() -> String { + format!( + r#"use sqlite; +pub fn run(path: string) -> int {{ + let db = sqlite::open({{ path: path, mode: "read_write_create" }}); + sqlite::transaction(&db, [ + {{ sql: "{MULTIPLE_SELECTS}", params: [], query: true, limits: {{}} }} + ]); + sqlite::close(db); + 1 +}} +"# + ) +} + +fn ordinary_select_source() -> &'static str { + r#"use sqlite; +pub fn run(path: string) -> int { + let db = sqlite::open({ path: path, mode: "read_write_create" }); + let result = sqlite::query(&db, "SELECT 1", [], {}); + let value = result.rows.copy()[0].cells.copy()[0].int_value; + sqlite::close(db); + value +} +"# +} + +fn expect_multiple_statements(error: &str) { + assert!( + error.contains("multiple SQLite statements are not allowed"), + "{error}" + ); +} + +#[test] +fn formal_sdk_execute_trigger_updates_revision() { + let root = private_root("sdk-execute-trigger"); + let revision = run_int(&root, "execute.sqlite", &execute_trigger_source()) + .expect("one legal trigger must execute, fire, and return the stored revision"); + assert_eq!( + revision, 1, + "insert must update the stored revision through the trigger" + ); +} + +#[test] +fn formal_sdk_transaction_trigger_updates_revision() { + let root = private_root("sdk-transaction-trigger"); + let revision = run_int(&root, "transaction.sqlite", &transaction_trigger_source()) + .expect("schema-dependent trigger transaction must commit and return the stored revision"); + assert_eq!( + revision, 1, + "transaction insert must update the stored revision, matching the committed query" + ); +} + +fn reject_tail(label: &str, source: &str) { + let root = private_root(label); + let prepared = run_int(&root, "fixture.sqlite", schema_source()).expect("schema setup"); + assert_eq!(prepared, 0); + let rejected = + run_int(&root, "fixture.sqlite", source).expect_err("trigger tail must be rejected"); + expect_multiple_statements(&rejected); + let residue = run_int(&root, "fixture.sqlite", residue_source()).expect("residue query"); + assert_eq!( + residue, 0, + "rejected tail must not create a trigger, drop the table, or change revision" + ); +} + +#[test] +fn formal_sdk_execute_rejects_trigger_tail_without_partial_ddl() { + reject_tail("sdk-execute-tail", &execute_tail_source()); +} + +#[test] +fn formal_sdk_transaction_rejects_trigger_tail_without_partial_ddl() { + reject_tail("sdk-transaction-tail", &transaction_tail_source()); +} + +fn reject_selects(label: &str, source: &str) { + let root = private_root(label); + let rejected = + run_int(&root, "selects.sqlite", source).expect_err("two selects must be rejected"); + expect_multiple_statements(&rejected); + let ordinary = + run_int(&root, "selects.sqlite", ordinary_select_source()).expect("ordinary select"); + assert_eq!( + ordinary, 1, + "rejecting two selects must leave a single select unchanged" + ); +} + +#[test] +fn formal_sdk_query_rejects_multiple_selects_without_rows() { + reject_selects("sdk-query-selects", &query_selects_source()); +} + +#[test] +fn formal_sdk_transaction_rejects_multiple_selects_without_rows() { + reject_selects("sdk-transaction-selects", &transaction_selects_source()); +} From 007da8bc603c691ba86b2ff24cf3951a190e8e16 Mon Sep 17 00:00:00 2001 From: fffonion Date: Mon, 19 Oct 2026 18:26:48 +0800 Subject: [PATCH 12/21] fix: validate managed documents without an identity map_err Keep one validation, the original parser error, and the fault-only rejection log. --- src/effects/journal.rs | 81 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 77 insertions(+), 4 deletions(-) diff --git a/src/effects/journal.rs b/src/effects/journal.rs index 16a0efa..2a69607 100644 --- a/src/effects/journal.rs +++ b/src/effects/journal.rs @@ -365,11 +365,12 @@ impl ManagedEffectRepository { .and_then(Value::as_str) .filter(|text| !text.is_empty()) { - managed_document(text).map_err(|error| { - #[cfg(feature = "workspace-io-fault")] + let validation = managed_document(text); + #[cfg(feature = "workspace-io-fault")] + let validation = validation.inspect_err(|error| { eprintln!("managed document validation rejected {field}: {error}"); - error - })?; + }); + validation?; } } self.persistence @@ -686,3 +687,75 @@ pub(crate) fn recover_managed_effects(persistence: &GatewayPersistence) -> Resul } Err("managed recovery work bound reached; startup unavailable with pending records".into()) } + +#[cfg(test)] +mod document_gate { + use super::*; + use crate::gateway::store::GatewayPersistence; + use std::os::unix::fs::PermissionsExt; + use std::sync::Arc; + + fn open_repository() -> (Arc, ManagedEffectRepository) { + let root = + std::path::PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()).join( + format!("journal-document-gate-{}", uuid::Uuid::new_v4().simple()), + ); + std::fs::create_dir(&root).unwrap(); + std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap(); + let persistence = GatewayPersistence::open( + &crate::AgentGatewayConfig::default(), + &root.join("state.sqlite"), + ) + .unwrap(); + persistence.load().unwrap(); + let persistence = Arc::new(persistence); + let repository = ManagedEffectRepository::new(Arc::clone(&persistence)); + (persistence, repository) + } + + #[test] + fn invalid_document_is_rejected_before_storage_with_the_parser_error_and_a_valid_document_reaches_the_worker() + { + let (persistence, repository) = open_repository(); + let cancellation = crate::RunCancellation::with_timeout(std::time::Duration::from_secs(5)); + let deadline = cancellation.deadline_instant(); + assert!(deadline.is_some()); + assert!(cancellation.requested().is_none()); + let invalid = "format: agent.managed-effect/v2\n"; + let expected = parse_managed_effect_yaml(invalid).unwrap_err(); + assert_eq!(expected, "managed format unsupported"); + let before = persistence.executed_storage_commands(); + for field in ["record_json", "prepared_json", "workspace_json"] { + let mut payload = json!({}); + payload[field] = json!(invalid); + let actual = repository + .command_with_cancellation("effect.workspace_get", &payload, &cancellation) + .unwrap_err(); + assert_eq!(actual, expected, "{field}"); + assert_eq!( + persistence.executed_storage_commands(), + before, + "{field} must not enter the SDK worker" + ); + } + assert!(cancellation.requested().is_none()); + assert_eq!(cancellation.deadline_instant(), deadline); + let valid = "note: kept\n"; + assert!(managed_document(valid).is_ok()); + let reached = repository.command_with_cancellation( + "effect.workspace_get", + &json!({"record_json": valid}), + &cancellation, + ); + assert!( + persistence.executed_storage_commands() > before, + "a document accepted by managed validation must reach the SDK worker; result={reached:?}" + ); + if let Err(error) = reached { + assert_ne!(error, expected); + } + assert!(cancellation.requested().is_none()); + assert_eq!(cancellation.deadline_instant(), deadline); + persistence.shutdown(); + } +} From ec81c250977ba71b6cbb97e2886d4168f566de10 Mon Sep 17 00:00:00 2001 From: fffonion Date: Mon, 19 Oct 2026 19:03:33 +0800 Subject: [PATCH 13/21] test: compile lease_existing_for_test only with its caller The helper is not a test. Its only caller is managed_authority_tests, included under the same workspace-io-fault cfg. Default unit builds otherwise reject the unused definition. --- src/effects/retention.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/effects/retention.rs b/src/effects/retention.rs index 213c421..525bd68 100644 --- a/src/effects/retention.rs +++ b/src/effects/retention.rs @@ -79,7 +79,7 @@ pub(crate) struct ManagedObjectLease { } impl ManagedObjectLeaseRegistry { - #[cfg(test)] + #[cfg(all(test, feature = "workspace-io-fault"))] pub(crate) fn lease_existing_for_test( self: &Arc, id: &str, From 2f2a14e969ea4d5c5f1df7b19300d445f6885c6f Mon Sep 17 00:00:00 2001 From: fffonion Date: Mon, 19 Oct 2026 19:56:57 +0800 Subject: [PATCH 14/21] test: expect the reviewed SDK pin b5066dd92fcd7c07df57e3ec2c29dbcf8c19c390 The pin guard still required d9387f98 after the authorized SDK upgrade. Keep the full SHA, HTTPS remote, three lock sources, stale B1 rejection, and abbreviation checks, and reject the prior SDK explicitly. --- tests/dependency_pin_tests.rs | 82 ++++++++++++++++++++++++++++------- 1 file changed, 66 insertions(+), 16 deletions(-) diff --git a/tests/dependency_pin_tests.rs b/tests/dependency_pin_tests.rs index 4a13ecc..30d067b 100644 --- a/tests/dependency_pin_tests.rs +++ b/tests/dependency_pin_tests.rs @@ -2,19 +2,22 @@ //! //! This test deliberately uses only `std` so it can also be compiled directly //! with `rustc --test` when a broken local path dependency prevents Cargo from -//! resolving the workspace: +//! resolving the workspace. `env!("CARGO_MANIFEST_DIR")` reads the compiler +//! process environment; rustc 1.99 has no `--env` flag: //! //! ```bash -//! rustc --test --env CARGO_MANIFEST_DIR=$PWD \ +//! CARGO_MANIFEST_DIR=$PWD rustc --test \ //! tests/dependency_pin_tests.rs -o /mnt/TEMP/rustscript/dependency-pin-tests/pin-direct //! ``` use std::path::PathBuf; const RUSTSCRIPT_GIT: &str = "https://github.com/rustscript-lang/rustscript.git"; -const RUSTSCRIPT_REV: &str = "d9387f98df78dd88d2d1c04eebac4415f844efaf"; +const RUSTSCRIPT_REV: &str = "b5066dd92fcd7c07df57e3ec2c29dbcf8c19c390"; const STALE_REV: &str = "b1d6cffede77f49410bf63525f30b9a46b02dc01"; -const ABBREVIATED_REV: &str = "d9387f9"; +const PRIOR_SDK_REV: &str = "d9387f98df78dd88d2d1c04eebac4415f844efaf"; +const ABBREVIATED_REV: &str = "b5066dd"; +const PRIOR_ABBREVIATED_REV: &str = "d9387f9"; fn manifest() -> String { std::fs::read_to_string(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml")) @@ -49,6 +52,14 @@ fn pd_vm_uses_the_reviewed_immutable_git_revision() { dependency.contains(&format!("git = \"{RUSTSCRIPT_GIT}\"")), "rustscript-vm must use the canonical HTTPS Git remote: {dependency}" ); + assert!( + !dependency.contains(PRIOR_SDK_REV), + "rustscript-vm must not remain on the prior SDK SHA {PRIOR_SDK_REV}: {dependency}" + ); + assert!( + !dependency.contains(STALE_REV), + "rustscript-vm must not mention the stale pin {STALE_REV}: {dependency}" + ); assert!( dependency.contains(&format!("rev = \"{RUSTSCRIPT_REV}\"")), "rustscript-vm must pin the reviewed full commit: {dependency}" @@ -74,22 +85,34 @@ fn pd_vm_pin_is_the_exact_full_sha_and_rejects_stale_or_abbreviated_pins() { rev.chars().all(|ch| ch.is_ascii_hexdigit()), "rustscript-vm rev must be hexadecimal: {rev}" ); - assert_eq!( - rev, RUSTSCRIPT_REV, - "rustscript-vm must pin the frozen full SHA {RUSTSCRIPT_REV}, got {rev}" - ); assert_ne!( - rev, ABBREVIATED_REV, - "rustscript-vm must not pin the abbreviated SHA {ABBREVIATED_REV}" + rev, PRIOR_SDK_REV, + "rustscript-vm must not remain on the prior SDK SHA {PRIOR_SDK_REV}" ); assert_ne!( rev, STALE_REV, "rustscript-vm must not remain on the stale SHA {STALE_REV}" ); + assert_ne!( + rev, ABBREVIATED_REV, + "rustscript-vm must not pin the abbreviated SHA {ABBREVIATED_REV}" + ); + assert_eq!( + rev, RUSTSCRIPT_REV, + "rustscript-vm must pin the frozen full SHA {RUSTSCRIPT_REV}, got {rev}" + ); assert!( !dependency.contains(&format!("rev = \"{ABBREVIATED_REV}\"")), "rustscript-vm must not use an abbreviated rev literal: {dependency}" ); + assert!( + !dependency.contains(&format!("rev = \"{PRIOR_ABBREVIATED_REV}\"")), + "rustscript-vm must not use the prior SDK abbreviated rev: {dependency}" + ); + assert!( + !dependency.contains(PRIOR_SDK_REV), + "rustscript-vm must not mention the prior SDK pin {PRIOR_SDK_REV}: {dependency}" + ); assert!( !dependency.contains(STALE_REV), "rustscript-vm must not mention the stale pin {STALE_REV}: {dependency}" @@ -109,6 +132,14 @@ fn pd_host_function_uses_the_same_frozen_full_sha() { "pd-host-function must use the canonical HTTPS Git remote: {dependency}" ); let rev = quoted_rev(dependency, "rev").expect("pd-host-function must declare rev"); + assert_ne!( + rev, PRIOR_SDK_REV, + "pd-host-function must not remain on the prior SDK SHA {PRIOR_SDK_REV}" + ); + assert_ne!( + rev, STALE_REV, + "pd-host-function must not remain on the stale SHA {STALE_REV}" + ); assert_eq!( rev, RUSTSCRIPT_REV, "pd-host-function must pin the same frozen full SHA as rustscript-vm: {dependency}" @@ -130,10 +161,16 @@ fn pd_vm_and_pd_host_function_lock_sources_are_canonical_https_at_the_pinned_rev // revision, and the `#` checkout suffix. let canonical = format!("git+{RUSTSCRIPT_GIT}?rev={RUSTSCRIPT_REV}#{RUSTSCRIPT_REV}"); let stale = format!("git+{RUSTSCRIPT_GIT}?rev={STALE_REV}#{STALE_REV}"); - // The full SHA has `b1d6cff` as a prefix, so a substring `rev=b1d6cff` is not - // enough. An abbreviated pin would be `rev=b1d6cff"` or `rev=b1d6cff#`. + let prior = format!("git+{RUSTSCRIPT_GIT}?rev={PRIOR_SDK_REV}#{PRIOR_SDK_REV}"); + // `b5066dd` is a prefix of the reviewed full SHA, so a bare substring + // `rev=b5066dd` also matches the canonical pin. An abbreviated pin is + // terminated by a quote or a fragment marker. The prior SDK abbreviation + // is rejected the same way; it is not a prefix of the reviewed SHA. The + // stale full SHA `b1d6cffede77...` is rejected separately and is not that prefix. let abbreviated_quoted = format!("rev={ABBREVIATED_REV}\""); let abbreviated_fragment = format!("rev={ABBREVIATED_REV}#"); + let prior_abbreviated_quoted = format!("rev={PRIOR_ABBREVIATED_REV}\""); + let prior_abbreviated_fragment = format!("rev={PRIOR_ABBREVIATED_REV}#"); for package in ["pd-vm", "pd-host-schema", "pd-host-function"] { let block = lockfile @@ -144,10 +181,13 @@ fn pd_vm_and_pd_host_function_lock_sources_are_canonical_https_at_the_pinned_rev .lines() .find(|line| line.starts_with("source = ")) .unwrap_or_else(|| panic!("Cargo.lock {package} must declare a source")); - assert_eq!( - source.trim(), - format!("source = \"{canonical}\""), - "Cargo.lock {package} must use the canonical HTTPS source at the pinned full rev" + assert!( + !block.contains(&prior), + "Cargo.lock {package} must not resolve the prior SDK SHA {PRIOR_SDK_REV}" + ); + assert!( + !block.contains(PRIOR_SDK_REV), + "Cargo.lock {package} must not mention the prior SDK SHA {PRIOR_SDK_REV}" ); assert!( !block.contains(&stale), @@ -157,5 +197,15 @@ fn pd_vm_and_pd_host_function_lock_sources_are_canonical_https_at_the_pinned_rev !source.contains(&abbreviated_quoted) && !source.contains(&abbreviated_fragment), "Cargo.lock {package} must not resolve an abbreviated SHA: {source}" ); + assert!( + !source.contains(&prior_abbreviated_quoted) + && !source.contains(&prior_abbreviated_fragment), + "Cargo.lock {package} must not resolve the prior SDK abbreviated SHA: {source}" + ); + assert_eq!( + source.trim(), + format!("source = \"{canonical}\""), + "Cargo.lock {package} must use the canonical HTTPS source at the pinned full rev" + ); } } From 49679c40d19fb50ad8b118887b3da8a261624d74 Mon Sep 17 00:00:00 2001 From: fffonion Date: Mon, 19 Oct 2026 20:16:46 +0800 Subject: [PATCH 15/21] docs: align deployment guide with the reviewed SDK pin --- docs/deployment.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/deployment.md b/docs/deployment.md index 5c82d3c..9cbfc65 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -8,7 +8,7 @@ treated as available. ## 1. Delivery constraint: the core dependency is not released `rustscript-agent` depends on the RustScript VM through a **pinned Git -dependency**: `rustscript-vm = { package = "pd-vm", git = "https://github.com/rustscript-lang/rustscript.git", rev = "d9387f98df78dd88d2d1c04eebac4415f844efaf", ... }` in `Cargo.toml`. +dependency**: `rustscript-vm = { package = "pd-vm", git = "https://github.com/rustscript-lang/rustscript.git", rev = "b5066dd92fcd7c07df57e3ec2c29dbcf8c19c390", ... }` in `Cargo.toml`. Consequences, stated plainly: - A build fetches the exact pinned core revision from the canonical HTTPS @@ -18,7 +18,7 @@ Consequences, stated plainly: - `cargo build/test --locked` resolves the core source from `Cargo.lock`, which records the canonical git source for both `pd-vm` and `pd-host-function`: - `git+https://github.com/rustscript-lang/rustscript.git?rev=d9387f98df78dd88d2d1c04eebac4415f844efaf#d9387f98df78dd88d2d1c04eebac4415f844efaf`. + `git+https://github.com/rustscript-lang/rustscript.git?rev=b5066dd92fcd7c07df57e3ec2c29dbcf8c19c390#b5066dd92fcd7c07df57e3ec2c29dbcf8c19c390`. A build therefore needs network access to github.com (or a pre-warmed Cargo git cache); it never reads the state of a local core checkout. - **There is no crates.io release of this crate, and none can be made until @@ -46,7 +46,7 @@ before deploying: ```bash # The lockfile records the exact git source for pd-vm and pd-host-function: grep -A3 'name = "pd-vm"' Cargo.lock -# source = "git+https://github.com/rustscript-lang/rustscript.git?rev=d9387f9…#d9387f9…" +# source = "git+https://github.com/rustscript-lang/rustscript.git?rev=b5066dd…#b5066dd…" # The resolved dependency graph shows the same source: cargo tree --locked -p rustscript-vm ``` From 3938fe5b73c2946ed4e4ea905181cc8ed26a09db Mon Sep 17 00:00:00 2001 From: fffonion Date: Mon, 19 Oct 2026 22:03:27 +0800 Subject: [PATCH 16/21] fix: refuse provider calls cancelled after the request commit Recheck the same RunCancellation after the crash seam and before the external call. Keep the committed model.requested row and return the existing typed cancel or deadline failure. --- src/durable_provider.rs | 352 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 352 insertions(+) diff --git a/src/durable_provider.rs b/src/durable_provider.rs index 96d8817..8ab213f 100644 --- a/src/durable_provider.rs +++ b/src/durable_provider.rs @@ -22,6 +22,7 @@ use crate::domain::{LlmContentBlock, MAX_DURABLE_TEXT_CHARS, Usage}; use crate::events::EventCommitError; use crate::metrics::Metrics; use crate::runtime::agent_host::{provider_error_is_retryable, typed_fail}; +use crate::runtime::cancellation::CancellationReason; use crate::runtime::rss_runner::RunCancellation; use crate::service::{AgentService, PendingApprovalPreviewBinding, ProviderCommitOutcome}; use crate::{AgentProviderHost, ProviderPendingDecision}; @@ -385,6 +386,17 @@ impl AgentProviderHost for DurableProviderHost { self.service.mark_provider_commit_crashed(); panic!("provider_request_crash"); } + if let Some(reason) = cancellation.requested() { + return match reason { + CancellationReason::Deadline => { + typed_fail("deadline_elapsed", "run deadline elapsed") + } + _ => typed_fail("cancelled", "run was cancelled"), + }; + } + if cancellation.deadline_passed() { + return typed_fail("deadline_elapsed", "run deadline elapsed"); + } // The private replay address never reaches the provider transport. let mut transport_request; @@ -915,8 +927,13 @@ pub(crate) fn reconstruct_provider_envelope( #[cfg(test)] mod tests { use super::*; + use std::thread::JoinHandle; + use std::time::Instant; + + use crate::CancellationReason; use crate::events::EventCommitError; use crate::gateway::AgentGatewayState; + use crate::gateway::store::PersistBlockGuard; use crate::runtime::agent_host::error_is_retryable_code; use crate::{AdmitRunRequest, AgentGatewayConfig, AgentProviderHost, ScriptedProvider}; @@ -1727,4 +1744,339 @@ mod tests { drop(state); let _ = std::fs::remove_file(path); } + + struct SqliteGateway { + path: std::path::PathBuf, + state: Option, + run_id: String, + } + + impl SqliteGateway { + fn state(&self) -> &AgentGatewayState { + self.state.as_ref().expect("sqlite gateway") + } + } + + impl Drop for SqliteGateway { + fn drop(&mut self) { + self.state.take(); + let _ = std::fs::remove_file(&self.path); + let mut wal = self.path.as_os_str().to_os_string(); + wal.push("-wal"); + let _ = std::fs::remove_file(wal); + let mut shm = self.path.as_os_str().to_os_string(); + shm.push("-shm"); + let _ = std::fs::remove_file(shm); + } + } + + async fn sqlite_gateway() -> SqliteGateway { + let mut fixture = SqliteGateway { + path: temporary_db_path(), + state: None, + run_id: String::new(), + }; + let state = AgentGatewayState::with_agent_source_and_sqlite( + AgentGatewayConfig::default(), + "pub fn run(context: map) -> map { context; }", + &fixture.path, + ) + .expect("sqlite gateway"); + fixture.state = Some(state); + let admitted = fixture + .state() + .service() + .admit(AdmitRunRequest { + input: json!({"message": "hello"}), + platform: "durable_provider_tests".to_string(), + ..AdmitRunRequest::default() + }) + .await + .expect("admit"); + fixture.run_id = admitted.run_id; + fixture + } + + /// Releases the persist seam and joins the owned caller on every exit, + /// including an assertion panic before the explicit join. + struct OwnedPersistCall { + block: Option, + worker: Option>, + } + + impl OwnedPersistCall { + fn release_and_join(&mut self) -> Option> { + if let Some(block) = self.block.take() { + block.release(); + } + self.worker.take().map(JoinHandle::join) + } + } + + impl Drop for OwnedPersistCall { + fn drop(&mut self) { + let _ = self.release_and_join(); + } + } + + fn call_during_request_persist( + state: &AgentGatewayState, + run_id: &str, + inner: ScriptedProvider, + cancel: &RunCancellation, + arm: impl FnOnce(), + ) -> JsonValue { + let block = state.persistence().expect("sqlite").inject_block_persist(); + let host = host_for(state, run_id, inner); + let request = request(); + let worker_cancel = cancel.clone(); + let worker = std::thread::spawn(move || host.call(&request, &worker_cancel)); + let mut owned = OwnedPersistCall { + block: Some(block), + worker: Some(worker), + }; + owned.block.as_ref().expect("persist block").wait_entered(); + arm(); + match owned.release_and_join() { + Some(Ok(envelope)) => envelope, + Some(Err(payload)) => std::panic::resume_unwind(payload), + None => panic!("persist call worker missing"), + } + } + + fn refusal_facts( + state: &AgentGatewayState, + run_id: &str, + inner: &ScriptedProvider, + envelope: &JsonValue, + ) -> ( + u64, + usize, + usize, + usize, + usize, + JsonValue, + JsonValue, + JsonValue, + ) { + ( + inner.call_count(), + events_named(state, run_id, "model.requested").len(), + events_named(state, run_id, "model.failed").len(), + events_named(state, run_id, "model.completed").len(), + events_named(state, run_id, "interrupted_provider").len(), + envelope["ok"].clone(), + envelope["error"]["code"].clone(), + envelope["error"]["message"].clone(), + ) + } + + fn assert_fresh_request_kept_and_external_call_refused( + state: &AgentGatewayState, + run_id: &str, + inner: &ScriptedProvider, + before: &[JsonValue], + envelope: &JsonValue, + code: &str, + message: &str, + ) { + assert_eq!( + refusal_facts(state, run_id, inner, envelope), + (0, 1, 0, 0, 0, json!(false), json!(code), json!(message),), + "{envelope}" + ); + let events = state.service().run_events(run_id); + assert_eq!(events.len(), before.len() + 1, "{events:?}"); + assert_eq!(&events[..before.len()], before, "{events:?}"); + let added = events.last().expect("committed model.requested"); + assert_eq!(added["event"], json!("model.requested")); + assert_eq!( + added["event_id"], + json!(crate::domain::durable_provider_event_id( + run_id, + 1, + "model.requested" + )) + ); + assert_eq!(added["data"]["attempt"], json!(1)); + let previous_seq = before + .last() + .and_then(|event| event["seq"].as_u64()) + .unwrap_or(0); + let added_seq = added["seq"].as_u64().expect("seq"); + assert!(added_seq > previous_seq, "{events:?}"); + let mut seen = std::collections::HashSet::new(); + let mut last_seq = 0; + for event in &events { + assert!( + seen.insert(event["event_id"].as_str().expect("event id").to_string()), + "duplicate append {}", + event["event_id"] + ); + let seq = event["seq"].as_u64().expect("seq"); + assert!(seq > last_seq, "{events:?}"); + last_seq = seq; + } + } + + async fn refuse_fresh_request_during_persist( + reason: CancellationReason, + code: &str, + message: &str, + ) { + let fixture = sqlite_gateway().await; + let inner = ScriptedProvider::new(); + inner.push_ok(text_ok("must not run")); + let cancel = RunCancellation::new(); + let before = fixture.state().service().run_events(&fixture.run_id); + let envelope = call_during_request_persist( + fixture.state(), + &fixture.run_id, + inner.clone(), + &cancel, + || cancel.request(reason), + ); + assert!(cancel.deadline_instant().is_none()); + assert_eq!(cancel.requested(), Some(reason)); + assert_fresh_request_kept_and_external_call_refused( + fixture.state(), + &fixture.run_id, + &inner, + &before, + &envelope, + code, + message, + ); + } + + #[tokio::test] + async fn post_persist_cancel_requested_refuses_external_call() { + // Typed stop and user stop share CancellationReason::Requested. + // They are not separate variants. + refuse_fresh_request_during_persist( + CancellationReason::Requested, + "cancelled", + "run was cancelled", + ) + .await; + } + + #[tokio::test] + async fn post_persist_cancel_resource_closed_refuses_external_call() { + refuse_fresh_request_during_persist( + CancellationReason::ResourceClosed, + "cancelled", + "run was cancelled", + ) + .await; + } + + #[tokio::test] + async fn post_persist_cancel_deadline_request_refuses_external_call() { + refuse_fresh_request_during_persist( + CancellationReason::Deadline, + "deadline_elapsed", + "run deadline elapsed", + ) + .await; + } + + #[tokio::test] + async fn post_persist_cancel_elapsed_deadline_refuses_external_call() { + let fixture = sqlite_gateway().await; + let inner = ScriptedProvider::new(); + inner.push_ok(text_ok("must not run")); + let deadline = Instant::now(); + let cancel = RunCancellation::with_deadline(deadline); + assert!(cancel.requested().is_none()); + assert!(cancel.deadline_passed()); + let before = fixture.state().service().run_events(&fixture.run_id); + let host = host_for(fixture.state(), &fixture.run_id, inner.clone()); + let envelope = host.call(&request(), &cancel); + assert_eq!(cancel.deadline_instant(), Some(deadline)); + assert!(cancel.requested().is_none()); + assert_fresh_request_kept_and_external_call_refused( + fixture.state(), + &fixture.run_id, + &inner, + &before, + &envelope, + "deadline_elapsed", + "run deadline elapsed", + ); + } + + #[tokio::test] + async fn post_persist_cancel_retry_refuses_external_call() { + let fixture = sqlite_gateway().await; + let inner = ScriptedProvider::new(); + inner.push_error(retryable_error()); + inner.push_ok(text_ok("must not run")); + let host = host_for(fixture.state(), &fixture.run_id, inner.clone()); + let first = host.call(&request(), &RunCancellation::new()); + assert_eq!(first["ok"], json!(false), "{first}"); + assert_eq!(inner.call_count(), 1); + let before = fixture.state().service().run_events(&fixture.run_id); + assert_eq!( + events_named(fixture.state(), &fixture.run_id, "model.requested").len(), + 1 + ); + assert_eq!( + events_named(fixture.state(), &fixture.run_id, "model.failed").len(), + 1 + ); + let cancel = RunCancellation::new(); + cancel.request(CancellationReason::Requested); + let retry_host = host_for(fixture.state(), &fixture.run_id, inner.clone()); + let envelope = retry_host.call(&request(), &cancel); + assert_eq!( + refusal_facts(fixture.state(), &fixture.run_id, &inner, &envelope), + ( + 1, + 1, + 1, + 0, + 0, + json!(false), + json!("cancelled"), + json!("run was cancelled"), + ), + "{envelope}" + ); + assert_eq!( + fixture.state().service().run_events(&fixture.run_id), + before, + "retry refusal must not append or reorder" + ); + assert!(cancel.deadline_instant().is_none()); + assert_eq!(cancel.requested(), Some(CancellationReason::Requested)); + } + + #[tokio::test] + async fn post_persist_cancel_crash_seam_panics_before_refusal() { + let fixture = sqlite_gateway().await; + let inner = ScriptedProvider::new(); + inner.push_ok(text_ok("must not run")); + fixture + .state() + .service() + .inject_crash_after_provider_request(); + let host = host_for(fixture.state(), &fixture.run_id, inner.clone()); + let deadline = Instant::now(); + let cancel = RunCancellation::with_deadline(deadline); + let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + host.call(&request(), &cancel) + })); + assert!( + panicked.is_err(), + "crash seam must still run before an external-call refusal" + ); + assert_eq!(inner.call_count(), 0); + assert_eq!( + events_named(fixture.state(), &fixture.run_id, "model.requested").len(), + 1 + ); + assert_eq!(cancel.deadline_instant(), Some(deadline)); + assert!(cancel.requested().is_none()); + } } From 0a8db1c2e7d7d0d60ba1d38f4d05a5cfc73cf2c2 Mon Sep 17 00:00:00 2001 From: fffonion Date: Mon, 2 Nov 2026 16:38:48 +0800 Subject: [PATCH 17/21] feat(effects): publish reviewed graph and managed workspace slices Include accepted graph management, retained invocation and effect boundaries, managed checkpoint/branch runs, and manual workspace publication contracts. Publish the exact accepted 385 snapshot with prior scope reviews and 17 final commands covering 126 unique target/test pairs. Versioned plugin state, formal cross-package calls, and whole-plan acceptance remain pending. --- ...2026-10-17_plugin-graphs-effect-sandbox.md | 711 ++--- rss/agent/compact.rss | 13 + rss/storage/admission.rss | 145 +- rss/storage/effects.rss | 254 +- rss/storage/events.rss | 41 +- rss/storage/main.rss | 46 +- rss/storage/plugins.rss | 121 +- rss/storage/schema.rss | 10 +- rss/storage/sessions.rss | 11 +- schemas/README.md | 4 + schemas/canonical/effect-contract.schema.json | 4 +- .../canonical/managed-checkpoint.schema.json | 2149 ++++++++++++++ schemas/canonical/managed-restore.schema.json | 502 ++++ .../canonical/plugin-graph-lock.schema.json | 98 + schemas/canonical/plugin-manifest.schema.json | 71 + schemas/effect-contract.schema.yaml | 7 +- schemas/managed-checkpoint.schema.yaml | 2149 ++++++++++++++ schemas/managed-restore.schema.yaml | 502 ++++ schemas/plugin-graph-lock.schema.yaml | 31 + schemas/plugin-manifest.schema.yaml | 29 + src/bin/rustscript-agent-gateway.rs | 48 + src/capabilities/types.rs | 6 + src/config.rs | 13 + src/durable_provider.rs | 30 + src/effects/authority.rs | 8 + src/effects/broker.rs | 1014 ++++++- src/effects/journal.rs | 998 ++++++- src/effects/managed_authority_tests.rs | 34 +- .../managed_checkpoint_schema_tests.rs | 313 ++ .../managed_restore_authority_tests.rs | 1626 ++++++++++ src/effects/mod.rs | 20 +- src/effects/retention_tests.rs | 1 + src/events.rs | 2 + src/gateway/api_server.rs | 151 +- src/gateway/effects.rs | 86 + src/gateway/managed_storage_budget_tests.rs | 487 ++- src/gateway/mod.rs | 21 +- src/gateway/plugins.rs | 119 + src/gateway/store.rs | 1326 ++++++++- src/gateway/telegram.rs | 2 + src/gateway/workspaces.rs | 191 ++ src/plugins/control_store.rs | 241 +- src/plugins/error.rs | 1 + src/plugins/manifest.rs | 18 +- src/plugins/mod.rs | 22 +- src/plugins/pin.rs | 472 ++- src/plugins/prepare.rs | 896 +++++- src/plugins/resolve.rs | 520 +++- src/plugins/schema.rs | 58 + src/plugins/store.rs | 329 +- src/plugins/yaml.rs | 12 +- src/prompt/coding.rs | 5 +- src/prompt/mod.rs | 1 + src/runtime/agent_host.rs | 83 + src/runtime/native_tools.rs | 3 + src/runtime/rss_runner.rs | 17 + src/service.rs | 2638 +++++++++++++++-- src/service/managed_branches.rs | 225 ++ src/service/managed_checkpoints.rs | 1155 ++++++++ src/service/managed_effects.rs | 73 +- src/service/managed_effects_tests.rs | 8 + src/service/managed_graph_tests.rs | 857 ++++++ src/service/managed_linearization_tests.rs | 1205 ++++++++ src/service/managed_protocol_tests.rs | 2581 +++++++++++++++- src/service/managed_publications.rs | 426 +++ src/service/managed_review_tests.rs | 4 + src/service/managed_run_effect_tests.rs | 485 +++ src/service/plugin_admin.rs | 662 +++++ tests/agent_loop_tests.rs | 173 ++ tests/coding_agent_edge_e2e_tests.rs | 685 ++++- tests/effect_admin_api_tests.rs | 438 +++ tests/effect_commit_recovery_tests.rs | 99 +- tests/gateway_tests.rs | 4 + tests/gateway_workspace_tests.rs | 22 + tests/managed_checkpoint_api_tests.rs | 66 + tests/managed_checkpoint_restore_tests.rs | 1774 +++++++++++ tests/plugin_admin_api_tests.rs | 554 ++++ tests/plugin_effect_api_e2e_tests.rs | 1658 +++++++++++ tests/plugin_graph_tests.rs | 16 + tests/plugin_hot_reload_tests.rs | 639 ++++ tests/plugin_manifest_tests.rs | 13 +- tests/plugin_schema_sync_tests.rs | 81 + tests/plugin_store_recovery_tests.rs | 874 +++++- tests/prompt_tests.rs | 15 + tests/service_tests.rs | 18 + tests/support/plugin_admin_cycle.rs | 1915 ++++++++++++ tests/support/plugin_effect_api_cycle.rs | 664 +++++ tests/support/signed_pinned_variants.rs | 3 + tests/support/signed_tool_effects.rs | 193 ++ tests/tools_pin_invoke_tracer.rs | 22 + tests/tools_pin_production_ab.rs | 254 +- tests/tools_pinned_commit_outcome.rs | 4 + 92 files changed, 35609 insertions(+), 966 deletions(-) create mode 100644 schemas/canonical/managed-checkpoint.schema.json create mode 100644 schemas/canonical/managed-restore.schema.json create mode 100644 schemas/managed-checkpoint.schema.yaml create mode 100644 schemas/managed-restore.schema.yaml create mode 100644 src/effects/managed_checkpoint_schema_tests.rs create mode 100644 src/effects/managed_restore_authority_tests.rs create mode 100644 src/gateway/effects.rs create mode 100644 src/gateway/plugins.rs create mode 100644 src/gateway/workspaces.rs create mode 100644 src/service/managed_branches.rs create mode 100644 src/service/managed_checkpoints.rs create mode 100644 src/service/managed_graph_tests.rs create mode 100644 src/service/managed_linearization_tests.rs create mode 100644 src/service/managed_publications.rs create mode 100644 src/service/managed_run_effect_tests.rs create mode 100644 src/service/plugin_admin.rs create mode 100644 tests/effect_admin_api_tests.rs create mode 100644 tests/managed_checkpoint_api_tests.rs create mode 100644 tests/managed_checkpoint_restore_tests.rs create mode 100644 tests/plugin_admin_api_tests.rs create mode 100644 tests/plugin_effect_api_e2e_tests.rs create mode 100644 tests/plugin_hot_reload_tests.rs create mode 100644 tests/support/plugin_admin_cycle.rs create mode 100644 tests/support/plugin_effect_api_cycle.rs diff --git a/plans/2026-10-17_plugin-graphs-effect-sandbox.md b/plans/2026-10-17_plugin-graphs-effect-sandbox.md index e23ccdf..4e83803 100644 --- a/plans/2026-10-17_plugin-graphs-effect-sandbox.md +++ b/plans/2026-10-17_plugin-graphs-effect-sandbox.md @@ -1,76 +1,85 @@ # RSS 插件图与 Tools 调用虚拟化实施计划 -> **当前范围更新:** 按用户最新要求,停止自建 OS sandbox;后续只推进 tools 侧调用虚拟化。保留插件版本/依赖图、显式 effect、Native 调用身份与资源映射、托管工作区/插件状态的 checkpoint 和恢复。进程沿用现有后端及有界取消/清理,不宣称具备新的 OS 安全隔离。Linux x86_64 为首期平台,不增加 32 位/x32 支持。下文 OCI、namespace、seccomp、cgroup 及相应探针和隔离门禁作为旧方案记录,不再是当前里程碑的前置条件;其余相关存储、调用、恢复和 schema 要求继续有效。 +> **范围:** 本计划实现 RSS 插件图,以及 tools 调用的 effect 声明、准入和托管执行。插件图包括图模型、manifest、exports、library 依赖、resolve、compiler、load、pin、closure、cache、activation、hot reload、persistence,以及这些图上的 runtime 执行。Tools 对托管工作区和插件状态的 effect 经 Native broker 执行。保留原 Native 授权、root、clock、取消,以及迟到批准不执行。托管支持 checkpoint、restore、branch、publish、replay 和 effect 账本查询,以及这些操作对应的管理接口。 +> +> 进程继续使用现有有界 `std::process::Command` 后端及其取消、清理。本计划不实施、不验收新的 OS namespace、OCI、cgroup、seccomp 或调试器隔离。OS 隔离资格不作为交付项。Native tools 效果测试使用现有受控后端和 fixture。 +> +> provider codec、delivery projection、main policy,以及 context、prompt、Guardian 的新插件行为不作为实现任务。既有内置 agent、reviewer、provider、默认 prompt 和 compaction 语义保持不变。既有 Tool 与 effect 安全约束保持不变。 +> +> 平台为 Linux x86_64。不增加 32 位或 x32。不改 Core、语言语法、JIT 或 VMBC。不新增 admin/tenant 系统。不新增下文未列出的 route、limit 或 SDK effect kind。不另开 schema 版本或兼容 lane。 -> **实施状态:** 已获准按本方案进行并发开发。独立工作区、依赖顺序、真实验证与独立审查继续作为合入门禁;下文拟新增能力须经相应阶段验收,不能因计划存在而视为已交付。 +**Goal:** 将 tools 与其 library 依赖组成可版本化、可解析、可原子切换的 RSS 插件图。工具对托管工作区和受控插件状态的 effect 在 Native 准入后执行,并在托管范围内支持 checkpoint、分支恢复和版本回退。 -**Goal:** 将 tools 和可替换的 harness 业务逻辑组成可版本化、可解析依赖、可原子切换的 RSS 插件图;让工具对托管工作区和插件状态的 effect 在 Native 沙盒内执行,并支持一致 checkpoint、分支恢复和版本回退。 +**Architecture:** 不可变插件包、闭合依赖图、按 run 固定的 graph lock,以及独立的 Native effect broker。代码版本、工作区版本、插件状态版本分别记录,通过显式 checkpoint 和 activation 记录关联。hot reload 面向新 run。恢复产生新 branch 与新 run,保留旧执行记录。 -**Architecture:** 使用不可变插件包、闭合依赖图、按 run 固定的 graph lock,以及独立的 Native effect broker。代码版本、工作区版本、插件状态版本分别管理,通过显式 checkpoint/部署记录建立关联。热重载面向新 run 生效;恢复通过新分支与新 run 完成,保留旧执行记录。 - -**Tech Stack:** 当前 RustScript/RSS + Rust Native + Axum + 现有 typed SQLite/RSS storage;Linux 首期使用经过能力探测的 rootless OCI 后端。包版本解析预计新增 `semver` Rust 依赖;不计划改 Core、语言语法、JIT 或 VMBC。 +**Tech Stack:** 当前 RustScript/RSS + Rust Native + Axum + 现有 typed SQLite/RSS storage。版本求解使用 resolver 已依赖的 `semver`。进程执行使用 `src/capabilities/vm_io/bounded_process.rs` 的现有 argv 后端。不增加 OCI runtime。 --- ## 1. 当前依据与设计边界 -工作区:`/mnt/TEMP/workspace/rustscript-agent/worktrees/workspace-session-binding-10-959a765d`。 +计划所在工作区:`/mnt/TEMP/workspace/rustscript-agent/worktrees/effect-g09-final-regression-232-e70f8c95`。 + +对照基线 HEAD `77653d574ebd4f0c451e443b5899fb5970f14cb6`,分支 `subagent/effect-g09-final-regression-232-e70f8c95`。工作区含未提交改动。实施时保全与本计划无关的既有修改,不能用该 HEAD 的测试结果代替当前候选验收。 -当前分支 `feat/production-delivery-integration`,已提交基线 `2f9678be44448fa5a28d3870c7eda63c7abd1573`。工作区包含尚未提交的 SSE、工具续轮和真实 API E2E 修复;实施时须先保全并单独验收这批修改,不能用已提交基线的测试结果代替当前候选验收。 +已核对的复用点: -已读取的复用点: -- `rss/tools/types.rss`、`registry.rss`、`dispatch.rss`:六工具描述、固定分派及 RSS 实现。 -- `src/registry.rs:923`:`ToolRegistrySnapshot` 已为不可变工具描述/校验器集合;其 identity 只用于一致性,不授予权限。 -- `src/runtime/module_snapshot.rs`:模块树源码快照和隔离编译;这与 effect 工作区快照属于不同概念。 -- `src/runtime/closed_bundle.rs`:现有有界、闭合 import 图编译,可作为包入口校验基础。 +- `rss/tools/types.rss`、`registry.rss`、`dispatch.rss`:现有工具描述、固定分派及 RSS 实现。 +- `src/registry.rs`:`ToolRegistrySnapshot` 是不可变工具描述和校验器集合。其 identity 只用于一致性,不授予权限。 +- `src/runtime/module_snapshot.rs`:模块树源码快照和隔离编译。它与 effect 工作区快照是不同对象。 +- `src/runtime/closed_bundle.rs`:有界闭合 import 图编译,上限 256 个文件、8 MiB 源码。插件加载复用该接口,不扩大上限,不改 Core。 - `src/service.rs`:admission、run 固定 registry、durable context、恢复与提交门。 - `src/capabilities/lifecycle.rs`、`src/runtime/agent_host.rs`:`tool_prepare` / `tool_commit`、token、parent、approval 与 replay。 - `src/capabilities/confined_io.rs`:dirfd/openat2 文件边界。 -- `src/capabilities/vm_io/bounded_process.rs:2016` 附近:当前子进程由 `std::process::Command` 启动,清空环境、固定 cwd,并使用 process group。所读启动路径未提供完整 OS namespace/seccomp 根文件系统隔离;不能据此承诺任意 terminal 程序的 effect 已被沙盒约束。 -- `rss/storage/*.rss`:业务 SQL、迁移与持久化投影继续由 RSS 管理;Native 负责授权与提交不变量。 -- `src/gateway/api_server.rs`、`tests/support/live_http_delivery.rs`:实际 HTTP admission、SSE 与消息查询测试入口。 +- `src/capabilities/vm_io/bounded_process.rs`:子进程由 `std::process::Command` 按 argv 启动。该模块不提供 namespace、seccomp 或根文件系统隔离。不能据此承诺任意 terminal 程序已被 OS 隔离。本计划不添加该隔离。 +- `src/plugins/`:manifest、store、resolve、prepare、pin、schema。`PLUGIN_KINDS` 只有 `library` 和 `tool`。 +- `src/effects/`:托管文件树、journal、checkpoint、restore。该模块不接入 OCI。 +- `rss/storage/*.rss`:业务 SQL、迁移与持久化投影继续由 RSS 管理。Native 负责授权与提交不变量。外部插件不能提供 SQL 字符串。 +- 已注册 HTTP:`POST /v1/runs`、`POST /v1/workspaces/{workspace_id}/checkpoints`、`POST /v1/workspaces/{workspace_id}/restore`、`GET /v1/runs/{run_id}/effects`。 + +插件化范围: + +- tool 包及其 library 依赖; +- 上述包的图模型、manifest、exports、解析、编译、加载、pin、闭包、缓存、激活、热重载、持久化和按 run 执行。 -环境只做了只读发现:存在 `/usr/sbin/runc`、`/usr/bin/unshare`;未发现 bwrap、podman、crun、fuse-overlayfs。工作区 statfs 报告 `ext2/ext3`;这不能证明可用的 CoW 或 snapshot 能力。尚未验证 rootless namespaces、cgroup 委派、seccomp 或运行时版本。 +不在本计划实现范围内: -### 范围选择 +- 把 agent 主循环、Guardian、context 选择、compaction、prompt 组合、provider codec 或 delivery projection 做成可替换插件; +- 新的 OS 隔离后端; +- 新的 admin/tenant 产品。 -推荐先覆盖 Linux、单机、托管工作区、RSS 插件。第三方 native 动态库、多机分布式事务、进程 RAM/连接恢复、任意网站操作撤销不列入首期保证。 +必须留在可信 Native 核心、且本计划不放宽的部分:权限交集、credential/secret 保管、执行 token 验证、工作区授权、绝对 deadline 与 cancel、现有进程边界、持久化原子提交、effect journal、恢复协调。RSS 插件可以提出请求,不能替换这些强制边界。存储后端协议和全局 schema 升级沿用宿主规则。第三方插件不直接写控制面数据库。 -插件化范围包括: -- tools; -- agent 主循环策略、Guardian 评估策略; -- context 选择、compaction、prompt 组合; -- provider 请求/响应 codec 与路由策略; -- delivery 格式化、事件投影、受控状态读写策略。 +既有内置 agent、reviewer、provider、默认 prompt、compaction 和 delivery 行为保持原语义。本计划只约束它们不被插件替换,不改写它们。 -必须保留在可信 Native 核心的部分:权限交集、credential/secret 保管、执行 token 验证、工作区授权、绝对 deadline/cancel、进程隔离、持久化原子提交、effect journal、恢复协调和管理面鉴权。RSS 插件可提出策略和请求,不能替换这些强制边界。存储后端协议和全局 schema 的升级受宿主兼容规则约束,不开放第三方插件直接写控制面数据库。 +首期保证限于 Linux、单机、托管工作区和 RSS `library`/`tool` 包。第三方 native 动态库、多机分布式事务、进程内存或连接恢复、任意网站操作撤销、任意外部副作用回滚,都不在保证内。 --- ## 2. 方案比较与推荐 -### A. 推荐:RSS 插件 + 不可变 graph + Native effect broker + rootless OS 沙盒 +### A. 采用:RSS 插件 + 不可变 graph + Native effect broker + 托管工作区视图 -复用现有 RSS/VM、typed host 与 registry。业务扩展保持 RSS;Native 收敛为授权、生命周期和真实 OS 操作。支持多图并存和精确回退;需要新增插件调用桥、版本化工作区与隔离后端。 +复用现有 RSS/VM、typed host 与 registry。tool 扩展保持 RSS。Native 负责授权、生命周期和托管文件操作。多图可以并存,回退指向已保留的旧图。进程仍走现有 Command 后端。 -### B. 每个插件都做独立 OCI 服务 +### B. 不采用:把主循环、provider、delivery 或 Guardian 改成可替换业务插件 -隔离更直观、语言自由度高,但简单 tool 也需要 IPC、协议管理和服务生命周期;现有 RSS 调度与资源 API 需要大量重做。可保留为将来的 `PluginExecutor` 后端,不作为首期默认。 +这些模块的现有语义保持为约束。本计划不把它们做成插件产品。图 lock 可以记录已有 `harness_selection` 字段,见第 4 节。该字段不授权新的 prompt、context 或 Guardian 行为。 -### C. Rust 动态库插件 + 原目录备份 +### C. 不采用:Rust 动态库插件 + 原目录备份 -接入看似直接,但同进程 native 插件可绕过 broker;目录备份不能约束 terminal 对其他路径或网络的 effect,也难以提供一致回退。不采用。 +同进程 native 插件可绕过 broker。目录备份不能给出托管工作区的一致 head,也不能约束 terminal 对其他路径的写入。 --- -## 3. 对外语义:先固定四种不同操作 +## 3. 对外语义:四种操作 -1. **reload / activate graph**:新 run 使用新插件闭包;在途 run 继续使用原图。 -2. **rollback graph**:将未来 run 的默认图切回保留的旧图,创建新的 activation generation;不回拨 generation,不修改旧 run。 -3. **checkpoint / restore effects**:记录并恢复托管工作区和受控插件状态;不撤销模型费用、已收到的事件或外部远端事实。 -4. **resume from checkpoint**:从 checkpoint 产生新 branch/run,显式选择原图或经过兼容迁移的新图,重新授权 Native 资源。 +1. **reload / activate graph**:新 run 使用新插件闭包。在途 run 继续使用原图。 +2. **rollback graph**:未来 run 的默认图切回保留的旧图,并创建新的 activation generation。generation 不回拨。旧 run 不修改。 +3. **checkpoint / restore effects**:记录并恢复托管工作区不可变树,以及 checkpoint 覆盖的受控插件状态根。不撤销模型费用、已经发出的事件或外部远端事实。不保存进程、线程、FD、socket 或内存。 +4. **resume from checkpoint**:从 checkpoint 产生新 branch 和新 run。显式选择原图,或经过状态兼容准备的新图。Native 资源重新授权。 -“热”表示 API Server 不停机、旧/新图并存。首期不在一条正在执行的模型/工具调用中替换代码,也不静默改写既有 run 的工具 schemas。若要在会话中立即采用新工具,由客户端显式开始新 run;若发生工具集合/状态契约不兼容,必须生成新会话分支或拒绝继承,不能沿用旧 pending call。 +“热”表示 API Server 不停机,旧图与新图并存。不在一条正在执行的模型调用或工具调用中替换代码,也不改写既有 run 已固定的工具 schemas。会话中要使用新工具时,由客户端开始新 run。工具集合或状态契约不兼容时,生成新会话分支或拒绝继承,不沿用旧 pending call。 --- @@ -78,9 +87,9 @@ ### 4.1 不可变包 -包目录只包含 manifest、RSS 源码、受控资源和测试描述。安装阶段复制到宿主拥有的不可变 store,完成闭合源码捕获后再编译,执行时不重新读取可变开发目录。 +包目录只包含 manifest、RSS 源码、受控资源和测试描述。安装时复制到宿主拥有的不可变 store,完成闭合源码捕获后再编译。执行时不重新读取可变开发目录。 -拟定 manifest 示例,字段均为新设计: +manifest 字段以仓库内 `schemas/plugin-manifest.schema.yaml` 和 `src/plugins/error.rs` 为准。示例: ```yaml format: rustscript.plugin/v1 @@ -117,97 +126,110 @@ state: schema: 1 ``` -- `(publisher namespace, id, version)` 唯一;同版本不同内容拒绝覆盖。开发变更产生新的 revision,不能就地修改 active 包。 -- `artifact_id` 是 store 分配的不可变对象标识,`graph_id` 是持久化 lock 对象标识;二者均不构成权限。 -- 第三方发布包需要管理员接受的发布者与签名;本地 unsigned 开发包只能由管理员显式导入受限开发空间。签名可信不扩大 capability。 -- 首期安装入口为受控本地目录。禁止自动执行安装脚本、自动拉取依赖、跟随符号链接或解包到任意宿主路径;在线 registry 后续单独设计。 -- manifest 严格解析:拒绝未知安全字段、重复 key、YAML alias/tag/merge 扩展及超预算输入。包数量、文件数、字节数、图深度、编译耗时和诊断输出均有界;现有界限不因插件加载被取消。 -- 初始配置上界:每图最多 64 个包、256 条依赖边、深度 16;闭包源码仍受现有 256 文件 / 8 MiB 总量约束;每包最多 32 个可选版本,求解最多 4096 个决策节点。达到上界返回具体 budget 错误,不扩大现有 compiler/runtime 限制。运行时缓存以实际对象大小与条目数双重计量,实施时复用并核实既有上界。 -- 包分为宿主内置 `system` 与受限 `tenant` 扩展。第三方签名和安装不会将 tenant 包提升为 system 包;控制面存储迁移及强制策略适配器只能来自宿主兼容的 system 集合。两者都不能绕过 Native 的原子提交和权限校验。 +- `kind` 只允许 `library` 或 `tool`。 +- `(id, version)` 唯一。同版本不同内容拒绝覆盖。开发变更产生新 revision,不就地修改 active 包。 +- `artifact_id` 是 store 分配的不可变对象标识。`graph_id` 是持久化 lock 对象标识。二者都不构成权限。 +- 安装使用现有 `InstallAuthorization` 与 `PackageTrust`。签名可信不扩大 capability。本地未签名开发包只能进入受限开发空间。 +- 首期安装入口为受控本地目录。禁止自动执行安装脚本、自动拉取依赖、跟随符号链接或解包到任意宿主路径。不在本计划设计在线 registry。 +- manifest 严格解析:拒绝未知安全字段、重复 key、YAML alias/tag/merge 及超预算输入。包数量、文件数、字节数、图深度、编译耗时和诊断输出均有界。现有界限不因插件加载被取消。 +- 现有上界,本计划不提高:每图最多 64 个包、256 条依赖边、深度 16;闭包源码 256 文件 / 8 MiB;每包最多 32 个可选版本;求解最多 4096 个决策节点。达到上界返回具体 budget 错误。运行时缓存按实际对象大小与条目数计量,复用既有上界。 +- 宿主内置包与后来安装的包使用现有 trust 区分。安装不把扩展包提升为控制面迁移或强制策略适配器。两类包都不能绕过 Native 原子提交和权限校验。 ### 4.2 三类依赖边 -- `library`:纯 RSS 库,静态链接到调用者的 VM,不拥有独立权限或状态。 -- `plugin`:显式插件调用边,经 Native broker 进入被调用插件的独立 VM/调用主体。 +- `library`:纯 RSS 库,链入调用者的 VM,不拥有独立权限或状态。 +- `plugin`:显式插件调用边,经 Native broker 进入被调用插件的独立 VM 和调用主体。 - `host ABI`:对宿主 named types、capability 接口和事件契约的兼容要求。 -禁止以普通 RSS 跨包 import 绕过 plugin 主体边界。Native 根据已校验 manifest 生成静态入口映射;用户输入不能构造 import、export 或磁盘路径。禁止并入整个插件集合后向单个 VM 授予所有插件 capability 的并集。 +图 lock 边上的 provenance 保持 `direct`、`plugin` 或 `library`。禁止用普通 RSS 跨包 import 绕过 plugin 主体边界。Native 根据已校验 manifest 生成静态入口映射。用户输入不能构造 import、export 或磁盘路径。禁止把多个插件的 capability 并集授予单个 VM。 -### 4.3 解析与 lock +### 4.3 解析、编译、加载、pin 与 cache -- 首期每个 graph 内每个 package 只允许一个具体版本;不同 graph 可同时运行不同版本。 -- resolver 只查询管理员安装的候选,确定性排序、有限搜索。缺失、冲突、循环、歧义、超预算均返回带最小冲突链的 typed 错误。 -- 生成持久化 `GraphLock`:准确 package revision、完整依赖边、入口、接口 schema、host ABI、权限声明、每个入口的直接/委托 effect 契约及解析后的传递闭包、状态 schema、编译环境兼容标识。 -- `PreparedGraph` 在 admission 前预解析、编译和验证,不运行有 effect 的 load hook。昂贵工作不得进入全局提交门或挤占原有 admission 时间。 -- 共享缓存只允许经过审计的不可变公共源码/Program/schema;Native resources、AuthHost、workspace 句柄、token、cancel 和带 owner 的对象每次调用重新绑定。缓存必须有容量和失效规则。 +- 每个 graph 内每个 package 只允许一个具体版本。不同 graph 可同时运行不同版本。 +- resolver 只查询已安装候选,确定性排序,有限搜索。缺失、冲突、循环、歧义、超预算均返回带最小冲突链的 typed 错误。 +- 持久化 `GraphLock` 记录 package revision、依赖边、入口、接口 schema、host ABI、权限声明、每个入口的 direct/delegated effect 及解析后的传递闭包、状态 schema、编译环境标识。 +- `GraphLock.harness_selection` 是图契约字段,取值包括 `Unselected`、`ToolOnly` 和 `ContextPrompt`。本计划的执行模式是 `ToolOnly`。`ContextPrompt` 与另外两个变体一样保留为可读、可校验的 lock 形状,不作为向后兼容产品,也不另开迁移 lane。它不授权新的 prompt、context 或 Guardian 插件行为,也不改变内置策略。 +- `PreparedGraph` 在 admission 前完成 resolve、compiler、load 和验证。load 不运行有 effect 的 hook。昂贵工作不进入全局提交门,不挤占原有 admission 时间。 +- admission 把 graph lock pin 到 run。后续 provider、tool 和 replay 使用该 pin。缺包返回 `plugin_artifact_unavailable`,不以当前 active 版本继续旧调用。 +- 共享 cache 只保存经过审计的不可变公共源码、Program 和 schema。Native resources、AuthHost、workspace 句柄、token、cancel 和带 owner 的对象每次调用重新绑定。缓存有容量和失效规则。 ### 4.4 类型与存储记录 -新增核心 named records:`PluginManifest`、`PackageRevision`、`GraphLock`、`ActivationRecord`、`PluginInvocationContext`、`EntryEffectContract`、`EffectRequirement`、`DelegatedEffectRequirement`、`EffectIntent`、`EffectReceipt`、`WorkspaceHead`、`CheckpointRecord`。它们分别记录不可变对象引用、owner、schema version、期望 generation/epoch 与状态,不以任意 map/unknown 暴露新的 host API。 +使用现有 named records,不平行新增一套 host API:`GraphLock`、`PackageRevision`、`InstalledPackage`、activation pointer/history、`CheckpointRecord`、managed effect journal、`ManagedWorkspaceHead`、plugin state root ref。它们记录不可变对象引用、owner、schema version、期望 generation/epoch 与状态。不以任意 map 或 unknown 暴露新的 host API。 -每个插件 entry 的输入/输出 schema 在 prepare 时验证并安装为带 package 命名空间的契约,拒绝同名冲突。通用调度桥使用 Native 已验证的 payload/resource 引用;跨 VM 由 broker 解封数据并重新发行本 VM 包装,不复制调用者的 guest resource。已有工具的内部 RSS map 兼容层不能成为新增 host 接口的开放动态通道。 +每个插件 entry 的输入/输出 schema 在 prepare 时验证,并安装为带 package 命名空间的契约。同名冲突拒绝。通用调度桥使用 Native 已验证的 payload 和 resource 引用。跨 VM 由 broker 解封数据并重新发行本 VM 包装,不复制调用者的 guest resource。已有工具的内部 RSS map 兼容层不能成为新的开放动态 host 通道。 -新增持久化记录采用 graph/package/activation、run graph pin、effect journal、workspace root/head、checkpoint 和 plugin state ref 的分离集合。它们通过 typed storage 命令加入既有 RSS SQL 事务,外部插件不能提供 SQL 字符串或自行修改这些记录。数据库约束验证 owner/ref 存在性与单调 generation,权限仍由 Native 检查。 +持久化记录分为 graph/package/activation、run graph pin、effect journal、workspace root/head、checkpoint 和 plugin state ref。它们通过 typed storage 命令加入既有 RSS SQL 事务。外部插件不能提供 SQL 字符串或自行修改这些记录。数据库约束验证 owner/ref 存在性与单调 generation。权限仍由 Native 检查。 -### 4.5 仓库内 YAML schema 与同步维护门禁 +### 4.5 仓库内 YAML schema -YAML 格式的 machine-readable schema 是正式交付物,必须随开发保持 up-to-date 并 check in repo;只在文档里给示例、只在本地生成或只上传构建产物均不满足要求。 +YAML schema 随实现提交到仓库。只在文档中给示例、只在本地生成或只上传构建产物,都不满足要求。 -拟定仓库路径: -- `schemas/plugin-manifest.schema.yaml`:包 manifest。 -- `schemas/effect-contract.schema.yaml`:入口 direct/delegated effects、kind、scope 和 recovery。 -- `schemas/plugin-graph-lock.schema.yaml`:持久化 graph lock。 -- `schemas/v1/checkpoint.schema.yaml`:checkpoint 的可序列化公开结构。 -- `schemas/README.md`:schema 方言、版本映射、兼容规则、校验/生成命令与维护方式。 +现有契约文件: + +- `schemas/plugin-manifest.schema.yaml` +- `schemas/effect-contract.schema.yaml` +- `schemas/plugin-graph-lock.schema.yaml` +- `schemas/plugin-activation.schema.yaml` +- `schemas/plugin-graph-ref.schema.yaml` +- `schemas/v1/checkpoint.schema.yaml` +- `schemas/managed-workspace.schema.yaml` +- `schemas/managed-effect.schema.yaml` +- `schemas/managed-checkpoint.schema.yaml`(含 `ManagedPublishRequest`、`ManagedPublishReceipt`、`ManagedWorkspacePublication`,当前 v1,不开新版本与兼容 lane) +- `schemas/managed-restore.schema.yaml` +- `schemas/README.md` +- `schemas/canonical/` 中与上述 YAML 对应的生成快照 约束: -1. 采用 YAML 表达的 JSON Schema Draft 2020-12;manifest `format`、effect `contract`、其他记录版本与对应 schema 显式映射。各 schema 与本地 refs 一并提交,运行时和 CI 不从网络解析远端 schema。 -2. 字段、required、默认值、enum、scope、依赖结构、effect catalog 可序列化契约或兼容规则发生变化时,对应 YAML schema、Rust/RSS 消费者、正反例 fixture 和文档必须在同一个变更中更新;提交时一并纳入版本控制。新增文件须实际 tracked,不能仅存在于忽略目录。 -3. 公开结构契约以仓库中的 YAML schema 为单一来源;Native 使用这些确切的本地 schema 验证去重后的数据,再执行更强的语义、依赖与授权检查。提供确定性的 YAML → canonical JSON 生成/检查入口,生成快照也需 check in;CI/test 要求重新生成无差异。结构校验、Native 语义校验和真实 authority 校验分别给出正反例,不能声称 JSON Schema 可以单独表达全部语义。 -4. CI 必须验证所有 schema 自身合法、全部示例通过、负例被拒绝,并运行 schema 与实际解析器/类型的漂移测试。需要更新 schema 却未更新,或 schema 修改后实际消费者未对齐,均阻止合并。 -5. 不兼容修改发布新 schema/contract 版本,保留仍被已安装包、graph、run 或 checkpoint 引用的旧版本;禁止覆盖旧 schema 后继续使用同一版本标识。向后兼容修改也必须同步提交,不能靠以后补文档。 -6. schema 是结构契约,权限、依赖闭包、目标授权和实际 effect 校验仍由 Native 执行;schema 校验通过不能替代这些检查。YAML 重复 key/alias/tag 等限制在转换为 JSON 数据之前执行。 + +1. schema 使用仓库内本地文件。运行时和 CI 不从网络解析远端 schema。现有 `format`、`contract` 和记录版本标识保持为当前 v1 事实。本计划不发布新的 schema 版本,也不保留多版本升级矩阵。 +2. 字段、required、enum、scope、依赖结构或 effect catalog 发生变化时,对应 YAML schema、Rust/RSS 消费者、正反例 fixture 和文档在同一个变更中更新。新增文件必须被版本控制跟踪。 +3. 公开结构以仓库 YAML schema 为结构来源。Native 用这些本地 schema 验证结构,再执行语义、依赖与授权检查。`cargo run --example plugin_schema_sync -- --check` 是现有漂移检查。结构校验通过不能代替 Native 语义校验和 authority 校验。 +4. CI 验证 schema 自身合法、示例通过、负例被拒绝,并运行 schema 与解析器的漂移测试。schema 与消费者不一致时阻止合并。 +5. schema 不表达 capability 相等、semver 满足、依赖闭包或发布者授权。`checkpoint` 只允许出现在 `workspace.fs.write`。`replay` 不属于 v1 recovery。activation schema 是结构记录,文件存在本身不表示 hot reload 已完成。 +6. YAML 重复 key、alias、tag 在转换为 JSON 数据之前拒绝。 --- -## 5. 调用主体、权限与 harness 插件化 +## 5. 调用主体与权限 ### 5.1 调用流程 ```text -HTTP / Telegram admission - → 固定 GraphLock + ToolRegistrySnapshot + workspace binding - → RSS main 插件 +HTTP admission + → pin GraphLock + ToolRegistrySnapshot + workspace binding + → RSS tool 插件 → Native plugin-call broker - → RSS Guardian / tool / codec / context 插件 - → Native effect broker - → 托管文件视图 / OS 沙盒 / 受控远端 adapter + → Native effect broker + → 托管工作区视图 / 现有 bounded process 后端 → durable result + workspace publication - → 固定版本的 delivery projection / outbox ``` - 跨插件只传已验证、限长的 named request/result,以及宿主控制的调用上下文。不得通过 JSON、整数或复制 guest resource 跨 VM 搬运权限。 -- Native 可以基于原有 run authority 给子调用重新发行本 VM 的资源包装;保留 graph、plugin revision、run、branch epoch、effect、owner 和绝对 deadline 绑定。 -- 有效权限取管理员策略、可信工作区授权、run 授权、调用者可委托权限、被调用者声明的交集;依赖声明不能自授权限。 -- caller 到 callee 的调用只允许 lock 中声明的边。禁止递归插件调用图;业务循环留在明确的 RSS 主循环中,并受原预算限制。 -- 凭据引用可以作为公开配置;实际 access/refresh、TLS key、认证头、FD 不进入插件状态、manifest、snapshot 或日志。 +- Native 可以基于原有 run authority 给子调用重新发行本 VM 的资源包装。包装绑定 graph、plugin revision、run、branch epoch、effect、owner 和绝对 deadline。 +- 有效权限取宿主策略、可信工作区授权、run 授权、调用者可委托权限、被调用者声明的交集。依赖声明不能自授权限。 +- caller 到 callee 只允许 lock 中声明的边。禁止递归插件调用图。业务循环留在现有 RSS 主循环中,并受原预算限制。本计划不替换该主循环。 +- 凭据引用可以作为公开配置。实际 access/refresh、TLS key、认证头和 FD 不进入插件状态、manifest、snapshot 或日志。 +- 既有 delivery 和 outbox 继续使用该 run 已固定的记录。本计划不改写 delivery projection。 + +### 5.2 从当前工具迁移 -### 5.2 从当前六工具迁移 +保持现有工具名称和 JSON schemas。`rss/tools/dispatch.rss` 从静态入口改为 graph 固定的声明式路由。file、search、patch、process 的业务实现继续在 RSS。 -保持现有工具名称和 JSON schemas。`rss/tools/dispatch.rss` 从静态六工具入口逐步变为 graph 固定的声明式路由;具体 file/search/patch/process 业务继续在 RSS。 +`tool_prepare` 仍先验证 parent、run、registry、审批与状态,再记录 started/intent 并发行一次性授权。`tool_commit` 仍负责 durable result。plugin revision、branch epoch 和 effect ID 加入同一授权事实,不能只检查工具名称。 -`tool_prepare` 仍先验证 parent、run、registry、审批与状态,再记录 started/intent 和发行一次性授权;`tool_commit` 仍负责 durable result。新的 plugin revision / branch epoch / effect ID 加入同一授权事实,不能仅检查工具名称。 +持久化重放必须同时匹配 graph、tool revision、branch lineage 与 call identity。重启后从 run 的 lock 恢复原图。无法找到原包时返回 `plugin_artifact_unavailable`。 -持久化重放必须同时匹配 graph、tool revision、branch lineage 与 call identity。重启后从 run 的 lock 恢复原图;无法找到原包时返回 `plugin_artifact_unavailable`,不以当前 active 版本继续旧调用。 +迟到批准不执行。取消和原 deadline 在 effect 开始前再次检查。批准到达时 run 已取消、已过 deadline 或授权已撤销的,不启动工具。 -### 5.3 状态约束 +### 5.3 插件状态 -- `invocation` / `run` / `session` 状态分别使用 Native 管理的有界版本化根引用。 -- 首期可变状态限于 run/session;服务级共享状态仅提供只读配置或宿主定义的事务 API,第三方插件不得自行全局写入。 -- 状态迁移是独立、纯函数、无权限的 RSS entry,输入旧 schema,输出新 schema;不能执行 SQL DDL、网络或文件操作。 -- 新状态写入新对象,不覆盖旧对象。需要切换 session 状态 schema 时取得该 session 的维护 lease,并等待其旧 run 到达终止边界;其他 session 不暂停。 -- 无法迁回旧状态时,graph rollback 返回 `state_incompatible` 或显式选择较早 checkpoint;禁止静默丢失新状态。 -- `rss/storage/schema.rss` 的控制面迁移使用向前兼容、扩展优先的宿主发布流程;它不随任意插件 activate 自动执行。 +- `invocation`、`run`、`session` 状态使用 Native 管理的有界版本化根引用。 +- 首期可变状态限于 run/session。服务级共享状态只提供只读配置或宿主定义的事务 API。第三方插件不得自行全局写入。 +- 状态迁移是独立、纯函数、无权限的 RSS entry。输入旧 schema,输出新 schema。不能执行 SQL DDL、网络或文件操作。 +- 新状态写入新对象,不覆盖旧对象。切换 session 状态 schema 时取得该 session 的维护 lease,并等待其旧 run 到达终止边界。其他 session 不暂停。 +- 无法迁回旧状态时,graph rollback 返回 `state_incompatible`,或显式选择较早 checkpoint。禁止静默丢失新状态。 +- 插件 activate 不执行 `rss/storage/schema.rss` 的控制面迁移。 --- @@ -218,36 +240,37 @@ HTTP / Telegram admission ```text installed → resolved → prepared → validated → staged → active ↘ rejected -active → retired(仍可被旧 run / checkpoint / outbox 引用) +active → retired(仍可被旧 run / checkpoint 引用) ``` 激活事务: -1. 在提交门外完成源码闭包、版本求解、编译、contract 检查、权限 diff、无 effect 的健康检查及必要的状态迁移准备。 -2. 管理员检查图 diff,提交 `expected_generation`。 -3. 原子提交新的 activation record 和 active graph/state-default 引用;更新内存。并发激活失败方返回 conflict,不能覆盖胜出方。 + +1. 在提交门外完成源码闭包、版本求解、编译、contract 检查、权限 diff、无 effect 的健康检查,以及必要的状态迁移准备。 +2. 宿主授权检查图 diff,提交 `expected_generation`。授权使用现有安装和 workspace/owner 校验,不新增 tenant 角色。 +3. 原子提交新的 activation record 和 active graph/state-default 引用,并更新内存。并发激活的失败方返回 conflict,不能覆盖胜出方。 4. 后续 admission 在原有原子事务内记录 graph lock 和 registry,不重新编译候选。 -5. 旧 run、旧 provider continuation、background process、pending delivery 使用它们已固定的版本完成。 +5. 旧 run 使用已 pin 的图完成。既有 provider continuation 和 pending delivery 也保持该 pin。本计划不改写这些子系统。 -- 回退为一次新的激活:例如 generation 8 指向以前保留的 graph;generation 不下降,防止 ABA。 -- 不兼容 ABI/schema、增加权限、改动已绑定工具 schemas 均不自动激活。 +- 回退是一次新的激活。例如 generation 8 指向以前保留的 graph。generation 不下降。 +- 不兼容 ABI/schema、增加权限、改动已绑定工具 schemas,均不自动激活。 - watcher 只发出“候选变化”信号。写到一半的包、编译失败或未确认的权限提升不会替换 active 图。 -- 显式撤销某插件时可禁止新调用并按策略取消在途 run;“已撤销”不能被旧 run pin 或 snapshot 恢复解除。 -- GC 以 run/session/checkpoint/activation/outbox 的 durable refs 和 Native 活跃 lease 为依据;有引用的包、状态和文件树不得删除。保留失败候选的有界诊断,不无限保留构建缓存。 +- 显式撤销某插件时,可以禁止新调用,并按既有取消策略停止在途 run。“已撤销”不能被旧 run pin 或 snapshot 恢复解除。 +- GC 以 run、session、checkpoint、activation 的 durable refs 和 Native 活跃 lease 为依据。有引用的包、状态和文件树不得删除。失败候选只保留有界诊断。 --- -## 7. Effect 显式契约、分类和可回退保证 +## 7. Effect 显式契约与可回退范围 -### 7.1 所有插件入口必须显式声明 effects +### 7.1 工具和库入口必须显式声明 effects -这是安装、依赖解析、激活与运行时的强制契约,覆盖 tool、main、Guardian、context、codec、delivery、library 和状态迁移入口。 +这是安装、依赖解析、激活与运行时的强制契约,覆盖 `tool` 入口、`library` 入口和纯状态迁移入口。不要求 main、Guardian、context、codec 或 delivery 入口。 -- manifest 必须包含带版本的 `effects`,每个可调用 export 必须给出 `direct` 和 `delegated`。缺失、未知 kind、未覆盖入口或不明确的通配声明直接拒绝;不采用“省略等于纯”的规则。 -- `direct` 是本入口可能直接触发的 effect 上界;`delegated` 是本入口允许通过依赖调用触发的 effect 上界,按依赖 alias 和准确 entry 列出。调用者声明委托 effect 不会得到被调用插件的凭据或直接 host 能力。 -- 纯入口必须显式声明 `direct: []`、`delegated: []`。普通返回值和 Native 自身的计量/安全审计不计为插件 effect;插件主动写状态、发事件、读取时钟/随机数、调用模型和发送消息均必须声明。纯入口不得偷偷使用这些能力。 -- 每条声明采用 Native 已知的 effect kind 和该 kind 的 typed scope;例如 workspace 绑定、状态命名空间、受控 provider/远端 adapter。参数中的实际目标仍要由 Native 在执行前按该 scope 校验,不能用模型提供的 path/URL 放大范围。 -- `recovery` 描述期望的恢复能力,实际可用性由 Native effect catalog 和 adapter 决定。插件不能把远端发送标成 checkpoint,也不能把 managed 写入标成 pure;冲突在 prepare/activation 时拒绝。 -- effect 契约描述“可能做什么”;capability grant 决定“本次允许做什么”。实际执行必须同时属于已固定声明、caller 可委托上界、被调入口上界、管理员/run/workspace 授权,并通过当前撤销、deadline 和 budget 校验。 +- manifest 必须包含带版本的 `effects`。每个可调用 export 必须给出 `direct` 和 `delegated`。缺失、未知 kind、未覆盖入口或不明确的通配声明直接拒绝。省略不等于纯。 +- `direct` 是本入口可能直接触发的 effect 上界。`delegated` 是本入口允许通过依赖调用触发的 effect 上界,按依赖 alias 和准确 entry 列出。调用者声明委托 effect,不会得到被调用插件的凭据或直接 host 能力。 +- 纯入口必须显式声明 `direct: []`、`delegated: []`。普通返回值和 Native 自身的计量、安全审计不计为插件 effect。插件主动写状态、发事件、读取时钟、调用模型或发送消息,必须声明对应既有 kind。纯入口不得使用这些能力。 +- 每条声明使用 Native 已知的 effect kind 和该 kind 的 typed scope。参数中的实际目标仍由 Native 在执行前按该 scope 校验。模型提供的 path 或 URL 不能放大范围。 +- `recovery` 只有 `none` 和 `checkpoint`。`checkpoint` 只允许用于 `workspace.fs.write`。插件不能把 `process.spawn`、`remote.write`、`model.request`、`delivery.send` 或其他 kind 标成 `checkpoint`。冲突在 prepare 和 activation 时拒绝。 +- effect 契约描述可能做什么。capability grant 决定本次允许做什么。实际执行必须同时属于已固定声明、caller 可委托上界、被调入口上界、run/workspace 授权,并通过当前撤销、deadline 和 budget 校验。默认拒绝。 纯入口示例: @@ -280,107 +303,99 @@ effects: recovery: checkpoint ``` -其中 `files` 必须在 manifest dependencies 中声明。解析后,将 callee entry 的全部可能 effects 连同它继续委托的 effects,经过目标绑定映射后检查为 caller 该边 `allow` 的子集;有一个未覆盖项就返回 `undeclared_transitive_effect`。闭包只包含显式允许的调用边,不能把依赖图的权限并集发给 VM。后台 job、回调和 outbox consumer 同样必须声明入口与委托边,并固定原 graph/effect 上界;不能通过异步调度隐藏 effect。新增外部投递订阅也必须进入 graph 的 effect diff 和审批。 - -### 7.2 静态校验、动态拦截与版本兼容 +`files` 必须在 manifest dependencies 中声明。解析后,callee entry 的全部可能 effects,连同它继续委托的 effects,经目标绑定映射后必须是 caller 该边 `allow` 的子集。有一个未覆盖项就返回 `undeclared_transitive_effect`。闭包只包含显式允许的调用边。后台 job 和回调若会触发 tool effect,必须声明入口与委托边,并固定原 graph/effect 上界。不能通过异步调度隐藏 effect。本计划不新增外部投递订阅。 -1. prepare 读取闭合 RSS/compiled imports,与应用侧 Native `EffectCatalog` 的准确 host ABI 对照。每个 effectful host 入口必须有明确 metadata;未知项拒绝。首期可采用保守的模块 imports 上界,不能声称已经具备语言级精确函数 effect 推导。 -2. effectful host 调用只绑定到相应的受控 bridge;跨插件调用校验锁定的 delegated 边。无法确定目标的动态调用不得绕过检查。 -3. Native 在真实 effect 开始前检查已固定入口契约。未声明操作返回 `undeclared_effect`,不得先执行再仅记录告警。journal 中保存声明契约 revision 和实际 receipt;契约声明不能替代真实 receipt。 -4. 对任意 terminal 程序,静态 argv 分析不能证明精确 effect。必须保守声明 `process.spawn`、`process.output` 及允许的 workspace 读写上界,并由 namespace、挂载和网络隔离约束其全部后代;执行脚本不能绕过声明扩大边界。 -5. effect 新增、scope 扩张、callee effects 扩张或 recovery 保证降低都属于安全契约变更。即使包版本标为 patch,也必须生成 graph effect diff 并重新审批,不允许自动热激活。scope 变窄或 effect 删除也要检查依赖调用兼容。 -6. Graph/API 管理视图显示入口级直接 effects、委托 effects、解析闭包、实际 grant 和恢复保证;调用审计可对照实际 receipt。模型可见的工具 metadata 从这个固定契约投影,不能由模型编写或修改声明。 +### 7.2 静态校验、动态拦截与 catalog -首期 effect catalog 至少区分:`workspace.fs.read/write`、`plugin_state.read/write`、`process.spawn/output`、`clock.read`、`random.read`、`model.request`、`event.emit`、`delivery.send` 和受控远端 adapter 的 read/write。具体命名和 scope 是有版本的 host contract;不使用一个 `filesystem` 或 `network` 大标签隐藏所有行为。 +1. prepare 读取闭合 RSS/compiled imports,与 Native effect catalog 对照。每个 effectful host 入口必须有明确 metadata。未知项拒绝。首期使用保守的模块 imports 上界,不声称已经具备语言级精确函数 effect 推导。 +2. effectful host 调用只绑定到相应的受控 bridge。跨插件调用校验锁定的 delegated 边。无法确定目标的动态调用不得绕过检查。 +3. Native 在真实 effect 开始前检查已固定入口契约。未声明操作返回 `undeclared_effect`,不得先执行再只记录告警。journal 保存声明契约 revision 和实际 receipt。契约声明不能替代真实 receipt。 +4. 对 terminal 程序,静态 argv 分析不能证明精确 effect。必须声明 `process.spawn` 以及允许的 `workspace.fs.read` / `workspace.fs.write` 上界。执行使用现有 bounded process 后端和托管工作区视图。脚本不能绕过声明扩大托管视图。本计划不用 namespace、挂载或网络隔离作为验收条件。 +5. effect 新增、scope 扩张、callee effects 扩张或 recovery 保证降低,都属于安全契约变更。即使包版本标为 patch,也必须生成 graph effect diff 并重新审批,不允许自动热激活。scope 变窄或 effect 删除也要检查依赖调用兼容。 +6. 图管理视图显示入口级直接 effects、委托 effects、解析闭包、实际 grant 和恢复保证。调用审计对照实际 receipt。模型可见的工具 metadata 从固定契约投影,不能由模型编写或修改声明。 -### 可以完整恢复的受控对象 +既有 effect catalog,本计划不增删 kind: -- 托管工作区内的文件树、内容和声明支持的权限元数据。 -- Native 管理的插件 run/session 状态。 -- 已终止本地进程产生的上述文件变化。 +| kind | scope | recovery | +| --- | --- | --- | +| `workspace.fs.read` | `bound_workspace` | `none` | +| `workspace.fs.write` | `bound_workspace` | `none` 或 `checkpoint` | +| `process.spawn` | `bound_process` | `none` | +| `clock.read` | `bound_run` | `none` | +| `model.request` | `bound_run` | `none` | +| `remote.write` | `bound_run` | `none` | +| `event.emit` | `bound_run` | `none` | +| `delivery.send` | `bound_delivery` | `none` | +| `secret.use` | `bound_secret` | `none` | -### 需要终止并重新启动的对象 +`model.request`、`delivery.send`、`remote.write`、`event.emit` 和 `secret.use` 留在 catalog 中,用于声明、默认拒绝和越权拦截。声明它们不会新建 provider codec、delivery projection、远端 adapter 或 secret 子系统,也不会授予这些能力。对应执行仍走既有宿主路径。未知 kind 拒绝。 -- 进程、线程、打开的 FD、socket、锁和内存。checkpoint 只在无活动写入者的安全边界成立,不保存这些运行态。 -- 后台 job 是 open effect;spawn 返回成功不表示整个 job 已完成。checkpoint/restore 要求先停止并确认所有后代退出。首期可先拒绝 sandbox profile 的 writable background 模式,后续以明确的 open-effect lease 支持,不能假装已 snapshot。 +### 7.3 可以恢复的对象 -### 不能承诺通用撤销的对象 - -- 模型请求、费用、已经流出的 stdout/SSE、已发送通知。 -- 远端 HTTP 写入、支付、邮件、Git push、第三方 DB 修改。 +- 托管工作区内已经提交到不可变树的文件内容和该树声明支持的元数据。导入一致性只有 `ordinary_copy`。复制不提供外部仍在写入的目录的跨文件时间点快照。本计划不实现文件系统 snapshot 后端。 +- checkpoint 覆盖的 Native 插件 run/session 状态根。 +- 已结束写入、并且结果已进入上述不可变树的本地文件变化。 -这些行为必须由专门 adapter 标记为 `remote_read`、`compensatable` 或 `irreversible`。补偿需要目标服务明确支持的协议,失败会进入 reconciliation 状态。未知 effect 默认拒绝;不能通过恢复本地 journal 把远端事实标为未发生。 - -首期 sandbox tool 默认禁网、无 daemon socket、无宿主设备和凭据。确需远端能力时,只能通过已审批的 Native broker adapter;provider 的 LLM transport 是单独的宿主能力,不向任意 tool 开放网络。 - ---- +### 7.4 不能快照的运行态 -## 8. 真正的 OS 沙盒与统一文件视图 +- 进程、线程、打开的 FD、socket、锁和内存。checkpoint 在仍有可写 view 时返回 `CheckpointBusy`。restore 在仍有可写 view 时返回 `Busy`。 +- `ManagedWriterState` 只有 `Settled` 和 `NoProcess`。活动写进程存在时不建立 checkpoint。本计划不保存进程镜像,也不把 OS 整树终止当作交付项。现有进程取消和清理保持原行为。 -### 8.1 后端选择 +### 7.5 不能撤销的对象 -选定 rootless OCI 作为首期进程后端,优先评估当前 runc;必须在实施阶段固定兼容版本并验证 rootless 能力。不得假定现有二进制已经具备所需配置。 +- 模型请求、费用、已经流出的 stdout/SSE、已发送通知。 +- 远端 HTTP 写入、支付、邮件、Git push、第三方数据库修改。 +- 任意进程的外部副作用。 -强制能力: -- user/mount/PID/network namespace,private rootfs,默认无网络; -- 只读的固定 toolchain/rootfs,只有该 effect 的工作区和私有 temp 可写; -- 无宿主 home、Hermes pool、SSH agent、Docker socket、宿主 `/proc` 或设备挂载; -- 最小 capabilities、no_new_privs、宿主审定的 seccomp policy; -- cgroup v2 的进程数、内存/CPU 与整树终止;rootless 委派不可用时该 strict backend 不可用; -- 明确且有界的 FD 继承、stdin/stdout/stderr、wall deadline 和输出预算。 +这些行为不因恢复本地 journal 而变成未发生。`ManagedExternalEffects` 使用 `NoneClaimed` 或 `OutsideTreeNotReverted`。恢复结果携带 `external_effects_not_reverted`。未知 effect 默认拒绝。不能通过恢复把远端事实标为未发生。 -不用 Docker daemon group 权限作为不可信代码的默认执行边界。不采用 cwd/环境变量替代 namespace。无法满足 mandatory 能力时返回 `sandbox_unavailable`,不退回宿主 `Command`。 +--- -工具链镜像由管理员预置并固定版本;执行期间不拉取镜像、不自动安装依赖。Rust linker、动态库、解释器等只读依赖必须作为镜像的一部分,不能为编译成功额外挂载宿主根目录。 +## 8. 托管工作区视图 -### 8.2 同一个 WorkspaceView +RSS 的文件工具与 terminal 子进程使用同一个 Native 发行的 `WorkspaceView`。Native FS adapter 指向该 effect 的私有视图。子进程的工作目录指向同一视图。不允许一个写宿主原目录、另一个写另一棵树。 -RSS 的 `read_file/write_file/patch/search_files` 与 terminal 子进程使用同一个 Native 发行的 `WorkspaceView`。Native FS adapter 指向该 effect 的私有视图 dirfd,子进程挂载同一视图;不允许一个写宿主原目录、另一个写容器目录。 +所有文件系统路径继续受原相对路径、symlink、长度和枚举限制。Native object store、控制面 DB 和插件包目录不在 tool 可写空间。 -所有文件系统路径继续受原相对路径、symlink、magic-link、长度和枚举限制。Native object store、控制面 DB、插件包目录不在 tool 挂载空间。 +树操作使用有界复制,不假设 reflink 或 OverlayFS。本计划不增加 CoW 后端。禁止用普通 hardlink 把可写树与不可变 snapshot 共享 inode。不复制宿主外部 hardlink、device、socket 或提权元数据。 -### 8.3 工作区导入、发布和回退范围 +现有树上限不提高:`MAX_TREE_ENTRIES` 1_000_000,`MAX_TREE_BYTES` 64 MiB,`MAX_TREE_DEPTH` 32,`MAX_LINEAGE` 64。 -首期以托管工作区为事务单位: -- 从授权源目录创建初始 seed,后续 run 在私有 branch 工作;原源目录不自动被覆写。 -- 对仍被外部写入的普通目录,复制不能提供跨文件的线性一致快照。严格导入必须依赖管理员静止源或经过验证的文件系统 snapshot;否则显式标记为普通导入、检测可见竞争并拒绝不一致,不能承诺来源的时间点一致性。 -- 管理端维护 `workspace_head` 引用;run 成功可按策略原子发布到托管 head。head 的 CAS 失败保留 private branch,返回 conflict;不覆盖其他 run 的更新。 -- 导出到宿主使用新目录的 staging + rename。对任意现有宿主目录做多文件合并不具备通用原子性,列为独立显式操作,不列入首期完整回退保证。 +导入、发布和回退: -该模型会改变当前直接写原工作区的行为,API 必须返回 branch/head 与 publication 状态,客户端不能把“已 staged”显示成“宿主原目录已更新”。 +- 从授权源目录创建初始 seed。后续 run 在私有 branch 工作。原源目录不自动被覆写。 +- 导入一致性只接受 `ordinary_copy`。对仍被外部写入的普通目录,不承诺时间点一致。本计划不依赖、不实现文件系统 snapshot。 +- 管理端维护 `workspace_head`。run 成功可按策略原子发布到托管 head。head 的 CAS 失败保留 private branch,返回 conflict,不覆盖其他 run 的更新。 +- 对任意现有宿主目录做多文件合并不具备通用原子性。该操作不列入完整回退保证。 +- API 返回 branch、head 与 publication 状态。`Staged` 不表示宿主原目录已更新。publication 状态使用现有 `ReadOnly`、`Committed`、`Staged`、`Conflict`、`NotPublished`、`Indeterminate`。 --- -## 9. Native Effect Broker 与 crash-safe 提交 - -### 9.1 事务单位 - -同步写工具默认使用子 effect view:基于当前 branch head 建立私有可写视图,在其中执行 RSS 多步文件操作或 terminal。工具成功后关闭写入者并生成不可变结果树;失败可丢弃子视图,父 head 不变。 +## 9. Native Effect Broker 与提交 -初始后端采用有界复制创建独立树,避免假设 reflink/OverlayFS。后续可增加有能力探测和一致语义测试的 CoW 后端。禁止用普通 hardlink 把可写树与不可变 snapshot 共享 inode;也不能复制宿主外部 hardlink、device、socket 或提权元数据。 +同步写工具默认使用子 effect view:基于当前 branch head 建立私有可写视图,在其中执行 RSS 文件操作或现有 terminal 后端。工具成功后关闭写入者并生成不可变结果树。失败可丢弃子视图,父 head 不变。 -大型工作区的复制开销必须测量。只读 toolchain/依赖缓存不进入每次复制;可写 build 输出仍属于 effect 和配额,不能因性能需求绕过隔离。 - -### 9.2 与现有 lifecycle 对接 +与现有 lifecycle 对接: ```text prepare/approval - → durable tool.started + effect.intent + → durable tool.started + effect intent → 发行绑定 graph/plugin/run/branch epoch 的一次性授权 - → Native effect 执行,所有输出进入私有 view - → quiesce + sync + 不可变 tree/receipt 准备 + → 再次检查取消、原 deadline、撤销和迟到批准 + → Native effect 执行,输出进入私有 view + → writer settled + 不可变 tree/receipt 准备 → durable effect receipt + branch-head CAS + tool result - → 内存 publication + provider continuation + → 内存 publication ``` -- 源码编译、文件树复制、进程等待和 fsync 等重活在全局提交门外。 -- SQLite 仍是控制面原子点:不可变文件对象先落盘,事务只发布它们的引用。崩溃产生的未引用对象按 journal 回收;已提交引用不能指向未持久化树。 -- 现有 tool_call/started 配对、不成功不发行 token、取消/超时/撤销门禁、原始 deadline、输出预算和去重不变量继续保留。 -- RSS 报告的 effect 类型只是请求,Native 以实际已发行能力和真实操作生成 receipt;不相信插件自称“纯函数”。 -- 若 Native 已完成 effect 而 RSS 未提交工具结果,恢复读取已持久化 receipt;不得再次执行已经发生的操作。状态不明则返回 `effect_indeterminate`,保留分支并等待显式 reconcile,不自动重复 terminal。 -- shared-service 可变状态和不具备事务协议的远端 effect 不伪装成上述本地原子事务。 +- 源码编译、文件树复制、进程等待和 fsync 在全局提交门外。 +- SQLite 仍是控制面原子点。不可变文件对象先落盘,事务只发布它们的引用。崩溃产生的未引用对象按 journal 回收。已提交引用不能指向未持久化树。 +- 现有 tool_call/started 配对、不成功不发行 token、取消、超时、撤销、原始 deadline、输出预算和去重不变量继续保留。 +- RSS 报告的 effect 类型只是请求。Native 以实际已发行能力和真实操作生成 receipt。不相信插件自称纯函数。 +- Native 已完成 effect 而 RSS 未提交工具结果时,恢复读取已持久化 receipt,不得再次执行已经发生的操作。状态不明则返回 `effect_indeterminate`,保留分支并等待显式 reconcile,不自动重复 terminal。 +- 不具备事务协议的远端 effect 不伪装成本地原子事务。`Indeterminate` 不能记为成功。 -建议 journal 状态:`intent → running → prepared → committed`;异常分支 `aborted / interrupted / indeterminate / needs_reconciliation`。每条记录绑定准确 owner、effect、call、graph、plugin revision、branch epoch、父/子 root 与 output artifact。 +journal 绑定 owner、effect、call、graph、plugin revision、branch epoch、父/子 root 与 output artifact。工作区 journal kind 使用现有 `Imported`、`Published`、`Discarded`、`Checkpoint`、`Restored`。不另造一套状态机名称。 --- @@ -389,246 +404,260 @@ prepare/approval ### 10.1 Checkpoint 内容 `Checkpoint` 保存: + - workspace immutable root 与 branch lineage; - 插件状态根及其 schema/revision; - graph lock、registry identity、公开配置 revision; - 业务 history cursor、已闭合 call/output 边界、parent run; - 已知不可逆 effect 的 high-water mark 和受控 artifact refs。 -不保存 access/refresh、VM heap、Native token、FD、socket、活进程、审批授权或整个控制面 DB 文件。 +不保存 access/refresh、VM heap、Native token、FD、socket、活进程、审批授权或整个控制面 DB 文件。公开 checkpoint 标识不能单独恢复分支。`restore_from_public_ids` 返回 `AuthorityRequired`。 ### 10.2 创建条件 -1. 取得该 branch/session 的维护 lease,并让相关 run 在工具批次闭合的协作安全点暂停新 provider 请求和 tool dispatch。已有 provider stream / effect 必须先 settled;不在半个模型调用或半组 tool outputs 上截断。暂停等待受管理请求期限和原 run 剩余期限共同约束,不能延长原 run。 -2. 在提交门外等待当前 effect settled。活动写进程存在时返回 `checkpoint_busy`;显式 quiesce 模式才先取消、终止并确认该沙盒的全部后代退出。 -3. Native 在提交门内重新验证原 graph、head、状态 revision、history cursor 和 lease;checkpoint 只落在完整模型工具批次结束的位置,不能保留无对应 output 的 pending tool_call。发生相关并发变化时拒绝或在原期限内重新准备,不能记录混合状态。 -4. 原子记录 checkpoint 引用并释放维护 lease。其他工作区继续运行;不使用服务级全局暂停。 +1. 取得该 branch/session 的维护 lease。相关 run 在工具批次闭合的协作安全点暂停新的 tool dispatch。已有 effect 必须先 settled。不在半个模型调用或半组 tool outputs 上截断。暂停等待受管理请求期限和原 run 剩余期限共同约束,不能延长原 run。 +2. 在提交门外等待当前 effect settled。可写 view 仍打开时返回 `CheckpointBusy`。不把 OS 后代命名空间清理作为前置条件。 +3. Native 在提交门内重新验证原 graph、head、状态 revision、history cursor 和 lease。checkpoint 只落在完整工具批次结束的位置,不能保留无对应 output 的 pending tool_call。发生相关并发变化时拒绝,或在原期限内重新准备。不能记录混合状态。 +4. 原子记录 checkpoint 引用并释放维护 lease。其他工作区继续运行。不使用服务级全局暂停。 ### 10.3 恢复 -- 用户恢复产生新 branch。若目标 branch 有活动 run,先显式取消并等待实际资源退出;不在执行中替换脚下目录。 -- 使用 checkpoint 的确切 graph;选择新 graph 时先通过独立的 schema/state 兼容准备。 -- branch epoch 递增;旧 token、process handle 和未完成 effect 的权限全部失效。重新检查当前 workspace/credential/plugin 撤销策略。 -- 新 run 从 checkpoint 的闭合历史前缀开始,保留 lineage;旧 run、旧事件、旧计费及旧投递记录保持只读。后续新调用使用新 call ID 和 parent。 -- 新 run 有明确的新 admission/deadline;不复活旧 run,不重置旧 run 的计时来绕过超时。 -- 已在远端发生的行为或用户已经收到的进度不被抹除;恢复结果附带 `external_effects_not_reverted` 清单。 -- 不能用 SQLite 文件回滚实现该功能,否则会错误恢复权限、usage、审批和投递游标。 +- 恢复产生新 branch。目标 branch 有活动 run 时,先按既有取消路径停止并等待现有进程后端报告退出。不在执行中替换脚下目录。 +- 默认使用 checkpoint 的确切 graph。选择新 graph 时先通过独立的 schema/state 兼容准备。 +- branch epoch 递增。旧 token、process handle 和未完成 effect 的权限失效。重新检查当前 workspace 和 plugin 撤销策略。 +- 新 run 从 checkpoint 的闭合历史前缀开始,保留 lineage。旧 run、旧事件、旧计费及旧投递记录保持只读。后续新调用使用新 call ID 和 parent。 +- 新 run 有明确的新 admission 和 deadline。不复活旧 run,不重置旧 run 的计时来绕过超时。 +- 已在远端发生的行为,或用户已经收到的进度,不被抹除。恢复结果附带 `external_effects_not_reverted`。 +- 不用 SQLite 文件回滚实现该功能。文件回滚会错误恢复权限、usage、审批和投递游标。 +- lineage 达到 64 时拒绝继续增长。 --- -## 11. 拟新增管理 API 与事件 +## 11. 管理 API 与事件 + +普通 run 的既有 HTTP routes 保持兼容。下文已注册的接口按现有契约维护。尚未注册的接口在实现前不能对外宣称存在。不新增本列表以外的 route。 -以下均为拟定新接口,不能在实现前对外宣称存在。普通 run 的既有 HTTP routes 保持兼容。 +现有 `POST /v1/runs` 可增加计划内可选字段 `workspace_branch`、`graph_id`。普通新分支默认采用 active graph。恢复分支默认采用 checkpoint 的 graph。显式指定其他图必须通过部署空间许可和状态兼容检查。graph ID 本身不授予任意已安装插件的调用权。响应增加实际固定的 graph、branch、epoch 和 publication 状态。这些字段进入 typed 请求契约,不能落入任意 options,也不能由客户端路径生成 Native authority。 -现有 `POST /v1/runs` 拟增加可选 `workspace_branch`、`graph_id`:普通新分支默认采用 active graph,恢复分支默认采用 checkpoint 的 graph。显式指定其他图必须通过部署空间许可和状态兼容检查;graph ID 本身不授予任意已安装插件的调用权。响应增加实际固定的 graph、branch、epoch 和 publication 策略。新字段必须进入 typed 请求契约,不能继续落入任意 options 或由客户端路径生成 Native authority。 +已注册: + +- `POST /v1/workspaces/{workspace_id}/checkpoints` +- `POST /v1/workspaces/{workspace_id}/restore` +- `GET /v1/runs/{run_id}/effects` + +`GET /v1/runs/{run_id}/effects` 使用该 run 的 typed managed binding 与 Native source-root 查询授权。RSS 在 `LIMIT` 前按 run、profile、session、workspace、branch 过滤。实现位于 `src/gateway/effects.rs`、`src/gateway/api_server.rs`、`src/service/managed_effects.rs`、`src/effects/journal.rs`、`src/gateway/store.rs` 和 `rss/storage/effects.rss`。该查询只覆盖 effect 账本,不覆盖本节其余管理接口。 + +仍属本计划、且名称已经确定的管理接口: -插件管理面,要求独立管理员作用域并绑定 tenant: - `POST /v1/plugins/install`:受控来源导入,返回不可变 revision。 -- `POST /v1/plugin-graphs/resolve`:解析 lock + diff,不激活。 -- `POST /v1/plugin-graphs/{graph_id}/prepare`:编译/验证,返回状态。 -- `POST /v1/plugin-activations`:携带 graph、目标部署空间、expected_generation。 +- `POST /v1/plugin-graphs/resolve`:解析 lock 和 diff,不激活。 +- `POST /v1/plugin-graphs/{graph_id}/prepare`:编译和验证,返回状态。 +- `POST /v1/plugin-activations`:携带 graph、目标部署空间、`expected_generation`。 - `GET /v1/plugin-graphs/{graph_id}`:节点、边、contract、权限和错误链。 -- `POST /v1/plugin-activations/{activation_id}/rollback`:产生新 activation,权限与正常激活相同。 - -工作区管理面,要求该 workspace 的读/恢复/发布权限: -- `POST /v1/workspaces/{workspace_id}/checkpoints`。 +- `POST /v1/plugin-activations/{activation_id}/rollback`:产生新 activation。权限与正常激活相同。 - `POST /v1/workspaces/{workspace_id}/branches`:可指定 checkpoint 来源。 -- `POST /v1/workspaces/{workspace_id}/restore`:返回新 branch,不能偷偷回写原目录。 -- `POST /v1/workspaces/{workspace_id}/publish`:携带 branch、expected_head。 -- `GET /v1/runs/{run_id}/effects`:有界 receipt/journal 列表。 +- `POST /v1/workspaces/{workspace_id}/publish`:携带 branch、`expected_head`。 + +安装和激活使用现有 `InstallAuthorization` 与 workspace/owner 校验。run token 不能激活插件。不新增 tenant,也不新增独立管理员产品。跨 workspace 或跨 branch 的未授权访问失败。冲突返回 409。不存在和无权的对象不泄露私有路径。对象标识由 Native 查询并验证 owner。path、graph ID 和签名都不能充当授权。 + +新增 SSE 字段与事件保持显式 schema,且只使用下列已列名称: -新增 SSE 字段与事件保持显式 schema/version: - 每个 run 可查询 `graph_id`、`activation_generation`、`workspace_branch`、`head`。 -- `plugin.graph_activated`、`effect.started/committed/aborted`、`checkpoint.created`、`workspace.restored/published`。 -- plugin/event version 不能在已冻结的 run 中改变语义;delivery outbox 对应的投影版本随记录固定。 -- 最终 `run.completed` 在配置要求的 workspace publication 完成后发出;若仅 staged,结果显式声明 staged branch。 -- 管理对象标识一律由 Native 查询并验证 owner;不能把 path、graph ID 或签名当作授权。 +- `plugin.graph_activated`、`effect.started`、`effect.committed`、`effect.aborted`、`checkpoint.created`、`workspace.restored`、`workspace.published`。 +- 已冻结 run 中的 plugin/event 语义不改变。既有 delivery/outbox 继续使用该 run 已固定的记录。 +- 配置要求 workspace publication 完成时,最终 `run.completed` 在该 publication 完成后发出。仅 staged 时,结果显式声明 staged branch。 --- ## 12. 分阶段实施任务与文件清单 -所有代码任务遵循 RED → GREEN → REFACTOR:先增加具体失败断言并实际运行,再做最小实现,然后运行该目标及直接邻接回归。下面列出的新文件和测试目标均为拟创建;实际实现若文件落点调整,须先更新本计划。不要为完成一个阶段一次性拆散整个 `service.rs`。 +代码任务遵循 RED、GREEN、REFACTOR:先增加具体失败断言并实际运行,再做最小实现,然后运行该目标及直接邻接回归。文件落点若调整,先更新本计划。不要为完成一个阶段一次性拆散整个 `service.rs`。 -### M0:验收基线与宿主能力探测 +T02 和 T09 已移出本计划。编号不回收,也不另开替代任务。 + +### M0:验收基线 **T01 基线固定** -- 读取当前改动和真实 API E2E 记录;将既有生产修复与本计划实现分开管理。 -- Test:`tests/codex_access_live_e2e_tests.rs` 已有 `live_rust_program::real_api_server_http_rust_program_e2e`;普通回归先运行 `service_tests`、`rss_process_tool_tests`、`real_tool_continuation_tests`。 -- 不重复真实凭据测试作为日常单测;新真实 E2E 在最终阶段显式 opt-in。 -**T02 sandbox 能力报告** -- Create:`src/sandbox/{mod.rs,types.rs,probe.rs}`、`tests/sandbox_backend_tests.rs`。OS 隔离后端独立于 workspace/effect 事务模块,便于独立实现与验证。 -- RED:缺少 mandatory namespace/cgroup/seccomp 任一项时 `strict_backend_is_unavailable_without_all_required_controls` 必须失败关闭。 -- GREEN:只读能力报告与单独的受控启动探针;不修改系统 sysctl,不自动提升权限。 -- Gate:`cargo test --locked --test sandbox_backend_tests`。缺少宿主条件是明确的交付前置条件,不能将跳过隔离用例记为 PASS。 +- 将既有生产修复与本计划实现分开管理。未提交的无关修改保持原样。 +- 普通回归使用现有 `tests/service_tests.rs`、`tests/rss_process_tool_tests.rs`、`tests/real_tool_continuation_tests.rs`。 +- 不把真实凭据、真实外网或真实用户数据测试作为本计划门禁。 ### M1:manifest、依赖图与持久化 lock **T03 manifest 与包存储** -- Create:`src/plugins/{mod.rs,types.rs,manifest.rs,store.rs}`、`tests/plugin_manifest_tests.rs`、`tests/plugin_schema_sync_tests.rs`、`schemas/plugin-manifest.schema.yaml`、`schemas/effect-contract.schema.yaml`、`schemas/README.md`。 -- 先增加 schema/解析器漂移与正反例测试;从本任务开始,每个新增 YAML 格式都要有仓库内 schema,后续实现修改必须同步更新。 -- RED:重复字段、路径逃逸、symlink 包、同版本覆盖、缺失发布者授权、缺失 effects、未覆盖 export、未知 effect 和伪造 recovery 均拒绝;显式空 effect 的纯入口及有效受限写入口接受。 -- GREEN:严格有界解析、Native 所有的不可变包目录和 typed metadata;根据需要在 `Cargo.toml`/`Cargo.lock` 添加 `semver`。 -- Gate:`cargo test --locked --test plugin_manifest_tests --test plugin_schema_sync_tests`;提交前确认相关 YAML schemas 已 tracked,CI 拒绝未同步或生成后有差异的版本。 - -**T04 resolver 与 closed bundle 集成** -- Create:`src/plugins/{resolver.rs,graph.rs}`、`tests/plugin_graph_tests.rs`、`schemas/plugin-graph-lock.schema.yaml`;同步扩展 `tests/plugin_schema_sync_tests.rs`。 -- Modify:`src/runtime/closed_bundle.rs` 的 crate 内复用接口,不扩展 Core。 -- RED:冲突链、缺依赖、循环、越权 host import、未声明的直接 effect、callee 传递 effect 未被 allow 覆盖、不同文件枚举顺序造成不同解;正控为准确且满足入口 effect 上界的固定闭包。 -- GREEN:版本求解、不可变 lock、受限入口解析及准备。 + +- 维护 `src/plugins/{mod.rs,manifest.rs,store.rs,schema.rs,yaml.rs,error.rs}`、`tests/plugin_manifest_tests.rs`、`tests/plugin_schema_sync_tests.rs`,以及第 4.5 节列出的 manifest/effect schema。 +- RED:重复字段、路径逃逸、symlink 包、同版本覆盖、缺失发布者授权、缺失 effects、未覆盖 export、未知 effect 和非法 recovery 均拒绝。显式空 effect 的纯入口,以及 `workspace.fs.write` 的 `checkpoint` recovery,可以接受。`process.spawn` 声称 `checkpoint` 时拒绝。 +- GREEN:严格有界解析、Native 所有的不可变包目录和 typed metadata。继续使用现有 `semver` 依赖,不增加第二个求解器。 +- Gate:`cargo test --locked --test plugin_manifest_tests --test plugin_schema_sync_tests`。 + +**T04 resolver、compiler 与 closed bundle** + +- 维护 `src/plugins/{resolve.rs,prepare.rs}`、`tests/plugin_graph_tests.rs`、`schemas/plugin-graph-lock.schema.yaml`。 +- 复用 `src/runtime/closed_bundle.rs` 的 crate 内接口。不扩展 Core,不提高 256 文件 / 8 MiB。 +- RED:冲突链、缺依赖、循环、越权 host import、未声明的直接 effect、callee 传递 effect 未被 allow 覆盖、不同文件枚举顺序造成不同解。正控为满足入口 effect 上界的固定闭包。 +- GREEN:版本求解、不可变 lock、受限入口解析、compiler 和 load。`ToolOnly` 可执行。`ContextPrompt` 只作为既有 lock 变体保持可校验。 - Gate:`cargo test --locked --test plugin_graph_tests`。 -**T05 lock / activation 的 storage 协议** -- Create:`rss/storage/plugins.rss`、`tests/plugin_store_recovery_tests.rs`。 -- Modify:`rss/storage/schema.rss`、`rss/storage/main.rss`、`src/domain.rs`、`src/sqlite_storage_rows.rs`。 -- RED:提交失败无半激活;重启重建同一 lock;有 durable 引用时不能 GC。 -- GREEN:向前兼容的对象/引用记录,SQLite 访问继续走生产 typed SDK/RSS 路径。 +**T05 lock、pin 与 activation 的 storage 协议** + +- 维护 `rss/storage/plugins.rss`、`tests/plugin_store_recovery_tests.rs`。 +- 需要时修改 `rss/storage/schema.rss`、`rss/storage/main.rss`、`src/domain.rs`、`src/sqlite_storage_rows.rs`。插件 activate 不触发控制面迁移。 +- RED:提交失败无半激活。重启重建同一 lock。有 durable 引用时不能 GC。 +- GREEN:对象和引用记录走现有 typed SDK/RSS 路径。 - Gate:`cargo test --locked --test plugin_store_recovery_tests`。 ### M2:按 run 固定图与 RSS 插件调用边界 **T06 admission / replay 固定图** -- Create:`tests/plugin_run_pinning_tests.rs`。 -- Modify:`src/service.rs`、`src/registry.rs`、`src/durable_provider.rs`、`rss/storage/admission.rss`、`rss/storage/runs.rss`。 -- RED:A run 开始后激活 B,A 的后续 provider/tool/replay 仍为 A;新 run 为 B;重启不改用 active 图;缺 A 包明确失败。 -- GREEN:既有 admission 事务加入 lock/ref 固定;prepared graph 在门外准备,不新增热路径编译。 -- Gate:`cargo test --locked --test plugin_run_pinning_tests --test provider_durable_replay_loop_tests`。 + +- 使用现有 `tests/tools_pin_production_ab.rs`、`tests/tools_pinned_commit_outcome.rs`、`tests/provider_durable_replay_loop_tests.rs`。不另建平行 pin 测试目标。 +- 修改 `src/service.rs`、`src/registry.rs`、`src/plugins/pin.rs`、`src/durable_provider.rs`、`rss/storage/admission.rss`、`rss/storage/runs.rss` 中与 graph pin 直接相关的部分。不改 provider 协议。 +- RED:A run 开始后激活 B,A 的后续 tool 和 replay 仍为 A。新 run 为 B。重启不改用 active 图。缺 A 包明确失败。 +- GREEN:既有 admission 事务加入 lock/ref 固定。prepared graph 在门外准备,不在热路径编译。 +- Gate:`cargo test --locked --test tools_pin_production_ab --test tools_pinned_commit_outcome --test provider_durable_replay_loop_tests`。 **T07 typed 插件调用桥与工具适配** -- Create:`src/plugins/invocation.rs`、`src/runtime/plugin_host.rs`、`tests/plugin_invocation_tests.rs`。 -- Modify:`src/runtime/{agent_host.rs,host_types.rs,rss_runner.rs}`、`rss/tools/{registry.rss,dispatch.rss}`。 -- RED:A 无法调用未声明的 B;B 不能继承 A 的其他权限;resource 无法借 JSON 跨 VM;同名不同 revision 不能重放旧结果;缺失 effect 的真实 Native 操作在发生前被拒绝;纯入口不能通过库、时钟或插件委托隐藏 effect。 -- GREEN:独立调用主体、Native 重新发行子 VM 资源、六工具 schemas 不变。 -- Gate:`cargo test --locked --test plugin_invocation_tests --test rss_process_tool_tests`。 -- 该阶段只为内置可信包提供兼容路径;在 M3 通过前不开放不可信进程插件。 -### M3:统一 effect view 与 OS 隔离 +- 维护 `src/runtime/native_tools.rs`、`src/runtime/agent_host.rs`、`rss/tools/{registry.rss,dispatch.rss}`、`tests/tools_pin_invoke_tracer.rs`。 +- RED:A 无法调用未声明的 B。B 不能继承 A 的其他权限。resource 无法借 JSON 跨 VM。同名不同 revision 不能重放旧结果。缺失 effect 的真实 Native 操作在发生前被拒绝。纯入口不能通过库、时钟或插件委托隐藏 effect。迟到批准不执行。 +- GREEN:独立调用主体、Native 重新发行子 VM 资源、现有工具 schemas 不变。 +- Gate:`cargo test --locked --test tools_pin_invoke_tracer --test rss_process_tool_tests`。 +- 本阶段只连接内置可信 tool/library 包。不开放不可信 native 进程插件。 + +### M3:托管 effect view 与提交 **T08 托管文件树和子 effect view** -- Create:`src/effects/{mod.rs,types.rs,workspace.rs,tree_store.rs}`、`tests/effect_workspace_tests.rs`。 -- Modify:`src/capabilities/{filesystem.rs,confined_io.rs}`。 -- RED:写 view 不改 seed;文件工具与 terminal 看到同一内容;失败工具的父 head 不变;可写 view 不与 checkpoint 共享可写 inode。 -- GREEN:有界复制后端、private temp、原子 Native refs、quota 和安全文件类型处理。 -- Gate:`cargo test --locked --test effect_workspace_tests`。 -**T09 rootless OCI 进程后端** -- Create:`src/sandbox/oci.rs`、`tests/sandbox_isolation_tests.rs`。 -- Modify:`src/capabilities/process.rs`、`src/capabilities/vm_io/bounded_process.rs`。 -- RED:尝试访问宿主哨兵文件、网络、daemon socket、宿主 PID namespace 必须拒绝;fork/setsid 后代在终止后实际消失。 -- GREEN:固定 OCI profile、工具链 rootfs、namespace/seccomp/cgroup 策略、Native launcher 生命周期。 -- Gate:`cargo test --locked --test sandbox_isolation_tests`;缺后端时给明确 NOT RUN 结果,发布验收不接受以 ignore 替代隔离证明。 +- 维护 `src/effects/{mod.rs,types.rs,workspace.rs,tree_store.rs}`、`tests/effect_workspace_tests.rs`。 +- 需要时修改 `src/capabilities/{filesystem.rs,confined_io.rs}`,使文件工具与 terminal 使用同一托管视图。不把该修改扩展成 OS 隔离后端。 +- RED:写 view 不改 seed。文件工具与 terminal 看到同一内容。失败工具的父 head 不变。可写 view 不与 checkpoint 共享可写 inode。导入非 `ordinary_copy` 时拒绝。 +- GREEN:有界复制、private temp、原子 Native refs、现有 quota 和安全文件类型处理。 +- Gate:`cargo test --locked --test effect_workspace_tests`。 **T10 effect journal 与真实工具提交** -- Create:`src/effects/{broker.rs,journal.rs}`、`rss/storage/effects.rss`、`tests/effect_commit_recovery_tests.rs`。 -- Modify:`src/capabilities/{lifecycle.rs,types.rs}`、`src/service.rs`、`rss/storage/schema.rss`。 -- RED:在 intent、文件准备、持久化 receipt、head CAS、result 提交各点崩溃,重启后无已提交悬空 root、无重复已执行 terminal。 -- GREEN:将 Native receipt 和现有 started/result 绑定;不扩大 token 权限、不绕过审批、不重置 deadline。 + +- 维护 `src/effects/{broker.rs,journal.rs}`、`rss/storage/effects.rss`、`tests/effect_commit_recovery_tests.rs`。 +- 需要时修改 `src/capabilities/{lifecycle.rs,types.rs}`、`src/service.rs`。不扩大 token 权限,不绕过审批,不重置 deadline。 +- RED:在 intent、文件准备、持久化 receipt、head CAS、result 提交各点崩溃后,重启无已提交悬空 root,无重复已执行 terminal。receipt 已存在时不重复 effect。未知状态不报成功。 +- GREEN:Native receipt 与现有 started/result 绑定。取消、原 clock 和迟到批准仍阻止未开始的执行。 - Gate:`cargo test --locked --test effect_commit_recovery_tests --test real_tool_continuation_tests`。 ### M4:checkpoint、恢复与插件状态 **T11 checkpoint 和 branch restore** -- Create:`src/effects/{checkpoint.rs,recovery.rs}`、`rss/storage/checkpoints.rss`、`tests/effect_checkpoint_tests.rs`、`schemas/v1/checkpoint.schema.yaml`;同步扩展 schema 一致性测试。 -- RED:活动 writer 拒绝 checkpoint;恢复前确认沙盒后代退出;旧 epoch token 被拒绝;旧审计/usage/审批记录不被删除或复活。 -- GREEN:闭合 history cursor + root refs;恢复新 branch/new run,重新授权。 -- Gate:`cargo test --locked --test effect_checkpoint_tests`。 + +- 维护 `src/effects/checkpoint.rs`、`rss/storage/checkpoints.rss`、`tests/effect_checkpoint_tests.rs`、`tests/managed_checkpoint_restore_tests.rs`、`tests/managed_checkpoint_api_tests.rs`,以及现有 checkpoint/restore schema。 +- RED:活动 writer 拒绝 checkpoint。旧 epoch token 被拒绝。公开 ID 不能恢复。旧审计、usage 和审批记录不被删除或复活。外部 effect 仍标为未撤销。 +- GREEN:闭合 history cursor 与 root refs。恢复产生新 branch 和新 run,并重新授权。 +- Gate:`cargo test --locked --test effect_checkpoint_tests --test managed_checkpoint_restore_tests --test managed_checkpoint_api_tests`。 **T12 有版本的插件状态** -- Create:`src/plugins/state.rs`、`tests/plugin_state_migration_tests.rs`。 -- Modify:`rss/storage/plugins.rss`、`src/plugins/types.rs`。 -- RED:无 inverse 的破坏性迁移不能声称可 graph rollback;旧 run 不观察新 schema;迁移崩溃不切换 active ref。 + +- 维护 `src/plugins/pin.rs`、`rss/storage/plugins.rss` 中的 plugin state root。测试使用 `tests/plugin_state_migration_tests.rs`;该文件尚不存在,是本任务的原定测试目标,不另起名称。 +- RED:无 inverse 的破坏性迁移不能声称可 graph rollback。旧 run 不观察新 schema。迁移崩溃不切换 active ref。迁移入口若执行文件、网络或 SQL,则拒绝。 - GREEN:纯迁移、新状态对象、session 维护 lease 和 checkpoint 兼容检查。 - Gate:`cargo test --locked --test plugin_state_migration_tests`。 -### M5:热重载、回退与 harness 业务模块迁移 +### M5:热重载、回退与 tool 包接入 **T13 原子激活、回退和 GC** -- Create:`src/plugins/{activation.rs,retention.rs}`、`tests/plugin_hot_reload_tests.rs`。 -- RED:两个管理请求同 generation 只有一个成功;回退 generation 递增;旧 run 与 pending delivery 固定旧图;引用对象不能删除;patch 版本新增 effect、扩大 scope 或降低 recovery 保证时不得自动激活。 + +- 维护 `src/plugins/control_store.rs`、`tests/plugin_hot_reload_tests.rs`。 +- RED:两个管理请求同 generation 只有一个成功。回退 generation 递增。旧 run 固定旧图。引用对象不能删除。patch 版本新增 effect、扩大 scope 或降低 recovery 保证时不得自动激活。 - GREEN:候选图准备、权限 diff、CAS 激活、可审计撤销和保留策略。 - Gate:`cargo test --locked --test plugin_hot_reload_tests`。 -**T14 harness 按边界迁移** -- Create:`rss/plugins/` 内置 manifest/入口、`tests/harness_plugin_tests.rs`。 -- Modify:`rss/agent/{main.rss,guardian.rss,compact.rss,provider_runtime.rss}`、`rss/llm/provider_dispatch.rss`、`src/prompt/coding.rs`、`src/runtime/delivery.rs`。 -- 按顺序完成独立 RED/GREEN:context/prompt → Guardian 策略 → provider codec → delivery 投影 → main 策略。 -- 每一步证明替换该插件不改变 Native grant、durable 事件身份、固定 schemas、秘密边界和停止行为。 -- Gate:`cargo test --locked --test harness_plugin_tests --test agent_loop_tests --test prompt_tests --test codex_responses_codec_tests`。 -- 存储底层与强制授权逻辑不因“插件化”移入可任意替换的普通插件。 - -### M6:真正的对外 API 与操作界面 - -**T15 插件 / 工作区管理 API** -- Create:`src/gateway/{plugins.rs,workspaces.rs}`、`tests/plugin_admin_api_tests.rs`、`tests/effect_admin_api_tests.rs`。 -- Modify:`src/gateway/api_server.rs`、`src/config.rs`、`src/cli.rs`。 -- RED:普通 run token 不可激活插件;跨 tenant、workspace、branch 越权失败;冲突返回 409;不存在和无权的对象不泄露私有路径。 -- GREEN:typed 管理请求、管理员作用域、dry-run/diff、effect/checkpoint/publication 响应。 -- Gate:`cargo test --locked --test plugin_admin_api_tests --test effect_admin_api_tests`。 - -**T16 HTTP 全链路 E2E 与崩溃矩阵** -- Create:`tests/plugin_effect_api_e2e_tests.rs`。 -- Modify:`tests/support/live_http_delivery.rs`;最终新增 opt-in live case 于 `tests/codex_access_live_e2e_tests.rs` / support。 -- 从真实 socket 上的 `POST /api/sessions`、`POST /v1/runs` 发起,禁止用内部 SDK 代交任务后把 SSE 接收等同于 API 入口验证。 -- 场景:A 图 run 写/编译 Rust → checkpoint → 激活 B → A run 继续使用 A → 新 run 使用 B → 恢复 checkpoint 到新分支 → 回退默认图 → HTTP 查询 graph、effects、messages,并验证最终 delivery 与 durable 数据一致。 -- 普通 E2E 使用确定性 provider fixture,真实工具和真实 OS 后端;最后单独 opt-in 实际模型写程序。真实场景通过并不替代 crash、越权和资源回收负控。 -- Gate:`cargo test --locked --test plugin_effect_api_e2e_tests`,再执行明确授权的 ignored live case。 +**T14 tool 包的图接入** + +保留图加载 tools 所需的内置包、manifest、exports 和 library 依赖。不迁移无关业务模块。 + +- 内置 tool/library 包进入插件 store,并由 graph resolve、load、pin。 +- 不修改 `rss/agent/main.rss`、`rss/agent/guardian.rss`、`rss/agent/compact.rss`、`rss/agent/provider_runtime.rss`、`rss/llm/provider_dispatch.rss`、`src/prompt/coding.rs`、`src/runtime/delivery.rs` 来安装新的可替换策略。这些文件的既有语义保持不变。 +- `harness_selection = ToolOnly` 是本任务的执行模式。`ContextPrompt` 只保持 lock 可读和可校验。 +- RED:替换或 pin tool 包不改变 Native grant、durable 事件身份、固定工具 schemas、秘密边界和停止行为。纯 library 不能获得 capability。 +- 不把 `tests/prompt_tests.rs` 或 `tests/codex_responses_codec_tests.rs` 作为本任务验收。`tests/agent_loop_tests.rs` 只作为内置循环未被改写的回归。 +- Gate:`cargo test --locked --test plugin_graph_tests --test tools_pin_production_ab --test tools_pin_invoke_tracer`。 + +### M6:管理 API 与范围内端到端测试 + +**T15 插件、工作区与 effect 管理 API** + +- 已注册的 effect 查询和 workspace checkpoint/restore 保持现有授权。继续用 `tests/effect_admin_api_tests.rs`、`tests/managed_checkpoint_api_tests.rs`。 +- 第 11 节尚未注册的 graph 管理、branch 和 publish 接口仍在本任务内。图管理测试目标保持 `tests/plugin_admin_api_tests.rs`。不另起测试名。 +- 修改 `src/gateway/api_server.rs` 以注册上述已列 route。工作区传输继续在 `src/gateway/workspaces.rs`。图管理传输放在 `src/gateway/plugins.rs`。publish 实现位于 `src/service/managed_publications.rs`。不修改 `src/cli.rs` 来增加管理命令。不在 `src/config.rs` 增加 tenant 配置。 +- RED:普通 run token 不可激活插件。跨 workspace、跨 branch 越权失败。冲突返回 409。不存在和无权的对象不泄露私有路径。 +- GREEN:typed 管理请求、现有 owner 授权、effect/checkpoint/publication 响应。 +- Gate:`cargo test --locked --test plugin_admin_api_tests --test effect_admin_api_tests --test managed_checkpoint_api_tests`。 + +**T16 范围内 HTTP 链路** + +- 测试目标保持 `tests/plugin_effect_api_e2e_tests.rs`。不另起名称。 +- 从真实 socket 上的现有 `POST /api/sessions`、`POST /v1/runs` 发起。禁止用内部 SDK 代交后把 SSE 接收等同于 API 入口验证。 +- 可以复用 `tests/support/live_http_delivery.rs` 的现有 HTTP 支撑。不改 delivery projection,不改 provider 协议,不把该文件的修改当作 delivery 重写验收。 +- 场景:A 图 run 对托管工作区写入,checkpoint,激活 B,A run 继续使用 A,新 run 使用 B,恢复 checkpoint 到新分支,回退默认图。HTTP 查询 graph pin、effects 和既有 messages。既有消息记录与 durable 记录保持一致。该检查不构成 delivery projection 重写验收。 +- 使用确定性 provider fixture、真实工具和现有 bounded process 后端。不跑真实外网,不使用真实用户数据,不把 OS 隔离负控当作通过条件。 +- crash、越权和资源回收负控仍须执行。跳过这些负控不能记为通过。 +- Gate:`cargo test --locked --test plugin_effect_api_e2e_tests`。不要求 `tests/codex_access_live_e2e_tests.rs` 的真实模型用例。 --- ## 13. 必须覆盖的测试矩阵 ### 插件与版本 -- 安装重复、损坏包、签名/发布者失败、依赖缺失/冲突/循环、契约不兼容。 -- 仓库 YAML schemas 自身合法、跨 schema 本地 refs 可解析、示例/负例与实际解析器一致;类型或 effect 契约变更而 schema 未更新时 CI 必须失败;生成文件无漂移且被版本控制。 -- effect 声明缺失、纯入口伪装、direct/delegated 边界、传递 effect 漏报、未知 host effect、伪造恢复能力、版本升级 effect 扩张及 runtime before-effect denial。 + +- 安装重复、损坏包、发布者授权失败、依赖缺失、冲突、循环、契约不兼容。 +- 仓库 YAML schemas 自身合法。本地 refs 可解析。示例和负例与实际解析器一致。类型或 effect 契约变更而 schema 未更新时 CI 失败。生成文件无漂移且被版本控制。 +- effect 声明缺失、纯入口伪装、direct/delegated 边界、传递 effect 漏报、未知 host effect、非法 recovery、版本升级 effect 扩张,以及 runtime 在 effect 发生前拒绝。 - Native imports 越权、跨包直接 import 绕过、插件 ID 与 graph ID 伪造。 -- A/B 并存、并发 activation、rollback ABA、重启恢复、缺包、GC 与缓存容量。 -- 源目录在准备后改变,已准备图仍只执行原始已捕获源码;旧 VM owner 资源不可在新 VM 使用。 +- A/B 并存、并发 activation、rollback 后 generation 仍递增、重启恢复、缺包、GC 与缓存容量。 +- 源目录在准备后改变,已准备图仍只执行原始已捕获源码。旧 VM owner 资源不可在新 VM 使用。 ### 文件、进程与恢复 -- 创建/修改/删除/rename、执行权限、空目录、受限 symlink/hardlink 及超额文件树。 -- terminal 使用绝对路径、子进程派生、setsid、持有旧 FD、超时、取消、输出淹没、磁盘不足。 -- 文件工具写入后 terminal 立即读取;terminal 写入后 RSS read_file 返回同一 view。 -- snapshot 后修改再 restore,保证受控树与状态一致;不能恢复已撤销权限。 -- 所有关键 FS/SQLite publication 崩溃点;receipt 已存在时不重复 effect,未知状态不假成功。 -- 不改变 global suite concurrency,不使用放宽 deadline、取消断言或 retry-until-pass 使测试通过。 - -### API、交付与外部 effect -- 401/403、cross-tenant、409 CAS conflict、202 admission、实际 SSE 完成、消息/graph/effect 查询。 -- rollback 不删除已经投递的正文;outbox 继续使用固定版本;消息 ID、seq 和最终正文与数据库投影一致。 -- 模型调用用量不随 checkpoint 恢复清零;远端 effect 标为不可恢复或需要 reconcile。 -- 隔离检查用管理员预置的无敏感哨兵;不得读取真实凭据来证明逃逸被阻止。 - -### 真实最终验收 -- 模型自行生成 Rust 源码,真实文件写入/读回、编译、至少三个测试与正常/空/非法 CLI 分支,最终答复通过 API Server HTTP SSE 收到。 -- 模型生成物由父级独立编译核验;snapshot 前后文件与状态按受控 Native API 验证。 -- 如用户再次授权 Hermes pool access-only,仍只在隔离副本填假 refresh,记录零刷新请求;真实 token 不进入日志或插件快照。 -- 真实 OS 后端不可用、缺少 cgroup 委派、某负控未执行时按实际记录,不能报告整个沙盒已验收。 - -验证命令随阶段使用上述具名 targets,并保持现有 lease 的 target/TMP/offline/jobs 设置。最终在有后端能力的环境执行 Debug/Release 相关矩阵、`cargo fmt --all --check`、`cargo clippy --locked --all-features --all-targets -- -D warnings`、`git diff --check`。是否执行全 workspace suite 以变更范围及既有规则决定;不把 no-run 或零选中测试计为通过。 + +- 创建、修改、删除、rename、空目录、受限 symlink/hardlink 及超额文件树。超额使用现有树上限,不提高上限。 +- terminal 的超时、取消、输出预算和磁盘不足,使用现有 bounded process 后端和 fixture。不把宿主哨兵逃逸、namespace、seccomp 或 cgroup 负控列为验收。 +- 文件工具写入后,terminal 立即读取同一 view。terminal 写入后,RSS 文件读取返回同一 view。 +- checkpoint 后修改再 restore,受控树与已覆盖插件状态一致。不能恢复已撤销权限。 +- 关键 FS 和 SQLite publication 崩溃点。receipt 已存在时不重复 effect。未知状态不报成功。 +- 不改变 global suite concurrency。不使用放宽 deadline、取消断言或 retry-until-pass 使测试通过。 + +### API 与外部 effect + +- 401/403、409 CAS conflict、既有 admission 状态、实际 SSE 完成、消息查询、graph 查询、effect 查询。 +- rollback 不删除已经发出的正文。既有 outbox 继续使用该 run 已固定的记录。消息 ID、seq 和最终正文与既有数据库投影一致。不为此重写 delivery。 +- 模型调用用量不随 checkpoint 恢复清零。远端 effect 和进程外部副作用标为未撤销。 +- 测试不读取真实凭据,不访问真实外网,不使用真实用户数据。 + +实现阶段运行上文具名 targets,并保持现有 lease 的 target、TMP、offline 和 jobs 设置。最终执行与变更范围相应的 `cargo fmt --all --check`、`cargo clippy --locked --all-features --all-targets -- -D warnings` 和 `git diff --check`。是否执行全 workspace suite,按变更范围及既有规则决定。no-run 或零选中测试不计为通过。 --- ## 14. 上线顺序与验收门 -1. **G1 插件基础完成**:manifest/resolver/lock、精确回放和 run pinning 通过;仅内置受信包可用。 -2. **G2 沙盒执行完成**:文件工具与 terminal 的统一 view、真实 OS 越权负控、进程全树终止及 journal recovery 通过。 -3. **G3 本地效果回退完成**:checkpoint、branch、状态迁移、epoch 撤销与 crash 矩阵通过。 -4. **G4 插件热重载完成**:原子激活、旧/新图并存、回退兼容、retention 与 harness 迁移通过。 -5. **G5 对外功能完成**:实际 HTTP admission → 工具 effects → snapshot/restore → graph rollback → 最终 delivery 全链路通过。 +1. **G1 插件基础完成**:manifest、resolver、compiler、load、lock、精确回放和 run pinning 通过。仅内置受信 tool/library 包可用。 +2. **G2 托管执行完成**:文件工具与 terminal 的统一 view、现有进程后端上的取消和预算、journal recovery 通过。不含 OS 越权负控。 +3. **G3 本地托管回退完成**:checkpoint、branch、插件状态迁移、epoch 撤销与 crash 矩阵通过。外部副作用仍标为未撤销。 +4. **G4 插件热重载完成**:原子激活、旧图与新图并存、回退兼容和 retention 通过。不含 main、Guardian、prompt、provider codec 或 delivery 迁移。 +5. **G5 对外功能完成**:实际 HTTP admission、工具 effects、checkpoint/restore、graph rollback,以及 effect 查询通过。不含 delivery projection 或 provider 协议重写。 -可独立发布的首个可用切片为:内置 tools 的版本化 graph + mandatory OS 沙盒 + 托管工作区 checkpoint。随后补齐 harness 扩展点与管理 API;不在 G2 前把第三方进程插件作为可用功能开放。 +可独立使用的首个切片是:内置 tools 的版本化 graph,加上托管工作区 checkpoint。管理 API 按第 11 节补齐。不把第三方 native 进程插件作为可用功能开放。 -实施仍需实测并报告的环境条件:rootless runtime 版本、user namespace 策略、cgroup v2 委派、seccomp 可用性、toolchain 镜像和存储配额。方案选型已明确,能力不足时给出具体缺项;不得通过宿主裸执行、临时特权或弱化测试绕过。 +存储配额不足时返回现有 quota 错误。不得通过直接宿主写入、绕过审批、重置 deadline 或减弱断言来通过验收。 -## 15. 设计自检结论 +--- -- YAML 格式的 machine-readable schemas 纳入仓库版本控制,与实现、fixture 和文档同步更新,CI 对 drift 与兼容版本执行门禁。 -- 每个插件入口显式声明直接/委托 effects,纯入口显式为空;传递闭包和真实 Native 操作都受该上界约束,声明不授予权限。 -- 插件版本回退、状态回退和外部行为补偿已分开定义。 -- 所有 effectful 扩展均受 Native broker 与原权限/取消不变量约束。 -- 不在运行中替换 frozen tools/schema;明确新 run、旧 run、旧 outbox 的版本归属。 -- 不承诺任意外部目录的原子多文件合并,不承诺活进程内存或远端服务通用撤销。 -- 不快照/回滚控制面 DB、凭据、审批和 usage。 -- 当前仅产出设计计划;以上新类型、模块、路由和测试均待实现与实测。 +## 15. 设计自检 + +- 插件图模型、manifest、exports、library 依赖、resolve、compiler、load、pin、closure、cache、activation、hot reload、persistence 和对应 runtime 执行仍在计划内。 +- Tools 调用虚拟化、effect 声明、admission、默认拒绝、原 Native 授权、root、clock、取消、迟到批准不执行,仍在计划内。 +- 托管工作区和插件状态的 checkpoint、restore、branch、publish、replay、effect 账本查询及对应管理接口仍在计划内。 +- `PLUGIN_KINDS` 仍只有 `library` 和 `tool`。effect catalog 和 recovery 规则不扩大。现有包、闭包、树和 lineage 上界不提高。 +- `ContextPrompt` 保留为图 lock 变体。它不构成新的 prompt、context 或 Guardian 实现任务。 +- catalog 中的 `model.request`、`delivery.send`、`remote.write`、`event.emit`、`secret.use` 只用于声明和拒绝。它们不构成新子系统。 +- 内置 agent、reviewer、provider、默认 prompt、compaction 和 delivery 语义不改。 +- 不承诺任意进程、活 FD 或外部副作用可以快照或回滚。导入只有 `ordinary_copy`。 +- 不实施 OS namespace、OCI、cgroup、seccomp 或调试器隔离。 +- 不新增 admin/tenant 系统,不新增未列出的 route、limit 或 SDK API,不开兼容版本 lane。 +- 计划存在不表示上述能力已经交付。 diff --git a/rss/agent/compact.rss b/rss/agent/compact.rss index b1a1ee6..8103b1f 100644 --- a/rss/agent/compact.rss +++ b/rss/agent/compact.rss @@ -296,6 +296,19 @@ fn compact_boundary(messages: array, start_boundary: int) -> int { if message_has_call(message, result_id) { latest_result = cursor; } + } else if message_role(candidate) == "user" { + let content: array = message_content(candidate); + let mut part_index = 0; + while part_index < content.length { + let part: map = part_at(content, part_index); + if part_type(part) == "tool_result" { + let result_id: string = part_tool_call_id(part); + if result_id != "" && message_has_call(message, result_id) { + latest_result = cursor; + } + } + part_index += 1; + } } cursor += 1; } diff --git a/rss/storage/admission.rss b/rss/storage/admission.rss index a2aafd8..188496b 100644 --- a/rss/storage/admission.rss +++ b/rss/storage/admission.rss @@ -51,6 +51,11 @@ struct AdmissionReplayInput { request_selector: string } +struct RestorePinRequest {checkpoint_id:string} +struct RestorePinGraph {checkpoint_graph_ref_id:string,harness_selection:map} +struct RestorePinResult {graph:RestorePinGraph} +struct RestorePinSelection {request:RestorePinRequest,result:RestorePinResult} + struct AdmissionCreateInput { session_id: string, session_new: int, @@ -81,7 +86,13 @@ struct AdmissionCreateInput { } fn admission_owner_pin_sql() -> string { - "SELECT COALESCE(json_extract(idem.response_json, '$.tool_graph_id'), ''), COALESCE(json_extract(idem.response_json, '$.tool_ref_id'), ''), COALESCE(CAST(json_extract(idem.response_json, '$.tool_generation') AS INTEGER), 0), idem.resource_id, COALESCE((SELECT session_id FROM runs WHERE id = idem.resource_id), ''), COALESCE(json_extract(idem.response_json, '$.registry_identity'), ''), COALESCE(json_extract(idem.response_json, '$.workspace'), ''), COALESCE(json_extract(idem.response_json, '$.profile'), '') FROM idempotency_records idem WHERE idem.scope = ? AND idem.key = ? AND idem.resource_id = ? AND EXISTS (SELECT 1 FROM plugin_graph_refs refs WHERE refs.graph_id = json_extract(idem.response_json, '$.tool_graph_id') AND refs.owner_kind = 'run' AND refs.owner_id = idem.resource_id AND refs.ref_id = json_extract(idem.response_json, '$.tool_ref_id'))" + "SELECT COALESCE(json_extract(idem.response_json, '$.tool_graph_id'), ''), COALESCE(json_extract(idem.response_json, '$.tool_ref_id'), ''), COALESCE(CAST(json_extract(idem.response_json, '$.tool_generation') AS INTEGER), 0), idem.resource_id, COALESCE((SELECT session_id FROM runs WHERE id = idem.resource_id), ''), COALESCE(json_extract(idem.response_json, '$.registry_identity'), ''), COALESCE(json_extract(idem.response_json, '$.workspace'), ''), COALESCE(json_extract(idem.response_json, '$.profile'), ''), COALESCE(json_extract(idem.response_json, '$.harness_selection'), 'null') FROM idempotency_records idem WHERE idem.scope = ? AND idem.key = ? AND idem.resource_id = ? AND EXISTS (SELECT 1 FROM plugin_graph_refs refs WHERE refs.graph_id = json_extract(idem.response_json, '$.tool_graph_id') AND refs.owner_kind = 'run' AND refs.owner_id = idem.resource_id AND refs.ref_id = json_extract(idem.response_json, '$.tool_ref_id'))" +} + +fn decode_pin_selection(encoded:string)->map { + let mut selection:map={}; + if encoded != "null" {selection=json::decode(encoded);} + selection } fn admission_owner_pin(queried: SqliteQueryResult) -> map { @@ -93,6 +104,7 @@ fn admission_owner_pin(queried: SqliteQueryResult) -> map { let mut registry_identity: string = ""; let mut workspace: string = ""; let mut profile: string = ""; + let mut harness_selection_json: string = "null"; let mut pinned: bool = false; let rows = queried.rows.copy(); if rows.length == 1 { @@ -106,6 +118,7 @@ fn admission_owner_pin(queried: SqliteQueryResult) -> map { registry_identity = cells[5].text_value; workspace = cells[6].text_value; profile = cells[7].text_value; + harness_selection_json = cells[8].text_value; pinned = graph_id.copy() != "" && ref_id.copy() != ""; } { @@ -117,6 +130,7 @@ fn admission_owner_pin(queried: SqliteQueryResult) -> map { registry_identity: registry_identity, workspace: workspace, profile: profile, + harness_selection: decode_pin_selection(harness_selection_json), pinned: pinned } } @@ -174,6 +188,7 @@ pub fn storage_admission_create(db_id: resource, payload_json let mut pin_registry: string = ""; let mut pin_workspace: string = ""; let mut pin_profile: string = ""; + let mut pin_selection: map = {}; if failed == false { if input.idempotency_key.copy() != "" { // Replay identity uses the original optional selector, never the current session default. @@ -197,39 +212,77 @@ pub fn storage_admission_create(db_id: resource, payload_json if failed == false { if replay == false && conflict == false { let mut statements: array = []; + let mut restore_json:string = ""; + if raw_payload.has("restore_json") { + restore_json = raw_payload["restore_json"].copy(); + } + if restore_json.copy() != "" { + let restore_statements:array = effects::restore_before_admission(json::encode({restore_json:restore_json.copy()}), input.tool_graph_id.copy(), input.tool_ref_id.copy(), input.run_id.copy()); + let mut restore_index:int = 0; + while restore_index < restore_statements.length { + statements[statements.length] = restore_statements[restore_index].copy(); + restore_index = restore_index + 1; + // Internal fixture cuts exercise actual rollback in this same transaction. + if (failpoint.copy() == "managed_statement_4" && restore_index == 4) || (failpoint.copy() == "managed_statement_6" && restore_index == 6) { + statements[statements.length] = {sql:"INSERT INTO managed_effect_guard(id) VALUES(2)",params:[],query:false,limits:sql_limits(1,4096)}; + } + } + } if input.session_new.copy() == 1 { statements[statements.length] = { - sql: "INSERT INTO sessions (id, profile, platform, account_id, chat_id, thread_id, user_id, generation, system_prompt, model, provider, toolset_hash, metadata_json, created_at_ms, updated_at_ms) SELECT ?, ?, ?, ?, '', '', '', 1, ?, ?, ?, '', ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM sessions existing WHERE existing.id = ?) AND (? = '' OR EXISTS (SELECT 1 FROM runs parent WHERE parent.id = ?)) AND NOT EXISTS (SELECT 1 FROM runs existing WHERE existing.id = ? AND existing.session_id <> ?)", - params: [sql_text(input.session_id.copy()), sql_text(input.profile.copy()), sql_text(input.platform.copy()), sql_text(input.account_id.copy()), sql_text(input.system_prompt.copy()), sql_text(input.model.copy()), sql_text(input.provider.copy()), sql_text(input.session_metadata_json.copy()), sql_int(input.now_ms.copy()), sql_int(input.now_ms.copy()), sql_text(input.session_id.copy()), sql_text(input.parent_run_id.copy()), sql_text(input.parent_run_id.copy()), sql_text(input.run_id.copy()), sql_text(input.session_id.copy())], + sql: "INSERT INTO sessions (id, profile, platform, account_id, chat_id, thread_id, user_id, generation, system_prompt, model, provider, toolset_hash, metadata_json, created_at_ms, updated_at_ms) SELECT ?, ?, ?, ?, '', '', '', 1, ?, ?, ?, '', ?, ?, ? WHERE (NOT EXISTS (SELECT 1 FROM sessions existing WHERE existing.id = ?) AND (? = '' OR EXISTS (SELECT 1 FROM runs parent WHERE parent.id = ?)) AND NOT EXISTS (SELECT 1 FROM runs existing WHERE existing.id = ? AND existing.session_id <> ?)) AND (? = '' OR (NOT EXISTS (SELECT 1 FROM plugin_graph_revocations WHERE graph_id = ?) AND NOT EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q WHERE q.run_id = ? AND (q.graph_id <> ? OR q.ref_id <> ?)))) ", + params: [sql_text(input.session_id.copy()), sql_text(input.profile.copy()), sql_text(input.platform.copy()), sql_text(input.account_id.copy()), sql_text(input.system_prompt.copy()), sql_text(input.model.copy()), sql_text(input.provider.copy()), sql_text(input.session_metadata_json.copy()), sql_int(input.now_ms.copy()), sql_int(input.now_ms.copy()), sql_text(input.session_id.copy()), sql_text(input.parent_run_id.copy()), sql_text(input.parent_run_id.copy()), sql_text(input.run_id.copy()), sql_text(input.session_id.copy()), sql_text(input.tool_graph_id.copy()), sql_text(input.tool_graph_id.copy()), sql_text(input.run_id.copy()), sql_text(input.tool_graph_id.copy()), sql_text(input.tool_ref_id.copy())], query: false, limits: sql_limits(1000, 4194304) }; } else { statements[statements.length] = { - sql: "UPDATE sessions SET model = CASE WHEN ? = '' THEN model ELSE ? END, provider = CASE WHEN ? = '' THEN provider ELSE ? END, system_prompt = CASE WHEN ? = '' THEN system_prompt ELSE ? END, metadata_json = CASE WHEN ? = '{}' THEN metadata_json ELSE json_set(CASE WHEN json_valid(metadata_json) THEN CASE WHEN json_type(metadata_json) = 'object' THEN metadata_json ELSE '{}' END ELSE '{}' END, '$.workspace', json_extract(?, '$.workspace')) END, updated_at_ms = ? WHERE id = ? AND (? = '{}' OR json_extract(metadata_json, '$.workspace.selector') IS NULL OR json_extract(metadata_json, '$.workspace.selector') = json_extract(?, '$.workspace.selector')) AND (? = '' OR EXISTS (SELECT 1 FROM runs parent WHERE parent.id = ?)) AND NOT EXISTS (SELECT 1 FROM runs existing WHERE existing.id = ? AND existing.session_id <> ?)", - params: [sql_text(input.model.copy()), sql_text(input.model.copy()), sql_text(input.provider.copy()), sql_text(input.provider.copy()), sql_text(input.system_prompt.copy()), sql_text(input.system_prompt.copy()), sql_text(input.session_metadata_json.copy()), sql_text(input.session_metadata_json.copy()), sql_int(input.now_ms.copy()), sql_text(input.session_id.copy()), sql_text(input.session_metadata_json.copy()), sql_text(input.session_metadata_json.copy()), sql_text(input.parent_run_id.copy()), sql_text(input.parent_run_id.copy()), sql_text(input.run_id.copy()), sql_text(input.session_id.copy())], + sql: "UPDATE sessions SET model = CASE WHEN ? = '' THEN model ELSE ? END, provider = CASE WHEN ? = '' THEN provider ELSE ? END, system_prompt = CASE WHEN ? = '' THEN system_prompt ELSE ? END, metadata_json = CASE WHEN ? = '{}' THEN metadata_json ELSE json_set(CASE WHEN json_valid(metadata_json) THEN CASE WHEN json_type(metadata_json) = 'object' THEN metadata_json ELSE '{}' END ELSE '{}' END, '$.workspace', json_extract(?, '$.workspace')) END, updated_at_ms = ? WHERE (id = ? AND (? = '{}' OR json_extract(metadata_json, '$.workspace.selector') IS NULL OR json_extract(metadata_json, '$.workspace.selector') = json_extract(?, '$.workspace.selector')) AND (? = '' OR EXISTS (SELECT 1 FROM runs parent WHERE parent.id = ?)) AND NOT EXISTS (SELECT 1 FROM runs existing WHERE existing.id = ? AND existing.session_id <> ?)) AND (? = '' OR (NOT EXISTS (SELECT 1 FROM plugin_graph_revocations WHERE graph_id = ?) AND NOT EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q WHERE q.run_id = ? AND (q.graph_id <> ? OR q.ref_id <> ?)))) ", + params: [sql_text(input.model.copy()), sql_text(input.model.copy()), sql_text(input.provider.copy()), sql_text(input.provider.copy()), sql_text(input.system_prompt.copy()), sql_text(input.system_prompt.copy()), sql_text(input.session_metadata_json.copy()), sql_text(input.session_metadata_json.copy()), sql_int(input.now_ms.copy()), sql_text(input.session_id.copy()), sql_text(input.session_metadata_json.copy()), sql_text(input.session_metadata_json.copy()), sql_text(input.parent_run_id.copy()), sql_text(input.parent_run_id.copy()), sql_text(input.run_id.copy()), sql_text(input.session_id.copy()), sql_text(input.tool_graph_id.copy()), sql_text(input.tool_graph_id.copy()), sql_text(input.run_id.copy()), sql_text(input.tool_graph_id.copy()), sql_text(input.tool_ref_id.copy())], query: false, limits: sql_limits(1000, 4194304) }; } + if restore_json.copy() != "" { + let history_statements:array = effects::restore_history_statements(json::encode({restore_json:restore_json.copy()}), input.tool_graph_id.copy(), input.tool_ref_id.copy(), input.run_id.copy()); + let mut history_index:int = 0; + while history_index < history_statements.length { + statements[statements.length] = history_statements[history_index].copy(); + history_index = history_index + 1; + } + if failpoint.copy() == "managed_statement_10" { + statements[statements.length] = {sql:"INSERT INTO managed_effect_guard(id) SELECT 2 WHERE (? = '' OR (NOT EXISTS (SELECT 1 FROM plugin_graph_revocations WHERE graph_id = ?) AND NOT EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q WHERE q.run_id = ? AND (q.graph_id <> ? OR q.ref_id <> ?)))) ",params:[sql_text(input.tool_graph_id.copy()), sql_text(input.tool_graph_id.copy()), sql_text(input.run_id.copy()), sql_text(input.tool_graph_id.copy()), sql_text(input.tool_ref_id.copy())],query:false,limits:sql_limits(1,4096)}; + } + } + if input.tool_graph_id.copy() != "" { + statements[statements.length] = { + sql: "INSERT INTO plugin_control_guard(id) SELECT 2 WHERE NOT EXISTS (SELECT 1 FROM plugin_graph_locks WHERE graph_id = ? AND ((json_extract(lock_json, '$.harness_selection.mode') = 'tool_only' AND json_type(?, '$.run_graph_selection') IS NULL) OR (graph_id = json_extract(?, '$.run_graph_selection.graph_id') AND json_extract(?, '$.run_graph_selection.ref_id') = ? AND json_type(?, '$.run_graph_selection.harness_selection') = 'object' AND json(json_extract(lock_json, '$.harness_selection')) = json(json_extract(?, '$.run_graph_selection.harness_selection')))))", + params: [sql_text(input.tool_graph_id.copy()), sql_text(input.input_json.copy()), sql_text(input.input_json.copy()), sql_text(input.input_json.copy()), sql_text(input.tool_ref_id.copy()), sql_text(input.input_json.copy()), sql_text(input.input_json.copy())], + query: false, + limits: sql_limits(1,4096) + }; + } let user_content = storage_admission_user_content(input.input_json.copy()); let encoded_content = messages::storage_message_encode_content(user_content); statements[statements.length] = { - sql: "INSERT INTO messages (id, session_id, ordinal, role, content_json, metadata_json, run_id, finish_reason, created_at_ms) SELECT ?, ?, COALESCE(MAX(ordinal), 0) + 1, 'user', ?, '{}', ?, '', ? FROM messages WHERE session_id = ? HAVING NOT EXISTS (SELECT 1 FROM messages existing WHERE existing.id = ? AND existing.session_id <> ?) AND EXISTS (SELECT 1 FROM sessions workspace_session WHERE workspace_session.id = ? AND (? = '{}' OR json_extract(workspace_session.metadata_json, '$.workspace.selector') = json_extract(?, '$.workspace.selector'))) AND (? = '' OR EXISTS (SELECT 1 FROM runs parent WHERE parent.id = ?)) AND NOT EXISTS (SELECT 1 FROM runs existing WHERE existing.id = ? AND existing.session_id <> ?)", - params: [sql_text(input.message_id.copy()), sql_text(input.session_id.copy()), sql_text(encoded_content), sql_text(input.message_run_id.copy()), sql_int(input.now_ms.copy()), sql_text(input.session_id.copy()), sql_text(input.message_id.copy()), sql_text(input.session_id.copy()), sql_text(input.session_id.copy()), sql_text(input.session_metadata_json.copy()), sql_text(input.session_metadata_json.copy()), sql_text(input.parent_run_id.copy()), sql_text(input.parent_run_id.copy()), sql_text(input.run_id.copy()), sql_text(input.session_id.copy())], + sql: "INSERT INTO messages (id, session_id, ordinal, role, content_json, metadata_json, run_id, finish_reason, created_at_ms) SELECT ?, ?, COALESCE(MAX(ordinal), 0) + 1, 'user', ?, '{}', ?, '', ? FROM messages WHERE session_id = ? HAVING (NOT EXISTS (SELECT 1 FROM messages existing WHERE existing.id = ? AND existing.session_id <> ?) AND EXISTS (SELECT 1 FROM sessions workspace_session WHERE workspace_session.id = ? AND (? = '{}' OR json_extract(workspace_session.metadata_json, '$.workspace.selector') = json_extract(?, '$.workspace.selector'))) AND (? = '' OR EXISTS (SELECT 1 FROM runs parent WHERE parent.id = ?)) AND NOT EXISTS (SELECT 1 FROM runs existing WHERE existing.id = ? AND existing.session_id <> ?)) AND (? = '' OR (NOT EXISTS (SELECT 1 FROM plugin_graph_revocations WHERE graph_id = ?) AND NOT EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q WHERE q.run_id = ? AND (q.graph_id <> ? OR q.ref_id <> ?)))) ", + params: [sql_text(input.message_id.copy()), sql_text(input.session_id.copy()), sql_text(encoded_content), sql_text(input.message_run_id.copy()), sql_int(input.now_ms.copy()), sql_text(input.session_id.copy()), sql_text(input.message_id.copy()), sql_text(input.session_id.copy()), sql_text(input.session_id.copy()), sql_text(input.session_metadata_json.copy()), sql_text(input.session_metadata_json.copy()), sql_text(input.parent_run_id.copy()), sql_text(input.parent_run_id.copy()), sql_text(input.run_id.copy()), sql_text(input.session_id.copy()), sql_text(input.tool_graph_id.copy()), sql_text(input.tool_graph_id.copy()), sql_text(input.run_id.copy()), sql_text(input.tool_graph_id.copy()), sql_text(input.tool_ref_id.copy())], query: false, limits: sql_limits(1000, 4194304) }; statements[statements.length] = { - sql: "INSERT INTO runs (id, session_id, parent_run_id, status, input_json, provider, model, script_hash, idempotency_scope, idempotency_key, created_at_ms, started_at_ms, updated_at_ms) SELECT ?, ?, ?, 'running', ?, ?, ?, ?, ?, ?, ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM runs existing WHERE existing.id = ? AND existing.session_id <> ?) AND EXISTS (SELECT 1 FROM sessions WHERE id = ?) AND (? = '' OR EXISTS (SELECT 1 FROM runs parent WHERE parent.id = ?)) AND EXISTS (SELECT 1 FROM sessions workspace_session WHERE workspace_session.id = ? AND (? = '{}' OR json_extract(workspace_session.metadata_json, '$.workspace.selector') = json_extract(?, '$.workspace.selector')))", - params: [sql_text(input.run_id.copy()), sql_text(input.session_id.copy()), sql_text(input.parent_run_id.copy()), sql_text(input.input_json.copy()), sql_text(input.provider.copy()), sql_text(input.model.copy()), sql_text(input.script_hash.copy()), sql_text(input.idempotency_scope.copy()), sql_text(input.idempotency_key.copy()), sql_int(input.now_ms.copy()), sql_int(input.now_ms.copy()), sql_int(input.now_ms.copy()), sql_text(input.run_id.copy()), sql_text(input.session_id.copy()), sql_text(input.session_id.copy()), sql_text(input.parent_run_id.copy()), sql_text(input.parent_run_id.copy()), sql_text(input.session_id.copy()), sql_text(input.session_metadata_json.copy()), sql_text(input.session_metadata_json.copy())], + sql: "INSERT INTO runs (id, session_id, parent_run_id, status, input_json, provider, model, script_hash, idempotency_scope, idempotency_key, created_at_ms, started_at_ms, updated_at_ms) SELECT ?, ?, ?, 'running', ?, ?, ?, ?, ?, ?, ?, ?, ? WHERE (NOT EXISTS (SELECT 1 FROM runs existing WHERE existing.id = ? AND existing.session_id <> ?) AND EXISTS (SELECT 1 FROM sessions WHERE id = ?) AND (? = '' OR EXISTS (SELECT 1 FROM runs parent WHERE parent.id = ?)) AND EXISTS (SELECT 1 FROM sessions workspace_session WHERE workspace_session.id = ? AND (? = '{}' OR json_extract(workspace_session.metadata_json, '$.workspace.selector') = json_extract(?, '$.workspace.selector')))) AND (? = '' OR (NOT EXISTS (SELECT 1 FROM plugin_graph_revocations WHERE graph_id = ?) AND NOT EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q WHERE q.run_id = ? AND (q.graph_id <> ? OR q.ref_id <> ?)))) ", + params: [sql_text(input.run_id.copy()), sql_text(input.session_id.copy()), sql_text(input.parent_run_id.copy()), sql_text(input.input_json.copy()), sql_text(input.provider.copy()), sql_text(input.model.copy()), sql_text(input.script_hash.copy()), sql_text(input.idempotency_scope.copy()), sql_text(input.idempotency_key.copy()), sql_int(input.now_ms.copy()), sql_int(input.now_ms.copy()), sql_int(input.now_ms.copy()), sql_text(input.run_id.copy()), sql_text(input.session_id.copy()), sql_text(input.session_id.copy()), sql_text(input.parent_run_id.copy()), sql_text(input.parent_run_id.copy()), sql_text(input.session_id.copy()), sql_text(input.session_metadata_json.copy()), sql_text(input.session_metadata_json.copy()), sql_text(input.tool_graph_id.copy()), sql_text(input.tool_graph_id.copy()), sql_text(input.run_id.copy()), sql_text(input.tool_graph_id.copy()), sql_text(input.tool_ref_id.copy())], query: false, limits: sql_limits(1000, 4194304) }; + if failpoint.copy() == "managed_statement_12" && restore_json.copy() != "" { + statements[statements.length] = {sql:"INSERT INTO managed_effect_guard(id) SELECT 2 WHERE (? = '' OR (NOT EXISTS (SELECT 1 FROM plugin_graph_revocations WHERE graph_id = ?) AND NOT EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q WHERE q.run_id = ? AND (q.graph_id <> ? OR q.ref_id <> ?)))) ",params:[sql_text(input.tool_graph_id.copy()), sql_text(input.tool_graph_id.copy()), sql_text(input.run_id.copy()), sql_text(input.tool_graph_id.copy()), sql_text(input.tool_ref_id.copy())],query:false,limits:sql_limits(1,4096)}; + } if input.parent_run_id.copy() != "" { statements[statements.length] = { - sql: "INSERT INTO child_run_links (parent_run_id, child_run_id, ordinal, relation, state, created_at_ms) SELECT ?, ?, COALESCE(MAX(ordinal), -1) + 1, 'subagent', 'pending', ? FROM child_run_links WHERE parent_run_id = ? HAVING EXISTS (SELECT 1 FROM runs parent WHERE parent.id = ?) AND EXISTS (SELECT 1 FROM runs child WHERE child.id = ? AND child.session_id = ?)", - params: [sql_text(input.parent_run_id.copy()), sql_text(input.run_id.copy()), sql_int(input.now_ms.copy()), sql_text(input.parent_run_id.copy()), sql_text(input.parent_run_id.copy()), sql_text(input.run_id.copy()), sql_text(input.session_id.copy())], + sql: "INSERT INTO child_run_links (parent_run_id, child_run_id, ordinal, relation, state, created_at_ms) SELECT ?, ?, COALESCE(MAX(ordinal), -1) + 1, 'subagent', 'pending', ? FROM child_run_links WHERE parent_run_id = ? HAVING (EXISTS (SELECT 1 FROM runs parent WHERE parent.id = ?) AND EXISTS (SELECT 1 FROM runs child WHERE child.id = ? AND child.session_id = ?)) AND (? = '' OR (NOT EXISTS (SELECT 1 FROM plugin_graph_revocations WHERE graph_id = ?) AND NOT EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q WHERE q.run_id = ? AND (q.graph_id <> ? OR q.ref_id <> ?)))) ", + params: [sql_text(input.parent_run_id.copy()), sql_text(input.run_id.copy()), sql_int(input.now_ms.copy()), sql_text(input.parent_run_id.copy()), sql_text(input.parent_run_id.copy()), sql_text(input.run_id.copy()), sql_text(input.session_id.copy()), sql_text(input.tool_graph_id.copy()), sql_text(input.tool_graph_id.copy()), sql_text(input.run_id.copy()), sql_text(input.tool_graph_id.copy()), sql_text(input.tool_ref_id.copy())], query: false, limits: sql_limits(1000, 4194304) }; @@ -239,35 +292,65 @@ pub fn storage_admission_create(db_id: resource, payload_json let receipt_key: string = if input.idempotency_key.copy() == "" => { input.run_id.copy() } else => { input.idempotency_key.copy() }; if input.idempotency_key.copy() != "" || input.tool_graph_id.copy() != "" { statements[statements.length] = { - sql: "INSERT OR IGNORE INTO idempotency_records (scope, key, request_hash, resource_type, resource_id, state, response_json, created_at_ms, expires_at_ms) SELECT ?, ?, ?, 'run', ?, 'completed', json_set(CASE WHEN ? = '' THEN '{\"run_id\":\"' || ? || '\",\"status\":\"running\"}' ELSE json_object('run_id', ?, 'status', 'running', 'workspace_identity', json(?)) END, '$.tool_graph_id', ?, '$.tool_ref_id', ?, '$.tool_generation', ?, '$.registry_identity', ?, '$.workspace', ?, '$.profile', ?), ?, ? WHERE ? <> '' AND EXISTS (SELECT 1 FROM runs admitted WHERE admitted.id = ? AND admitted.session_id = ?)", - params: [sql_text(receipt_scope.copy()), sql_text(receipt_key.copy()), sql_text(input.request_hash.copy()), sql_text(input.run_id.copy()), sql_text(input.workspace_identity_json.copy()), sql_text(input.run_id.copy()), sql_text(input.run_id.copy()), sql_text(input.workspace_identity_json.copy()), sql_text(input.tool_graph_id.copy()), sql_text(input.tool_ref_id.copy()), sql_int(input.tool_graph_generation.copy()), sql_text(registry_identity.copy()), sql_text(workspace.copy()), sql_text(input.profile.copy()), sql_int(input.now_ms.copy()), sql_int(input.expires_at_ms.copy()), sql_text(receipt_key.copy()), sql_text(input.run_id.copy()), sql_text(input.session_id.copy())], + sql: "INSERT OR IGNORE INTO idempotency_records (scope, key, request_hash, resource_type, resource_id, state, response_json, created_at_ms, expires_at_ms) SELECT ?, ?, ?, 'run', ?, 'completed', json_set(CASE WHEN ? = '' THEN '{\"run_id\":\"' || ? || '\",\"status\":\"running\"}' ELSE json_object('run_id', ?, 'status', 'running', 'workspace_identity', json(?)) END, '$.tool_graph_id', ?, '$.tool_ref_id', ?, '$.tool_generation', ?, '$.registry_identity', ?, '$.workspace', ?, '$.profile', ?, '$.harness_selection', COALESCE(json_extract(?, '$.run_graph_selection.harness_selection'), (SELECT json_extract(lock_json, '$.harness_selection') FROM plugin_graph_locks WHERE graph_id = ?))), ?, ? WHERE (? <> '' AND EXISTS (SELECT 1 FROM runs admitted WHERE admitted.id = ? AND admitted.session_id = ?)) AND (? = '' OR (NOT EXISTS (SELECT 1 FROM plugin_graph_revocations WHERE graph_id = ?) AND NOT EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q WHERE q.run_id = ? AND (q.graph_id <> ? OR q.ref_id <> ?)))) ", + params: [sql_text(receipt_scope.copy()), sql_text(receipt_key.copy()), sql_text(input.request_hash.copy()), sql_text(input.run_id.copy()), sql_text(input.workspace_identity_json.copy()), sql_text(input.run_id.copy()), sql_text(input.run_id.copy()), sql_text(input.workspace_identity_json.copy()), sql_text(input.tool_graph_id.copy()), sql_text(input.tool_ref_id.copy()), sql_int(input.tool_graph_generation.copy()), sql_text(registry_identity.copy()), sql_text(workspace.copy()), sql_text(input.profile.copy()), sql_text(input.input_json.copy()), sql_text(input.tool_graph_id.copy()), sql_int(input.now_ms.copy()), sql_int(input.expires_at_ms.copy()), sql_text(receipt_key.copy()), sql_text(input.run_id.copy()), sql_text(input.session_id.copy()), sql_text(input.tool_graph_id.copy()), sql_text(input.tool_graph_id.copy()), sql_text(input.run_id.copy()), sql_text(input.tool_graph_id.copy()), sql_text(input.tool_ref_id.copy())], query: false, limits: sql_limits(1000, 4194304) }; } statements[statements.length] = { - sql: "INSERT INTO run_events (run_id, seq, event_id, event_type, payload_json, created_at_ms) SELECT ?, COALESCE((SELECT MAX(events.seq) FROM run_events events WHERE events.run_id = ?), 0) + 1, ?, 'run.started', '{\"status\":\"running\",\"session_id\":\"' || ? || '\"}', ? WHERE EXISTS (SELECT 1 FROM runs WHERE id = ?)", - params: [sql_text(input.run_id.copy()), sql_text(input.run_id.copy()), sql_text(input.event_id.copy()), sql_text(input.session_id.copy()), sql_int(input.now_ms.copy()), sql_text(input.run_id.copy())], + sql: "INSERT INTO run_events (run_id, seq, event_id, event_type, payload_json, created_at_ms) SELECT ?, COALESCE((SELECT MAX(events.seq) FROM run_events events WHERE events.run_id = ?), 0) + 1, ?, 'run.started', '{\"status\":\"running\",\"session_id\":\"' || ? || '\"}', ? WHERE (EXISTS (SELECT 1 FROM runs WHERE id = ?)) AND (? = '' OR (NOT EXISTS (SELECT 1 FROM plugin_graph_revocations WHERE graph_id = ?) AND NOT EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q WHERE q.run_id = ? AND (q.graph_id <> ? OR q.ref_id <> ?)))) ", + params: [sql_text(input.run_id.copy()), sql_text(input.run_id.copy()), sql_text(input.event_id.copy()), sql_text(input.session_id.copy()), sql_int(input.now_ms.copy()), sql_text(input.run_id.copy()), sql_text(input.tool_graph_id.copy()), sql_text(input.tool_graph_id.copy()), sql_text(input.run_id.copy()), sql_text(input.tool_graph_id.copy()), sql_text(input.tool_ref_id.copy())], query: false, limits: sql_limits(1000, 4194304) }; statements[statements.length] = { - sql: "INSERT INTO run_retention (run_id, first_seq, high_water_seq, updated_at_ms) SELECT ?, COALESCE((SELECT MIN(seq) FROM run_events WHERE run_id = ?), 0), COALESCE((SELECT MAX(seq) FROM run_events WHERE run_id = ?), 0), ? WHERE EXISTS (SELECT 1 FROM runs WHERE id = ?) ON CONFLICT (run_id) DO UPDATE SET first_seq = excluded.first_seq, high_water_seq = MAX(run_retention.high_water_seq, excluded.high_water_seq), updated_at_ms = excluded.updated_at_ms", - params: [sql_text(input.run_id.copy()), sql_text(input.run_id.copy()), sql_text(input.run_id.copy()), sql_int(input.now_ms.copy()), sql_text(input.run_id.copy())], + sql: "INSERT INTO run_retention (run_id, first_seq, high_water_seq, updated_at_ms) SELECT ?, COALESCE((SELECT MIN(seq) FROM run_events WHERE run_id = ?), 0), COALESCE((SELECT MAX(seq) FROM run_events WHERE run_id = ?), 0), ? WHERE (EXISTS (SELECT 1 FROM runs WHERE id = ?)) AND (? = '' OR (NOT EXISTS (SELECT 1 FROM plugin_graph_revocations WHERE graph_id = ?) AND NOT EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q WHERE q.run_id = ? AND (q.graph_id <> ? OR q.ref_id <> ?)))) ON CONFLICT (run_id) DO UPDATE SET first_seq = excluded.first_seq, high_water_seq = MAX(run_retention.high_water_seq, excluded.high_water_seq), updated_at_ms = excluded.updated_at_ms", + params: [sql_text(input.run_id.copy()), sql_text(input.run_id.copy()), sql_text(input.run_id.copy()), sql_int(input.now_ms.copy()), sql_text(input.run_id.copy()), sql_text(input.tool_graph_id.copy()), sql_text(input.tool_graph_id.copy()), sql_text(input.run_id.copy()), sql_text(input.tool_graph_id.copy()), sql_text(input.tool_ref_id.copy())], query: false, limits: sql_limits(1000, 4194304) }; if input.tool_graph_id.copy() != "" { if raw_payload.has("managed_binding_json") { let managed_json: string = raw_payload["managed_binding_json"].copy(); - let managed_statements: array = effects::admission_statements(managed_json); + let managed_statements: array = effects::admission_statements(managed_json, input.tool_graph_id.copy(), input.tool_ref_id.copy()); let mut managed_index: int = 0; while managed_index < managed_statements.length { statements[statements.length] = managed_statements[managed_index].copy(); managed_index = managed_index + 1; } } - let pin_statements: array = plugins::storage_admission_pin_statements(input.tool_graph_id.copy(), input.tool_graph_generation.copy(), input.tool_ref_id.copy(), input.run_id.copy(), input.session_id.copy(), input.tool_pin_now_ms.copy()); + if restore_json.copy() != "" { + let receipt_statements:array = effects::restore_receipt_statements(json::encode({restore_json:restore_json.copy()}), input.tool_graph_id.copy(), input.tool_ref_id.copy(), input.run_id.copy()); + let mut receipt_index:int = 0; + while receipt_index < receipt_statements.length { + statements[statements.length] = receipt_statements[receipt_index].copy(); + receipt_index = receipt_index + 1; + } + if failpoint.copy() == "managed_statement_17" { + statements[statements.length] = {sql:"INSERT INTO managed_effect_guard(id) VALUES(2)",params:[],query:false,limits:sql_limits(1,4096)}; + } + } + let mut checkpoint_id: string = ""; + let mut checkpoint_ref: string = ""; + if raw_payload.has("checkpoint_id") { + checkpoint_id = raw_payload["checkpoint_id"].copy(); + } + if raw_payload.has("checkpoint_graph_ref_id") { + checkpoint_ref = raw_payload["checkpoint_graph_ref_id"].copy(); + } + let pin_statements:array = if restore_json.copy() != "" => { + let selection:RestorePinSelection = json::decode::(restore_json.copy()); + let restore_harness_json:string=json::encode(selection.result.copy().graph.harness_selection); + statements[statements.length]={sql:"INSERT INTO plugin_control_guard(id) SELECT 2 WHERE NOT EXISTS(SELECT 1 FROM plugin_graph_locks WHERE graph_id=? AND json(json_extract(lock_json,'$.harness_selection'))=json(?))",params:[sql_text(input.tool_graph_id.copy()),sql_text(restore_harness_json)],query:false,limits:sql_limits(1,4096)}; + plugins::storage_checkpoint_pin_statements(input.tool_graph_id.copy(),input.tool_graph_generation.copy(),input.tool_ref_id.copy(),input.run_id.copy(),input.session_id.copy(),input.tool_pin_now_ms.copy(),selection.request.checkpoint_id,selection.result.graph.checkpoint_graph_ref_id) + } else => { + if checkpoint_id.copy() != "" => { + plugins::storage_checkpoint_pin_statements(input.tool_graph_id.copy(),input.tool_graph_generation.copy(),input.tool_ref_id.copy(),input.run_id.copy(),input.session_id.copy(),input.tool_pin_now_ms.copy(),checkpoint_id.copy(),checkpoint_ref.copy()) + } else => { + plugins::storage_admission_pin_statements(input.tool_graph_id.copy(),input.tool_graph_generation.copy(),input.tool_ref_id.copy(),input.run_id.copy(),input.session_id.copy(),input.tool_pin_now_ms.copy()) + } + }; let mut pin_index: int = 0; while pin_index < pin_statements.length { statements[statements.length] = pin_statements[pin_index].copy(); @@ -275,14 +358,25 @@ pub fn storage_admission_create(db_id: resource, payload_json } pinned = true; } + if failpoint.copy() == "managed_statement_99" && restore_json.copy() != "" { + statements[statements.length] = {sql:"INSERT INTO managed_effect_guard(id) VALUES(2)",params:[],query:false,limits:sql_limits(1,4096)}; + } + if pinned { + statements[statements.length] = plugins::storage_admission_pin_classification(input.tool_graph_id.copy(), input.tool_ref_id.copy(), input.run_id.copy()); + } let transaction_result: array = sqlite::transaction(&db_id, statements); if pinned == true && transaction_result.length > 0 { - let last_index: int = transaction_result.length - 1; + let last_index: int = transaction_result.length - 2; let receipt: SqliteTransactionResult = transaction_result[last_index].copy(); pin_graph_id = plugins::storage_pin_text(receipt.copy(), 0); pin_ref_id = plugins::storage_pin_text(receipt.copy(), 1); pin_generation = plugins::storage_pin_int(receipt.copy(), 2); - if pin_graph_id.copy() != input.tool_graph_id.copy() || pin_ref_id.copy() != input.tool_ref_id.copy() { + let classification: string = plugins::storage_pin_text(transaction_result[transaction_result.length - 1].copy(), 0); + if classification.copy() != "ok" { + failed = true; + failure_code = classification; + failure_message = "tool graph is unavailable for admission"; + } else if pin_graph_id.copy() != input.tool_graph_id.copy() || pin_ref_id.copy() != input.tool_ref_id.copy() { failed = true; failure_code = "plugin_activation_conflict"; failure_message = "tool graph pin did not commit"; @@ -313,8 +407,10 @@ pub fn storage_admission_create(db_id: resource, payload_json } else => { sqlite::query(&db_id, "SELECT scope, key, request_hash, resource_type, resource_id, state, response_json, created_at_ms, expires_at_ms, completed_at_ms FROM idempotency_records WHERE scope = ? AND key = ? LIMIT 1", [sql_text(input.idempotency_scope.copy()), sql_text(input.idempotency_key.copy())], sql_limits(1, 8192)) }; - if replay == true { - let recovered: map = admission_owner_pin(sqlite::query(&db_id, admission_owner_pin_sql(), [sql_text(input.idempotency_scope.copy()), sql_text(input.idempotency_key.copy()), sql_text(existing_run_id.copy())], sql_limits(1, 8192))); + if replay == true || input.tool_graph_id.copy() != "" { + let receipt_scope:string=if input.idempotency_key.copy()=="" => {"native.tool-pin"} else => {input.idempotency_scope.copy()}; + let receipt_key:string=if input.idempotency_key.copy()=="" => {query_run_id.copy()} else => {input.idempotency_key.copy()}; + let recovered: map = admission_owner_pin(sqlite::query(&db_id, admission_owner_pin_sql(), [sql_text(receipt_scope.copy()), sql_text(receipt_key.copy()), sql_text(query_run_id.copy())], sql_limits(1, 8192))); let recovered_graph_id: string = recovered["graph_id"]; let recovered_ref_id: string = recovered["ref_id"]; let recovered_generation: int = recovered["generation"]; @@ -332,6 +428,7 @@ pub fn storage_admission_create(db_id: resource, payload_json pin_registry = recovered_registry.copy(); pin_workspace = recovered_workspace.copy(); pin_profile = recovered_profile.copy(); + pin_selection = recovered["harness_selection"].copy(); pinned = recovered_pinned; } else { pin_run_id = input.run_id.copy(); @@ -360,6 +457,7 @@ pub fn storage_admission_create(db_id: resource, payload_json registry_identity: pin_registry, workspace: pin_workspace, profile: pin_profile, + harness_selection: pin_selection, pinned: pinned } } @@ -475,6 +573,7 @@ pub fn storage_admission_replay(db_id: resource, payload_json registry_identity: recovered_registry, workspace: recovered_workspace, profile: recovered_profile, + harness_selection: recovered["harness_selection"].copy(), pinned: recovered_pinned } } diff --git a/rss/storage/effects.rss b/rss/storage/effects.rss index 8cb0825..284eb44 100644 --- a/rss/storage/effects.rss +++ b/rss/storage/effects.rss @@ -1,6 +1,7 @@ // Native-owned managed effect metadata. Fixed SQL only; no guest forwarding. use json; use sqlite; +use plugins; struct ManagedObjectIdInput {store_id:string,object_id:string} pub fn object_metadata(db:resource,payload:string)->map{ @@ -19,6 +20,27 @@ pub fn object_refs(db:resource,payload:string)->map{ let result:SqliteQueryResult=sqlite::query(&db,"WITH target AS(SELECT ? AS store_id,? AS object_id) SELECT m.store_id,m.object_id,'registered_object' AS reference_kind,m.object_id AS reference_id FROM managed_objects m,target t WHERE m.store_id=t.store_id AND m.object_id=t.object_id UNION ALL SELECT w.store_id,t.object_id,'seed',w.workspace_id FROM managed_workspaces w,target t WHERE w.store_id=t.store_id AND w.seed_object_id=t.object_id UNION ALL SELECT b.store_id,t.object_id,'branch_head',b.branch_id FROM managed_branches b,target t WHERE b.store_id=t.store_id AND b.head_object_id=t.object_id UNION ALL SELECT b.store_id,t.object_id,'history',h.workspace_id||':'||h.head_version FROM managed_head_history h JOIN managed_branches b ON b.workspace_id=h.workspace_id AND b.branch_id=h.branch_id,target t WHERE b.store_id=t.store_id AND h.head_object_id=t.object_id UNION ALL SELECT t.store_id,t.object_id,'call_base',c.effect_id FROM managed_effect_calls c,target t WHERE json_extract(c.identity_json,'$.workspace.store_id')=t.store_id AND json_extract(c.identity_json,'$.expected_head')=t.object_id UNION ALL SELECT t.store_id,t.object_id,json_extract(o.value,'$.kind'),c.effect_id FROM managed_effect_calls c,target t,json_each(json_array(json_object('kind','prepared_candidate','id',json_extract(c.record_json,'$.prepared.candidate_root.object_id')),json_object('kind','prepared_result','id',json_extract(c.record_json,'$.prepared.canonical_result.object_id')),json_object('kind','prepared_conflict','id',json_extract(c.record_json,'$.prepared.conflict_result.object_id')),json_object('kind','selected_result','id',json_extract(c.record_json,'$.outcome.canonical_result_ref.object_id')),json_object('kind','intent_details','id',json_extract(c.identity_json,'$.argument_identity.details.object_id')))) o WHERE json_extract(c.identity_json,'$.workspace.store_id')=t.store_id AND json_extract(o.value,'$.id')=t.object_id LIMIT 513",[text(input.store_id),text(input.object_id)],limits());ok(result) } +struct ManagedRunIdInput {run_id:string} +struct ManagedRunEffectListInput {run_id:string,profile:string,session_id:string,workspace_id:string,branch_id:string,after_effect_id:string,limit:int} +pub fn run_binding(db:resource,payload:string)->map{ + let input:ManagedRunIdInput=json::decode::(payload); + let result:SqliteQueryResult=sqlite::query(&db,"SELECT json_object('owner',json_object('profile',b.profile,'session_id',b.session_id,'run_id',b.run_id),'workspace',json_set(json_extract(w.record_json,'$.identity'),'$.branch_id',b.branch_id),'epoch',b.epoch) AS record_json FROM managed_run_bindings b JOIN runs r ON r.id=b.run_id AND r.session_id=b.session_id JOIN sessions s ON s.id=b.session_id AND s.profile=b.profile JOIN managed_workspaces w ON w.workspace_id=b.workspace_id AND w.profile=b.profile JOIN managed_branches h ON h.workspace_id=b.workspace_id AND h.branch_id=b.branch_id AND h.store_id=w.store_id WHERE b.run_id=? LIMIT 1",[text(input.run_id)],limits());ok(result) +} +pub fn run_list(db:resource,payload:string)->map{ + let input:ManagedRunEffectListInput=json::decode::(payload); + let result:SqliteQueryResult=sqlite::query(&db,"SELECT effect_id,run_id,call_id,CASE state WHEN 'intent' THEN 'indeterminate' WHEN 'prepared' THEN 'staged' ELSE state END AS state,workspace_id,branch_id,json_extract(identity_json,'$.epoch') AS epoch,json_extract(record_json,'$.prepared.prepared_at_ms') AS prepared_at_ms,(SELECT created_at_ms FROM messages WHERE id=json_extract(managed_effect_calls.record_json,'$.outcome.message.id')) AS decided_at_ms FROM managed_effect_calls WHERE run_id=? AND json_extract(identity_json,'$.owner.run_id')=run_id AND json_extract(identity_json,'$.owner.profile')=? AND json_extract(identity_json,'$.owner.session_id')=? AND workspace_id=? AND branch_id=? AND effect_id>? ORDER BY effect_id LIMIT ?",[text(input.run_id),text(input.profile),text(input.session_id),text(input.workspace_id),text(input.branch_id),text(input.after_effect_id),integer(input.limit)],limits());ok(result) +} + +pub fn run_list_has_more(db:resource,payload:string)->map{ + let input:ManagedRunEffectListInput=json::decode::(payload); + let result:SqliteQueryResult=sqlite::query(&db,"SELECT effect_id,run_id,call_id,CASE state WHEN 'intent' THEN 'indeterminate' WHEN 'prepared' THEN 'staged' ELSE state END AS state,workspace_id,branch_id,json_extract(identity_json,'$.epoch') AS epoch,json_extract(record_json,'$.prepared.prepared_at_ms') AS prepared_at_ms,(SELECT created_at_ms FROM messages WHERE id=json_extract(managed_effect_calls.record_json,'$.outcome.message.id')) AS decided_at_ms FROM managed_effect_calls WHERE run_id=? AND json_extract(identity_json,'$.owner.run_id')=run_id AND json_extract(identity_json,'$.owner.profile')=? AND json_extract(identity_json,'$.owner.session_id')=? AND workspace_id=? AND branch_id=? AND effect_id>? ORDER BY effect_id LIMIT 1",[text(input.run_id),text(input.profile),text(input.session_id),text(input.workspace_id),text(input.branch_id),text(input.after_effect_id)],limits());ok(result) +} + +pub fn run_cursor(db:resource,payload:string)->map{ + let input:ManagedRunEffectListInput=json::decode::(payload); + let result:SqliteQueryResult=sqlite::query(&db,"SELECT effect_id,run_id,call_id,CASE state WHEN 'intent' THEN 'indeterminate' WHEN 'prepared' THEN 'staged' ELSE state END AS state,workspace_id,branch_id,json_extract(identity_json,'$.epoch') AS epoch,json_extract(record_json,'$.prepared.prepared_at_ms') AS prepared_at_ms,(SELECT created_at_ms FROM messages WHERE id=json_extract(managed_effect_calls.record_json,'$.outcome.message.id')) AS decided_at_ms FROM managed_effect_calls WHERE run_id=? AND json_extract(identity_json,'$.owner.run_id')=run_id AND json_extract(identity_json,'$.owner.profile')=? AND json_extract(identity_json,'$.owner.session_id')=? AND workspace_id=? AND branch_id=? AND effect_id=? ORDER BY effect_id LIMIT 1",[text(input.run_id),text(input.profile),text(input.session_id),text(input.workspace_id),text(input.branch_id),text(input.after_effect_id)],limits());ok(result) +} + struct ManagedEffectListInput {workspace_id:string,after_effect_id:string,limit:int} struct ManagedEffectIdInput {effect_id:string} pub fn list(db:resource,payload:string)->map{ @@ -39,14 +61,19 @@ fn integer(value: int) -> SqliteValue { { kind: "int", int_value: value, float_v fn limits() -> SqliteLimits { { max_connections: 16, max_statements: 128, max_rows: 512, max_columns: 16, max_result_bytes: 1048576, max_statement_bytes: 1048576, max_parameters: 128, max_parameter_bytes: 1048576, max_pending_operations: 32, max_transaction_ms: 5000, busy_timeout_ms: 5000 } } fn ok(result: SqliteQueryResult) -> map { { ok: true, code: "ok", message: "", result: { columns: result.columns, rows: result.rows, truncated: result.truncated } } } struct ManagedStorageInput { record_json: string, prepared_json: string, workspace_json: string, projections_json: string, effect_id: string, receipt_id: string, commit_id: string, run_id: string, call_id: string, store_id: string, profile: string, source_device: string, source_inode: string, workspace_id: string, branch_id: string, now_ms: int, max_events: int, cut: string } +struct ManagedBeginInput { record_json: string, prepared_json: string, workspace_json: string, projections_json: string, effect_id: string, receipt_id: string, commit_id: string, run_id: string, call_id: string, store_id: string, profile: string, source_device: string, source_inode: string, workspace_id: string, branch_id: string, now_ms: int, max_events: int, cut: string, sealed_class: string } -pub fn migration_count() -> int { 10 } +pub fn migration_count() -> int { 18 } + +pub fn migration(index: int) -> string { + if index == 17 => { "CREATE TABLE managed_publish_receipts(workspace_id TEXT NOT NULL,request_id TEXT NOT NULL,branch_id TEXT NOT NULL,original_request_proof TEXT NOT NULL,receipt_json TEXT NOT NULL CHECK(json_valid(receipt_json)),action TEXT NOT NULL CHECK(action='publish'),PRIMARY KEY(workspace_id,request_id),FOREIGN KEY(workspace_id,branch_id) REFERENCES managed_branches(workspace_id,branch_id))" } else => { if index == 16 => { "CREATE TABLE managed_workspace_publications(workspace_id TEXT PRIMARY KEY REFERENCES managed_workspaces(workspace_id),profile TEXT NOT NULL,branch_id TEXT NOT NULL,epoch INTEGER NOT NULL CHECK(epoch>=1),head_object_id TEXT NOT NULL,head_version INTEGER NOT NULL CHECK(head_version>=1),previous_head_object_id TEXT,request_id TEXT NOT NULL,published_at_ms INTEGER NOT NULL,revision INTEGER NOT NULL CHECK(revision>=1),store_id TEXT NOT NULL,schema_version INTEGER NOT NULL CHECK(schema_version=1),record_json TEXT NOT NULL CHECK(json_valid(record_json)),FOREIGN KEY(workspace_id,branch_id) REFERENCES managed_branches(workspace_id,branch_id))" } else => { if index == 15 => { "CREATE TABLE managed_branch_receipts(workspace_id TEXT NOT NULL,request_id TEXT NOT NULL,checkpoint_id TEXT NOT NULL DEFAULT '',source_branch_id TEXT NOT NULL,original_request_proof TEXT NOT NULL,receipt_json TEXT NOT NULL CHECK(json_valid(receipt_json)),origin_json TEXT NOT NULL CHECK(json_valid(origin_json)),PRIMARY KEY(workspace_id,request_id),FOREIGN KEY(workspace_id,source_branch_id) REFERENCES managed_branches(workspace_id,branch_id))" } else => { if index == 14 => { "CREATE TABLE managed_restore_receipts(workspace_id TEXT NOT NULL,request_id TEXT NOT NULL,checkpoint_id TEXT NOT NULL,original_request_proof TEXT NOT NULL,envelope_json TEXT NOT NULL CHECK(json_valid(envelope_json)),result_json TEXT NOT NULL CHECK(json_valid(result_json)),PRIMARY KEY(workspace_id,request_id))" } else => { if index >= 10 => { checkpoint_schema(index - 10) } else => { effect_schema(index) } } } } } +} -pub fn migration(index: int) -> string { if index == 0 => { "CREATE TABLE managed_objects (store_id TEXT NOT NULL, object_id TEXT NOT NULL, schema_version INTEGER NOT NULL CHECK(schema_version=1), kind TEXT NOT NULL CHECK(kind IN ('tree','canonical_result','intent_details')), record_json TEXT NOT NULL CHECK(json_valid(record_json)), readiness TEXT NOT NULL CHECK(readiness='synced'), PRIMARY KEY(store_id,object_id))" } else => { if index == 1 => { "CREATE TABLE managed_workspaces (workspace_id TEXT PRIMARY KEY, store_id TEXT NOT NULL, profile TEXT NOT NULL, source_device TEXT NOT NULL, source_inode TEXT NOT NULL, schema_version INTEGER NOT NULL CHECK(schema_version=1), seed_object_id TEXT NOT NULL, record_json TEXT NOT NULL CHECK(json_valid(record_json)), UNIQUE(store_id,profile,source_device,source_inode), FOREIGN KEY(store_id,seed_object_id) REFERENCES managed_objects(store_id,object_id))" } else => { if index == 2 => { "CREATE TABLE managed_branches (workspace_id TEXT NOT NULL REFERENCES managed_workspaces(workspace_id), branch_id TEXT NOT NULL, epoch INTEGER NOT NULL CHECK(epoch>0), head_object_id TEXT NOT NULL, head_version INTEGER NOT NULL CHECK(head_version>=1), store_id TEXT NOT NULL, schema_version INTEGER NOT NULL CHECK(schema_version=1), PRIMARY KEY(workspace_id,branch_id), FOREIGN KEY(store_id,head_object_id) REFERENCES managed_objects(store_id,object_id))" } else => { if index == 3 => { "CREATE TABLE managed_run_bindings (run_id TEXT PRIMARY KEY REFERENCES runs(id), profile TEXT NOT NULL, session_id TEXT NOT NULL REFERENCES sessions(id), workspace_id TEXT NOT NULL, branch_id TEXT NOT NULL, epoch INTEGER NOT NULL CHECK(epoch>0), graph_ref_id TEXT NOT NULL, original_deadline_ms INTEGER NOT NULL, schema_version INTEGER NOT NULL CHECK(schema_version=1), FOREIGN KEY(workspace_id,branch_id) REFERENCES managed_branches(workspace_id,branch_id))" } else => { if index == 4 => { "CREATE TABLE managed_effect_calls (effect_id TEXT PRIMARY KEY, run_id TEXT NOT NULL REFERENCES runs(id), call_id TEXT NOT NULL, workspace_id TEXT NOT NULL, branch_id TEXT NOT NULL, state TEXT NOT NULL CHECK(state IN ('intent','prepared','committed','read_only','not_published','conflict','indeterminate')), schema_version INTEGER NOT NULL CHECK(schema_version=1), identity_json TEXT NOT NULL CHECK(json_valid(identity_json)), record_json TEXT NOT NULL CHECK(json_valid(record_json)), commit_id TEXT, observed_head TEXT, observed_version INTEGER, UNIQUE(run_id,call_id), FOREIGN KEY(workspace_id,branch_id) REFERENCES managed_branches(workspace_id,branch_id))" } else => { if index == 5 => { "CREATE TABLE managed_effect_receipts (effect_id TEXT PRIMARY KEY REFERENCES managed_effect_calls(effect_id), receipt_id TEXT NOT NULL UNIQUE, prepared_record_json TEXT NOT NULL CHECK(json_valid(prepared_record_json)), outcome_json TEXT CHECK(outcome_json IS NULL OR json_valid(outcome_json)), schema_version INTEGER NOT NULL CHECK(schema_version=1))" } else => { if index == 6 => { "CREATE TABLE managed_head_history (workspace_id TEXT NOT NULL, branch_id TEXT NOT NULL, head_version INTEGER NOT NULL, epoch INTEGER NOT NULL, effect_id TEXT UNIQUE REFERENCES managed_effect_calls(effect_id), previous_head TEXT, head_object_id TEXT NOT NULL, schema_version INTEGER NOT NULL CHECK(schema_version=1), PRIMARY KEY(workspace_id,branch_id,head_version), FOREIGN KEY(workspace_id,branch_id) REFERENCES managed_branches(workspace_id,branch_id))" } else => { if index == 7 => { "CREATE TABLE managed_effect_guard (id INTEGER PRIMARY KEY CHECK(id=1))" } else => { if index == 8 => { "CREATE INDEX managed_effect_recovery ON managed_effect_calls(workspace_id,branch_id,state,effect_id)" } else => { if index == 9 => { "CREATE INDEX managed_effect_owner ON managed_effect_calls(run_id,call_id,effect_id)" } else => { "" } } } } } } } } } } } +fn effect_schema(index: int) -> string { if index == 0 => { "CREATE TABLE managed_objects (store_id TEXT NOT NULL, object_id TEXT NOT NULL, schema_version INTEGER NOT NULL CHECK(schema_version=1), kind TEXT NOT NULL CHECK(kind IN ('tree','canonical_result','intent_details')), record_json TEXT NOT NULL CHECK(json_valid(record_json)), readiness TEXT NOT NULL CHECK(readiness='synced'), PRIMARY KEY(store_id,object_id))" } else => { if index == 1 => { "CREATE TABLE managed_workspaces (workspace_id TEXT PRIMARY KEY, store_id TEXT NOT NULL, profile TEXT NOT NULL, source_device TEXT NOT NULL, source_inode TEXT NOT NULL, schema_version INTEGER NOT NULL CHECK(schema_version=1), seed_object_id TEXT NOT NULL, current_branch_id TEXT NOT NULL, record_json TEXT NOT NULL CHECK(json_valid(record_json)), UNIQUE(store_id,profile,source_device,source_inode), FOREIGN KEY(store_id,seed_object_id) REFERENCES managed_objects(store_id,object_id))" } else => { if index == 2 => { "CREATE TABLE managed_branches (workspace_id TEXT NOT NULL REFERENCES managed_workspaces(workspace_id), branch_id TEXT NOT NULL, epoch INTEGER NOT NULL CHECK(epoch>0), status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','retired')), lineage_json TEXT NOT NULL DEFAULT '{}', process_lineage_tainted INTEGER NOT NULL DEFAULT 0 CHECK(process_lineage_tainted IN (0,1)), head_object_id TEXT NOT NULL, head_version INTEGER NOT NULL CHECK(head_version>=1), store_id TEXT NOT NULL, schema_version INTEGER NOT NULL CHECK(schema_version=1), PRIMARY KEY(workspace_id,branch_id), FOREIGN KEY(store_id,head_object_id) REFERENCES managed_objects(store_id,object_id))" } else => { if index == 3 => { "CREATE TABLE managed_run_bindings (run_id TEXT PRIMARY KEY REFERENCES runs(id), profile TEXT NOT NULL, session_id TEXT NOT NULL REFERENCES sessions(id), workspace_id TEXT NOT NULL, branch_id TEXT NOT NULL, epoch INTEGER NOT NULL CHECK(epoch>0), graph_ref_id TEXT NOT NULL, original_deadline_ms INTEGER NOT NULL, schema_version INTEGER NOT NULL CHECK(schema_version=1), FOREIGN KEY(workspace_id,branch_id) REFERENCES managed_branches(workspace_id,branch_id))" } else => { if index == 4 => { "CREATE TABLE managed_effect_calls (effect_id TEXT PRIMARY KEY, run_id TEXT NOT NULL REFERENCES runs(id), call_id TEXT NOT NULL, workspace_id TEXT NOT NULL, branch_id TEXT NOT NULL, state TEXT NOT NULL CHECK(state IN ('intent','prepared','committed','read_only','not_published','conflict','indeterminate')), schema_version INTEGER NOT NULL CHECK(schema_version=1), identity_json TEXT NOT NULL CHECK(json_valid(identity_json)), record_json TEXT NOT NULL CHECK(json_valid(record_json)), commit_id TEXT, observed_head TEXT, observed_version INTEGER, UNIQUE(run_id,call_id), FOREIGN KEY(workspace_id,branch_id) REFERENCES managed_branches(workspace_id,branch_id))" } else => { if index == 5 => { "CREATE TABLE managed_effect_receipts (effect_id TEXT PRIMARY KEY REFERENCES managed_effect_calls(effect_id), receipt_id TEXT NOT NULL UNIQUE, prepared_record_json TEXT NOT NULL CHECK(json_valid(prepared_record_json)), outcome_json TEXT CHECK(outcome_json IS NULL OR json_valid(outcome_json)), schema_version INTEGER NOT NULL CHECK(schema_version=1))" } else => { if index == 6 => { "CREATE TABLE managed_head_history (workspace_id TEXT NOT NULL, branch_id TEXT NOT NULL, head_version INTEGER NOT NULL, epoch INTEGER NOT NULL, effect_id TEXT UNIQUE REFERENCES managed_effect_calls(effect_id), previous_head TEXT, head_object_id TEXT NOT NULL, schema_version INTEGER NOT NULL CHECK(schema_version=1), PRIMARY KEY(workspace_id,branch_id,head_version), FOREIGN KEY(workspace_id,branch_id) REFERENCES managed_branches(workspace_id,branch_id))" } else => { if index == 7 => { "CREATE TABLE managed_effect_guard (id INTEGER PRIMARY KEY CHECK(id=1))" } else => { if index == 8 => { "CREATE INDEX managed_effect_recovery ON managed_effect_calls(workspace_id,branch_id,state,effect_id)" } else => { if index == 9 => { "CREATE INDEX managed_effect_owner ON managed_effect_calls(run_id,call_id,effect_id)" } else => { "" } } } } } } } } } } } pub fn workspace_get(db: resource, payload: string) -> map { let input: ManagedStorageInput = json::decode::(payload); ok(sqlite::query(&db, "SELECT record_json FROM managed_workspaces WHERE store_id=? AND profile=? AND source_device=? AND source_inode=?", [text(input.store_id.copy()), text(input.profile.copy()), text(input.source_device.copy()), text(input.source_inode.copy())], limits())) } -pub fn head(db: resource, payload: string) -> map { let input: ManagedStorageInput = json::decode::(payload); ok(sqlite::query(&db, "SELECT json_object('workspace_id',workspace_id,'branch_id',branch_id,'epoch',epoch,'head_object_id',head_object_id,'head_version',head_version,'store_id',store_id,'schema_version',schema_version) AS record_json FROM managed_branches WHERE workspace_id=? AND branch_id=?", [text(input.workspace_id.copy()), text(input.branch_id.copy())], limits())) } +pub fn head(db: resource, payload: string) -> map { let input: ManagedStorageInput = json::decode::(payload); ok(sqlite::query(&db, "SELECT json_object('workspace_id',workspace_id,'branch_id',branch_id,'epoch',epoch,'head_object_id',head_object_id,'head_version',head_version,'store_id',store_id,'schema_version',schema_version,'process_lineage_tainted',json(CASE process_lineage_tainted WHEN 1 THEN 'true' ELSE 'false' END)) AS record_json FROM managed_branches WHERE workspace_id=? AND branch_id=COALESCE(NULLIF(?,''),(SELECT current_branch_id FROM managed_workspaces WHERE managed_workspaces.workspace_id=managed_branches.workspace_id))", [text(input.workspace_id.copy()), text(input.branch_id.copy())], limits())) } pub fn get(db: resource, payload: string) -> map { let input: ManagedStorageInput = json::decode::(payload); ok(sqlite::query(&db, "SELECT record_json FROM managed_effect_calls WHERE run_id=? AND call_id=? AND json_extract(identity_json,'$.owner.profile')=?", [text(input.run_id.copy()), text(input.call_id.copy()), text(input.profile.copy())], limits())) } @@ -54,7 +81,7 @@ pub fn workspace_register(db: resource, payload: string) -> m let input: ManagedStorageInput = json::decode::(payload); let statements: array = [ { sql: "INSERT INTO managed_objects(store_id,object_id,schema_version,kind,record_json,readiness) SELECT json_extract(value,'$.store_id'),json_extract(value,'$.object_id'),1,json_extract(value,'$.object_kind'),value,'synced' FROM json_each(json_array(json_extract(?,'$.seed_root'))) WHERE value IS NOT NULL ON CONFLICT(store_id,object_id) DO NOTHING", params: [text(input.workspace_json.copy())], query: false, limits: limits() }, -{ sql: "INSERT INTO managed_workspaces(workspace_id,store_id,profile,source_device,source_inode,schema_version,seed_object_id,record_json) SELECT json_extract(?,'$.identity.workspace_id'),json_extract(?,'$.identity.store_id'),json_extract(?,'$.profile'),json_extract(?,'$.identity.source_identity.device_decimal'),json_extract(?,'$.identity.source_identity.inode_decimal'),1,json_extract(?,'$.seed_root.object_id'),? ON CONFLICT(store_id,profile,source_device,source_inode) DO NOTHING", params: [text(input.workspace_json.copy()), text(input.workspace_json.copy()), text(input.workspace_json.copy()), text(input.workspace_json.copy()), text(input.workspace_json.copy()), text(input.workspace_json.copy()), text(input.workspace_json.copy())], query: false, limits: limits() }, +{ sql: "INSERT INTO managed_workspaces(workspace_id,store_id,profile,source_device,source_inode,schema_version,seed_object_id,record_json,current_branch_id) SELECT json_extract(?,'$.identity.workspace_id'),json_extract(?,'$.identity.store_id'),json_extract(?,'$.profile'),json_extract(?,'$.identity.source_identity.device_decimal'),json_extract(?,'$.identity.source_identity.inode_decimal'),1,json_extract(?,'$.seed_root.object_id'),?,json_extract(?,'$.identity.branch_id') ON CONFLICT(store_id,profile,source_device,source_inode) DO NOTHING", params: [text(input.workspace_json.copy()), text(input.workspace_json.copy()), text(input.workspace_json.copy()), text(input.workspace_json.copy()), text(input.workspace_json.copy()), text(input.workspace_json.copy()), text(input.workspace_json.copy()),text(input.workspace_json.copy())], query: false, limits: limits() }, { sql: "INSERT INTO managed_effect_guard(id) SELECT 2 WHERE NOT EXISTS (SELECT 1 FROM managed_workspaces WHERE store_id=? AND profile=? AND source_device=? AND source_inode=? AND json_extract(record_json,'$.max_entries')=json_extract(?,'$.max_entries') AND json_extract(record_json,'$.max_bytes')=json_extract(?,'$.max_bytes') AND json_extract(record_json,'$.max_depth')=json_extract(?,'$.max_depth'))", params: [text(input.store_id.copy()), text(input.profile.copy()), text(input.source_device.copy()), text(input.source_inode.copy()), text(input.workspace_json.copy()), text(input.workspace_json.copy()), text(input.workspace_json.copy())], query: false, limits: limits() }, { sql: "INSERT INTO managed_branches(workspace_id,branch_id,epoch,head_object_id,head_version,store_id,schema_version) SELECT workspace_id,json_extract(record_json,'$.identity.branch_id'),1,seed_object_id,1,store_id,1 FROM managed_workspaces WHERE store_id=? AND profile=? AND source_device=? AND source_inode=? ON CONFLICT(workspace_id,branch_id) DO NOTHING", params: [text(input.store_id.copy()), text(input.profile.copy()), text(input.source_device.copy()), text(input.source_inode.copy())], query: false, limits: limits() }, { sql: "INSERT INTO managed_head_history(workspace_id,branch_id,head_version,epoch,effect_id,previous_head,head_object_id,schema_version) SELECT workspace_id,branch_id,1,1,NULL,NULL,head_object_id,1 FROM managed_branches WHERE workspace_id=(SELECT workspace_id FROM managed_workspaces WHERE store_id=? AND profile=? AND source_device=? AND source_inode=?) ON CONFLICT(workspace_id,branch_id,head_version) DO NOTHING", params: [text(input.store_id.copy()), text(input.profile.copy()), text(input.source_device.copy()), text(input.source_inode.copy())], query: false, limits: limits() }, @@ -66,39 +93,49 @@ pub fn workspace_register(db: resource, payload: string) -> m ok(query) } -pub fn admission_statements(payload: string) -> array { +pub fn admission_statements(payload: string, graph_id: string, ref_id: string) -> array { let input: ManagedStorageInput = json::decode::(payload); [ -{ sql: "INSERT INTO managed_run_bindings(run_id,profile,session_id,workspace_id,branch_id,epoch,graph_ref_id,original_deadline_ms,schema_version) SELECT ?,?,json_extract(?,'$.session_id'),?,?,1,json_extract(?,'$.graph_ref_id'),json_extract(?,'$.original_deadline_ms'),1 WHERE ?<>''", params: [text(input.run_id.copy()), text(input.profile.copy()), text(input.record_json.copy()), text(input.workspace_id.copy()), text(input.branch_id.copy()), text(input.record_json.copy()), text(input.record_json.copy()), text(input.workspace_id.copy())], query: false, limits: limits() } +{sql:"INSERT INTO managed_effect_guard(id) SELECT 2 WHERE (NOT EXISTS(SELECT 1 FROM managed_branches b JOIN managed_workspaces w ON w.workspace_id=b.workspace_id WHERE b.workspace_id=? AND b.branch_id=? AND b.epoch=json_extract(?,'$.epoch') AND b.head_object_id=json_extract(?,'$.head_object_id') AND b.head_version=json_extract(?,'$.head_version') AND b.store_id=json_extract(?,'$.store_id') AND b.status='active' AND (w.current_branch_id=b.branch_id OR EXISTS(SELECT 1 FROM managed_branch_receipts r WHERE r.workspace_id=b.workspace_id AND json_extract(r.receipt_json,'$.branch.branch_id')=b.branch_id AND json_extract(r.origin_json,'$.workspace_id')=b.workspace_id AND ((json_extract(r.receipt_json,'$.branch.epoch')=b.epoch AND json_extract(r.receipt_json,'$.branch.head_object_id')=b.head_object_id AND json_extract(r.receipt_json,'$.branch.head_version')=b.head_version AND json_extract(r.origin_json,'$.head_object_id')=b.head_object_id) OR json_extract(r.origin_json,'$.kind')='checkpoint'))))) AND (? = '' OR (NOT EXISTS (SELECT 1 FROM plugin_graph_revocations WHERE graph_id = ?) AND NOT EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q WHERE q.run_id = ? AND (q.graph_id <> ? OR q.ref_id <> ?)))) ",params:[text(input.workspace_id.copy()),text(input.branch_id.copy()),text(input.record_json.copy()),text(input.record_json.copy()),text(input.record_json.copy()),text(input.record_json.copy()), text(graph_id.copy()), text(graph_id.copy()), text(input.run_id.copy()), text(graph_id.copy()), text(ref_id.copy())],query:false,limits:limits()}, +{ sql: "INSERT INTO managed_run_bindings(run_id,profile,session_id,workspace_id,branch_id,epoch,graph_ref_id,original_deadline_ms,schema_version) SELECT ?,?,json_extract(?,'$.session_id'),?,?,json_extract(?,'$.epoch'),json_extract(?,'$.graph_ref_id'),json_extract(?,'$.original_deadline_ms'),1 WHERE (?<>'') AND (? = '' OR (NOT EXISTS (SELECT 1 FROM plugin_graph_revocations WHERE graph_id = ?) AND NOT EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q WHERE q.run_id = ? AND (q.graph_id <> ? OR q.ref_id <> ?)))) ", params: [text(input.run_id.copy()), text(input.profile.copy()), text(input.record_json.copy()), text(input.workspace_id.copy()), text(input.branch_id.copy()), text(input.record_json.copy()), text(input.record_json.copy()), text(input.record_json.copy()), text(input.workspace_id.copy()), text(graph_id.copy()), text(graph_id.copy()), text(input.run_id.copy()), text(graph_id.copy()), text(ref_id.copy())], query: false, limits: limits() } ] } pub fn begin(db: resource, payload: string) -> map { - let input: ManagedStorageInput = json::decode::(payload.copy()); + let input: ManagedBeginInput = json::decode::(payload.copy()); let existing:SqliteQueryResult=sqlite::query(&db,"SELECT record_json FROM managed_effect_calls WHERE effect_id=? AND run_id=? AND call_id=? AND identity_json=json_extract(?,'$.identity')",[text(input.effect_id),text(input.run_id),text(input.call_id),text(input.record_json)],limits()); let inspected:SqliteQueryResult=existing.copy();let count:int=inspected.rows.length;let truncated:bool=inspected.truncated; if count == 1 && truncated == false => {ok(existing)} else => {begin_new(db,payload)} } fn begin_new(db: resource, payload: string) -> map { - let input: ManagedStorageInput = json::decode::(payload); + let input: ManagedBeginInput = json::decode::(payload); let statements: array = [ -{ sql: "INSERT INTO managed_objects(store_id,object_id,schema_version,kind,record_json,readiness) SELECT json_extract(value,'$.store_id'),json_extract(value,'$.object_id'),1,json_extract(value,'$.object_kind'),value,'synced' FROM json_each(json_array(json_extract(?,'$.identity.argument_identity.details'))) WHERE value IS NOT NULL ON CONFLICT(store_id,object_id) DO NOTHING", params: [text(input.record_json.copy())], query: false, limits: limits() }, -{ sql: "INSERT INTO managed_effect_guard(id) SELECT 2 WHERE json_extract(?,'$.identity.argument_identity.kind')='object' AND NOT EXISTS(SELECT 1 FROM managed_objects WHERE store_id=json_extract(?,'$.identity.argument_identity.details.store_id') AND object_id=json_extract(?,'$.identity.argument_identity.details.object_id') AND kind='intent_details' AND record_json=json_extract(?,'$.identity.argument_identity.details'))", params: [text(input.record_json.copy()),text(input.record_json.copy()),text(input.record_json.copy()),text(input.record_json.copy())], query: false, limits: limits() }, -{ sql: "INSERT INTO managed_effect_guard(id) SELECT 2 WHERE NOT EXISTS (SELECT 1 FROM managed_run_bindings b JOIN runs r ON r.id=b.run_id WHERE b.run_id=? AND r.status IN ('running','queued') AND b.workspace_id=json_extract(?,'$.identity.workspace.workspace_id') AND b.branch_id=json_extract(?,'$.identity.workspace.branch_id') AND b.graph_ref_id=json_extract(?,'$.identity.code.graph_ref_id') AND ?=0 THEN json_extract(draft,'$.created_at_ms') END FROM (SELECT json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.output' ELSE '$.normal.output' END) AS draft)) FROM managed_effect_calls c WHERE c.effect_id=? AND c.commit_id=? AND EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q JOIN managed_run_bindings b ON b.run_id=q.run_id AND b.graph_ref_id=q.ref_id JOIN plugin_graph_refs g ON g.owner_kind='run' AND g.owner_id=q.run_id AND g.graph_id=q.graph_id AND g.ref_id=q.ref_id WHERE q.run_id=? AND NOT EXISTS (SELECT 1 FROM plugin_graph_revocations r WHERE r.graph_id=q.graph_id))", params: [text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.effect_id.copy()), text(input.commit_id.copy()), text(input.run_id.copy())], query: false, limits: limits() }, +{ sql: "INSERT INTO managed_effect_guard(id) SELECT 2 WHERE (? = 'BeginAfterFirstEvent') AND EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q JOIN managed_run_bindings b ON b.run_id=q.run_id AND b.graph_ref_id=q.ref_id JOIN plugin_graph_refs g ON g.owner_kind='run' AND g.owner_id=q.run_id AND g.graph_id=q.graph_id AND g.ref_id=q.ref_id WHERE q.run_id=? AND NOT EXISTS (SELECT 1 FROM plugin_graph_revocations r WHERE r.graph_id=q.graph_id))", params: [text(input.cut.copy()), text(input.run_id.copy())], query: false, limits: limits() }, +{ sql: "INSERT INTO run_events(run_id,seq,event_id,event_type,payload_json,created_at_ms) SELECT c.run_id,json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.terminal' ELSE '$.normal.terminal' END||'.seq'),json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.terminal' ELSE '$.normal.terminal' END||'.event_id'),json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.terminal' ELSE '$.normal.terminal' END||'.event_type'),json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.terminal' ELSE '$.normal.terminal' END||'.payload_json'),(SELECT CASE WHEN json_type(draft,'$.created_at_ms')='integer' AND typeof(json_extract(draft,'$.created_at_ms'))='integer' AND json_extract(draft,'$.created_at_ms')>=0 THEN json_extract(draft,'$.created_at_ms') END FROM (SELECT json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.terminal' ELSE '$.normal.terminal' END) AS draft)) FROM managed_effect_calls c WHERE c.effect_id=? AND c.commit_id=? AND EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q JOIN managed_run_bindings b ON b.run_id=q.run_id AND b.graph_ref_id=q.ref_id JOIN plugin_graph_refs g ON g.owner_kind='run' AND g.owner_id=q.run_id AND g.graph_id=q.graph_id AND g.ref_id=q.ref_id WHERE q.run_id=? AND NOT EXISTS (SELECT 1 FROM plugin_graph_revocations r WHERE r.graph_id=q.graph_id))", params: [text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.effect_id.copy()), text(input.commit_id.copy()), text(input.run_id.copy())], query: false, limits: limits() }, +{ sql: "UPDATE runs SET updated_at_ms=? WHERE id=? AND EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q JOIN managed_run_bindings b ON b.run_id=q.run_id AND b.graph_ref_id=q.ref_id JOIN plugin_graph_refs g ON g.owner_kind='run' AND g.owner_id=q.run_id AND g.graph_id=q.graph_id AND g.ref_id=q.ref_id WHERE q.run_id=? AND NOT EXISTS (SELECT 1 FROM plugin_graph_revocations r WHERE r.graph_id=q.graph_id))", params: [integer(input.now_ms.copy()), text(input.run_id.copy()), text(input.run_id.copy())], query: false, limits: limits() }, +{ sql: "DELETE FROM run_events WHERE run_id=? AND seq NOT IN (SELECT seq FROM run_events WHERE run_id=? ORDER BY seq DESC LIMIT ?) AND EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q JOIN managed_run_bindings b ON b.run_id=q.run_id AND b.graph_ref_id=q.ref_id JOIN plugin_graph_refs g ON g.owner_kind='run' AND g.owner_id=q.run_id AND g.graph_id=q.graph_id AND g.ref_id=q.ref_id WHERE q.run_id=? AND NOT EXISTS (SELECT 1 FROM plugin_graph_revocations r WHERE r.graph_id=q.graph_id))", params: [text(input.run_id.copy()), text(input.run_id.copy()), integer(input.max_events.copy()), text(input.run_id.copy())], query: false, limits: limits() }, +{ sql: "INSERT INTO run_retention(run_id,first_seq,high_water_seq,updated_at_ms) SELECT ?,COALESCE(MIN(seq),0),COALESCE(MAX(seq),0),? FROM run_events WHERE run_id=? HAVING EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q JOIN managed_run_bindings b ON b.run_id=q.run_id AND b.graph_ref_id=q.ref_id JOIN plugin_graph_refs g ON g.owner_kind='run' AND g.owner_id=q.run_id AND g.graph_id=q.graph_id AND g.ref_id=q.ref_id WHERE q.run_id=? AND NOT EXISTS (SELECT 1 FROM plugin_graph_revocations r WHERE r.graph_id=q.graph_id)) ON CONFLICT(run_id) DO UPDATE SET first_seq=excluded.first_seq,high_water_seq=MAX(run_retention.high_water_seq,excluded.high_water_seq),updated_at_ms=excluded.updated_at_ms", params: [text(input.run_id.copy()), integer(input.now_ms.copy()), text(input.run_id.copy()), text(input.run_id.copy())], query: false, limits: limits() }, +{ sql: "UPDATE managed_effect_calls SET commit_id=NULL WHERE effect_id=? AND EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q JOIN managed_run_bindings b ON b.run_id=q.run_id AND b.graph_ref_id=q.ref_id JOIN plugin_graph_refs g ON g.owner_kind='run' AND g.owner_id=q.run_id AND g.graph_id=q.graph_id AND g.ref_id=q.ref_id WHERE q.run_id=? AND NOT EXISTS (SELECT 1 FROM plugin_graph_revocations r WHERE r.graph_id=q.graph_id))", params: [text(input.effect_id.copy()), text(input.run_id.copy())], query: false, limits: limits() }, +{ sql: "SELECT record_json FROM managed_effect_calls WHERE effect_id=?", params: [text(input.effect_id.copy())], query: true, limits: limits() }, +{ sql: "SELECT CASE WHEN EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q JOIN plugin_graph_revocations r ON r.graph_id=q.graph_id WHERE q.run_id=?) THEN 'plugin_graph_revoked' WHEN EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q JOIN managed_run_bindings b ON b.run_id=q.run_id AND b.graph_ref_id=q.ref_id JOIN plugin_graph_refs g ON g.owner_kind='run' AND g.owner_id=q.run_id AND g.graph_id=q.graph_id AND g.ref_id=q.ref_id WHERE q.run_id=? AND NOT EXISTS (SELECT 1 FROM plugin_graph_revocations r WHERE r.graph_id=q.graph_id)) THEN 'ok' ELSE 'plugin_graph_pin_required' END", params: [text(input.run_id.copy()), text(input.run_id.copy())], query: true, limits: limits() } ]; let result: array = sqlite::transaction(&db, statements); - let last: SqliteTransactionResult = result[result.length - 1].copy(); - let query: SqliteQueryResult = last.query; - ok(query) + let classification: SqliteTransactionResult = result[result.length - 1].copy(); + let code: string = plugins::storage_pin_text(classification, 0); + if code.copy() == "ok" => { + let last: SqliteTransactionResult = result[result.length - 2].copy(); + let query: SqliteQueryResult = last.query; + ok(query) + } else => { + {ok:false,code:code,message:"tool graph pin is required",result:{}} + } } pub fn prepare(db: resource, payload: string) -> map { @@ -147,9 +184,9 @@ fn commit_new(db: resource, payload: string) -> map { { sql: "UPDATE managed_effect_calls SET record_json=json_set(record_json,'$.state',state,'$.outcome',json_extract(?,CASE WHEN state='conflict' THEN '$.conflict.outcome' ELSE '$.normal.outcome' END),'$.outcome.disposition',state,'$.outcome.publication.status',state,'$.outcome.publication.observed_head',observed_head,'$.outcome.publication.observed_head_version',observed_version) WHERE effect_id=? AND commit_id=?", params: [text(input.projections_json.copy()), text(input.effect_id.copy()), text(input.commit_id.copy())], query: false, limits: limits() }, { sql: "UPDATE managed_effect_receipts SET outcome_json=(SELECT json_extract(record_json,'$.outcome') FROM managed_effect_calls WHERE effect_id=?) WHERE effect_id=?", params: [text(input.effect_id.copy()), text(input.effect_id.copy())], query: false, limits: limits() }, { sql: "INSERT INTO managed_effect_guard(id) SELECT 2 WHERE ? = 'CommitAfterReceipt'", params: [text(input.cut.copy())], query: false, limits: limits() }, -{ sql: "INSERT INTO run_events(run_id,seq,event_id,event_type,payload_json,created_at_ms) SELECT c.run_id,json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.output' ELSE '$.normal.output' END||'.seq'),json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.output' ELSE '$.normal.output' END||'.event_id'),json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.output' ELSE '$.normal.output' END||'.event_type'),json_patch(json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.output' ELSE '$.normal.output' END||'.payload_json'),CASE WHEN json_extract(c.record_json,'$.outcome.publication') IS NULL THEN '{}' ELSE json_object('managed_publication',json_extract(c.record_json,'$.outcome.publication')) END),? FROM managed_effect_calls c WHERE c.effect_id=? AND c.commit_id=?", params: [text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.projections_json.copy()), integer(input.now_ms.copy()), text(input.effect_id.copy()), text(input.commit_id.copy())], query: false, limits: limits() }, +{ sql: "INSERT INTO run_events(run_id,seq,event_id,event_type,payload_json,created_at_ms) SELECT c.run_id,json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.output' ELSE '$.normal.output' END||'.seq'),json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.output' ELSE '$.normal.output' END||'.event_id'),json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.output' ELSE '$.normal.output' END||'.event_type'),json_set(json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.output' ELSE '$.normal.output' END||'.payload_json'),'$.managed_publication',json_extract(c.record_json,'$.outcome.publication')),(SELECT CASE WHEN json_type(draft,'$.created_at_ms')='integer' AND typeof(json_extract(draft,'$.created_at_ms'))='integer' AND json_extract(draft,'$.created_at_ms')>=0 THEN json_extract(draft,'$.created_at_ms') END FROM (SELECT json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.output' ELSE '$.normal.output' END) AS draft)) FROM managed_effect_calls c WHERE c.effect_id=? AND c.commit_id=?", params: [text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.effect_id.copy()), text(input.commit_id.copy())], query: false, limits: limits() }, { sql: "INSERT INTO managed_effect_guard(id) SELECT 2 WHERE ? = 'CommitAfterFirstEvent'", params: [text(input.cut.copy())], query: false, limits: limits() }, -{ sql: "INSERT INTO run_events(run_id,seq,event_id,event_type,payload_json,created_at_ms) SELECT c.run_id,json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.terminal' ELSE '$.normal.terminal' END||'.seq'),json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.terminal' ELSE '$.normal.terminal' END||'.event_id'),json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.terminal' ELSE '$.normal.terminal' END||'.event_type'),json_patch(json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.terminal' ELSE '$.normal.terminal' END||'.payload_json'),CASE WHEN json_extract(c.record_json,'$.outcome.publication') IS NULL THEN '{}' ELSE json_object('managed_publication',json_extract(c.record_json,'$.outcome.publication')) END),? FROM managed_effect_calls c WHERE c.effect_id=? AND c.commit_id=?", params: [text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.projections_json.copy()), integer(input.now_ms.copy()), text(input.effect_id.copy()), text(input.commit_id.copy())], query: false, limits: limits() }, +{ sql: "INSERT INTO run_events(run_id,seq,event_id,event_type,payload_json,created_at_ms) SELECT c.run_id,json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.terminal' ELSE '$.normal.terminal' END||'.seq'),json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.terminal' ELSE '$.normal.terminal' END||'.event_id'),json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.terminal' ELSE '$.normal.terminal' END||'.event_type'),json_set(json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.terminal' ELSE '$.normal.terminal' END||'.payload_json'),'$.managed_publication',json_extract(c.record_json,'$.outcome.publication')),(SELECT CASE WHEN json_type(draft,'$.created_at_ms')='integer' AND typeof(json_extract(draft,'$.created_at_ms'))='integer' AND json_extract(draft,'$.created_at_ms')>=0 THEN json_extract(draft,'$.created_at_ms') END FROM (SELECT json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.terminal' ELSE '$.normal.terminal' END) AS draft)) FROM managed_effect_calls c WHERE c.effect_id=? AND c.commit_id=?", params: [text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.effect_id.copy()), text(input.commit_id.copy())], query: false, limits: limits() }, { sql: "INSERT INTO messages(id,session_id,role,content_json,created_at_ms,run_id,name,tool_call_id,parent_message_id,token_estimate,metadata_json,ordinal,finish_reason) SELECT json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.message.id' ELSE '$.normal.message.id' END),json_extract(c.identity_json,'$.owner.session_id'),'user',json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.message.content_json' ELSE '$.normal.message.content_json' END),?,c.run_id,json_extract(c.identity_json,'$.tool_name'),c.call_id,json_extract(?,'$.normal.message.parent_message_id'),0,json_extract(c.record_json,'$.outcome.publication'),json_extract(?,'$.normal.message.ordinal'),'' FROM managed_effect_calls c WHERE effect_id=? AND commit_id=?", params: [text(input.projections_json.copy()), text(input.projections_json.copy()), integer(input.now_ms.copy()), text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.effect_id.copy()), text(input.commit_id.copy())], query: false, limits: limits() }, { sql: "INSERT INTO managed_effect_guard(id) SELECT 2 WHERE ? = 'CommitAfterMessage'", params: [text(input.cut.copy())], query: false, limits: limits() }, { sql: "UPDATE sessions SET last_message_seq=MAX(last_message_seq,COALESCE((SELECT MAX(ordinal) FROM messages WHERE session_id=sessions.id),0)),updated_at_ms=? WHERE id=(SELECT session_id FROM runs WHERE id=?)", params: [integer(input.now_ms.copy()), text(input.run_id.copy())], query: false, limits: limits() }, @@ -168,8 +205,8 @@ pub fn recover_one(db: resource, payload: string) -> map { let input: ManagedStorageInput = json::decode::(payload); let statements: array = [ { sql: "UPDATE managed_effect_calls SET state=CASE WHEN state='intent' THEN 'indeterminate' ELSE state END,commit_id=?,record_json=json_set(record_json,'$.state',CASE WHEN state='prepared' THEN 'staged' ELSE 'indeterminate' END,'$.recovery',json_extract(?,'$.normal.recovery')) WHERE effect_id=? AND state IN ('intent','prepared','indeterminate') AND json_extract(record_json,'$.recovery') IS NULL", params: [text(input.commit_id.copy()), text(input.projections_json.copy()), text(input.effect_id.copy())], query: false, limits: limits() }, -{ sql: "INSERT INTO run_events(run_id,seq,event_id,event_type,payload_json,created_at_ms) SELECT c.run_id,json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.output' ELSE '$.normal.output' END||'.seq'),json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.output' ELSE '$.normal.output' END||'.event_id'),json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.output' ELSE '$.normal.output' END||'.event_type'),json_patch(json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.output' ELSE '$.normal.output' END||'.payload_json'),CASE WHEN json_extract(c.record_json,'$.outcome.publication') IS NULL THEN '{}' ELSE json_object('managed_publication',json_extract(c.record_json,'$.outcome.publication')) END),? FROM managed_effect_calls c WHERE c.effect_id=? AND c.commit_id=?", params: [text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.projections_json.copy()), integer(input.now_ms.copy()), text(input.effect_id.copy()), text(input.commit_id.copy())], query: false, limits: limits() }, -{ sql: "INSERT INTO run_events(run_id,seq,event_id,event_type,payload_json,created_at_ms) SELECT c.run_id,json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.terminal' ELSE '$.normal.terminal' END||'.seq'),json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.terminal' ELSE '$.normal.terminal' END||'.event_id'),json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.terminal' ELSE '$.normal.terminal' END||'.event_type'),json_patch(json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.terminal' ELSE '$.normal.terminal' END||'.payload_json'),CASE WHEN json_extract(c.record_json,'$.outcome.publication') IS NULL THEN '{}' ELSE json_object('managed_publication',json_extract(c.record_json,'$.outcome.publication')) END),? FROM managed_effect_calls c WHERE c.effect_id=? AND c.commit_id=?", params: [text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.projections_json.copy()), integer(input.now_ms.copy()), text(input.effect_id.copy()), text(input.commit_id.copy())], query: false, limits: limits() }, +{ sql: "INSERT INTO run_events(run_id,seq,event_id,event_type,payload_json,created_at_ms) SELECT c.run_id,json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.output' ELSE '$.normal.output' END||'.seq'),json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.output' ELSE '$.normal.output' END||'.event_id'),json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.output' ELSE '$.normal.output' END||'.event_type'),json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.output' ELSE '$.normal.output' END||'.payload_json'),(SELECT CASE WHEN json_type(draft,'$.created_at_ms')='integer' AND typeof(json_extract(draft,'$.created_at_ms'))='integer' AND json_extract(draft,'$.created_at_ms')>=0 THEN json_extract(draft,'$.created_at_ms') END FROM (SELECT json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.output' ELSE '$.normal.output' END) AS draft)) FROM managed_effect_calls c WHERE c.effect_id=? AND c.commit_id=?", params: [text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.effect_id.copy()), text(input.commit_id.copy())], query: false, limits: limits() }, +{ sql: "INSERT INTO run_events(run_id,seq,event_id,event_type,payload_json,created_at_ms) SELECT c.run_id,json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.terminal' ELSE '$.normal.terminal' END||'.seq'),json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.terminal' ELSE '$.normal.terminal' END||'.event_id'),json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.terminal' ELSE '$.normal.terminal' END||'.event_type'),json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.terminal' ELSE '$.normal.terminal' END||'.payload_json'),(SELECT CASE WHEN json_type(draft,'$.created_at_ms')='integer' AND typeof(json_extract(draft,'$.created_at_ms'))='integer' AND json_extract(draft,'$.created_at_ms')>=0 THEN json_extract(draft,'$.created_at_ms') END FROM (SELECT json_extract(?,CASE WHEN c.state='conflict' THEN '$.conflict.terminal' ELSE '$.normal.terminal' END) AS draft)) FROM managed_effect_calls c WHERE c.effect_id=? AND c.commit_id=?", params: [text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.effect_id.copy()), text(input.commit_id.copy())], query: false, limits: limits() }, { sql: "INSERT INTO messages(id,session_id,role,content_json,created_at_ms,run_id,name,tool_call_id,parent_message_id,token_estimate,metadata_json,ordinal,finish_reason) SELECT json_extract(?,'$.normal.message.id'),json_extract(c.identity_json,'$.owner.session_id'),'user',json_extract(?,'$.normal.message.content_json'),?,c.run_id,json_extract(c.identity_json,'$.tool_name'),c.call_id,json_extract(?,'$.normal.message.parent_message_id'),0,'{}',json_extract(?,'$.normal.message.ordinal'),'' FROM managed_effect_calls c WHERE c.effect_id=? AND c.commit_id=?", params: [text(input.projections_json.copy()), text(input.projections_json.copy()), integer(input.now_ms.copy()), text(input.projections_json.copy()), text(input.projections_json.copy()), text(input.effect_id.copy()), text(input.commit_id.copy())], query: false, limits: limits() }, { sql: "UPDATE runs SET updated_at_ms=? WHERE id=?", params: [integer(input.now_ms.copy()), text(input.run_id.copy())], query: false, limits: limits() }, { sql: "DELETE FROM run_events WHERE run_id=? AND seq NOT IN (SELECT seq FROM run_events WHERE run_id=? ORDER BY seq DESC LIMIT ?)", params: [text(input.run_id.copy()), text(input.run_id.copy()), integer(input.max_events.copy())], query: false, limits: limits() }, @@ -182,9 +219,172 @@ pub fn recover_one(db: resource, payload: string) -> map { ok(query) } + +struct ManagedMaintenanceInput {maintenance_json:string,cut:string} + +pub fn checkpoint_context(db:resource,payload:string)->map { + let input:ManagedStorageInput=json::decode::(payload); + ok(sqlite::query(&db,"SELECT json_object('workspace',json(w.record_json),'head',json_object('workspace_id',b.workspace_id,'branch_id',b.branch_id,'epoch',b.epoch,'head_object_id',b.head_object_id,'head_version',b.head_version,'store_id',b.store_id,'schema_version',b.schema_version,'process_lineage_tainted',json(CASE b.process_lineage_tainted WHEN 1 THEN 'true' ELSE 'false' END)),'session_id',r.session_id,'session_generation',s.generation,'last_ordinal',s.last_message_seq,'messages',json((SELECT json_group_array(json(row)) FROM (SELECT json_object('id',m.id,'ordinal',m.ordinal,'role',m.role,'content_json',m.content_json,'metadata_json',m.metadata_json,'run_id',NULLIF(m.run_id,''),'name',NULLIF(m.name,''),'tool_call_id',NULLIF(m.tool_call_id,''),'parent_message_id',NULLIF(m.parent_message_id,''),'finish_reason',NULLIF(m.finish_reason,''),'token_estimate',m.token_estimate,'compacted',json(CASE m.compacted WHEN 1 THEN 'true' ELSE 'false' END),'created_at_ms',m.created_at_ms) AS row FROM messages m WHERE m.session_id=r.session_id ORDER BY m.ordinal LIMIT 129))),'effects',json((SELECT json_group_array(json(record_json)) FROM (SELECT c.record_json FROM managed_effect_calls c JOIN runs cr ON cr.id=c.run_id WHERE cr.session_id=r.session_id ORDER BY c.effect_id LIMIT 129))),'external',json((SELECT json_group_array(json(row)) FROM (SELECT json_object('run_id',er.id,'event_seq',COALESCE(rt.high_water_seq,0),'provider_request_count',COALESCE((SELECT SUM(request_count) FROM provider_usage WHERE run_id=er.id),0),'input_tokens',er.input_tokens,'output_tokens',er.output_tokens) AS row FROM runs er LEFT JOIN run_retention rt ON rt.run_id=er.id WHERE er.session_id=r.session_id ORDER BY er.id LIMIT 33))),'source_runs',json((SELECT json_group_array(json(row)) FROM (SELECT json_object('run_id',sr.run_id,'session_id',sr.session_id,'status',rr.status,'original_deadline_ms',sr.original_deadline_ms) AS row FROM managed_run_bindings sr JOIN runs rr ON rr.id=sr.run_id WHERE sr.workspace_id=b.workspace_id AND sr.branch_id=b.branch_id ORDER BY sr.run_id LIMIT 129))),'unsettled_runs',(SELECT COUNT(*) FROM managed_run_bindings mb JOIN runs mr ON mr.id=mb.run_id WHERE mb.workspace_id=b.workspace_id AND mb.branch_id=b.branch_id AND mr.status NOT IN ('completed','failed','cancelled')),'pending_compactions',(SELECT COUNT(*) FROM compactions WHERE session_id=r.session_id AND state='pending'),'compaction',json((SELECT json_object('id',id,'generation',generation,'source_start_ordinal',source_start_ordinal,'source_end_ordinal',source_end_ordinal,'retained_tail_ordinal',retained_tail_ordinal,'summary_json',summary_json,'token_estimate',token_estimate,'model',model,'created_at_ms',created_at_ms,'completed_at_ms',completed_at_ms) FROM compactions WHERE session_id=r.session_id AND state='committed' ORDER BY generation DESC LIMIT 1))) AS record_json FROM managed_run_bindings rb JOIN runs r ON r.id=rb.run_id JOIN sessions s ON s.id=r.session_id JOIN managed_workspaces w ON w.workspace_id=rb.workspace_id JOIN managed_branches b ON b.workspace_id=rb.workspace_id AND b.branch_id=rb.branch_id WHERE rb.run_id=? AND rb.workspace_id=? AND rb.branch_id=? AND rb.profile=?",[text(input.run_id),text(input.workspace_id),text(input.branch_id),text(input.profile)],limits())) +} + + +struct ManagedRestoreStorageInput {restore_json:string} + +pub fn restore_before_admission(payload:string, graph_id:string, ref_id:string, run_id:string)->array { + let input:ManagedRestoreStorageInput=json::decode::(payload); + [ +{sql:"INSERT INTO managed_effect_guard(id) SELECT 2 WHERE (EXISTS(SELECT 1 FROM compactions c JOIN managed_run_bindings b ON b.session_id=c.session_id WHERE b.workspace_id=json_extract(?,'$.request.workspace_id') AND b.branch_id=json_extract(?,'$.request.source_branch_id') AND c.state='pending')) AND (? = '' OR (NOT EXISTS (SELECT 1 FROM plugin_graph_revocations WHERE graph_id = ?) AND NOT EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q WHERE q.run_id = ? AND (q.graph_id <> ? OR q.ref_id <> ?)))) ",params:[text(input.restore_json.copy()),text(input.restore_json.copy()), text(graph_id.copy()), text(graph_id.copy()), text(run_id.copy()), text(graph_id.copy()), text(ref_id.copy())],query:false,limits:limits()}, +{sql:"INSERT INTO managed_effect_guard(id) SELECT 2 WHERE (NOT EXISTS(SELECT 1 FROM managed_branches b JOIN managed_workspaces w ON w.workspace_id=b.workspace_id AND w.current_branch_id=b.branch_id WHERE b.store_id=json_extract(?,'$.request.expected_source.store_id') AND b.workspace_id=json_extract(?,'$.request.workspace_id') AND b.branch_id=json_extract(?,'$.request.source_branch_id') AND b.epoch=json_extract(?,'$.request.expected_source.epoch') AND b.head_object_id=json_extract(?,'$.request.expected_source.head_object_id') AND b.head_version=json_extract(?,'$.request.expected_source.head_version') AND b.status='active' AND b.process_lineage_tainted=0)) AND (? = '' OR (NOT EXISTS (SELECT 1 FROM plugin_graph_revocations WHERE graph_id = ?) AND NOT EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q WHERE q.run_id = ? AND (q.graph_id <> ? OR q.ref_id <> ?)))) ",params:[text(input.restore_json.copy()),text(input.restore_json.copy()),text(input.restore_json.copy()),text(input.restore_json.copy()),text(input.restore_json.copy()),text(input.restore_json.copy()), text(graph_id.copy()), text(graph_id.copy()), text(run_id.copy()), text(graph_id.copy()), text(ref_id.copy())],query:false,limits:limits()}, +{sql:"INSERT INTO managed_effect_guard(id) SELECT 2 WHERE (EXISTS(SELECT 1 FROM managed_run_bindings b JOIN runs r ON r.id=b.run_id WHERE b.workspace_id=json_extract(?,'$.request.workspace_id') AND b.branch_id=json_extract(?,'$.request.source_branch_id') AND r.status NOT IN ('completed','failed','cancelled')) OR (SELECT COUNT(*) FROM managed_restore_receipts WHERE workspace_id=json_extract(?,'$.request.workspace_id'))>=64) AND (? = '' OR (NOT EXISTS (SELECT 1 FROM plugin_graph_revocations WHERE graph_id = ?) AND NOT EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q WHERE q.run_id = ? AND (q.graph_id <> ? OR q.ref_id <> ?)))) ",params:[text(input.restore_json.copy()),text(input.restore_json.copy()),text(input.restore_json.copy()), text(graph_id.copy()), text(graph_id.copy()), text(run_id.copy()), text(graph_id.copy()), text(ref_id.copy())],query:false,limits:limits()}, +{sql:"INSERT INTO managed_effect_guard(id) SELECT 2 WHERE (NOT EXISTS(SELECT 1 FROM managed_checkpoints c JOIN plugin_graph_refs g ON g.owner_kind='checkpoint' AND g.owner_id=c.checkpoint_id WHERE c.checkpoint_id=json_extract(?,'$.request.checkpoint_id') AND c.workspace_id=json_extract(?,'$.request.workspace_id') AND json_extract(c.receipt_json,'$.checkpoint.frozen_root.object_id')=json_extract(?,'$.result.new_branch.head_object_id') AND g.ref_id=json_extract(?,'$.result.graph.checkpoint_graph_ref_id'))) AND (? = '' OR (NOT EXISTS (SELECT 1 FROM plugin_graph_revocations WHERE graph_id = ?) AND NOT EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q WHERE q.run_id = ? AND (q.graph_id <> ? OR q.ref_id <> ?)))) ",params:[text(input.restore_json.copy()),text(input.restore_json.copy()),text(input.restore_json.copy()),text(input.restore_json.copy()), text(graph_id.copy()), text(graph_id.copy()), text(run_id.copy()), text(graph_id.copy()), text(ref_id.copy())],query:false,limits:limits()}, +{sql:"UPDATE managed_branches SET epoch=json_extract(?,'$.result.source_after.epoch'),head_version=json_extract(?,'$.result.source_after.head_version'),status='retired' WHERE (workspace_id=json_extract(?,'$.request.workspace_id') AND branch_id=json_extract(?,'$.request.source_branch_id')) AND (? = '' OR (NOT EXISTS (SELECT 1 FROM plugin_graph_revocations WHERE graph_id = ?) AND NOT EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q WHERE q.run_id = ? AND (q.graph_id <> ? OR q.ref_id <> ?)))) ",params:[text(input.restore_json.copy()),text(input.restore_json.copy()),text(input.restore_json.copy()),text(input.restore_json.copy()), text(graph_id.copy()), text(graph_id.copy()), text(run_id.copy()), text(graph_id.copy()), text(ref_id.copy())],query:false,limits:limits()}, +{sql:"INSERT INTO managed_head_history(workspace_id,branch_id,head_version,epoch,effect_id,previous_head,head_object_id,schema_version) SELECT workspace_id,branch_id,head_version,epoch,NULL,head_object_id,head_object_id,1 FROM managed_branches WHERE (workspace_id=json_extract(?,'$.request.workspace_id') AND branch_id=json_extract(?,'$.request.source_branch_id')) AND (? = '' OR (NOT EXISTS (SELECT 1 FROM plugin_graph_revocations WHERE graph_id = ?) AND NOT EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q WHERE q.run_id = ? AND (q.graph_id <> ? OR q.ref_id <> ?)))) ",params:[text(input.restore_json.copy()),text(input.restore_json.copy()), text(graph_id.copy()), text(graph_id.copy()), text(run_id.copy()), text(graph_id.copy()), text(ref_id.copy())],query:false,limits:limits()}, +{sql:"INSERT INTO managed_branches(workspace_id,branch_id,epoch,head_object_id,head_version,store_id,schema_version,status,lineage_json) SELECT json_extract(?,'$.result.new_branch.workspace_id'),json_extract(?,'$.result.new_branch.branch_id'),json_extract(?,'$.result.new_branch.epoch'),json_extract(?,'$.result.new_branch.head_object_id'),1,json_extract(?,'$.result.new_branch.store_id'),1,'active',json_extract(?,'$.result') WHERE (? = '' OR (NOT EXISTS (SELECT 1 FROM plugin_graph_revocations WHERE graph_id = ?) AND NOT EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q WHERE q.run_id = ? AND (q.graph_id <> ? OR q.ref_id <> ?)))) ",params:[text(input.restore_json.copy()),text(input.restore_json.copy()),text(input.restore_json.copy()),text(input.restore_json.copy()),text(input.restore_json.copy()),text(input.restore_json.copy()), text(graph_id.copy()), text(graph_id.copy()), text(run_id.copy()), text(graph_id.copy()), text(ref_id.copy())],query:false,limits:limits()}, +{sql:"INSERT INTO managed_head_history(workspace_id,branch_id,head_version,epoch,effect_id,previous_head,head_object_id,schema_version) SELECT workspace_id,branch_id,head_version,epoch,NULL,NULL,head_object_id,1 FROM managed_branches WHERE (workspace_id=json_extract(?,'$.request.workspace_id') AND branch_id=json_extract(?,'$.result.new_branch.branch_id')) AND (? = '' OR (NOT EXISTS (SELECT 1 FROM plugin_graph_revocations WHERE graph_id = ?) AND NOT EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q WHERE q.run_id = ? AND (q.graph_id <> ? OR q.ref_id <> ?)))) ",params:[text(input.restore_json.copy()),text(input.restore_json.copy()), text(graph_id.copy()), text(graph_id.copy()), text(run_id.copy()), text(graph_id.copy()), text(ref_id.copy())],query:false,limits:limits()}, +{sql:"UPDATE managed_workspaces SET current_branch_id=json_extract(?,'$.result.new_branch.branch_id') WHERE (workspace_id=json_extract(?,'$.request.workspace_id')) AND (? = '' OR (NOT EXISTS (SELECT 1 FROM plugin_graph_revocations WHERE graph_id = ?) AND NOT EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q WHERE q.run_id = ? AND (q.graph_id <> ? OR q.ref_id <> ?)))) ",params:[text(input.restore_json.copy()),text(input.restore_json.copy()), text(graph_id.copy()), text(graph_id.copy()), text(run_id.copy()), text(graph_id.copy()), text(ref_id.copy())],query:false,limits:limits()} + ] +} + +pub fn restore_history_statements(payload:string, graph_id:string, ref_id:string, run_id:string)->array { + let input:ManagedRestoreStorageInput=json::decode::(payload); + [ +{sql:"INSERT INTO messages(id,session_id,ordinal,role,content_json,metadata_json,run_id,name,tool_call_id,parent_message_id,token_estimate,finish_reason,created_at_ms,compacted) SELECT 'restored-'||json_extract(?,'$.result.new_session_id')||'-'||h.ordinal,json_extract(?,'$.result.new_session_id'),h.ordinal,json_extract(h.record_json,'$.role'),json_extract(h.record_json,'$.content_json'),json_object('origin',json_object('checkpoint_id',json_extract(?,'$.request.checkpoint_id'),'source_message_id',h.source_message_id)),'',COALESCE(json_extract(h.record_json,'$.name'),''),COALESCE(json_extract(h.record_json,'$.tool_call_id'),''),CASE WHEN COALESCE(json_extract(h.record_json,'$.parent_message_id'),'')='' THEN '' ELSE 'restored-'||json_extract(?,'$.result.new_session_id')||'-'||(SELECT p.ordinal FROM managed_checkpoint_history p WHERE p.history_root_id=h.history_root_id AND p.source_message_id=json_extract(h.record_json,'$.parent_message_id')) END,COALESCE(json_extract(h.record_json,'$.token_estimate'),0),COALESCE(json_extract(h.record_json,'$.finish_reason'),''),json_extract(h.record_json,'$.created_at_ms'),json_extract(h.record_json,'$.compacted') FROM managed_checkpoint_history h WHERE (h.history_root_id=json_extract(?,'$.history_root_id')) AND (? = '' OR (NOT EXISTS (SELECT 1 FROM plugin_graph_revocations WHERE graph_id = ?) AND NOT EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q WHERE q.run_id = ? AND (q.graph_id <> ? OR q.ref_id <> ?)))) ORDER BY h.ordinal",params:[text(input.restore_json.copy()),text(input.restore_json.copy()),text(input.restore_json.copy()),text(input.restore_json.copy()),text(input.restore_json.copy()), text(graph_id.copy()), text(graph_id.copy()), text(run_id.copy()), text(graph_id.copy()), text(ref_id.copy())],query:false,limits:limits()} + ] +} + +pub fn restore_receipt_statements(payload:string, graph_id:string, ref_id:string, run_id:string)->array { + let input:ManagedRestoreStorageInput=json::decode::(payload); + [ +{sql:"INSERT INTO compactions(id,session_id,run_id,generation,source_start_ordinal,source_end_ordinal,retained_tail_ordinal,summary_json,token_estimate,model,state,created_at_ms,completed_at_ms) SELECT 'restored-'||json_extract(?,'$.result.new_session_id'),json_extract(?,'$.result.new_session_id'),json_extract(?,'$.result.new_run_id'),json_extract(c.compaction_json,'$.generation'),json_extract(c.compaction_json,'$.source_start_ordinal'),json_extract(c.compaction_json,'$.source_end_ordinal'),json_extract(c.compaction_json,'$.retained_tail_ordinal'),json_extract(c.compaction_json,'$.summary_json'),json_extract(c.compaction_json,'$.token_estimate'),json_extract(c.compaction_json,'$.model'),'committed',json_extract(c.compaction_json,'$.created_at_ms'),json_extract(c.compaction_json,'$.completed_at_ms') FROM managed_checkpoints c WHERE (c.checkpoint_id=json_extract(?,'$.request.checkpoint_id') AND json_type(c.compaction_json)='object') AND (? = '' OR (NOT EXISTS (SELECT 1 FROM plugin_graph_revocations WHERE graph_id = ?) AND NOT EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q WHERE q.run_id = ? AND (q.graph_id <> ? OR q.ref_id <> ?)))) ",params:[text(input.restore_json.copy()),text(input.restore_json.copy()),text(input.restore_json.copy()),text(input.restore_json.copy()), text(graph_id.copy()), text(graph_id.copy()), text(run_id.copy()), text(graph_id.copy()), text(ref_id.copy())],query:false,limits:limits()}, +{sql:"UPDATE sessions SET generation=(SELECT json_extract(c.receipt_json,'$.checkpoint.history.session_generation') FROM managed_checkpoints c WHERE c.checkpoint_id=json_extract(?,'$.request.checkpoint_id')) WHERE (id=json_extract(?,'$.result.new_session_id')) AND (? = '' OR (NOT EXISTS (SELECT 1 FROM plugin_graph_revocations WHERE graph_id = ?) AND NOT EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q WHERE q.run_id = ? AND (q.graph_id <> ? OR q.ref_id <> ?)))) ",params:[text(input.restore_json.copy()),text(input.restore_json.copy()), text(graph_id.copy()), text(graph_id.copy()), text(run_id.copy()), text(graph_id.copy()), text(ref_id.copy())],query:false,limits:limits()}, +{sql:"INSERT INTO managed_restore_receipts(workspace_id,request_id,checkpoint_id,original_request_proof,envelope_json,result_json) SELECT json_extract(?,'$.request.workspace_id'),json_extract(?,'$.request.request_id'),json_extract(?,'$.request.checkpoint_id'),json_extract(?,'$.original_request_proof'),?,json_extract(?,'$.result') WHERE (? = '' OR (NOT EXISTS (SELECT 1 FROM plugin_graph_revocations WHERE graph_id = ?) AND NOT EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q WHERE q.run_id = ? AND (q.graph_id <> ? OR q.ref_id <> ?)))) ",params:[text(input.restore_json.copy()),text(input.restore_json.copy()),text(input.restore_json.copy()),text(input.restore_json.copy()),text(input.restore_json.copy()),text(input.restore_json.copy()), text(graph_id.copy()), text(graph_id.copy()), text(run_id.copy()), text(graph_id.copy()), text(ref_id.copy())],query:false,limits:limits()} + ] +} + +pub fn maintenance_receipt(db:resource,payload:string)->map { + let input:ManagedStorageInput=json::decode::(payload); + if input.cut == "checkpoint" => { + ok(sqlite::query(&db,"SELECT json_object('original_request_proof',original_request_proof,'receipt',json(receipt_json)) AS record_json FROM managed_checkpoints WHERE workspace_id=? AND request_id=?",[text(input.workspace_id),text(input.receipt_id)],limits())) + } else => { + ok(sqlite::query(&db,"SELECT json_object('original_request_proof',r.original_request_proof,'envelope',json(r.envelope_json),'checkpoint_graph',(SELECT json_extract(c.receipt_json,'$.checkpoint.graph') FROM managed_checkpoints c WHERE c.workspace_id=r.workspace_id AND c.checkpoint_id=r.checkpoint_id)) AS record_json FROM managed_restore_receipts r WHERE r.workspace_id=? AND r.request_id=?",[text(input.workspace_id),text(input.receipt_id)],limits())) + } +} + +pub fn branch_publication_history(db:resource,payload:string)->map { + let input:ManagedStorageInput=json::decode::(payload); + if input.cut.copy() == "checkpoint_rollback" => { + ok(sqlite::query(&db,"SELECT json_object('source',json_object('store_id',b.store_id,'workspace_id',b.workspace_id,'branch_id',b.branch_id,'epoch',b.epoch,'head_object_id',b.head_object_id,'head_version',b.head_version),'current_branch_id',w.current_branch_id,'target_rows',(SELECT COUNT(*) FROM managed_checkpoints WHERE checkpoint_id=json_extract(?,'$.receipt.checkpoint.checkpoint_id') OR (workspace_id=b.workspace_id AND request_id=json_extract(?,'$.request.request_id')))+(SELECT COUNT(*) FROM managed_checkpoint_history WHERE history_root_id=json_extract(?,'$.receipt.checkpoint.history.history_root_id'))+(SELECT COUNT(*) FROM managed_checkpoint_public_configs WHERE config_ref_id=json_extract(?,'$.receipt.checkpoint.public_config.config_ref_id'))+(SELECT COUNT(*) FROM managed_checkpoint_refs WHERE checkpoint_id=json_extract(?,'$.receipt.checkpoint.checkpoint_id'))+(SELECT COUNT(*) FROM plugin_graph_refs WHERE ref_id=json_extract(?,'$.receipt.checkpoint.graph.checkpoint_graph_ref_id'))) AS record_json FROM managed_branches b JOIN managed_workspaces w ON w.workspace_id=b.workspace_id WHERE b.workspace_id=? AND b.branch_id=json_extract(?,'$.request.source_branch_id') AND b.status='active'",[text(input.record_json.copy()),text(input.record_json.copy()),text(input.record_json.copy()),text(input.record_json.copy()),text(input.record_json.copy()),text(input.record_json.copy()),text(input.workspace_id.copy()),text(input.record_json.copy())],limits())) + } else => { if input.cut.copy() == "rollback" => { + ok(sqlite::query(&db,"SELECT json_object('source',json_object('store_id',b.store_id,'workspace_id',b.workspace_id,'branch_id',b.branch_id,'epoch',b.epoch,'head_object_id',b.head_object_id,'head_version',b.head_version),'current_branch_id',w.current_branch_id,'target_rows',(SELECT COUNT(*) FROM managed_branches WHERE branch_id=json_extract(?,'$.result.new_branch.branch_id'))+(SELECT COUNT(*) FROM sessions WHERE id=json_extract(?,'$.result.new_session_id'))+(SELECT COUNT(*) FROM runs WHERE id=json_extract(?,'$.result.new_run_id'))+(SELECT COUNT(*) FROM messages WHERE session_id=json_extract(?,'$.result.new_session_id'))+(SELECT COUNT(*) FROM plugin_graph_refs WHERE ref_id=json_extract(?,'$.admission_pin.ref_id'))+(SELECT COUNT(*) FROM managed_restore_receipts WHERE workspace_id=b.workspace_id AND request_id=json_extract(?,'$.request.request_id'))+(SELECT COUNT(*) FROM managed_head_history WHERE workspace_id=b.workspace_id AND ((branch_id=b.branch_id AND head_version>json_extract(?,'$.result.source_before.head_version')) OR branch_id=json_extract(?,'$.result.new_branch.branch_id')))+(SELECT COUNT(*) FROM managed_run_bindings WHERE run_id=json_extract(?,'$.result.new_run_id'))+(SELECT COUNT(*) FROM run_events WHERE run_id=json_extract(?,'$.result.new_run_id'))+(SELECT COUNT(*) FROM idempotency_records WHERE resource_id=json_extract(?,'$.result.new_run_id'))+(SELECT COUNT(*) FROM run_retention WHERE run_id=json_extract(?,'$.result.new_run_id'))) AS record_json FROM managed_branches b JOIN managed_workspaces w ON w.workspace_id=b.workspace_id WHERE b.workspace_id=? AND b.branch_id=json_extract(?,'$.request.source_branch_id') AND b.status='active'",[text(input.record_json.copy()),text(input.record_json.copy()),text(input.record_json.copy()),text(input.record_json.copy()),text(input.record_json.copy()),text(input.record_json.copy()),text(input.record_json.copy()),text(input.record_json.copy()),text(input.record_json.copy()),text(input.record_json.copy()),text(input.record_json.copy()),text(input.record_json.copy()),text(input.workspace_id.copy()),text(input.record_json.copy())],limits())) + } else => { + ok(sqlite::query(&db,"SELECT json_object('source',json_object('store_id',s.store_id,'workspace_id',s.workspace_id,'branch_id',s.branch_id,'epoch',s.epoch,'head_object_id',s.head_object_id,'head_version',s.head_version),'current',json_object('store_id',n.store_id,'workspace_id',n.workspace_id,'branch_id',n.branch_id,'epoch',n.epoch,'head_object_id',n.head_object_id,'head_version',n.head_version),'history_count',(SELECT COUNT(*) FROM managed_head_history h WHERE h.workspace_id=r.workspace_id AND ((h.branch_id=s.branch_id AND h.epoch=s.epoch AND h.head_version=s.head_version AND h.head_object_id=s.head_object_id) OR (h.branch_id=n.branch_id AND h.epoch=n.epoch AND h.head_version=1 AND h.head_object_id=n.head_object_id))),'admission_pin',json_extract(r.envelope_json,'$.admission_pin'),'graph',json_extract(r.envelope_json,'$.result.graph')) AS record_json FROM managed_restore_receipts r JOIN managed_branches s ON s.workspace_id=r.workspace_id AND s.branch_id=json_extract(r.envelope_json,'$.request.source_branch_id') AND s.status='retired' JOIN managed_branches n ON n.workspace_id=r.workspace_id AND n.branch_id=json_extract(r.result_json,'$.new_branch.branch_id') AND n.status='active' JOIN managed_workspaces w ON w.workspace_id=r.workspace_id AND w.current_branch_id=n.branch_id JOIN runs a ON a.id=json_extract(r.result_json,'$.new_run_id') AND a.session_id=json_extract(r.result_json,'$.new_session_id') JOIN managed_run_bindings b ON b.run_id=a.id AND b.workspace_id=n.workspace_id AND b.branch_id=n.branch_id AND b.epoch=n.epoch AND b.original_deadline_ms=json_extract(r.result_json,'$.new_run_deadline_at_ms') JOIN plugin_graph_refs g ON g.owner_kind='run' AND g.owner_id=a.id AND g.ref_id=b.graph_ref_id AND g.ref_id=json_extract(r.envelope_json,'$.admission_pin.ref_id') AND g.graph_id=json_extract(r.envelope_json,'$.admission_pin.graph_id') JOIN managed_checkpoints c ON c.checkpoint_id=r.checkpoint_id AND c.workspace_id=r.workspace_id AND json_extract(c.receipt_json,'$.checkpoint.frozen_root.object_id')=n.head_object_id WHERE r.workspace_id=? AND r.request_id=?",[text(input.workspace_id),text(input.receipt_id)],limits())) + } + } +} + +pub fn checkpoint_get(db:resource,payload:string)->map { + let input:ManagedStorageInput=json::decode::(payload); + ok(sqlite::query(&db,"SELECT json_object('checkpoint',json_extract(c.receipt_json,'$.checkpoint'),'compaction',json(c.compaction_json),'execution_config',json(c.execution_config_json),'messages',json((SELECT json_group_array(json(record_json)) FROM (SELECT h.record_json FROM managed_checkpoint_history h WHERE h.history_root_id=json_extract(c.receipt_json,'$.checkpoint.history.history_root_id') ORDER BY h.ordinal LIMIT 129)))) AS record_json FROM managed_checkpoints c WHERE c.workspace_id=? AND c.checkpoint_id=?",[text(input.workspace_id),text(input.commit_id)],limits())) +} + +pub fn checkpoint_create(db:resource,payload:string)->map { + let input:ManagedMaintenanceInput=json::decode::(payload); + let mut statements:array = [ +{sql:"INSERT INTO managed_effect_guard(id) SELECT 2 WHERE EXISTS(SELECT 1 FROM compactions WHERE session_id=json_extract(?,'$.receipt.checkpoint.history.source_session_id') AND state='pending')",params:[text(input.maintenance_json.copy())],query:false,limits:limits()}, +{sql:"INSERT INTO managed_effect_guard(id) SELECT 2 WHERE NOT EXISTS(SELECT 1 FROM managed_branches WHERE workspace_id=json_extract(?,'$.captured_source.workspace_id') AND branch_id=json_extract(?,'$.captured_source.branch_id') AND store_id=json_extract(?,'$.captured_source.store_id') AND epoch=json_extract(?,'$.captured_source.epoch') AND head_object_id=json_extract(?,'$.captured_source.head_object_id') AND head_version=json_extract(?,'$.captured_source.head_version') AND process_lineage_tainted=0)",params:[text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy())],query:false,limits:limits()}, +{sql:"INSERT INTO managed_effect_guard(id) SELECT 2 WHERE EXISTS(SELECT 1 FROM managed_effect_calls WHERE workspace_id=json_extract(?,'$.request.workspace_id') AND state IN ('intent','prepared','indeterminate'))",params:[text(input.maintenance_json.copy())],query:false,limits:limits()}, +{sql:"INSERT INTO managed_effect_guard(id) SELECT 2 WHERE NOT EXISTS(SELECT 1 FROM sessions WHERE id=json_extract(?,'$.receipt.checkpoint.history.source_session_id') AND generation=json_extract(?,'$.receipt.checkpoint.history.session_generation') AND last_message_seq=json_extract(?,'$.receipt.checkpoint.history.last_ordinal')) OR (SELECT COUNT(*) FROM managed_checkpoints WHERE workspace_id=json_extract(?,'$.request.workspace_id'))>=64",params:[text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy())],query:false,limits:limits()}, +{sql:"INSERT INTO managed_effect_guard(id) SELECT 2 WHERE NOT EXISTS(SELECT 1 FROM plugin_graph_refs WHERE owner_kind='run' AND owner_id=json_extract(?,'$.request.source_run_id') AND graph_id=json_extract(?,'$.receipt.checkpoint.graph.graph_id'))",params:[text(input.maintenance_json.copy()),text(input.maintenance_json.copy())],query:false,limits:limits()}, +{sql:"INSERT INTO managed_checkpoints(checkpoint_id,workspace_id,request_id,original_request_proof,receipt_json,compaction_json,execution_config_json) SELECT json_extract(?,'$.receipt.checkpoint.checkpoint_id'),json_extract(?,'$.request.workspace_id'),json_extract(?,'$.request.request_id'),json_extract(?,'$.original_request_proof'),json_extract(?,'$.receipt'),COALESCE(json_extract(?,'$.compaction'),'null'),json_extract(?,'$.execution_config')",params:[text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy())],query:false,limits:limits()}, +{sql:"INSERT INTO managed_checkpoint_history(history_root_id,ordinal,source_message_id,record_json) SELECT json_extract(?,'$.receipt.checkpoint.history.history_root_id'),json_extract(value,'$.ordinal'),json_extract(value,'$.id'),value FROM json_each(json_extract(?,'$.messages'))",params:[text(input.maintenance_json.copy()),text(input.maintenance_json.copy())],query:false,limits:limits()}, +{sql:"INSERT INTO managed_effect_guard(id) SELECT 2 WHERE (SELECT COUNT(*) FROM managed_checkpoint_history h JOIN messages m ON m.id=h.source_message_id WHERE h.history_root_id=json_extract(?,'$.receipt.checkpoint.history.history_root_id') AND m.content_json=json_extract(h.record_json,'$.content_json') AND m.metadata_json=json_extract(h.record_json,'$.metadata_json') AND m.ordinal=h.ordinal)<>json_extract(?,'$.receipt.checkpoint.history.message_count')",params:[text(input.maintenance_json.copy()),text(input.maintenance_json.copy())],query:false,limits:limits()}, +{sql:"INSERT INTO managed_checkpoint_public_configs(config_ref_id,checkpoint_id,record_json) SELECT json_extract(?,'$.receipt.checkpoint.public_config.config_ref_id'),json_extract(?,'$.receipt.checkpoint.checkpoint_id'),json_extract(?,'$.receipt.checkpoint.public_config.config')",params:[text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy())],query:false,limits:limits()}, +{sql:"INSERT INTO managed_checkpoint_refs(checkpoint_id,store_id,object_id) SELECT json_extract(?,'$.receipt.checkpoint.checkpoint_id'),json_extract(value,'$.store_id'),json_extract(value,'$.object_id') FROM json_each(json_array(json_extract(?,'$.receipt.checkpoint.frozen_root'))) UNION SELECT json_extract(?,'$.receipt.checkpoint.checkpoint_id'),json_extract(value,'$.result.store_id'),json_extract(value,'$.result.object_id') FROM json_each(json_extract(?,'$.receipt.checkpoint.history.closed_output_refs'))",params:[text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy())],query:false,limits:limits()}, +{sql:"INSERT INTO managed_effect_guard(id) SELECT 2 WHERE EXISTS(SELECT 1 FROM managed_checkpoint_refs c LEFT JOIN managed_objects o ON o.store_id=c.store_id AND o.object_id=c.object_id WHERE c.checkpoint_id=json_extract(?,'$.receipt.checkpoint.checkpoint_id') AND o.object_id IS NULL)",params:[text(input.maintenance_json.copy())],query:false,limits:limits()}, +{sql:"INSERT INTO plugin_graph_refs(graph_id,owner_kind,owner_id,ref_id,retained_at_ms) SELECT json_extract(?,'$.receipt.checkpoint.graph.graph_id'),'checkpoint',json_extract(?,'$.receipt.checkpoint.checkpoint_id'),json_extract(?,'$.receipt.checkpoint.graph.checkpoint_graph_ref_id'),json_extract(?,'$.receipt.committed_at_ms')",params:[text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy())],query:false,limits:limits()}, +{sql:"SELECT receipt_json AS record_json FROM managed_checkpoints WHERE checkpoint_id=json_extract(?,'$.receipt.checkpoint.checkpoint_id')",params:[text(input.maintenance_json.copy())],query:true,limits:limits()} + ]; + if input.cut == "managed_rollback" { + statements[statements.length] = {sql:"INSERT INTO managed_effect_guard(id) VALUES(2)",params:[],query:false,limits:limits()}; + } + let result:array = sqlite::transaction(&db,statements); + let last:SqliteTransactionResult=result[result.length-1].copy(); + let query:SqliteQueryResult=last.query;ok(query) +} + +fn checkpoint_schema(index: int) -> string { if index == 0 => { "CREATE TABLE managed_checkpoints(checkpoint_id TEXT PRIMARY KEY,workspace_id TEXT NOT NULL,request_id TEXT NOT NULL,original_request_proof TEXT NOT NULL,receipt_json TEXT NOT NULL CHECK(json_valid(receipt_json)),compaction_json TEXT NOT NULL CHECK(json_valid(compaction_json)),execution_config_json TEXT NOT NULL CHECK(json_valid(execution_config_json)),UNIQUE(workspace_id,request_id))" } else => { if index == 1 => { "CREATE TABLE managed_checkpoint_history(history_root_id TEXT NOT NULL,ordinal INTEGER NOT NULL,source_message_id TEXT NOT NULL,record_json TEXT NOT NULL CHECK(json_valid(record_json)),PRIMARY KEY(history_root_id,ordinal))" } else => { if index == 2 => { "CREATE TABLE managed_checkpoint_refs(checkpoint_id TEXT NOT NULL,store_id TEXT NOT NULL,object_id TEXT NOT NULL,PRIMARY KEY(checkpoint_id,store_id,object_id))" } else => { if index == 3 => { "CREATE TABLE managed_checkpoint_public_configs(config_ref_id TEXT PRIMARY KEY,checkpoint_id TEXT NOT NULL UNIQUE,record_json TEXT NOT NULL CHECK(json_valid(record_json)))" } else => { "" } } } } } + pub fn recovery_context(db: resource, payload: string) -> map { let input: ManagedStorageInput = json::decode::(payload); ok(sqlite::query(&db, "SELECT json_object('next_seq',MAX(COALESCE((SELECT high_water_seq FROM run_retention WHERE run_id=r.id),0),COALESCE((SELECT MAX(seq) FROM run_events WHERE run_id=r.id),0))+1,'ordinal',COALESCE((SELECT MAX(ordinal) FROM messages WHERE session_id=r.session_id),0)+1,'parent',COALESCE((SELECT id FROM messages m WHERE m.run_id=r.id AND m.role='assistant' AND EXISTS (SELECT 1 FROM json_each(m.content_json) b WHERE json_extract(b.value,'$.type')='tool_call' AND json_extract(b.value,'$.tool_call_id')=?)),'')) AS record_json FROM runs r WHERE r.id=?", [text(input.call_id.copy()),text(input.run_id.copy())], limits())) } pub fn pending(db: resource, payload: string) -> map { let input: ManagedStorageInput = json::decode::(payload); ok(sqlite::query(&db, "SELECT record_json FROM managed_effect_calls WHERE state IN ('intent','prepared','indeterminate') AND json_extract(record_json,'$.recovery') IS NULL ORDER BY effect_id LIMIT 128", [], limits())) } + +pub fn branch_receipt(db: resource, payload: string) -> map { + let input: ManagedStorageInput = json::decode::(payload); + ok(sqlite::query(&db, "SELECT json_object('proof',original_request_proof,'receipt',json(receipt_json),'origin',json(origin_json)) AS record_json FROM managed_branch_receipts WHERE workspace_id=? AND request_id=?", [text(input.workspace_id.copy()), text(input.receipt_id.copy())], limits())) +} + +pub fn branch_origin(db: resource, payload: string) -> map { + let input: ManagedStorageInput = json::decode::(payload); + ok(sqlite::query(&db, "SELECT CASE WHEN EXISTS (SELECT 1 FROM managed_branch_receipts r JOIN managed_branches b ON b.workspace_id=r.workspace_id AND b.branch_id=? AND b.status='active' WHERE r.workspace_id=? AND json_extract(r.receipt_json,'$.branch.branch_id')=b.branch_id) THEN (SELECT json_object('proof',r.original_request_proof,'receipt',json(r.receipt_json),'origin',json(r.origin_json)) FROM managed_branch_receipts r WHERE r.workspace_id=? AND json_extract(r.receipt_json,'$.branch.branch_id')=? LIMIT 1) WHEN EXISTS (SELECT 1 FROM managed_branches WHERE branch_id=? AND status='active') THEN json_object('foreign', json('true')) ELSE json_object('missing', json('true')) END AS record_json", [text(input.branch_id.copy()), text(input.workspace_id.copy()), text(input.workspace_id.copy()), text(input.branch_id.copy()), text(input.branch_id.copy())], limits())) +} + +pub fn branch_create(db: resource, payload: string) -> map { + let input: ManagedMaintenanceInput = json::decode::(payload); + let mut statements: array = [ + {sql:"INSERT INTO managed_effect_guard(id) SELECT 2 WHERE NOT EXISTS (SELECT 1 FROM managed_branches WHERE workspace_id=json_extract(?,'$.source.workspace_id') AND branch_id=json_extract(?,'$.source.branch_id') AND store_id=json_extract(?,'$.source.store_id') AND epoch=json_extract(?,'$.source.epoch') AND head_object_id=json_extract(?,'$.source.head_object_id') AND head_version=json_extract(?,'$.source.head_version'))", params:[text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy())], query:false, limits:limits()}, + {sql:"INSERT INTO managed_effect_guard(id) SELECT 2 WHERE json_extract(?,'$.checkpoint_id') IS NOT NULL AND NOT EXISTS (SELECT 1 FROM managed_checkpoints WHERE workspace_id=json_extract(?,'$.source.workspace_id') AND checkpoint_id=json_extract(?,'$.checkpoint_id') AND json_extract(receipt_json,'$.checkpoint.source_branch_id')=json_extract(?,'$.source.branch_id') AND json_extract(receipt_json,'$.checkpoint.frozen_root.object_id')=json_extract(?,'$.branch.head_object_id') AND json_extract(receipt_json,'$.checkpoint.workspace_id')=json_extract(?,'$.source.workspace_id'))", params:[text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy())], query:false, limits:limits()}, + {sql:"INSERT INTO managed_effect_guard(id) SELECT 2 WHERE json_extract(?,'$.checkpoint_id') IS NULL AND (NOT EXISTS (SELECT 1 FROM managed_branches WHERE workspace_id=json_extract(?,'$.source.workspace_id') AND branch_id=json_extract(?,'$.source.branch_id') AND status='active') OR EXISTS (SELECT 1 FROM managed_effect_calls WHERE workspace_id=json_extract(?,'$.source.workspace_id') AND branch_id=json_extract(?,'$.source.branch_id') AND state IN ('intent','prepared','indeterminate')))", params:[text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy())], query:false, limits:limits()}, + {sql:"INSERT INTO managed_effect_guard(id) SELECT 2 WHERE NOT EXISTS (SELECT 1 FROM managed_branches WHERE workspace_id=json_extract(?,'$.source.workspace_id') AND branch_id=json_extract(?,'$.source.branch_id') AND epoch+1=json_extract(?,'$.branch.epoch') AND json_extract(?,'$.branch.head_version')=1 AND json_extract(?,'$.branch.branch_id')<>branch_id AND json_extract(?,'$.branch.workspace_id')=workspace_id AND json_extract(?,'$.branch.store_id')=store_id)", params:[text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy())], query:false, limits:limits()}, + {sql:"INSERT INTO managed_effect_guard(id) SELECT 2 WHERE (SELECT CASE WHEN json_type(lineage_json)='array' THEN json_array_length(lineage_json) ELSE 1 END FROM managed_branches WHERE workspace_id=json_extract(?,'$.source.workspace_id') AND branch_id=json_extract(?,'$.source.branch_id'))>=64", params:[text(input.maintenance_json.copy()),text(input.maintenance_json.copy())], query:false, limits:limits()}, + {sql:"INSERT INTO managed_effect_guard(id) SELECT 2 WHERE EXISTS (SELECT 1 FROM managed_branches WHERE workspace_id=json_extract(?,'$.source.workspace_id') AND branch_id=json_extract(?,'$.source.branch_id') AND process_lineage_tainted=1)", params:[text(input.maintenance_json.copy()),text(input.maintenance_json.copy())], query:false, limits:limits()}, + {sql:"INSERT INTO managed_effect_guard(id) SELECT 2 WHERE EXISTS (SELECT 1 FROM managed_branches WHERE workspace_id=json_extract(?,'$.branch.workspace_id') AND branch_id=json_extract(?,'$.branch.branch_id'))", params:[text(input.maintenance_json.copy()),text(input.maintenance_json.copy())], query:false, limits:limits()}, + {sql:"INSERT INTO managed_effect_guard(id) SELECT 2 WHERE EXISTS (SELECT 1 FROM managed_branch_receipts WHERE workspace_id=json_extract(?,'$.source.workspace_id') AND request_id=json_extract(?,'$.request_id') AND original_request_proof<>json_extract(?,'$.proof'))", params:[text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy())], query:false, limits:limits()}, + {sql:"INSERT INTO managed_branches(workspace_id,branch_id,epoch,head_object_id,head_version,store_id,schema_version,status,lineage_json,process_lineage_tainted) SELECT json_extract(?,'$.branch.workspace_id'),json_extract(?,'$.branch.branch_id'),json_extract(?,'$.branch.epoch'),json_extract(?,'$.branch.head_object_id'),1,json_extract(?,'$.branch.store_id'),1,'active',CASE WHEN json_type(lineage_json)='array' THEN json_insert(lineage_json,'$[#]',json_extract(?,'$.branch.branch_id')) ELSE json_array(branch_id,json_extract(?,'$.branch.branch_id')) END,process_lineage_tainted FROM managed_branches WHERE workspace_id=json_extract(?,'$.source.workspace_id') AND branch_id=json_extract(?,'$.source.branch_id') AND NOT EXISTS (SELECT 1 FROM managed_branch_receipts WHERE workspace_id=json_extract(?,'$.source.workspace_id') AND request_id=json_extract(?,'$.request_id'))", params:[text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy())], query:false, limits:limits()}, + {sql:"INSERT INTO managed_head_history(workspace_id,branch_id,head_version,epoch,effect_id,previous_head,head_object_id,schema_version) SELECT workspace_id,branch_id,1,epoch,NULL,NULL,head_object_id,1 FROM managed_branches WHERE workspace_id=json_extract(?,'$.branch.workspace_id') AND branch_id=json_extract(?,'$.branch.branch_id') AND NOT EXISTS (SELECT 1 FROM managed_head_history WHERE workspace_id=json_extract(?,'$.branch.workspace_id') AND branch_id=json_extract(?,'$.branch.branch_id') AND head_version=1)", params:[text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy())], query:false, limits:limits()}, + {sql:"INSERT INTO managed_branch_receipts(workspace_id,request_id,checkpoint_id,source_branch_id,original_request_proof,receipt_json,origin_json) SELECT json_extract(?,'$.source.workspace_id'),json_extract(?,'$.request_id'),COALESCE(json_extract(?,'$.checkpoint_id'),''),json_extract(?,'$.source.branch_id'),json_extract(?,'$.proof'),json_extract(?,'$.receipt'),json_extract(?,'$.origin') WHERE EXISTS (SELECT 1 FROM managed_branches WHERE workspace_id=json_extract(?,'$.branch.workspace_id') AND branch_id=json_extract(?,'$.branch.branch_id')) AND NOT EXISTS (SELECT 1 FROM managed_branch_receipts WHERE workspace_id=json_extract(?,'$.source.workspace_id') AND request_id=json_extract(?,'$.request_id'))", params:[text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy())], query:false, limits:limits()}, + {sql:"SELECT receipt_json AS record_json FROM managed_branch_receipts WHERE workspace_id=json_extract(?,'$.source.workspace_id') AND request_id=json_extract(?,'$.request_id') AND original_request_proof=json_extract(?,'$.proof')", params:[text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy())], query:true, limits:limits()} + ]; + let result: array = sqlite::transaction(&db, statements); + let last: SqliteTransactionResult = result[result.length - 1].copy(); + let query: SqliteQueryResult = last.query; + ok(query) +} + +pub fn publish_eligibility(db: resource, payload: string) -> map { + let input: ManagedMaintenanceInput = json::decode::(payload); + ok(sqlite::query(&db, "SELECT CASE WHEN EXISTS (SELECT 1 FROM managed_branches WHERE branch_id=json_extract(?,'$.branch_id') AND status='active' AND workspace_id<>json_extract(?,'$.workspace_id')) THEN json_object('code','foreign') WHEN NOT EXISTS (SELECT 1 FROM managed_branches WHERE workspace_id=json_extract(?,'$.workspace_id') AND branch_id=json_extract(?,'$.branch_id')) THEN json_object('code','missing') WHEN EXISTS (SELECT 1 FROM managed_branches WHERE workspace_id=json_extract(?,'$.workspace_id') AND branch_id=json_extract(?,'$.branch_id') AND status<>'active') THEN json_object('code','missing') WHEN NOT EXISTS (SELECT 1 FROM managed_branches b JOIN managed_workspaces w ON w.workspace_id=b.workspace_id AND w.profile=json_extract(?,'$.profile') WHERE b.workspace_id=json_extract(?,'$.workspace_id') AND b.branch_id=json_extract(?,'$.branch_id') AND b.status='active' AND b.store_id=json_extract(?,'$.expected_head.store_id') AND b.epoch=json_extract(?,'$.expected_head.epoch') AND b.head_object_id=json_extract(?,'$.expected_head.head_object_id') AND b.head_version=json_extract(?,'$.expected_head.head_version') AND b.schema_version=json_extract(?,'$.expected_head.schema_version') AND b.process_lineage_tainted=(CASE WHEN json_extract(?,'$.expected_head.process_lineage_tainted') THEN 1 ELSE 0 END) AND b.workspace_id=json_extract(?,'$.expected_head.workspace_id') AND b.branch_id=json_extract(?,'$.expected_head.branch_id')) THEN json_object('code','stale') WHEN EXISTS (SELECT 1 FROM managed_effect_calls WHERE workspace_id=json_extract(?,'$.workspace_id') AND branch_id=json_extract(?,'$.branch_id') AND state IN ('intent','prepared','indeterminate')) OR EXISTS (SELECT 1 FROM managed_run_bindings b JOIN runs r ON r.id=b.run_id WHERE b.workspace_id=json_extract(?,'$.workspace_id') AND b.branch_id=json_extract(?,'$.branch_id') AND r.status NOT IN ('completed','failed','cancelled')) THEN json_object('code','busy') WHEN EXISTS (SELECT 1 FROM managed_publish_receipts WHERE workspace_id=json_extract(?,'$.workspace_id') AND request_id=json_extract(?,'$.request_id') AND original_request_proof<>json_extract(?,'$.proof')) OR EXISTS (SELECT 1 FROM managed_branch_receipts WHERE workspace_id=json_extract(?,'$.workspace_id') AND request_id=json_extract(?,'$.request_id')) OR EXISTS (SELECT 1 FROM managed_checkpoints WHERE workspace_id=json_extract(?,'$.workspace_id') AND request_id=json_extract(?,'$.request_id')) OR EXISTS (SELECT 1 FROM managed_restore_receipts WHERE workspace_id=json_extract(?,'$.workspace_id') AND request_id=json_extract(?,'$.request_id')) THEN json_object('code','conflict') ELSE json_object('code','idle','revision',COALESCE((SELECT revision FROM managed_workspace_publications WHERE workspace_id=json_extract(?,'$.workspace_id')),0)) END AS record_json", [text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy())], limits())) +} + +pub fn publish_receipt(db: resource, payload: string) -> map { + let input: ManagedMaintenanceInput = json::decode::(payload); + ok(sqlite::query(&db, "SELECT json_object('proof',original_request_proof,'receipt',json(receipt_json)) AS record_json FROM managed_publish_receipts WHERE workspace_id=json_extract(?,'$.workspace_id') AND request_id=json_extract(?,'$.request_id')", [text(input.maintenance_json.copy()),text(input.maintenance_json.copy())], limits())) +} + +pub fn publication_get(db: resource, payload: string) -> map { + let input: ManagedMaintenanceInput = json::decode::(payload); + ok(sqlite::query(&db, "SELECT record_json FROM managed_workspace_publications WHERE workspace_id=json_extract(?,'$.workspace_id') AND profile=json_extract(?,'$.profile')", [text(input.maintenance_json.copy()),text(input.maintenance_json.copy())], limits())) +} + +pub fn publish_submit(db: resource, payload: string) -> map { + let input: ManagedMaintenanceInput = json::decode::(payload); + let mut statements: array = [ + {sql:"INSERT INTO managed_effect_guard(id) SELECT 2 WHERE EXISTS (SELECT 1 FROM managed_publish_receipts WHERE workspace_id=json_extract(?,'$.workspace_id') AND request_id=json_extract(?,'$.request_id') AND original_request_proof<>json_extract(?,'$.proof')) OR EXISTS (SELECT 1 FROM managed_branch_receipts WHERE workspace_id=json_extract(?,'$.workspace_id') AND request_id=json_extract(?,'$.request_id')) OR EXISTS (SELECT 1 FROM managed_checkpoints WHERE workspace_id=json_extract(?,'$.workspace_id') AND request_id=json_extract(?,'$.request_id')) OR EXISTS (SELECT 1 FROM managed_restore_receipts WHERE workspace_id=json_extract(?,'$.workspace_id') AND request_id=json_extract(?,'$.request_id'))", params:[text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy())], query:false, limits:limits()}, + {sql:"INSERT INTO managed_effect_guard(id) SELECT 2 WHERE NOT EXISTS (SELECT 1 FROM managed_publish_receipts WHERE workspace_id=json_extract(?,'$.workspace_id') AND request_id=json_extract(?,'$.request_id') AND original_request_proof=json_extract(?,'$.proof')) AND EXISTS (SELECT 1 FROM managed_branches WHERE workspace_id=json_extract(?,'$.workspace_id') AND branch_id=json_extract(?,'$.branch_id') AND process_lineage_tainted=1)", params:[text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy())], query:false, limits:limits()}, + {sql:"INSERT INTO managed_effect_guard(id) SELECT 2 WHERE NOT EXISTS (SELECT 1 FROM managed_publish_receipts WHERE workspace_id=json_extract(?,'$.workspace_id') AND request_id=json_extract(?,'$.request_id') AND original_request_proof=json_extract(?,'$.proof')) AND NOT EXISTS (SELECT 1 FROM managed_branches b JOIN managed_workspaces w ON w.workspace_id=b.workspace_id AND w.profile=json_extract(?,'$.profile') WHERE b.workspace_id=json_extract(?,'$.workspace_id') AND b.branch_id=json_extract(?,'$.branch_id') AND b.status='active' AND b.store_id=json_extract(?,'$.expected_head.store_id') AND b.epoch=json_extract(?,'$.expected_head.epoch') AND b.head_object_id=json_extract(?,'$.expected_head.head_object_id') AND b.head_version=json_extract(?,'$.expected_head.head_version') AND b.schema_version=json_extract(?,'$.expected_head.schema_version') AND b.process_lineage_tainted=(CASE WHEN json_extract(?,'$.expected_head.process_lineage_tainted') THEN 1 ELSE 0 END) AND b.workspace_id=json_extract(?,'$.expected_head.workspace_id') AND b.branch_id=json_extract(?,'$.expected_head.branch_id'))", params:[text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy())], query:false, limits:limits()}, + {sql:"INSERT INTO managed_effect_guard(id) SELECT 2 WHERE NOT EXISTS (SELECT 1 FROM managed_publish_receipts WHERE workspace_id=json_extract(?,'$.workspace_id') AND request_id=json_extract(?,'$.request_id') AND original_request_proof=json_extract(?,'$.proof')) AND (EXISTS (SELECT 1 FROM managed_effect_calls WHERE workspace_id=json_extract(?,'$.workspace_id') AND branch_id=json_extract(?,'$.branch_id') AND state IN ('intent','prepared','indeterminate')) OR EXISTS (SELECT 1 FROM managed_run_bindings b JOIN runs r ON r.id=b.run_id WHERE b.workspace_id=json_extract(?,'$.workspace_id') AND b.branch_id=json_extract(?,'$.branch_id') AND r.status NOT IN ('completed','failed','cancelled')))", params:[text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy())], query:false, limits:limits()}, + {sql:"INSERT INTO managed_effect_guard(id) SELECT 2 WHERE NOT EXISTS (SELECT 1 FROM managed_publish_receipts WHERE workspace_id=json_extract(?,'$.workspace_id') AND request_id=json_extract(?,'$.request_id') AND original_request_proof=json_extract(?,'$.proof')) AND ((json_extract(?,'$.expected_revision')=0 AND EXISTS (SELECT 1 FROM managed_workspace_publications WHERE workspace_id=json_extract(?,'$.workspace_id'))) OR (json_extract(?,'$.expected_revision')<>0 AND NOT EXISTS (SELECT 1 FROM managed_workspace_publications WHERE workspace_id=json_extract(?,'$.workspace_id') AND revision=json_extract(?,'$.expected_revision'))))", params:[text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy())], query:false, limits:limits()}, + {sql:"INSERT INTO managed_workspace_publications(workspace_id,profile,branch_id,epoch,head_object_id,head_version,previous_head_object_id,request_id,published_at_ms,revision,store_id,schema_version,record_json) SELECT b.workspace_id,w.profile,b.branch_id,b.epoch,b.head_object_id,b.head_version,NULL,json_extract(?,'$.request_id'),json_extract(?,'$.now_ms'),1,b.store_id,b.schema_version,'{}' FROM managed_branches b JOIN managed_workspaces w ON w.workspace_id=b.workspace_id WHERE json_extract(?,'$.expected_revision')=0 AND b.workspace_id=json_extract(?,'$.workspace_id') AND b.branch_id=json_extract(?,'$.branch_id') AND NOT EXISTS (SELECT 1 FROM managed_workspace_publications WHERE workspace_id=b.workspace_id) AND NOT EXISTS (SELECT 1 FROM managed_publish_receipts WHERE workspace_id=json_extract(?,'$.workspace_id') AND request_id=json_extract(?,'$.request_id') AND original_request_proof=json_extract(?,'$.proof'))", params:[text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy())], query:false, limits:limits()}, + {sql:"UPDATE managed_workspace_publications SET profile=(SELECT profile FROM managed_workspaces WHERE workspace_id=managed_workspace_publications.workspace_id), branch_id=json_extract(?,'$.branch_id'), epoch=(SELECT epoch FROM managed_branches WHERE workspace_id=json_extract(?,'$.workspace_id') AND branch_id=json_extract(?,'$.branch_id')), head_object_id=(SELECT head_object_id FROM managed_branches WHERE workspace_id=json_extract(?,'$.workspace_id') AND branch_id=json_extract(?,'$.branch_id')), head_version=(SELECT head_version FROM managed_branches WHERE workspace_id=json_extract(?,'$.workspace_id') AND branch_id=json_extract(?,'$.branch_id')), previous_head_object_id=head_object_id, request_id=json_extract(?,'$.request_id'), published_at_ms=json_extract(?,'$.now_ms'), revision=revision+1, store_id=(SELECT store_id FROM managed_branches WHERE workspace_id=json_extract(?,'$.workspace_id') AND branch_id=json_extract(?,'$.branch_id')), schema_version=(SELECT schema_version FROM managed_branches WHERE workspace_id=json_extract(?,'$.workspace_id') AND branch_id=json_extract(?,'$.branch_id')) WHERE workspace_id=json_extract(?,'$.workspace_id') AND revision=json_extract(?,'$.expected_revision') AND json_extract(?,'$.expected_revision')<>0 AND NOT EXISTS (SELECT 1 FROM managed_publish_receipts WHERE workspace_id=json_extract(?,'$.workspace_id') AND request_id=json_extract(?,'$.request_id') AND original_request_proof=json_extract(?,'$.proof'))", params:[text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy())], query:false, limits:limits()}, + {sql:"UPDATE managed_workspace_publications SET record_json=json_object('workspace_id',workspace_id,'profile',profile,'branch_id',branch_id,'epoch',epoch,'head_object_id',head_object_id,'head_version',head_version,'previous_published_head',CASE WHEN previous_head_object_id IS NULL THEN json('null') ELSE json_quote(previous_head_object_id) END,'request_id',request_id,'published_at_ms',published_at_ms,'revision',revision,'store_id',store_id,'schema_version',schema_version) WHERE workspace_id=json_extract(?,'$.workspace_id') AND request_id=json_extract(?,'$.request_id') AND NOT EXISTS (SELECT 1 FROM managed_publish_receipts WHERE workspace_id=json_extract(?,'$.workspace_id') AND request_id=json_extract(?,'$.request_id') AND original_request_proof=json_extract(?,'$.proof'))", params:[text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy())], query:false, limits:limits()}, + {sql:"INSERT INTO managed_publish_receipts(workspace_id,request_id,branch_id,original_request_proof,receipt_json,action) SELECT workspace_id,request_id,branch_id,json_extract(?,'$.proof'),json_object('status','committed','workspace_id',workspace_id,'profile',profile,'branch_id',branch_id,'epoch',epoch,'head_object_id',head_object_id,'head_version',head_version,'previous_published_head',CASE WHEN previous_head_object_id IS NULL THEN json('null') ELSE json_quote(previous_head_object_id) END,'request_id',request_id,'published_at_ms',published_at_ms),'publish' FROM managed_workspace_publications WHERE workspace_id=json_extract(?,'$.workspace_id') AND request_id=json_extract(?,'$.request_id') AND NOT EXISTS (SELECT 1 FROM managed_publish_receipts WHERE workspace_id=json_extract(?,'$.workspace_id') AND request_id=json_extract(?,'$.request_id'))", params:[text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy())], query:false, limits:limits()}, + {sql:"INSERT INTO managed_effect_guard(id) SELECT 2 WHERE NOT EXISTS (SELECT 1 FROM managed_publish_receipts WHERE workspace_id=json_extract(?,'$.workspace_id') AND request_id=json_extract(?,'$.request_id') AND original_request_proof=json_extract(?,'$.proof'))", params:[text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy())], query:false, limits:limits()}, + {sql:"SELECT receipt_json AS record_json FROM managed_publish_receipts WHERE workspace_id=json_extract(?,'$.workspace_id') AND request_id=json_extract(?,'$.request_id') AND original_request_proof=json_extract(?,'$.proof')", params:[text(input.maintenance_json.copy()),text(input.maintenance_json.copy()),text(input.maintenance_json.copy())], query:true, limits:limits()} + ]; + let result: array = sqlite::transaction(&db, statements); + let last: SqliteTransactionResult = result[result.length - 1].copy(); + let query: SqliteQueryResult = last.query; + ok(query) +} diff --git a/rss/storage/events.rss b/rss/storage/events.rss index 0097276..6fd996f 100644 --- a/rss/storage/events.rss +++ b/rss/storage/events.rss @@ -1,5 +1,6 @@ use json; use sqlite; +use plugins; fn sql_text(value: string) -> SqliteValue { { kind: "text", int_value: null, float_value: null, text_value: value, blob_value: null } @@ -143,43 +144,55 @@ pub fn storage_event_append_pair(db_id: resource, payload_jso let max_events: int = schema::max_events_limit(input.max_events.copy()); let statements: array = [ { - sql: "INSERT INTO run_events (run_id, seq, event_id, event_type, payload_json, created_at_ms) SELECT ?, CASE WHEN ? > 0 THEN ? ELSE COALESCE((SELECT MAX(events.seq) FROM run_events events WHERE events.run_id = ?), 0) + 1 END, ?, ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM run_events existing WHERE existing.event_id = ?)", - params: [sql_text(input.run_id.copy()), sql_int(reserved_seq_a.copy()), sql_int(reserved_seq_a.copy()), sql_text(input.run_id.copy()), sql_text(input.event_id_a.copy()), sql_text(input.event_type_a.copy()), sql_text(input.payload_json_a.copy()), sql_int(input.now_ms.copy()), sql_text(input.event_id_a.copy())], + sql: "INSERT INTO run_events (run_id, seq, event_id, event_type, payload_json, created_at_ms) SELECT ?, CASE WHEN ? > 0 THEN ? ELSE COALESCE((SELECT MAX(events.seq) FROM run_events events WHERE events.run_id = ?), 0) + 1 END, ?, ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM run_events existing WHERE existing.event_id = ?) AND NOT EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q WHERE q.run_id = ? AND (EXISTS (SELECT 1 FROM plugin_graph_revocations r WHERE r.graph_id = q.graph_id) OR NOT EXISTS (SELECT 1 FROM plugin_graph_refs g WHERE g.owner_kind = 'run' AND g.owner_id = q.run_id AND g.graph_id = q.graph_id AND g.ref_id = q.ref_id)))", + params: [sql_text(input.run_id.copy()), sql_int(reserved_seq_a.copy()), sql_int(reserved_seq_a.copy()), sql_text(input.run_id.copy()), sql_text(input.event_id_a.copy()), sql_text(input.event_type_a.copy()), sql_text(input.payload_json_a.copy()), sql_int(input.now_ms.copy()), sql_text(input.event_id_a.copy()), sql_text(input.run_id.copy())], query: false, limits: sql_limits(1000, 4194304) }, { - sql: "INSERT INTO run_events (run_id, seq, event_id, event_type, payload_json, created_at_ms) SELECT ?, CASE WHEN ? > 0 THEN ? ELSE COALESCE((SELECT MAX(events.seq) FROM run_events events WHERE events.run_id = ?), 0) + 1 END, ?, ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM run_events existing WHERE existing.event_id = ?)", - params: [sql_text(input.run_id.copy()), sql_int(reserved_seq_b.copy()), sql_int(reserved_seq_b.copy()), sql_text(input.run_id.copy()), sql_text(input.event_id_b.copy()), sql_text(input.event_type_b.copy()), sql_text(input.payload_json_b.copy()), sql_int(input.now_ms.copy()), sql_text(input.event_id_b.copy())], + sql: "INSERT INTO run_events (run_id, seq, event_id, event_type, payload_json, created_at_ms) SELECT ?, CASE WHEN ? > 0 THEN ? ELSE COALESCE((SELECT MAX(events.seq) FROM run_events events WHERE events.run_id = ?), 0) + 1 END, ?, ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM run_events existing WHERE existing.event_id = ?) AND NOT EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q WHERE q.run_id = ? AND (EXISTS (SELECT 1 FROM plugin_graph_revocations r WHERE r.graph_id = q.graph_id) OR NOT EXISTS (SELECT 1 FROM plugin_graph_refs g WHERE g.owner_kind = 'run' AND g.owner_id = q.run_id AND g.graph_id = q.graph_id AND g.ref_id = q.ref_id)))", + params: [sql_text(input.run_id.copy()), sql_int(reserved_seq_b.copy()), sql_int(reserved_seq_b.copy()), sql_text(input.run_id.copy()), sql_text(input.event_id_b.copy()), sql_text(input.event_type_b.copy()), sql_text(input.payload_json_b.copy()), sql_int(input.now_ms.copy()), sql_text(input.event_id_b.copy()), sql_text(input.run_id.copy())], query: false, limits: sql_limits(1000, 4194304) }, { - sql: "DELETE FROM run_events WHERE rowid IN (SELECT rowid FROM run_events WHERE run_id = ? ORDER BY seq ASC LIMIT CASE WHEN (SELECT COUNT(*) FROM run_events WHERE run_id = ?) > ? THEN (SELECT COUNT(*) FROM run_events WHERE run_id = ?) - ? ELSE 0 END)", - params: [sql_text(input.run_id.copy()), sql_text(input.run_id.copy()), sql_int(max_events.copy()), sql_text(input.run_id.copy()), sql_int(max_events.copy())], + sql: "DELETE FROM run_events WHERE NOT EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q WHERE q.run_id = ? AND (EXISTS (SELECT 1 FROM plugin_graph_revocations r WHERE r.graph_id = q.graph_id) OR NOT EXISTS (SELECT 1 FROM plugin_graph_refs g WHERE g.owner_kind = 'run' AND g.owner_id = q.run_id AND g.graph_id = q.graph_id AND g.ref_id = q.ref_id))) AND rowid IN (SELECT rowid FROM run_events WHERE run_id = ? ORDER BY seq ASC LIMIT CASE WHEN (SELECT COUNT(*) FROM run_events WHERE run_id = ?) > ? THEN (SELECT COUNT(*) FROM run_events WHERE run_id = ?) - ? ELSE 0 END)", + params: [sql_text(input.run_id.copy()), sql_text(input.run_id.copy()), sql_text(input.run_id.copy()), sql_int(max_events.copy()), sql_text(input.run_id.copy()), sql_int(max_events.copy())], query: false, limits: sql_limits(1000, 4194304) }, { - sql: "INSERT INTO run_retention (run_id, first_seq, high_water_seq, updated_at_ms) SELECT ?, COALESCE((SELECT MIN(seq) FROM run_events WHERE run_id = ?), 0), COALESCE((SELECT MAX(seq) FROM run_events WHERE run_id = ?), 0), ? ON CONFLICT (run_id) DO UPDATE SET first_seq = excluded.first_seq, high_water_seq = MAX(run_retention.high_water_seq, excluded.high_water_seq), updated_at_ms = excluded.updated_at_ms", - params: [sql_text(input.run_id.copy()), sql_text(input.run_id.copy()), sql_text(input.run_id.copy()), sql_int(input.now_ms.copy())], + sql: "INSERT INTO run_retention (run_id, first_seq, high_water_seq, updated_at_ms) SELECT ?, COALESCE((SELECT MIN(seq) FROM run_events WHERE run_id = ?), 0), COALESCE((SELECT MAX(seq) FROM run_events WHERE run_id = ?), 0), ? WHERE NOT EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q WHERE q.run_id = ? AND (EXISTS (SELECT 1 FROM plugin_graph_revocations r WHERE r.graph_id = q.graph_id) OR NOT EXISTS (SELECT 1 FROM plugin_graph_refs g WHERE g.owner_kind = 'run' AND g.owner_id = q.run_id AND g.graph_id = q.graph_id AND g.ref_id = q.ref_id))) ON CONFLICT (run_id) DO UPDATE SET first_seq = excluded.first_seq, high_water_seq = MAX(run_retention.high_water_seq, excluded.high_water_seq), updated_at_ms = excluded.updated_at_ms", + params: [sql_text(input.run_id.copy()), sql_text(input.run_id.copy()), sql_text(input.run_id.copy()), sql_int(input.now_ms.copy()), sql_text(input.run_id.copy())], query: false, limits: sql_limits(1000, 4194304) }, { - sql: "UPDATE runs SET updated_at_ms = ? WHERE id = ?", - params: [sql_int(input.now_ms.copy()), sql_text(input.run_id.copy())], + sql: "UPDATE runs SET updated_at_ms = ? WHERE id = ? AND NOT EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q WHERE q.run_id = ? AND (EXISTS (SELECT 1 FROM plugin_graph_revocations r WHERE r.graph_id = q.graph_id) OR NOT EXISTS (SELECT 1 FROM plugin_graph_refs g WHERE g.owner_kind = 'run' AND g.owner_id = q.run_id AND g.graph_id = q.graph_id AND g.ref_id = q.ref_id)))", + params: [sql_int(input.now_ms.copy()), sql_text(input.run_id.copy()), sql_text(input.run_id.copy())], query: false, limits: sql_limits(1000, 4194304) }, { - sql: "UPDATE runs SET status = 'after_partial_write' WHERE ? = 'after_partial_write' AND id = ?", - params: [sql_text(failpoint), sql_text(input.run_id.copy())], + sql: "UPDATE runs SET status = 'after_partial_write' WHERE ? = 'after_partial_write' AND id = ? AND NOT EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q WHERE q.run_id = ? AND (EXISTS (SELECT 1 FROM plugin_graph_revocations r WHERE r.graph_id = q.graph_id) OR NOT EXISTS (SELECT 1 FROM plugin_graph_refs g WHERE g.owner_kind = 'run' AND g.owner_id = q.run_id AND g.graph_id = q.graph_id AND g.ref_id = q.ref_id)))", + params: [sql_text(failpoint), sql_text(input.run_id.copy()), sql_text(input.run_id.copy())], query: false, limits: sql_limits(1000, 4194304) - } + }, + { + sql: "SELECT CASE WHEN EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q JOIN plugin_graph_revocations r ON r.graph_id=q.graph_id WHERE q.run_id=?) THEN 'plugin_graph_revoked' WHEN EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q WHERE q.run_id = ? AND (EXISTS (SELECT 1 FROM plugin_graph_revocations r WHERE r.graph_id = q.graph_id) OR NOT EXISTS (SELECT 1 FROM plugin_graph_refs g WHERE g.owner_kind = 'run' AND g.owner_id = q.run_id AND g.graph_id = q.graph_id AND g.ref_id = q.ref_id))) THEN 'plugin_graph_pin_required' ELSE 'ok' END", + params: [sql_text(input.run_id.copy()), sql_text(input.run_id.copy())], + query: true, + limits: sql_limits(1, 4096) + } ]; - result = { ok: true, code: "ok", message: "", result: sqlite::transaction(&db_id, statements) }; + let transaction: array = sqlite::transaction(&db_id, statements); + let code: string = plugins::storage_pin_text(transaction[transaction.length - 1].copy(), 0); + if code.copy() == "ok" { + result = { ok: true, code: "ok", message: "", result: transaction }; + } else { + result = { ok: false, code: code, message: "tool graph pin is required", result: [] }; + } if failpoint == "after_commit_before_publish" { result = { ok: false, code: "failpoint_after_commit_before_publish", message: "durable commit succeeded; live publish skipped", result: [] }; } diff --git a/rss/storage/main.rss b/rss/storage/main.rss index 5b18585..464bd75 100644 --- a/rss/storage/main.rss +++ b/rss/storage/main.rss @@ -521,7 +521,9 @@ fn storage_dispatch(db_id: resource, command: StorageCommand) result=storage_error(request_id,op,"managed_effect_retained","session has retained managed effect bindings"); }else{ let delete_results: array = sessions::storage_session_delete(db_id, command.payload_json); - if storage_nth_rows_affected(delete_results, 3) == 0 { + // Index 4 is DELETE FROM sessions. Index 3 is the preceding + // idempotency delete and is 0 for a session that has no runs. + if storage_nth_rows_affected(delete_results, 4) == 0 { result = storage_error(request_id, op, "session_not_found", "session does not exist"); } else { result = storage_array_ok(request_id, op, delete_results); @@ -665,10 +667,42 @@ fn storage_dispatch(db_id: resource, command: StorageCommand) result=storage_unwrap(request_id,op,effects::object_metadata(db_id,command.payload_json)); } else if command.op == "effect.object_refs" { result=storage_unwrap(request_id,op,effects::object_refs(db_id,command.payload_json)); + } else if command.op == "effect.run_binding" { + result=storage_unwrap(request_id,op,effects::run_binding(db_id,command.payload_json)); + } else if command.op == "effect.run_list_has_more" { + result=storage_unwrap(request_id,op,effects::run_list_has_more(db_id,command.payload_json)); + } else if command.op == "effect.run_cursor" { + result=storage_unwrap(request_id,op,effects::run_cursor(db_id,command.payload_json)); + } else if command.op == "effect.run_list" { + result=storage_unwrap(request_id,op,effects::run_list(db_id,command.payload_json)); } else if command.op == "effect.list" { result=storage_unwrap(request_id,op,effects::list(db_id,command.payload_json)); } else if command.op == "effect.list_has_more" { result=storage_unwrap(request_id,op,effects::list_has_more(db_id,command.payload_json)); + } else if command.op == "effect.checkpoint_context" { + result = storage_unwrap(request_id, op, effects::checkpoint_context(db_id, command.payload_json)); + } else if command.op == "effect.maintenance_receipt" { + result = storage_unwrap(request_id, op, effects::maintenance_receipt(db_id, command.payload_json)); + } else if command.op == "effect.branch_publication_history" { + result = storage_unwrap(request_id, op, effects::branch_publication_history(db_id, command.payload_json)); + } else if command.op == "effect.checkpoint_get" { + result = storage_unwrap(request_id, op, effects::checkpoint_get(db_id, command.payload_json)); + } else if command.op == "effect.checkpoint_create" { + result = storage_unwrap(request_id, op, effects::checkpoint_create(db_id, command.payload_json)); + } else if command.op == "effect.branch_create" { + result = storage_unwrap(request_id, op, effects::branch_create(db_id, command.payload_json)); + } else if command.op == "effect.branch_receipt" { + result = storage_unwrap(request_id, op, effects::branch_receipt(db_id, command.payload_json)); + } else if command.op == "effect.branch_origin" { + result = storage_unwrap(request_id, op, effects::branch_origin(db_id, command.payload_json)); + } else if command.op == "effect.publish_eligibility" { + result = storage_unwrap(request_id, op, effects::publish_eligibility(db_id, command.payload_json)); + } else if command.op == "effect.publish_submit" { + result = storage_unwrap(request_id, op, effects::publish_submit(db_id, command.payload_json)); + } else if command.op == "effect.publish_receipt" { + result = storage_unwrap(request_id, op, effects::publish_receipt(db_id, command.payload_json)); + } else if command.op == "effect.publication_get" { + result = storage_unwrap(request_id, op, effects::publication_get(db_id, command.payload_json)); } else if command.op == "effect.begin" { result = storage_unwrap(request_id, op, effects::begin(db_id, command.payload_json)); } else if command.op == "effect.prepare" { @@ -701,8 +735,18 @@ fn storage_dispatch(db_id: resource, command: StorageCommand) result = storage_unwrap(request_id, op, plugins::storage_plugin_retention_get(db_id, command.payload_json)); } else if command.op == "plugin.release" { result = storage_unwrap(request_id, op, plugins::storage_plugin_release(db_id, command.payload_json)); + } else if command.op == "plugin.fixture_pin_state" { + result = storage_unwrap(request_id, op, plugins::storage_fixture_pin_state(db_id, command.payload_json)); + } else if command.op == "plugin.pin_requirement_get" { + result = storage_unwrap(request_id, op, plugins::storage_plugin_pin_requirement_get(db_id, command.payload_json)); + } else if command.op == "plugin.harness_binding_matches" { + result = storage_unwrap(request_id, op, plugins::storage_harness_binding_matches(db_id, command.payload_json)); } else if command.op == "plugin.pin_for_owner" { result = storage_unwrap(request_id, op, plugins::storage_plugin_pin_for_owner(db_id, command.payload_json)); + } else if command.op == "plugin.graph_revoke" { + result = storage_unwrap(request_id, op, plugins::storage_plugin_graph_revoke(db_id, command.payload_json)); + } else if command.op == "plugin.graph_revocation_get" { + result = storage_unwrap(request_id, op, plugins::storage_plugin_graph_revocation_get(db_id, command.payload_json)); } else if command.op == "plugin.collect" { result = storage_unwrap(request_id, op, plugins::storage_plugin_collect(db_id, command.payload_json)); } diff --git a/rss/storage/plugins.rss b/rss/storage/plugins.rss index 1dee398..78acb4f 100644 --- a/rss/storage/plugins.rss +++ b/rss/storage/plugins.rss @@ -340,11 +340,80 @@ pub fn storage_plugin_release(db_id: resource, payload_json: result } +// Only the cfg(test) Native seam can enqueue this closed mutation command. +// No caller SQL, additional connection, or guest storage forwarding is accepted. +struct PluginPinFixtureInput { owner_id: string, mutation: string } +pub fn storage_fixture_pin_state(db_id: resource, payload_json: string) -> map { + let input: PluginPinFixtureInput = json::decode::(payload_json); + let statements: array = [ + {sql:"DELETE FROM plugin_graph_pin_requirements WHERE run_id=? AND ?='drop_requirement'",params:[sql_text(input.owner_id.copy()),sql_text(input.mutation.copy())],query:false,limits:plugin_limits()}, + {sql:"DELETE FROM managed_run_bindings WHERE run_id=? AND ?='drop_binding'",params:[sql_text(input.owner_id.copy()),sql_text(input.mutation.copy())],query:false,limits:plugin_limits()}, + {sql:"UPDATE plugin_graph_refs SET ref_id='fixture-replacement-ref' WHERE owner_kind='run' AND owner_id=? AND ?='replace_ref'",params:[sql_text(input.owner_id.copy()),sql_text(input.mutation.copy())],query:false,limits:plugin_limits()}, + {sql:"UPDATE managed_restore_receipts SET envelope_json=json_set(envelope_json,'$.result.graph.harness_selection',json_object('mode','tool_only','tool_package',json_extract(envelope_json,'$.result.graph.harness_selection.tool_package'))),result_json=json_set(result_json,'$.graph.harness_selection',json_object('mode','tool_only','tool_package',json_extract(result_json,'$.graph.harness_selection.tool_package'))) WHERE request_id=? AND ?='forge_restore_selection'",params:[sql_text(input.owner_id.copy()),sql_text(input.mutation.copy())],query:false,limits:plugin_limits()}, + {sql:"DELETE FROM plugin_package_refs WHERE kind='library' AND graph_id IN (SELECT graph_id FROM plugin_graph_pin_requirements WHERE run_id=?) AND ?='drop_package_ref'",params:[sql_text(input.owner_id.copy()),sql_text(input.mutation.copy())],query:false,limits:plugin_limits()}, + {sql:"UPDATE idempotency_records SET response_json=json_set(response_json,'$.harness_selection',json_object('mode','tool_only','tool_package',json_extract(response_json,'$.harness_selection.tool_package'))) WHERE resource_id=? AND ?='forge_run_selection'",params:[sql_text(input.owner_id.copy()),sql_text(input.mutation.copy())],query:false,limits:plugin_limits()} + ]; + let result = sqlite::transaction(&db_id, statements); + let _used = result; + ok_value({}) +} + +pub fn storage_plugin_pin_requirement_get(db_id: resource, payload_json: string) -> map { + let input: PluginOwnerInput = json::decode::(payload_json); + ok_query(sqlite::query(&db_id, "SELECT run_id, graph_id, ref_id, required_mode FROM plugin_graph_pin_requirements WHERE run_id = ? LIMIT 1", [sql_text(input.owner_id.copy())], plugin_limits())) +} + + +struct HarnessBindingQuery {owner_id:string,profile:string,session_id:string,workspace_id:string,branch_id:string,epoch:int,deadline_ms:int,graph_id:string,ref_id:string} +pub fn storage_harness_binding_matches(db_id:resource,payload_json:string)->map { + let input:HarnessBindingQuery=json::decode::(payload_json); + ok_query(sqlite::query(&db_id,"SELECT 1 FROM managed_run_bindings b JOIN plugin_graph_pin_requirements q ON q.run_id=b.run_id AND q.ref_id=b.graph_ref_id AND q.required_mode='required' JOIN plugin_graph_refs g ON g.graph_id=q.graph_id AND g.ref_id=q.ref_id AND g.owner_kind='run' AND g.owner_id=b.run_id WHERE b.run_id=? AND b.profile=? AND b.session_id=? AND b.workspace_id=? AND b.branch_id=? AND b.epoch=? AND b.original_deadline_ms=? AND q.graph_id=? AND q.ref_id=? LIMIT 1",[sql_text(input.owner_id.copy()),sql_text(input.profile.copy()),sql_text(input.session_id.copy()),sql_text(input.workspace_id.copy()),sql_text(input.branch_id.copy()),sql_int(input.epoch.copy()),sql_int(input.deadline_ms.copy()),sql_text(input.graph_id.copy()),sql_text(input.ref_id.copy())],plugin_limits())) +} + pub fn storage_plugin_pin_for_owner(db_id: resource, payload_json: string) -> map { let input: PluginOwnerInput = json::decode::(payload_json); ok_value(storage_pin_by_owner(db_id, input.owner_id.copy())) } +struct PluginGraphRevokeInput { + graph_id: string, + now_ms: int, + failpoint: string, +} + +pub fn storage_plugin_graph_revoke(db_id: resource, payload_json: string) -> map { + let input: PluginGraphRevokeInput = json::decode::(payload_json); + let statements: array = [ + { + sql: "INSERT INTO plugin_graph_revocations (graph_id, revoked_at_ms) SELECT ?, ? WHERE NOT EXISTS (SELECT 1 FROM plugin_graph_revocations WHERE graph_id = ?)", + params: [sql_text(input.graph_id.copy()), sql_int(input.now_ms.copy()), sql_text(input.graph_id.copy())], + query: false, + limits: plugin_limits(), + }, + { + sql: "INSERT INTO plugin_control_guard (id) SELECT 2 WHERE ? = 'after_partial_write'", + params: [sql_text(input.failpoint.copy())], + query: false, + limits: plugin_limits(), + }, + { + sql: "SELECT graph_id, revoked_at_ms FROM plugin_graph_revocations WHERE graph_id = ? LIMIT 1", + params: [sql_text(input.graph_id.copy())], + query: true, + limits: plugin_limits(), + } + ]; + let result: array = sqlite::transaction(&db_id, statements); + let last: SqliteTransactionResult = result[2].copy(); + let query: SqliteQueryResult = last.query; + ok_query(query) +} + +pub fn storage_plugin_graph_revocation_get(db_id: resource, payload_json: string) -> map { + let input: PluginGraphInput = json::decode::(payload_json); + ok_query(sqlite::query(&db_id, "SELECT graph_id, revoked_at_ms FROM plugin_graph_revocations WHERE graph_id = ? LIMIT 1", [sql_text(input.graph_id.copy())], plugin_limits())) +} + pub fn storage_plugin_collect(db_id: resource, payload_json: string) -> map { let input: PluginFailpointInput = json::decode::(payload_json); let before: SqliteQueryResult = sqlite::query(&db_id, "SELECT graph_id FROM plugin_graph_locks WHERE graph_id NOT IN (SELECT graph_id FROM plugin_activation) AND graph_id NOT IN (SELECT graph_id FROM plugin_graph_refs) ORDER BY graph_id", [], plugin_limits()); @@ -374,26 +443,36 @@ pub fn storage_plugin_collect(db_id: resource, payload_json: ok_value(listed) } +fn storage_pin_requirement_statement(graph_id: string, ref_id: string, run_id: string, session_id: string) -> SqliteStatement { + { + sql: "INSERT INTO plugin_graph_pin_requirements (run_id, graph_id, ref_id, required_mode) SELECT ?, ?, ?, 'required' WHERE (EXISTS (SELECT 1 FROM runs WHERE id = ? AND session_id = ?) AND NOT EXISTS (SELECT 1 FROM plugin_graph_pin_requirements existing WHERE existing.run_id = ? AND (existing.graph_id <> ? OR existing.ref_id <> ?))) AND (? = '' OR (NOT EXISTS (SELECT 1 FROM plugin_graph_revocations WHERE graph_id = ?) AND NOT EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q WHERE q.run_id = ? AND (q.graph_id <> ? OR q.ref_id <> ?)))) ", + params: [sql_text(run_id.copy()), sql_text(graph_id.copy()), sql_text(ref_id.copy()), sql_text(run_id.copy()), sql_text(session_id.copy()), sql_text(run_id.copy()), sql_text(graph_id.copy()), sql_text(ref_id.copy()), sql_text(graph_id.copy()), sql_text(graph_id.copy()), sql_text(run_id.copy()), sql_text(graph_id.copy()), sql_text(ref_id.copy())], + query: false, + limits: plugin_limits(), + } +} + pub fn storage_admission_pin_statements(graph_id: string, generation: int, ref_id: string, run_id: string, session_id: string, now_ms: int) -> array { [ { - sql: "INSERT INTO plugin_control_guard (id) SELECT 2 WHERE NOT EXISTS (SELECT 1 FROM plugin_activation WHERE graph_id = ? AND generation = ?) OR NOT EXISTS (SELECT 1 FROM plugin_graph_locks WHERE graph_id = ?)", - params: [sql_text(graph_id.copy()), sql_int(generation.copy()), sql_text(graph_id.copy())], + sql: "INSERT INTO plugin_control_guard (id) SELECT 2 WHERE (NOT EXISTS (SELECT 1 FROM plugin_activation WHERE graph_id = ? AND generation = ?) OR NOT EXISTS (SELECT 1 FROM plugin_graph_locks WHERE graph_id = ?)) AND (? = '' OR (NOT EXISTS (SELECT 1 FROM plugin_graph_revocations WHERE graph_id = ?) AND NOT EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q WHERE q.run_id = ? AND (q.graph_id <> ? OR q.ref_id <> ?)))) ", + params: [sql_text(graph_id.copy()), sql_int(generation.copy()), sql_text(graph_id.copy()), sql_text(graph_id.copy()), sql_text(graph_id.copy()), sql_text(run_id.copy()), sql_text(graph_id.copy()), sql_text(ref_id.copy())], query: false, limits: plugin_limits(), }, { - sql: "INSERT INTO plugin_graph_refs (graph_id, owner_kind, owner_id, ref_id, retained_at_ms) SELECT ?, 'run', ?, ?, ? WHERE EXISTS (SELECT 1 FROM plugin_activation WHERE graph_id = ? AND generation = ?) AND EXISTS (SELECT 1 FROM runs WHERE id = ? AND session_id = ?) AND NOT EXISTS (SELECT 1 FROM plugin_graph_refs existing WHERE existing.owner_kind = 'run' AND existing.owner_id = ? AND (existing.graph_id <> ? OR existing.ref_id <> ?))", - params: [sql_text(graph_id.copy()), sql_text(run_id.copy()), sql_text(ref_id.copy()), sql_int(now_ms.copy()), sql_text(graph_id.copy()), sql_int(generation.copy()), sql_text(run_id.copy()), sql_text(session_id.copy()), sql_text(run_id.copy()), sql_text(graph_id.copy()), sql_text(ref_id.copy())], + sql: "INSERT INTO plugin_graph_refs (graph_id, owner_kind, owner_id, ref_id, retained_at_ms) SELECT ?, 'run', ?, ?, ? WHERE (EXISTS (SELECT 1 FROM plugin_activation WHERE graph_id = ? AND generation = ?) AND EXISTS (SELECT 1 FROM runs WHERE id = ? AND session_id = ?) AND NOT EXISTS (SELECT 1 FROM plugin_graph_refs existing WHERE existing.owner_kind = 'run' AND existing.owner_id = ? AND (existing.graph_id <> ? OR existing.ref_id <> ?))) AND (? = '' OR (NOT EXISTS (SELECT 1 FROM plugin_graph_revocations WHERE graph_id = ?) AND NOT EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q WHERE q.run_id = ? AND (q.graph_id <> ? OR q.ref_id <> ?)))) ", + params: [sql_text(graph_id.copy()), sql_text(run_id.copy()), sql_text(ref_id.copy()), sql_int(now_ms.copy()), sql_text(graph_id.copy()), sql_int(generation.copy()), sql_text(run_id.copy()), sql_text(session_id.copy()), sql_text(run_id.copy()), sql_text(graph_id.copy()), sql_text(ref_id.copy()), sql_text(graph_id.copy()), sql_text(graph_id.copy()), sql_text(run_id.copy()), sql_text(graph_id.copy()), sql_text(ref_id.copy())], query: false, limits: plugin_limits(), }, { - sql: "INSERT INTO plugin_control_guard (id) SELECT 2 WHERE NOT EXISTS (SELECT 1 FROM plugin_graph_refs WHERE owner_kind = 'run' AND owner_id = ? AND graph_id = ? AND ref_id = ?)", - params: [sql_text(run_id.copy()), sql_text(graph_id.copy()), sql_text(ref_id.copy())], + sql: "INSERT INTO plugin_control_guard (id) SELECT 2 WHERE (NOT EXISTS (SELECT 1 FROM plugin_graph_refs WHERE owner_kind = 'run' AND owner_id = ? AND graph_id = ? AND ref_id = ?)) AND (? = '' OR (NOT EXISTS (SELECT 1 FROM plugin_graph_revocations WHERE graph_id = ?) AND NOT EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q WHERE q.run_id = ? AND (q.graph_id <> ? OR q.ref_id <> ?)))) ", + params: [sql_text(run_id.copy()), sql_text(graph_id.copy()), sql_text(ref_id.copy()), sql_text(graph_id.copy()), sql_text(graph_id.copy()), sql_text(run_id.copy()), sql_text(graph_id.copy()), sql_text(ref_id.copy())], query: false, limits: plugin_limits(), }, + storage_pin_requirement_statement(graph_id.copy(), ref_id.copy(), run_id.copy(), session_id.copy()), { sql: "SELECT refs.graph_id, refs.ref_id, activation.generation FROM plugin_graph_refs refs JOIN plugin_activation activation ON activation.graph_id = refs.graph_id AND activation.generation = ? WHERE refs.owner_kind = 'run' AND refs.owner_id = ? AND refs.ref_id = ?", params: [sql_int(generation.copy()), sql_text(run_id.copy()), sql_text(ref_id.copy())], @@ -403,6 +482,25 @@ pub fn storage_admission_pin_statements(graph_id: string, generation: int, ref_i ] } +pub fn storage_checkpoint_pin_statements(graph_id:string,generation:int,ref_id:string,run_id:string,session_id:string,now_ms:int,checkpoint_id:string,checkpoint_ref:string)->array { + [ +{sql:"INSERT INTO plugin_control_guard(id) SELECT 2 WHERE (NOT EXISTS(SELECT 1 FROM plugin_graph_refs WHERE graph_id=? AND owner_kind='checkpoint' AND owner_id=? AND ref_id=?) OR NOT EXISTS(SELECT 1 FROM plugin_graph_locks WHERE graph_id=?)) AND (? = '' OR (NOT EXISTS (SELECT 1 FROM plugin_graph_revocations WHERE graph_id = ?) AND NOT EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q WHERE q.run_id = ? AND (q.graph_id <> ? OR q.ref_id <> ?)))) ",params:[sql_text(graph_id.copy()),sql_text(checkpoint_id.copy()),sql_text(checkpoint_ref.copy()),sql_text(graph_id.copy()), sql_text(graph_id.copy()), sql_text(graph_id.copy()), sql_text(run_id.copy()), sql_text(graph_id.copy()), sql_text(ref_id.copy())],query:false,limits:plugin_limits()}, +{sql:"INSERT INTO plugin_graph_refs(graph_id,owner_kind,owner_id,ref_id,retained_at_ms) SELECT ?,'run',?,?,? WHERE (EXISTS(SELECT 1 FROM runs WHERE id=? AND session_id=?)) AND (? = '' OR (NOT EXISTS (SELECT 1 FROM plugin_graph_revocations WHERE graph_id = ?) AND NOT EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q WHERE q.run_id = ? AND (q.graph_id <> ? OR q.ref_id <> ?)))) ",params:[sql_text(graph_id.copy()),sql_text(run_id.copy()),sql_text(ref_id.copy()),sql_int(now_ms.copy()),sql_text(run_id.copy()),sql_text(session_id.copy()), sql_text(graph_id.copy()), sql_text(graph_id.copy()), sql_text(run_id.copy()), sql_text(graph_id.copy()), sql_text(ref_id.copy())],query:false,limits:plugin_limits()}, +{sql:"INSERT INTO plugin_control_guard(id) SELECT 2 WHERE (NOT EXISTS(SELECT 1 FROM plugin_graph_refs WHERE graph_id=? AND owner_kind='run' AND owner_id=? AND ref_id=?)) AND (? = '' OR (NOT EXISTS (SELECT 1 FROM plugin_graph_revocations WHERE graph_id = ?) AND NOT EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q WHERE q.run_id = ? AND (q.graph_id <> ? OR q.ref_id <> ?)))) ",params:[sql_text(graph_id.copy()),sql_text(run_id.copy()),sql_text(ref_id.copy()), sql_text(graph_id.copy()), sql_text(graph_id.copy()), sql_text(run_id.copy()), sql_text(graph_id.copy()), sql_text(ref_id.copy())],query:false,limits:plugin_limits()}, +storage_pin_requirement_statement(graph_id.copy(), ref_id.copy(), run_id.copy(), session_id.copy()), +{sql:"SELECT graph_id,ref_id,? AS generation FROM plugin_graph_refs WHERE owner_kind='run' AND owner_id=? AND ref_id=?",params:[sql_int(generation.copy()),sql_text(run_id.copy()),sql_text(ref_id.copy())],query:true,limits:plugin_limits()} + ] +} + +pub fn storage_admission_pin_classification(graph_id: string, ref_id: string, run_id: string) -> SqliteStatement { + { + sql: "SELECT CASE WHEN EXISTS (SELECT 1 FROM plugin_graph_revocations WHERE graph_id = ?) THEN 'plugin_graph_revoked' WHEN NOT EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q JOIN plugin_graph_refs g ON g.graph_id=q.graph_id AND g.ref_id=q.ref_id AND g.owner_kind='run' AND g.owner_id=q.run_id WHERE q.run_id=? AND q.graph_id=? AND q.ref_id=?) THEN 'plugin_graph_pin_required' ELSE 'ok' END", + params: [sql_text(graph_id.copy()), sql_text(run_id.copy()), sql_text(graph_id.copy()), sql_text(ref_id.copy())], + query: true, + limits: plugin_limits(), + } +} + pub fn storage_pin_text(entry: SqliteTransactionResult, column: int) -> string { plugin_query_text(entry, column) } @@ -411,8 +509,14 @@ pub fn storage_pin_int(entry: SqliteTransactionResult, column: int) -> int { plugin_query_int(entry, column) } +fn decode_pin_selection(encoded:string)->map { + let mut selection:map={}; + if encoded != "null" {selection=json::decode(encoded);} + selection +} + pub fn storage_pin_by_owner(db_id: resource, owner_id: string) -> map { - let queried: SqliteQueryResult = sqlite::query(&db_id, "SELECT COALESCE(json_extract(idem.response_json, '$.tool_graph_id'), ''), COALESCE(json_extract(idem.response_json, '$.tool_ref_id'), ''), COALESCE(CAST(json_extract(idem.response_json, '$.tool_generation') AS INTEGER), 0), idem.resource_id, COALESCE((SELECT session_id FROM runs WHERE id = idem.resource_id), ''), COALESCE(json_extract(idem.response_json, '$.registry_identity'), ''), COALESCE(json_extract(idem.response_json, '$.workspace'), ''), COALESCE(json_extract(idem.response_json, '$.profile'), '') FROM idempotency_records idem JOIN runs original ON original.id = idem.resource_id WHERE idem.resource_id = ? AND ((original.idempotency_key <> '' AND idem.scope = original.idempotency_scope AND idem.key = original.idempotency_key) OR (original.idempotency_key = '' AND idem.scope = 'native.tool-pin' AND idem.key = original.id)) AND EXISTS (SELECT 1 FROM plugin_graph_refs refs WHERE refs.graph_id = json_extract(idem.response_json, '$.tool_graph_id') AND refs.owner_kind = 'run' AND refs.owner_id = idem.resource_id AND refs.ref_id = json_extract(idem.response_json, '$.tool_ref_id'))", [sql_text(owner_id.copy())], plugin_limits()); + let queried: SqliteQueryResult = sqlite::query(&db_id, "SELECT COALESCE(json_extract(idem.response_json, '$.tool_graph_id'), ''), COALESCE(json_extract(idem.response_json, '$.tool_ref_id'), ''), COALESCE(CAST(json_extract(idem.response_json, '$.tool_generation') AS INTEGER), 0), idem.resource_id, COALESCE((SELECT session_id FROM runs WHERE id = idem.resource_id), ''), COALESCE(json_extract(idem.response_json, '$.registry_identity'), ''), COALESCE(json_extract(idem.response_json, '$.workspace'), ''), COALESCE(json_extract(idem.response_json, '$.profile'), ''), COALESCE(json_extract(idem.response_json, '$.harness_selection'), 'null') FROM idempotency_records idem JOIN runs original ON original.id = idem.resource_id WHERE idem.resource_id = ? AND ((original.idempotency_key <> '' AND idem.scope = original.idempotency_scope AND idem.key = original.idempotency_key) OR (original.idempotency_key = '' AND idem.scope = 'native.tool-pin' AND idem.key = original.id)) AND EXISTS (SELECT 1 FROM plugin_graph_refs refs WHERE refs.graph_id = json_extract(idem.response_json, '$.tool_graph_id') AND refs.owner_kind = 'run' AND refs.owner_id = idem.resource_id AND refs.ref_id = json_extract(idem.response_json, '$.tool_ref_id'))", [sql_text(owner_id.copy())], plugin_limits()); let mut graph_id: string = ""; let mut ref_id: string = ""; let mut generation: int = 0; @@ -421,6 +525,7 @@ pub fn storage_pin_by_owner(db_id: resource, owner_id: string let mut registry_identity: string = ""; let mut workspace: string = ""; let mut profile: string = ""; + let mut harness_selection_json: string = "null"; let mut pinned: bool = false; let rows = queried.rows.copy(); if rows.length == 1 { @@ -434,6 +539,7 @@ pub fn storage_pin_by_owner(db_id: resource, owner_id: string registry_identity = cells[5].text_value; workspace = cells[6].text_value; profile = cells[7].text_value; + harness_selection_json = cells[8].text_value; pinned = graph_id.copy() != "" && ref_id.copy() != ""; } { @@ -445,6 +551,7 @@ pub fn storage_pin_by_owner(db_id: resource, owner_id: string registry_identity: registry_identity, workspace: workspace, profile: profile, + harness_selection: decode_pin_selection(harness_selection_json), pinned: pinned } } diff --git a/rss/storage/schema.rss b/rss/storage/schema.rss index 458e53e..d59b127 100644 --- a/rss/storage/schema.rss +++ b/rss/storage/schema.rss @@ -88,7 +88,13 @@ fn plugin_control_migration_statement(statement_index: int) -> string { if statement_index == 2 => { plugin_activation_sql() } else => { if statement_index == 3 => { plugin_activation_history_sql() } else => { if statement_index == 4 => { plugin_graph_refs_sql() } else => { - plugin_control_guard_sql() + if statement_index == 6 => { + "CREATE TABLE IF NOT EXISTS plugin_graph_revocations (graph_id TEXT PRIMARY KEY, revoked_at_ms INTEGER NOT NULL)" + } else => { + if statement_index == 7 => { + "CREATE TABLE IF NOT EXISTS plugin_graph_pin_requirements (run_id TEXT PRIMARY KEY, graph_id TEXT NOT NULL, ref_id TEXT NOT NULL, required_mode TEXT NOT NULL CHECK (required_mode = 'required'))" + } else => { plugin_control_guard_sql() } + } } } } @@ -204,7 +210,7 @@ pub fn schema_migration_statement_count(migration_index: int) -> int { if migration_index == 0 => { 11 } else => { if migration_index == 1 => { 12 } else => { if migration_index == 2 => { 1 } else => { - if migration_index == 3 => { 5 } else => { if migration_index == 4 => { 6 } else => { effects::migration_count() } } + if migration_index == 3 => { 5 } else => { if migration_index == 4 => { 8 } else => { effects::migration_count() } } } } } diff --git a/rss/storage/sessions.rss b/rss/storage/sessions.rss index 3e1b3ae..91321be 100644 --- a/rss/storage/sessions.rss +++ b/rss/storage/sessions.rss @@ -176,8 +176,9 @@ pub fn storage_session_workspace_bind(db_id: resource, payloa /// graph refs. Both ref and idempotency deletes precede the session delete: /// the latter may cascade to runs when the host enables FKs, and both /// predicates need those runs to still exist. The session row delete is -/// statement 2, so `rows_affected` of that result tells the caller whether -/// the session existed (0 -> session_not_found). +/// statement index 4. Preceding deletes, including idempotency at index 3, +/// are not existence proof: an empty session has no idempotency rows. + pub fn storage_session_managed_retained(db_id: resource,payload_json:string)->bool { let input:SessionIdInput=json::decode::(payload_json); let found:SqliteQueryResult=sqlite::query(&db_id,"SELECT run_id FROM managed_run_bindings WHERE session_id=? LIMIT 1",[sql_text(input.session_id)],sql_limits(1,4096)); @@ -191,6 +192,12 @@ pub fn storage_session_delete(db_id: resource, payload_json: sql:"INSERT INTO managed_effect_guard(id) SELECT 2 WHERE EXISTS(SELECT 1 FROM managed_run_bindings WHERE session_id=?)", params:[sql_text(input.session_id.copy())],query:false,limits:sql_limits(1,4096) }, + { + sql: "DELETE FROM plugin_graph_pin_requirements WHERE run_id IN (SELECT id FROM runs WHERE session_id = ?)", + params: [sql_text(input.session_id.copy())], + query: false, + limits: sql_limits(1000, 4194304) + }, { sql: "DELETE FROM plugin_graph_refs WHERE owner_kind = 'run' AND owner_id IN (SELECT id FROM runs WHERE session_id = ?)", params: [sql_text(input.session_id.copy())], diff --git a/schemas/README.md b/schemas/README.md index 88d18b0..51b2b3e 100644 --- a/schemas/README.md +++ b/schemas/README.md @@ -11,3 +11,7 @@ A graph-lock edge records the caller entry, callee entry, alias, version require `plugin-activation.schema.yaml` and `plugin-graph-ref.schema.yaml` are structural records for a stored activation pointer/history entry and a durable graph reference. A graph id is an opaque lock identity. These schemas do not grant host-catalog authority, rebuild a callable program, pin a live run, or claim hot reload. Reference owners are only `run` and `checkpoint`. The joined generator also projects `schemas/v1/checkpoint.schema.yaml` to `schemas/canonical/checkpoint.schema.json`. That projection is structural drift coverage only. It does not validate checkpoint writes, permissions, or serialization; those stay on the Native checkpoint consumer. + +`managed-checkpoint.schema.yaml` and `managed-restore.schema.yaml` describe the current managed maintenance DTOs and their nested named storage bodies. They use local `$defs` references, closed objects/enumerations and explicit boolean, null, optional and bounded collection rules. Native producers and strict parsers validate against these same schemas. Stateless coverage requires true absence assertions and empty state roots; artifact roots are unsupported. The independent `v1/checkpoint` contract is unchanged. + +Managed receipts expose only the named public configuration projection. Safe provider execution options are retained in a separate private named record under the original validated option whitelist. Restore uses the frozen tree for guidance and the committed compaction summary plus exact retained history tail. Native authority, current admission limits and approvals are never serialized as reusable permission. Complete escaped transport, canonical original request proof, derived restore envelope and all repeated fixed SQL parameters share the existing aggregate metadata bound; an oversized complete request is rejected before enqueue. diff --git a/schemas/canonical/effect-contract.schema.json b/schemas/canonical/effect-contract.schema.json index 03bfacb..3095688 100644 --- a/schemas/canonical/effect-contract.schema.json +++ b/schemas/canonical/effect-contract.schema.json @@ -273,8 +273,8 @@ "additionalProperties": { "$ref": "#/$defs/entry" }, - "description": "v1 structural cardinality is one public effect entry. Matching that key to entry.export, and closing every supported entry, are Native semantic checks. This schema does not claim those checks.\n", - "maxProperties": 1, + "description": "One tool entry or exactly three explicitly pure library entries. Selected callable identity and closure are additionally checked by Native.\n", + "maxProperties": 3, "minProperties": 1, "type": "object" } diff --git a/schemas/canonical/managed-checkpoint.schema.json b/schemas/canonical/managed-checkpoint.schema.json new file mode 100644 index 0000000..8d54ca4 --- /dev/null +++ b/schemas/canonical/managed-checkpoint.schema.json @@ -0,0 +1,2149 @@ +{ + "$defs": { + "CanonicalToolResultJson": { + "additionalProperties": false, + "properties": { + "json": { + "maxLength": 1048576, + "type": "string" + } + }, + "required": [ + "json" + ], + "type": "object" + }, + "DirectoryIdentity": { + "additionalProperties": false, + "properties": { + "device_decimal": { + "maxLength": 256, + "type": "string" + }, + "inode_decimal": { + "maxLength": 256, + "type": "string" + } + }, + "required": [ + "device_decimal", + "inode_decimal" + ], + "type": "object" + }, + "ManagedApprovalBasis": { + "enum": [ + "read_without_attestation", + "guardian_attested" + ], + "type": "string" + }, + "ManagedArgumentIdentity": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "canonical_arguments": { + "maxLength": 256, + "type": "string" + }, + "kind": { + "const": "inline" + } + }, + "required": [ + "kind", + "canonical_arguments" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "details": { + "$ref": "#/$defs/ManagedObjectRef" + }, + "kind": { + "const": "object" + } + }, + "required": [ + "kind", + "details" + ], + "type": "object" + } + ] + }, + "ManagedCallIdentity": { + "additionalProperties": false, + "properties": { + "approval_basis": { + "$ref": "#/$defs/ManagedApprovalBasis" + }, + "approved_risk": { + "maxLength": 256, + "type": "string" + }, + "argument_identity": { + "$ref": "#/$defs/ManagedArgumentIdentity" + }, + "authorization_generation": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "call_id": { + "maxLength": 256, + "type": "string" + }, + "code": { + "$ref": "#/$defs/ManagedCodeIdentity" + }, + "epoch": { + "maximum": 9223372036854775807, + "minimum": 1, + "type": "integer" + }, + "expected_head": { + "maxLength": 256, + "type": "string" + }, + "expected_head_version": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "original_deadline_ms": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "owner": { + "$ref": "#/$defs/ManagedOwnerScope" + }, + "tool_name": { + "maxLength": 256, + "type": "string" + }, + "workspace": { + "$ref": "#/$defs/ManagedWorkspaceIdentity" + } + }, + "required": [ + "owner", + "call_id", + "tool_name", + "argument_identity", + "code", + "workspace", + "epoch", + "expected_head", + "expected_head_version", + "original_deadline_ms", + "authorization_generation", + "approved_risk", + "approval_basis" + ], + "type": "object" + }, + "ManagedCheckpointArtifactRef": { + "additionalProperties": false, + "properties": { + "artifact_id": { + "maxLength": 256, + "type": "string" + }, + "object": { + "$ref": "#/$defs/ManagedObjectRef" + } + }, + "required": [ + "artifact_id", + "object" + ], + "type": "object" + }, + "ManagedCheckpointCommitInput": { + "additionalProperties": false, + "properties": { + "captured_source": { + "$ref": "#/$defs/ManagedSourceCas" + }, + "compaction": { + "anyOf": [ + { + "$ref": "#/$defs/ManagedFrozenCompaction" + }, + { + "type": "null" + } + ] + }, + "execution_config": { + "$ref": "#/$defs/ManagedFrozenExecutionConfig" + }, + "messages": { + "items": { + "$ref": "#/$defs/ManagedFrozenMessage" + }, + "maxItems": 128, + "type": "array" + }, + "original_request_proof": { + "maxLength": 1048576, + "type": "string" + }, + "receipt": { + "$ref": "#/$defs/ManagedCheckpointReceipt" + }, + "request": { + "$ref": "#/$defs/ManagedCheckpointCreateRequest" + } + }, + "required": [ + "request", + "original_request_proof", + "captured_source", + "receipt", + "messages", + "execution_config", + "compaction" + ], + "type": "object" + }, + "ManagedCheckpointContext": { + "additionalProperties": false, + "properties": { + "compaction": { + "anyOf": [ + { + "$ref": "#/$defs/ManagedFrozenCompaction" + }, + { + "type": "null" + } + ] + }, + "effects": { + "items": { + "$ref": "#/$defs/ManagedEffectRecord" + }, + "maxItems": 129, + "type": "array" + }, + "external": { + "items": { + "$ref": "#/$defs/ManagedExternalHighWater" + }, + "maxItems": 33, + "type": "array" + }, + "head": { + "$ref": "#/$defs/ManagedWorkspaceHead" + }, + "last_ordinal": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "messages": { + "items": { + "$ref": "#/$defs/ManagedFrozenMessage" + }, + "maxItems": 129, + "type": "array" + }, + "pending_compactions": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "session_generation": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "session_id": { + "maxLength": 256, + "type": "string" + }, + "source_runs": { + "items": { + "$ref": "#/$defs/ManagedSourceRunRef" + }, + "maxItems": 129, + "type": "array" + }, + "unsettled_runs": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "workspace": { + "$ref": "#/$defs/ManagedWorkspaceRecord" + } + }, + "required": [ + "workspace", + "head", + "session_id", + "session_generation", + "last_ordinal", + "messages", + "effects", + "external", + "unsettled_runs", + "source_runs", + "pending_compactions", + "compaction" + ], + "type": "object" + }, + "ManagedCheckpointCreateRequest": { + "additionalProperties": false, + "properties": { + "expected_epoch": { + "maximum": 9223372036854775807, + "minimum": 1, + "type": "integer" + }, + "mode": { + "$ref": "#/$defs/ManagedCheckpointMode" + }, + "request_id": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "type": "string" + }, + "source_branch_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "source_run_id": { + "maxLength": 256, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "type": "string" + }, + "workspace_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "source_run_id", + "workspace_id", + "source_branch_id", + "expected_epoch", + "request_id", + "mode" + ], + "type": "object" + }, + "ManagedCheckpointGraphRef": { + "additionalProperties": false, + "properties": { + "artifact_id": { + "maxLength": 256, + "type": "string" + }, + "captured_activation_generation": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "checkpoint_graph_ref_id": { + "maxLength": 256, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "type": "string" + }, + "effect_contract_version": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "export": { + "maxLength": 256, + "type": "string" + }, + "graph_id": { + "maxLength": 256, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "type": "string" + }, + "harness_selection": { + "$ref": "https://schemas.rustscript.local/v1/plugin-graph-lock.schema.yaml#/properties/harness_selection" + }, + "package_id": { + "maxLength": 256, + "type": "string" + }, + "registry_identity": { + "maxLength": 256, + "type": "string" + } + }, + "required": [ + "graph_id", + "checkpoint_graph_ref_id", + "captured_activation_generation", + "package_id", + "artifact_id", + "export", + "registry_identity", + "effect_contract_version", + "harness_selection" + ], + "type": "object" + }, + "ManagedCheckpointLookup": { + "additionalProperties": false, + "properties": { + "checkpoint": { + "$ref": "#/$defs/ManagedCheckpointRef" + }, + "compaction": { + "anyOf": [ + { + "$ref": "#/$defs/ManagedFrozenCompaction" + }, + { + "type": "null" + } + ] + }, + "execution_config": { + "$ref": "#/$defs/ManagedFrozenExecutionConfig" + }, + "messages": { + "items": { + "$ref": "#/$defs/ManagedFrozenMessage" + }, + "maxItems": 128, + "type": "array" + } + }, + "required": [ + "checkpoint", + "messages", + "execution_config", + "compaction" + ], + "type": "object" + }, + "ManagedCheckpointMode": { + "enum": [ + "at_closed_batch" + ], + "type": "string" + }, + "ManagedCheckpointPublicConfig": { + "additionalProperties": false, + "properties": { + "model": { + "maxLength": 256, + "type": "string" + }, + "policy_revision": { + "maxLength": 256, + "type": "string" + }, + "provider": { + "anyOf": [ + { + "maxLength": 256, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "publication_policy": { + "$ref": "#/$defs/ManagedCheckpointPublication" + }, + "registry_identity": { + "maxLength": 256, + "type": "string" + }, + "system_instructions": { + "anyOf": [ + { + "maxLength": 65536, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "tool_schemas_ref": { + "maxLength": 256, + "type": "string" + } + }, + "required": [ + "model", + "registry_identity", + "tool_schemas_ref", + "policy_revision", + "publication_policy" + ], + "type": "object" + }, + "ManagedCheckpointPublication": { + "enum": [ + "managed_only" + ], + "type": "string" + }, + "ManagedCheckpointReceipt": { + "additionalProperties": false, + "properties": { + "checkpoint": { + "$ref": "#/$defs/ManagedCheckpointRef" + }, + "committed_at_ms": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "replayed": { + "type": "boolean" + }, + "request_id": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "type": "string" + } + }, + "required": [ + "request_id", + "checkpoint", + "committed_at_ms", + "replayed" + ], + "type": "object" + }, + "ManagedCheckpointRef": { + "additionalProperties": false, + "properties": { + "checkpoint_id": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "type": "string" + }, + "external_boundary": { + "$ref": "#/$defs/ManagedExternalBoundary" + }, + "frozen_root": { + "$ref": "#/$defs/ManagedObjectRef" + }, + "graph": { + "$ref": "#/$defs/ManagedCheckpointGraphRef" + }, + "history": { + "$ref": "#/$defs/ManagedFrozenHistoryRef" + }, + "parent_run_id": { + "maxLength": 256, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "type": "string" + }, + "plugin_state": { + "$ref": "#/$defs/ManagedCheckpointStateCoverage" + }, + "public_config": { + "$ref": "#/$defs/ManagedPublicConfigRef" + }, + "source_branch_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "source_epoch": { + "maximum": 9223372036854775807, + "minimum": 1, + "type": "integer" + }, + "workspace_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "checkpoint_id", + "workspace_id", + "parent_run_id", + "source_branch_id", + "source_epoch", + "frozen_root", + "history", + "graph", + "public_config", + "plugin_state", + "external_boundary" + ], + "type": "object" + }, + "ManagedCheckpointStateCoverage": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "artifact_id": { + "maxLength": 256, + "type": "string" + }, + "graph_id": { + "maxLength": 256, + "type": "string" + }, + "kind": { + "const": "stateless_builtin" + }, + "manifest_state_absent": { + "const": true + }, + "package_id": { + "maxLength": 256, + "type": "string" + }, + "roots": { + "items": { + "$ref": "#/$defs/ManagedPluginStateRootRef" + }, + "maxItems": 0, + "type": "array" + }, + "state_effects_absent": { + "const": true + } + }, + "required": [ + "kind", + "graph_id", + "package_id", + "artifact_id", + "manifest_state_absent", + "state_effects_absent", + "roots" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "artifact_id": { + "maxLength": 256, + "type": "string" + }, + "graph_id": { + "maxLength": 256, + "type": "string" + }, + "kind": { + "const": "stateless_builtin_with_pure_policy" + }, + "manifest_state_absent": { + "const": true + }, + "package_id": { + "maxLength": 256, + "type": "string" + }, + "policy": { + "$ref": "https://schemas.rustscript.local/v1/plugin-graph-lock.schema.yaml#/properties/harness_selection/oneOf/2/properties/policy" + }, + "policy_host_imports_absent": { + "const": true + }, + "roots": { + "items": { + "$ref": "#/$defs/ManagedPluginStateRootRef" + }, + "maxItems": 0, + "type": "array" + }, + "state_effects_absent": { + "const": true + } + }, + "required": [ + "kind", + "graph_id", + "package_id", + "artifact_id", + "manifest_state_absent", + "state_effects_absent", + "roots", + "policy", + "policy_host_imports_absent" + ], + "type": "object" + } + ] + }, + "ManagedClosedOutputRef": { + "additionalProperties": false, + "properties": { + "call_id": { + "maxLength": 256, + "type": "string" + }, + "output_message_id": { + "maxLength": 256, + "type": "string" + }, + "parent_message_id": { + "maxLength": 256, + "type": "string" + }, + "receipt_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "result": { + "$ref": "#/$defs/ManagedObjectRef" + }, + "run_id": { + "maxLength": 256, + "type": "string" + } + }, + "required": [ + "run_id", + "call_id", + "parent_message_id", + "output_message_id", + "receipt_id", + "result" + ], + "type": "object" + }, + "ManagedCodeIdentity": { + "additionalProperties": false, + "properties": { + "activation_generation": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "artifact_id": { + "maxLength": 256, + "type": "string" + }, + "effect_contract_version": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "export": { + "maxLength": 256, + "type": "string" + }, + "graph_id": { + "maxLength": 256, + "type": "string" + }, + "graph_ref_id": { + "maxLength": 256, + "type": "string" + }, + "package_id": { + "maxLength": 256, + "type": "string" + }, + "registry_identity": { + "maxLength": 256, + "type": "string" + } + }, + "required": [ + "graph_id", + "activation_generation", + "graph_ref_id", + "package_id", + "artifact_id", + "export", + "registry_identity", + "effect_contract_version" + ], + "type": "object" + }, + "ManagedCommitOutcome": { + "additionalProperties": false, + "properties": { + "canonical_result_ref": { + "$ref": "#/$defs/ManagedObjectRef" + }, + "disposition": { + "$ref": "#/$defs/ManagedPublicationStatus" + }, + "message": { + "$ref": "#/$defs/ManagedMessageRef" + }, + "output_event": { + "$ref": "#/$defs/ManagedEventRef" + }, + "publication": { + "$ref": "#/$defs/ManagedPublicationRef" + }, + "replayed": { + "type": "boolean" + }, + "terminal_event": { + "$ref": "#/$defs/ManagedEventRef" + } + }, + "required": [ + "disposition", + "publication", + "canonical_result_ref", + "output_event", + "terminal_event", + "message", + "replayed" + ], + "type": "object" + }, + "ManagedEffectRecord": { + "additionalProperties": false, + "properties": { + "effect_id": { + "maxLength": 256, + "type": "string" + }, + "format": { + "maxLength": 256, + "type": "string" + }, + "identity": { + "$ref": "#/$defs/ManagedCallIdentity" + }, + "outcome": { + "anyOf": [ + { + "$ref": "#/$defs/ManagedCommitOutcome" + }, + { + "type": "null" + } + ] + }, + "prepared": { + "anyOf": [ + { + "$ref": "#/$defs/ManagedPreparedRecord" + }, + { + "type": "null" + } + ] + }, + "recovery": { + "anyOf": [ + { + "$ref": "#/$defs/ManagedRecoveryObservation" + }, + { + "type": "null" + } + ] + }, + "schemaVersion": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "state": { + "$ref": "#/$defs/ManagedPublicationStatus" + } + }, + "required": [ + "format", + "effect_id", + "schemaVersion", + "identity", + "state" + ], + "type": "object" + }, + "ManagedEventRef": { + "additionalProperties": false, + "properties": { + "event_id": { + "maxLength": 256, + "type": "string" + }, + "seq": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "event_id", + "seq" + ], + "type": "object" + }, + "ManagedExternalBoundary": { + "additionalProperties": false, + "properties": { + "artifact_refs": { + "items": { + "$ref": "#/$defs/ManagedCheckpointArtifactRef" + }, + "maxItems": 0, + "type": "array" + }, + "high_water": { + "items": { + "$ref": "#/$defs/ManagedExternalHighWater" + }, + "maxItems": 32, + "type": "array" + }, + "not_reverted": { + "items": { + "$ref": "#/$defs/ManagedExternalNotice" + }, + "maxItems": 32, + "type": "array" + } + }, + "required": [ + "high_water", + "not_reverted", + "artifact_refs" + ], + "type": "object" + }, + "ManagedExternalEffects": { + "enum": [ + "none_claimed", + "outside_tree_not_reverted" + ], + "type": "string" + }, + "ManagedExternalHighWater": { + "additionalProperties": false, + "properties": { + "event_seq": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "input_tokens": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "output_tokens": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "provider_request_count": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "run_id": { + "maxLength": 256, + "type": "string" + } + }, + "required": [ + "run_id", + "event_seq", + "provider_request_count", + "input_tokens", + "output_tokens" + ], + "type": "object" + }, + "ManagedExternalNotice": { + "enum": [ + "provider_usage_not_reverted", + "delivered_content_not_reverted" + ], + "type": "string" + }, + "ManagedFrozenCompaction": { + "additionalProperties": false, + "properties": { + "completed_at_ms": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "created_at_ms": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "generation": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "id": { + "maxLength": 256, + "type": "string" + }, + "model": { + "maxLength": 256, + "type": "string" + }, + "retained_tail_ordinal": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "source_end_ordinal": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "source_start_ordinal": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "summary_json": { + "maxLength": 1048576, + "type": "string" + }, + "token_estimate": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "id", + "generation", + "source_start_ordinal", + "source_end_ordinal", + "retained_tail_ordinal", + "summary_json", + "token_estimate", + "model", + "created_at_ms", + "completed_at_ms" + ], + "type": "object" + }, + "ManagedFrozenExecutionConfig": { + "additionalProperties": false, + "properties": { + "provider_options": { + "$ref": "#/$defs/ManagedFrozenProviderOptions" + }, + "provider_profile": { + "maxLength": 256, + "type": "string" + } + }, + "required": [ + "provider_profile", + "provider_options" + ], + "type": "object" + }, + "ManagedFrozenHistoryRef": { + "additionalProperties": false, + "properties": { + "byte_count": { + "maximum": 524288, + "minimum": 0, + "type": "integer" + }, + "closed_output_refs": { + "items": { + "$ref": "#/$defs/ManagedClosedOutputRef" + }, + "maxItems": 128, + "type": "array" + }, + "history_root_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "last_closed_batch_id": { + "maxLength": 256, + "type": "string" + }, + "last_message_id": { + "maxLength": 256, + "type": "string" + }, + "last_ordinal": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "message_count": { + "maximum": 128, + "minimum": 0, + "type": "integer" + }, + "session_generation": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "source_session_id": { + "maxLength": 256, + "type": "string" + } + }, + "required": [ + "history_root_id", + "source_session_id", + "session_generation", + "last_message_id", + "last_ordinal", + "last_closed_batch_id", + "message_count", + "byte_count", + "closed_output_refs" + ], + "type": "object" + }, + "ManagedFrozenMessage": { + "additionalProperties": false, + "properties": { + "compacted": { + "type": "boolean" + }, + "content_json": { + "maxLength": 1048576, + "type": "string" + }, + "created_at_ms": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "finish_reason": { + "anyOf": [ + { + "maxLength": 256, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "id": { + "maxLength": 256, + "type": "string" + }, + "metadata_json": { + "maxLength": 1048576, + "type": "string" + }, + "name": { + "anyOf": [ + { + "maxLength": 256, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "ordinal": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "parent_message_id": { + "anyOf": [ + { + "maxLength": 256, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "role": { + "maxLength": 256, + "type": "string" + }, + "run_id": { + "anyOf": [ + { + "maxLength": 256, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "token_estimate": { + "anyOf": [ + { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "tool_call_id": { + "anyOf": [ + { + "maxLength": 256, + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "ordinal", + "role", + "content_json", + "metadata_json", + "compacted", + "created_at_ms" + ], + "type": "object" + }, + "ManagedFrozenProviderOptions": { + "additionalProperties": false, + "properties": { + "base_url": { + "anyOf": [ + { + "maxLength": 4096, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "credential_id": { + "anyOf": [ + { + "maxLength": 4096, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "max_output_tokens": { + "anyOf": [ + { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + { + "type": "null" + } + ] + }, + "profile": { + "anyOf": [ + { + "maxLength": 4096, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "protocol": { + "anyOf": [ + { + "maxLength": 4096, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "reasoning_effort": { + "anyOf": [ + { + "maxLength": 4096, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "stream": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "temperature": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "top_p": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + } + }, + "required": [], + "type": "object" + }, + "ManagedMessageRef": { + "additionalProperties": false, + "properties": { + "id": { + "maxLength": 256, + "type": "string" + }, + "ordinal": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "id", + "ordinal" + ], + "type": "object" + }, + "ManagedObjectKind": { + "enum": [ + "tree", + "canonical_result", + "intent_details" + ], + "type": "string" + }, + "ManagedObjectRef": { + "additionalProperties": false, + "properties": { + "byte_count": { + "maxLength": 256, + "pattern": "^[0-9]+$", + "type": "string" + }, + "entry_count": { + "maxLength": 256, + "pattern": "^[0-9]+$", + "type": "string" + }, + "object_format": { + "maxLength": 256, + "type": "string" + }, + "object_id": { + "maxLength": 256, + "type": "string" + }, + "object_kind": { + "$ref": "#/$defs/ManagedObjectKind" + }, + "store_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "store_id", + "object_id", + "object_kind", + "byte_count", + "entry_count", + "object_format" + ], + "type": "object" + }, + "ManagedOperationClass": { + "enum": [ + "read_only", + "tree_write" + ], + "type": "string" + }, + "ManagedOwnerScope": { + "additionalProperties": false, + "properties": { + "profile": { + "maxLength": 256, + "type": "string" + }, + "run_id": { + "maxLength": 256, + "type": "string" + }, + "session_id": { + "maxLength": 256, + "type": "string" + } + }, + "required": [ + "profile", + "session_id", + "run_id" + ], + "type": "object" + }, + "ManagedPluginStateRootRef": { + "additionalProperties": false, + "properties": { + "artifact_id": { + "maxLength": 256, + "type": "string" + }, + "immutable_root_id": { + "maxLength": 256, + "type": "string" + }, + "package_id": { + "maxLength": 256, + "type": "string" + }, + "revision": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "schema": { + "maxLength": 256, + "type": "string" + }, + "scope": { + "enum": [ + "run", + "session" + ], + "type": "string" + } + }, + "required": [ + "package_id", + "artifact_id", + "scope", + "schema", + "revision", + "immutable_root_id" + ], + "type": "object" + }, + "ManagedPreparedRecord": { + "additionalProperties": false, + "properties": { + "candidate_root": { + "anyOf": [ + { + "$ref": "#/$defs/ManagedObjectRef" + }, + { + "type": "null" + } + ] + }, + "canonical_result": { + "$ref": "#/$defs/ManagedObjectRef" + }, + "conflict_result": { + "anyOf": [ + { + "$ref": "#/$defs/ManagedObjectRef" + }, + { + "type": "null" + } + ] + }, + "effect_id": { + "maxLength": 256, + "type": "string" + }, + "external_effects": { + "$ref": "#/$defs/ManagedExternalEffects" + }, + "format": { + "maxLength": 256, + "type": "string" + }, + "identity": { + "$ref": "#/$defs/ManagedCallIdentity" + }, + "observation_ok": { + "type": "boolean" + }, + "operation_class": { + "$ref": "#/$defs/ManagedOperationClass" + }, + "parent_root": { + "$ref": "#/$defs/ManagedObjectRef" + }, + "prepared_at_ms": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "schemaVersion": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "writer_state": { + "$ref": "#/$defs/ManagedWriterState" + } + }, + "required": [ + "format", + "schemaVersion", + "effect_id", + "identity", + "parent_root", + "canonical_result", + "writer_state", + "operation_class", + "prepared_at_ms", + "observation_ok", + "external_effects" + ], + "type": "object" + }, + "ManagedPublicConfigRef": { + "additionalProperties": false, + "properties": { + "config": { + "$ref": "#/$defs/ManagedCheckpointPublicConfig" + }, + "config_ref_id": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "type": "string" + } + }, + "required": [ + "config_ref_id", + "config" + ], + "type": "object" + }, + "ManagedPublicationRef": { + "additionalProperties": false, + "properties": { + "base_head": { + "maxLength": 256, + "type": "string" + }, + "base_head_version": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "branch_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "candidate_head": { + "anyOf": [ + { + "maxLength": 256, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "effect_id": { + "maxLength": 256, + "type": "string" + }, + "epoch": { + "maximum": 9223372036854775807, + "minimum": 1, + "type": "integer" + }, + "format": { + "maxLength": 256, + "type": "string" + }, + "observed_head": { + "maxLength": 256, + "type": "string" + }, + "observed_head_version": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "status": { + "$ref": "#/$defs/ManagedPublicationStatus" + }, + "workspace_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "format", + "effect_id", + "workspace_id", + "branch_id", + "epoch", + "base_head", + "base_head_version", + "observed_head", + "observed_head_version", + "status" + ], + "type": "object" + }, + "ManagedPublicationStatus": { + "enum": [ + "read_only", + "committed", + "staged", + "conflict", + "not_published", + "indeterminate" + ], + "type": "string" + }, + "ManagedPublishReceipt": { + "additionalProperties": false, + "properties": { + "branch_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "epoch": { + "maximum": 9223372036854775807, + "minimum": 1, + "type": "integer" + }, + "head_object_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "head_version": { + "maximum": 9223372036854775807, + "minimum": 1, + "type": "integer" + }, + "previous_published_head": { + "anyOf": [ + { + "maxLength": 256, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "profile": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "published_at_ms": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "request_id": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "type": "string" + }, + "status": { + "const": "committed" + }, + "workspace_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "status", + "workspace_id", + "profile", + "branch_id", + "epoch", + "head_object_id", + "head_version", + "request_id", + "published_at_ms" + ], + "type": "object" + }, + "ManagedPublishRequest": { + "additionalProperties": false, + "properties": { + "branch_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "expected_head": { + "$ref": "#/$defs/ManagedWorkspaceHead" + }, + "request_id": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "type": "string" + }, + "workspace_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspace_id", + "branch_id", + "expected_head", + "request_id" + ], + "type": "object" + }, + "ManagedRecoveryObservation": { + "additionalProperties": false, + "properties": { + "canonical_failure_json": { + "$ref": "#/$defs/CanonicalToolResultJson" + }, + "code": { + "maxLength": 256, + "type": "string" + }, + "message": { + "$ref": "#/$defs/ManagedMessageRef" + }, + "output_event": { + "$ref": "#/$defs/ManagedEventRef" + }, + "publication": { + "$ref": "#/$defs/ManagedPublicationRef" + }, + "terminal_event": { + "$ref": "#/$defs/ManagedEventRef" + } + }, + "required": [ + "code", + "publication", + "canonical_failure_json", + "output_event", + "terminal_event", + "message" + ], + "type": "object" + }, + "ManagedSavedCheckpointReceipt": { + "additionalProperties": false, + "properties": { + "original_request_proof": { + "maxLength": 1048576, + "type": "string" + }, + "receipt": { + "$ref": "#/$defs/ManagedCheckpointReceipt" + } + }, + "required": [ + "original_request_proof", + "receipt" + ], + "type": "object" + }, + "ManagedSourceCas": { + "additionalProperties": false, + "properties": { + "branch_id": { + "maxLength": 256, + "minLength": 1, + "pattern": "^br-[0-9a-f]{32}$", + "type": "string" + }, + "epoch": { + "maximum": 9223372036854775807, + "minimum": 1, + "type": "integer" + }, + "head_object_id": { + "maxLength": 256, + "minLength": 1, + "pattern": "^tree-[0-9a-f]{32}$", + "type": "string" + }, + "head_version": { + "maximum": 9223372036854775807, + "minimum": 1, + "type": "integer" + }, + "store_id": { + "maxLength": 256, + "minLength": 1, + "pattern": "^store-[0-9a-f]{32}$", + "type": "string" + }, + "workspace_id": { + "maxLength": 256, + "minLength": 1, + "pattern": "^ws-[0-9a-f]{32}$", + "type": "string" + } + }, + "required": [ + "store_id", + "workspace_id", + "branch_id", + "epoch", + "head_object_id", + "head_version" + ], + "type": "object" + }, + "ManagedSourceRunRef": { + "additionalProperties": false, + "properties": { + "original_deadline_ms": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "run_id": { + "maxLength": 256, + "type": "string" + }, + "session_id": { + "maxLength": 256, + "type": "string" + }, + "status": { + "maxLength": 256, + "type": "string" + } + }, + "required": [ + "run_id", + "session_id", + "status", + "original_deadline_ms" + ], + "type": "object" + }, + "ManagedWorkspaceHead": { + "additionalProperties": false, + "properties": { + "branch_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "epoch": { + "maximum": 9223372036854775807, + "minimum": 1, + "type": "integer" + }, + "head_object_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "head_version": { + "maximum": 9223372036854775807, + "minimum": 1, + "type": "integer" + }, + "process_lineage_tainted": { + "type": "boolean" + }, + "schema_version": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "store_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "workspace_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspace_id", + "branch_id", + "epoch", + "head_object_id", + "head_version", + "store_id", + "schema_version", + "process_lineage_tainted" + ], + "type": "object" + }, + "ManagedWorkspaceIdentity": { + "additionalProperties": false, + "properties": { + "branch_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "source_identity": { + "$ref": "#/$defs/DirectoryIdentity" + }, + "store_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "workspace_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "store_id", + "workspace_id", + "branch_id", + "source_identity" + ], + "type": "object" + }, + "ManagedWorkspacePublication": { + "additionalProperties": false, + "properties": { + "branch_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "epoch": { + "maximum": 9223372036854775807, + "minimum": 1, + "type": "integer" + }, + "head_object_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "head_version": { + "maximum": 9223372036854775807, + "minimum": 1, + "type": "integer" + }, + "previous_published_head": { + "anyOf": [ + { + "maxLength": 256, + "type": "string" + }, + { + "type": "null" + } + ] + }, + "profile": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "published_at_ms": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "request_id": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "type": "string" + }, + "revision": { + "maximum": 9223372036854775807, + "minimum": 1, + "type": "integer" + }, + "schema_version": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "store_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "workspace_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspace_id", + "profile", + "branch_id", + "epoch", + "head_object_id", + "head_version", + "request_id", + "published_at_ms", + "revision", + "store_id", + "schema_version" + ], + "type": "object" + }, + "ManagedWorkspaceRecord": { + "additionalProperties": false, + "properties": { + "format": { + "maxLength": 256, + "type": "string" + }, + "identity": { + "$ref": "#/$defs/ManagedWorkspaceIdentity" + }, + "max_bytes": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "max_depth": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "max_entries": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "profile": { + "maxLength": 256, + "type": "string" + }, + "schemaVersion": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "seed_root": { + "$ref": "#/$defs/ManagedObjectRef" + } + }, + "required": [ + "format", + "schemaVersion", + "identity", + "profile", + "seed_root", + "max_entries", + "max_bytes", + "max_depth" + ], + "type": "object" + }, + "ManagedWriterState": { + "enum": [ + "settled", + "no_process" + ], + "type": "string" + } + }, + "$id": "https://schemas.rustscript.local/managed-checkpoint.schema.yaml", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "oneOf": [ + { + "$ref": "#/$defs/ManagedCheckpointCreateRequest" + }, + { + "$ref": "#/$defs/ManagedCheckpointReceipt" + }, + { + "$ref": "#/$defs/ManagedCheckpointCommitInput" + }, + { + "$ref": "#/$defs/ManagedCheckpointLookup" + }, + { + "$ref": "#/$defs/ManagedCheckpointContext" + }, + { + "$ref": "#/$defs/ManagedSavedCheckpointReceipt" + }, + { + "$ref": "#/$defs/ManagedPublishRequest" + }, + { + "$ref": "#/$defs/ManagedPublishReceipt" + }, + { + "$ref": "#/$defs/ManagedWorkspacePublication" + } + ], + "title": "managed-checkpoint current typed contract" +} diff --git a/schemas/canonical/managed-restore.schema.json b/schemas/canonical/managed-restore.schema.json new file mode 100644 index 0000000..392c14e --- /dev/null +++ b/schemas/canonical/managed-restore.schema.json @@ -0,0 +1,502 @@ +{ + "$defs": { + "ManagedAdmissionPin": { + "additionalProperties": false, + "properties": { + "generation": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "graph_id": { + "maxLength": 256, + "type": "string" + }, + "harness_selection": { + "$ref": "https://schemas.rustscript.local/v1/plugin-graph-lock.schema.yaml#/properties/harness_selection" + }, + "pinned": { + "type": "boolean" + }, + "profile": { + "maxLength": 256, + "type": "string" + }, + "ref_id": { + "maxLength": 256, + "type": "string" + }, + "registry_identity": { + "maxLength": 256, + "type": "string" + }, + "run_id": { + "maxLength": 256, + "type": "string" + }, + "session_id": { + "maxLength": 256, + "type": "string" + }, + "workspace": { + "maxLength": 256, + "type": "string" + } + }, + "required": [ + "graph_id", + "ref_id", + "generation", + "run_id", + "session_id", + "registry_identity", + "workspace", + "profile", + "pinned", + "harness_selection" + ], + "type": "object" + }, + "ManagedBranchPublication": { + "additionalProperties": false, + "properties": { + "admission_pin": { + "$ref": "#/$defs/ManagedAdmissionPin" + }, + "current": { + "$ref": "#/$defs/ManagedSourceCas" + }, + "graph": { + "$ref": "https://schemas.rustscript.local/managed-checkpoint.schema.yaml#/$defs/ManagedCheckpointGraphRef" + }, + "history_count": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "source": { + "$ref": "#/$defs/ManagedSourceCas" + } + }, + "required": [ + "source", + "current", + "history_count", + "admission_pin", + "graph" + ], + "type": "object" + }, + "ManagedCheckpointGraphRef": { + "additionalProperties": false, + "properties": { + "artifact_id": { + "maxLength": 256, + "type": "string" + }, + "captured_activation_generation": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "checkpoint_graph_ref_id": { + "maxLength": 256, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "type": "string" + }, + "effect_contract_version": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "export": { + "maxLength": 256, + "type": "string" + }, + "graph_id": { + "maxLength": 256, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "type": "string" + }, + "harness_selection": { + "$ref": "https://schemas.rustscript.local/v1/plugin-graph-lock.schema.yaml#/properties/harness_selection" + }, + "package_id": { + "maxLength": 256, + "type": "string" + }, + "registry_identity": { + "maxLength": 256, + "type": "string" + } + }, + "required": [ + "graph_id", + "checkpoint_graph_ref_id", + "captured_activation_generation", + "package_id", + "artifact_id", + "export", + "registry_identity", + "effect_contract_version", + "harness_selection" + ], + "type": "object" + }, + "ManagedCheckpointPublication": { + "enum": [ + "managed_only" + ], + "type": "string" + }, + "ManagedExternalNotice": { + "enum": [ + "provider_usage_not_reverted", + "delivered_content_not_reverted" + ], + "type": "string" + }, + "ManagedMaintenanceFailure": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "code": { + "maxLength": 256, + "type": "string" + }, + "disposition": { + "const": "rejected" + } + }, + "required": [ + "disposition", + "code" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "disposition": { + "const": "submitted_unknown" + }, + "receipt_lookup_id": { + "maxLength": 256, + "type": "string" + }, + "request_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "disposition", + "request_id", + "receipt_lookup_id" + ], + "type": "object" + } + ] + }, + "ManagedRestoreAndAdmitRequest": { + "additionalProperties": false, + "properties": { + "cancel_active_source": { + "const": true + }, + "checkpoint_id": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "type": "string" + }, + "expected_source": { + "$ref": "#/$defs/ManagedSourceCas" + }, + "graph_selection": { + "$ref": "#/$defs/ManagedRestoreGraphSelection" + }, + "input": { + "$ref": "#/$defs/ManagedRestoreRunInput" + }, + "request_id": { + "maxLength": 256, + "minLength": 1, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$", + "type": "string" + }, + "source_branch_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "workspace_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "workspace_id", + "source_branch_id", + "checkpoint_id", + "expected_source", + "request_id", + "input", + "cancel_active_source", + "graph_selection" + ], + "type": "object" + }, + "ManagedRestoreAndAdmitResult": { + "additionalProperties": false, + "properties": { + "checkpoint_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "disposition": { + "$ref": "#/$defs/ManagedRestoreDisposition" + }, + "external_effects_not_reverted": { + "items": { + "$ref": "#/$defs/ManagedExternalNotice" + }, + "maxItems": 32, + "type": "array" + }, + "graph": { + "$ref": "#/$defs/ManagedCheckpointGraphRef" + }, + "new_branch": { + "$ref": "#/$defs/ManagedSourceCas" + }, + "new_run_deadline_at_ms": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "new_run_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "new_session_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "publication": { + "$ref": "#/$defs/ManagedCheckpointPublication" + }, + "receipt_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "replayed": { + "type": "boolean" + }, + "source_after": { + "$ref": "#/$defs/ManagedSourceCas" + }, + "source_before": { + "$ref": "#/$defs/ManagedSourceCas" + } + }, + "required": [ + "disposition", + "receipt_id", + "checkpoint_id", + "source_before", + "source_after", + "new_branch", + "new_session_id", + "new_run_id", + "new_run_deadline_at_ms", + "graph", + "publication", + "external_effects_not_reverted", + "replayed" + ], + "type": "object" + }, + "ManagedRestoreDisposition": { + "enum": [ + "restored_and_admitted" + ], + "type": "string" + }, + "ManagedRestoreExecutionEnvelope": { + "additionalProperties": false, + "properties": { + "admission_pin": { + "$ref": "#/$defs/ManagedAdmissionPin" + }, + "history_root_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "original_request_proof": { + "maxLength": 1048576, + "type": "string" + }, + "request": { + "$ref": "#/$defs/ManagedRestoreAndAdmitRequest" + }, + "result": { + "$ref": "#/$defs/ManagedRestoreAndAdmitResult" + } + }, + "required": [ + "admission_pin", + "request", + "original_request_proof", + "result", + "history_root_id" + ], + "type": "object" + }, + "ManagedRestoreGraphSelection": { + "enum": [ + "checkpoint_exact" + ], + "type": "string" + }, + "ManagedRestoreRunInput": { + "additionalProperties": false, + "properties": { + "text": { + "maxLength": 65536, + "type": "string" + } + }, + "required": [ + "text" + ], + "type": "object" + }, + "ManagedRollbackFacts": { + "additionalProperties": false, + "properties": { + "current_branch_id": { + "maxLength": 256, + "type": "string" + }, + "source": { + "$ref": "#/$defs/ManagedSourceCas" + }, + "target_rows": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "source", + "current_branch_id", + "target_rows" + ], + "type": "object" + }, + "ManagedSavedRestoreReceipt": { + "additionalProperties": false, + "properties": { + "checkpoint_graph": { + "$ref": "https://schemas.rustscript.local/managed-checkpoint.schema.yaml#/$defs/ManagedCheckpointGraphRef" + }, + "envelope": { + "$ref": "#/$defs/ManagedRestoreExecutionEnvelope" + }, + "original_request_proof": { + "maxLength": 1048576, + "type": "string" + } + }, + "required": [ + "original_request_proof", + "envelope", + "checkpoint_graph" + ], + "type": "object" + }, + "ManagedSourceCas": { + "additionalProperties": false, + "properties": { + "branch_id": { + "maxLength": 256, + "minLength": 1, + "pattern": "^br-[0-9a-f]{32}$", + "type": "string" + }, + "epoch": { + "maximum": 9223372036854775807, + "minimum": 1, + "type": "integer" + }, + "head_object_id": { + "maxLength": 256, + "minLength": 1, + "pattern": "^tree-[0-9a-f]{32}$", + "type": "string" + }, + "head_version": { + "maximum": 9223372036854775807, + "minimum": 1, + "type": "integer" + }, + "store_id": { + "maxLength": 256, + "minLength": 1, + "pattern": "^store-[0-9a-f]{32}$", + "type": "string" + }, + "workspace_id": { + "maxLength": 256, + "minLength": 1, + "pattern": "^ws-[0-9a-f]{32}$", + "type": "string" + } + }, + "required": [ + "store_id", + "workspace_id", + "branch_id", + "epoch", + "head_object_id", + "head_version" + ], + "type": "object" + } + }, + "$id": "https://schemas.rustscript.local/managed-restore.schema.yaml", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "oneOf": [ + { + "$ref": "#/$defs/ManagedRestoreAndAdmitRequest" + }, + { + "$ref": "#/$defs/ManagedRestoreAndAdmitResult" + }, + { + "$ref": "#/$defs/ManagedRestoreExecutionEnvelope" + }, + { + "$ref": "#/$defs/ManagedSavedRestoreReceipt" + }, + { + "$ref": "#/$defs/ManagedMaintenanceFailure" + }, + { + "$ref": "#/$defs/ManagedBranchPublication" + }, + { + "$ref": "#/$defs/ManagedRollbackFacts" + } + ], + "title": "managed-restore current typed contract" +} diff --git a/schemas/canonical/plugin-graph-lock.schema.json b/schemas/canonical/plugin-graph-lock.schema.json index 084e7b5..0b2fdff 100644 --- a/schemas/canonical/plugin-graph-lock.schema.json +++ b/schemas/canonical/plugin-graph-lock.schema.json @@ -178,6 +178,103 @@ "description": "Opaque lock identity. Not a capability and not a content digest.", "type": "string" }, + "harness_selection": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "mode": { + "const": "unselected" + } + }, + "required": [ + "mode" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "mode": { + "const": "tool_only" + }, + "tool_package": { + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "mode", + "tool_package" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "mode": { + "const": "context_prompt" + }, + "policy": { + "additionalProperties": false, + "properties": { + "artifact_id": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "context_export": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "entry_path": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "guardian_export": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "package_id": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "prompt_export": { + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "package_id", + "artifact_id", + "entry_path", + "prompt_export", + "context_export", + "guardian_export" + ], + "type": "object" + }, + "tool_package": { + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "mode", + "tool_package", + "policy" + ], + "type": "object" + } + ] + }, "host_runtime": { "type": "string" }, @@ -218,6 +315,7 @@ "compile_environment", "edges", "graph_id", + "harness_selection", "host_runtime", "packages" ], diff --git a/schemas/canonical/plugin-manifest.schema.json b/schemas/canonical/plugin-manifest.schema.json index 987f7d5..5bb33df 100644 --- a/schemas/canonical/plugin-manifest.schema.json +++ b/schemas/canonical/plugin-manifest.schema.json @@ -2,6 +2,77 @@ "$id": "https://schemas.rustscript.local/v1/plugin-manifest.schema.yaml", "$schema": "https://json-schema.org/draft/2020-12/schema", "additionalProperties": false, + "allOf": [ + { + "if": { + "properties": { + "kind": { + "const": "tool" + } + } + }, + "then": { + "properties": { + "effects": { + "properties": { + "entries": { + "maxProperties": 1 + } + } + } + } + } + }, + { + "if": { + "properties": { + "effects": { + "properties": { + "entries": { + "minProperties": 2 + } + } + } + } + }, + "then": { + "not": { + "required": [ + "state" + ] + }, + "properties": { + "capabilities": { + "maxItems": 0 + }, + "dependencies": { + "maxItems": 0 + }, + "effects": { + "properties": { + "entries": { + "additionalProperties": { + "properties": { + "delegated": { + "maxItems": 0 + }, + "direct": { + "maxItems": 0 + } + } + }, + "maxProperties": 3, + "minProperties": 3 + } + } + }, + "kind": { + "const": "library" + } + } + } + } + ], "properties": { "capabilities": { "items": { diff --git a/schemas/effect-contract.schema.yaml b/schemas/effect-contract.schema.yaml index f3f14b2..5b24b9b 100644 --- a/schemas/effect-contract.schema.yaml +++ b/schemas/effect-contract.schema.yaml @@ -12,11 +12,10 @@ properties: entries: type: object minProperties: 1 - maxProperties: 1 + maxProperties: 3 description: > - v1 structural cardinality is one public effect entry. Matching that key - to entry.export, and closing every supported entry, are Native semantic - checks. This schema does not claim those checks. + One tool entry or exactly three explicitly pure library entries. Selected + callable identity and closure are additionally checked by Native. additionalProperties: $ref: "#/$defs/entry" $defs: diff --git a/schemas/managed-checkpoint.schema.yaml b/schemas/managed-checkpoint.schema.yaml new file mode 100644 index 0000000..74bc3d4 --- /dev/null +++ b/schemas/managed-checkpoint.schema.yaml @@ -0,0 +1,2149 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.rustscript.local/managed-checkpoint.schema.yaml", + "title": "managed-checkpoint current typed contract", + "oneOf": [ + { + "$ref": "#/$defs/ManagedCheckpointCreateRequest" + }, + { + "$ref": "#/$defs/ManagedCheckpointReceipt" + }, + { + "$ref": "#/$defs/ManagedCheckpointCommitInput" + }, + { + "$ref": "#/$defs/ManagedCheckpointLookup" + }, + { + "$ref": "#/$defs/ManagedCheckpointContext" + }, + { + "$ref": "#/$defs/ManagedSavedCheckpointReceipt" + }, + { + "$ref": "#/$defs/ManagedPublishRequest" + }, + { + "$ref": "#/$defs/ManagedPublishReceipt" + }, + { + "$ref": "#/$defs/ManagedWorkspacePublication" + } + ], + "$defs": { + "CanonicalToolResultJson": { + "type": "object", + "additionalProperties": false, + "required": [ + "json" + ], + "properties": { + "json": { + "type": "string", + "maxLength": 1048576 + } + } + }, + "DirectoryIdentity": { + "type": "object", + "additionalProperties": false, + "required": [ + "device_decimal", + "inode_decimal" + ], + "properties": { + "device_decimal": { + "type": "string", + "maxLength": 256 + }, + "inode_decimal": { + "type": "string", + "maxLength": 256 + } + } + }, + "ManagedApprovalBasis": { + "type": "string", + "enum": [ + "read_without_attestation", + "guardian_attested" + ] + }, + "ManagedArgumentIdentity": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "canonical_arguments" + ], + "properties": { + "kind": { + "const": "inline" + }, + "canonical_arguments": { + "type": "string", + "maxLength": 256 + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "details" + ], + "properties": { + "kind": { + "const": "object" + }, + "details": { + "$ref": "#/$defs/ManagedObjectRef" + } + } + } + ] + }, + "ManagedCallIdentity": { + "type": "object", + "additionalProperties": false, + "required": [ + "owner", + "call_id", + "tool_name", + "argument_identity", + "code", + "workspace", + "epoch", + "expected_head", + "expected_head_version", + "original_deadline_ms", + "authorization_generation", + "approved_risk", + "approval_basis" + ], + "properties": { + "owner": { + "$ref": "#/$defs/ManagedOwnerScope" + }, + "call_id": { + "type": "string", + "maxLength": 256 + }, + "tool_name": { + "type": "string", + "maxLength": 256 + }, + "argument_identity": { + "$ref": "#/$defs/ManagedArgumentIdentity" + }, + "code": { + "$ref": "#/$defs/ManagedCodeIdentity" + }, + "workspace": { + "$ref": "#/$defs/ManagedWorkspaceIdentity" + }, + "epoch": { + "type": "integer", + "minimum": 1, + "maximum": 9223372036854775807 + }, + "expected_head": { + "type": "string", + "maxLength": 256 + }, + "expected_head_version": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + }, + "original_deadline_ms": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + }, + "authorization_generation": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + }, + "approved_risk": { + "type": "string", + "maxLength": 256 + }, + "approval_basis": { + "$ref": "#/$defs/ManagedApprovalBasis" + } + } + }, + "ManagedCheckpointArtifactRef": { + "type": "object", + "additionalProperties": false, + "required": [ + "artifact_id", + "object" + ], + "properties": { + "artifact_id": { + "type": "string", + "maxLength": 256 + }, + "object": { + "$ref": "#/$defs/ManagedObjectRef" + } + } + }, + "ManagedCheckpointCommitInput": { + "type": "object", + "additionalProperties": false, + "required": [ + "request", + "original_request_proof", + "captured_source", + "receipt", + "messages", + "execution_config", + "compaction" + ], + "properties": { + "request": { + "$ref": "#/$defs/ManagedCheckpointCreateRequest" + }, + "original_request_proof": { + "type": "string", + "maxLength": 1048576 + }, + "captured_source": { + "$ref": "#/$defs/ManagedSourceCas" + }, + "receipt": { + "$ref": "#/$defs/ManagedCheckpointReceipt" + }, + "messages": { + "type": "array", + "maxItems": 128, + "items": { + "$ref": "#/$defs/ManagedFrozenMessage" + } + }, + "compaction": { + "anyOf": [ + { + "$ref": "#/$defs/ManagedFrozenCompaction" + }, + { + "type": "null" + } + ] + }, + "execution_config": { + "$ref": "#/$defs/ManagedFrozenExecutionConfig" + } + } + }, + "ManagedCheckpointContext": { + "type": "object", + "additionalProperties": false, + "required": [ + "workspace", + "head", + "session_id", + "session_generation", + "last_ordinal", + "messages", + "effects", + "external", + "unsettled_runs", + "source_runs", + "pending_compactions", + "compaction" + ], + "properties": { + "workspace": { + "$ref": "#/$defs/ManagedWorkspaceRecord" + }, + "head": { + "$ref": "#/$defs/ManagedWorkspaceHead" + }, + "session_id": { + "type": "string", + "maxLength": 256 + }, + "session_generation": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + }, + "last_ordinal": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + }, + "messages": { + "type": "array", + "maxItems": 129, + "items": { + "$ref": "#/$defs/ManagedFrozenMessage" + } + }, + "effects": { + "type": "array", + "maxItems": 129, + "items": { + "$ref": "#/$defs/ManagedEffectRecord" + } + }, + "external": { + "type": "array", + "maxItems": 33, + "items": { + "$ref": "#/$defs/ManagedExternalHighWater" + } + }, + "unsettled_runs": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + }, + "source_runs": { + "type": "array", + "maxItems": 129, + "items": { + "$ref": "#/$defs/ManagedSourceRunRef" + } + }, + "pending_compactions": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + }, + "compaction": { + "anyOf": [ + { + "$ref": "#/$defs/ManagedFrozenCompaction" + }, + { + "type": "null" + } + ] + } + } + }, + "ManagedCheckpointCreateRequest": { + "type": "object", + "additionalProperties": false, + "required": [ + "source_run_id", + "workspace_id", + "source_branch_id", + "expected_epoch", + "request_id", + "mode" + ], + "properties": { + "source_run_id": { + "type": "string", + "maxLength": 256, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" + }, + "workspace_id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "source_branch_id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "expected_epoch": { + "type": "integer", + "minimum": 1, + "maximum": 9223372036854775807 + }, + "request_id": { + "type": "string", + "maxLength": 256, + "minLength": 1, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" + }, + "mode": { + "$ref": "#/$defs/ManagedCheckpointMode" + } + } + }, + "ManagedCheckpointGraphRef": { + "type": "object", + "additionalProperties": false, + "required": [ + "graph_id", + "checkpoint_graph_ref_id", + "captured_activation_generation", + "package_id", + "artifact_id", + "export", + "registry_identity", + "effect_contract_version", + "harness_selection" + ], + "properties": { + "graph_id": { + "type": "string", + "maxLength": 256, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" + }, + "checkpoint_graph_ref_id": { + "type": "string", + "maxLength": 256, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" + }, + "captured_activation_generation": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + }, + "package_id": { + "type": "string", + "maxLength": 256 + }, + "artifact_id": { + "type": "string", + "maxLength": 256 + }, + "export": { + "type": "string", + "maxLength": 256 + }, + "registry_identity": { + "type": "string", + "maxLength": 256 + }, + "effect_contract_version": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "harness_selection": { + "$ref": "https://schemas.rustscript.local/v1/plugin-graph-lock.schema.yaml#/properties/harness_selection" + } + } + }, + "ManagedCheckpointLookup": { + "type": "object", + "additionalProperties": false, + "required": [ + "checkpoint", + "messages", + "execution_config", + "compaction" + ], + "properties": { + "checkpoint": { + "$ref": "#/$defs/ManagedCheckpointRef" + }, + "messages": { + "type": "array", + "maxItems": 128, + "items": { + "$ref": "#/$defs/ManagedFrozenMessage" + } + }, + "compaction": { + "anyOf": [ + { + "$ref": "#/$defs/ManagedFrozenCompaction" + }, + { + "type": "null" + } + ] + }, + "execution_config": { + "$ref": "#/$defs/ManagedFrozenExecutionConfig" + } + } + }, + "ManagedCheckpointMode": { + "type": "string", + "enum": [ + "at_closed_batch" + ] + }, + "ManagedCheckpointPublicConfig": { + "type": "object", + "additionalProperties": false, + "required": [ + "model", + "registry_identity", + "tool_schemas_ref", + "policy_revision", + "publication_policy" + ], + "properties": { + "model": { + "type": "string", + "maxLength": 256 + }, + "provider": { + "anyOf": [ + { + "type": "string", + "maxLength": 256 + }, + { + "type": "null" + } + ] + }, + "system_instructions": { + "anyOf": [ + { + "type": "string", + "maxLength": 65536 + }, + { + "type": "null" + } + ] + }, + "registry_identity": { + "type": "string", + "maxLength": 256 + }, + "tool_schemas_ref": { + "type": "string", + "maxLength": 256 + }, + "policy_revision": { + "type": "string", + "maxLength": 256 + }, + "publication_policy": { + "$ref": "#/$defs/ManagedCheckpointPublication" + } + } + }, + "ManagedCheckpointPublication": { + "type": "string", + "enum": [ + "managed_only" + ] + }, + "ManagedCheckpointReceipt": { + "type": "object", + "additionalProperties": false, + "required": [ + "request_id", + "checkpoint", + "committed_at_ms", + "replayed" + ], + "properties": { + "request_id": { + "type": "string", + "maxLength": 256, + "minLength": 1, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" + }, + "checkpoint": { + "$ref": "#/$defs/ManagedCheckpointRef" + }, + "committed_at_ms": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + }, + "replayed": { + "type": "boolean" + } + } + }, + "ManagedCheckpointRef": { + "type": "object", + "additionalProperties": false, + "required": [ + "checkpoint_id", + "workspace_id", + "parent_run_id", + "source_branch_id", + "source_epoch", + "frozen_root", + "history", + "graph", + "public_config", + "plugin_state", + "external_boundary" + ], + "properties": { + "checkpoint_id": { + "type": "string", + "maxLength": 256, + "minLength": 1, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" + }, + "workspace_id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "parent_run_id": { + "type": "string", + "maxLength": 256, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" + }, + "source_branch_id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "source_epoch": { + "type": "integer", + "minimum": 1, + "maximum": 9223372036854775807 + }, + "frozen_root": { + "$ref": "#/$defs/ManagedObjectRef" + }, + "history": { + "$ref": "#/$defs/ManagedFrozenHistoryRef" + }, + "graph": { + "$ref": "#/$defs/ManagedCheckpointGraphRef" + }, + "public_config": { + "$ref": "#/$defs/ManagedPublicConfigRef" + }, + "plugin_state": { + "$ref": "#/$defs/ManagedCheckpointStateCoverage" + }, + "external_boundary": { + "$ref": "#/$defs/ManagedExternalBoundary" + } + } + }, + "ManagedCheckpointStateCoverage": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "graph_id", + "package_id", + "artifact_id", + "manifest_state_absent", + "state_effects_absent", + "roots" + ], + "properties": { + "kind": { + "const": "stateless_builtin" + }, + "graph_id": { + "type": "string", + "maxLength": 256 + }, + "package_id": { + "type": "string", + "maxLength": 256 + }, + "artifact_id": { + "type": "string", + "maxLength": 256 + }, + "manifest_state_absent": { + "const": true + }, + "state_effects_absent": { + "const": true + }, + "roots": { + "type": "array", + "maxItems": 0, + "items": { + "$ref": "#/$defs/ManagedPluginStateRootRef" + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "graph_id", + "package_id", + "artifact_id", + "manifest_state_absent", + "state_effects_absent", + "roots", + "policy", + "policy_host_imports_absent" + ], + "properties": { + "kind": { + "const": "stateless_builtin_with_pure_policy" + }, + "graph_id": { + "type": "string", + "maxLength": 256 + }, + "package_id": { + "type": "string", + "maxLength": 256 + }, + "artifact_id": { + "type": "string", + "maxLength": 256 + }, + "manifest_state_absent": { + "const": true + }, + "state_effects_absent": { + "const": true + }, + "roots": { + "type": "array", + "maxItems": 0, + "items": { + "$ref": "#/$defs/ManagedPluginStateRootRef" + } + }, + "policy": { + "$ref": "https://schemas.rustscript.local/v1/plugin-graph-lock.schema.yaml#/properties/harness_selection/oneOf/2/properties/policy" + }, + "policy_host_imports_absent": { + "const": true + } + } + } + ] + }, + "ManagedClosedOutputRef": { + "type": "object", + "additionalProperties": false, + "required": [ + "run_id", + "call_id", + "parent_message_id", + "output_message_id", + "receipt_id", + "result" + ], + "properties": { + "run_id": { + "type": "string", + "maxLength": 256 + }, + "call_id": { + "type": "string", + "maxLength": 256 + }, + "parent_message_id": { + "type": "string", + "maxLength": 256 + }, + "output_message_id": { + "type": "string", + "maxLength": 256 + }, + "receipt_id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "result": { + "$ref": "#/$defs/ManagedObjectRef" + } + } + }, + "ManagedCodeIdentity": { + "type": "object", + "additionalProperties": false, + "required": [ + "graph_id", + "activation_generation", + "graph_ref_id", + "package_id", + "artifact_id", + "export", + "registry_identity", + "effect_contract_version" + ], + "properties": { + "graph_id": { + "type": "string", + "maxLength": 256 + }, + "activation_generation": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + }, + "graph_ref_id": { + "type": "string", + "maxLength": 256 + }, + "package_id": { + "type": "string", + "maxLength": 256 + }, + "artifact_id": { + "type": "string", + "maxLength": 256 + }, + "export": { + "type": "string", + "maxLength": 256 + }, + "registry_identity": { + "type": "string", + "maxLength": 256 + }, + "effect_contract_version": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + } + } + }, + "ManagedCommitOutcome": { + "type": "object", + "additionalProperties": false, + "required": [ + "disposition", + "publication", + "canonical_result_ref", + "output_event", + "terminal_event", + "message", + "replayed" + ], + "properties": { + "disposition": { + "$ref": "#/$defs/ManagedPublicationStatus" + }, + "publication": { + "$ref": "#/$defs/ManagedPublicationRef" + }, + "canonical_result_ref": { + "$ref": "#/$defs/ManagedObjectRef" + }, + "output_event": { + "$ref": "#/$defs/ManagedEventRef" + }, + "terminal_event": { + "$ref": "#/$defs/ManagedEventRef" + }, + "message": { + "$ref": "#/$defs/ManagedMessageRef" + }, + "replayed": { + "type": "boolean" + } + } + }, + "ManagedEffectRecord": { + "type": "object", + "additionalProperties": false, + "required": [ + "format", + "effect_id", + "schemaVersion", + "identity", + "state" + ], + "properties": { + "format": { + "type": "string", + "maxLength": 256 + }, + "effect_id": { + "type": "string", + "maxLength": 256 + }, + "schemaVersion": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "identity": { + "$ref": "#/$defs/ManagedCallIdentity" + }, + "state": { + "$ref": "#/$defs/ManagedPublicationStatus" + }, + "prepared": { + "anyOf": [ + { + "$ref": "#/$defs/ManagedPreparedRecord" + }, + { + "type": "null" + } + ] + }, + "outcome": { + "anyOf": [ + { + "$ref": "#/$defs/ManagedCommitOutcome" + }, + { + "type": "null" + } + ] + }, + "recovery": { + "anyOf": [ + { + "$ref": "#/$defs/ManagedRecoveryObservation" + }, + { + "type": "null" + } + ] + } + } + }, + "ManagedEventRef": { + "type": "object", + "additionalProperties": false, + "required": [ + "event_id", + "seq" + ], + "properties": { + "event_id": { + "type": "string", + "maxLength": 256 + }, + "seq": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + } + } + }, + "ManagedExternalBoundary": { + "type": "object", + "additionalProperties": false, + "required": [ + "high_water", + "not_reverted", + "artifact_refs" + ], + "properties": { + "high_water": { + "type": "array", + "maxItems": 32, + "items": { + "$ref": "#/$defs/ManagedExternalHighWater" + } + }, + "not_reverted": { + "type": "array", + "maxItems": 32, + "items": { + "$ref": "#/$defs/ManagedExternalNotice" + } + }, + "artifact_refs": { + "type": "array", + "maxItems": 0, + "items": { + "$ref": "#/$defs/ManagedCheckpointArtifactRef" + } + } + } + }, + "ManagedExternalEffects": { + "type": "string", + "enum": [ + "none_claimed", + "outside_tree_not_reverted" + ] + }, + "ManagedExternalHighWater": { + "type": "object", + "additionalProperties": false, + "required": [ + "run_id", + "event_seq", + "provider_request_count", + "input_tokens", + "output_tokens" + ], + "properties": { + "run_id": { + "type": "string", + "maxLength": 256 + }, + "event_seq": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + }, + "provider_request_count": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + }, + "input_tokens": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + }, + "output_tokens": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + } + } + }, + "ManagedExternalNotice": { + "type": "string", + "enum": [ + "provider_usage_not_reverted", + "delivered_content_not_reverted" + ] + }, + "ManagedFrozenCompaction": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "generation", + "source_start_ordinal", + "source_end_ordinal", + "retained_tail_ordinal", + "summary_json", + "token_estimate", + "model", + "created_at_ms", + "completed_at_ms" + ], + "properties": { + "id": { + "type": "string", + "maxLength": 256 + }, + "generation": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + }, + "source_start_ordinal": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + }, + "source_end_ordinal": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + }, + "retained_tail_ordinal": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + }, + "summary_json": { + "type": "string", + "maxLength": 1048576 + }, + "token_estimate": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + }, + "model": { + "type": "string", + "maxLength": 256 + }, + "created_at_ms": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + }, + "completed_at_ms": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + } + } + }, + "ManagedFrozenExecutionConfig": { + "type": "object", + "additionalProperties": false, + "required": [ + "provider_profile", + "provider_options" + ], + "properties": { + "provider_profile": { + "type": "string", + "maxLength": 256 + }, + "provider_options": { + "$ref": "#/$defs/ManagedFrozenProviderOptions" + } + } + }, + "ManagedFrozenHistoryRef": { + "type": "object", + "additionalProperties": false, + "required": [ + "history_root_id", + "source_session_id", + "session_generation", + "last_message_id", + "last_ordinal", + "last_closed_batch_id", + "message_count", + "byte_count", + "closed_output_refs" + ], + "properties": { + "history_root_id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "source_session_id": { + "type": "string", + "maxLength": 256 + }, + "session_generation": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + }, + "last_message_id": { + "type": "string", + "maxLength": 256 + }, + "last_ordinal": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + }, + "last_closed_batch_id": { + "type": "string", + "maxLength": 256 + }, + "message_count": { + "type": "integer", + "minimum": 0, + "maximum": 128 + }, + "byte_count": { + "type": "integer", + "minimum": 0, + "maximum": 524288 + }, + "closed_output_refs": { + "type": "array", + "maxItems": 128, + "items": { + "$ref": "#/$defs/ManagedClosedOutputRef" + } + } + } + }, + "ManagedFrozenMessage": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "ordinal", + "role", + "content_json", + "metadata_json", + "compacted", + "created_at_ms" + ], + "properties": { + "id": { + "type": "string", + "maxLength": 256 + }, + "ordinal": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + }, + "role": { + "type": "string", + "maxLength": 256 + }, + "content_json": { + "type": "string", + "maxLength": 1048576 + }, + "metadata_json": { + "type": "string", + "maxLength": 1048576 + }, + "run_id": { + "anyOf": [ + { + "type": "string", + "maxLength": 256 + }, + { + "type": "null" + } + ] + }, + "name": { + "anyOf": [ + { + "type": "string", + "maxLength": 256 + }, + { + "type": "null" + } + ] + }, + "tool_call_id": { + "anyOf": [ + { + "type": "string", + "maxLength": 256 + }, + { + "type": "null" + } + ] + }, + "parent_message_id": { + "anyOf": [ + { + "type": "string", + "maxLength": 256 + }, + { + "type": "null" + } + ] + }, + "finish_reason": { + "anyOf": [ + { + "type": "string", + "maxLength": 256 + }, + { + "type": "null" + } + ] + }, + "token_estimate": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + }, + { + "type": "null" + } + ] + }, + "compacted": { + "type": "boolean" + }, + "created_at_ms": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + } + } + }, + "ManagedFrozenProviderOptions": { + "type": "object", + "additionalProperties": false, + "required": [], + "properties": { + "profile": { + "anyOf": [ + { + "type": "string", + "maxLength": 4096 + }, + { + "type": "null" + } + ] + }, + "protocol": { + "anyOf": [ + { + "type": "string", + "maxLength": 4096 + }, + { + "type": "null" + } + ] + }, + "base_url": { + "anyOf": [ + { + "type": "string", + "maxLength": 4096 + }, + { + "type": "null" + } + ] + }, + "credential_id": { + "anyOf": [ + { + "type": "string", + "maxLength": 4096 + }, + { + "type": "null" + } + ] + }, + "reasoning_effort": { + "anyOf": [ + { + "type": "string", + "maxLength": 4096 + }, + { + "type": "null" + } + ] + }, + "temperature": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "top_p": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "max_output_tokens": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + }, + { + "type": "null" + } + ] + }, + "stream": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + } + }, + "ManagedMessageRef": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "ordinal" + ], + "properties": { + "id": { + "type": "string", + "maxLength": 256 + }, + "ordinal": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + } + } + }, + "ManagedObjectKind": { + "type": "string", + "enum": [ + "tree", + "canonical_result", + "intent_details" + ] + }, + "ManagedObjectRef": { + "type": "object", + "additionalProperties": false, + "required": [ + "store_id", + "object_id", + "object_kind", + "byte_count", + "entry_count", + "object_format" + ], + "properties": { + "store_id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "object_id": { + "type": "string", + "maxLength": 256 + }, + "object_kind": { + "$ref": "#/$defs/ManagedObjectKind" + }, + "byte_count": { + "type": "string", + "maxLength": 256, + "pattern": "^[0-9]+$" + }, + "entry_count": { + "type": "string", + "maxLength": 256, + "pattern": "^[0-9]+$" + }, + "object_format": { + "type": "string", + "maxLength": 256 + } + } + }, + "ManagedOperationClass": { + "type": "string", + "enum": [ + "read_only", + "tree_write" + ] + }, + "ManagedOwnerScope": { + "type": "object", + "additionalProperties": false, + "required": [ + "profile", + "session_id", + "run_id" + ], + "properties": { + "profile": { + "type": "string", + "maxLength": 256 + }, + "session_id": { + "type": "string", + "maxLength": 256 + }, + "run_id": { + "type": "string", + "maxLength": 256 + } + } + }, + "ManagedPluginStateRootRef": { + "type": "object", + "additionalProperties": false, + "required": [ + "package_id", + "artifact_id", + "scope", + "schema", + "revision", + "immutable_root_id" + ], + "properties": { + "package_id": { + "type": "string", + "maxLength": 256 + }, + "artifact_id": { + "type": "string", + "maxLength": 256 + }, + "scope": { + "type": "string", + "enum": [ + "run", + "session" + ] + }, + "schema": { + "type": "string", + "maxLength": 256 + }, + "revision": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + }, + "immutable_root_id": { + "type": "string", + "maxLength": 256 + } + } + }, + "ManagedPreparedRecord": { + "type": "object", + "additionalProperties": false, + "required": [ + "format", + "schemaVersion", + "effect_id", + "identity", + "parent_root", + "canonical_result", + "writer_state", + "operation_class", + "prepared_at_ms", + "observation_ok", + "external_effects" + ], + "properties": { + "format": { + "type": "string", + "maxLength": 256 + }, + "schemaVersion": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "effect_id": { + "type": "string", + "maxLength": 256 + }, + "identity": { + "$ref": "#/$defs/ManagedCallIdentity" + }, + "parent_root": { + "$ref": "#/$defs/ManagedObjectRef" + }, + "candidate_root": { + "anyOf": [ + { + "$ref": "#/$defs/ManagedObjectRef" + }, + { + "type": "null" + } + ] + }, + "canonical_result": { + "$ref": "#/$defs/ManagedObjectRef" + }, + "conflict_result": { + "anyOf": [ + { + "$ref": "#/$defs/ManagedObjectRef" + }, + { + "type": "null" + } + ] + }, + "writer_state": { + "$ref": "#/$defs/ManagedWriterState" + }, + "operation_class": { + "$ref": "#/$defs/ManagedOperationClass" + }, + "prepared_at_ms": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + }, + "observation_ok": { + "type": "boolean" + }, + "external_effects": { + "$ref": "#/$defs/ManagedExternalEffects" + } + } + }, + "ManagedPublicConfigRef": { + "type": "object", + "additionalProperties": false, + "required": [ + "config_ref_id", + "config" + ], + "properties": { + "config_ref_id": { + "type": "string", + "maxLength": 256, + "minLength": 1, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" + }, + "config": { + "$ref": "#/$defs/ManagedCheckpointPublicConfig" + } + } + }, + "ManagedPublicationRef": { + "type": "object", + "additionalProperties": false, + "required": [ + "format", + "effect_id", + "workspace_id", + "branch_id", + "epoch", + "base_head", + "base_head_version", + "observed_head", + "observed_head_version", + "status" + ], + "properties": { + "format": { + "type": "string", + "maxLength": 256 + }, + "effect_id": { + "type": "string", + "maxLength": 256 + }, + "workspace_id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "branch_id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "epoch": { + "type": "integer", + "minimum": 1, + "maximum": 9223372036854775807 + }, + "base_head": { + "type": "string", + "maxLength": 256 + }, + "base_head_version": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + }, + "candidate_head": { + "anyOf": [ + { + "type": "string", + "maxLength": 256 + }, + { + "type": "null" + } + ] + }, + "observed_head": { + "type": "string", + "maxLength": 256 + }, + "observed_head_version": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + }, + "status": { + "$ref": "#/$defs/ManagedPublicationStatus" + } + } + }, + "ManagedPublicationStatus": { + "type": "string", + "enum": [ + "read_only", + "committed", + "staged", + "conflict", + "not_published", + "indeterminate" + ] + }, + "ManagedRecoveryObservation": { + "type": "object", + "additionalProperties": false, + "required": [ + "code", + "publication", + "canonical_failure_json", + "output_event", + "terminal_event", + "message" + ], + "properties": { + "code": { + "type": "string", + "maxLength": 256 + }, + "publication": { + "$ref": "#/$defs/ManagedPublicationRef" + }, + "canonical_failure_json": { + "$ref": "#/$defs/CanonicalToolResultJson" + }, + "output_event": { + "$ref": "#/$defs/ManagedEventRef" + }, + "terminal_event": { + "$ref": "#/$defs/ManagedEventRef" + }, + "message": { + "$ref": "#/$defs/ManagedMessageRef" + } + } + }, + "ManagedSavedCheckpointReceipt": { + "type": "object", + "additionalProperties": false, + "required": [ + "original_request_proof", + "receipt" + ], + "properties": { + "original_request_proof": { + "type": "string", + "maxLength": 1048576 + }, + "receipt": { + "$ref": "#/$defs/ManagedCheckpointReceipt" + } + } + }, + "ManagedSourceCas": { + "type": "object", + "additionalProperties": false, + "required": [ + "store_id", + "workspace_id", + "branch_id", + "epoch", + "head_object_id", + "head_version" + ], + "properties": { + "store_id": { + "type": "string", + "maxLength": 256, + "minLength": 1, + "pattern": "^store-[0-9a-f]{32}$" + }, + "workspace_id": { + "type": "string", + "maxLength": 256, + "minLength": 1, + "pattern": "^ws-[0-9a-f]{32}$" + }, + "branch_id": { + "type": "string", + "maxLength": 256, + "minLength": 1, + "pattern": "^br-[0-9a-f]{32}$" + }, + "epoch": { + "type": "integer", + "minimum": 1, + "maximum": 9223372036854775807 + }, + "head_object_id": { + "type": "string", + "maxLength": 256, + "minLength": 1, + "pattern": "^tree-[0-9a-f]{32}$" + }, + "head_version": { + "type": "integer", + "minimum": 1, + "maximum": 9223372036854775807 + } + } + }, + "ManagedSourceRunRef": { + "type": "object", + "additionalProperties": false, + "required": [ + "run_id", + "session_id", + "status", + "original_deadline_ms" + ], + "properties": { + "run_id": { + "type": "string", + "maxLength": 256 + }, + "session_id": { + "type": "string", + "maxLength": 256 + }, + "status": { + "type": "string", + "maxLength": 256 + }, + "original_deadline_ms": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + } + } + }, + "ManagedWorkspaceHead": { + "type": "object", + "additionalProperties": false, + "required": [ + "workspace_id", + "branch_id", + "epoch", + "head_object_id", + "head_version", + "store_id", + "schema_version", + "process_lineage_tainted" + ], + "properties": { + "workspace_id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "branch_id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "epoch": { + "type": "integer", + "minimum": 1, + "maximum": 9223372036854775807 + }, + "head_object_id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "head_version": { + "type": "integer", + "minimum": 1, + "maximum": 9223372036854775807 + }, + "store_id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "schema_version": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "process_lineage_tainted": { + "type": "boolean" + } + } + }, + "ManagedWorkspaceIdentity": { + "type": "object", + "additionalProperties": false, + "required": [ + "store_id", + "workspace_id", + "branch_id", + "source_identity" + ], + "properties": { + "store_id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "workspace_id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "branch_id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "source_identity": { + "$ref": "#/$defs/DirectoryIdentity" + } + } + }, + "ManagedWorkspaceRecord": { + "type": "object", + "additionalProperties": false, + "required": [ + "format", + "schemaVersion", + "identity", + "profile", + "seed_root", + "max_entries", + "max_bytes", + "max_depth" + ], + "properties": { + "format": { + "type": "string", + "maxLength": 256 + }, + "schemaVersion": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "identity": { + "$ref": "#/$defs/ManagedWorkspaceIdentity" + }, + "profile": { + "type": "string", + "maxLength": 256 + }, + "seed_root": { + "$ref": "#/$defs/ManagedObjectRef" + }, + "max_entries": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + }, + "max_bytes": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + }, + "max_depth": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + } + } + }, + "ManagedWriterState": { + "type": "string", + "enum": [ + "settled", + "no_process" + ] + }, + "ManagedPublishRequest": { + "type": "object", + "additionalProperties": false, + "required": [ + "workspace_id", + "branch_id", + "expected_head", + "request_id" + ], + "properties": { + "workspace_id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "branch_id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "expected_head": { + "$ref": "#/$defs/ManagedWorkspaceHead" + }, + "request_id": { + "type": "string", + "maxLength": 256, + "minLength": 1, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" + } + } + }, + "ManagedPublishReceipt": { + "type": "object", + "additionalProperties": false, + "required": [ + "status", + "workspace_id", + "profile", + "branch_id", + "epoch", + "head_object_id", + "head_version", + "request_id", + "published_at_ms" + ], + "properties": { + "status": { + "const": "committed" + }, + "workspace_id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "profile": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "branch_id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "epoch": { + "type": "integer", + "minimum": 1, + "maximum": 9223372036854775807 + }, + "head_object_id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "head_version": { + "type": "integer", + "minimum": 1, + "maximum": 9223372036854775807 + }, + "previous_published_head": { + "anyOf": [ + { + "type": "string", + "maxLength": 256 + }, + { + "type": "null" + } + ] + }, + "request_id": { + "type": "string", + "maxLength": 256, + "minLength": 1, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" + }, + "published_at_ms": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + } + } + }, + "ManagedWorkspacePublication": { + "type": "object", + "additionalProperties": false, + "required": [ + "workspace_id", + "profile", + "branch_id", + "epoch", + "head_object_id", + "head_version", + "request_id", + "published_at_ms", + "revision", + "store_id", + "schema_version" + ], + "properties": { + "workspace_id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "profile": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "branch_id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "epoch": { + "type": "integer", + "minimum": 1, + "maximum": 9223372036854775807 + }, + "head_object_id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "head_version": { + "type": "integer", + "minimum": 1, + "maximum": 9223372036854775807 + }, + "previous_published_head": { + "anyOf": [ + { + "type": "string", + "maxLength": 256 + }, + { + "type": "null" + } + ] + }, + "request_id": { + "type": "string", + "maxLength": 256, + "minLength": 1, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" + }, + "published_at_ms": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + }, + "revision": { + "type": "integer", + "minimum": 1, + "maximum": 9223372036854775807 + }, + "store_id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "schema_version": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + } + } + } + } +} diff --git a/schemas/managed-restore.schema.yaml b/schemas/managed-restore.schema.yaml new file mode 100644 index 0000000..55b16d8 --- /dev/null +++ b/schemas/managed-restore.schema.yaml @@ -0,0 +1,502 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://schemas.rustscript.local/managed-restore.schema.yaml", + "title": "managed-restore current typed contract", + "oneOf": [ + { + "$ref": "#/$defs/ManagedRestoreAndAdmitRequest" + }, + { + "$ref": "#/$defs/ManagedRestoreAndAdmitResult" + }, + { + "$ref": "#/$defs/ManagedRestoreExecutionEnvelope" + }, + { + "$ref": "#/$defs/ManagedSavedRestoreReceipt" + }, + { + "$ref": "#/$defs/ManagedMaintenanceFailure" + }, + { + "$ref": "#/$defs/ManagedBranchPublication" + }, + { + "$ref": "#/$defs/ManagedRollbackFacts" + } + ], + "$defs": { + "ManagedAdmissionPin": { + "type": "object", + "additionalProperties": false, + "required": [ + "graph_id", + "ref_id", + "generation", + "run_id", + "session_id", + "registry_identity", + "workspace", + "profile", + "pinned", + "harness_selection" + ], + "properties": { + "graph_id": { + "type": "string", + "maxLength": 256 + }, + "ref_id": { + "type": "string", + "maxLength": 256 + }, + "generation": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + }, + "run_id": { + "type": "string", + "maxLength": 256 + }, + "session_id": { + "type": "string", + "maxLength": 256 + }, + "registry_identity": { + "type": "string", + "maxLength": 256 + }, + "workspace": { + "type": "string", + "maxLength": 256 + }, + "profile": { + "type": "string", + "maxLength": 256 + }, + "pinned": { + "type": "boolean" + }, + "harness_selection": { + "$ref": "https://schemas.rustscript.local/v1/plugin-graph-lock.schema.yaml#/properties/harness_selection" + } + } + }, + "ManagedBranchPublication": { + "type": "object", + "additionalProperties": false, + "required": [ + "source", + "current", + "history_count", + "admission_pin", + "graph" + ], + "properties": { + "source": { + "$ref": "#/$defs/ManagedSourceCas" + }, + "current": { + "$ref": "#/$defs/ManagedSourceCas" + }, + "history_count": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + }, + "admission_pin": { + "$ref": "#/$defs/ManagedAdmissionPin" + }, + "graph": { + "$ref": "https://schemas.rustscript.local/managed-checkpoint.schema.yaml#/$defs/ManagedCheckpointGraphRef" + } + } + }, + "ManagedCheckpointGraphRef": { + "type": "object", + "additionalProperties": false, + "required": [ + "graph_id", + "checkpoint_graph_ref_id", + "captured_activation_generation", + "package_id", + "artifact_id", + "export", + "registry_identity", + "effect_contract_version", + "harness_selection" + ], + "properties": { + "graph_id": { + "type": "string", + "maxLength": 256, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" + }, + "checkpoint_graph_ref_id": { + "type": "string", + "maxLength": 256, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" + }, + "captured_activation_generation": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + }, + "package_id": { + "type": "string", + "maxLength": 256 + }, + "artifact_id": { + "type": "string", + "maxLength": 256 + }, + "export": { + "type": "string", + "maxLength": 256 + }, + "registry_identity": { + "type": "string", + "maxLength": 256 + }, + "effect_contract_version": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "harness_selection": { + "$ref": "https://schemas.rustscript.local/v1/plugin-graph-lock.schema.yaml#/properties/harness_selection" + } + } + }, + "ManagedCheckpointPublication": { + "type": "string", + "enum": [ + "managed_only" + ] + }, + "ManagedExternalNotice": { + "type": "string", + "enum": [ + "provider_usage_not_reverted", + "delivered_content_not_reverted" + ] + }, + "ManagedMaintenanceFailure": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "disposition", + "code" + ], + "properties": { + "disposition": { + "const": "rejected" + }, + "code": { + "type": "string", + "maxLength": 256 + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "disposition", + "request_id", + "receipt_lookup_id" + ], + "properties": { + "disposition": { + "const": "submitted_unknown" + }, + "request_id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "receipt_lookup_id": { + "type": "string", + "maxLength": 256 + } + } + } + ] + }, + "ManagedRestoreAndAdmitRequest": { + "type": "object", + "additionalProperties": false, + "required": [ + "workspace_id", + "source_branch_id", + "checkpoint_id", + "expected_source", + "request_id", + "input", + "cancel_active_source", + "graph_selection" + ], + "properties": { + "workspace_id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "source_branch_id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "checkpoint_id": { + "type": "string", + "maxLength": 256, + "minLength": 1, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" + }, + "expected_source": { + "$ref": "#/$defs/ManagedSourceCas" + }, + "request_id": { + "type": "string", + "maxLength": 256, + "minLength": 1, + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" + }, + "input": { + "$ref": "#/$defs/ManagedRestoreRunInput" + }, + "cancel_active_source": { + "const": true + }, + "graph_selection": { + "$ref": "#/$defs/ManagedRestoreGraphSelection" + } + } + }, + "ManagedRestoreAndAdmitResult": { + "type": "object", + "additionalProperties": false, + "required": [ + "disposition", + "receipt_id", + "checkpoint_id", + "source_before", + "source_after", + "new_branch", + "new_session_id", + "new_run_id", + "new_run_deadline_at_ms", + "graph", + "publication", + "external_effects_not_reverted", + "replayed" + ], + "properties": { + "disposition": { + "$ref": "#/$defs/ManagedRestoreDisposition" + }, + "receipt_id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "checkpoint_id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "source_before": { + "$ref": "#/$defs/ManagedSourceCas" + }, + "source_after": { + "$ref": "#/$defs/ManagedSourceCas" + }, + "new_branch": { + "$ref": "#/$defs/ManagedSourceCas" + }, + "new_session_id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "new_run_id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "new_run_deadline_at_ms": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + }, + "graph": { + "$ref": "#/$defs/ManagedCheckpointGraphRef" + }, + "publication": { + "$ref": "#/$defs/ManagedCheckpointPublication" + }, + "external_effects_not_reverted": { + "type": "array", + "maxItems": 32, + "items": { + "$ref": "#/$defs/ManagedExternalNotice" + } + }, + "replayed": { + "type": "boolean" + } + } + }, + "ManagedRestoreDisposition": { + "type": "string", + "enum": [ + "restored_and_admitted" + ] + }, + "ManagedRestoreExecutionEnvelope": { + "type": "object", + "additionalProperties": false, + "required": [ + "admission_pin", + "request", + "original_request_proof", + "result", + "history_root_id" + ], + "properties": { + "admission_pin": { + "$ref": "#/$defs/ManagedAdmissionPin" + }, + "request": { + "$ref": "#/$defs/ManagedRestoreAndAdmitRequest" + }, + "original_request_proof": { + "type": "string", + "maxLength": 1048576 + }, + "result": { + "$ref": "#/$defs/ManagedRestoreAndAdmitResult" + }, + "history_root_id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + } + } + }, + "ManagedRestoreGraphSelection": { + "type": "string", + "enum": [ + "checkpoint_exact" + ] + }, + "ManagedRestoreRunInput": { + "type": "object", + "additionalProperties": false, + "required": [ + "text" + ], + "properties": { + "text": { + "type": "string", + "maxLength": 65536 + } + } + }, + "ManagedRollbackFacts": { + "type": "object", + "additionalProperties": false, + "required": [ + "source", + "current_branch_id", + "target_rows" + ], + "properties": { + "source": { + "$ref": "#/$defs/ManagedSourceCas" + }, + "current_branch_id": { + "type": "string", + "maxLength": 256 + }, + "target_rows": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + } + } + }, + "ManagedSavedRestoreReceipt": { + "type": "object", + "additionalProperties": false, + "required": [ + "original_request_proof", + "envelope", + "checkpoint_graph" + ], + "properties": { + "original_request_proof": { + "type": "string", + "maxLength": 1048576 + }, + "envelope": { + "$ref": "#/$defs/ManagedRestoreExecutionEnvelope" + }, + "checkpoint_graph": { + "$ref": "https://schemas.rustscript.local/managed-checkpoint.schema.yaml#/$defs/ManagedCheckpointGraphRef" + } + } + }, + "ManagedSourceCas": { + "type": "object", + "additionalProperties": false, + "required": [ + "store_id", + "workspace_id", + "branch_id", + "epoch", + "head_object_id", + "head_version" + ], + "properties": { + "store_id": { + "type": "string", + "maxLength": 256, + "minLength": 1, + "pattern": "^store-[0-9a-f]{32}$" + }, + "workspace_id": { + "type": "string", + "maxLength": 256, + "minLength": 1, + "pattern": "^ws-[0-9a-f]{32}$" + }, + "branch_id": { + "type": "string", + "maxLength": 256, + "minLength": 1, + "pattern": "^br-[0-9a-f]{32}$" + }, + "epoch": { + "type": "integer", + "minimum": 1, + "maximum": 9223372036854775807 + }, + "head_object_id": { + "type": "string", + "maxLength": 256, + "minLength": 1, + "pattern": "^tree-[0-9a-f]{32}$" + }, + "head_version": { + "type": "integer", + "minimum": 1, + "maximum": 9223372036854775807 + } + } + } + } +} diff --git a/schemas/plugin-graph-lock.schema.yaml b/schemas/plugin-graph-lock.schema.yaml index 8c6f6ee..92ab84f 100644 --- a/schemas/plugin-graph-lock.schema.yaml +++ b/schemas/plugin-graph-lock.schema.yaml @@ -8,9 +8,40 @@ required: - compile_environment - edges - graph_id + - harness_selection - host_runtime - packages properties: + harness_selection: + oneOf: + - type: object + additionalProperties: false + required: [mode] + properties: + mode: {const: unselected} + - type: object + additionalProperties: false + required: [mode, tool_package] + properties: + mode: {const: tool_only} + tool_package: {type: string, minLength: 1, maxLength: 128} + - type: object + additionalProperties: false + required: [mode, tool_package, policy] + properties: + mode: {const: context_prompt} + tool_package: {type: string, minLength: 1, maxLength: 128} + policy: + type: object + additionalProperties: false + required: [package_id, artifact_id, entry_path, prompt_export, context_export, guardian_export] + properties: + package_id: {type: string, minLength: 1, maxLength: 128} + artifact_id: {type: string, minLength: 1, maxLength: 128} + entry_path: {type: string, minLength: 1, maxLength: 256} + prompt_export: {type: string, minLength: 1, maxLength: 128} + context_export: {type: string, minLength: 1, maxLength: 128} + guardian_export: {type: string, minLength: 1, maxLength: 128} closure: type: array items: diff --git a/schemas/plugin-manifest.schema.yaml b/schemas/plugin-manifest.schema.yaml index fc5419f..55e9388 100644 --- a/schemas/plugin-manifest.schema.yaml +++ b/schemas/plugin-manifest.schema.yaml @@ -15,6 +15,35 @@ required: - dependencies - capabilities - effects +allOf: + - if: + properties: + kind: {const: tool} + then: + properties: + effects: + properties: + entries: {maxProperties: 1} + - if: + properties: + effects: + properties: + entries: {minProperties: 2} + then: + properties: + kind: {const: library} + dependencies: {maxItems: 0} + capabilities: {maxItems: 0} + effects: + properties: + entries: + minProperties: 3 + maxProperties: 3 + additionalProperties: + properties: + direct: {maxItems: 0} + delegated: {maxItems: 0} + not: {required: [state]} properties: capabilities: type: array diff --git a/src/bin/rustscript-agent-gateway.rs b/src/bin/rustscript-agent-gateway.rs index 1fee705..417cc78 100644 --- a/src/bin/rustscript-agent-gateway.rs +++ b/src/bin/rustscript-agent-gateway.rs @@ -12,6 +12,7 @@ use rustscript_agent::{ AgentGatewayConfig, AgentGatewayState, ConfigFile, TelegramConfig, build_agent_gateway_app, config_file::{AgentPaths, CompactionSettings, ConfigFileError}, gateway::telegram::{TelegramAdapter, spawn_telegram_adapter}, + plugins::{InstallAuthorization, PublisherKey}, }; #[tokio::main] @@ -201,6 +202,7 @@ async fn main() -> Result<(), Box> { } else { state }; + let state = bind_host_plugin_control(state).map_err(std::io::Error::other)?; let listener = tokio::net::TcpListener::bind(address).await?; eprintln!( @@ -449,3 +451,49 @@ fn split_list(value: &str) -> Vec { .map(ToOwned::to_owned) .collect() } + +fn bind_host_plugin_control(state: AgentGatewayState) -> Result { + let root = match env::var_os("RUSTSCRIPT_AGENT_PLUGIN_SOURCE_ROOT") { + Some(value) => value, + None => return Ok(state), + }; + let space = env::var("RUSTSCRIPT_AGENT_PLUGIN_DEPLOYMENT_SPACE") + .map_err(|_| "plugin control deployment space is not configured".to_string())?; + let mut accepted_publishers = Vec::new(); + if let Some(dir) = env::var_os("RUSTSCRIPT_AGENT_PLUGIN_PUBLISHER_DIR") { + let dir = std::path::PathBuf::from(dir); + let entries = std::fs::read_dir(&dir) + .map_err(|_| "plugin publisher directory is unavailable".to_string())?; + for entry in entries { + let entry = + entry.map_err(|_| "plugin publisher directory is unavailable".to_string())?; + let name = entry + .file_name() + .into_string() + .map_err(|_| "plugin publisher name is invalid".to_string())?; + if name.contains('/') || name.contains('\\') || name.starts_with('.') { + return Err("plugin publisher name is invalid".into()); + } + let bytes = std::fs::read(entry.path()) + .map_err(|_| "plugin publisher key is unavailable".to_string())?; + if bytes.len() != 32 { + return Err("plugin publisher key is invalid".into()); + } + accepted_publishers.push(PublisherKey { + publisher: name, + verifying_key: bytes, + }); + } + } + let unsigned_dev_space = + env::var_os("RUSTSCRIPT_AGENT_PLUGIN_UNSIGNED_DEV").map(std::path::PathBuf::from); + state.with_plugin_control( + InstallAuthorization { + admin: true, + accepted_publishers, + unsigned_dev_space, + }, + std::path::PathBuf::from(root), + space, + ) +} diff --git a/src/capabilities/types.rs b/src/capabilities/types.rs index 5396feb..571c082 100644 --- a/src/capabilities/types.rs +++ b/src/capabilities/types.rs @@ -162,6 +162,8 @@ pub enum LifecycleError { DuplicateClose, TokenUnknown, LimitExceeded, + PluginGraphRevoked, + PluginGraphPinRequired, StartedCommitFailed(String), ResultCommitFailed(String), ResultTooLarge, @@ -184,6 +186,8 @@ impl LifecycleError { Self::DuplicateClose => "duplicate_close", Self::TokenUnknown => "token_unknown", Self::LimitExceeded => "max_tool_calls", + Self::PluginGraphRevoked => "plugin_graph_revoked", + Self::PluginGraphPinRequired => "plugin_graph_pin_required", Self::StartedCommitFailed(_) => "started_commit_failed", Self::ResultCommitFailed(_) => "result_commit_failed", Self::ResultTooLarge => "result_too_large", @@ -213,6 +217,8 @@ impl LifecycleError { Self::DuplicateClose => "execution token is already closed".to_string(), Self::TokenUnknown => "execution token is unknown".to_string(), Self::LimitExceeded => "max_tool_calls exceeded".to_string(), + Self::PluginGraphRevoked => "plugin graph is revoked".to_string(), + Self::PluginGraphPinRequired => "tool graph pin is required".to_string(), Self::StartedCommitFailed(message) | Self::ResultCommitFailed(message) => { message.clone() } diff --git a/src/config.rs b/src/config.rs index dd8f6ee..5a91d30 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1662,6 +1662,18 @@ pub struct ManagedWorkspaceConfig { pub source_root: PathBuf, pub quota: crate::effects::ResourceQuota, pub retention: ManagedRetentionLimits, + pub maintenance_policy: ManagedMaintenancePolicy, +} + +/// Trusted action policy. None of these flags creates Native workspace authority. +#[derive(Clone, Copy, Debug, Default)] +pub struct ManagedMaintenancePolicy { + pub create_checkpoint: bool, + pub restore: bool, + /// Non-destructive branch creation. Default false; does not grant authority. + pub branch: bool, + /// Explicit workspace publication. Default false; does not grant authority. + pub publish: bool, } impl ManagedWorkspaceConfig { @@ -1677,6 +1689,7 @@ impl ManagedWorkspaceConfig { ) .map_err(|e| e.to_string())?, retention: ManagedRetentionLimits::default(), + maintenance_policy: ManagedMaintenancePolicy::default(), }; config.validate()?; Ok(config) diff --git a/src/durable_provider.rs b/src/durable_provider.rs index 8ab213f..9af5265 100644 --- a/src/durable_provider.rs +++ b/src/durable_provider.rs @@ -95,6 +95,12 @@ impl DurableProviderHost { EventCommitError::Terminal => typed_fail("run_terminal", "run is terminal"), EventCommitError::Cancelled => typed_fail("cancelled", "run was cancelled"), EventCommitError::PersistFailed(_) => Self::persist_failed(), + EventCommitError::PluginGraphRevoked => { + typed_fail("plugin_graph_revoked", "plugin graph is revoked") + } + EventCommitError::PluginGraphPinRequired => { + typed_fail("plugin_graph_pin_required", "tool graph pin is required") + } EventCommitError::MissingParent => typed_fail( "missing_tool_parent", "tool result parent tool_call is missing", @@ -312,6 +318,15 @@ impl AgentProviderHost for DurableProviderHost { } fn call(&self, request: &JsonValue, cancellation: &RunCancellation) -> JsonValue { + let mut managed_provider = match self.service.managed_provider_enter(&self.run_id) { + Ok(entry) => entry, + Err(_) => { + return typed_fail( + "managed_provider_paused_or_closed", + "managed provider boundary is closed", + ); + } + }; let positional = self.turn.load(Ordering::SeqCst); let key = match request.get("_provider_step_key") { None => None, @@ -341,6 +356,9 @@ impl AgentProviderHost for DurableProviderHost { self.turn.store(turn, Ordering::SeqCst); match self.replay_completed(turn) { Ok(Some(envelope)) => { + if let Some(entry) = managed_provider.as_mut() { + entry.completed(); + } self.advance_turn(); return envelope; } @@ -356,6 +374,9 @@ impl AgentProviderHost for DurableProviderHost { Ok(ProviderPendingDecision::Replay) => { return match self.replay_completed(turn) { Ok(Some(envelope)) => { + if let Some(entry) = managed_provider.as_mut() { + entry.completed(); + } self.advance_turn(); envelope } @@ -410,6 +431,9 @@ impl AgentProviderHost for DurableProviderHost { } else { request }; + if let Err(code) = self.service.check_harness_run(&self.run_id, cancellation) { + return typed_fail(code, code); + } let envelope = self.inner.call(inner_request, cancellation); if envelope.get("ok").and_then(JsonValue::as_bool) != Some(true) { if let Err(error) = self.persist_classified_failure(turn, attempt, &envelope) { @@ -446,6 +470,9 @@ impl AgentProviderHost for DurableProviderHost { step.reasoning.as_ref(), ) { Ok(ProviderCommitOutcome::Inserted(commit)) => { + if let Some(entry) = managed_provider.as_mut() { + entry.completed(); + } self.metrics.record_turn(); self.advance_turn(); if self.service.take_crash_after_provider_commit() { @@ -455,6 +482,9 @@ impl AgentProviderHost for DurableProviderHost { commit.envelope } Ok(ProviderCommitOutcome::Existing(commit)) => { + if let Some(entry) = managed_provider.as_mut() { + entry.completed(); + } self.advance_turn(); commit.envelope } diff --git a/src/effects/authority.rs b/src/effects/authority.rs index ba2fa5e..fa3aaaf 100644 --- a/src/effects/authority.rs +++ b/src/effects/authority.rs @@ -41,6 +41,14 @@ impl ManagedPolicyClass { _ => Err("managed unknown operation declaration".into()), } } + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::WorkspaceRead => "workspace_read", + Self::WorkspaceWrite => "workspace_write", + Self::ForegroundProcess => "foreground_process", + Self::ProcessControl => "process_control", + } + } pub(crate) fn expected_risk(self) -> CapabilityRisk { match self { Self::WorkspaceRead => CapabilityRisk::Read, diff --git a/src/effects/broker.rs b/src/effects/broker.rs index aec4800..cb58cee 100644 --- a/src/effects/broker.rs +++ b/src/effects/broker.rs @@ -23,6 +23,8 @@ use crate::{ runtime::{RunCancellation, rss_runner::WorkspaceAuthority}, }; use parking_lot::Mutex; +#[cfg(test)] +type RestoreClosedObserver = Arc; use serde_json::{Value, json}; use std::{ collections::HashMap, @@ -41,12 +43,15 @@ use std::{ #[cfg(all(test, feature = "workspace-io-fault"))] #[path = "managed_authority_tests.rs"] mod managed_authority_tests; +#[cfg(all(test, feature = "workspace-io-fault"))] +#[path = "managed_restore_authority_tests.rs"] +mod managed_restore_authority_tests; #[derive(Clone)] pub(crate) struct ManagedEntryContract { pub(crate) trust: crate::plugins::PackageTrust, pub(crate) effects: Vec, - pub(crate) single_builtin_graph: bool, + pub(crate) topology: Option, } #[derive(Clone)] @@ -66,6 +71,18 @@ pub(crate) struct ManagedEffectRuntime { #[cfg(test)] native_ready_observer: Mutex>, #[cfg(test)] + before_adapter_install: Mutex>, + #[cfg(test)] + before_operation_count: Mutex>, + #[cfg(test)] + before_prepared_install: Mutex>, + #[cfg(test)] + before_final_commit: Mutex>, + #[cfg(test)] + restore_closed_observer: Mutex>, + #[cfg(test)] + settled_call_observer: Mutex>, + #[cfg(test)] close_after_native_spawn: AtomicBool, #[cfg(feature = "workspace-io-fault")] fault: Mutex>, @@ -83,6 +100,209 @@ pub(crate) struct ManagedEffectRuntime { leases: Arc, pub(crate) tree_sync: tree_store::DirectorySync, initialized: Mutex>, + branches: Mutex>>, + bindings: Mutex>>, +} + +pub(crate) struct ManagedBranchState { + pub(crate) epoch: u64, + state: Mutex, + changed: parking_lot::Condvar, +} +struct ManagedBranchActivity { + phase: ManagedBranchPhase, + active: usize, + providers: usize, + checkpoint_provider_failed: bool, + pending: std::collections::HashSet<(String, String)>, + deadlines: HashMap, +} +impl ManagedBranchActivity { + fn new(phase: ManagedBranchPhase) -> Self { + Self { + phase, + active: 0, + providers: 0, + checkpoint_provider_failed: false, + pending: Default::default(), + deadlines: HashMap::new(), + } + } +} +#[derive(Clone, Copy, Eq, PartialEq)] +enum ManagedBranchPhase { + Active, + CheckpointPausing, + RestoreClosing, + RestoreUnknown, + Frozen, + Unpublished, + Retired, +} +pub(crate) struct ManagedRestoreBranch { + runtime: Arc, + source: ManagedBranchFreeze, + branch: Arc, + pub(crate) identity: ManagedWorkspaceIdentity, + pub(crate) target: ManagedSourceCas, + source_branch_id: String, +} +impl ManagedRestoreBranch { + pub(crate) fn frozen_guidance( + &self, + budgets: crate::prompt::CodingPromptBudgets, + ) -> Result, String> { + self.runtime.check()?; + let root = self + .runtime + .root + .subroot( + &format!("trees/{}", self.target.head_object_id), + self.runtime.root.limits(), + ) + .map_err(|_| "managed_artifact_unavailable")?; + Ok(crate::prompt::load_guidance(&root, budgets)) + } + fn source_branch_id(&self) -> &str { + &self.source_branch_id + } + pub(crate) fn confirmed_rollback(&self) { + self.source.branch.state.lock().phase = ManagedBranchPhase::Active; + self.source.branch.changed.notify_all(); + } + pub(crate) fn submitted_unknown(&self) { + self.source.branch.state.lock().phase = ManagedBranchPhase::RestoreUnknown; + } + pub(crate) fn publish(&self) { + self.source.branch.state.lock().phase = ManagedBranchPhase::Retired; + self.branch.state.lock().phase = ManagedBranchPhase::Active; + } +} +pub(crate) struct ManagedBranchFreeze { + branch: Arc, +} +impl Drop for ManagedBranchFreeze { + fn drop(&mut self) { + let mut state = self.branch.state.lock(); + if matches!( + state.phase, + ManagedBranchPhase::Frozen + | ManagedBranchPhase::CheckpointPausing + | ManagedBranchPhase::RestoreClosing + ) { + state.phase = ManagedBranchPhase::Active; + self.branch.changed.notify_all(); + } + } +} +pub(crate) struct ManagedProviderEntry { + branch: Arc, + completed: bool, +} +impl ManagedProviderEntry { + pub(crate) fn completed(&mut self) { + self.completed = true; + } +} +impl Drop for ManagedProviderEntry { + fn drop(&mut self) { + let mut state = self.branch.state.lock(); + state.providers -= 1; + if !self.completed && state.phase == ManagedBranchPhase::CheckpointPausing { + state.checkpoint_provider_failed = true; + } + self.branch.changed.notify_all(); + } +} +impl ManagedBranchFreeze { + pub(crate) fn wait_frozen(&self, cancellation: &RunCancellation) -> Result<(), String> { + let mut state = self.branch.state.lock(); + let mut deadline = cancellation + .deadline_instant() + .ok_or("managed_deadline_elapsed")?; + for original in state.deadlines.values() { + deadline = deadline.min(*original); + } + while state.providers != 0 || !state.pending.is_empty() || state.active != 0 { + if std::time::Instant::now() >= deadline || cancellation.requested().is_some() { + return Err("managed_checkpoint_busy".into()); + } + self.branch.changed.wait_until(&mut state, deadline); + } + if state.checkpoint_provider_failed + || (deadline <= std::time::Instant::now() && !state.deadlines.is_empty()) + || cancellation.deadline_passed() + || cancellation.requested().is_some() + { + return Err("managed_checkpoint_busy".into()); + } + state.phase = ManagedBranchPhase::Frozen; + Ok(()) + } + + /// Freeze a branch whose durable effect batch is already closed. + /// An in-flight provider computing the next turn is not an active writer + /// and must not block `at_closed_batch`. Pending or active calls still block. + pub(crate) fn wait_closed_batch(&self, cancellation: &RunCancellation) -> Result<(), String> { + let mut state = self.branch.state.lock(); + let mut deadline = cancellation + .deadline_instant() + .ok_or("managed_deadline_elapsed")?; + for original in state.deadlines.values() { + deadline = deadline.min(*original); + } + while !state.pending.is_empty() || state.active != 0 { + if std::time::Instant::now() >= deadline || cancellation.requested().is_some() { + return Err("managed_checkpoint_busy".into()); + } + self.branch.changed.wait_until(&mut state, deadline); + } + if state.checkpoint_provider_failed + || (deadline <= std::time::Instant::now() && !state.deadlines.is_empty()) + || cancellation.deadline_passed() + || cancellation.requested().is_some() + { + return Err("managed_checkpoint_busy".into()); + } + state.phase = ManagedBranchPhase::Frozen; + Ok(()) + } +} +struct ManagedBranchEntry { + branch: Arc, +} +impl Drop for ManagedBranchEntry { + fn drop(&mut self) { + self.branch.state.lock().active -= 1; + self.branch.changed.notify_all(); + } +} +impl ManagedBranchState { + fn enter_checked( + self: &Arc, + epoch: u64, + call: (&str, &str), + validate: impl FnOnce() -> Result<(), String>, + ) -> Result { + let mut state = self.state.lock(); + let permitted = state.phase == ManagedBranchPhase::Active + || (state.phase == ManagedBranchPhase::CheckpointPausing + && state + .pending + .contains(&(call.0.to_string(), call.1.to_string()))); + if self.epoch != epoch || !permitted { + return Err("managed branch unavailable".into()); + } + let next = state + .active + .checked_add(1) + .ok_or("managed operation overflow")?; + validate()?; + state.active = next; + Ok(ManagedBranchEntry { + branch: Arc::clone(self), + }) + } } /// Per-store, one-shot deterministic managed protocol cuts. @@ -270,6 +490,7 @@ mod managed_native_tests { source_root: workspace.clone(), quota: super::super::ResourceQuota::new(1000, 1048576, 16).unwrap(), retention: crate::config::ManagedRetentionLimits::default(), + maintenance_policy: Default::default(), }), ..Default::default() }; @@ -379,6 +600,7 @@ mod managed_native_tests { source_root: workspace, quota: super::super::ResourceQuota::new(1000, 1048576, 16).unwrap(), retention: crate::config::ManagedRetentionLimits::default(), + maintenance_policy: Default::default(), }), ..Default::default() }; @@ -586,6 +808,18 @@ impl ManagedEffectRuntime { #[cfg(test)] native_ready_observer: Mutex::new(None), #[cfg(test)] + before_adapter_install: Mutex::new(None), + #[cfg(test)] + before_operation_count: Mutex::new(None), + #[cfg(test)] + before_prepared_install: Mutex::new(None), + #[cfg(test)] + before_final_commit: Mutex::new(None), + #[cfg(test)] + restore_closed_observer: Mutex::new(None), + #[cfg(test)] + settled_call_observer: Mutex::new(None), + #[cfg(test)] close_after_native_spawn: AtomicBool::new(false), #[cfg(feature = "workspace-io-fault")] fault: Mutex::new(None), @@ -603,6 +837,8 @@ impl ManagedEffectRuntime { leases, tree_sync: tree_store::DirectorySync::default(), initialized: Mutex::new(None), + branches: Mutex::new(HashMap::new()), + bindings: Mutex::new(HashMap::new()), })) } pub(crate) fn check(&self) -> Result<(), String> { @@ -961,6 +1197,431 @@ impl ManagedEffectRuntime { Ok(()) } + #[cfg(test)] + pub(crate) fn checkpoint_pausing_for_test(&self, branch_id: &str) -> bool { + self.branches.lock().get(branch_id).is_some_and(|branch| { + branch.state.lock().phase == ManagedBranchPhase::CheckpointPausing + }) + } + pub(crate) fn begin_checkpoint_pause( + &self, + workspace: &ManagedWorkspaceIdentity, + epoch: u64, + ) -> Result { + self.check()?; + let branch = self + .branches + .lock() + .get(&workspace.branch_id) + .cloned() + .ok_or("managed_checkpoint_busy")?; + let mut state = branch.state.lock(); + if branch.epoch != epoch || state.phase != ManagedBranchPhase::Active { + return Err("managed_checkpoint_busy".into()); + } + state.checkpoint_provider_failed = false; + state.phase = ManagedBranchPhase::CheckpointPausing; + drop(state); + Ok(ManagedBranchFreeze { branch }) + } + fn close_source_branch( + &self, + workspace: &ManagedWorkspaceIdentity, + epoch: u64, + ) -> Result { + self.check()?; + let branch = self + .branches + .lock() + .get(&workspace.branch_id) + .cloned() + .ok_or("managed_restore_busy")?; + let mut state = branch.state.lock(); + if branch.epoch != epoch || state.phase != ManagedBranchPhase::Active { + return Err("managed_restore_busy".into()); + } + state.phase = ManagedBranchPhase::RestoreClosing; + drop(state); + #[cfg(test)] + let observer = self.restore_closed_observer.lock().clone(); + #[cfg(test)] + if let Some(observer) = observer { + observer("closed"); + } + Ok(ManagedBranchFreeze { branch }) + } + pub(crate) fn settle_restore_source( + &self, + prepared: &ManagedRestoreBranch, + cancellation: &RunCancellation, + ) -> Result<(), String> { + let mut deadline = cancellation + .deadline_instant() + .ok_or("managed_deadline_elapsed")?; + { + let mut state = prepared.source.branch.state.lock(); + for original in state.deadlines.values() { + deadline = deadline.min(*original); + } + while state.active != 0 { + if std::time::Instant::now() >= deadline || cancellation.requested().is_some() { + return Err("managed_restore_busy".into()); + } + prepared + .source + .branch + .changed + .wait_until(&mut state, deadline); + } + } + let bindings = self + .bindings + .lock() + .values() + .filter_map(Weak::upgrade) + .filter(|binding| binding.admitted_branch.branch_id == prepared.source_branch_id()) + .collect::>(); + for binding in bindings { + let calls = binding.calls.lock().values().cloned().collect::>(); + for call in calls { + call.close_for_restore(deadline)?; + #[cfg(test)] + if let Some(observer) = self.settled_call_observer.lock().clone() { + observer(&format!( + "settled:{}:{}", + binding.owner.run_id, call.identity.call_id + )); + } + } + } + Ok(()) + } + #[cfg(test)] + pub(crate) fn observe_restore_cancelled(&self) { + let observer = self.restore_closed_observer.lock().clone(); + if let Some(observer) = observer { + observer("cancelled"); + } + } + #[cfg(test)] + pub(crate) fn observe_before_final_commit(&self, call: Arc) { + let observer = self.before_final_commit.lock().clone(); + if let Some(observer) = observer { + observer(call); + } + } + pub(crate) fn prepare_restore_branch( + self: &Arc, + authority: &WorkspaceAuthority, + workspace: &ManagedWorkspaceIdentity, + source: &ManagedSourceCas, + frozen: &ManagedObjectRef, + ) -> Result { + self.authorize_query(authority, workspace)?; + if source.store_id != workspace.store_id + || source.workspace_id != workspace.workspace_id + || source.branch_id != workspace.branch_id + || frozen.store_id != source.store_id + { + return Err("managed_source_conflict".into()); + } + self.verify_tree(&frozen.object_id)?; + let epoch = source + .epoch + .checked_add(1) + .filter(|epoch| *epoch <= i64::MAX as u64) + .ok_or("managed_source_conflict")?; + let source_guard = self.close_source_branch(workspace, source.epoch)?; + let mut identity = workspace.clone(); + identity.branch_id = fresh("br"); + let branch = Arc::new(ManagedBranchState { + epoch, + state: Mutex::new(ManagedBranchActivity::new(ManagedBranchPhase::Unpublished)), + changed: parking_lot::Condvar::new(), + }); + self.branches + .lock() + .insert(identity.branch_id.clone(), Arc::clone(&branch)); + Ok(ManagedRestoreBranch { + runtime: Arc::clone(self), + source: source_guard, + source_branch_id: workspace.branch_id.clone(), + branch, + target: ManagedSourceCas { + store_id: source.store_id.clone(), + workspace_id: source.workspace_id.clone(), + branch_id: identity.branch_id.clone(), + epoch, + head_object_id: frozen.object_id.clone(), + head_version: 1, + }, + identity, + }) + } + + /// Register a branch only after its durable receipt committed. + pub(crate) fn remember_branch( + &self, + workspace: &ManagedWorkspaceIdentity, + epoch: u64, + ) -> Result<(), String> { + let branch = Arc::new(ManagedBranchState { + epoch, + state: Mutex::new(ManagedBranchActivity::new(ManagedBranchPhase::Active)), + changed: parking_lot::Condvar::new(), + }); + let mut branches = self.branches.lock(); + if let Some(existing) = branches.get(&workspace.branch_id) { + if existing.epoch != epoch { + return Err("managed_source_conflict".into()); + } + return Ok(()); + } + branches.insert(workspace.branch_id.clone(), branch); + Ok(()) + } + + pub(crate) fn authorized_workspace( + &self, + authority: &WorkspaceAuthority, + profile: &str, + workspace_id: &str, + cancellation: &RunCancellation, + ) -> Result { + validate_id("ws", workspace_id)?; + let (device, inode) = authority + .grant() + .held() + .root() + .binding_identity() + .map_err(error)?; + let source = DirectoryIdentity { + device_decimal: device.to_string(), + inode_decimal: inode.to_string(), + }; + let record = self + .repository + .workspace(&self.store_id, profile, &source, cancellation)? + .ok_or("managed_authority_required")?; + if record.identity.workspace_id != workspace_id { + return Err("managed_authority_required".into()); + } + self.authorize_query(authority, &record.identity)?; + Ok(record) + } + + pub(crate) fn source_has_active_writer(&self, branch_id: &str) -> bool { + self.branches.lock().get(branch_id).is_some_and(|branch| { + let state = branch.state.lock(); + state.active != 0 + || !state.pending.is_empty() + || state.phase != ManagedBranchPhase::Active + }) + } + + pub(crate) fn resolve_branch_selector( + &self, + authority: &WorkspaceAuthority, + profile: &str, + branch_id: &str, + cancellation: &RunCancellation, + ) -> Result<(ManagedWorkspaceHead, Option), String> { + validate_id("br", branch_id)?; + authority.check().map_err(error)?; + self.check()?; + let configured = fs::canonicalize(&self.config.source_root).map_err(error)?; + if configured != Path::new(authority.canonical_path()) { + return Err("managed_authority_required".into()); + } + let source = authority.grant(); + let (device, inode) = source.held().root().binding_identity().map_err(error)?; + let identity = DirectoryIdentity { + device_decimal: device.to_string(), + inode_decimal: inode.to_string(), + }; + let workspace = self + .repository + .workspace(&self.store_id, profile, &identity, cancellation)? + .ok_or("managed_branch_missing")?; + self.authorize_query(authority, &workspace.identity)?; + let saved = self.repository.branch_origin( + &workspace.identity.workspace_id, + branch_id, + cancellation, + )?; + if saved.receipt.branch.workspace_id != workspace.identity.workspace_id + || saved.receipt.branch.branch_id != branch_id + || saved.receipt.branch.store_id != workspace.identity.store_id + { + return Err("managed_authority_required".into()); + } + let mut selected_identity = workspace.identity.clone(); + selected_identity.branch_id = branch_id.to_string(); + let head = self.repository.head(&selected_identity, cancellation)?; + let kind = saved + .origin + .get("kind") + .and_then(Value::as_str) + .unwrap_or(""); + if head.branch_id != branch_id + || head.workspace_id != workspace.identity.workspace_id + || head.store_id != saved.receipt.branch.store_id + || head.process_lineage_tainted + || (kind != "checkpoint" + && (head.epoch != saved.receipt.branch.epoch + || head.head_object_id != saved.receipt.branch.head_object_id + || head.head_version != saved.receipt.branch.head_version)) + { + return Err("managed_source_conflict".into()); + } + let origin_workspace = saved + .origin + .get("workspace_id") + .and_then(Value::as_str) + .unwrap_or(""); + let origin_head = saved + .origin + .get("head_object_id") + .and_then(Value::as_str) + .unwrap_or(""); + if origin_workspace != head.workspace_id || origin_head.is_empty() { + return Err("managed_authority_required".into()); + } + self.verify_tree(&head.head_object_id)?; + let checkpoint = if kind == "head" { + if origin_head != head.head_object_id { + return Err("managed_authority_required".into()); + } + None + } else if kind == "checkpoint" { + let checkpoint_id = saved + .origin + .get("checkpoint_id") + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + .ok_or("managed_authority_required")?; + let lookup = + self.repository + .checkpoint_get(&head.workspace_id, checkpoint_id, cancellation)?; + let checkpoint = lookup.checkpoint; + if checkpoint.workspace_id != head.workspace_id + || checkpoint.frozen_root.object_id != origin_head + || checkpoint.checkpoint_id != checkpoint_id + { + return Err("managed_authority_required".into()); + } + Some(checkpoint) + } else { + return Err("managed_authority_required".into()); + }; + Ok((head, checkpoint)) + } + + /// Rebuild only an existing durable owner. The saved branch head is the + /// authority; the workspace current branch is not a substitute. + pub(crate) fn restore_run_binding( + self: &Arc, + authority: Arc, + saved: &ManagedRunQueryBinding, + captured: ( + ManagedCodeIdentity, + ManagedEntryContract, + Arc, + Arc, + ), + cancel: RunCancellation, + ) -> Result, String> { + let (code, contract, policy_catalog, registry_snapshot) = captured; + cancel.ensure_open().map_err(str::to_owned)?; + self.authorize_query(&authority, &saved.workspace)?; + if !captured_native_grant(&contract, &code) + || !policy_catalog.matches_code(&code) + || code.registry_identity != registry_snapshot.identity() + { + return Err("plugin_graph_pin_required".into()); + } + let workspace = self + .repository + .workspace( + &self.store_id, + &saved.owner.profile, + &saved.workspace.source_identity, + &cancel, + )? + .ok_or("plugin_graph_pin_required")?; + if workspace.identity.workspace_id != saved.workspace.workspace_id + || workspace.identity.store_id != saved.workspace.store_id + || workspace.max_entries != self.config.quota.max_entries + || workspace.max_bytes != self.config.quota.max_bytes + || workspace.max_depth != self.config.quota.max_depth + { + return Err("plugin_graph_pin_required".into()); + } + self.verify_tree(&workspace.seed_root.object_id)?; + let mut saved_identity = workspace.identity.clone(); + saved_identity.branch_id = saved.workspace.branch_id.clone(); + let head = self.repository.head(&saved_identity, &cancel)?; + if head.branch_id != saved.workspace.branch_id + || head.epoch != saved.epoch + || head.workspace_id != saved.workspace.workspace_id + || head.store_id != saved.workspace.store_id + { + return Err("plugin_graph_pin_required".into()); + } + self.verify_tree(&head.head_object_id)?; + let branch = self + .branches + .lock() + .entry(saved.workspace.branch_id.clone()) + .or_insert_with(|| { + Arc::new(ManagedBranchState { + epoch: saved.epoch, + state: Mutex::new(ManagedBranchActivity::new(ManagedBranchPhase::Active)), + changed: parking_lot::Condvar::new(), + }) + }) + .clone(); + if branch.epoch != saved.epoch || branch.state.lock().phase != ManagedBranchPhase::Active { + return Err("plugin_graph_pin_required".into()); + } + let binding = Arc::new(ManagedRunBinding { + runtime: Arc::clone(self), + authority, + owner: saved.owner.clone(), + code, + contract, + policy_catalog, + registry_snapshot, + cancel, + workspace, + admitted_branch: saved.workspace.clone(), + branch, + calls: Mutex::new(HashMap::new()), + admitted_limits: Mutex::new(None), + }); + binding.check_admission()?; + // Reconstruction prepares a candidate; only the retained publication + // winner may enter the weak registry and branch deadline map. + Ok(binding) + } + + pub(crate) fn publish_restored_binding(&self, binding: &Arc) { + if let Some(deadline) = binding.cancel.deadline_instant() { + binding + .branch + .state + .lock() + .deadlines + .entry(binding.owner.run_id.clone()) + .and_modify(|original| *original = (*original).min(deadline)) + .or_insert(deadline); + } + self.bindings + .lock() + .insert(binding.owner.run_id.clone(), Arc::downgrade(binding)); + } + + #[cfg(test)] pub(crate) fn prepare_run_binding( self: &Arc, authority: Arc, @@ -970,12 +1631,34 @@ impl ManagedEffectRuntime { captured_policy: (Arc, Arc), cancel: RunCancellation, ) -> Result, String> { + self.prepare_run_binding_from( + authority, + owner, + code, + contract, + captured_policy, + (cancel, None, None), + ) + } + + pub(crate) fn prepare_run_binding_from( + self: &Arc, + authority: Arc, + owner: ManagedOwnerScope, + code: ManagedCodeIdentity, + contract: ManagedEntryContract, + captured_policy: (Arc, Arc), + admission: ( + RunCancellation, + Option<&ManagedRestoreBranch>, + Option<&ManagedWorkspaceHead>, + ), + ) -> Result, String> { + let (cancel, restored, selected) = admission; let (policy_catalog, registry_snapshot) = captured_policy; authority.check().map_err(error)?; self.check()?; - if !contract.single_builtin_graph - || contract.trust != crate::plugins::PackageTrust::EmbeddingOwnedBuiltin - { + if !captured_native_grant(&contract, &code) { return Err("managed graph must be captured embedding-owned single builtin".into()); } if !policy_catalog.matches_code(&code) @@ -1053,7 +1736,56 @@ impl ManagedEffectRuntime { ) .map_err(error)?; authority.check().map_err(error)?; - Ok(Arc::new(ManagedRunBinding { + let head = if let Some(selected) = selected { + if restored.is_some() { + return Err("managed_source_conflict".into()); + } + if selected.workspace_id != workspace.identity.workspace_id + || selected.store_id != workspace.identity.store_id + || selected.process_lineage_tainted + { + return Err("managed_authority_required".into()); + } + self.verify_tree(&selected.head_object_id)?; + selected.clone() + } else { + self.repository.current_head(&workspace.identity, &cancel)? + }; + let admitted_branch = match restored { + Some(restored) if Arc::ptr_eq(self, &restored.runtime) => restored.identity.clone(), + Some(_) => return Err("managed restore runtime mismatch".into()), + None => ManagedWorkspaceIdentity { + branch_id: head.branch_id.clone(), + ..workspace.identity.clone() + }, + }; + let binding = Arc::new(ManagedRunBinding { + admitted_branch, + branch: match restored { + Some(restored) => Arc::clone(&restored.branch), + None => { + let branch = self + .branches + .lock() + .entry(head.branch_id.clone()) + .or_insert_with(|| { + Arc::new(ManagedBranchState { + epoch: head.epoch, + state: Mutex::new(ManagedBranchActivity::new( + ManagedBranchPhase::Active, + )), + changed: parking_lot::Condvar::new(), + }) + }) + .clone(); + if branch.epoch != head.epoch + || branch.state.lock().phase != ManagedBranchPhase::Active + { + return Err("managed branch unavailable".into()); + } + branch + } + }, runtime: Arc::clone(self), authority, owner, @@ -1065,9 +1797,35 @@ impl ManagedEffectRuntime { workspace, calls: Mutex::new(HashMap::new()), admitted_limits: Mutex::new(None), - })) + }); + if let Some(deadline) = binding.cancel.deadline_instant() { + binding + .branch + .state + .lock() + .deadlines + .insert(binding.owner.run_id.clone(), deadline); + } + self.bindings + .lock() + .insert(binding.owner.run_id.clone(), Arc::downgrade(&binding)); + Ok(binding) } } +fn captured_native_grant(contract: &ManagedEntryContract, code: &ManagedCodeIdentity) -> bool { + if contract.topology.is_none() { + return false; + } + let embedding = code.package_id == crate::runtime::native_tools::EMBEDDING_PACKAGE_ID; + match contract.trust { + crate::plugins::PackageTrust::EmbeddingOwnedBuiltin => embedding, + crate::plugins::PackageTrust::AcceptedPublisher => { + !embedding && !code.package_id.is_empty() + } + crate::plugins::PackageTrust::DevUnsigned => false, + } +} + pub(crate) fn validate_id(prefix: &str, id: &str) -> Result<(), String> { if id.strip_prefix(&format!("{prefix}-")).is_none_or(|rest| { rest.len() != 32 @@ -1096,6 +1854,8 @@ pub(crate) struct ManagedRunBinding { registry_snapshot: Arc, pub(crate) cancel: RunCancellation, pub(crate) workspace: ManagedWorkspaceRecord, + pub(crate) admitted_branch: ManagedWorkspaceIdentity, + branch: Arc, calls: Mutex>>, admitted_limits: Mutex>, } @@ -1104,6 +1864,71 @@ pub(crate) struct ManagedLiveReplay { pub(crate) result: Value, } impl ManagedRunBinding { + pub(crate) fn provider_enter(&self) -> Result { + let deadline = self + .cancel + .deadline_instant() + .ok_or("managed_deadline_elapsed")?; + let mut state = self.branch.state.lock(); + while matches!( + state.phase, + ManagedBranchPhase::CheckpointPausing + | ManagedBranchPhase::Frozen + | ManagedBranchPhase::RestoreClosing + ) { + if std::time::Instant::now() >= deadline || self.cancel.requested().is_some() { + return Err("managed_deadline_elapsed".into()); + } + self.branch.changed.wait_until( + &mut state, + deadline.min(std::time::Instant::now() + std::time::Duration::from_millis(10)), + ); + } + if state.phase != ManagedBranchPhase::Active { + return Err("managed branch unavailable".into()); + } + self.authority.check().map_err(error)?; + state.providers += 1; + Ok(ManagedProviderEntry { + branch: Arc::clone(&self.branch), + completed: false, + }) + } + pub(crate) fn provider_committed(&self, blocks: &[crate::domain::LlmContentBlock]) { + let mut state = self.branch.state.lock(); + for block in blocks { + if block.block_type == "tool_call" + && let Some(call) = &block.tool_call_id + { + state + .pending + .insert((self.owner.run_id.clone(), call.clone())); + } + } + self.branch.changed.notify_all(); + } + pub(crate) fn terminal_boundary(&self) { + self.branch + .state + .lock() + .deadlines + .remove(&self.owner.run_id); + self.branch.changed.notify_all(); + } + pub(crate) fn check_admission(&self) -> Result<(), String> { + self.check()?; + if self.branch.state.lock().phase != ManagedBranchPhase::Active { + return Err("managed branch unavailable".into()); + } + Ok(()) + } + pub(crate) fn admitted_epoch(&self) -> u64 { + self.branch.epoch + } + pub(crate) fn captured_registry(&self) -> ToolRegistrySnapshot { + (*self.registry_snapshot).clone() + } + #[cfg(test)] pub(crate) fn with_cancellation_for_test(&self, cancel: RunCancellation) -> Arc { Arc::new(Self { @@ -1116,6 +1941,8 @@ impl ManagedRunBinding { registry_snapshot: Arc::clone(&self.registry_snapshot), cancel, workspace: self.workspace.clone(), + admitted_branch: self.admitted_branch.clone(), + branch: Arc::clone(&self.branch), calls: Mutex::new(HashMap::new()), admitted_limits: Mutex::new(*self.admitted_limits.lock()), }) @@ -1123,7 +1950,7 @@ impl ManagedRunBinding { pub(crate) fn matches_contract(&self, contract: Option<&ManagedEntryContract>) -> bool { contract.is_some_and(|contract| { contract.trust == self.contract.trust - && contract.single_builtin_graph == self.contract.single_builtin_graph + && contract.topology == self.contract.topology && contract.effects.len() == self.contract.effects.len() && contract .effects @@ -1189,7 +2016,7 @@ impl ManagedRunBinding { || identity.call_id != call_id || identity.tool_name != tool_name || identity.code != self.code - || identity.workspace != self.workspace.identity + || identity.workspace != self.admitted_branch || identity.authorization_generation != self.authority.generation() { return Err("managed replay identity mismatch".into()); @@ -1197,7 +2024,7 @@ impl ManagedRunBinding { let head = self .runtime .repository - .head(&self.workspace.identity, &self.cancel)?; + .head(&self.admitted_branch, &self.cancel)?; if identity.epoch != head.epoch { return Err("managed replay epoch mismatch".into()); } @@ -1261,6 +2088,16 @@ impl ManagedRunBinding { Ok(()) } pub(crate) fn check(&self) -> Result<(), String> { + self.check_authority()?; + if !matches!( + self.branch.state.lock().phase, + ManagedBranchPhase::Active | ManagedBranchPhase::CheckpointPausing + ) { + return Err("managed branch unavailable".into()); + } + Ok(()) + } + fn check_authority(&self) -> Result<(), String> { self.authority.check().map_err(error)?; self.runtime.check()?; if self.cancel.requested().is_some() || self.cancel.deadline_passed() { @@ -1280,6 +2117,11 @@ impl ManagedRunBinding { deadline_ms: u64, ) -> Result, String> { self.check()?; + let _preparation = + self.branch + .enter_checked(self.branch.epoch, (&self.owner.run_id, call_id), || { + self.check_authority() + })?; if self.calls.lock().contains_key(call_id) { return Err("managed exact call already registered".into()); } @@ -1290,13 +2132,13 @@ impl ManagedRunBinding { let head = self .runtime .repository - .head(&self.workspace.identity, &self.cancel)?; + .head(&self.admitted_branch, &self.cancel)?; let parent = self .runtime .repository .object_metadata( &ManagedObjectReferencesRequest { - store_id: self.workspace.identity.store_id.clone(), + store_id: self.admitted_branch.store_id.clone(), object_id: head.head_object_id.clone(), }, &self.cancel, @@ -1364,7 +2206,7 @@ impl ManagedRunBinding { tool_name: tool_name.into(), argument_identity, code: self.code.clone(), - workspace: self.workspace.identity.clone(), + workspace: self.admitted_branch.clone(), epoch: head.epoch, expected_head: head.head_object_id, expected_head_version: head.head_version, @@ -1490,6 +2332,7 @@ pub(crate) struct ManagedCallContext { } pub(crate) struct ManagedOperationEvidence { call: Arc, + _branch: ManagedBranchEntry, } impl Drop for ManagedOperationEvidence { fn drop(&mut self) { @@ -1608,17 +2451,39 @@ impl ManagedCallContext { if !self.policy.allows_operation(operation) { return Err("managed primitive denied by captured class".into()); } - self.authorize(claims, operation.required_risk())?; - let mut active = self.native_operations.lock(); - if self.closing.load(Ordering::Acquire) || *self.phase.lock() != CallPhase::Executing { - return Err("managed call closing".into()); - } - if *active >= 32 { - return Err("managed operation capacity exceeded".into()); + #[cfg(test)] + let observer = self + .binding()? + .runtime + .before_operation_count + .lock() + .clone(); + #[cfg(test)] + if let Some(observer) = observer { + observer(Arc::clone(self)); } - *active += 1; + let binding = self.binding()?; + let branch = binding.branch.enter_checked( + self.identity.epoch, + (&self.identity.owner.run_id, &self.identity.call_id), + || { + self.check_token_binding_authority(claims)?; + let mut active = self.native_operations.lock(); + if self.closing.load(Ordering::Acquire) + || *self.phase.lock() != CallPhase::Executing + { + return Err("managed call closing".into()); + } + if *active >= 32 { + return Err("managed operation capacity exceeded".into()); + } + *active += 1; + Ok(()) + }, + )?; Ok(ManagedOperationEvidence { call: Arc::clone(self), + _branch: branch, }) } pub(crate) fn record_outcome(&self, outcome: &ManagedCommitOutcome) { @@ -1674,6 +2539,11 @@ impl ManagedCallContext { ) -> Result<(), String> { let binding = self.binding()?; binding.check()?; + let _preparation = binding.branch.enter_checked( + self.identity.epoch, + (&self.identity.owner.run_id, &self.identity.call_id), + || binding.check_authority(), + )?; let limits = binding .admitted_limits .lock() @@ -1782,6 +2652,25 @@ impl ManagedCallContext { let process_owner = Arc::clone(&processes); processes.set_after_os_spawn_hook(Arc::new(move || process_owner.shutdown_all())); } + binding.check()?; + #[cfg(test)] + let before_install = binding.runtime.before_adapter_install.lock().clone(); + #[cfg(test)] + if let Some(observer) = before_install { + observer(Arc::clone(self)); + } + let state = binding.branch.state.lock(); + let permitted = state.phase == ManagedBranchPhase::Active + || (state.phase == ManagedBranchPhase::CheckpointPausing + && state.pending.contains(&( + self.identity.owner.run_id.clone(), + self.identity.call_id.clone(), + ))); + if binding.branch.epoch != self.identity.epoch || !permitted { + return Err("managed branch unavailable".into()); + } + binding.check_authority()?; + let _operations = self.native_operations.lock(); *self.adapters.lock() = Some(Arc::new(ManagedAdapters { lifecycle, filesystem, @@ -1795,6 +2684,9 @@ impl ManagedCallContext { view_charge: Mutex::new(Some(view_charge)), })); *self.phase.lock() = CallPhase::Executing; + drop(_operations); + drop(state); + drop(_preparation); #[cfg(test)] let observer = binding.runtime.native_ready_observer.lock().clone(); #[cfg(test)] @@ -1829,8 +2721,12 @@ impl ManagedCallContext { Ok(()) } fn check_token_binding(&self, claims: &TokenClaims) -> Result<(), String> { + self.binding()?.check()?; + self.check_token_binding_authority(claims) + } + fn check_token_binding_authority(&self, claims: &TokenClaims) -> Result<(), String> { let binding = self.binding()?; - binding.check()?; + binding.check_authority()?; if !self.policy.matches( &binding.policy_catalog, &binding.code, @@ -1848,7 +2744,7 @@ impl ManagedCallContext { || claims.workspace != Path::new(binding.authority.canonical_path()) || claims.risk_ceiling.as_str() != self.identity.approved_risk || self.identity.code != binding.code - || self.identity.workspace != binding.workspace.identity + || self.identity.workspace != binding.admitted_branch { return Err("managed call closing or identity mismatch".into()); } @@ -1885,6 +2781,11 @@ impl ManagedCallContext { ) -> Result, String> { let binding = self.binding()?; binding.check()?; + let _preparation = binding.branch.enter_checked( + self.identity.epoch, + (&self.identity.owner.run_id, &self.identity.call_id), + || binding.check_authority(), + )?; self.fence_for_prepare()?; self.wait_operations_drained( binding @@ -1988,14 +2889,43 @@ impl ManagedCallContext { } }; binding.runtime.repository.command_with_cancellation("effect.prepare",&json!({"effect_id":self.effect_id,"receipt_id":self.receipt_id,"prepared_json":serde_json::to_string(&record).map_err(error)?,"cut":cut}),&binding.cancel)?; + #[cfg(test)] + let observer = binding.runtime.before_prepared_install.lock().clone(); + #[cfg(test)] + if let Some(observer) = observer { + observer( + binding + .call(&self.identity.call_id) + .ok_or("managed call unavailable")?, + ); + } let witness = Arc::new(PreparedEffectWitness { record, original: serde_json::from_str(canonical.as_str()).map_err(error)?, conflict, objects, }); + let state = binding.branch.state.lock(); + let permitted = state.phase == ManagedBranchPhase::Active + || (state.phase == ManagedBranchPhase::CheckpointPausing + && state.pending.contains(&( + self.identity.owner.run_id.clone(), + self.identity.call_id.clone(), + ))); + if binding.branch.epoch != self.identity.epoch || !permitted { + return Err("managed branch unavailable".into()); + } + binding.check_authority()?; + let operations = self.native_operations.lock(); + let mut phase = self.phase.lock(); + if *operations != 0 || *phase != CallPhase::Preparing { + return Err("managed preparation already fenced".into()); + } *self.prepared.lock() = Some(Arc::clone(&witness)); - *self.phase.lock() = CallPhase::Prepared; + *phase = CallPhase::Prepared; + drop(phase); + drop(operations); + drop(state); #[cfg(feature = "workspace-io-fault")] if binding .runtime @@ -2028,6 +2958,37 @@ impl ManagedCallContext { .clone() .ok_or("managed prepared witness unavailable".into()) } + fn close_for_restore(&self, deadline: std::time::Instant) -> Result<(), String> { + self.wait_operations_drained(deadline)?; + let phase = *self.phase.lock(); + match phase { + CallPhase::Registered if self.adapters.lock().is_none() => Ok(()), + CallPhase::Prepared | CallPhase::Final => { + let prepared = self.prepared.lock().clone().ok_or("managed_restore_busy")?; + if !matches!( + prepared.record.writer_state, + ManagedWriterState::Settled | ManagedWriterState::NoProcess + ) { + return Err("managed_restore_busy".into()); + } + prepared.check_ready(&self.binding()?.runtime) + } + CallPhase::Executing => { + let binding = self.binding()?; + binding + .authority + .check() + .map_err(|_| "managed_restore_busy")?; + let adapter = self.adapters.lock().clone().ok_or("managed_restore_busy")?; + adapter + .processes + .settle_managed_call(&adapter.token) + .map_err(|_| "managed_restore_busy")?; + Ok(()) + } + _ => Err("managed_restore_busy".into()), + } + } pub(crate) fn interrupt_unpublished(&self) { let mut phase = self.phase.lock(); if *phase != CallPhase::Final { @@ -2046,5 +3007,12 @@ impl ManagedCallContext { CallPhase::Unknown }; self.reservation.release_unspent(); + if known && let Ok(binding) = self.binding() { + binding.branch.state.lock().pending.remove(&( + self.identity.owner.run_id.clone(), + self.identity.call_id.clone(), + )); + binding.branch.changed.notify_all(); + } } } diff --git a/src/effects/journal.rs b/src/effects/journal.rs index 2a69607..7e8caef 100644 --- a/src/effects/journal.rs +++ b/src/effects/journal.rs @@ -4,10 +4,353 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use std::sync::Arc; +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "disposition", rename_all = "snake_case", deny_unknown_fields)] +pub enum ManagedMaintenanceFailure { + Rejected { + code: String, + }, + SubmittedUnknown { + request_id: String, + receipt_lookup_id: String, + }, +} +impl std::fmt::Display for ManagedMaintenanceFailure { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Rejected { code } => code, + Self::SubmittedUnknown { .. } => "managed_submitted_unknown", + }) + } +} +impl std::error::Error for ManagedMaintenanceFailure {} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ManagedSavedCheckpointReceipt { + pub(crate) original_request_proof: String, + pub(crate) receipt: ManagedCheckpointReceipt, +} + pub const MANAGED_FORMAT: &str = "agent.managed-effect/v1"; pub const WORKSPACE_FORMAT: &str = "agent.managed-workspace/v1"; pub const METADATA_BYTES: usize = 1024 * 1024; +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedSourceCas { + pub store_id: String, + pub workspace_id: String, + pub branch_id: String, + pub epoch: u64, + pub head_object_id: String, + pub head_version: u64, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ManagedCheckpointMode { + AtClosedBatch, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedCheckpointCreateRequest { + pub source_run_id: String, + pub workspace_id: String, + pub source_branch_id: String, + pub expected_epoch: u64, + pub request_id: String, + pub mode: ManagedCheckpointMode, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedClosedOutputRef { + pub run_id: String, + pub call_id: String, + pub parent_message_id: String, + pub output_message_id: String, + pub receipt_id: String, + pub result: ManagedObjectRef, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedFrozenHistoryRef { + pub history_root_id: String, + pub source_session_id: String, + pub session_generation: u64, + pub last_message_id: String, + pub last_ordinal: u64, + pub last_closed_batch_id: String, + pub message_count: usize, + pub byte_count: usize, + pub closed_output_refs: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedCheckpointGraphRef { + pub graph_id: String, + pub harness_selection: crate::plugins::HarnessSelection, + pub checkpoint_graph_ref_id: String, + pub captured_activation_generation: u64, + pub package_id: String, + pub artifact_id: String, + pub export: String, + pub registry_identity: String, + pub effect_contract_version: u32, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedCheckpointPublicConfig { + pub model: String, + pub provider: Option, + pub system_instructions: Option, + pub registry_identity: String, + pub tool_schemas_ref: String, + pub policy_revision: String, + pub publication_policy: ManagedCheckpointPublication, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ManagedCheckpointPublication { + ManagedOnly, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedPublicConfigRef { + pub config_ref_id: String, + pub config: ManagedCheckpointPublicConfig, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedPluginStateRootRef { + pub package_id: String, + pub artifact_id: String, + pub scope: String, + pub schema: String, + pub revision: u64, + pub immutable_root_id: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum ManagedCheckpointStateCoverage { + StatelessBuiltinWithPurePolicy { + graph_id: String, + package_id: String, + artifact_id: String, + policy: crate::plugins::PurePolicySelection, + manifest_state_absent: bool, + state_effects_absent: bool, + policy_host_imports_absent: bool, + roots: Vec, + }, + StatelessBuiltin { + graph_id: String, + package_id: String, + artifact_id: String, + manifest_state_absent: bool, + state_effects_absent: bool, + roots: Vec, + }, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedExternalHighWater { + pub run_id: String, + pub event_seq: u64, + pub provider_request_count: u64, + pub input_tokens: u64, + pub output_tokens: u64, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ManagedExternalNotice { + ProviderUsageNotReverted, + DeliveredContentNotReverted, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedCheckpointArtifactRef { + pub artifact_id: String, + pub object: ManagedObjectRef, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedExternalBoundary { + pub high_water: Vec, + pub not_reverted: Vec, + pub artifact_refs: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedCheckpointRef { + pub checkpoint_id: String, + pub workspace_id: String, + pub parent_run_id: String, + pub source_branch_id: String, + pub source_epoch: u64, + pub frozen_root: ManagedObjectRef, + pub history: ManagedFrozenHistoryRef, + pub graph: ManagedCheckpointGraphRef, + pub public_config: ManagedPublicConfigRef, + pub plugin_state: ManagedCheckpointStateCoverage, + pub external_boundary: ManagedExternalBoundary, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedCheckpointReceipt { + pub request_id: String, + pub checkpoint: ManagedCheckpointRef, + pub committed_at_ms: u64, + pub replayed: bool, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedBranchCreateRequest { + pub workspace_id: String, + pub source_branch_id: String, + pub checkpoint_id: Option, + pub expected_source: Option, + pub request_id: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedBranchReceipt { + pub request_id: String, + pub checkpoint_id: Option, + pub source: ManagedSourceCas, + pub branch: ManagedSourceCas, + pub replayed: bool, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ManagedSavedBranchReceipt { + pub proof: String, + pub receipt: ManagedBranchReceipt, + pub origin: Value, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedPublishRequest { + pub workspace_id: String, + pub branch_id: String, + pub expected_head: ManagedWorkspaceHead, + pub request_id: String, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedPublishReceipt { + pub status: String, + pub workspace_id: String, + pub profile: String, + pub branch_id: String, + pub epoch: u64, + pub head_object_id: String, + pub head_version: u64, + pub previous_published_head: Option, + pub request_id: String, + pub published_at_ms: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedWorkspacePublication { + pub workspace_id: String, + pub profile: String, + pub branch_id: String, + pub epoch: u64, + pub head_object_id: String, + pub head_version: u64, + pub previous_published_head: Option, + pub request_id: String, + pub published_at_ms: u64, + pub revision: u64, + pub store_id: String, + pub schema_version: u32, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ManagedSavedPublishReceipt { + pub proof: String, + pub receipt: ManagedPublishReceipt, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ManagedPublishEligibility { + pub code: String, + #[serde(default)] + pub revision: u64, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedRestoreRunInput { + pub text: String, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ManagedRestoreGraphSelection { + CheckpointExact, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedRestoreAndAdmitRequest { + pub workspace_id: String, + pub source_branch_id: String, + pub checkpoint_id: String, + pub expected_source: ManagedSourceCas, + pub request_id: String, + pub input: ManagedRestoreRunInput, + pub cancel_active_source: bool, + pub graph_selection: ManagedRestoreGraphSelection, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ManagedRestoreDisposition { + RestoredAndAdmitted, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedRestoreAndAdmitResult { + pub disposition: ManagedRestoreDisposition, + pub receipt_id: String, + pub checkpoint_id: String, + pub source_before: ManagedSourceCas, + pub source_after: ManagedSourceCas, + pub new_branch: ManagedSourceCas, + pub new_session_id: String, + pub new_run_id: String, + pub new_run_deadline_at_ms: u64, + pub graph: ManagedCheckpointGraphRef, + pub publication: ManagedCheckpointPublication, + pub external_effects_not_reverted: Vec, + pub replayed: bool, +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ManagedOwnerScope { @@ -194,6 +537,7 @@ pub struct ManagedWorkspaceHead { pub head_version: u64, pub store_id: String, pub schema_version: u32, + pub process_lineage_tainted: bool, } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -201,6 +545,31 @@ pub struct ManagedEffectGetRequest { pub owner: ManagedOwnerScope, pub call_id: String, } +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedRunEffectListRequest { + pub run_id: String, + pub after_effect_id: Option, + pub limit: usize, +} + +/// Closed public failures. Private Native/storage diagnostics never cross HTTP. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum ManagedRunEffectError { + InvalidRequest, + Unavailable, + StorageUnavailable, + Cancelled, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ManagedRunQueryBinding { + pub owner: ManagedOwnerScope, + pub workspace: ManagedWorkspaceIdentity, + pub epoch: u64, +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ManagedEffectListRequest { @@ -345,6 +714,164 @@ pub enum ManagedCallReplay { }, } +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ManagedFrozenMessage { + pub id: String, + pub ordinal: u64, + pub role: String, + pub content_json: String, + pub metadata_json: String, + pub run_id: Option, + pub name: Option, + pub tool_call_id: Option, + pub parent_message_id: Option, + pub finish_reason: Option, + pub token_estimate: Option, + pub compacted: bool, + pub created_at_ms: u64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ManagedSourceRunRef { + pub run_id: String, + pub session_id: String, + pub status: String, + pub original_deadline_ms: u64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ManagedFrozenCompaction { + pub id: String, + pub generation: u64, + pub source_start_ordinal: u64, + pub source_end_ordinal: u64, + pub retained_tail_ordinal: u64, + pub summary_json: String, + pub token_estimate: i64, + pub model: String, + pub created_at_ms: u64, + pub completed_at_ms: u64, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ManagedFrozenProviderOptions { + #[serde(skip_serializing_if = "Option::is_none")] + pub profile: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub protocol: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub base_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub credential_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reasoning_effort: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub top_p: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_output_tokens: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stream: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ManagedFrozenExecutionConfig { + pub provider_profile: String, + pub provider_options: ManagedFrozenProviderOptions, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ManagedCheckpointContext { + pub workspace: ManagedWorkspaceRecord, + pub head: ManagedWorkspaceHead, + pub session_id: String, + pub session_generation: u64, + pub last_ordinal: u64, + pub messages: Vec, + pub effects: Vec, + pub external: Vec, + pub unsettled_runs: usize, + pub source_runs: Vec, + pub pending_compactions: usize, + pub compaction: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ManagedCheckpointCommitInput { + pub request: ManagedCheckpointCreateRequest, + pub original_request_proof: String, + pub captured_source: ManagedSourceCas, + pub receipt: ManagedCheckpointReceipt, + pub messages: Vec, + pub compaction: Option, + pub execution_config: ManagedFrozenExecutionConfig, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ManagedCheckpointLookup { + pub checkpoint: ManagedCheckpointRef, + pub messages: Vec, + pub compaction: Option, + pub execution_config: ManagedFrozenExecutionConfig, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ManagedAdmissionPin { + pub harness_selection: crate::plugins::HarnessSelection, + pub graph_id: String, + pub ref_id: String, + pub generation: u64, + pub run_id: String, + pub session_id: String, + pub registry_identity: String, + pub workspace: String, + pub profile: String, + pub pinned: bool, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ManagedRollbackFacts { + pub source: ManagedSourceCas, + pub current_branch_id: String, + pub target_rows: u64, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ManagedBranchPublication { + pub graph: ManagedCheckpointGraphRef, + pub source: ManagedSourceCas, + pub current: ManagedSourceCas, + pub history_count: usize, + pub admission_pin: ManagedAdmissionPin, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct ManagedRestoreExecutionEnvelope { + pub admission_pin: ManagedAdmissionPin, + pub request: ManagedRestoreAndAdmitRequest, + pub original_request_proof: String, + pub result: ManagedRestoreAndAdmitResult, + pub history_root_id: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct ManagedSavedRestoreReceipt { + checkpoint_graph: ManagedCheckpointGraphRef, + original_request_proof: String, + envelope: ManagedRestoreExecutionEnvelope, +} + #[derive(Clone)] pub(crate) struct ManagedEffectRepository { persistence: Arc, @@ -375,7 +902,365 @@ impl ManagedEffectRepository { } self.persistence .managed_effect_command_with_cancellation(op, payload, cancellation) - .map_err(|e| e.to_string()) + .map_err(|e| match e.code.as_str() { + "plugin_graph_revoked" | "plugin_graph_pin_required" => e.code, + _ => e.to_string(), + }) + } + pub(crate) fn checkpoint_receipt( + &self, + request: &ManagedCheckpointCreateRequest, + cancellation: &crate::RunCancellation, + claim: Option<&Arc>, + ) -> Result, String> { + let proof = + serde_json::to_string(request).map_err(|_| "managed_metadata_budget_exceeded")?; + let payload = json!({"workspace_id":request.workspace_id,"receipt_id":request.request_id,"cut":"checkpoint"}); + let result = if let Some(claim) = claim { + self.persistence.maintenance_read( + claim, + &claim.identity, + &proof, + "effect.maintenance_receipt", + &payload, + cancellation, + )? + } else { + self.command_with_cancellation("effect.maintenance_receipt", &payload, cancellation)? + }; + let Some(saved): Option = decode_record(&result)? else { + return Ok(None); + }; + let original: ManagedCheckpointCreateRequest = + serde_json::from_str(&saved.original_request_proof) + .map_err(|_| "managed_restore_reconciliation_required")?; + if original != *request { + return Err("managed_request_conflict".into()); + } + if saved.receipt.request_id != request.request_id + || saved.receipt.checkpoint.workspace_id != request.workspace_id + || saved.receipt.checkpoint.source_branch_id != request.source_branch_id + || saved.receipt.checkpoint.parent_run_id != request.source_run_id + || saved.receipt.checkpoint.source_epoch != request.expected_epoch + { + return Err("managed_restore_reconciliation_required".into()); + } + Ok(Some(saved.receipt)) + } + pub(crate) fn maintenance_claim( + &self, + identity: ManagedWorkspaceIdentity, + proof: String, + request_id: String, + action: &'static str, + ) -> Result, String> { + self.persistence + .maintenance_claim(identity, proof, request_id, action) + } + pub(crate) fn checkpoint_submit( + &self, + input: &ManagedCheckpointCommitInput, + cancellation: &crate::RunCancellation, + claim: &Arc, + ) -> Result { + let payload = json!({"maintenance_json":serde_json::to_string(input).map_err(|_| "managed_metadata_budget_exceeded")?}); + let result = self + .persistence + .maintenance_submit(claim, &payload, cancellation) + .map_err(|error| error.code)?; + let receipt: ManagedCheckpointReceipt = decode_record(&result) + .map_err(|_| "managed_submitted_unknown")? + .ok_or("managed_submitted_unknown")?; + if receipt != input.receipt { + return Err("managed_submitted_unknown".into()); + } + Ok(receipt) + } + pub(crate) fn checkpoint_context( + &self, + request: &ManagedCheckpointCreateRequest, + cancellation: &crate::RunCancellation, + ) -> Result { + let result = self.command_with_cancellation( + "effect.checkpoint_context", + &json!({"run_id": request.source_run_id, "workspace_id": request.workspace_id, + "branch_id": request.source_branch_id, "profile": crate::config::ADMISSION_SESSION_PROFILE}), + cancellation, + )?; + decode_record(&result)?.ok_or_else(|| "managed_authority_required".into()) + } + pub(crate) fn lookup_restore_receipt( + &self, + request: &ManagedRestoreAndAdmitRequest, + cancellation: &crate::RunCancellation, + ) -> Result, String> { + self.lookup_restore_receipt_claim(request, cancellation, None) + } + pub(crate) fn lookup_restore_receipt_claim( + &self, + request: &ManagedRestoreAndAdmitRequest, + cancellation: &crate::RunCancellation, + claim: Option<&Arc>, + ) -> Result, String> { + let proof = + serde_json::to_string(request).map_err(|_| "managed_metadata_budget_exceeded")?; + let payload = json!({"workspace_id":request.workspace_id,"receipt_id":request.request_id,"cut":"restore","record_json":proof}); + let result = if let Some(claim) = claim { + self.persistence.maintenance_read( + claim, + &claim.identity, + &proof, + "effect.maintenance_receipt", + &payload, + cancellation, + )? + } else { + self.command_with_cancellation("effect.maintenance_receipt", &payload, cancellation)? + }; + let Some(saved): Option = decode_record(&result)? else { + return Ok(None); + }; + let original = serde_json::to_string(&saved.envelope.request) + .map_err(|_| "managed_restore_reconciliation_required")?; + if saved.envelope.admission_pin.harness_selection + != saved.envelope.result.graph.harness_selection + || saved.envelope.result.graph != saved.checkpoint_graph + || saved.envelope.admission_pin.graph_id != saved.envelope.result.graph.graph_id + || original != saved.original_request_proof + || saved.envelope.result.source_before != saved.envelope.request.expected_source + || saved.envelope.result.receipt_id != saved.envelope.request.request_id + || saved.envelope.result.checkpoint_id != saved.envelope.request.checkpoint_id + { + return Err("managed_restore_reconciliation_required".into()); + } + if proof != original { + return Err("managed_request_conflict".into()); + } + Ok(Some(saved.envelope.result)) + } + pub(crate) fn checkpoint_rollback_confirmed( + &self, + claim: &Arc, + input: &ManagedCheckpointCommitInput, + cancellation: &crate::RunCancellation, + ) -> Result { + if !claim.worker_failed() { + return Ok(false); + } + let payload = json!({"workspace_id":claim.identity.workspace_id,"receipt_id":claim.request_id,"cut":"checkpoint_rollback","record_json":serde_json::to_string(input).map_err(|_| "managed_metadata_budget_exceeded")?}); + let value = self.persistence.maintenance_read( + claim, + &claim.identity, + &claim.proof, + "effect.branch_publication_history", + &payload, + cancellation, + )?; + let facts: ManagedRollbackFacts = + decode_record(&value)?.ok_or("managed_restore_reconciliation_required")?; + Ok(facts.source == input.captured_source + && facts.current_branch_id == input.request.source_branch_id + && facts.target_rows == 0) + } + pub(crate) fn restore_rollback_confirmed( + &self, + claim: &Arc, + envelope: &ManagedRestoreExecutionEnvelope, + cancellation: &crate::RunCancellation, + ) -> Result { + if !claim.worker_failed() { + return Ok(false); + } + let payload = json!({"workspace_id":claim.identity.workspace_id,"receipt_id":claim.request_id,"cut":"rollback","record_json":serde_json::to_string(envelope).map_err(|_| "managed_metadata_budget_exceeded")?}); + let value = self.persistence.maintenance_read( + claim, + &claim.identity, + &claim.proof, + "effect.branch_publication_history", + &payload, + cancellation, + )?; + let facts: ManagedRollbackFacts = + decode_record(&value)?.ok_or("managed_restore_reconciliation_required")?; + Ok(facts.source == envelope.result.source_before + && facts.current_branch_id == envelope.request.source_branch_id + && facts.target_rows == 0) + } + pub(crate) fn branch_publication_history( + &self, + claim: &Arc, + envelope: &ManagedRestoreExecutionEnvelope, + cancellation: &crate::RunCancellation, + ) -> Result { + let payload = + json!({"workspace_id":claim.identity.workspace_id,"receipt_id":claim.request_id}); + let value = self.persistence.maintenance_read( + claim, + &claim.identity, + &claim.proof, + "effect.branch_publication_history", + &payload, + cancellation, + )?; + let page: ManagedBranchPublication = + decode_record(&value)?.ok_or("managed_restore_reconciliation_required")?; + if page.source != envelope.result.source_after + || page.current != envelope.result.new_branch + || page.history_count != 2 + || page.admission_pin != envelope.admission_pin + || page.graph != envelope.result.graph + { + return Err("managed_restore_reconciliation_required".into()); + } + Ok(page) + } + #[cfg(test)] + pub(crate) fn restore_receipt( + &self, + request: &ManagedRestoreAndAdmitRequest, + cancellation: &crate::RunCancellation, + ) -> Result { + self.lookup_restore_receipt(request, cancellation)? + .ok_or_else(|| "managed_restore_reconciliation_required".into()) + } + pub(crate) fn checkpoint_get( + &self, + workspace_id: &str, + checkpoint_id: &str, + cancellation: &crate::RunCancellation, + ) -> Result { + let result = self.command_with_cancellation( + "effect.checkpoint_get", + &json!({"workspace_id":workspace_id,"commit_id":checkpoint_id}), + cancellation, + )?; + decode_record(&result)?.ok_or_else(|| "managed_authority_required".into()) + } + pub(crate) fn branch_receipt( + &self, + workspace_id: &str, + request_id: &str, + cancellation: &crate::RunCancellation, + ) -> Result, String> { + let result = self.command_with_cancellation( + "effect.branch_receipt", + &json!({"workspace_id": workspace_id, "receipt_id": request_id}), + cancellation, + )?; + decode_record(&result) + } + pub(crate) fn branch_origin( + &self, + workspace_id: &str, + branch_id: &str, + cancellation: &crate::RunCancellation, + ) -> Result { + let result = self.command_with_cancellation( + "effect.branch_origin", + &json!({"workspace_id": workspace_id, "branch_id": branch_id}), + cancellation, + )?; + let cell = result + .get("rows") + .and_then(Value::as_array) + .and_then(|rows| rows.first()) + .and_then(Value::as_array) + .and_then(|row| row.first()) + .ok_or("managed_storage_unavailable")?; + let value = if let Some(text) = cell.as_str() { + serde_json::from_str::(text) + .map_err(|_| "managed_storage_unavailable".to_string())? + } else { + cell.clone() + }; + if value.get("foreign") == Some(&Value::Bool(true)) { + return Err("managed_authority_required".into()); + } + if value.get("missing") == Some(&Value::Bool(true)) { + return Err("managed_branch_missing".into()); + } + serde_json::from_value(value).map_err(|_| "managed_storage_unavailable".to_string()) + } + pub(crate) fn publish_receipt( + &self, + workspace_id: &str, + request_id: &str, + cancellation: &crate::RunCancellation, + ) -> Result, String> { + let payload = maintenance_payload(&json!({ + "workspace_id": workspace_id, + "request_id": request_id, + }))?; + let result = self + .command_with_cancellation("effect.publish_receipt", &payload, cancellation) + .map_err(|error| closed_read(&error))?; + decode_record(&result).map_err(|_| "managed_storage_unavailable".to_string()) + } + pub(crate) fn publish_eligibility( + &self, + document: &Value, + cancellation: &crate::RunCancellation, + ) -> Result { + let payload = maintenance_payload(document)?; + let result = self + .command_with_cancellation("effect.publish_eligibility", &payload, cancellation) + .map_err(|error| closed_read(&error))?; + decode_record(&result)?.ok_or_else(|| "managed_storage_unavailable".to_string()) + } + pub(crate) fn publication( + &self, + workspace_id: &str, + profile: &str, + cancellation: &crate::RunCancellation, + ) -> Result, String> { + let payload = maintenance_payload(&json!({ + "workspace_id": workspace_id, + "profile": profile, + }))?; + let result = self + .command_with_cancellation("effect.publication_get", &payload, cancellation) + .map_err(|error| closed_read(&error))?; + decode_record(&result).map_err(|_| "managed_storage_unavailable".to_string()) + } + pub(crate) fn publish_submit( + &self, + document: &Value, + cancellation: &crate::RunCancellation, + claim: &Arc, + ) -> Result { + let payload = maintenance_payload(document)?; + match self.persistence.publish_command( + "effect.publish_submit", + &payload, + cancellation, + Some(claim), + ) { + Ok(result) => decode_record(&result) + .map_err(|_| "managed_submitted_unknown".to_string())? + .ok_or_else(|| "managed_submitted_unknown".to_string()), + Err(error) if error.code == "managed_transaction_rolled_back" => { + Err("managed_transaction_rolled_back".into()) + } + Err(_) if claim.submitted() => Err("managed_submitted_unknown".into()), + Err(_) if cancellation.deadline_passed() || cancellation.has_deadline_overflow() => { + Err("managed_deadline_elapsed".into()) + } + Err(_) if cancellation.requested().is_some() => Err("managed_checkpoint_busy".into()), + Err(_) => Err("managed_storage_unavailable".into()), + } + } + pub(crate) fn branch_submit( + &self, + document: &Value, + cancellation: &crate::RunCancellation, + ) -> Result { + let payload = json!({ + "maintenance_json": serde_json::to_string(document) + .map_err(|_| "managed_metadata_budget_exceeded")? + }); + let result = + self.command_with_cancellation("effect.branch_create", &payload, cancellation)?; + decode_record(&result)?.ok_or_else(|| "managed_source_conflict".into()) } pub(crate) fn workspace( &self, @@ -387,6 +1272,18 @@ impl ManagedEffectRepository { let result = self.command_with_cancellation("effect.workspace_get", &json!({"store_id":store_id,"profile":profile,"source_device":source.device_decimal,"source_inode":source.inode_decimal}), cancellation)?; decode_record(&result) } + pub(crate) fn current_head( + &self, + workspace: &ManagedWorkspaceIdentity, + cancellation: &crate::RunCancellation, + ) -> Result { + let result = self.command_with_cancellation( + "effect.head", + &json!({"workspace_id":workspace.workspace_id,"branch_id":""}), + cancellation, + )?; + decode_record(&result)?.ok_or_else(|| "managed workspace head missing".into()) + } pub(crate) fn head( &self, workspace: &ManagedWorkspaceIdentity, @@ -489,6 +1386,25 @@ pub(crate) fn decode_effect_summaries( Ok(entries) } +fn maintenance_payload(document: &Value) -> Result { + let text = serde_json::to_string(document).map_err(|_| "managed_metadata_budget_exceeded")?; + if text.len() > METADATA_BYTES { + return Err("managed_metadata_budget_exceeded".into()); + } + Ok(json!({"maintenance_json": text})) +} + +fn closed_read(error: &str) -> String { + match error { + "managed_deadline_elapsed" | "deadline_elapsed" => "managed_deadline_elapsed".into(), + "managed_checkpoint_busy" | "cancelled" => "managed_checkpoint_busy".into(), + "managed_metadata_budget_exceeded" => "managed_metadata_budget_exceeded".into(), + other if other.contains("deadline") => "managed_deadline_elapsed".into(), + other if other.contains("cancel") => "managed_checkpoint_busy".into(), + _ => "managed_storage_unavailable".into(), + } +} + pub(crate) fn decode_record Deserialize<'de>>( value: &Value, ) -> Result, String> { @@ -517,10 +1433,12 @@ pub(crate) fn decode_record Deserialize<'de>>( return Err("managed record exceeds bound".into()); } let value = managed_document(text)?; + validate_maintenance_value::(&value)?; serde_json::from_value(value).map_err(|_| "managed named record decoding failed".into()) }) .transpose() } + pub(crate) fn storage_error(message: impl Into) -> StorageError { StorageError { code: "managed_effect_invalid".into(), @@ -536,6 +1454,56 @@ struct ManagedRecoveryContext { parent: String, } +pub(crate) fn validate_maintenance_value(value: &Value) -> Result<(), String> { + let ty = std::any::type_name::(); + let definition = ty.rsplit("::").next().unwrap_or(""); + let schema = match definition { + "ManagedCheckpointCreateRequest" + | "ManagedCheckpointReceipt" + | "ManagedCheckpointRef" + | "ManagedCheckpointCommitInput" + | "ManagedCheckpointLookup" + | "ManagedCheckpointContext" + | "ManagedSavedCheckpointReceipt" + | "ManagedPublishRequest" + | "ManagedPublishReceipt" + | "ManagedWorkspacePublication" => "managed-checkpoint.schema.yaml", + "ManagedRestoreAndAdmitRequest" + | "ManagedRestoreAndAdmitResult" + | "ManagedRestoreExecutionEnvelope" + | "ManagedSavedRestoreReceipt" + | "ManagedMaintenanceFailure" + | "ManagedBranchPublication" + | "ManagedRollbackFacts" => "managed-restore.schema.yaml", + _ => return Ok(()), + }; + crate::plugins::validate_managed_maintenance(schema, definition, value) + .map_err(|_| "managed maintenance schema rejected".into()) +} + +fn parse_maintenance Deserialize<'de>>(text: &str) -> Result { + let value = managed_document(text)?; + validate_maintenance_value::(&value)?; + serde_json::from_value(value).map_err(|_| "managed named maintenance decoding failed".into()) +} + +pub fn parse_managed_checkpoint_request_yaml( + text: &str, +) -> Result { + parse_maintenance(text) +} +pub fn parse_managed_checkpoint_yaml(text: &str) -> Result { + parse_maintenance(text) +} +pub fn parse_managed_restore_request_yaml( + text: &str, +) -> Result { + parse_maintenance(text) +} +pub fn parse_managed_restore_yaml(text: &str) -> Result { + parse_maintenance(text) +} + pub fn parse_managed_effect_yaml(text: &str) -> Result { let value = managed_document(text)?; crate::plugins::validate_managed_effect(&value) @@ -678,9 +1646,10 @@ pub(crate) fn recover_managed_effects(persistence: &GatewayPersistence) -> Resul message: message.clone(), }; let data = json!({"tool_call_id":call,"ok":false,"truncated":false,"error_code":code,"managed_publication":publication}); + let created_at_ms = crate::domain::timestamp(); let projections = json!({"normal":{"recovery":recovery, - "output":{"event_id":output.event_id,"event_type":"tool.output","seq":output.seq,"payload_json":data.to_string()}, - "terminal":{"event_id":terminal.event_id,"event_type":"tool.failed","seq":terminal.seq,"payload_json":data.to_string()}, + "output":{"event_id":output.event_id,"event_type":"tool.output","seq":output.seq,"payload_json":data.to_string(),"created_at_ms":created_at_ms}, + "terminal":{"event_id":terminal.event_id,"event_type":"tool.failed","seq":terminal.seq,"payload_json":data.to_string(),"created_at_ms":created_at_ms}, "message":{"id":message.id,"ordinal":message.ordinal,"content_json":content.to_string(),"parent_message_id":context.parent}}}); persistence.managed_effect_command("effect.recover_one",&json!({"effect_id":record.effect_id,"run_id":owner.run_id,"call_id":call,"commit_id":format!("recovery:{}",record.effect_id),"projections_json":projections.to_string()})).map_err(|e|e.to_string())?; } @@ -688,6 +1657,10 @@ pub(crate) fn recover_managed_effects(persistence: &GatewayPersistence) -> Resul Err("managed recovery work bound reached; startup unavailable with pending records".into()) } +#[cfg(test)] +#[path = "managed_checkpoint_schema_tests.rs"] +mod managed_checkpoint_schema_tests; + #[cfg(test)] mod document_gate { use super::*; @@ -695,6 +1668,25 @@ mod document_gate { use std::os::unix::fs::PermissionsExt; use std::sync::Arc; + #[test] + fn managed_named_decoder_rejects_false_restore_cancellation_by_current_schema() { + let mut document = json!({"workspace_id":format!("ws-{}","0".repeat(32)),"source_branch_id":format!("br-{}","0".repeat(32)),"checkpoint_id":"00000000-0000-4000-8000-000000000001","expected_source":{"store_id":format!("store-{}","0".repeat(32)),"workspace_id":format!("ws-{}","0".repeat(32)),"branch_id":format!("br-{}","0".repeat(32)),"epoch":1,"head_object_id":format!("tree-{}","0".repeat(32)),"head_version":1},"request_id":"00000000-0000-4000-8000-000000000002","input":{"text":"resume"},"cancel_active_source":true,"graph_selection":"checkpoint_exact"}); + let positive = + json!({"columns":["record_json"],"rows":[[document.to_string()]],"truncated":false}); + assert!( + decode_record::(&positive) + .unwrap() + .is_some() + ); + document["cancel_active_source"] = json!(false); + let wire = + json!({"columns":["record_json"],"rows":[[document.to_string()]],"truncated":false}); + assert!( + decode_record::(&wire).is_err(), + "formal typed decoder must enforce the current managed schema, including boolean rules" + ); + } + fn open_repository() -> (Arc, ManagedEffectRepository) { let root = std::path::PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()).join( diff --git a/src/effects/managed_authority_tests.rs b/src/effects/managed_authority_tests.rs index be9b1d2..ac720b1 100644 --- a/src/effects/managed_authority_tests.rs +++ b/src/effects/managed_authority_tests.rs @@ -21,6 +21,7 @@ async fn original_native_token_rejects_sibling_and_changed_claims_then_same_vm_w source_root: workspace, quota: super::super::ResourceQuota::new(1000, 1048576, 16).unwrap(), retention: crate::config::ManagedRetentionLimits::default(), + maintenance_policy: Default::default(), }), ..Default::default() }; @@ -108,11 +109,11 @@ async fn original_native_token_rejects_sibling_and_changed_claims_then_same_vm_w binding.cancel.clone() ) .is_err(), - "a signature or development trust cannot mint managed authority" + "a signature flipped onto the embedding package, or development trust, cannot mint managed authority" ); } let mut graph = binding.contract.clone(); - graph.single_builtin_graph = false; + graph.topology = None; assert!( binding .runtime @@ -129,6 +130,28 @@ async fn original_native_token_rejects_sibling_and_changed_claims_then_same_vm_w ) .is_err() ); + let mut unsigned_publisher = binding.contract.clone(); + unsigned_publisher.trust = crate::plugins::PackageTrust::AcceptedPublisher; + unsigned_publisher.topology = None; + let mut publisher_code = binding.code.clone(); + publisher_code.package_id = "publisher-tool".into(); + assert!( + binding + .runtime + .prepare_run_binding( + Arc::clone(&binding.authority), + binding.owner.clone(), + publisher_code, + unsigned_publisher, + ( + Arc::clone(&binding.policy_catalog), + Arc::clone(&binding.registry_snapshot) + ), + binding.cancel.clone() + ) + .is_err(), + "accepted publisher without a sealed topology cannot grant" + ); let mut pure = binding.contract.clone(); pure.effects.clear(); let pure = binding @@ -217,6 +240,11 @@ async fn original_native_token_rejects_sibling_and_changed_claims_then_same_vm_w } fn native_fixture() -> (PathBuf, AgentGatewayState) { + native_fixture_config(|_| {}) +} +pub(super) fn native_fixture_config( + configure: impl FnOnce(&mut AgentGatewayConfig), +) -> (PathBuf, AgentGatewayState) { let root = PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()).join(format!( "managed-native-ready-{}", uuid::Uuid::new_v4().simple() @@ -234,11 +262,13 @@ fn native_fixture() -> (PathBuf, AgentGatewayState) { source_root: workspace, quota: super::super::ResourceQuota::new(1000, 1048576, 16).unwrap(), retention: crate::config::ManagedRetentionLimits::default(), + maintenance_policy: Default::default(), }), ..Default::default() }; config.approvals.write = "allow".into(); config.approvals.process = "allow".into(); + configure(&mut config); let state = AgentGatewayState::with_sqlite_path(config, root.join("gateway.sqlite")) .unwrap() .with_workspace_home(&home); diff --git a/src/effects/managed_checkpoint_schema_tests.rs b/src/effects/managed_checkpoint_schema_tests.rs new file mode 100644 index 0000000..8b7a824 --- /dev/null +++ b/src/effects/managed_checkpoint_schema_tests.rs @@ -0,0 +1,313 @@ +//! Current managed schemas and the same strict Native named decoder. +use super::*; + +fn referenced_document(uri: &str) -> Value { + let name = uri.rsplit('/').next().unwrap_or(""); + let text = match name { + "plugin-graph-lock.schema.yaml" => { + include_str!("../../schemas/plugin-graph-lock.schema.yaml") + } + "managed-checkpoint.schema.yaml" => { + include_str!("../../schemas/managed-checkpoint.schema.yaml") + } + other => panic!("unregistered local schema reference {other}"), + }; + crate::plugins::strict_yaml_to_json(text).expect("referenced schema is strict YAML") +} + +fn sample(schema: &Value, body: &Value) -> Value { + if let Some(path) = body["$ref"].as_str() { + if let Some((uri, fragment)) = path.split_once('#') + && !uri.is_empty() + { + let document = referenced_document(uri); + return sample( + &document, + document + .pointer(fragment) + .expect("registered schema pointer"), + ); + } + return sample( + schema, + schema.pointer(path.trim_start_matches('#')).unwrap(), + ); + } + if let Some(value) = body.get("const") { + return value.clone(); + } + if let Some(values) = body["enum"].as_array() { + return values[0].clone(); + } + for union in ["anyOf", "oneOf"] { + if let Some(values) = body[union].as_array() { + return sample(schema, &values[0]); + } + } + match body["type"].as_str().unwrap() { + "object" => Value::Object( + body["properties"] + .as_object() + .unwrap() + .iter() + .map(|(key, value)| (key.clone(), sample(schema, value))) + .collect(), + ), + "array" => json!([]), + "boolean" => json!(false), + "integer" => body.get("minimum").cloned().unwrap_or(json!(0)), + "number" => json!(0.25), + "null" => Value::Null, + "string" => { + let pattern = body["pattern"].as_str().unwrap_or(""); + if pattern.starts_with("^[0-9a-fA-F]{8}") { + return json!("00000000-0000-4000-8000-000000000001"); + } + if let Some((prefix, _)) = pattern.strip_prefix('^').and_then(|p| p.split_once("-[")) { + return json!(format!("{prefix}-{}", "0".repeat(32))); + } + if pattern == "^[0-9]+$" { + return json!("0"); + } + json!("x") + } + kind => panic!("uncovered schema type {kind}"), + } +} +fn load_schema(name: &str) -> Value { + let text = match name { + "managed-checkpoint.schema.yaml" => { + include_str!("../../schemas/managed-checkpoint.schema.yaml") + } + "managed-restore.schema.yaml" => include_str!("../../schemas/managed-restore.schema.yaml"), + _ => panic!("unknown managed test schema"), + }; + serde_json::from_str(text).unwrap() +} +fn assert_named Deserialize<'de> + Serialize>(name: &str, definition: &str) { + let schema = load_schema(name); + let value = sample(&schema, &schema["$defs"][definition]); + crate::plugins::validate_managed_maintenance(name, definition, &value) + .unwrap_or_else(|e| panic!("{definition}: {e}")); + let typed: T = + serde_json::from_value(value.clone()).unwrap_or_else(|e| panic!("{definition}: {e}")); + let produced = serde_json::to_value(typed).unwrap(); + crate::plugins::validate_managed_maintenance(name, definition, &produced).unwrap(); + if let Some(fields) = value.as_object() { + let mut unknown = value.clone(); + unknown["unrecognized_authority"] = json!(true); + assert!(crate::plugins::validate_managed_maintenance(name, definition, &unknown).is_err()); + assert!(serde_json::from_value::(unknown).is_err()); + for key in fields.keys() { + let property = &schema["$defs"][definition]["properties"][key]; + let mut omitted = value.clone(); + omitted.as_object_mut().unwrap().remove(key); + let required = schema["$defs"][definition]["required"] + .as_array() + .unwrap() + .contains(&json!(key)); + assert_eq!( + crate::plugins::validate_managed_maintenance(name, definition, &omitted).is_err(), + required, + "{definition}.{key} required" + ); + let mut null = value.clone(); + null[key] = Value::Null; + let nullable = property["anyOf"] + .as_array() + .is_some_and(|items| items.iter().any(|p| p["type"] == "null")); + assert_eq!( + crate::plugins::validate_managed_maintenance(name, definition, &null).is_ok(), + nullable, + "{definition}.{key} null" + ); + if property["type"] == "boolean" || property.get("const").is_some_and(Value::is_boolean) + { + let mut wrong = value.clone(); + wrong[key] = json!("false"); + assert!( + crate::plugins::validate_managed_maintenance(name, definition, &wrong).is_err() + ); + assert!(serde_json::from_value::(wrong).is_err()); + } + } + } +} +#[test] +fn managed_every_named_producer_and_schema_agree_on_fields_bool_null_and_optional_rules() { + macro_rules! checkpoint { ($($ty:ty),+ $(,)?) => { $(assert_named::<$ty>("managed-checkpoint.schema.yaml",stringify!($ty));)+ }; } + macro_rules! restore { ($($ty:ty),+ $(,)?) => { $(assert_named::<$ty>("managed-restore.schema.yaml",stringify!($ty));)+ }; } + checkpoint!( + ManagedCheckpointCreateRequest, + ManagedCheckpointReceipt, + ManagedCheckpointRef, + ManagedSourceCas, + ManagedClosedOutputRef, + ManagedFrozenHistoryRef, + ManagedCheckpointGraphRef, + ManagedCheckpointPublicConfig, + ManagedPublicConfigRef, + ManagedPluginStateRootRef, + ManagedExternalHighWater, + ManagedCheckpointArtifactRef, + ManagedExternalBoundary, + ManagedFrozenMessage, + ManagedFrozenCompaction, + ManagedFrozenProviderOptions, + ManagedFrozenExecutionConfig, + ManagedCheckpointContext, + ManagedCheckpointCommitInput, + ManagedCheckpointLookup + ); + restore!( + ManagedRestoreRunInput, + ManagedRestoreAndAdmitRequest, + ManagedRestoreAndAdmitResult, + ManagedRestoreExecutionEnvelope, + ManagedAdmissionPin, + ManagedRollbackFacts, + ManagedBranchPublication + ); +} +#[test] +fn managed_public_parsers_reject_duplicates_aliases_tags_unknown_and_enum_drift() { + let schema = load_schema("managed-restore.schema.yaml"); + let request = sample(&schema, &schema["$defs"]["ManagedRestoreAndAdmitRequest"]); + let text = request.to_string(); + parse_managed_restore_request_yaml(&text).unwrap(); + for invalid in [ + text.replacen("{", "{\"request_id\":\"duplicate\",", 1), + text.replacen("checkpoint_exact", "active_default", 1), + text.replacen("true", "false", 1), + text.replacen("{", "{\"claims\":{},", 1), + "request_id: &anchor value\nworkspace_id: *anchor\n".into(), + "request_id: !!str value\n".into(), + "request_id: a\nrequest_id: b\n".into(), + ] { + assert!(parse_managed_restore_request_yaml(&invalid).is_err()); + } + let schema = load_schema("managed-checkpoint.schema.yaml"); + let receipt = sample(&schema, &schema["$defs"]["ManagedCheckpointReceipt"]); + parse_managed_checkpoint_yaml(&receipt.to_string()).unwrap(); + let mut state = receipt.clone(); + state["checkpoint"]["plugin_state"]["manifest_state_absent"] = json!(false); + assert!(parse_managed_checkpoint_yaml(&state.to_string()).is_err()); + let mut roots = receipt.clone(); + roots["checkpoint"]["plugin_state"]["roots"] = json!([sample( + &schema, + &schema["$defs"]["ManagedPluginStateRootRef"] + )]); + assert!(parse_managed_checkpoint_yaml(&roots.to_string()).is_err()); + let mut artifacts = receipt; + artifacts["checkpoint"]["external_boundary"]["artifact_refs"] = json!([sample( + &schema, + &schema["$defs"]["ManagedCheckpointArtifactRef"] + )]); + assert!(parse_managed_checkpoint_yaml(&artifacts.to_string()).is_err()); +} + +#[test] +fn managed_publish_request_receipt_and_publication_reject_unknown_missing_and_status_drift() { + let schema = load_schema("managed-checkpoint.schema.yaml"); + for definition in [ + "ManagedPublishRequest", + "ManagedPublishReceipt", + "ManagedWorkspacePublication", + ] { + let value = sample(&schema, &schema["$defs"][definition]); + crate::plugins::validate_managed_maintenance( + "managed-checkpoint.schema.yaml", + definition, + &value, + ) + .unwrap_or_else(|e| panic!("{definition}: {e}")); + let mut unknown = value.clone(); + unknown["unrecognized_authority"] = json!(true); + assert!( + crate::plugins::validate_managed_maintenance( + "managed-checkpoint.schema.yaml", + definition, + &unknown + ) + .is_err(), + "{definition} unknown" + ); + let required = schema["$defs"][definition]["required"] + .as_array() + .cloned() + .unwrap_or_default(); + assert!(!required.is_empty(), "{definition} required"); + let key = required[0].as_str().unwrap(); + let mut missing = value.clone(); + missing.as_object_mut().unwrap().remove(key); + assert!( + crate::plugins::validate_managed_maintenance( + "managed-checkpoint.schema.yaml", + definition, + &missing + ) + .is_err(), + "{definition}.{key} missing" + ); + } + let receipt = sample(&schema, &schema["$defs"]["ManagedPublishReceipt"]); + assert_eq!(receipt["status"], json!("committed")); + let mut drifted = receipt.clone(); + drifted["status"] = json!("staged"); + assert!( + crate::plugins::validate_managed_maintenance( + "managed-checkpoint.schema.yaml", + "ManagedPublishReceipt", + &drifted + ) + .is_err(), + "receipt status drift" + ); + let request = sample(&schema, &schema["$defs"]["ManagedPublishRequest"]); + let mut head_missing = request.clone(); + head_missing["expected_head"] + .as_object_mut() + .unwrap() + .remove("head_object_id"); + assert!( + crate::plugins::validate_managed_maintenance( + "managed-checkpoint.schema.yaml", + "ManagedPublishRequest", + &head_missing + ) + .is_err(), + "request expected_head missing member" + ); + let mut head_drift = request.clone(); + head_drift["expected_head"]["epoch"] = json!(0); + assert!( + crate::plugins::validate_managed_maintenance( + "managed-checkpoint.schema.yaml", + "ManagedPublishRequest", + &head_drift + ) + .is_err(), + "request expected_head bound drift" + ); + let publication = sample(&schema, &schema["$defs"]["ManagedWorkspacePublication"]); + let mut request_unknown = request.clone(); + request_unknown["claimed_authority"] = json!(true); + let mut receipt_unknown = receipt.clone(); + receipt_unknown["claimed_authority"] = json!(true); + let mut publication_unknown = publication.clone(); + publication_unknown["claimed_authority"] = json!(true); + assert!(validate_maintenance_value::(&request).is_ok()); + assert!(validate_maintenance_value::(&receipt).is_ok()); + assert!(validate_maintenance_value::(&publication).is_ok()); + assert!(validate_maintenance_value::(&request_unknown).is_err()); + assert!(validate_maintenance_value::(&receipt_unknown).is_err()); + assert!( + validate_maintenance_value::(&publication_unknown).is_err() + ); + assert!(validate_maintenance_value::(&head_missing).is_err()); + assert!(validate_maintenance_value::(&head_drift).is_err()); + assert!(validate_maintenance_value::(&drifted).is_err()); + assert!(serde_json::from_value::(request_unknown).is_err()); + assert!(serde_json::from_value::(receipt_unknown).is_err()); + assert!(serde_json::from_value::(publication_unknown).is_err()); +} diff --git a/src/effects/managed_restore_authority_tests.rs b/src/effects/managed_restore_authority_tests.rs new file mode 100644 index 0000000..77f69ec --- /dev/null +++ b/src/effects/managed_restore_authority_tests.rs @@ -0,0 +1,1626 @@ +//! Real managed Native owners at restore boundaries; no public-ID authority. +use super::*; +use crate::{AdmitRunRequest, AgentGatewayState, ScriptedProvider}; +use std::time::Duration; + +fn fixture(timeout: Duration) -> (PathBuf, AgentGatewayState) { + super::managed_authority_tests::native_fixture_config(|config| { + config.run_timeout = timeout; + config.bearer_token = Some("synthetic-maintenance-fixture".into()); + let policy = &mut config + .managed_workspace + .as_mut() + .unwrap() + .maintenance_policy; + policy.create_checkpoint = true; + policy.restore = true; + }) +} +async fn admit(service: &Arc, id: &str) -> crate::AdmittedRun { + admit_call( + service, + json!({"id":id,"name":"write_file","arguments":{"path":"value.txt","content":"A"}}), + ) + .await +} +async fn admit_call(service: &Arc, call: Value) -> crate::AdmittedRun { + let provider = Arc::new(ScriptedProvider::new()); + provider.push_ok(json!({"text":"","tool_calls":[call],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2},"reasoning":"","stop_reason":"tool_calls"})); + provider.push_ok(json!({"text":"done","tool_calls":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2},"reasoning":"","stop_reason":"stop"})); + service.inject_provider_host(provider); + service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"Native restore boundary"}), + ..Default::default() + }, + None, + ) + .await + .unwrap() +} +fn cas(head: &ManagedWorkspaceHead) -> ManagedSourceCas { + ManagedSourceCas { + store_id: head.store_id.clone(), + workspace_id: head.workspace_id.clone(), + branch_id: head.branch_id.clone(), + epoch: head.epoch, + head_object_id: head.head_object_id.clone(), + head_version: head.head_version, + } +} + +// This gate is inside the actual Native write, after operation RAII acquisition. +// Always release it and join the owned worker before propagating assertions. +#[tokio::test(flavor = "multi_thread")] +async fn restore_drain_obeys_original_source_owner_deadline() { + let (_root, state) = fixture(Duration::from_secs(4)); + let service = state.service(); + let runtime = service.managed_runtime_for_test().unwrap(); + let (entered, received) = std::sync::mpsc::sync_channel(1); + let (release, waiting) = std::sync::mpsc::sync_channel(1); + let waiting = Mutex::new(waiting); + *runtime.native_write_observer.lock() = Some(Arc::new(move |call| { + assert_eq!(call.active_native_operations(), 1); + entered.send(call).unwrap(); + waiting + .lock() + .recv_timeout(Duration::from_secs(12)) + .unwrap(); + })); + let run = admit(&service, "entered-deadline").await; + let worker = tokio::spawn( + service + .clone() + .run_worker(run.run_id.clone(), String::new()), + ); + let call = received.recv_timeout(Duration::from_secs(3)).unwrap(); + let binding = call.binding().unwrap(); + let head = runtime + .repository + .head(&binding.admitted_branch, &binding.cancel) + .unwrap(); + let source = cas(&head); + let frozen = runtime + .repository + .object_metadata( + &ManagedObjectReferencesRequest { + store_id: source.store_id.clone(), + object_id: source.head_object_id.clone(), + }, + &binding.cancel, + ) + .unwrap() + .unwrap(); + let prepared = runtime + .prepare_restore_branch( + &binding.authority, + &binding.admitted_branch, + &source, + &frozen, + ) + .unwrap(); + let original = binding.cancel.deadline_instant().unwrap(); + let management = RunCancellation::with_timeout(Duration::from_secs(9)); + let rt = runtime.clone(); + let mut drain = + tokio::task::spawn_blocking(move || rt.settle_restore_source(&prepared, &management)); + let result = tokio::time::timeout_at( + tokio::time::Instant::from_std(original + Duration::from_millis(400)), + &mut drain, + ) + .await; + release.send(()).unwrap(); + worker.await.unwrap(); + if result.is_err() { + let _ = drain.await.unwrap(); + } + *runtime.native_write_observer.lock() = None; + service.shutdown().await.unwrap(); + assert!( + result.is_ok(), + "restore drain extended an already-entered source owner's original deadline with the management clock" + ); + assert!(result.unwrap().unwrap().is_err()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn late_copy_cannot_install_adapter_after_restore_closes_source() { + let (_root, state) = fixture(Duration::from_secs(60)); + let service = state.service(); + let runtime = service.managed_runtime_for_test().unwrap(); + let (entered, received) = std::sync::mpsc::sync_channel(1); + let (release, waiting) = std::sync::mpsc::sync_channel(1); + let waiting = Mutex::new(waiting); + *runtime.before_adapter_install.lock() = Some(Arc::new(move |call| { + assert!(call.adapters.lock().is_none()); + entered.send(call).unwrap(); + waiting + .lock() + .recv_timeout(Duration::from_secs(10)) + .unwrap(); + })); + let run = admit(&service, "late-copy").await; + let worker = tokio::spawn( + service + .clone() + .run_worker(run.run_id.clone(), String::new()), + ); + let call = received.recv_timeout(Duration::from_secs(4)).unwrap(); + let binding = call.binding().unwrap(); + let head = runtime + .repository + .head(&binding.admitted_branch, &binding.cancel) + .unwrap(); + let frozen = runtime + .repository + .object_metadata( + &ManagedObjectReferencesRequest { + store_id: head.store_id.clone(), + object_id: head.head_object_id.clone(), + }, + &binding.cancel, + ) + .unwrap() + .unwrap(); + let prepared = runtime + .prepare_restore_branch( + &binding.authority, + &binding.admitted_branch, + &cas(&head), + &frozen, + ) + .unwrap(); + assert_eq!( + binding.branch.state.lock().active, + 1, + "actual copy preparation RAII remains held" + ); + release.send(()).unwrap(); + worker.await.unwrap(); + let installed = call.adapters.lock().is_some(); + drop(prepared); + *runtime.before_adapter_install.lock() = None; + service.shutdown().await.unwrap(); + assert!( + !installed, + "a view copied before restore closure installed its adapter after closure" + ); +} + +fn checkpoint_request( + run: &crate::AdmittedRun, + binding: &ManagedRunBinding, +) -> ManagedCheckpointCreateRequest { + ManagedCheckpointCreateRequest { + source_run_id: run.run_id.clone(), + workspace_id: binding.admitted_branch.workspace_id.clone(), + source_branch_id: binding.admitted_branch.branch_id.clone(), + expected_epoch: binding.admitted_epoch(), + request_id: uuid::Uuid::new_v4().to_string(), + mode: ManagedCheckpointMode::AtClosedBatch, + } +} +async fn checkpoint_source( + service: &Arc, +) -> ( + crate::AdmittedRun, + ManagedCheckpointCreateRequest, + ManagedCheckpointRef, +) { + let run = admit(service, "checkpoint-base").await; + let binding = service + .retained_tool_for_test(&run.run_id) + .unwrap() + .managed + .clone() + .unwrap(); + service + .clone() + .run_worker(run.run_id.clone(), String::new()) + .await; + let request = checkpoint_request(&run, &binding); + let checkpoint = service + .create_managed_checkpoint(request.clone()) + .await + .unwrap() + .checkpoint; + (run, request, checkpoint) +} +fn restore_request( + runtime: &ManagedEffectRuntime, + checkpoint: &ManagedCheckpointRef, +) -> ManagedRestoreAndAdmitRequest { + let read = RunCancellation::with_timeout(Duration::from_secs(5)); + let context = runtime + .repository + .checkpoint_context( + &ManagedCheckpointCreateRequest { + source_run_id: checkpoint.parent_run_id.clone(), + workspace_id: checkpoint.workspace_id.clone(), + source_branch_id: checkpoint.source_branch_id.clone(), + expected_epoch: checkpoint.source_epoch, + request_id: uuid::Uuid::new_v4().to_string(), + mode: ManagedCheckpointMode::AtClosedBatch, + }, + &read, + ) + .unwrap(); + ManagedRestoreAndAdmitRequest { + workspace_id: checkpoint.workspace_id.clone(), + source_branch_id: checkpoint.source_branch_id.clone(), + checkpoint_id: checkpoint.checkpoint_id.clone(), + expected_source: cas(&context.head), + request_id: uuid::Uuid::new_v4().to_string(), + input: ManagedRestoreRunInput { + text: "new branch read A and write C".into(), + }, + cancel_active_source: true, + graph_selection: ManagedRestoreGraphSelection::CheckpointExact, + } +} +struct ReleaseGate { + released: Mutex, + changed: parking_lot::Condvar, +} +impl ReleaseGate { + fn wait(&self) { + let mut state = self.released.lock(); + let end = std::time::Instant::now() + Duration::from_secs(12); + while !*state { + assert!( + !self.changed.wait_until(&mut state, end).timed_out(), + "owned barrier deadline" + ); + } + } + fn release(&self) { + *self.released.lock() = true; + self.changed.notify_all(); + } +} +struct HeldOwner { + call: Arc, + binding: Arc, + adapter: Arc, + _lease: ManagedObjectLease, +} +fn held(call: Arc) -> HeldOwner { + let binding = call.binding().unwrap(); + let adapter = call.adapters.lock().clone().unwrap(); + let lease = binding + .runtime + .leases + .lease_existing_for_test(&adapter.view_id) + .unwrap(); + HeldOwner { + call, + binding, + adapter, + _lease: lease, + } +} +fn reject_old_native( + owner: &HeldOwner, + runtime: &ManagedEffectRuntime, + identity: &ManagedWorkspaceIdentity, + new_branch: Option<&ManagedWorkspaceIdentity>, +) { + let read = RunCancellation::with_timeout(Duration::from_secs(5)); + let head = runtime.repository.head(identity, &read).unwrap(); + let new_head = new_branch.map(|identity| runtime.repository.head(identity, &read).unwrap()); + let before = fs::read(owner.adapter.work.path().join("value.txt")).ok(); + let check = |denied: bool| { + assert!(denied); + assert_eq!(owner.call.active_native_operations(), 0); + assert_eq!( + fs::read(owner.adapter.work.path().join("value.txt")).ok(), + before + ); + assert!(!owner.adapter.work.path().join("denied.txt").exists()); + assert_eq!(runtime.repository.head(identity, &read).unwrap(), head); + assert_eq!( + new_branch.map(|identity| runtime.repository.head(identity, &read).unwrap()), + new_head + ); + }; + let token = &owner.adapter.token; + check( + owner + .adapter + .filesystem + .metadata(token, "value.txt") + .is_err(), + ); + check( + owner + .adapter + .filesystem + .read_range(token, "value.txt", 0, 64) + .is_err(), + ); + check(owner.adapter.filesystem.list(token, "", 0, 16).is_err()); + check( + owner + .adapter + .filesystem + .write_atomic(token, "denied.txt", "*", b"denied") + .is_err(), + ); + check( + owner + .adapter + .processes + .spawn( + token, + &["/bin/true".into()], + ".", + &[], + ProcessLimits::default(), + ) + .is_err(), + ); + // No process exists in a restorable lineage. Existing-object control is + // covered by the explicit process-lineage rejection tests, not this handle. + let handle = "unissued-handle"; + check(owner.adapter.processes.poll(token, handle, 0, 64).is_err()); + check( + owner + .adapter + .processes + .wait(token, handle, Some(1)) + .is_err(), + ); + check(owner.adapter.processes.log(token, handle, 0, 64).is_err()); + check( + owner + .adapter + .processes + .write_stdin(token, handle, b"denied", Some(1)) + .is_err(), + ); + check(owner.adapter.processes.close_stdin(token, handle).is_err()); + check(owner.adapter.processes.kill(token, handle).is_err()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn restore_closes_two_live_runs_and_evicted_issued_owner_then_new_branch_performs_io() { + let (_root, state) = fixture(Duration::from_secs(90)); + let service = state.service(); + let runtime = service.managed_runtime_for_test().unwrap(); + let (_source, _request, checkpoint) = checkpoint_source(&service).await; + // A separate real run issues a view, finishes, then leaves the live handle table. + let (sent, received) = std::sync::mpsc::channel(); + *runtime.native_ready_observer.lock() = Some(Arc::new(move |call| { + sent.send(held(call)).unwrap(); + })); + let evicted = admit(&service, "issued-evicted").await; + service + .clone() + .run_worker(evicted.run_id.clone(), String::new()) + .await; + let evicted_owner = received.recv_timeout(Duration::from_secs(2)).unwrap(); + service.evict_run_handle(&evicted.run_id); + let gate = Arc::new(ReleaseGate { + released: Mutex::new(false), + changed: parking_lot::Condvar::new(), + }); + let blocked = gate.clone(); + let (sent, received) = std::sync::mpsc::channel(); + *runtime.native_ready_observer.lock() = Some(Arc::new(move |call| { + sent.send(held(call)).unwrap(); + blocked.wait(); + })); + let first = admit(&service, "before-enter-one").await; + let first_worker = tokio::spawn( + service + .clone() + .run_worker(first.run_id.clone(), String::new()), + ); + let one = received.recv_timeout(Duration::from_secs(3)).unwrap(); + let second = admit(&service, "before-enter-two").await; + let second_worker = tokio::spawn( + service + .clone() + .run_worker(second.run_id.clone(), String::new()), + ); + let two = received.recv_timeout(Duration::from_secs(3)).unwrap(); + assert!(Arc::ptr_eq(&one.binding.branch, &two.binding.branch)); + assert!(Arc::ptr_eq( + &one.binding.branch, + &evicted_owner.binding.branch + )); + let request = restore_request(&runtime, &checkpoint); + let (sent, closed) = std::sync::mpsc::sync_channel(1); + *runtime.restore_closed_observer.lock() = Some(Arc::new(move |stage| { + sent.send(stage.to_string()).unwrap(); + })); + let task = tokio::spawn({ + let service = service.clone(); + async move { service.restore_managed_checkpoint_and_admit(request).await } + }); + // The exact gate precedes Native enter, so no old effect can start. + let checks = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + assert_eq!( + closed.recv_timeout(Duration::from_secs(3)).unwrap(), + "closed" + ); + assert_eq!( + closed.recv_timeout(Duration::from_secs(3)).unwrap(), + "cancelled" + ); + for owner in [&one, &two, &evicted_owner] { + reject_old_native(owner, &runtime, &one.binding.admitted_branch, None); + } + assert!( + !task.is_finished(), + "restore must wait for original workers to exit" + ); + })); + gate.release(); + first_worker.await.unwrap(); + second_worker.await.unwrap(); + let result = task.await.unwrap(); + *runtime.native_ready_observer.lock() = None; + *runtime.restore_closed_observer.lock() = None; + if let Err(error) = checks { + service.shutdown().await.unwrap(); + std::panic::resume_unwind(error); + } + if result.is_err() { + for owner in [&one, &two, &evicted_owner] { + let phase = *owner.call.phase.lock(); + eprintln!( + "owner {}: final={} unknown={} prepared={} event_names={:?}", + owner.call.identity.call_id, + phase == CallPhase::Final, + phase == CallPhase::Unknown, + owner.call.prepared.lock().is_some(), + service + .run_events(&owner.call.identity.owner.run_id) + .iter() + .map(|event| event["event"].clone()) + .collect::>() + ); + } + } + let result = result.unwrap(); + assert!(one.binding.branch.state.lock().phase == ManagedBranchPhase::Retired); + let new_binding = service + .retained_tool_for_test(&result.new_run_id) + .unwrap() + .managed + .clone() + .unwrap(); + for owner in [&one, &two, &evicted_owner] { + reject_old_native( + owner, + &runtime, + &one.binding.admitted_branch, + Some(&new_binding.admitted_branch), + ); + } + let witness = evicted_owner.call.witness().unwrap(); + let old_owner = CapabilityOwner::new( + "gateway", + &evicted_owner.call.identity.owner.session_id, + &evicted_owner.call.identity.owner.run_id, + ) + .unwrap(); + assert!( + evicted_owner + .adapter + .lifecycle + .commit( + &old_owner, + &evicted_owner.adapter.token, + witness.original.clone() + ) + .is_err() + ); + let provider = Arc::new(ScriptedProvider::new()); + provider.push_ok(json!({"text":"","tool_calls":[{"id":"new-read","name":"read_file","arguments":{"path":"value.txt"}},{"id":"new-write","name":"write_file","arguments":{"path":"value.txt","content":"C"}}],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2},"reasoning":"","stop_reason":"tool_calls"})); + provider.push_ok(json!({"text":"done","tool_calls":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2},"reasoning":"","stop_reason":"stop"})); + service.inject_provider_host(provider); + service + .clone() + .run_worker(result.new_run_id.clone(), String::new()) + .await; + let get = |call_id: &str| { + state + .persistence() + .unwrap() + .managed_effect_get(&ManagedEffectGetRequest { + owner: ManagedOwnerScope { + profile: "gateway".into(), + session_id: result.new_session_id.clone(), + run_id: result.new_run_id.clone(), + }, + call_id: call_id.into(), + }) + .unwrap() + .unwrap() + }; + let read = get("new-read"); + let write = get("new-write"); + assert_eq!( + read.outcome.as_ref().unwrap().disposition, + ManagedPublicationStatus::ReadOnly + ); + let canonical = runtime + .read_result(&read.outcome.unwrap().canonical_result_ref) + .unwrap(); + assert_eq!(canonical["content"], "A"); + assert_eq!( + write.outcome.as_ref().unwrap().disposition, + ManagedPublicationStatus::Committed + ); + assert_ne!( + write.identity.workspace.branch_id, + one.binding.admitted_branch.branch_id + ); + service.shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn foreground_intent_before_native_taints_lineage_and_denies_both_maintenance_entries() { + process_lineage_case(true).await; +} +#[tokio::test(flavor = "multi_thread")] +async fn settled_foreground_with_empty_owned_table_still_denies_checkpoint_and_restore() { + process_lineage_case(false).await; +} +async fn process_lineage_case(after_intent: bool) { + let (_root, state) = fixture(Duration::from_secs(90)); + let service = state.service(); + let runtime = service.managed_runtime_for_test().unwrap(); + let (_base, mut checkpoint_request, checkpoint) = checkpoint_source(&service).await; + let restore = restore_request(&runtime, &checkpoint); + let (sent, received) = std::sync::mpsc::channel(); + *runtime.native_ready_observer.lock() = Some(Arc::new(move |call| { + sent.send(held(call)).unwrap(); + })); + if after_intent { + runtime.arm_fault(ManagedEffectFault::AfterIntent); + } + let run=admit_call(&service,json!({"id":"foreground-lineage","name":"terminal","arguments":{"argv":["/bin/true"],"timeout_ms":1000}})).await; + service + .clone() + .run_worker(run.run_id.clone(), String::new()) + .await; + let read = RunCancellation::with_timeout(Duration::from_secs(5)); + let record = runtime + .repository + .get( + &ManagedEffectGetRequest { + owner: ManagedOwnerScope { + profile: "gateway".into(), + session_id: run.session_id, + run_id: run.run_id, + }, + call_id: "foreground-lineage".into(), + }, + &read, + ) + .unwrap() + .expect("formal started intent exists"); + if after_intent { + assert!(record.prepared.is_none()); + assert!( + received.try_recv().is_err(), + "AfterIntent precedes Native view construction" + ); + } else { + let owner = received.recv_timeout(Duration::from_secs(2)).unwrap(); + assert_eq!(owner.adapter.processes.table_len(), 0); + assert_eq!(owner.adapter.processes.active_stdin_workers(), 0); + assert_eq!( + record.prepared.as_ref().unwrap().writer_state, + ManagedWriterState::Settled + ); + assert_eq!( + record.outcome.as_ref().unwrap().disposition, + ManagedPublicationStatus::Committed + ); + } + let context = runtime + .repository + .checkpoint_context(&checkpoint_request, &read) + .unwrap(); + assert!(context.head.process_lineage_tainted); + checkpoint_request.request_id = uuid::Uuid::new_v4().to_string(); + let denied_checkpoint = service + .create_managed_checkpoint(checkpoint_request.clone()) + .await; + let denied_restore = service + .restore_managed_checkpoint_and_admit(restore.clone()) + .await; + assert!( + matches!(denied_checkpoint,Err(ManagedMaintenanceFailure::Rejected {code}) if code=="managed_process_lineage_unsupported") + ); + assert!( + matches!(denied_restore,Err(ManagedMaintenanceFailure::Rejected {code}) if code=="managed_process_lineage_unsupported") + ); + assert_eq!( + runtime + .repository + .head(&context.workspace.identity, &read) + .unwrap(), + context.head + ); + assert!( + runtime + .repository + .lookup_restore_receipt(&restore, &read) + .unwrap() + .is_none() + ); + assert!( + runtime + .repository + .checkpoint_receipt(&checkpoint_request, &read, None) + .unwrap() + .is_none() + ); + *runtime.native_ready_observer.lock() = None; + service.shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn closure_between_authorization_and_operation_raii_denies_actual_direct_write() { + let (_root, state) = fixture(Duration::from_secs(60)); + let service = state.service(); + let runtime = service.managed_runtime_for_test().unwrap(); + let ready = Arc::new(ReleaseGate { + released: Mutex::new(false), + changed: parking_lot::Condvar::new(), + }); + let wait = ready.clone(); + let (sent, received) = std::sync::mpsc::channel(); + *runtime.native_ready_observer.lock() = Some(Arc::new(move |call| { + sent.send(held(call)).unwrap(); + wait.wait(); + })); + let run = admit(&service, "atomic-enter").await; + let worker = tokio::spawn( + service + .clone() + .run_worker(run.run_id.clone(), String::new()), + ); + let owner = received.recv_timeout(Duration::from_secs(3)).unwrap(); + let gate = Arc::new(ReleaseGate { + released: Mutex::new(false), + changed: parking_lot::Condvar::new(), + }); + let wait = gate.clone(); + let (sent, received) = std::sync::mpsc::channel(); + *runtime.before_operation_count.lock() = Some(Arc::new(move |call| { + assert_eq!(call.active_native_operations(), 0); + sent.send(()).unwrap(); + wait.wait(); + })); + let adapter = owner.adapter.clone(); + let direct = tokio::task::spawn_blocking(move || { + adapter + .filesystem + .write_atomic(&adapter.token, "must-not-start.txt", "*", b"forbidden") + }); + received.recv_timeout(Duration::from_secs(3)).unwrap(); + let head = runtime + .repository + .head(&owner.binding.admitted_branch, &owner.binding.cancel) + .unwrap(); + let frozen = runtime + .repository + .object_metadata( + &ManagedObjectReferencesRequest { + store_id: head.store_id.clone(), + object_id: head.head_object_id.clone(), + }, + &owner.binding.cancel, + ) + .unwrap() + .unwrap(); + let prepared = runtime + .prepare_restore_branch( + &owner.binding.authority, + &owner.binding.admitted_branch, + &cas(&head), + &frozen, + ) + .unwrap(); + gate.release(); + let outcome = direct.await.unwrap(); + *runtime.before_operation_count.lock() = None; + let changed = owner + .adapter + .work + .path() + .join("must-not-start.txt") + .exists(); + drop(prepared); + ready.release(); + worker.await.unwrap(); + *runtime.native_ready_observer.lock() = None; + service.shutdown().await.unwrap(); + assert!( + outcome.is_err(), + "old direct Native write obtained operation evidence after source closure" + ); + assert!(!changed); +} + +#[tokio::test(flavor = "multi_thread")] +async fn entered_write_drains_before_restore_outcome_and_never_publishes_old_candidate() { + let (_root, state) = fixture(Duration::from_secs(90)); + let service = state.service(); + let runtime = service.managed_runtime_for_test().unwrap(); + let (_base, _request, checkpoint) = checkpoint_source(&service).await; + let gate = Arc::new(ReleaseGate { + released: Mutex::new(false), + changed: parking_lot::Condvar::new(), + }); + let wait = gate.clone(); + let (sent, received) = std::sync::mpsc::channel(); + *runtime.native_write_observer.lock() = Some(Arc::new(move |call| { + assert_eq!(call.active_native_operations(), 1); + sent.send(held(call)).unwrap(); + wait.wait(); + })); + let run=admit_call(&service,json!({"id":"entered-source-write","name":"write_file","arguments":{"path":"entered.txt","content":"B"}})).await; + let worker = tokio::spawn( + service + .clone() + .run_worker(run.run_id.clone(), String::new()), + ); + let owner = received.recv_timeout(Duration::from_secs(3)).unwrap(); + let request = restore_request(&runtime, &checkpoint); + let original = request.clone(); + let (sent, closed) = std::sync::mpsc::channel(); + *runtime.restore_closed_observer.lock() = Some(Arc::new(move |stage| { + sent.send(stage.to_string()).unwrap(); + })); + let task = tokio::spawn({ + let service = service.clone(); + async move { service.restore_managed_checkpoint_and_admit(request).await } + }); + let checks = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + assert_eq!( + closed.recv_timeout(Duration::from_secs(3)).unwrap(), + "closed" + ); + assert_eq!(owner.call.active_native_operations(), 1); + assert!(!task.is_finished()); + assert!( + !owner.adapter.work.path().join("entered.txt").exists(), + "actual write is after the acquired RAII barrier" + ); + assert!( + runtime + .repository + .lookup_restore_receipt( + &original, + &RunCancellation::with_timeout(Duration::from_secs(5)) + ) + .unwrap() + .is_none() + ); + })); + gate.release(); + worker.await.unwrap(); + let outcome = task.await.unwrap(); + *runtime.native_write_observer.lock() = None; + *runtime.restore_closed_observer.lock() = None; + let read = RunCancellation::with_timeout(Duration::from_secs(5)); + let head = runtime + .repository + .head(&owner.binding.admitted_branch, &read) + .unwrap(); + assert_eq!( + head.head_object_id, original.expected_source.head_object_id, + "private old write cannot publish its candidate after closure" + ); + if let Ok(result) = outcome { + assert_eq!(result.source_before, original.expected_source); + assert!( + !runtime + .path + .join("trees") + .join(result.new_branch.head_object_id) + .join("entered.txt") + .exists() + ); + } else { + assert!( + runtime + .repository + .lookup_restore_receipt(&original, &read) + .unwrap() + .is_none() + ); + assert!(owner.binding.branch.state.lock().phase == ManagedBranchPhase::Active); + } + assert_eq!(owner.call.active_native_operations(), 0); + service.shutdown().await.unwrap(); + if let Err(error) = checks { + std::panic::resume_unwind(error); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn prepared_and_readonly_witnesses_cannot_commit_across_source_closure() { + for readonly in [false, true] { + let (_root, state) = fixture(Duration::from_secs(90)); + let service = state.service(); + let runtime = service.managed_runtime_for_test().unwrap(); + let (_base, _request, checkpoint) = checkpoint_source(&service).await; + let (sent, received) = std::sync::mpsc::channel(); + *runtime.native_ready_observer.lock() = Some(Arc::new(move |call| { + sent.send(held(call)).unwrap(); + })); + let gate = runtime.arm_prepare_gate(Some("prepared-source")); + let tool = if readonly { + json!({"id":"prepared-source","name":"read_file","arguments":{"path":"value.txt"}}) + } else { + json!({"id":"prepared-source","name":"write_file","arguments":{"path":"prepared-only.txt","content":"B"}}) + }; + let run = admit_call(&service, tool).await; + let worker = tokio::spawn( + service + .clone() + .run_worker(run.run_id.clone(), String::new()), + ); + let owner = received.recv_timeout(Duration::from_secs(3)).unwrap(); + gate.wait_arrived(1).unwrap(); + let witness = owner.call.witness().unwrap(); + assert!(owner.call.outcome.lock().is_none()); + assert_eq!( + witness.record.operation_class, + if readonly { + ManagedOperationClass::ReadOnly + } else { + ManagedOperationClass::TreeWrite + } + ); + let request = restore_request(&runtime, &checkpoint); + let original = request.clone(); + let (sent, closed) = std::sync::mpsc::channel(); + *runtime.restore_closed_observer.lock() = Some(Arc::new(move |stage| { + sent.send(stage.to_string()).unwrap(); + })); + let task = tokio::spawn({ + let service = service.clone(); + async move { service.restore_managed_checkpoint_and_admit(request).await } + }); + let checks = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + assert_eq!( + closed.recv_timeout(Duration::from_secs(3)).unwrap(), + "closed" + ); + assert!(!task.is_finished(), "prepared RAII must drain"); + assert!(owner.binding.check().is_err()); + })); + gate.release(); + worker.await.unwrap(); + let outcome = task.await.unwrap(); + *runtime.native_ready_observer.lock() = None; + *runtime.restore_closed_observer.lock() = None; + let read = RunCancellation::with_timeout(Duration::from_secs(5)); + let head = runtime + .repository + .head(&owner.binding.admitted_branch, &read) + .unwrap(); + assert_eq!(head.head_object_id, original.expected_source.head_object_id); + assert!( + owner.call.outcome.lock().is_none(), + "neither read-only nor tree-write late commit may become a durable outcome" + ); + let owner_facts = CapabilityOwner::new( + "gateway", + &owner.call.identity.owner.session_id, + &owner.call.identity.owner.run_id, + ) + .unwrap(); + assert!( + owner + .adapter + .lifecycle + .commit(&owner_facts, &owner.adapter.token, witness.original.clone()) + .is_err() + ); + if outcome.is_err() { + assert!( + runtime + .repository + .lookup_restore_receipt(&original, &read) + .unwrap() + .is_none() + ); + } + service.shutdown().await.unwrap(); + if let Err(error) = checks { + std::panic::resume_unwind(error); + } + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn identity_rejections_leave_source_and_receipts_unchanged_with_same_owner_positive_control() +{ + let (root, state) = fixture(Duration::from_secs(90)); + let service = state.service(); + let runtime = service.managed_runtime_for_test().unwrap(); + let (_base, create, checkpoint) = checkpoint_source(&service).await; + let original = restore_request(&runtime, &checkpoint); + let (_other_root, other_state) = fixture(Duration::from_secs(90)); + let other = other_state.service(); + let (other_run, _other_create, other_checkpoint) = checkpoint_source(&other).await; + let foreign = admit(&other, "foreign-authority").await; + let foreign_binding = other + .retained_tool_for_test(&foreign.run_id) + .unwrap() + .managed + .clone() + .unwrap(); + assert!( + runtime + .authorize_query(&foreign_binding.authority, &foreign_binding.admitted_branch) + .is_err() + ); + assert!( + runtime + .prepare_restore_branch( + &foreign_binding.authority, + &foreign_binding.admitted_branch, + &original.expected_source, + &checkpoint.frozen_root + ) + .is_err() + ); + let local = admit(&service, "local-authority").await; + let binding = service + .retained_tool_for_test(&local.run_id) + .unwrap() + .managed + .clone() + .unwrap(); + assert!( + runtime + .authorize_workspace_query( + &binding.authority, + "other-profile", + &original.workspace_id, + &binding.cancel + ) + .is_err() + ); + assert!( + runtime + .authorize_query(&binding.authority, &foreign_binding.admitted_branch) + .is_err() + ); + let mut variants = Vec::new(); + let mut wrong = original.clone(); + wrong.workspace_id = other_checkpoint.workspace_id.clone(); + wrong.expected_source.workspace_id = wrong.workspace_id.clone(); + variants.push(wrong); + let mut wrong = original.clone(); + wrong.source_branch_id = other_checkpoint.source_branch_id.clone(); + wrong.expected_source.branch_id = wrong.source_branch_id.clone(); + variants.push(wrong); + let mut wrong = original.clone(); + wrong.expected_source.store_id = foreign_binding.admitted_branch.store_id.clone(); + variants.push(wrong); + let mut wrong = original.clone(); + wrong.checkpoint_id = other_checkpoint.checkpoint_id; + variants.push(wrong); + for mut wrong in variants { + wrong.request_id = uuid::Uuid::new_v4().to_string(); + assert!( + service + .restore_managed_checkpoint_and_admit(wrong.clone()) + .await + .is_err() + ); + let read = RunCancellation::with_timeout(Duration::from_secs(5)); + assert!( + runtime + .repository + .lookup_restore_receipt(&wrong, &read) + .unwrap() + .is_none() + ); + assert_eq!( + cas(&runtime + .repository + .head(&binding.admitted_branch, &read) + .unwrap()), + original.expected_source + ); + } + // The actual Native grant can be revoked independently of matching public facts. + binding.authority.revoke(); + assert!( + runtime + .prepare_restore_branch( + &binding.authority, + &binding.admitted_branch, + &original.expected_source, + &checkpoint.frozen_root + ) + .is_err() + ); + service + .clone() + .run_worker(local.run_id, String::new()) + .await; + other + .clone() + .run_worker(foreign.run_id, String::new()) + .await; + // Both operations use the ordinary restricted admission producer again. + let result = service + .restore_managed_checkpoint_and_admit(original.clone()) + .await + .unwrap(); + assert_ne!(result.new_branch.branch_id, original.source_branch_id); + let read = RunCancellation::with_timeout(Duration::from_secs(5)); + assert!( + runtime + .repository + .lookup_restore_receipt(&original, &read) + .unwrap() + .is_some() + ); + // The other real store/branch still admits and executes a new Native write. + let independent = admit(&other, "unrelated-positive").await; + other + .clone() + .run_worker(independent.run_id.clone(), String::new()) + .await; + let effect = other_state + .persistence() + .unwrap() + .managed_effect_get(&ManagedEffectGetRequest { + owner: ManagedOwnerScope { + profile: "gateway".into(), + session_id: independent.session_id, + run_id: independent.run_id, + }, + call_id: "unrelated-positive".into(), + }) + .unwrap() + .unwrap(); + assert_eq!( + effect.outcome.unwrap().disposition, + ManagedPublicationStatus::Committed + ); + assert_eq!( + effect.identity.workspace.branch_id, + foreign_binding.admitted_branch.branch_id + ); + assert_ne!(other_run.run_id, checkpoint.parent_run_id); + // Replacement changes directory identity despite an unchanged configured path. + fs::rename(root.join("workspace"), root.join("old-workspace")).unwrap(); + fs::create_dir(root.join("workspace")).unwrap(); + let mut replaced = create; + replaced.request_id = uuid::Uuid::new_v4().to_string(); + assert!(service.create_managed_checkpoint(replaced).await.is_err()); + let mut replaced = original; + replaced.request_id = uuid::Uuid::new_v4().to_string(); + assert!( + service + .restore_managed_checkpoint_and_admit(replaced) + .await + .is_err() + ); + service.shutdown().await.unwrap(); + other.shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn prepared_copy_cannot_install_witness_after_restore_source_closes() { + let (_root, state) = fixture(Duration::from_secs(90)); + let service = state.service(); + let runtime = service.managed_runtime_for_test().unwrap(); + let gate = Arc::new(ReleaseGate { + released: Mutex::new(false), + changed: parking_lot::Condvar::new(), + }); + let wait = gate.clone(); + let (sent, received) = std::sync::mpsc::channel(); + *runtime.before_prepared_install.lock() = Some(Arc::new(move |call| { + assert!(call.prepared.lock().is_none()); + sent.send(held(call)).unwrap(); + wait.wait(); + })); + let run = admit(&service, "late-prepared").await; + let worker = tokio::spawn( + service + .clone() + .run_worker(run.run_id.clone(), String::new()), + ); + let owner = received.recv_timeout(Duration::from_secs(4)).unwrap(); + let read = RunCancellation::with_timeout(Duration::from_secs(5)); + let head = runtime + .repository + .head(&owner.binding.admitted_branch, &read) + .unwrap(); + let frozen = runtime + .repository + .object_metadata( + &ManagedObjectReferencesRequest { + store_id: head.store_id.clone(), + object_id: head.head_object_id.clone(), + }, + &read, + ) + .unwrap() + .unwrap(); + let prepared = runtime + .prepare_restore_branch( + &owner.binding.authority, + &owner.binding.admitted_branch, + &cas(&head), + &frozen, + ) + .unwrap(); + gate.release(); + worker.await.unwrap(); + let installed = owner.call.prepared.lock().is_some(); + drop(prepared); + *runtime.before_prepared_install.lock() = None; + assert!(owner.call.outcome.lock().is_none()); + assert_eq!( + runtime + .repository + .head(&owner.binding.admitted_branch, &read) + .unwrap(), + head + ); + service.shutdown().await.unwrap(); + assert!( + !installed, + "late preparation installed a new witness after source closure" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn uncooperative_source_cleanup_timeout_rejects_publication_and_releases_memory_fence() { + let (_root, state) = fixture(Duration::from_secs(90)); + let service = state.service(); + let runtime = service.managed_runtime_for_test().unwrap(); + let (_base, _create, checkpoint) = checkpoint_source(&service).await; + let original = restore_request(&runtime, &checkpoint); + service.inject_uncooperative_dispatch(); + let run = admit(&service, "cleanup-timeout").await; + let binding = service + .retained_tool_for_test(&run.run_id) + .unwrap() + .managed + .clone() + .unwrap(); + let host = service + .capability_host_bridges(&run.run_id) + .unwrap() + .unwrap(); + assert!(host.lifecycle.is_some()); + let result = service + .restore_managed_checkpoint_and_admit(original.clone()) + .await; + service.release_uncooperative_dispatch(); + let read = RunCancellation::with_timeout(Duration::from_secs(5)); + assert!( + matches!(result,Err(ManagedMaintenanceFailure::Rejected {code}) if code=="managed_restore_busy") + ); + assert!( + runtime + .repository + .lookup_restore_receipt(&original, &read) + .unwrap() + .is_none() + ); + assert_eq!( + cas(&runtime + .repository + .head(&binding.admitted_branch, &read) + .unwrap()), + original.expected_source + ); + assert!(binding.branch.state.lock().phase == ManagedBranchPhase::Active); + assert!(binding.cancel.requested().is_some()); + assert!( + binding.check().is_err(), + "cancelled source owner cannot revive when memory fence is released" + ); + assert!( + service + .run_events(&run.run_id) + .iter() + .any(|event| event["event"] == "run.cancelled") + ); + let fresh = admit(&service, "after-known-cleanup-failure").await; + assert_ne!(fresh.run_id, run.run_id); + service + .clone() + .run_worker(fresh.run_id, String::new()) + .await; + service.shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn filesystem_started_intent_without_adapter_stays_old_evidence_after_restore() { + let (_root, state) = fixture(Duration::from_secs(90)); + let service = state.service(); + let runtime = service.managed_runtime_for_test().unwrap(); + let (_base, _create, checkpoint) = checkpoint_source(&service).await; + runtime.arm_fault(ManagedEffectFault::AfterIntent); + let run=admit_call(&service,json!({"id":"started-no-io","name":"write_file","arguments":{"path":"must-not-start.txt","content":"B"}})).await; + let binding = service + .retained_tool_for_test(&run.run_id) + .unwrap() + .managed + .clone() + .unwrap(); + service + .clone() + .run_worker(run.run_id.clone(), String::new()) + .await; + let call = binding.call("started-no-io").unwrap(); + assert!(call.adapters.lock().is_none()); + assert!(call.prepared.lock().is_none()); + let read = RunCancellation::with_timeout(Duration::from_secs(5)); + let before = runtime + .repository + .get( + &ManagedEffectGetRequest { + owner: binding.owner.clone(), + call_id: call.identity.call_id.clone(), + }, + &read, + ) + .unwrap() + .unwrap(); + assert!(before.outcome.is_none()); + let original = restore_request(&runtime, &checkpoint); + let result = service + .restore_managed_checkpoint_and_admit(original.clone()) + .await; + assert!(binding.check().is_err()); + let after = runtime + .repository + .get( + &ManagedEffectGetRequest { + owner: binding.owner.clone(), + call_id: call.identity.call_id.clone(), + }, + &read, + ) + .unwrap() + .unwrap(); + assert_eq!(before, after); + assert!( + !runtime + .path + .join("trees") + .join(&original.expected_source.head_object_id) + .join("must-not-start.txt") + .exists() + ); + if result.is_err() { + assert!( + runtime + .repository + .lookup_restore_receipt(&original, &read) + .unwrap() + .is_none() + ); + } + service.shutdown().await.unwrap(); +} + +struct PausedProvider { + gate: Arc, + entered: std::sync::mpsc::Sender<()>, +} +impl crate::AgentProviderHost for PausedProvider { + fn call(&self, _: &Value, _: &RunCancellation) -> Value { + self.entered.send(()).unwrap(); + self.gate.wait(); + json!({"ok":false,"error":{"code":"cancelled","message":"owned barrier released","retryable":false}}) + } +} +#[tokio::test(flavor = "multi_thread")] +async fn provider_worker_drain_cannot_borrow_later_management_deadline() { + let (_root, state) = fixture(Duration::from_secs(4)); + let service = state.service(); + let runtime = service.managed_runtime_for_test().unwrap(); + let (_base, _create, checkpoint) = checkpoint_source(&service).await; + let gate = Arc::new(ReleaseGate { + released: Mutex::new(false), + changed: parking_lot::Condvar::new(), + }); + let (sent, received) = std::sync::mpsc::channel(); + let run = admit(&service, "unused-provider-call").await; + let binding = service + .retained_tool_for_test(&run.run_id) + .unwrap() + .managed + .clone() + .unwrap(); + service.inject_provider_host(Arc::new(PausedProvider { + gate: gate.clone(), + entered: sent, + })); + let worker = tokio::spawn( + service + .clone() + .run_worker(run.run_id.clone(), String::new()), + ); + received.recv_timeout(Duration::from_secs(3)).unwrap(); + let request = restore_request(&runtime, &checkpoint); + let original = request.clone(); + let mut task = tokio::spawn({ + let service = service.clone(); + async move { service.restore_managed_checkpoint_and_admit(request).await } + }); + let limit = binding.cancel.deadline_instant().unwrap() + Duration::from_millis(400); + let bounded = tokio::time::timeout_at(tokio::time::Instant::from_std(limit), &mut task).await; + let within_original = bounded.is_ok(); + gate.release(); + worker.await.unwrap(); + let outcome = match bounded { + Ok(result) => result.unwrap(), + Err(_) => task.await.unwrap(), + }; + let read = RunCancellation::with_timeout(Duration::from_secs(5)); + let receipt = runtime + .repository + .lookup_restore_receipt(&original, &read) + .unwrap(); + service.shutdown().await.unwrap(); + assert!( + within_original, + "restore extended source worker drain past its original deadline" + ); + assert!(outcome.is_err()); + assert!(receipt.is_none()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn already_entered_direct_write_finishes_before_successful_restore_publication() { + let (_root, state) = fixture(Duration::from_secs(90)); + let service = state.service(); + let runtime = service.managed_runtime_for_test().unwrap(); + let (_base, _create, checkpoint) = checkpoint_source(&service).await; + let ready = Arc::new(ReleaseGate { + released: Mutex::new(false), + changed: parking_lot::Condvar::new(), + }); + let entered_gate = Arc::new(ReleaseGate { + released: Mutex::new(false), + changed: parking_lot::Condvar::new(), + }); + let wait = ready.clone(); + let (sent, received) = std::sync::mpsc::channel(); + *runtime.native_ready_observer.lock() = Some(Arc::new(move |call| { + sent.send(held(call)).unwrap(); + wait.wait(); + })); + let wait = entered_gate.clone(); + let (sent, entered) = std::sync::mpsc::channel(); + *runtime.native_write_observer.lock() = Some(Arc::new(move |call| { + assert_eq!(call.active_native_operations(), 1); + sent.send(()).unwrap(); + wait.wait(); + })); + let run = admit(&service, "entered-success").await; + let worker = tokio::spawn( + service + .clone() + .run_worker(run.run_id.clone(), String::new()), + ); + let owner = received.recv_timeout(Duration::from_secs(3)).unwrap(); + let adapter = owner.adapter.clone(); + let direct = tokio::task::spawn_blocking(move || { + adapter + .filesystem + .write_atomic(&adapter.token, "entered-direct.txt", "*", b"B") + }); + entered.recv_timeout(Duration::from_secs(3)).unwrap(); + let original = restore_request(&runtime, &checkpoint); + let request = original.clone(); + let (sent, closed) = std::sync::mpsc::channel(); + *runtime.restore_closed_observer.lock() = Some(Arc::new(move |stage| { + sent.send(stage.to_string()).unwrap(); + })); + let task = tokio::spawn({ + let service = service.clone(); + async move { service.restore_managed_checkpoint_and_admit(request).await } + }); + let before = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + assert_eq!( + closed.recv_timeout(Duration::from_secs(3)).unwrap(), + "closed" + ); + assert!(!task.is_finished()); + assert_eq!(owner.call.active_native_operations(), 1); + assert!( + !owner + .adapter + .work + .path() + .join("entered-direct.txt") + .exists() + ); + assert!( + runtime + .repository + .lookup_restore_receipt( + &original, + &RunCancellation::with_timeout(Duration::from_secs(5)) + ) + .unwrap() + .is_none() + ); + })); + entered_gate.release(); + let written = direct.await.unwrap(); + let drained = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + assert_eq!( + closed.recv_timeout(Duration::from_secs(3)).unwrap(), + "cancelled" + ); + assert!(written.is_ok()); + assert_eq!(owner.call.active_native_operations(), 0); + assert_eq!( + fs::read(owner.adapter.work.path().join("entered-direct.txt")).unwrap(), + b"B" + ); + assert!( + !task.is_finished(), + "old worker must still exit before publication" + ); + })); + ready.release(); + worker.await.unwrap(); + let result = task.await.unwrap(); + *runtime.native_ready_observer.lock() = None; + *runtime.native_write_observer.lock() = None; + *runtime.restore_closed_observer.lock() = None; + if let Err(error) = before.and(drained) { + service.shutdown().await.unwrap(); + std::panic::resume_unwind(error); + } + let result = result.unwrap(); + assert_eq!(result.source_before, original.expected_source); + assert!( + !runtime + .path + .join("trees") + .join(&result.new_branch.head_object_id) + .join("entered-direct.txt") + .exists() + ); + let new_binding = service + .retained_tool_for_test(&result.new_run_id) + .unwrap() + .managed + .clone() + .unwrap(); + reject_old_native( + &owner, + &runtime, + &owner.binding.admitted_branch, + Some(&new_binding.admitted_branch), + ); + service.shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn ready_prepared_tree_and_readonly_receipts_are_not_committed_after_successful_restore() { + for readonly in [false, true] { + let (_root, state) = fixture(Duration::from_secs(90)); + let service = state.service(); + let runtime = service.managed_runtime_for_test().unwrap(); + let (_base, _create, checkpoint) = checkpoint_source(&service).await; + let gate = Arc::new(ReleaseGate { + released: Mutex::new(false), + changed: parking_lot::Condvar::new(), + }); + let wait = gate.clone(); + let (sent, received) = std::sync::mpsc::channel(); + *runtime.before_final_commit.lock() = Some(Arc::new(move |call| { + sent.send(held(call)).unwrap(); + wait.wait(); + })); + let tool = if readonly { + json!({"id":"ready-before-commit","name":"read_file","arguments":{"path":"value.txt"}}) + } else { + json!({"id":"ready-before-commit","name":"write_file","arguments":{"path":"candidate-only.txt","content":"B"}}) + }; + let run = admit_call(&service, tool).await; + let worker = tokio::spawn( + service + .clone() + .run_worker(run.run_id.clone(), String::new()), + ); + let owner = received.recv_timeout(Duration::from_secs(4)).unwrap(); + let witness = owner.call.witness().unwrap(); + assert_eq!( + owner.binding.branch.state.lock().active, + 0, + "preparation RAII has left before this real final-commit barrier" + ); + let original = restore_request(&runtime, &checkpoint); + let request = original.clone(); + let (sent, closed) = std::sync::mpsc::channel(); + *runtime.restore_closed_observer.lock() = Some(Arc::new(move |stage| { + sent.send(stage.to_string()).unwrap(); + })); + let task = tokio::spawn({ + let service = service.clone(); + async move { service.restore_managed_checkpoint_and_admit(request).await } + }); + let checks = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + assert_eq!( + closed.recv_timeout(Duration::from_secs(3)).unwrap(), + "closed" + ); + assert_eq!( + closed.recv_timeout(Duration::from_secs(3)).unwrap(), + "cancelled" + ); + assert!(!task.is_finished()); + assert!(owner.call.outcome.lock().is_none()); + })); + gate.release(); + worker.await.unwrap(); + let result = task.await.unwrap(); + *runtime.before_final_commit.lock() = None; + *runtime.restore_closed_observer.lock() = None; + if let Err(error) = checks { + service.shutdown().await.unwrap(); + std::panic::resume_unwind(error); + } + let result = result.unwrap(); + assert_eq!(result.source_before, original.expected_source); + assert!(owner.call.outcome.lock().is_none()); + let owner_facts = CapabilityOwner::new( + "gateway", + &owner.call.identity.owner.session_id, + &owner.call.identity.owner.run_id, + ) + .unwrap(); + assert!( + owner + .adapter + .lifecycle + .commit(&owner_facts, &owner.adapter.token, witness.original.clone()) + .is_err() + ); + let read = RunCancellation::with_timeout(Duration::from_secs(5)); + let record = runtime + .repository + .get( + &ManagedEffectGetRequest { + owner: owner.binding.owner.clone(), + call_id: owner.call.identity.call_id.clone(), + }, + &read, + ) + .unwrap() + .unwrap(); + assert!(record.prepared.is_some()); + assert!(record.outcome.is_none()); + assert!( + !runtime + .path + .join("trees") + .join(result.new_branch.head_object_id) + .join("candidate-only.txt") + .exists() + ); + service.shutdown().await.unwrap(); + } +} diff --git a/src/effects/mod.rs b/src/effects/mod.rs index 435add3..a7f0f9e 100644 --- a/src/effects/mod.rs +++ b/src/effects/mod.rs @@ -27,13 +27,31 @@ pub use journal::{ ManagedWorkspaceRecord, ManagedWriterState, parse_managed_effect_yaml, parse_managed_workspace_yaml, }; +pub use journal::{ + ManagedBranchCreateRequest, ManagedBranchReceipt, ManagedPublishReceipt, ManagedPublishRequest, + ManagedWorkspacePublication, +}; +pub use journal::{ + ManagedCheckpointArtifactRef, ManagedCheckpointCreateRequest, ManagedCheckpointGraphRef, + ManagedCheckpointMode, ManagedCheckpointPublicConfig, ManagedCheckpointPublication, + ManagedCheckpointReceipt, ManagedCheckpointRef, ManagedCheckpointStateCoverage, + ManagedClosedOutputRef, ManagedExternalBoundary, ManagedExternalHighWater, + ManagedExternalNotice, ManagedFrozenHistoryRef, ManagedMaintenanceFailure, + ManagedPluginStateRootRef, ManagedPublicConfigRef, ManagedSourceCas, + parse_managed_checkpoint_request_yaml, parse_managed_checkpoint_yaml, + parse_managed_restore_request_yaml, parse_managed_restore_yaml, +}; pub use journal::{ ManagedEffectListRequest, ManagedEffectPage, ManagedEffectSummary, ManagedResultChunk, - ManagedResultReadRequest, + ManagedResultReadRequest, ManagedRunEffectError, ManagedRunEffectListRequest, }; pub use journal::{ ManagedObjectReference, ManagedObjectReferenceKind, ManagedObjectReferencesRequest, }; +pub use journal::{ + ManagedRestoreAndAdmitRequest, ManagedRestoreAndAdmitResult, ManagedRestoreDisposition, + ManagedRestoreGraphSelection, ManagedRestoreRunInput, +}; pub use types::{ BranchEpoch, BranchId, CheckpointId, CheckpointRequest, EffectCancellation, EffectDeadline, EffectError, EffectErrorKind, EffectFailure, ExternalEffectNotice, ImportConsistency, diff --git a/src/effects/retention_tests.rs b/src/effects/retention_tests.rs index eff6178..1ec7d22 100644 --- a/src/effects/retention_tests.rs +++ b/src/effects/retention_tests.rs @@ -26,6 +26,7 @@ async fn managed_fresh_startup_census_is_quota_bounded_not_incidental_clock_boun source_root: source, quota: ResourceQuota::new(1000, 1048576, 16).unwrap(), retention: Default::default(), + maintenance_policy: Default::default(), }; let config = AgentGatewayConfig { managed_workspace: Some(managed.clone()), diff --git a/src/events.rs b/src/events.rs index e80382c..69f3f0f 100644 --- a/src/events.rs +++ b/src/events.rs @@ -83,6 +83,8 @@ pub enum EventCommitError { Terminal, Cancelled, PersistFailed(String), + PluginGraphRevoked, + PluginGraphPinRequired, MissingParent, Corrupt(String), } diff --git a/src/gateway/api_server.rs b/src/gateway/api_server.rs index d2f227a..6f16c9f 100644 --- a/src/gateway/api_server.rs +++ b/src/gateway/api_server.rs @@ -116,6 +116,11 @@ struct CreateRunRequest { workspace: Option, #[serde(rename = "conversation_history")] conversation_history: Option, + /// Typed selectors. Named fields stay out of `extra`/`options` and do not grant by themselves. + #[serde(default)] + graph_id: Option, + #[serde(default)] + workspace_branch: Option, #[serde(flatten)] extra: HashMap, } @@ -189,7 +194,39 @@ pub fn build_agent_gateway_app(state: AgentGatewayState) -> Router { "/api/sessions/{session_id}/chat", post(session_chat_handler), ) + .route( + "/v1/workspaces/{workspace_id}/checkpoints", + post(super::workspaces::create_checkpoint), + ) + .route( + "/v1/workspaces/{workspace_id}/restore", + post(super::workspaces::restore_checkpoint), + ) + .route( + "/v1/workspaces/{workspace_id}/branches", + post(super::workspaces::create_branch), + ) + .route( + "/v1/workspaces/{workspace_id}/publish", + post(super::workspaces::publish_workspace), + ) + .route("/v1/plugins/install", post(super::plugins::install)) + .route("/v1/plugin-graphs/resolve", post(super::plugins::resolve)) + .route( + "/v1/plugin-graphs/{graph_id}/prepare", + post(super::plugins::prepare), + ) + .route("/v1/plugin-activations", post(super::plugins::activate)) + .route( + "/v1/plugin-graphs/{graph_id}", + get(super::plugins::get_graph), + ) + .route( + "/v1/plugin-activations/{activation_id}/rollback", + post(super::plugins::rollback), + ) .route("/v1/runs", post(create_run_handler)) + .route("/v1/runs/{run_id}/effects", get(super::effects::list)) .route("/v1/runs/{run_id}/events", get(run_events_handler)) .route("/v1/runs/{run_id}/stop", post(stop_run_handler)) .route( @@ -335,8 +372,35 @@ impl RateLimiter { } } +fn is_plugin_control_route(method: &axum::http::Method, path: &str) -> bool { + let segments = path.split('/').collect::>(); + match (method, segments.as_slice()) { + (method, ["", "v1", "plugins", "install"]) if *method == axum::http::Method::POST => true, + (method, ["", "v1", "plugin-graphs", "resolve"]) if *method == axum::http::Method::POST => { + true + } + (method, ["", "v1", "plugin-graphs", _, "prepare"]) + if *method == axum::http::Method::POST => + { + true + } + (method, ["", "v1", "plugin-activations"]) if *method == axum::http::Method::POST => true, + (method, ["", "v1", "plugin-graphs", id]) + if *method == axum::http::Method::GET && *id != "resolve" => + { + true + } + (method, ["", "v1", "plugin-activations", _, "rollback"]) + if *method == axum::http::Method::POST => + { + true + } + _ => false, + } +} + async fn gateway_guard_middleware( - request: Request, + mut request: Request, next: Next, rate_limiter: Arc, config: Arc, @@ -358,6 +422,26 @@ async fn gateway_guard_middleware( } } let Some(expected) = config.bearer_token.as_deref() else { + let segments = request.uri().path().split('/').collect::>(); + if (segments.len() == 5 + && segments[1] == "v1" + && ((request.method() == axum::http::Method::POST + && segments[2] == "workspaces" + && matches!( + segments[4], + "checkpoints" | "restore" | "branches" | "publish" + )) + || (request.method() == axum::http::Method::GET + && segments[2] == "runs" + && segments[4] == "effects"))) + || is_plugin_control_route(request.method(), request.uri().path()) + { + return json_error( + StatusCode::FORBIDDEN, + "managed_management_denied", + "managed maintenance requires configured authentication", + ); + } return next.run(request).await; }; let authorized = request @@ -384,6 +468,11 @@ async fn gateway_guard_middleware( return rate_limited_response(retry_after); } } + if is_plugin_control_route(request.method(), request.uri().path()) { + request + .extensions_mut() + .insert(super::plugins::VerifiedOwnerCredential); + } next.run(request).await } @@ -1012,6 +1101,8 @@ async fn create_run_handler( "instructions": request.instructions.clone(), "workspace": request.workspace.clone(), "conversation_history": request.conversation_history.clone(), + "graph_id": request.graph_id.clone(), + "workspace_branch": request.workspace_branch.clone(), "extra": request.extra.clone(), })) .unwrap_or_default(); @@ -1046,12 +1137,38 @@ async fn create_run_handler( platform: "api_server".to_string(), idempotency_key, idempotency_hash: request_hash, + graph_id: request.graph_id.clone(), + workspace_branch: request.workspace_branch.clone(), }, request.workspace.clone(), ) .await { Ok(admitted) => admitted, + Err(AdmitError::HarnessPolicy(code)) => { + return json_error(StatusCode::UNPROCESSABLE_ENTITY, code, code); + } + Err(AdmitError::PluginArtifactUnavailable) => { + return json_error( + StatusCode::UNPROCESSABLE_ENTITY, + "plugin_artifact_unavailable", + "plugin artifact is unavailable", + ); + } + Err(AdmitError::PluginGraphRevoked) => { + return json_error( + StatusCode::CONFLICT, + "plugin_graph_revoked", + "plugin graph is revoked", + ); + } + Err(AdmitError::PluginGraphPinRequired) => { + return json_error( + StatusCode::UNPROCESSABLE_ENTITY, + "plugin_graph_pin_required", + "tool graph pin is required", + ); + } Err(AdmitError::RunLimitReached) => { return json_error( StatusCode::TOO_MANY_REQUESTS, @@ -1103,11 +1220,18 @@ async fn create_run_handler( ); } Err(AdmitError::Invalid(message)) => { - return json_error( - StatusCode::UNPROCESSABLE_ENTITY, - "invalid_admission", - &message, - ); + let (status, code) = match message.as_str() { + "managed_branch_missing" => (StatusCode::NOT_FOUND, "managed_branch_missing"), + "managed_authority_required" => { + (StatusCode::FORBIDDEN, "managed_authority_required") + } + "managed_source_conflict" => (StatusCode::CONFLICT, "managed_source_conflict"), + "plugin_state_unsupported" => { + (StatusCode::UNPROCESSABLE_ENTITY, "plugin_state_unsupported") + } + _ => (StatusCode::UNPROCESSABLE_ENTITY, "invalid_admission"), + }; + return json_error(status, code, &message); } Err(AdmitError::Halting) => { return json_error( @@ -1120,7 +1244,7 @@ async fn create_run_handler( if admitted.replayed { return json_response( StatusCode::ACCEPTED, - json!({"run_id": admitted.run_id, "status": admitted.status}), + admitted_run_body(&state, &admitted.run_id, &admitted.status), ); } let service = state.service(); @@ -1146,7 +1270,7 @@ async fn create_run_handler( }); json_response( StatusCode::ACCEPTED, - json!({"run_id": admitted.run_id, "status": "started"}), + admitted_run_body(&state, &admitted.run_id, "started"), ) } @@ -1669,6 +1793,17 @@ fn event_stream( ) } +fn admitted_run_body(state: &AgentGatewayState, run_id: &str, status: &str) -> Value { + let mut body = json!({"run_id": run_id, "status": status}); + let view = state.service().captured_run_projection(run_id); + if let (Some(body), Some(view)) = (body.as_object_mut(), view.as_object()) { + for (key, value) in view { + body.insert(key.clone(), value.clone()); + } + } + body +} + fn json_response(status: StatusCode, value: Value) -> Response { (status, Json(value)).into_response() } diff --git a/src/gateway/effects.rs b/src/gateway/effects.rs new file mode 100644 index 0000000..c21ec34 --- /dev/null +++ b/src/gateway/effects.rs @@ -0,0 +1,86 @@ +//! Authenticated read-only projection of Native-authorized run summaries. +use crate::{ + AgentGatewayState, + effects::{ManagedRunEffectError, ManagedRunEffectListRequest}, +}; +use axum::{ + Json, + extract::{Path, Query, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde::{Deserialize, Serialize}; + +/// Default 32, maximum 128. No caller-provided authority or workspace selectors. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct RunEffectsQuery { + after_effect_id: Option, + #[serde(default = "default_limit")] + limit: usize, +} +fn default_limit() -> usize { + 32 +} +#[derive(Serialize)] +struct ErrorBody { + error: ErrorFields, +} +#[derive(Serialize)] +struct ErrorFields { + code: &'static str, + message: &'static str, +} +fn failure(error: ManagedRunEffectError) -> Response { + let (status, code) = match error { + ManagedRunEffectError::InvalidRequest => { + (StatusCode::UNPROCESSABLE_ENTITY, "managed_request_invalid") + } + ManagedRunEffectError::Unavailable => { + (StatusCode::FORBIDDEN, "managed_effects_unavailable") + } + ManagedRunEffectError::StorageUnavailable => ( + StatusCode::SERVICE_UNAVAILABLE, + "managed_storage_unavailable", + ), + ManagedRunEffectError::Cancelled => { + (StatusCode::REQUEST_TIMEOUT, "managed_query_cancelled") + } + }; + ( + status, + Json(ErrorBody { + error: ErrorFields { + code, + message: code, + }, + }), + ) + .into_response() +} +pub(super) async fn list( + State(state): State, + run_id: Result, axum::extract::rejection::PathRejection>, + query: Result, axum::extract::rejection::QueryRejection>, +) -> Response { + let Ok(Path(run_id)) = run_id else { + return failure(ManagedRunEffectError::InvalidRequest); + }; + let Ok(Query(query)) = query else { + return failure(ManagedRunEffectError::InvalidRequest); + }; + let service = state.service(); + let result = tokio::task::spawn_blocking(move || { + service.query_managed_run_effects(ManagedRunEffectListRequest { + run_id, + after_effect_id: query.after_effect_id, + limit: query.limit, + }) + }) + .await; + match result { + Ok(Ok(page)) => Json(page).into_response(), + Ok(Err(error)) => failure(error), + Err(_) => failure(ManagedRunEffectError::StorageUnavailable), + } +} diff --git a/src/gateway/managed_storage_budget_tests.rs b/src/gateway/managed_storage_budget_tests.rs index abbb877..347c2e3 100644 --- a/src/gateway/managed_storage_budget_tests.rs +++ b/src/gateway/managed_storage_budget_tests.rs @@ -1,9 +1,195 @@ use super::*; +#[test] +fn graph_pin_and_pair_repeated_parameters_reject_before_enqueue() { + let (_root, p) = persistence(); + for (op, payload) in [ + ( + "admission.create", + json!({"tool_graph_id":"g".repeat(70000)}), + ), + ( + "event.append_pair", + json!({"run_id":"r".repeat(70000),"payload_json_a":"{}","payload_json_b":"{}"}), + ), + ] { + assert!( + serde_json::to_vec(&payload).unwrap().len() < crate::effects::journal::METADATA_BYTES + ); + let before = p.executed_storage_commands(); + let result = p.command_data(op, &payload); + assert!(result.is_err()); + assert_eq!( + p.executed_storage_commands(), + before, + "{op} repeated text parameters must reject before enqueue: {result:?}" + ); + } + p.shutdown(); +} + +#[test] +fn managed_draft_repeated_projection_bytes_use_updated_weights_before_enqueue() { + let (_root, persistence) = persistence(); + for (op, weight) in [ + ("effect.begin", 10), + ("effect.commit", 15), + ("effect.recover_one", 15), + ] { + let padding = "x".repeat(crate::effects::journal::METADATA_BYTES / weight); + let projection = json!({"output":{"created_at_ms":11},"terminal":{"created_at_ms":12},"message":{"created_at_ms":13}}); + let payload = json!({"sealed_class":"workspace_read","projections_json":json!({"normal":projection,"conflict":projection,"padding":padding}).to_string()}); + assert!( + serde_json::to_vec(&payload).unwrap().len() < crate::effects::journal::METADATA_BYTES + ); + let before = persistence.executed_storage_commands(); + let error = persistence + .managed_effect_command(op, &payload) + .unwrap_err(); + assert!(error.message.contains("aggregate parameter")); + assert_eq!(persistence.executed_storage_commands(), before); + } + persistence.shutdown(); +} + +#[test] +fn composed_restore_repeated_envelope_rejects_before_enqueue() { + let (_root, persistence) = persistence(); + let before = persistence.executed_storage_commands(); + let payload = json!({"restore_json":json!({"original_request_proof":"\\\"\n".repeat(12000)}).to_string()}); + assert!(serde_json::to_vec(&payload).unwrap().len() < 1048576); + let error = persistence.admission_create(&payload).unwrap_err(); + assert_eq!( + persistence.executed_storage_commands(), + before, + "aggregate restore bindings must reject before worker enqueue" + ); + assert_eq!(error.message, "managed_metadata_budget_exceeded"); + assert_eq!(persistence.managed_pending.load(Ordering::Acquire), 0); + persistence.shutdown(); +} + +#[test] +fn composed_restore_fixed_statement_parameter_census_matches_native_weights() { + let source = include_str!("../../rss/storage/effects.rss"); + let mut envelope = 0; + for (function, expected) in [ + ("restore_before_admission", 31), + ("restore_history_statements", 5), + ("restore_receipt_statements", 12), + ] { + let body = source + .split(&format!("pub fn {function}(")) + .nth(1) + .unwrap() + .split("pub fn ") + .next() + .unwrap(); + let actual: usize = body + .lines() + .filter_map(|line| line.split("params:").nth(1)) + .map(|params| { + params + .split(']') + .next() + .unwrap() + .matches("text(input.restore_json") + .count() + }) + .sum(); + assert_eq!( + actual, expected, + "actual fixed SQL parameter-array census: {function}" + ); + envelope += actual; + } + assert_eq!(RESTORE_ENVELOPE_BINDINGS, envelope); + let admission = include_str!("../../rss/storage/admission.rss"); + let transaction = admission + .split("let mut statements: array = [];") + .nth(1) + .unwrap() + .split("let transaction_result:") + .next() + .unwrap(); + let mut actual = std::collections::BTreeMap::new(); + let mut derived = std::collections::BTreeMap::new(); + for line in transaction.lines().filter(|line| line.contains("params:")) { + if line.contains("sql_text(input.model.copy()), sql_text(input.model.copy())") || line.contains("sql_text(input.parent_run_id.copy()), sql_text(input.run_id.copy()), sql_int(input.now_ms.copy())") {continue;} + let params = line + .split("params:") + .nth(1) + .unwrap() + .split(']') + .next() + .unwrap(); + // The original admission census remains distinct from the added + // fixed graph predicate, whose complete alternatives are checked below. + let guard = "sql_text(input.tool_graph_id.copy()), sql_text(input.tool_graph_id.copy()), sql_text(input.run_id.copy()), sql_text(input.tool_graph_id.copy()), sql_text(input.tool_ref_id.copy())"; + let params = params.trim().trim_end_matches(',').trim(); + let params = params + .strip_suffix(guard) + .unwrap_or(params) + .trim_end_matches(',') + .trim(); + for tail in params.split("sql_text(").skip(1) { + let expression = tail.split(")").next().unwrap().trim_end_matches(".copy("); + if let Some(field) = expression.strip_prefix("input.") { + *actual.entry(field.to_string()).or_insert(0usize) += 1; + } else { + *derived.entry(expression.to_string()).or_insert(0usize) += 1; + } + } + } + let declared = RESTORE_ADMISSION_BINDINGS + .iter() + .map(|(name, count)| (name.to_string(), *count)) + .collect::>(); + assert_eq!( + actual, declared, + "fresh-session statement parameter weights drifted" + ); + assert_eq!( + derived, + [ + ("encoded_content".into(), 1), + ("receipt_key".into(), 2), + ("receipt_scope".into(), 1), + ("registry_identity".into(), 1), + ("restore_harness_json".into(), 1), + ("workspace".into(), 1) + ] + .into_iter() + .collect() + ); + // Formal pinned SQLite host accounts only text/blob payload bytes. The + // existing normalization's five whole-wire binds are separate queries. + let normalization = admission + .split("fn admission_with_pin_fields(") + .nth(1) + .unwrap(); + assert_eq!( + normalization + .split("[sql_text(") + .nth(1) + .unwrap() + .split(']') + .next() + .unwrap() + .matches("payload_json.copy()") + .count(), + 5 + ); +} + #[test] fn managed_aggregate_sql_parameter_budget_precedes_sdk_enqueue_and_matches_fixed_rss() { let source = include_str!("../../rss/storage/effects.rss"); for op in [ + "run_binding", + "run_list", + "run_list_has_more", + "run_cursor", "workspace_get", "workspace_register", "head", @@ -14,6 +200,18 @@ fn managed_aggregate_sql_parameter_budget_precedes_sdk_enqueue_and_matches_fixed "pending", "recover_one", "recovery_context", + "checkpoint_context", + "checkpoint_get", + "checkpoint_create", + "branch_create", + "branch_receipt", + "branch_origin", + "publish_eligibility", + "publish_submit", + "publish_receipt", + "publication_get", + "maintenance_receipt", + "branch_publication_history", ] { let function = source .split(&format!("pub fn {op}(")) @@ -74,6 +272,7 @@ async fn managed_reopen_preserves_nonzero_formal_provider_usage() { source_root: workspace, quota: crate::effects::ResourceQuota::new(1000, 1048576, 16).unwrap(), retention: ManagedRetentionLimits::default(), + maintenance_policy: Default::default(), }), ..Default::default() }; @@ -118,6 +317,289 @@ async fn managed_reopen_preserves_nonzero_formal_provider_usage() { drop(persistence); reopened.service().shutdown().await.unwrap(); } +#[test] +fn graph_fixed_statement_arrays_and_guard_bindings_match_complete_post_edit_census() { + fn body<'a>(source: &'a str, start: &str, end: &str) -> &'a str { + source + .split(start) + .nth(1) + .unwrap() + .split(end) + .next() + .unwrap() + } + fn census(source: &str, prefix: &str) -> std::collections::BTreeMap { + let mut counts = std::collections::BTreeMap::new(); + for line in source.lines().filter(|line| line.contains("params:")) { + let params = line + .split("params:") + .nth(1) + .unwrap() + .split(']') + .next() + .unwrap(); + for tail in params.split(prefix).skip(1) { + *counts + .entry(tail.split(['.', ')']).next().unwrap().to_string()) + .or_insert(0) += 1; + } + } + counts + } + let plugins = include_str!("../../rss/storage/plugins.rss"); + let helper = body( + plugins, + "fn storage_pin_requirement_statement(", + "pub fn storage_admission_pin_statements(", + ); + let classification = body( + plugins, + "pub fn storage_admission_pin_classification(", + "pub fn storage_pin_text(", + ); + for (name, end, declared) in [ + ( + "storage_admission_pin_statements", + "storage_checkpoint_pin_statements", + ORDINARY_PIN_BINDINGS, + ), + ( + "storage_checkpoint_pin_statements", + "storage_admission_pin_classification", + CHECKPOINT_PIN_BINDINGS, + ), + ] { + let source = format!( + "{}\n{helper}\n{classification}", + body( + plugins, + &format!("pub fn {name}("), + &format!("pub fn {end}(") + ) + ); + let actual = census(&source, "sql_text("); + let expected = declared + .iter() + .map(|(field, n)| { + ( + (match *field { + "tool_graph_id" => "graph_id", + "tool_ref_id" => "ref_id", + x => x, + }) + .to_string(), + *n, + ) + }) + .chain(if name == "storage_checkpoint_pin_statements" { + vec![("checkpoint_id".into(), 1), ("checkpoint_ref".into(), 1)] + } else { + vec![] + }) + .collect(); + assert_eq!( + actual, expected, + "complete helper-expanded pin census {name}" + ); + } + let events = include_str!("../../rss/storage/events.rss"); + let pair = body( + events, + "pub fn storage_event_append_pair(", + "/// Returns the persisted retention", + ) + .split("let statements:") + .nth(1) + .unwrap(); + let mut actual = census(pair, "sql_text(input."); + actual.insert( + "failpoint".into(), + pair.matches("sql_text(failpoint)").count(), + ); + assert_eq!( + actual, + EVENT_PAIR_BINDINGS + .iter() + .map(|(field, n)| (field.to_string(), *n)) + .collect() + ); + let admission = include_str!("../../rss/storage/admission.rss"); + let transaction = body( + admission, + "let mut statements: array = [];", + "let transaction_result:", + ); + let predicate_tail = "sql_text(input.tool_graph_id.copy()), sql_text(input.tool_graph_id.copy()), sql_text(input.run_id.copy()), sql_text(input.tool_graph_id.copy()), sql_text(input.tool_ref_id.copy())"; + let guard_count = transaction.matches(predicate_tail).count(); + assert_eq!(guard_count, 10); + assert_eq!( + ADMISSION_GRAPH_GUARD_BINDINGS, + &[ + ("tool_graph_id", guard_count * 3), + ("run_id", guard_count), + ("tool_ref_id", guard_count) + ] + ); + let effects = include_str!("../../rss/storage/effects.rss"); + for (start, end, declared) in [ + ( + "pub fn restore_before_admission(", + "pub fn maintenance_receipt(", + RESTORE_GRAPH_GUARD_BINDINGS, + ), + ( + "pub fn admission_statements(", + "pub fn begin(", + MANAGED_ADMISSION_GRAPH_GUARD_BINDINGS, + ), + ] { + let source = body(effects, start, end); + let graph = census(source, "text("); + let run_count = if start.contains("admission_statements") { + 2 + } else { + 13 + }; + assert_eq!(graph.get("graph_id"), Some(&(run_count * 3))); + assert_eq!(graph.get("ref_id"), Some(&run_count)); + let actual_run = if start.contains("admission_statements") { + source.matches("text(input.run_id.copy())").count() - 1 + } else { + graph["run_id"] + }; + assert_eq!(actual_run, run_count); + assert_eq!( + declared, + &[ + ("tool_graph_id", run_count * 3), + ("run_id", run_count), + ("tool_ref_id", run_count) + ] + ); + } +} + +#[test] +fn plugin_control_current_owner_count_is_eight_without_new_version_or_hidden_ddl() { + let schema = include_str!("../../rss/storage/schema.rss"); + assert!(schema.contains("if migration_index == 4 => { 8 }")); + assert!(schema.contains("pub fn schema_current_version() -> int {\n 6\n}")); + assert!(schema.contains("rss-storage-v5-plugin-control")); + assert!(schema.contains("statement_index == 6")); + assert!(schema.contains("statement_index == 7")); + for table in ["plugin_graph_revocations", "plugin_graph_pin_requirements"] { + assert_eq!( + schema + .matches(&format!("CREATE TABLE IF NOT EXISTS {table} ")) + .count(), + 1 + ); + assert!( + !include_str!("../../rss/storage/main.rss") + .contains(&format!("CREATE TABLE IF NOT EXISTS {table}")) + ); + } +} + +#[test] +fn graph_parameter_real_boundary_controls_and_integer_zero_accounting() { + let (_root, p) = persistence(); + let cap = crate::effects::journal::METADATA_BYTES; + for (op, field, weight) in [ + ("admission.create", "tool_graph_id", 61usize), + ("event.append_pair", "run_id", 21usize), + ] { + let overhead = if op == "admission.create" { + serde_json::to_vec(&json!({field:""})).unwrap().len() * 5 + + "native.tool-pin".len() * 2 + + 8 + + serde_json::to_vec(&json!([{"type":"text","text":"null"}])) + .unwrap() + .len() + } else { + 0 + }; + let n = (cap - overhead) / weight; + let payload = json!({field:"x".repeat(n)}); + assert!(validate_graph_parameter_budget(op, &payload).is_ok()); + let before = p.executed_storage_commands(); + assert!(p.command_data(op, &payload).is_err()); + assert_eq!( + p.executed_storage_commands(), + before + 1, + "below-bound control must reach formal worker" + ); + let payload = json!({field:"x".repeat(n+1)}); + let before = p.executed_storage_commands(); + assert_eq!( + validate_graph_parameter_budget(op, &payload), + Err("managed_metadata_budget_exceeded".into()) + ); + assert!(p.command_data(op, &payload).is_err()); + assert_eq!(p.executed_storage_commands(), before); + } + assert_eq!( + fixed_parameter_bytes(&json!({"run_id":7,"max_events":1}), EVENT_PAIR_BINDINGS).unwrap(), + 0 + ); + let unpinned = json!({"run_id":"r".repeat(70000),"tool_graph_id":""}); + assert!(validate_graph_parameter_budget("admission.create", &unpinned).is_ok()); + p.shutdown(); +} + +#[test] +fn required_pin_lifetime_is_independent_of_completed_receipt_expiry() { + let (root, p) = persistence(); + let packages = crate::plugins::PackageStore::open(root.join("packages")).unwrap(); + packages.bind_policy(&crate::plugins::InstallAuthorization { + admin: true, + accepted_publishers: vec![], + unsigned_dev_space: None, + }); + packages.install_embedding_owned().unwrap(); + let graph = packages + .resolve(&crate::plugins::ResolveRequest { + harness_selection: crate::plugins::HarnessSelectionRequest::ToolOnly { + tool_package: crate::runtime::native_tools::EMBEDDING_PACKAGE_ID.into(), + }, + roots: vec![crate::plugins::PackageRequirement { + id: crate::runtime::native_tools::EMBEDDING_PACKAGE_ID.into(), + version: "1.0.0".into(), + }], + host_runtime: crate::plugins::HOST_RUNTIME.into(), + }) + .unwrap(); + let control = crate::plugins::PluginControlStore::new(&p); + control.store_lock(&graph).unwrap(); + let refs: Vec<_> = graph + .packages + .iter() + .map(|package| crate::plugins::PackageArtifactRef { + package_id: package.id.clone(), + version: package.version.clone(), + kind: package.kind.clone(), + artifact_id: package.artifact_id.clone(), + }) + .collect(); + control.activate(&graph.graph_id, 0, &refs).unwrap(); + p.admission_create(&json!({ + "session_id":"expiry-session","session_new":1,"profile":"gateway","platform":"api_server","account_id":"expiry-session", + "model":"m","provider":"p","system_prompt":"","session_metadata_json":"{}","run_id":"expiry-run","parent_run_id":"", + "input_json":"{\"text\":\"expiry\"}","message_id":"expiry-message","message_run_id":"expiry-run","script_hash":"s", + "idempotency_scope":"expiry-scope","idempotency_key":"expiry-key","request_hash":"expiry-proof","workspace_identity_json":"", + "event_id":"expiry-event","now_ms":100,"expires_at_ms":101,"tool_graph_id":graph.graph_id,"tool_graph_generation":1,"tool_ref_id":"expiry-ref","tool_pin_now_ms":100 + })).unwrap(); + let required = control.pin_requirement_for_run("expiry-run").unwrap(); + let receipt = p.command_data("idempotency.begin", &json!({"scope":"expiry-scope","key":"expiry-key","request_hash":"expiry-proof","resource_type":"run","resource_id":"expiry-run","claim_token":"new-claim","now_ms":200,"expires_at_ms":300})).unwrap(); + assert_eq!(receipt["rows"][0][5], "completed"); + assert_eq!( + control.pin_requirement_for_run("expiry-run").unwrap(), + required + ); + assert!(required.is_some()); + p.shutdown(); +} + fn persistence() -> (std::path::PathBuf, GatewayPersistence) { let root = std::path::PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()) .join(format!("managed-budget-{}", Uuid::new_v4().simple())); @@ -167,7 +649,10 @@ fn managed_native_permit_is_owned_by_queued_request_until_worker_acknowledgement payload: json!({}), respond, cancellation: Some(crate::RunCancellation::with_timeout(Duration::from_secs(5))), - _managed_permit: Some(permit), + _managed_permit: Some(Arc::new(permit)), + maintenance: None, + _maintenance_read: None, + reply_block: None, }) .unwrap(); // The following SDK request is ordered after the dropped-receiver request. diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index a2ca591..3729a2c 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -3,12 +3,15 @@ //! AgentService and cannot call providers or tools directly. mod api_server; +mod effects; +mod plugins; /// Native Telegram Bot API transport, poller, and adapter. Public so /// integration tests can drive the client and adapter against a fixture /// server. pub mod telegram; /// Canonical event → Telegram text rendering (pure; no I/O). mod telegram_render; +mod workspaces; pub use telegram_render::{EventRenderer, RenderAction, TELEGRAM_MAX_UTF16, chunk_text, utf16_len}; /// The typed RSS-backed storage repository (normalized schema, dedicated /// storage worker). Public so integration tests can exercise the typed @@ -196,7 +199,7 @@ impl AgentGatewayState { // Errors opening/compiling the selected file are static and // never fall back to bundled nor echo the path or compiler // private data. - let runner = AgentRunner::from_file(&path, agent_config) + let runner = AgentRunner::from_file(std::path::Path::new(&path), agent_config) .map_err(|_| "selected agent source could not be compiled".to_string())?; let metadata = AgentSourceMetadata { kind: "file", @@ -294,7 +297,7 @@ impl AgentGatewayState { sqlite: config.sqlite.clone(), fuel: config.fuel, }; - let runner = AgentRunner::from_file(&path, agent_config) + let runner = AgentRunner::from_file(std::path::Path::new(&path), agent_config) .map_err(|error| format!("compile RSS agent entry: {error}"))?; let http_config = config.http.clone(); let store = Arc::new(RwLock::new(store::GatewayStore::default())); @@ -338,7 +341,7 @@ impl AgentGatewayState { sqlite: config.sqlite.clone(), fuel: config.fuel, }; - let runner = AgentRunner::from_file(&path, agent_config) + let runner = AgentRunner::from_file(std::path::Path::new(&path), agent_config) .map_err(|error| format!("compile RSS agent entry: {error}"))?; Self::assemble( config, @@ -413,6 +416,18 @@ impl AgentGatewayState { Arc::clone(&self.service) } + /// Host startup or embedding binds plugin control. Request JSON cannot. + pub fn with_plugin_control( + self, + authorization: crate::plugins::InstallAuthorization, + source_root: PathBuf, + deployment_space: impl Into, + ) -> Result { + self.service + .bind_plugin_control(authorization, source_root, deployment_space.into())?; + Ok(self) + } + /// The bounded metrics registry shared by the service, delivery, storage /// worker, and API handlers. pub fn metrics(&self) -> Arc { diff --git a/src/gateway/plugins.rs b/src/gateway/plugins.rs new file mode 100644 index 0000000..9e01981 --- /dev/null +++ b/src/gateway/plugins.rs @@ -0,0 +1,119 @@ +//! Plugin graph administration over the production router. +//! A verified owner marker is not a grant by itself. The trusted host binding +//! supplies InstallAuthorization. Request JSON cannot. + +use axum::{ + Json, + body::Bytes, + extract::{Extension, Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde_json::{Value, json}; + +use super::AgentGatewayState; + +/// Published only after the gateway bearer check succeeds. It carries no token. +#[derive(Clone, Copy, Debug)] +pub(crate) struct VerifiedOwnerCredential; + +pub(crate) async fn install( + State(state): State, + owner: Option>, + body: Bytes, +) -> Response { + dispatch(&state, owner, Some(&body), |service, parsed| { + service.install_controlled_plugin(parsed) + }) +} + +pub(crate) async fn resolve( + State(state): State, + owner: Option>, + body: Bytes, +) -> Response { + dispatch(&state, owner, Some(&body), |service, parsed| { + service.resolve_controlled_graph(parsed) + }) +} + +pub(crate) async fn prepare( + State(state): State, + owner: Option>, + Path(graph_id): Path, +) -> Response { + dispatch(&state, owner, None, |service, _| { + service.prepare_controlled_graph(&graph_id) + }) +} + +pub(crate) async fn get_graph( + State(state): State, + owner: Option>, + Path(graph_id): Path, +) -> Response { + dispatch(&state, owner, None, |service, _| { + service.plugin_graph_view(&graph_id) + }) +} + +pub(crate) async fn activate( + State(state): State, + owner: Option>, + body: Bytes, +) -> Response { + dispatch(&state, owner, Some(&body), |service, parsed| { + service.activate_controlled_graph(parsed) + }) +} + +pub(crate) async fn rollback( + State(state): State, + owner: Option>, + Path(activation_id): Path, +) -> Response { + dispatch(&state, owner, None, |service, _| { + service.rollback_controlled_activation(&activation_id) + }) +} + +fn dispatch( + state: &AgentGatewayState, + owner: Option>, + body: Option<&Bytes>, + operation: impl FnOnce(&crate::service::AgentService, &Value) -> Result, +) -> Response { + if owner.is_none() { + return static_error(StatusCode::FORBIDDEN, "managed_management_denied"); + } + if !state.service().plugin_control_configured() { + return static_error(StatusCode::FORBIDDEN, "plugin_control_unconfigured"); + } + let parsed = match body { + Some(bytes) => match serde_json::from_slice::(bytes) { + Ok(value) if value.is_object() => value, + _ => return static_error(StatusCode::UNPROCESSABLE_ENTITY, "plugin_request_invalid"), + }, + None => Value::Null, + }; + match operation(state.service().as_ref(), &parsed) { + Ok(value) => (StatusCode::OK, Json(value)).into_response(), + Err(code) => static_error(status_for(code), code), + } +} + +fn status_for(code: &str) -> StatusCode { + match code { + "managed_management_denied" + | "plugin_control_unconfigured" + | "plugin_space_mismatch" + | "plugin_publisher_unauthorized" => StatusCode::FORBIDDEN, + "plugin_graph_missing" | "plugin_activation_missing" => StatusCode::NOT_FOUND, + "plugin_activation_conflict" | "plugin_graph_revoked" => StatusCode::CONFLICT, + _ => StatusCode::UNPROCESSABLE_ENTITY, + } +} + +fn static_error(status: StatusCode, code: &'static str) -> Response { + (status, Json(json!({"error":{"code":code,"message":code}}))).into_response() +} diff --git a/src/gateway/store.rs b/src/gateway/store.rs index f638cd4..db165d8 100644 --- a/src/gateway/store.rs +++ b/src/gateway/store.rs @@ -96,6 +96,35 @@ impl std::error::Error for StorageError {} type AdmissionPrecommitHook = Box; +#[cfg(test)] +type AdmissionPayloadTransform = Box; +#[cfg(test)] +type AdmissionResultObserver = Box; + +/// Code of one original admission command. The observer never receives the +/// payload, message, or headers. +#[cfg(test)] +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct AdmissionCommandCode { + pub ok: bool, + pub code: String, +} + +/// Applies a one-shot fixture closure to a shadow, then rejects every delta +/// except the existing failpoint field set to `after_commit_before_publish`. +#[cfg(test)] +fn admission_failpoint_shadow(payload: &Value, transform: AdmissionPayloadTransform) -> Value { + let mut shadow = payload.clone(); + transform(&mut shadow); + let mut expected = payload.clone(); + expected["failpoint"] = json!("after_commit_before_publish"); + if shadow.get("failpoint") != Some(&json!("after_commit_before_publish")) || shadow != expected + { + panic!("admission payload transform may only set failpoint=after_commit_before_publish"); + } + shadow +} + #[cfg(feature = "config-fixture")] struct ApprovalExpireHook { before: Box, @@ -114,6 +143,10 @@ pub struct GatewayPersistence { pub(crate) managed_request_hook: Mutex>, #[cfg(all(test, feature = "workspace-io-fault"))] drop_next_managed_commit_ack: Arc, + #[cfg(all(test, feature = "workspace-io-fault"))] + maintenance_fault_statement: std::sync::atomic::AtomicUsize, + #[cfg(test)] + maintenance_reply_block: Mutex>>, managed_pending: Arc, worker: StorageWorker, max_events: i64, @@ -133,6 +166,31 @@ pub struct GatewayPersistence { plugin_retain_precommit_pause: Mutex>>, plugin_activate_receipt_hook: Mutex>, admission_precommit_hook: Mutex>, + #[cfg(test)] + admission_postcommit_hook: Mutex>, + /// One-shot shadow transform for the same `admission.create` command. + /// Production builds do not have this slot. + #[cfg(test)] + admission_payload_transform: Mutex>, + /// One-shot read-only observer of the original command code. + #[cfg(test)] + admission_result_observer: Mutex>, + /// One-shot drop of a successful admission command ack. The transaction is + /// not submitted again. + #[cfg(test)] + admission_ack_loss: std::sync::atomic::AtomicBool, + /// One-shot post-commit secondary-decode loss. Same seam as ack loss, with + /// the existing `storage_error` code. Not a second RSS failpoint. + #[cfg(test)] + admission_secondary_decode_loss: std::sync::atomic::AtomicBool, + /// One-shot precommit failure. Returns before the transaction, so no row + /// is committed. + #[cfg(test)] + admission_precommit_fail: std::sync::atomic::AtomicBool, + /// Bounded `admission_replay` failures. Armed only after the original + /// postcommit hook so precommit reads cannot consume them. + #[cfg(test)] + admission_replay_fail_remaining: Mutex, #[cfg(feature = "config-fixture")] approval_expire_hook: Mutex>, #[cfg(test)] @@ -391,6 +449,296 @@ impl Drop for PluginActivateReceiptAttempt { } } +pub(crate) struct ManagedMaintenanceClaim { + permit: Arc, + pub(crate) identity: crate::effects::ManagedWorkspaceIdentity, + pub(crate) proof: String, + pub(crate) request_id: String, + pub(crate) action: &'static str, + submitted: AtomicBool, + settled: AtomicBool, + failed: AtomicBool, + busy: AtomicBool, + read_busy: AtomicBool, +} +struct ManagedMaintenanceReadPermit(Arc); +impl Drop for ManagedMaintenanceReadPermit { + fn drop(&mut self) { + self.0.read_busy.store(false, Ordering::Release); + } +} + +fn maintenance_data(result: Value) -> Result { + if result.get("ok") != Some(&Value::Bool(true)) { + match result.get("code").and_then(Value::as_str) { + Some("plugin_graph_revoked") => { + return Err(StorageError { + code: "plugin_graph_revoked".into(), + message: "plugin graph is revoked".into(), + }); + } + Some("plugin_graph_pin_required") => { + return Err(StorageError { + code: "plugin_graph_pin_required".into(), + message: "tool graph pin is required".into(), + }); + } + _ => {} + } + return Err(StorageError { + code: "managed_transaction_rolled_back".into(), + message: "managed transaction rejected".into(), + }); + } + decode_command_data(result.get("data").cloned().unwrap_or(Value::Null)) +} +impl ManagedMaintenanceClaim { + pub(crate) fn submitted(&self) -> bool { + self.submitted.load(Ordering::Acquire) + } + pub(crate) fn worker_failed(&self) -> bool { + self.settled.load(Ordering::Acquire) && self.failed.load(Ordering::Acquire) + } +} +impl GatewayPersistence { + #[cfg(test)] + pub(crate) fn record_synthetic_usage_for_test( + &self, + run_id: &str, + ) -> Result { + self.command_data("run.usage", &json!({"id":"frozen-usage-fact","run_id":run_id,"provider":"synthetic","model":"synthetic","request_count":1,"input_tokens":17,"output_tokens":9,"reasoning_tokens":3,"cached_tokens":2,"now_ms":10})) + } + #[cfg(test)] + pub(crate) fn block_maintenance_reply_for_test(&self) -> PersistBlockGuard { + let guard = self.inject_block_persist(); + *self.maintenance_reply_block.lock().unwrap() = self.persist_block.lock().unwrap().take(); + guard + } + #[cfg(test)] + pub(crate) fn fill_pending_for_test(&self) -> impl Sized { + (self.managed_pending.load(Ordering::Acquire)..32) + .map(|_| ManagedStoragePermit::acquire(&self.managed_pending).unwrap()) + .collect::>() + } + #[cfg(test)] + pub(crate) fn pending_count_for_test(&self) -> usize { + self.managed_pending.load(Ordering::Acquire) + } + pub(crate) fn maintenance_claim( + &self, + identity: crate::effects::ManagedWorkspaceIdentity, + proof: String, + request_id: String, + action: &'static str, + ) -> Result, String> { + Ok(Arc::new(ManagedMaintenanceClaim { + permit: Arc::new(ManagedStoragePermit::acquire(&self.managed_pending)?), + identity, + proof, + request_id, + action, + submitted: AtomicBool::new(false), + settled: AtomicBool::new(false), + failed: AtomicBool::new(false), + busy: AtomicBool::new(false), + read_busy: AtomicBool::new(false), + })) + } + pub(crate) fn maintenance_submit( + &self, + claim: &Arc, + payload: &Value, + cancellation: &crate::RunCancellation, + ) -> Result { + let (proof, request_id, workspace_id, branch_id, store_id) = if claim.action == "checkpoint" + { + let value = crate::plugins::strict_managed_yaml_to_json( + payload["maintenance_json"].as_str().unwrap_or_default(), + ) + .map_err(|_| crate::effects::journal::storage_error("managed metadata invalid"))?; + crate::effects::journal::validate_maintenance_value::< + crate::effects::journal::ManagedCheckpointCommitInput, + >(&value) + .map_err(crate::effects::journal::storage_error)?; + let input: crate::effects::journal::ManagedCheckpointCommitInput = + serde_json::from_str(payload["maintenance_json"].as_str().unwrap_or_default()) + .map_err(|_| { + crate::effects::journal::storage_error( + "managed maintenance identity mismatch", + ) + })?; + ( + input.original_request_proof, + input.request.request_id, + input.captured_source.workspace_id, + input.captured_source.branch_id, + input.captured_source.store_id, + ) + } else { + let value = crate::plugins::strict_managed_yaml_to_json( + payload["restore_json"].as_str().unwrap_or_default(), + ) + .map_err(|_| crate::effects::journal::storage_error("managed metadata invalid"))?; + crate::effects::journal::validate_maintenance_value::< + crate::effects::journal::ManagedRestoreExecutionEnvelope, + >(&value) + .map_err(crate::effects::journal::storage_error)?; + let input: crate::effects::journal::ManagedRestoreExecutionEnvelope = + serde_json::from_str(payload["restore_json"].as_str().unwrap_or_default()) + .map_err(|_| { + crate::effects::journal::storage_error( + "managed maintenance identity mismatch", + ) + })?; + ( + input.original_request_proof, + input.request.request_id, + input.request.expected_source.workspace_id, + input.request.expected_source.branch_id, + input.request.expected_source.store_id, + ) + }; + if proof != claim.proof + || request_id != claim.request_id + || workspace_id != claim.identity.workspace_id + || branch_id != claim.identity.branch_id + || store_id != claim.identity.store_id + || !Arc::ptr_eq(&claim.permit.0, &self.managed_pending) + { + return Err(crate::effects::journal::storage_error( + "managed maintenance identity mismatch", + )); + } + if claim.action == "restore" { + #[cfg(test)] + if let Some(hook) = self + .admission_precommit_hook + .lock() + .expect("admission hook") + .take() + { + hook(payload); + } + validate_composed_restore_budget(payload).map_err(|_| StorageError { + code: "managed_metadata_budget_exceeded".into(), + message: "managed_metadata_budget_exceeded".into(), + })?; + } + if claim.busy.swap(true, Ordering::AcqRel) || claim.settled.load(Ordering::Acquire) { + return Err(crate::effects::journal::storage_error( + "managed maintenance claim already submitted", + )); + } + #[cfg(all(test, feature = "workspace-io-fault"))] + let payload = { + let mut payload = payload.clone(); + let statement = self.maintenance_fault_statement.swap(0, Ordering::AcqRel); + if statement != 0 { + if claim.action == "checkpoint" { + payload["cut"] = json!("managed_rollback"); + } else { + payload["failpoint"] = json!(format!("managed_statement_{statement}")); + } + } + payload + }; + #[cfg(all(test, feature = "workspace-io-fault"))] + let payload = &payload; + let outcome = if claim.action == "checkpoint" { + self.managed_effect_command_claim( + "effect.checkpoint_create", + payload, + Some(cancellation), + Some(claim), + ) + } else { + self.command_with_claim( + "admission.create", + payload, + Some(cancellation), + Some(claim), + None, + ) + .map_err(|message| StorageError { + code: "storage_unavailable".into(), + message, + }) + .and_then(maintenance_data) + }; + outcome.map_err(|error| { + // These closed RSS classifications follow a transaction whose + // every admission write was disabled by the same pin predicate. + if matches!( + error.code.as_str(), + "plugin_graph_revoked" | "plugin_graph_pin_required" + ) { + return error; + } + StorageError { + code: if claim.submitted() { + "managed_submitted_unknown" + } else { + "managed_not_submitted" + } + .into(), + message: error.message, + } + }) + } + pub(crate) fn maintenance_read( + &self, + claim: &Arc, + identity: &crate::effects::ManagedWorkspaceIdentity, + proof: &str, + op: &str, + payload: &Value, + cancellation: &crate::RunCancellation, + ) -> Result { + if !claim.settled.load(Ordering::Acquire) { + return Err("managed_submitted_unknown".into()); + } + if !Arc::ptr_eq(&claim.permit.0, &self.managed_pending) + || &claim.identity != identity + || claim.proof != proof + || !matches!( + op, + "effect.maintenance_receipt" | "effect.branch_publication_history" + ) + || payload["workspace_id"].as_str() != Some(identity.workspace_id.as_str()) + { + return Err("managed_restore_reconciliation_required".into()); + } + if payload["receipt_id"] != claim.request_id + || (op == "effect.maintenance_receipt" && payload["cut"] != claim.action) + { + return Err("managed_restore_reconciliation_required".into()); + } + if op == "effect.branch_publication_history" && payload["cut"] == "checkpoint_rollback" { + let input: crate::effects::journal::ManagedCheckpointCommitInput = + serde_json::from_str(payload["record_json"].as_str().unwrap_or_default()) + .map_err(|_| "managed_restore_reconciliation_required")?; + if claim.action != "checkpoint" + || input.original_request_proof != claim.proof + || input.request.request_id != claim.request_id + { + return Err("managed_restore_reconciliation_required".into()); + } + } + if op == "effect.branch_publication_history" && payload["cut"] == "rollback" { + let envelope: crate::effects::journal::ManagedRestoreExecutionEnvelope = + serde_json::from_str(payload["record_json"].as_str().unwrap_or_default()) + .map_err(|_| "managed_restore_reconciliation_required")?; + if envelope.original_request_proof != claim.proof + || envelope.request.request_id != claim.request_id + { + return Err("managed_restore_reconciliation_required".into()); + } + } + self.managed_effect_command_claim(op, payload, Some(cancellation), Some(claim)) + .map_err(|_| "managed_submitted_unknown".into()) + } +} + struct ManagedStoragePermit(Arc); impl ManagedStoragePermit { fn acquire(pending: &Arc) -> Result { @@ -414,7 +762,11 @@ struct StorageRequest { payload: Value, respond: Sender>, cancellation: Option, - _managed_permit: Option, + _managed_permit: Option>, + maintenance: Option>, + _maintenance_read: Option, + #[cfg(test)] + reply_block: Option>, } /// The dedicated storage worker: owns the compiled RSS program and executes @@ -539,11 +891,35 @@ fn storage_worker_loop( Ok(mut request) => { let result = runner.command(&request.op, &request.payload, request.cancellation.as_ref()); + #[cfg(test)] + if let Some(block) = request.reply_block.take() { + block.enter_and_wait(); + } // The SDK operation has settled; acknowledge only after its // Native admission charge is released, even if the receiver left. + if let Some(claim) = &request.maintenance + && (request.op == "admission.create" + || request.op == "effect.checkpoint_create") + { + claim.failed.store( + !result + .as_ref() + .is_ok_and(|value| value.get("ok") == Some(&Value::Bool(true))), + Ordering::Release, + ); + claim.settled.store(true, Ordering::Release); + } + drop(request._maintenance_read.take()); drop(request._managed_permit.take()); #[cfg(all(test, feature = "workspace-io-fault"))] - if request.op == "effect.commit" + if (request.op == "effect.commit" + || request.op == "effect.checkpoint_create" + || (request.op == "admission.create" + && request + .payload + .get("restore_json") + .and_then(Value::as_str) + .is_some_and(|value| !value.is_empty()))) && result .as_ref() .is_ok_and(|value| value.get("ok") == Some(&Value::Bool(true))) @@ -652,25 +1028,54 @@ fn managed_parameter_bindings(op: &str) -> &'static [(&'static str, usize)] { ("source_inode", 1), ], "effect.workspace_register" => &[ - ("workspace_json", 11), + ("workspace_json", 12), ("store_id", 4), ("profile", 4), ("source_device", 4), ("source_inode", 4), ], + "effect.run_binding" => &[("run_id", 1)], + "effect.run_list" | "effect.run_list_has_more" | "effect.run_cursor" => &[ + ("run_id", 1), + ("profile", 1), + ("session_id", 1), + ("workspace_id", 1), + ("branch_id", 1), + ("after_effect_id", 1), + ], "effect.head" => &[("workspace_id", 1), ("branch_id", 1)], "effect.get" => &[("run_id", 1), ("call_id", 1), ("profile", 1)], "effect.begin" => &[ ("effect_id", 6), - ("run_id", 8), + ("run_id", 23), ("call_id", 2), ("record_json", 12), - ("workspace_id", 1), - ("branch_id", 1), + ("workspace_id", 2), + ("branch_id", 2), ("commit_id", 3), - ("projections_json", 8), + ("projections_json", 10), ("cut", 1), + ("sealed_class", 2), ], + "effect.checkpoint_context" => &[ + ("run_id", 1), + ("workspace_id", 1), + ("branch_id", 1), + ("profile", 1), + ], + "effect.maintenance_receipt" => &[("workspace_id", 2), ("receipt_id", 2)], + "effect.branch_publication_history" => { + &[("workspace_id", 3), ("receipt_id", 1), ("record_json", 20)] + } + "effect.checkpoint_get" => &[("workspace_id", 1), ("commit_id", 1)], + "effect.checkpoint_create" => &[("maintenance_json", 38)], + "effect.branch_create" => &[("maintenance_json", 62)], + "effect.branch_receipt" => &[("workspace_id", 1), ("receipt_id", 1)], + "effect.branch_origin" => &[("workspace_id", 2), ("branch_id", 3)], + "effect.publish_eligibility" => &[("maintenance_json", 31)], + "effect.publish_submit" => &[("maintenance_json", 86)], + "effect.publish_receipt" => &[("maintenance_json", 2)], + "effect.publication_get" => &[("maintenance_json", 2)], "effect.prepare" => &[ ("effect_id", 5), ("receipt_id", 2), @@ -686,11 +1091,11 @@ fn managed_parameter_bindings(op: &str) -> &'static [(&'static str, usize)] { ("receipt_id", 1), ("prepared_json", 9), ("cut", 5), - ("projections_json", 13), + ("projections_json", 15), ], "effect.recover_one" => &[ ("commit_id", 4), - ("projections_json", 13), + ("projections_json", 15), ("effect_id", 5), ("run_id", 5), ], @@ -699,6 +1104,354 @@ fn managed_parameter_bindings(op: &str) -> &'static [(&'static str, usize)] { } } +// Fixed fresh-session restore transaction text bindings. Numeric/null SDK +// parameters have zero byte weight; the source census checks these weights. +// Complete canonical admission.create literal query/statement text binds, +// conservatively including alternative branches; helper-expanded binds follow separately. +const ADMISSION_CREATE_BINDINGS: &[(&str, usize)] = &[ + ("account_id", 1), + ("event_id", 1), + ("idempotency_key", 3), + ("idempotency_scope", 3), + ("input_json", 7), + ("message_id", 3), + ("message_run_id", 1), + ("model", 4), + ("parent_run_id", 13), + ("platform", 1), + ("profile", 2), + ("provider", 4), + ("request_hash", 1), + ("run_id", 30), + ("script_hash", 1), + ("session_id", 21), + ("session_metadata_json", 11), + ("system_prompt", 3), + ("tool_graph_id", 34), + ("tool_ref_id", 12), + ("tool_registry_identity", 1), + ("tool_workspace", 1), + ("workspace_identity_json", 8), +]; + +const RESTORE_ENVELOPE_BINDINGS: usize = 48; +#[cfg(test)] +const RESTORE_ADMISSION_BINDINGS: &[(&str, usize)] = &[ + ("session_id", 14), + ("profile", 2), + ("platform", 1), + ("account_id", 1), + ("system_prompt", 1), + ("model", 2), + ("provider", 2), + ("session_metadata_json", 5), + ("parent_run_id", 7), + ("run_id", 15), + ("message_id", 2), + ("message_run_id", 1), + ("input_json", 7), + ("script_hash", 1), + ("idempotency_scope", 1), + ("idempotency_key", 1), + ("request_hash", 1), + ("workspace_identity_json", 2), + ("tool_graph_id", 4), + ("tool_ref_id", 2), + ("event_id", 1), +]; + +const ORDINARY_PIN_BINDINGS: &[(&str, usize)] = &[ + ("tool_graph_id", 22), + ("run_id", 13), + ("tool_ref_id", 11), + ("session_id", 2), +]; +const CHECKPOINT_PIN_BINDINGS: &[(&str, usize)] = &[ + ("tool_graph_id", 20), + ("run_id", 12), + ("tool_ref_id", 10), + ("session_id", 2), +]; +// All alternative admission writes and bounded rollback cuts are charged conservatively. +#[cfg(test)] +const ADMISSION_GRAPH_GUARD_BINDINGS: &[(&str, usize)] = + &[("tool_graph_id", 30), ("run_id", 10), ("tool_ref_id", 10)]; +const MANAGED_ADMISSION_GRAPH_GUARD_BINDINGS: &[(&str, usize)] = + &[("tool_graph_id", 6), ("run_id", 2), ("tool_ref_id", 2)]; +const RESTORE_GRAPH_GUARD_BINDINGS: &[(&str, usize)] = + &[("tool_graph_id", 39), ("run_id", 13), ("tool_ref_id", 13)]; +const EVENT_PAIR_BINDINGS: &[(&str, usize)] = &[ + ("run_id", 20), + ("event_id_a", 2), + ("event_type_a", 1), + ("payload_json_a", 1), + ("event_id_b", 2), + ("event_type_b", 1), + ("payload_json_b", 1), + ("failpoint", 1), +]; + +fn fixed_parameter_bytes(payload: &Value, bindings: &[(&str, usize)]) -> Result { + bindings.iter().try_fold(0usize, |sum, (name, count)| { + payload[name] + .as_str() + .unwrap_or_default() + .len() + .checked_mul(*count) + .and_then(|bytes| sum.checked_add(bytes)) + .ok_or_else(|| "managed_metadata_budget_exceeded".into()) + }) +} + +// These are SQLite's actual output names (qualified simple columns lose the +// table qualifier). Keep the positional nine-cell projections in parity tests. +const HARNESS_PIN_READ_COLUMNS: &[&str] = &[ + "COALESCE(json_extract(idem.response_json, '$.tool_graph_id'), '')", + "COALESCE(json_extract(idem.response_json, '$.tool_ref_id'), '')", + "COALESCE(CAST(json_extract(idem.response_json, '$.tool_generation') AS INTEGER), 0)", + "resource_id", + "COALESCE((SELECT session_id FROM runs WHERE id = idem.resource_id), '')", + "COALESCE(json_extract(idem.response_json, '$.registry_identity'), '')", + "COALESCE(json_extract(idem.response_json, '$.workspace'), '')", + "COALESCE(json_extract(idem.response_json, '$.profile'), '')", + "COALESCE(json_extract(idem.response_json, '$.harness_selection'), 'null')", +]; +fn harness_receipt_read_bytes(payload: &Value, outer: &Value) -> Result<(usize, usize), String> { + let rejected = || "managed_metadata_budget_exceeded".to_string(); + let mut receipt = json!({"run_id":payload["run_id"],"status":"running","tool_graph_id":payload["tool_graph_id"],"tool_ref_id":payload["tool_ref_id"],"tool_generation":payload["tool_graph_generation"],"registry_identity":payload["tool_registry_identity"].as_str().unwrap_or_default(),"workspace":payload["tool_workspace"].as_str().unwrap_or_default(),"profile":payload["profile"],"harness_selection":outer["run_graph_selection"]["harness_selection"]}); + if let Some(identity) = payload["workspace_identity_json"] + .as_str() + .filter(|s| !s.is_empty()) + { + receipt["workspace_identity"] = serde_json::from_str(identity).map_err(|_| rejected())?; + } + let encoded = serde_json::to_vec(&receipt).map_err(|_| rejected())?; + let keyed = !payload["idempotency_key"] + .as_str() + .unwrap_or_default() + .is_empty(); + let scope = if keyed { + payload["idempotency_scope"].as_str().unwrap_or_default() + } else { + "native.tool-pin" + }; + let key = if keyed { + &payload["idempotency_key"] + } else { + &payload["run_id"] + }; + let mut receipt_bytes = crate::config::ADMISSION_IDEMPOTENCY_QUERY_COLUMNS + .iter() + .map(|c| c.name.len()) + .sum::(); + for n in [ + scope.len(), + key.as_str().unwrap_or_default().len(), + payload["request_hash"].as_str().unwrap_or_default().len(), + 3, + payload["run_id"].as_str().unwrap_or_default().len(), + 9, + encoded.len(), + 24, + ] { + receipt_bytes = receipt_bytes.checked_add(n).ok_or_else(rejected)?; + } + let mut pin_bytes = HARNESS_PIN_READ_COLUMNS + .iter() + .map(|s| s.len()) + .sum::(); + for n in [ + payload["tool_graph_id"].as_str().unwrap_or_default().len(), + payload["tool_ref_id"].as_str().unwrap_or_default().len(), + 8, + payload["run_id"].as_str().unwrap_or_default().len(), + payload["session_id"].as_str().unwrap_or_default().len(), + payload["tool_registry_identity"] + .as_str() + .unwrap_or_default() + .len(), + payload["tool_workspace"].as_str().unwrap_or_default().len(), + payload["profile"].as_str().unwrap_or_default().len(), + serde_json::to_vec(&outer["run_graph_selection"]["harness_selection"]) + .map_err(|_| rejected())? + .len(), + ] { + pin_bytes = pin_bytes.checked_add(n).ok_or_else(rejected)?; + } + Ok((receipt_bytes, pin_bytes)) +} +fn validate_harness_receipt_reads(payload: &Value) -> Result<(), String> { + let outer: Value = serde_json::from_str(payload["input_json"].as_str().unwrap_or("{}")) + .map_err(|_| "managed_metadata_budget_exceeded".to_string())?; + if outer["run_graph_selection"]["harness_selection"]["mode"] != "context_prompt" { + return Ok(()); + } + let (receipt, pin) = harness_receipt_read_bytes(payload, &outer)?; + if receipt > crate::config::ADMISSION_IDEMPOTENCY_QUERY_LIMIT_BYTES + || pin > crate::config::ADMISSION_IDEMPOTENCY_QUERY_LIMIT_BYTES + { + return Err("managed_metadata_budget_exceeded".into()); + } + Ok(()) +} + +fn harness_admission_parameter_bytes(payload: &Value, restore: bool) -> Result { + let rejected = || "managed_metadata_budget_exceeded".to_string(); + let wire = serde_json::to_vec(payload).map_err(|_| rejected())?.len(); + let mut total = wire.checked_mul(5).ok_or_else(rejected)?; + let mut add = |n: usize, w: usize| -> Result<(), String> { + total = total + .checked_add(n.checked_mul(w).ok_or_else(rejected)?) + .ok_or_else(rejected)?; + Ok(()) + }; + add( + fixed_parameter_bytes(payload, ADMISSION_CREATE_BINDINGS)?, + 1, + )?; + add( + fixed_parameter_bytes( + payload, + if restore { + CHECKPOINT_PIN_BINDINGS + } else { + ORDINARY_PIN_BINDINGS + }, + )?, + 1, + )?; + add(payload["input_json"].as_str().unwrap_or_default().len(), 4)?; + let keyed = !payload["idempotency_key"] + .as_str() + .unwrap_or_default() + .is_empty(); + let scope = if keyed { + payload["idempotency_scope"].as_str().unwrap_or_default() + } else { + "native.tool-pin" + }; + let key = if keyed { + payload["idempotency_key"].as_str().unwrap_or_default() + } else { + payload["run_id"].as_str().unwrap_or_default() + }; + add(scope.len(), 2)?; + add(key.len(), 3)?; + let outer: Value = serde_json::from_str(payload["input_json"].as_str().unwrap_or("{}")) + .map_err(|_| rejected())?; + let raw = serde_json::to_string(&outer["run_context"]["input"]).map_err(|_| rejected())?; + add(raw.len(), 2)?; + add( + serde_json::to_vec(&json!([{"type":"text","text":raw}])) + .map_err(|_| rejected())? + .len(), + 1, + )?; + if let Some(binding) = payload["managed_binding_json"] + .as_str() + .filter(|s| !s.is_empty()) + { + add( + fixed_parameter_bytes(payload, MANAGED_ADMISSION_GRAPH_GUARD_BINDINGS)?, + 1, + )?; + let binding: Value = serde_json::from_str(binding).map_err(|_| rejected())?; + for (field, n) in [ + ("workspace_id", 3), + ("branch_id", 2), + ("record_json", 5), + ("run_id", 1), + ("profile", 1), + ] { + add(binding[field].as_str().unwrap_or_default().len(), n)?; + } + } + if restore { + let envelope = payload["restore_json"].as_str().ok_or_else(rejected)?; + add(envelope.len(), RESTORE_ENVELOPE_BINDINGS)?; + add( + fixed_parameter_bytes(payload, RESTORE_GRAPH_GUARD_BINDINGS)?, + 1, + )?; + let envelope: Value = serde_json::from_str(envelope).map_err(|_| rejected())?; + add( + serde_json::to_vec(&envelope["result"]["graph"]["harness_selection"]) + .map_err(|_| rejected())? + .len(), + 1, + )?; + for v in [ + &envelope["request"]["checkpoint_id"], + &envelope["result"]["graph"]["checkpoint_graph_ref_id"], + ] { + add(v.as_str().unwrap_or_default().len(), 1)?; + } + } + Ok(total) +} +fn validate_harness_admission_budget(payload: &Value) -> Result<(), String> { + validate_harness_receipt_reads(payload)?; + if harness_admission_parameter_bytes(payload, false)? > crate::effects::journal::METADATA_BYTES + || serde_json::to_vec(payload) + .map_err(|_| "managed_metadata_budget_exceeded")? + .len() + > crate::effects::journal::METADATA_BYTES + { + return Err("managed_metadata_budget_exceeded".into()); + } + Ok(()) +} + +fn validate_graph_parameter_budget(op: &str, payload: &Value) -> Result<(), String> { + let mut total = 0usize; + let mut add = |bindings| -> Result<(), String> { + total = total + .checked_add(fixed_parameter_bytes(payload, bindings)?) + .ok_or("managed_metadata_budget_exceeded")?; + Ok(()) + }; + if op == "event.append_pair" { + add(EVENT_PAIR_BINDINGS)?; + // The run-existence prequery is outside the fixed statement array. + add(&[("run_id", 1)])?; + } else if op == "admission.create" + && !payload["tool_graph_id"] + .as_str() + .unwrap_or_default() + .is_empty() + && payload["restore_json"] + .as_str() + .unwrap_or_default() + .is_empty() + { + return validate_harness_admission_budget(payload); + } + if total > crate::effects::journal::METADATA_BYTES { + return Err("managed_metadata_budget_exceeded".into()); + } + Ok(()) +} + +pub(crate) fn validate_composed_restore_budget(payload: &Value) -> Result<(), String> { + if payload["restore_json"] + .as_str() + .unwrap_or_default() + .is_empty() + { + return Ok(()); + } + validate_harness_receipt_reads(payload)?; + if harness_admission_parameter_bytes(payload, true)? > crate::effects::journal::METADATA_BYTES + || serde_json::to_vec(payload) + .map_err(|_| "managed_metadata_budget_exceeded")? + .len() + > crate::effects::journal::METADATA_BYTES + { + return Err("managed_metadata_budget_exceeded".into()); + } + Ok(()) +} + #[cfg(test)] #[path = "managed_storage_budget_tests.rs"] mod managed_storage_budget_tests; @@ -757,6 +1510,10 @@ impl GatewayPersistence { Ok(Self { #[cfg(all(test, feature = "workspace-io-fault"))] drop_next_managed_commit_ack, + #[cfg(all(test, feature = "workspace-io-fault"))] + maintenance_fault_statement: std::sync::atomic::AtomicUsize::new(0), + #[cfg(test)] + maintenance_reply_block: Mutex::new(None), managed_pending: Arc::new(std::sync::atomic::AtomicUsize::new(0)), #[cfg(test)] managed_request_hook: Mutex::new(None), @@ -781,6 +1538,20 @@ impl GatewayPersistence { plugin_retain_precommit_pause: Mutex::new(None), plugin_activate_receipt_hook: Mutex::new(None), admission_precommit_hook: Mutex::new(None), + #[cfg(test)] + admission_postcommit_hook: Mutex::new(None), + #[cfg(test)] + admission_payload_transform: Mutex::new(None), + #[cfg(test)] + admission_result_observer: Mutex::new(None), + #[cfg(test)] + admission_ack_loss: std::sync::atomic::AtomicBool::new(false), + #[cfg(test)] + admission_secondary_decode_loss: std::sync::atomic::AtomicBool::new(false), + #[cfg(test)] + admission_precommit_fail: std::sync::atomic::AtomicBool::new(false), + #[cfg(test)] + admission_replay_fail_remaining: Mutex::new(0), #[cfg(feature = "config-fixture")] approval_expire_hook: Mutex::new(None), #[cfg(test)] @@ -802,6 +1573,11 @@ impl GatewayPersistence { self.worker.executed_commands.load(Ordering::Relaxed) } + #[cfg(all(test, feature = "workspace-io-fault"))] + pub(crate) fn inject_maintenance_transaction_fault(&self, statement: usize) { + self.maintenance_fault_statement + .store(statement, Ordering::Release); + } #[cfg(all(test, feature = "workspace-io-fault"))] pub(crate) fn inject_drop_managed_commit_ack(&self) { self.drop_next_managed_commit_ack @@ -827,7 +1603,21 @@ impl GatewayPersistence { payload: &Value, parent: Option<&crate::RunCancellation>, ) -> Result { - let cancellation = if op.starts_with("effect.") { + self.command_with_claim(op, payload, parent, None, None) + } + fn command_with_claim( + &self, + op: &str, + payload: &Value, + parent: Option<&crate::RunCancellation>, + claim: Option<&Arc>, + read_timeout: Option, + ) -> Result { + validate_graph_parameter_budget(op, payload)?; + if op == "admission.create" { + validate_composed_restore_budget(payload)?; + } + let cancellation = if op.starts_with("effect.") || claim.is_some() { let parent = parent.ok_or("managed storage context missing")?; if parent.deadline_instant().is_none() { return Err("managed storage finite original deadline required".into()); @@ -853,8 +1643,28 @@ impl GatewayPersistence { { return Err("managed storage original deadline/cancellation".into()); } - let managed_permit = if cancellation.is_some() { - Some(ManagedStoragePermit::acquire(&self.managed_pending)?) + let maintenance_read = if let Some(claim) = claim.filter(|_| { + matches!( + op, + "effect.maintenance_receipt" | "effect.branch_publication_history" + ) + }) { + if claim.read_busy.swap(true, Ordering::AcqRel) { + return Err("managed_submitted_unknown".into()); + } + Some(ManagedMaintenanceReadPermit(claim.clone())) + } else { + None + }; + let managed_permit = if let Some(claim) = claim { + if !Arc::ptr_eq(&claim.permit.0, &self.managed_pending) { + return Err("managed maintenance persistence mismatch".into()); + } + Some(claim.permit.clone()) + } else if cancellation.is_some() { + Some(Arc::new(ManagedStoragePermit::acquire( + &self.managed_pending, + )?)) } else { None }; @@ -888,12 +1698,16 @@ impl GatewayPersistence { Err("gateway storage worker is not running".to_string()) } else { let (respond, response) = mpsc::channel(); - let response_timeout = cancellation - .as_ref() - .and_then(crate::RunCancellation::remaining_deadline) - .map_or(STORAGE_COMMAND_TIMEOUT, |remaining| { - remaining.min(STORAGE_COMMAND_TIMEOUT) - }); + let response_timeout = if let Some(timeout) = read_timeout { + timeout.min(STORAGE_COMMAND_TIMEOUT) + } else { + cancellation + .as_ref() + .and_then(crate::RunCancellation::remaining_deadline) + .map_or(STORAGE_COMMAND_TIMEOUT, |remaining| { + remaining.min(STORAGE_COMMAND_TIMEOUT) + }) + }; #[cfg(test)] if let Some(cancel) = &cancellation { let hook = self @@ -911,16 +1725,35 @@ impl GatewayPersistence { respond, cancellation, _managed_permit: managed_permit, + maintenance: claim.cloned(), + _maintenance_read: maintenance_read, + #[cfg(test)] + reply_block: if claim.is_some() { + self.maintenance_reply_block.lock().unwrap().take() + } else { + None + }, }); match sent { - Ok(()) => response - .recv_timeout(response_timeout) - .map_err(|error| match error { - RecvTimeoutError::Timeout => "gateway storage worker timed out".to_string(), - RecvTimeoutError::Disconnected => { - "gateway storage worker exited unexpectedly".to_string() - } - })?, + Ok(()) => { + if let Some(claim) = claim + && (op == "admission.create" + || op == "effect.checkpoint_create" + || op == "effect.publish_submit") + { + claim.submitted.store(true, Ordering::Release); + } + response + .recv_timeout(response_timeout) + .map_err(|error| match error { + RecvTimeoutError::Timeout => { + "gateway storage worker timed out".to_string() + } + RecvTimeoutError::Disconnected => { + "gateway storage worker exited unexpectedly".to_string() + } + })? + } Err(_) => Err("gateway storage worker is not running".to_string()), } }; @@ -981,6 +1814,73 @@ impl GatewayPersistence { } decode_command_data(result.get("data").cloned().unwrap_or(Value::Null)) } + + /// Private original-owner proof read. `Some(until)` shares one window of + /// the original remaining deadline, capped by the existing command timeout. + /// `None` is an already-expired confirmation: the existing command timeout + /// still runs once and is not rejected for remaining=0. This is not a new + /// execution clock and does not refresh the run deadline. + pub(crate) fn command_data_for_original_proof( + &self, + op: &str, + payload: &Value, + budget_until: Option, + ) -> Result { + let read_timeout = if let Some(until) = budget_until { + let now = Instant::now(); + if now >= until { + return Err(StorageError { + code: "storage_unavailable".into(), + message: "original proof read budget exhausted".into(), + }); + } + Some(until.saturating_duration_since(now)) + } else { + None + }; + let result = self + .command_with_claim(op, payload, None, None, read_timeout) + .map_err(|message| StorageError { + code: "storage_unavailable".into(), + message, + })?; + if result.get("ok") != Some(&Value::Bool(true)) { + return Err(StorageError { + code: result + .get("code") + .and_then(Value::as_str) + .unwrap_or("storage_error") + .to_string(), + message: result + .get("message") + .and_then(Value::as_str) + .unwrap_or("storage command failed") + .to_string(), + }); + } + decode_command_data(result.get("data").cloned().unwrap_or(Value::Null)) + } + + /// Shared proof window from the original remaining deadline, capped by the + /// existing command timeout. `None` means the original deadline is already + /// gone: confirmation still uses the existing command timeout once and is + /// not rejected. The execution deadline is not refreshed. + pub(crate) fn original_proof_budget(original: &crate::RunCancellation) -> Option { + if original.requested().is_some() + || original.deadline_passed() + || original + .remaining_deadline() + .is_some_and(|remaining| remaining.is_zero()) + { + return None; + } + let window = original + .remaining_deadline() + .unwrap_or(STORAGE_COMMAND_TIMEOUT) + .min(STORAGE_COMMAND_TIMEOUT); + Instant::now().checked_add(window) + } + pub(crate) fn managed_effect_command_with_cancellation( &self, op: &str, @@ -1002,14 +1902,39 @@ impl GatewayPersistence { op: &str, payload: &Value, cancellation: Option<&crate::RunCancellation>, + ) -> Result { + self.managed_effect_command_claim(op, payload, cancellation, None) + } + fn managed_effect_command_claim( + &self, + op: &str, + payload: &Value, + cancellation: Option<&crate::RunCancellation>, + claim: Option<&Arc>, ) -> Result { use crate::effects::journal::{METADATA_BYTES, storage_error}; const OPS: &[&str] = &[ + "effect.run_binding", + "effect.run_list", + "effect.run_list_has_more", + "effect.run_cursor", "effect.workspace_get", "effect.workspace_register", "effect.head", "effect.get", "effect.begin", + "effect.checkpoint_context", + "effect.checkpoint_create", + "effect.branch_create", + "effect.branch_receipt", + "effect.branch_origin", + "effect.publish_eligibility", + "effect.publish_submit", + "effect.publish_receipt", + "effect.publication_get", + "effect.checkpoint_get", + "effect.maintenance_receipt", + "effect.branch_publication_history", "effect.prepare", "effect.commit", "effect.pending", @@ -1022,7 +1947,7 @@ impl GatewayPersistence { let mut wire = json!({"record_json":"", "prepared_json":"", "workspace_json":"", "projections_json":"", "effect_id":"", "receipt_id":"", "commit_id":"", "run_id":"", "call_id":"", "store_id":"", "profile":"", "source_device":"", "source_inode":"", "workspace_id":"", "branch_id":"", - "now_ms":timestamp(), "max_events":self.max_events, "cut":""}); + "now_ms":timestamp(), "max_events":self.max_events, "cut":"", "sealed_class":"", "maintenance_json":"", "session_id":"", "after_effect_id":"", "limit":128}); let fields = payload .as_object() .ok_or_else(|| storage_error("managed command must be a record"))?; @@ -1036,6 +1961,16 @@ impl GatewayPersistence { return Err(storage_error("unknown managed command field")); } } + if op == "effect.begin" + && !matches!( + wire["sealed_class"].as_str(), + Some( + "workspace_read" | "workspace_write" | "foreground_process" | "process_control" + ) + ) + { + return Err(storage_error("managed sealed class invalid")); + } if serde_json::to_vec(&wire) .map_err(|e| storage_error(e.to_string()))? .len() @@ -1066,13 +2001,31 @@ impl GatewayPersistence { "managed aggregate parameter bytes exceed 1 MiB bound", )); } - if let Some(cancellation) = cancellation { + if let Some(claim) = claim { + let result = self + .command_with_claim(op, &wire, cancellation, Some(claim), None) + .map_err(|message| StorageError { + code: "managed_submitted_unknown".into(), + message, + })?; + maintenance_data(result) + } else if let Some(cancellation) = cancellation { self.command_data_with_cancellation(op, &wire, cancellation) } else { self.command_data(op, &wire) } } + pub(crate) fn publish_command( + &self, + op: &str, + payload: &Value, + cancellation: &crate::RunCancellation, + claim: Option<&Arc>, + ) -> Result { + self.managed_effect_command_claim(op, payload, Some(cancellation), claim) + } + /// Trusted SDK metadata lookup. IDs never create workspace authority. pub(crate) fn managed_object_census( &self, @@ -1227,6 +2180,101 @@ impl GatewayPersistence { crate::effects::journal::decode_record(&value) .map_err(crate::effects::journal::storage_error) } + pub(crate) fn managed_run_query_binding( + &self, + run_id: &str, + cancellation: &crate::RunCancellation, + ) -> Result, StorageError> { + let value = self.managed_effect_command_with_cancellation( + "effect.run_binding", + &json!({"run_id":run_id}), + cancellation, + )?; + crate::effects::journal::decode_record(&value) + .map_err(crate::effects::journal::storage_error) + } + + pub(crate) fn managed_run_effect_list( + &self, + binding: &crate::effects::journal::ManagedRunQueryBinding, + request: &crate::effects::ManagedRunEffectListRequest, + cancellation: &crate::RunCancellation, + ) -> Result { + use crate::effects::ManagedRunEffectError as E; + let wire = json!({"run_id":binding.owner.run_id,"profile":binding.owner.profile,"session_id":binding.owner.session_id, + "workspace_id":binding.workspace.workspace_id,"branch_id":binding.workspace.branch_id, + "after_effect_id":request.after_effect_id.as_deref().unwrap_or(""),"limit":request.limit}); + if let Some(cursor) = &request.after_effect_id { + let value = self + .managed_effect_command_with_cancellation("effect.run_cursor", &wire, cancellation) + .map_err(|_| E::StorageUnavailable)?; + let rows = crate::effects::journal::decode_effect_summaries(&value, 1) + .map_err(|_| E::StorageUnavailable)?; + if rows.len() != 1 + || rows[0].effect_id != *cursor + || rows[0].run_id != binding.owner.run_id + || rows[0].workspace_id != binding.workspace.workspace_id + || rows[0].branch_id != binding.workspace.branch_id + || rows[0].epoch != binding.epoch + { + return Err(E::Unavailable); + } + } + let value = self + .managed_effect_command_with_cancellation("effect.run_list", &wire, cancellation) + .map_err(|_| E::StorageUnavailable)?; + let entries = crate::effects::journal::decode_effect_summaries(&value, request.limit) + .map_err(|_| E::StorageUnavailable)?; + let mut previous = request.after_effect_id.as_deref().unwrap_or(""); + for entry in &entries { + if entry.run_id != binding.owner.run_id + || entry.workspace_id != binding.workspace.workspace_id + || entry.branch_id != binding.workspace.branch_id + || entry.epoch != binding.epoch + || entry.effect_id.as_str() <= previous + { + return Err(E::StorageUnavailable); + } + previous = &entry.effect_id; + } + let has_more = if let Some(last) = entries.last() { + let mut after = wire; + after["after_effect_id"] = json!(last.effect_id); + after["limit"] = json!(1); + let value = self + .managed_effect_command_with_cancellation( + "effect.run_list_has_more", + &after, + cancellation, + ) + .map_err(|_| E::StorageUnavailable)?; + let remaining = crate::effects::journal::decode_effect_summaries(&value, 1) + .map_err(|_| E::StorageUnavailable)?; + if remaining.iter().any(|entry| { + entry.run_id != binding.owner.run_id + || entry.workspace_id != binding.workspace.workspace_id + || entry.branch_id != binding.workspace.branch_id + || entry.epoch != binding.epoch + || entry.effect_id <= last.effect_id + }) { + return Err(E::StorageUnavailable); + } + !remaining.is_empty() + } else { + false + }; + let next_cursor = if has_more { + entries.last().map(|entry| entry.effect_id.clone()) + } else { + None + }; + Ok(crate::effects::ManagedEffectPage { + entries, + next_cursor, + has_more, + }) + } + pub fn managed_effect_list( &self, request: &crate::effects::ManagedEffectListRequest, @@ -1342,6 +2390,21 @@ impl GatewayPersistence { .ok_or_else(|| crate::effects::journal::storage_error("managed head missing")) } + pub fn managed_workspace_publication( + &self, + workspace_id: &str, + profile: &str, + ) -> Result, StorageError> { + let document = json!({"workspace_id": workspace_id, "profile": profile}); + let payload = json!({ + "maintenance_json": serde_json::to_string(&document) + .map_err(|_| crate::effects::journal::storage_error("managed metadata invalid"))?, + }); + let value = self.managed_effect_command("effect.publication_get", &payload)?; + crate::effects::journal::decode_record(&value) + .map_err(crate::effects::journal::storage_error) + } + pub(crate) fn plugin_control_command( &self, op: &str, @@ -1349,6 +2412,7 @@ impl GatewayPersistence { ) -> Result { const OPS: &[&str] = &[ "plugin.lock_put", + "plugin.harness_binding_matches", "plugin.lock_get", "plugin.lock_list", "plugin.ref_list", @@ -1359,7 +2423,10 @@ impl GatewayPersistence { "plugin.retention_get", "plugin.release", "plugin.pin_for_owner", + "plugin.pin_requirement_get", "plugin.collect", + "plugin.graph_revoke", + "plugin.graph_revocation_get", ]; if !OPS.contains(&op) { return Err(StorageError { @@ -1370,6 +2437,26 @@ impl GatewayPersistence { self.command_data(op, payload) } + pub(crate) fn plugin_control_command_for_proof( + &self, + op: &str, + payload: &Value, + budget_until: Option, + ) -> Result { + const OPS: &[&str] = &[ + "plugin.pin_for_owner", + "plugin.pin_requirement_get", + "plugin.harness_binding_matches", + ]; + if !OPS.contains(&op) { + return Err(StorageError { + code: "plugin_control_unavailable".to_string(), + message: "plugin control operation is not registered".to_string(), + }); + } + self.command_data_for_original_proof(op, payload, budget_until) + } + // ------------------------------------------------------------------ // Typed repository commands (production read/write path). // ------------------------------------------------------------------ @@ -1387,7 +2474,108 @@ impl GatewayPersistence { if let Some(hook) = hook { hook(payload); } - self.command_data("admission.create", payload) + #[cfg(test)] + if self + .admission_precommit_fail + .swap(false, std::sync::atomic::Ordering::AcqRel) + { + return Err(StorageError { + code: "storage_unavailable".to_string(), + message: "gateway storage worker exited unexpectedly".to_string(), + }); + } + // The shadow is the same command, not a second transaction. The + // caller's payload and the immutable hook stay unchanged. + #[cfg(test)] + let transformed = { + let transform = self + .admission_payload_transform + .lock() + .expect("admission payload transform") + .take(); + transform.map(|transform| admission_failpoint_shadow(payload, transform)) + }; + #[cfg(test)] + let command_payload = transformed.as_ref().unwrap_or(payload); + #[cfg(not(test))] + let command_payload = payload; + let result = self.command_data("admission.create", command_payload); + #[cfg(test)] + { + let observer = self + .admission_result_observer + .lock() + .expect("admission result observer") + .take(); + if let Some(observer) = observer { + observer(AdmissionCommandCode { + ok: result.is_ok(), + code: match &result { + Ok(_) => "ok".to_string(), + Err(error) => error.code.clone(), + }, + }); + } + } + #[cfg(test)] + if result.as_ref().is_ok() + || result + .as_ref() + .is_err_and(|e| e.code == "failpoint_after_commit_before_publish") + { + let hook = self.admission_postcommit_hook.lock().unwrap().take(); + if let Some(hook) = hook { + hook(payload); + } + } + #[cfg(test)] + if result.as_ref().is_ok() + && self + .admission_ack_loss + .swap(false, std::sync::atomic::Ordering::AcqRel) + { + return Err(StorageError { + code: "storage_unavailable".to_string(), + message: "gateway storage worker exited unexpectedly".to_string(), + }); + } + #[cfg(test)] + if result.as_ref().is_ok() + && self + .admission_secondary_decode_loss + .swap(false, std::sync::atomic::Ordering::AcqRel) + { + return Err(StorageError { + code: "storage_error".to_string(), + message: "decode admission result: response body was not valid storage json" + .to_string(), + }); + } + result + } + /// Drops one successful admission command ack after the real transaction + /// and postcommit hook. The returned error is the existing response-loss + /// code. The original payload and transaction are unchanged. + #[cfg(test)] + pub(crate) fn inject_admission_ack_loss(&self) { + self.admission_ack_loss + .store(true, std::sync::atomic::Ordering::Release); + } + /// Sets the bounded replay-read fault. Only this mutex is taken; callbacks + /// are not invoked here. + #[cfg(test)] + pub(crate) fn arm_admission_replay_read_failures(&self, count: u32) { + *self + .admission_replay_fail_remaining + .lock() + .expect("admission replay fault") = count; + } + #[cfg(test)] + pub(crate) fn inject_admission_postcommit_hook( + &self, + hook: impl FnOnce(&Value) + Send + 'static, + ) { + *self.admission_postcommit_hook.lock().unwrap() = Some(Box::new(hook)); } /// Fixture-only Native seam after preparation and before the original @@ -1407,6 +2595,21 @@ impl GatewayPersistence { request_hash: &str, request_selector: Option<&str>, ) -> Result { + #[cfg(test)] + { + let mut remaining = self + .admission_replay_fail_remaining + .lock() + .expect("admission replay fault"); + if *remaining > 0 { + *remaining -= 1; + drop(remaining); + return Err(StorageError { + code: "storage_unavailable".to_string(), + message: "gateway storage worker exited unexpectedly".to_string(), + }); + } + } self.command_data( "admission.replay", &json!({ @@ -1419,6 +2622,42 @@ impl GatewayPersistence { ) } + pub(crate) fn admission_replay_for_proof( + &self, + scope: &str, + key: &str, + request_hash: &str, + request_selector: Option<&str>, + budget_until: Option, + ) -> Result { + #[cfg(test)] + { + let mut remaining = self + .admission_replay_fail_remaining + .lock() + .expect("admission replay fault"); + if *remaining > 0 { + *remaining -= 1; + drop(remaining); + return Err(StorageError { + code: "storage_unavailable".to_string(), + message: "gateway storage worker exited unexpectedly".to_string(), + }); + } + } + self.command_data_for_original_proof( + "admission.replay", + &json!({ + "idempotency_scope": scope, + "idempotency_key": key, + "request_hash": request_hash, + "request_selector_present": request_selector.is_some(), + "request_selector": request_selector.unwrap_or_default(), + }), + budget_until, + ) + } + pub fn session_create(&self, payload: &Value) -> Result { self.command_data("session.create", payload) } @@ -1538,6 +2777,27 @@ impl GatewayPersistence { self.command_data("recovery.reconcile_effects", payload) } + #[cfg(test)] + pub(crate) fn mutate_graph_pin_for_test( + &self, + run_id: &str, + mutation: &str, + ) -> Result { + assert!(matches!( + mutation, + "drop_requirement" + | "drop_binding" + | "replace_ref" + | "forge_restore_selection" + | "drop_package_ref" + | "forge_run_selection" + )); + self.command_data( + "plugin.fixture_pin_state", + &json!({"owner_id":run_id,"mutation":mutation}), + ) + } + /// Test failpoint: the next storage command fails before the worker runs. pub fn inject_persist_failure(&self) { self.fail_next @@ -1795,6 +3055,14 @@ impl GatewayPersistence { self.command_data("run.get", &json!({ "run_id": run_id })) } + pub(crate) fn run_get_for_proof( + &self, + run_id: &str, + budget_until: Option, + ) -> Result { + self.command_data_for_original_proof("run.get", &json!({ "run_id": run_id }), budget_until) + } + pub fn session_get(&self, session_id: &str) -> Result { self.command_data("session.get", &json!({ "session_id": session_id })) } diff --git a/src/gateway/telegram.rs b/src/gateway/telegram.rs index 04ee5b9..1500c66 100644 --- a/src/gateway/telegram.rs +++ b/src/gateway/telegram.rs @@ -2135,6 +2135,8 @@ async fn admit_text( platform: "telegram".to_string(), idempotency_key: Some(idempotency_key), idempotency_hash: Some(idempotency_hash), + graph_id: None, + workspace_branch: None, ..AdmitRunRequest::default() }, None, diff --git a/src/gateway/workspaces.rs b/src/gateway/workspaces.rs new file mode 100644 index 0000000..6cadbd1 --- /dev/null +++ b/src/gateway/workspaces.rs @@ -0,0 +1,191 @@ +//! Authenticated managed-workspace maintenance transport. + +use axum::{ + Json, + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; +use serde::Serialize; + +use crate::{AgentGatewayState, effects::ManagedCheckpointCreateRequest}; + +#[derive(Serialize)] +struct MaintenanceErrorBody { + error: MaintenanceError, +} + +#[derive(Serialize)] +struct MaintenanceError { + code: &'static str, + message: &'static str, +} + +fn rejected(status: StatusCode, code: &'static str) -> Response { + ( + status, + Json(MaintenanceErrorBody { + error: MaintenanceError { + code, + message: code, + }, + }), + ) + .into_response() +} + +pub(super) async fn create_checkpoint( + State(state): State, + Path(workspace_id): Path, + request: Result, axum::extract::rejection::JsonRejection>, +) -> Response { + let request = match request { + Ok(Json(request)) => request, + Err(rejection) => return rejected(rejection.status(), "managed_request_invalid"), + }; + if workspace_id != request.workspace_id { + return rejected(StatusCode::FORBIDDEN, "managed_authority_required"); + } + match state.service().create_managed_checkpoint(request).await { + Ok(receipt) => (StatusCode::CREATED, Json(receipt)).into_response(), + Err( + error @ crate::effects::journal::ManagedMaintenanceFailure::SubmittedUnknown { .. }, + ) => (StatusCode::ACCEPTED, Json(error)).into_response(), + Err(error) => maintenance_failure(&error.to_string()), + } +} + +fn maintenance_failure(error: &str) -> Response { + let (status, code) = match error { + "managed_management_denied" => (StatusCode::FORBIDDEN, "managed_management_denied"), + "managed_authority_required" => (StatusCode::FORBIDDEN, "managed_authority_required"), + "managed_source_conflict" => (StatusCode::CONFLICT, "managed_source_conflict"), + "managed_checkpoint_conflict" => (StatusCode::CONFLICT, "managed_checkpoint_conflict"), + "managed_request_conflict" => (StatusCode::CONFLICT, "managed_request_conflict"), + "managed_request_invalid" => (StatusCode::BAD_REQUEST, "managed_request_invalid"), + "managed_checkpoint_busy" => (StatusCode::CONFLICT, "managed_checkpoint_busy"), + "managed_branch_missing" => (StatusCode::NOT_FOUND, "managed_branch_missing"), + "managed_checkpoint_missing" => (StatusCode::NOT_FOUND, "managed_checkpoint_missing"), + "managed_restore_busy" => (StatusCode::CONFLICT, "managed_restore_busy"), + "managed_process_lineage_unsupported" => ( + StatusCode::UNPROCESSABLE_ENTITY, + "managed_process_lineage_unsupported", + ), + "managed_state_unsupported" => ( + StatusCode::UNPROCESSABLE_ENTITY, + "managed_state_unsupported", + ), + "managed_history_incomplete" => ( + StatusCode::UNPROCESSABLE_ENTITY, + "managed_history_incomplete", + ), + "managed_history_budget_exceeded" => ( + StatusCode::UNPROCESSABLE_ENTITY, + "managed_history_budget_exceeded", + ), + "managed_artifact_unavailable" => ( + StatusCode::UNPROCESSABLE_ENTITY, + "managed_artifact_unavailable", + ), + "plugin_graph_revoked" => (StatusCode::CONFLICT, "plugin_graph_revoked"), + "plugin_graph_pin_required" => ( + StatusCode::UNPROCESSABLE_ENTITY, + "plugin_graph_pin_required", + ), + "plugin_artifact_unavailable" => ( + StatusCode::UNPROCESSABLE_ENTITY, + "plugin_artifact_unavailable", + ), + "harness_policy_selection_invalid" => ( + StatusCode::UNPROCESSABLE_ENTITY, + "harness_policy_selection_invalid", + ), + "managed_metadata_budget_exceeded" => ( + StatusCode::UNPROCESSABLE_ENTITY, + "managed_metadata_budget_exceeded", + ), + "managed_deadline_elapsed" => (StatusCode::REQUEST_TIMEOUT, "managed_deadline_elapsed"), + _ => ( + StatusCode::SERVICE_UNAVAILABLE, + "managed_storage_unavailable", + ), + }; + rejected(status, code) +} + +pub(super) async fn restore_checkpoint( + State(state): State, + Path(workspace_id): Path, + request: Result< + Json, + axum::extract::rejection::JsonRejection, + >, +) -> Response { + let request = match request { + Ok(Json(request)) => request, + Err(rejection) => return rejected(rejection.status(), "managed_request_invalid"), + }; + if workspace_id != request.workspace_id { + return rejected(StatusCode::FORBIDDEN, "managed_authority_required"); + } + match state + .service() + .restore_managed_checkpoint_and_admit(request) + .await + { + Ok(receipt) => { + let service = state.service(); + service.start_managed_restore_worker(&receipt.new_run_id); + (StatusCode::ACCEPTED, Json(receipt)).into_response() + } + Err( + error @ crate::effects::journal::ManagedMaintenanceFailure::SubmittedUnknown { .. }, + ) => (StatusCode::ACCEPTED, Json(error)).into_response(), + Err(error) => maintenance_failure(&error.to_string()), + } +} + +pub(super) async fn create_branch( + State(state): State, + Path(workspace_id): Path, + request: Result< + Json, + axum::extract::rejection::JsonRejection, + >, +) -> Response { + let request = match request { + Ok(Json(request)) => request, + Err(rejection) => return rejected(rejection.status(), "managed_request_invalid"), + }; + if workspace_id != request.workspace_id { + return rejected(StatusCode::FORBIDDEN, "managed_authority_required"); + } + match state.service().create_managed_branch(request).await { + Ok(receipt) => (StatusCode::CREATED, Json(receipt)).into_response(), + Err(error) => maintenance_failure(&error), + } +} + +pub(super) async fn publish_workspace( + State(state): State, + Path(workspace_id): Path, + request: Result< + Json, + axum::extract::rejection::JsonRejection, + >, +) -> Response { + let request = match request { + Ok(Json(request)) => request, + Err(rejection) => return rejected(rejection.status(), "managed_request_invalid"), + }; + if workspace_id != request.workspace_id { + return rejected(StatusCode::FORBIDDEN, "managed_authority_required"); + } + match state.service().publish_managed_workspace(request).await { + Ok(receipt) => (StatusCode::OK, Json(receipt)).into_response(), + Err( + error @ crate::effects::journal::ManagedMaintenanceFailure::SubmittedUnknown { .. }, + ) => (StatusCode::ACCEPTED, Json(error)).into_response(), + Err(error) => maintenance_failure(&error.to_string()), + } +} diff --git a/src/plugins/control_store.rs b/src/plugins/control_store.rs index 8e3b211..c788418 100644 --- a/src/plugins/control_store.rs +++ b/src/plugins/control_store.rs @@ -7,7 +7,7 @@ use serde::Serialize; use serde_json::{Value, json}; -use super::resolve::GraphLock; +use super::resolve::{GraphLock, PermissionDiff, compare_graph_permissions}; use super::schema::validate_lock; use crate::GatewayPersistence; @@ -134,6 +134,19 @@ pub struct GraphRetention { pub ref_id: String, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GraphRevocation { + pub graph_id: String, + pub revoked_at_ms: u64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GraphPinRequirement { + pub run_id: String, + pub graph_id: String, + pub ref_id: String, +} + pub struct PluginControlStore<'a> { persistence: &'a GatewayPersistence, } @@ -197,6 +210,22 @@ impl<'a> PluginControlStore<'a> { Ok(ValidatedLockMetadata { lock }) } + /// Reads two stored, validated locks. The result is not an activation permit. + pub fn permission_diff( + &self, + previous_graph_id: &str, + candidate_graph_id: &str, + ) -> Result { + let previous = self.load_lock(previous_graph_id)?; + let candidate = self.load_lock(candidate_graph_id)?; + compare_graph_permissions(&previous.lock, &candidate.lock).map_err(|_| { + PluginControlError::new( + "plugin_diff_budget_exceeded", + "plugin permission diff exceeds comparison budget", + ) + }) + } + pub fn list_locks(&self) -> Result, PluginControlError> { let data = self.command("plugin.lock_list", &json!({}))?; storage_rows(&data)? @@ -360,6 +389,101 @@ impl<'a> PluginControlStore<'a> { Ok(()) } + /// Revoke one supported embedding-owned graph; shared package identity + /// does not widen the revocation to any other graph. + pub fn revoke_graph( + &self, + packages: &super::PackageStore, + graph_id: &str, + ) -> Result { + let loaded = self.load_lock(graph_id)?; + let tool_id = match &loaded.lock.harness_selection { + super::HarnessSelection::ToolOnly { tool_package } + | super::HarnessSelection::ContextPrompt { tool_package, .. } => tool_package, + super::HarnessSelection::Unselected => return Err(PluginControlError::invalid()), + }; + let package = loaded + .lock + .packages + .iter() + .find(|p| &p.id == tool_id) + .ok_or_else(PluginControlError::invalid)?; + if package.id != crate::runtime::native_tools::EMBEDDING_PACKAGE_ID { + return Err(PluginControlError::invalid()); + } + let installed = packages + .read_installed(&package.artifact_id) + .map_err(|_| PluginControlError::invalid())?; + if installed.trust != super::PackageTrust::EmbeddingOwnedBuiltin + || installed.manifest.id != package.id + || super::SupportedManagedTopology::validate(&loaded.lock, &installed).is_none() + { + return Err(PluginControlError::invalid()); + } + let data = self.command( + "plugin.graph_revoke", + &json!({"graph_id":graph_id,"now_ms":now_ms(),"failpoint":self.failpoint()}), + )?; + let rows = storage_rows(&data)?; + let row = rows.first().ok_or_else(PluginControlError::unavailable)?; + let fact = GraphRevocation { + graph_id: text_at(row, 0)?, + revoked_at_ms: int_at(row, 1)?, + }; + if fact.graph_id != graph_id { + return Err(PluginControlError::unavailable()); + } + Ok(fact) + } + + pub fn graph_revocation( + &self, + graph_id: &str, + ) -> Result, PluginControlError> { + let data = self.command("plugin.graph_revocation_get", &json!({"graph_id":graph_id}))?; + storage_rows(&data)? + .first() + .map(|row| { + Ok(GraphRevocation { + graph_id: text_at(row, 0)?, + revoked_at_ms: int_at(row, 1)?, + }) + }) + .transpose() + } + + pub fn pin_requirement_for_run( + &self, + run_id: &str, + ) -> Result, PluginControlError> { + let data = self.command("plugin.pin_requirement_get", &json!({"owner_id":run_id}))?; + storage_rows(&data)? + .first() + .map(|row| { + if text_at(row, 3)? != "required" { + return Err(PluginControlError::unavailable()); + } + Ok(GraphPinRequirement { + run_id: text_at(row, 0)?, + graph_id: text_at(row, 1)?, + ref_id: text_at(row, 2)?, + }) + }) + .transpose() + } + + pub(crate) fn harness_binding_matches( + &self, + owner: &crate::effects::ManagedOwnerScope, + code: &crate::effects::ManagedCodeIdentity, + branch: &crate::effects::ManagedWorkspaceIdentity, + epoch: u64, + deadline: u64, + ) -> Result { + let data=self.command("plugin.harness_binding_matches",&json!({"owner_id":owner.run_id,"profile":owner.profile,"session_id":owner.session_id,"workspace_id":branch.workspace_id,"branch_id":branch.branch_id,"epoch":epoch,"deadline_ms":deadline,"graph_id":code.graph_id,"ref_id":code.graph_ref_id}))?; + Ok(!storage_rows(&data)?.is_empty()) + } + pub fn pin_for_run(&self, owner_id: &str) -> Result { self.command("plugin.pin_for_owner", &json!({ "owner_id": owner_id })) } @@ -384,6 +508,71 @@ impl<'a> PluginControlStore<'a> { .map_err(map_storage) } + fn command_for_proof( + &self, + op: &str, + payload: &Value, + budget_until: Option, + ) -> Result { + self.persistence + .plugin_control_command_for_proof(op, payload, budget_until) + .map_err(map_storage) + } + + pub(crate) fn pin_for_run_for_proof( + &self, + owner_id: &str, + budget_until: Option, + ) -> Result { + self.command_for_proof( + "plugin.pin_for_owner", + &json!({ "owner_id": owner_id }), + budget_until, + ) + } + + pub(crate) fn pin_requirement_for_run_for_proof( + &self, + run_id: &str, + budget_until: Option, + ) -> Result, PluginControlError> { + let data = self.command_for_proof( + "plugin.pin_requirement_get", + &json!({"owner_id": run_id}), + budget_until, + )?; + storage_rows(&data)? + .first() + .map(|row| { + if text_at(row, 3)? != "required" { + return Err(PluginControlError::unavailable()); + } + Ok(GraphPinRequirement { + run_id: text_at(row, 0)?, + graph_id: text_at(row, 1)?, + ref_id: text_at(row, 2)?, + }) + }) + .transpose() + } + + pub(crate) fn harness_binding_matches_for_proof( + &self, + owner: &crate::effects::ManagedOwnerScope, + code: &crate::effects::ManagedCodeIdentity, + branch: &crate::effects::ManagedWorkspaceIdentity, + epoch: u64, + deadline: u64, + budget_until: Option, + ) -> Result { + let data = self.command_for_proof( + "plugin.harness_binding_matches", + &json!({"owner_id":owner.run_id,"profile":owner.profile,"session_id":owner.session_id,"workspace_id":branch.workspace_id,"branch_id":branch.branch_id,"epoch":epoch,"deadline_ms":deadline,"graph_id":code.graph_id,"ref_id":code.graph_ref_id}), + budget_until, + )?; + Ok(!storage_rows(&data)?.is_empty()) + } + fn failpoint(&self) -> &'static str { if self.persistence.take_plugin_control_failpoint() { "after_partial_write" @@ -517,6 +706,12 @@ fn map_storage(err: crate::gateway::store::StorageError) -> PluginControlError { "plugin_graph_missing" => PluginControlError::missing_graph(), "plugin_package_ref_missing" => PluginControlError::missing_ref(), "plugin_activation_conflict" => PluginControlError::activation_conflict(), + "plugin_graph_revoked" => { + PluginControlError::new("plugin_graph_revoked", "plugin graph is revoked") + } + "plugin_graph_pin_required" => { + PluginControlError::new("plugin_graph_pin_required", "tool graph pin is required") + } "plugin_ref_invalid" => PluginControlError::invalid_ref(), crate::gateway::store::PLUGIN_CONTROL_FIXTURE_ABORTED => { PluginControlError::fixture_aborted() @@ -545,3 +740,47 @@ fn now_ms() -> u64 { .map(|elapsed| elapsed.as_millis() as u64) .unwrap_or(0) } + +#[cfg(test)] +mod t14_lock_budget_tests { + use super::*; + + #[test] + fn t14_lock_decode_exact_256k_and_one_over() { + let root = std::path::PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()) + .join(format!("t195-lock-budget-{}", uuid::Uuid::new_v4())); + let store = crate::plugins::PackageStore::open(root).unwrap(); + store.bind_policy(&crate::plugins::InstallAuthorization { + admin: false, + accepted_publishers: vec![], + unsigned_dev_space: None, + }); + store.install_embedding_owned().unwrap(); + let lock = store + .resolve(&crate::plugins::ResolveRequest { + roots: vec![crate::plugins::PackageRequirement { + id: crate::runtime::native_tools::EMBEDDING_PACKAGE_ID.into(), + version: "1.0.0".into(), + }], + host_runtime: crate::plugins::HOST_RUNTIME.into(), + harness_selection: crate::plugins::HarnessSelectionRequest::ToolOnly { + tool_package: crate::runtime::native_tools::EMBEDDING_PACKAGE_ID.into(), + }, + }) + .unwrap(); + let mut encoded = serde_json::to_string(&lock.schema_value()).unwrap(); + assert!(encoded.len() < MAX_LOCK_BYTES); + // The closed canonical producer does not emit padding. This is the + // real private decoder bound, using valid JSON whitespace and the + // unchanged valid lock, not an invented end-to-end policy graph. + encoded.push_str(&" ".repeat(MAX_LOCK_BYTES - encoded.len())); + assert_eq!(encoded.len(), 262144); + assert_eq!(parse_lock(&encoded).unwrap(), lock); + encoded.push(' '); + assert_eq!(encoded.len(), 262145); + assert_eq!( + parse_lock(&encoded).unwrap_err().code, + "plugin_lock_invalid" + ); + } +} diff --git a/src/plugins/error.rs b/src/plugins/error.rs index e7082b9..ad4e9dd 100644 --- a/src/plugins/error.rs +++ b/src/plugins/error.rs @@ -90,6 +90,7 @@ pub const GRAPH_LOCK_FIELDS: &[&str] = &[ "compile_environment", "edges", "graph_id", + "harness_selection", "host_runtime", "packages", ]; diff --git a/src/plugins/manifest.rs b/src/plugins/manifest.rs index df26676..9b49807 100644 --- a/src/plugins/manifest.rs +++ b/src/plugins/manifest.rs @@ -146,6 +146,22 @@ pub fn parse_plugin_manifest(text: &str) -> Result .ok_or_else(|| PluginError::new(PluginErrorKind::UnknownField, "capabilities"))?, )?; let effects = parse_effects(object.get("effects").unwrap(), &entry.export, &dependencies)?; + if effects.entries.len() != 1 + && (kind != "library" + || effects.entries.len() != 3 + || !dependencies.is_empty() + || !capabilities.is_empty() + || object.contains_key("state") + || effects + .entries + .values() + .any(|e| !e.direct.is_empty() || !e.delegated.is_empty())) + { + return Err(PluginError::new( + PluginErrorKind::UncoveredExport, + "entry cardinality", + )); + } let state = match object.get("state") { Some(value) => Some(parse_state(value)?), None => None, @@ -470,7 +486,7 @@ fn safe_ident(text: &str) -> bool { && !text.is_empty() } -fn safe_export(text: &str) -> bool { +pub(crate) fn safe_export(text: &str) -> bool { let mut chars = text.chars(); matches!(chars.next(), Some(first) if first == '_' || first.is_ascii_alphabetic()) && chars.all(|character| character == '_' || character.is_ascii_alphanumeric()) diff --git a/src/plugins/mod.rs b/src/plugins/mod.rs index 86b352d..9a98500 100644 --- a/src/plugins/mod.rs +++ b/src/plugins/mod.rs @@ -15,8 +15,9 @@ mod yaml; pub use crate::runtime::native_tools::trusted_tool_package_root; pub use control_store::{ - ActivationHistoryEntry, ActivationPointer, GraphRetention, PackageArtifactRef, - PluginControlError, PluginControlStore, StoreLockReceipt, ValidatedLockMetadata, + ActivationHistoryEntry, ActivationPointer, GraphPinRequirement, GraphRetention, + GraphRevocation, PackageArtifactRef, PluginControlError, PluginControlStore, StoreLockReceipt, + ValidatedLockMetadata, }; pub use error::{ COMPILE_ENVIRONMENT, EFFECT_KINDS, EFFECT_RECOVERY, GRAPH_LOCK_FIELDS, HOST_RUNTIME, @@ -27,13 +28,22 @@ pub use pin::{ PinnedAdmitContext, PinnedToolAdmission, PinnedToolAdmitError, admit_pinned_tool_run, restore_owned_pin, }; -pub(crate) use pin::{prepare_production_pin, seal_production_receipt}; +pub(crate) use pin::{ + prepare_checkpoint_pin, prepare_production_pin, restore_owned_pin_with_cancellation, + seal_production_receipt, validate_owned_harness_fact, +}; +pub(crate) use prepare::SupportedManagedTopology; pub use resolve::{ - EffectOrigin, GraphLock, LockedEdge, LockedEffect, LockedEffectItem, LockedPackage, - PackageRequirement, ResolveRequest, ScopeBinding, resolve_installed, + CompatibilityCheck, EffectOrigin, GraphLock, GraphMetadataDiff, HarnessSelection, + HarnessSelectionRequest, LockedEdge, LockedEffect, LockedEffectItem, LockedPackage, + PackageRequirement, PackageRevisionDiff, PermissionChange, PermissionChangeKind, + PermissionDiff, PermissionIdentity, PermissionLocation, PermissionValue, PurePolicySelection, + ResolveRequest, ScopeBinding, resolve_installed, }; pub use schema::{canonical_schema_documents, schema_snapshot_drift}; -pub(crate) use schema::{validate_managed_effect, validate_managed_workspace}; +pub(crate) use schema::{ + validate_managed_effect, validate_managed_maintenance, validate_managed_workspace, +}; pub(crate) use store::embedding_payload_bytes; pub use store::{ InstallAuthorization, InstalledPackage, PackageRevision, PackageStore, PackageTrust, diff --git a/src/plugins/pin.rs b/src/plugins/pin.rs index 350f7bb..275dbbe 100644 --- a/src/plugins/pin.rs +++ b/src/plugins/pin.rs @@ -21,6 +21,7 @@ const POSTCOMMIT_UNPUBLISHED: &str = "failpoint_after_commit_before_publish"; #[derive(Debug)] pub struct PinnedToolAdmitError { + pub code: &'static str, pub message: String, } @@ -31,20 +32,60 @@ impl std::fmt::Display for PinnedToolAdmitError { } impl PinnedToolAdmitError { - fn new(message: impl Into) -> Self { + fn plugin(error: super::PluginError) -> Self { + let code = error + .chain + .first() + .and_then(|item| graph_admit_code(item)) + .unwrap_or("pinned_tool_unavailable"); Self { - message: message.into(), + code, + message: if code == "pinned_tool_unavailable" { + error.to_string() + } else { + code.into() + }, } } + fn new(message: impl Into) -> Self { + let message = message.into(); + let code = graph_admit_code(&message).unwrap_or("pinned_tool_unavailable"); + Self { code, message } + } +} + +fn graph_admit_code(message: &str) -> Option<&'static str> { + const CODES: &[&str] = &[ + "plugin_graph_revoked", + "plugin_graph_pin_required", + "plugin_artifact_unavailable", + "harness_policy_selection_invalid", + "harness_topology_unsupported", + "pinned_tool_unavailable", + "deadline_elapsed", + "cancelled", + ]; + CODES.iter().copied().find(|code| *code == message) } /// Trusted host identity for a new admission. It is not read from caller JSON. -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct PinnedAdmitContext { + /// Original Native run root. Required for a policy-bearing graph. + pub original_cancellation: Option, pub registry_identity: String, pub workspace: PathBuf, } +impl std::fmt::Debug for PinnedAdmitContext { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PinnedAdmitContext") + .field("registry_identity", &self.registry_identity) + .field("workspace", &self.workspace) + .finish_non_exhaustive() + } +} + /// Admission receipt plus the callable program compiled from the pinned capture. /// Display fields are not the execution identity. `program_handle` clones the /// binding sealed once from the verified capture and committed receipt. @@ -97,7 +138,12 @@ pub fn admit_pinned_tool_run( )); } if replayed.get("replayed") == Some(&Value::Bool(true)) { - return recover_pinned(persistence, store, &replayed); + return recover_pinned( + persistence, + store, + &replayed, + context.original_cancellation.as_ref(), + ); } } admit_new(persistence, store, caller, context) @@ -122,9 +168,18 @@ fn admit_new( .load_lock(&active.graph_id) .map_err(|error| PinnedToolAdmitError::new(error.message))?; let package = select_tool_package(&loaded.lock)?; - let prepared = store - .prepare(&loaded.lock) - .map_err(|error| PinnedToolAdmitError::new(error.to_string()))?; + if matches!( + loaded.lock.harness_selection, + super::HarnessSelection::ContextPrompt { .. } + ) && context.original_cancellation.is_none() + { + return Err(PinnedToolAdmitError::new("deadline_elapsed")); + } + let prepared = match &context.original_cancellation { + Some(cancel) => store.prepare_harness_graph(&loaded.lock, cancel), + None => store.prepare(&loaded.lock), + } + .map_err(PinnedToolAdmitError::plugin)?; let captured = capture_tool(&prepared, package)?; let ref_id = Uuid::new_v4().to_string(); let now_ms = caller.get("now_ms").cloned().unwrap_or(json!(0)); @@ -160,14 +215,47 @@ fn admit_new( "tool_workspace".to_string(), json!(path_text(&context.workspace)), ); + if matches!( + loaded.lock.harness_selection, + super::HarnessSelection::ContextPrompt { .. } + ) { + let encoded = payload["input_json"] + .as_str() + .ok_or_else(|| PinnedToolAdmitError::new("harness_policy_selection_invalid"))?; + let mut outer: Value = serde_json::from_str(encoded) + .map_err(|_| PinnedToolAdmitError::new("harness_policy_selection_invalid"))?; + if !outer["run_context"].is_object() { + return Err(PinnedToolAdmitError::new( + "harness_policy_selection_invalid", + )); + } + outer["run_graph_selection"] = json!({"graph_id":active.graph_id,"ref_id":ref_id,"harness_selection":loaded.lock.harness_selection}); + let encoded = serde_json::to_string(&outer) + .map_err(|_| PinnedToolAdmitError::new("harness_policy_selection_invalid"))?; + if encoded.len() > crate::config::MAX_RUN_CONTEXT_STORAGE_BYTES { + return Err(PinnedToolAdmitError::new( + "harness_policy_input_budget_exceeded", + )); + } + payload["input_json"] = json!(encoded); + } + if let Some(cancel) = &context.original_cancellation { + cancel.ensure_open().map_err(PinnedToolAdmitError::new)?; + } match persistence.admission_create(&payload) { Ok(data) => { if data.get("replayed") == Some(&Value::Bool(true)) { - return recover_pinned(persistence, store, &data); + return recover_pinned( + persistence, + store, + &data, + context.original_cancellation.as_ref(), + ); } let pin = data .get("pin") .ok_or_else(|| PinnedToolAdmitError::new("admission did not return a tool pin"))?; + validate_pin_selection(pin, &loaded.lock)?; let pinned_graph = text_field(pin, "graph_id"); let pinned_ref = text_field(pin, "ref_id"); let pinned_generation = number_field(pin, "generation"); @@ -191,6 +279,9 @@ fn admit_new( "admission receipt is missing the committed run identity", )); } + if let Some(cancel) = &context.original_cancellation { + cancel.ensure_open().map_err(PinnedToolAdmitError::new)?; + } Ok(binding_from_capture( captured, PinnedToolReceipt { @@ -212,6 +303,7 @@ fn admit_new( &text_field(caller, "idempotency_key"), &text_field(caller, "request_hash"), optional_selector(caller).as_deref(), + context.original_cancellation.as_ref(), ), Err(error) => Err(PinnedToolAdmitError::new(format!( "{}: {}", @@ -227,6 +319,7 @@ fn recover_unpublished( key: &str, hash: &str, selector: Option<&str>, + cancel: Option<&crate::RunCancellation>, ) -> Result { if key.is_empty() { return Err(PinnedToolAdmitError::new( @@ -241,13 +334,14 @@ fn recover_unpublished( "admission committed but live publication failed and exact recovery did not return the original run", )); } - recover_pinned(persistence, store, &replayed) + recover_pinned(persistence, store, &replayed, cancel) } fn recover_pinned( persistence: &GatewayPersistence, store: &PackageStore, data: &Value, + cancel: Option<&crate::RunCancellation>, ) -> Result { let pin = data .get("pin") @@ -269,6 +363,25 @@ fn recover_pinned( let loaded = control .load_lock(&graph_id) .map_err(|error| PinnedToolAdmitError::new(error.message))?; + validate_pin_selection(pin, &loaded.lock)?; + if matches!( + loaded.lock.harness_selection, + super::HarnessSelection::ContextPrompt { .. } + ) { + let cancel = cancel.ok_or_else(|| PinnedToolAdmitError::new("deadline_elapsed"))?; + let binding = restore_owned_pin_with_cancellation(persistence, store, &run_id, cancel)?; + let entry = binding.declared_export().to_string(); + return Ok(PinnedToolAdmission { + binding: Arc::new(binding), + graph_id, + generation, + ref_id, + run_id, + session_id, + profile, + entry, + }); + } let package = select_tool_package(&loaded.lock)?; if !control .package_refs(&graph_id) @@ -282,7 +395,7 @@ fn recover_pinned( } let prepared = store .prepare(&loaded.lock) - .map_err(|error| PinnedToolAdmitError::new(error.to_string()))?; + .map_err(PinnedToolAdmitError::plugin)?; let captured = capture_tool(&prepared, package)?; Ok(binding_from_capture( captured, @@ -300,6 +413,7 @@ fn recover_pinned( } struct CapturedTool { + harness_selection: super::resolve::HarnessSelection, managed_policy_catalog: Option>, managed_contract: Option, program: Arc, @@ -314,6 +428,15 @@ fn capture_tool( prepared: &super::prepare::PreparedGraph, package: &LockedPackage, ) -> Result { + if matches!( + prepared.harness_selection, + super::HarnessSelection::ContextPrompt { .. } + ) && prepared + .managed_contract(&package.id) + .is_none_or(|contract| contract.topology.is_none()) + { + return Err(PinnedToolAdmitError::new("harness_topology_unsupported")); + } let program = prepared.program(&package.id).ok_or_else(|| { PinnedToolAdmitError::new(format!("prepared graph dropped program {}", package.id)) })?; @@ -328,6 +451,7 @@ fn capture_tool( .ok_or_else(|| PinnedToolAdmitError::new("captured manifest has no export"))? .to_string(); Ok(CapturedTool { + harness_selection: prepared.harness_selection.clone(), managed_contract: prepared.managed_contract(&package.id), managed_policy_catalog: prepared.managed_policy_catalog(&package.id), registry: prepared @@ -344,6 +468,7 @@ fn capture_tool( fn binding_from_capture(captured: CapturedTool, receipt: PinnedToolReceipt) -> PinnedToolAdmission { let binding = PinnedToolProgram::seal( PinnedToolCode { + harness_selection: captured.harness_selection, managed_contract: captured.managed_contract, managed_policy_catalog: captured.managed_policy_catalog, program: captured.program, @@ -379,6 +504,9 @@ pub(crate) struct PreparedProductionPin { } impl PreparedProductionPin { + pub(crate) fn harness_selection(&self) -> &super::resolve::HarnessSelection { + &self.captured.harness_selection + } pub(crate) fn managed_policy_catalog( &self, ) -> Option> { @@ -401,37 +529,175 @@ impl PreparedProductionPin { } } +pub(crate) fn prepare_checkpoint_pin( + persistence: &GatewayPersistence, + store: &PackageStore, + checkpoint: &crate::effects::ManagedCheckpointRef, + cancellation: &crate::RunCancellation, +) -> Result { + cancellation + .ensure_open() + .map_err(PinnedToolAdmitError::new)?; + let graph = &checkpoint.graph; + let control = PluginControlStore::new(persistence); + if control + .graph_revocation(&graph.graph_id) + .map_err(|_| PinnedToolAdmitError::new("plugin_graph_pin_required"))? + .is_some() + { + return Err(PinnedToolAdmitError::new("plugin_graph_revoked")); + } + if !control + .retained(&graph.graph_id) + .map_err(|_| PinnedToolAdmitError::new("checkpoint graph unavailable"))? + .iter() + .any(|reference| { + reference.owner_kind == "checkpoint" + && reference.owner_id == checkpoint.checkpoint_id + && reference.ref_id == graph.checkpoint_graph_ref_id + }) + { + return Err(PinnedToolAdmitError { + code: "plugin_graph_pin_required", + message: "tool graph pin is required".into(), + }); + } + let loaded = control + .load_lock(&graph.graph_id) + .map_err(|_| PinnedToolAdmitError::new("checkpoint graph unavailable"))?; + let package = select_tool_package(&loaded.lock)?; + if loaded.lock.harness_selection != graph.harness_selection { + // The retained graph owns the library identity. Do not reclassify a + // primary-tool or selection-mode mismatch as a policy failure. + if matches!( + (&loaded.lock.harness_selection, &graph.harness_selection), + ( + super::HarnessSelection::ContextPrompt { tool_package: expected, policy }, + super::HarnessSelection::ContextPrompt { tool_package: actual, policy: supplied }, + ) if expected == actual + && policy.package_id == supplied.package_id + && policy.artifact_id == supplied.artifact_id + && policy.entry_path == supplied.entry_path + && policy.prompt_export == supplied.prompt_export + && policy.context_export == supplied.context_export + && policy.guardian_export != supplied.guardian_export + ) { + return Err(PinnedToolAdmitError::new( + "harness_policy_selection_invalid", + )); + } + return Err(PinnedToolAdmitError::new( + "checkpoint harness selection mismatch", + )); + } + use crate::effects::journal::ManagedCheckpointStateCoverage as Coverage; + let covered = match (&loaded.lock.harness_selection, &checkpoint.plugin_state) { + ( + super::HarnessSelection::ToolOnly { .. }, + Coverage::StatelessBuiltin { + graph_id, + package_id, + artifact_id, + manifest_state_absent, + state_effects_absent, + roots, + }, + ) => { + graph_id == &graph.graph_id + && package_id == &package.id + && artifact_id == &package.artifact_id + && *manifest_state_absent + && *state_effects_absent + && roots.is_empty() + && checkpoint.public_config.config.policy_revision == "stateless_builtin" + } + ( + super::HarnessSelection::ContextPrompt { policy, .. }, + Coverage::StatelessBuiltinWithPurePolicy { + graph_id, + package_id, + artifact_id, + policy: proof, + manifest_state_absent, + state_effects_absent, + policy_host_imports_absent, + roots, + }, + ) => { + graph_id == &graph.graph_id + && package_id == &package.id + && artifact_id == &package.artifact_id + && proof == policy + && *manifest_state_absent + && *state_effects_absent + && *policy_host_imports_absent + && roots.is_empty() + && checkpoint.public_config.config.policy_revision + == "stateless_builtin_with_pure_policy" + } + _ => false, + }; + if !covered { + return Err(PinnedToolAdmitError::new( + "harness_policy_selection_invalid", + )); + } + if package.id != graph.package_id || package.artifact_id != graph.artifact_id { + return Err(PinnedToolAdmitError::new("checkpoint package mismatch")); + } + // A compiled capture does not replace the checkpoint package's required + // installed artifact on a fresh restore admission. + store + .read_installed(&package.artifact_id) + .map_err(|_| PinnedToolAdmitError { + code: "plugin_artifact_unavailable", + message: "plugin artifact is unavailable".into(), + })?; + let prepared = store + .prepare_harness_graph(&loaded.lock, cancellation) + .map_err(PinnedToolAdmitError::plugin)?; + let captured = capture_tool(&prepared, package)?; + if captured.export != graph.export { + return Err(PinnedToolAdmitError::new("checkpoint export mismatch")); + } + Ok(PreparedProductionPin { + graph_id: graph.graph_id.clone(), + generation: graph.captured_activation_generation, + ref_id: Uuid::new_v4().to_string(), + captured, + }) +} + pub(crate) fn prepare_production_pin( persistence: &GatewayPersistence, store: &PackageStore, active: &super::control_store::ActivationPointer, + cancellation: &crate::RunCancellation, ) -> Result { let control = PluginControlStore::new(persistence); + cancellation + .ensure_open() + .map_err(PinnedToolAdmitError::new)?; + if control + .graph_revocation(&active.graph_id) + .map_err(|_| PinnedToolAdmitError::new("plugin_graph_pin_required"))? + .is_some() + { + return Err(PinnedToolAdmitError::new("plugin_graph_revoked")); + } if active.graph_id.is_empty() { return Err(PinnedToolAdmitError::new( "tool admission requires an active plugin graph", )); } - let cached = store - .prepared - .lock() - .unwrap_or_else(|error| error.into_inner()) - .as_ref() - .filter(|(lock, _)| lock.graph_id == active.graph_id) - .map(|(lock, _)| lock.clone()); - let lock = match cached { - Some(lock) => lock, - None => { - control - .load_lock(&active.graph_id) - .map_err(|error| PinnedToolAdmitError::new(error.message))? - .lock - } - }; + let lock = control + .load_lock(&active.graph_id) + .map_err(|e| PinnedToolAdmitError::new(e.message))? + .lock; let package = select_tool_package(&lock)?; let prepared = store - .prepare(&lock) - .map_err(|error| PinnedToolAdmitError::new(error.to_string()))?; + .prepare_harness_graph(&lock, cancellation) + .map_err(PinnedToolAdmitError::plugin)?; let captured = capture_tool(&prepared, package)?; Ok(PreparedProductionPin { graph_id: active.graph_id.clone(), @@ -453,6 +719,15 @@ pub(crate) fn seal_production_receipt( let pinned_graph = required_text(pin, "graph_id")?; let pinned_ref = required_text(pin, "ref_id")?; let pinned_generation = number_field(pin, "generation"); + let selection: super::resolve::HarnessSelection = serde_json::from_value( + pin.get("harness_selection") + .cloned() + .ok_or_else(|| PinnedToolAdmitError::new("harness selection missing"))?, + ) + .map_err(|_| PinnedToolAdmitError::new("harness selection invalid"))?; + if selection != *prepared.harness_selection() { + return Err(PinnedToolAdmitError::new("harness selection mismatch")); + } if pinned_graph != prepared.graph_id || pinned_ref != prepared.ref_id || pinned_generation != prepared.generation @@ -492,6 +767,77 @@ pub fn restore_owned_pin( store: &PackageStore, owner_id: &str, ) -> Result { + restore_owned_pin_inner(persistence, store, owner_id, None) +} +/// Read-only receipt validation. This produces no runnable code and does not +/// re-check execution availability or revocation; execution uses the clock-bound +/// restore path before a provider/tool can run. +pub(crate) fn validate_owned_harness_fact( + persistence: &GatewayPersistence, + owner: &str, +) -> Result { + let control = PluginControlStore::new(persistence); + let pin = control + .pin_for_run(owner) + .map_err(|_| PinnedToolAdmitError::new("plugin_graph_pin_required"))?; + if pin["harness_selection"]["mode"] != "context_prompt" { + return Ok(false); + } + let required = control + .pin_requirement_for_run(owner) + .map_err(|_| PinnedToolAdmitError::new("plugin_graph_pin_required"))? + .ok_or_else(|| PinnedToolAdmitError::new("plugin_graph_pin_required"))?; + if pin["pinned"] != true + || pin["run_id"] != owner + || pin["graph_id"] != required.graph_id + || pin["ref_id"] != required.ref_id + { + return Err(PinnedToolAdmitError::new("plugin_graph_pin_required")); + } + let lock = control + .load_lock(&required.graph_id) + .map_err(|_| PinnedToolAdmitError::new("plugin_graph_pin_required"))? + .lock; + validate_pin_selection(&pin, &lock)?; + let run = persistence + .run_get(owner) + .map_err(|_| PinnedToolAdmitError::new("plugin_graph_pin_required"))?; + let outer: Value = serde_json::from_str( + run["rows"][0][4] + .as_str() + .ok_or_else(|| PinnedToolAdmitError::new("harness_policy_selection_invalid"))?, + ) + .map_err(|_| PinnedToolAdmitError::new("harness_policy_selection_invalid"))?; + if outer["run_graph_selection"] + != json!({"graph_id":required.graph_id,"ref_id":required.ref_id,"harness_selection":lock.harness_selection}) + { + return Err(PinnedToolAdmitError::new( + "harness_policy_selection_invalid", + )); + } + Ok(true) +} + +pub(crate) fn restore_owned_pin_with_cancellation( + persistence: &GatewayPersistence, + store: &PackageStore, + owner_id: &str, + cancel: &crate::RunCancellation, +) -> Result { + restore_owned_pin_inner(persistence, store, owner_id, Some(cancel)) +} +fn restore_owned_pin_inner( + persistence: &GatewayPersistence, + store: &PackageStore, + owner_id: &str, + cancel: Option<&crate::RunCancellation>, +) -> Result { + if let Some(cancel) = cancel { + cancel.ensure_open().map_err(|code| PinnedToolAdmitError { + code, + message: code.into(), + })?; + } let control = PluginControlStore::new(persistence); let pin = control .pin_for_run(owner_id) @@ -517,6 +863,46 @@ pub fn restore_owned_pin( let loaded = control .load_lock(&graph_id) .map_err(|error| PinnedToolAdmitError::new(error.message))?; + validate_pin_selection(&pin, &loaded.lock)?; + if matches!( + loaded.lock.harness_selection, + super::HarnessSelection::ContextPrompt { .. } + ) { + let required = control + .pin_requirement_for_run(owner_id) + .map_err(|_| PinnedToolAdmitError::new("plugin_graph_pin_required"))? + .ok_or_else(|| PinnedToolAdmitError::new("plugin_graph_pin_required"))?; + if required.graph_id != graph_id || required.ref_id != ref_id { + return Err(PinnedToolAdmitError::new("plugin_graph_pin_required")); + } + if control + .graph_revocation(&graph_id) + .map_err(|_| PinnedToolAdmitError::new("plugin_graph_pin_required"))? + .is_some() + { + return Err(PinnedToolAdmitError::new("plugin_graph_revoked")); + } + for package in &loaded.lock.packages { + store + .read_installed(&package.artifact_id) + .map_err(|_| PinnedToolAdmitError::new("plugin_artifact_unavailable"))?; + } + let run = persistence + .run_get(owner_id) + .map_err(|_| PinnedToolAdmitError::new("plugin_graph_pin_required"))?; + let encoded = run["rows"][0][4] + .as_str() + .ok_or_else(|| PinnedToolAdmitError::new("harness_policy_selection_invalid"))?; + let outer: Value = serde_json::from_str(encoded) + .map_err(|_| PinnedToolAdmitError::new("harness_policy_selection_invalid"))?; + if outer["run_graph_selection"] + != json!({"graph_id":graph_id,"ref_id":ref_id,"harness_selection":loaded.lock.harness_selection}) + { + return Err(PinnedToolAdmitError::new( + "harness_policy_selection_invalid", + )); + } + } let package = select_tool_package(&loaded.lock)?; if !control .package_refs(&graph_id) @@ -528,9 +914,11 @@ pub fn restore_owned_pin( "pinned package artifact is no longer the captured ref", )); } - let prepared = store - .prepare(&loaded.lock) - .map_err(|error| PinnedToolAdmitError::new(error.to_string()))?; + let prepared = match cancel { + Some(cancel) => store.prepare_harness_graph(&loaded.lock, cancel), + None => store.prepare(&loaded.lock), + } + .map_err(PinnedToolAdmitError::plugin)?; let captured = capture_tool(&prepared, package)?; Ok(binding_from_capture( captured, @@ -548,7 +936,29 @@ pub fn restore_owned_pin( .program_handle()) } +fn validate_pin_selection(pin: &Value, lock: &GraphLock) -> Result<(), PinnedToolAdmitError> { + let selection: super::resolve::HarnessSelection = serde_json::from_value( + pin.get("harness_selection") + .cloned() + .ok_or_else(|| PinnedToolAdmitError::new("harness selection missing"))?, + ) + .map_err(|_| PinnedToolAdmitError::new("harness selection invalid"))?; + if selection != lock.harness_selection { + return Err(PinnedToolAdmitError::new("harness selection mismatch")); + } + Ok(()) +} + fn select_tool_package(lock: &GraphLock) -> Result<&LockedPackage, PinnedToolAdmitError> { + if matches!( + lock.harness_selection, + super::resolve::HarnessSelection::Unselected + ) { + return Err(PinnedToolAdmitError { + code: "harness_topology_unsupported", + message: "explicit harness selection required".into(), + }); + } let mut tools = lock .packages .iter() diff --git a/src/plugins/prepare.rs b/src/plugins/prepare.rs index cd8861e..59d5aee 100644 --- a/src/plugins/prepare.rs +++ b/src/plugins/prepare.rs @@ -19,9 +19,71 @@ use super::store::{InstalledPackage, PackageStore, PackageTrust}; const VIRTUAL_ROOT: &str = "/__rustscript_agent_plugin_rss__"; +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct SupportedManagedTopology { + selection: super::resolve::HarnessSelection, +} +impl SupportedManagedTopology { + pub(crate) fn validate(lock: &GraphLock, tool: &InstalledPackage) -> Option { + use super::resolve::HarnessSelection; + if !tool.manifest.dependencies.is_empty() || !lock.edges.is_empty() { + return None; + } + let embedding = tool.manifest.id == crate::runtime::native_tools::EMBEDDING_PACKAGE_ID + && tool.trust == PackageTrust::EmbeddingOwnedBuiltin; + if !embedding && !signed_stateless_tool_only(lock, tool) { + return None; + } + let valid = match &lock.harness_selection { + HarnessSelection::ToolOnly { tool_package } => { + tool_package == &tool.manifest.id + && lock.packages.len() == 1 + && lock.closure.len() == 1 + && (embedding || catalog_export_paths(tool).len() == 1) + } + HarnessSelection::ContextPrompt { + tool_package, + policy, + } => { + embedding + && tool_package == &tool.manifest.id + && lock.packages.len() == 2 + && lock.closure.len() == 4 + && lock.packages.iter().any(|p| { + p.id == policy.package_id + && p.artifact_id == policy.artifact_id + && p.kind == "library" + }) + && [ + &policy.prompt_export, + &policy.context_export, + &policy.guardian_export, + ] + .iter() + .all(|name| { + lock.closure + .iter() + .filter(|e| { + e.package == policy.package_id + && &e.entry == *name + && e.effects.is_empty() + }) + .count() + == 1 + }) + } + HarnessSelection::Unselected => false, + }; + valid.then(|| Self { + selection: lock.harness_selection.clone(), + }) + } +} + #[derive(Clone)] pub struct PreparedGraph { sources: BTreeMap<(String, String), Vec>, + pub(crate) harness_selection: super::resolve::HarnessSelection, callables: BTreeMap, exports: BTreeMap, trust: BTreeMap, @@ -102,6 +164,44 @@ impl PackageStore { } pub fn prepare(&self, lock: &GraphLock) -> Result { + self.prepare_inner(lock, None) + } + pub(crate) fn prepare_harness_graph( + &self, + lock: &GraphLock, + cancel: &crate::RunCancellation, + ) -> Result { + self.prepare_inner(lock, Some(cancel)) + } + fn prepare_inner( + &self, + lock: &GraphLock, + cancel: Option<&crate::RunCancellation>, + ) -> Result { + check_cancel(cancel)?; + if cancel.is_none() + && matches!( + lock.harness_selection, + super::HarnessSelection::ContextPrompt { .. } + ) + { + return Err(PluginError::new( + PluginErrorKind::CompileFailed, + "deadline_elapsed", + )); + } + lock.validate_persisted()?; + if cancel.is_some() { + for package in &lock.packages { + self.read_installed(&package.artifact_id).map_err(|_| { + PluginError::new( + PluginErrorKind::MissingDependency, + "plugin_artifact_unavailable", + ) + })?; + } + } + check_cancel(cancel)?; if lock.compile_environment != COMPILE_ENVIRONMENT { return Err(PluginError::new( PluginErrorKind::ContractIncompatible, @@ -136,11 +236,49 @@ impl PackageStore { })?; selected.push(package); } + let request = match &lock.harness_selection { + super::resolve::HarnessSelection::Unselected => { + super::resolve::HarnessSelectionRequest::Unselected + } + super::resolve::HarnessSelection::ToolOnly { tool_package } => { + super::resolve::HarnessSelectionRequest::ToolOnly { + tool_package: tool_package.clone(), + } + } + super::resolve::HarnessSelection::ContextPrompt { + tool_package, + policy, + } => super::resolve::HarnessSelectionRequest::ContextPrompt { + tool_package: tool_package.clone(), + policy_package: policy.package_id.clone(), + prompt_export: policy.prompt_export.clone(), + context_export: policy.context_export.clone(), + guardian_export: policy.guardian_export.clone(), + }, + }; + let actual = selected + .iter() + .map(|p| (p.manifest.id.clone(), *p)) + .collect(); + if super::resolve::select_harness(&actual, &request)? != lock.harness_selection { + return Err(PluginError::new( + PluginErrorKind::ContractIncompatible, + "harness_policy_selection_invalid", + )); + } for package in &selected { - scan_package(package, &selected)?; + let pure = matches!(&lock.harness_selection,super::HarnessSelection::ContextPrompt{policy,..} if policy.package_id==package.manifest.id); + scan_package(package, &selected, pure).map_err(|error| { + if pure { + PluginError::new(error.kind, "harness_policy_not_pure") + } else { + error + } + })?; } let mut prepared = PreparedGraph { sources: BTreeMap::new(), + harness_selection: lock.harness_selection.clone(), callables: BTreeMap::new(), exports: BTreeMap::new(), trust: BTreeMap::new(), @@ -151,7 +289,11 @@ impl PackageStore { }; for package in &selected { if package.trust == PackageTrust::EmbeddingOwnedBuiltin { - let catalog = capture_managed_policy_catalog(package, lock)?; + let catalog = capture_managed_policy_catalog_inner(package, lock, cancel)?; + prepared + .managed_policy_catalogs + .insert(package.manifest.id.clone(), catalog); + } else if let Some(catalog) = capture_signed_stateless_catalog(package, lock, cancel)? { prepared .managed_policy_catalogs .insert(package.manifest.id.clone(), catalog); @@ -180,11 +322,7 @@ impl PackageStore { crate::effects::broker::ManagedEntryContract { trust: package.trust, effects, - single_builtin_graph: lock.packages.len() == 1 - && lock.edges.is_empty() - && package.manifest.dependencies.is_empty() - && package.manifest.id - == crate::runtime::native_tools::EMBEDDING_PACKAGE_ID, + topology: SupportedManagedTopology::validate(lock, package), }, ); for (path, bytes) in &package.sources { @@ -199,7 +337,9 @@ impl PackageStore { package.manifest.id.clone(), package.manifest.entry.export.clone(), ); + check_cancel(cancel)?; let (callable, program) = compile_package(package)?; + check_cancel(cancel)?; let imports: Vec<_> = program .imports .iter() @@ -219,6 +359,7 @@ impl PackageStore { .programs .insert(package.manifest.id.clone(), program); } + check_cancel(cancel)?; *self .prepared .lock() @@ -227,10 +368,29 @@ impl PackageStore { } } +fn check_cancel(cancel: Option<&crate::RunCancellation>) -> Result<(), PluginError> { + if let Some(cancel) = cancel { + cancel + .ensure_open() + .map_err(|e| PluginError::new(PluginErrorKind::CompileFailed, e))?; + } + Ok(()) +} + +#[cfg(test)] fn capture_managed_policy_catalog( package: &InstalledPackage, lock: &GraphLock, ) -> Result, PluginError> { + capture_managed_policy_catalog_inner(package, lock, None) +} + +fn capture_managed_policy_catalog_inner( + package: &InstalledPackage, + lock: &GraphLock, + cancel: Option<&crate::RunCancellation>, +) -> Result, PluginError> { + check_cancel(cancel)?; let invalid = || { PluginError::new( PluginErrorKind::ContractIncompatible, @@ -240,7 +400,7 @@ fn capture_managed_policy_catalog( if package.trust != PackageTrust::EmbeddingOwnedBuiltin || !is_tool_role(package) || package.manifest.id != crate::runtime::native_tools::EMBEDDING_PACKAGE_ID - || lock.packages.len() != 1 + || !matches!(lock.packages.len(), 1 | 2) || !lock.edges.is_empty() || !package.manifest.dependencies.is_empty() || package.manifest.effects.entries.len() != 1 @@ -261,9 +421,26 @@ fn capture_managed_policy_catalog( version: package.manifest.version.clone(), }], host_runtime: lock.host_runtime.clone(), + harness_selection: super::resolve::HarnessSelectionRequest::ToolOnly { + tool_package: package.manifest.id.clone(), + }, }, )?; - if rebuilt.packages != lock.packages || rebuilt.closure != lock.closure { + if rebuilt.packages + != lock + .packages + .iter() + .filter(|p| p.id == package.manifest.id) + .cloned() + .collect::>() + || rebuilt.closure + != lock + .closure + .iter() + .filter(|e| e.package == package.manifest.id) + .cloned() + .collect::>() + { return Err(invalid()); } let sources: Vec<_> = package @@ -294,9 +471,26 @@ fn capture_managed_policy_catalog( ) .map_err(|_| invalid())?; let entry = compiled.entry_name.ok_or_else(invalid)?; + check_cancel(cancel)?; + let program = Arc::new(compiled.program); + if let Some(cancel) = cancel { + cancel + .ensure_open() + .map_err(|e| PluginError::new(PluginErrorKind::CompileFailed, e))?; + } let value = - crate::runtime::rss_runner::eval_captured_pure_export(Arc::new(compiled.program), &entry) - .map_err(|_| invalid())?; + crate::runtime::rss_runner::eval_captured_pure_export(program, &entry).map_err(|e| { + if cancel.is_some() { + PluginError::new(PluginErrorKind::CompileFailed, e) + } else { + invalid() + } + })?; + if let Some(cancel) = cancel { + cancel + .ensure_open() + .map_err(|e| PluginError::new(PluginErrorKind::CompileFailed, e))?; + } crate::effects::authority::ManagedToolPolicyCatalog::from_captured_value( &package.manifest.id, &package.artifact_id, @@ -306,9 +500,187 @@ fn capture_managed_policy_catalog( .map_err(|_| invalid()) } +const MANAGED_CATALOG_EXPORT: &str = "managed_authority_catalog"; + +fn signed_stateless_tool_only(lock: &GraphLock, package: &InstalledPackage) -> bool { + use super::resolve::HarnessSelection; + package.trust == PackageTrust::AcceptedPublisher + && is_tool_role(package) + && package.manifest.state.is_none() + && package.manifest.dependencies.is_empty() + && lock.edges.is_empty() + && lock.packages.len() == 1 + && lock.closure.len() == 1 + && matches!( + &lock.harness_selection, + HarnessSelection::ToolOnly { tool_package } if tool_package == &package.manifest.id + ) + && package.manifest.effects.entries.len() == 1 + && package + .manifest + .effects + .entries + .get(&package.manifest.entry.export) + .is_some_and(|entry| entry.delegated.is_empty()) +} + +fn catalog_export_paths(package: &InstalledPackage) -> Vec { + let mut found = Vec::new(); + for (path, bytes) in &package.sources { + let Ok(text) = std::str::from_utf8(bytes) else { + continue; + }; + let scanned = format!("{}{text}", catalog_prelude()); + let Ok(ir) = parse_source_with_dialect( + &scanned, + snapshot_parser_dialect(), + SharedParserOptions { + source_id: 0, + allow_implicit_externs: true, + allow_implicit_semicolons: false, + enforce_mutable_bindings: false, + import_scan_mode: true, + }, + ) else { + continue; + }; + if ir + .functions + .iter() + .any(|function| function.exported && function.name == MANAGED_CATALOG_EXPORT) + { + found.push(path.clone()); + } + } + found.sort(); + found +} + +fn capture_signed_stateless_catalog( + package: &InstalledPackage, + lock: &GraphLock, + cancel: Option<&crate::RunCancellation>, +) -> Result>, PluginError> { + if !signed_stateless_tool_only(lock, package) { + return Ok(None); + } + let invalid = || { + PluginError::new( + PluginErrorKind::ContractIncompatible, + "managed captured catalog", + ) + }; + let paths = catalog_export_paths(package); + let entry_path = match paths.len() { + 0 => return Ok(None), + 1 => paths[0].clone(), + _ => return Err(invalid()), + }; + check_cancel(cancel)?; + let rebuilt = super::resolve::resolve_installed( + std::slice::from_ref(package), + &super::resolve::ResolveRequest { + roots: vec![super::resolve::PackageRequirement { + id: package.manifest.id.clone(), + version: package.manifest.version.clone(), + }], + host_runtime: lock.host_runtime.clone(), + harness_selection: super::resolve::HarnessSelectionRequest::ToolOnly { + tool_package: package.manifest.id.clone(), + }, + }, + )?; + if rebuilt.packages + != lock + .packages + .iter() + .filter(|item| item.id == package.manifest.id) + .cloned() + .collect::>() + || rebuilt.closure + != lock + .closure + .iter() + .filter(|item| item.package == package.manifest.id) + .cloned() + .collect::>() + { + return Err(invalid()); + } + let sources: Vec<_> = package + .sources + .iter() + .map(|(key, bytes)| { + std::str::from_utf8(bytes) + .map(|text| (key.as_str(), text)) + .map_err(|_| invalid()) + }) + .collect::>()?; + let host_roots = host_roots(); + let selected = closed_bundle::select_catalog_closure( + &sources, + agent_host_catalog().as_ref(), + &entry_path, + &host_roots, + ) + .map_err(|_| invalid())?; + let sources: Vec<_> = sources + .into_iter() + .filter(|(path, _)| selected.iter().any(|item| item == path)) + .collect(); + let compiled = closed_bundle::compile_bundled_closed_bundle( + &entry_path, + &sources, + ClosedBundleOptions { + limits: ClosedBundleLimits { + max_files: 64, + max_depth: 16, + max_file_bytes: 1024 * 1024, + max_total_bytes: 8 * 1024 * 1024, + }, + virtual_root: Path::new(VIRTUAL_ROOT), + host_roots: &host_roots, + catalog: agent_host_catalog(), + dialect: snapshot_parser_dialect(), + entry_export: MANAGED_CATALOG_EXPORT, + }, + ) + .map_err(|_| invalid())?; + let entry = compiled.entry_name.ok_or_else(invalid)?; + check_cancel(cancel)?; + let program = Arc::new(compiled.program); + if let Some(cancel) = cancel { + cancel + .ensure_open() + .map_err(|e| PluginError::new(PluginErrorKind::CompileFailed, e))?; + } + let value = + crate::runtime::rss_runner::eval_captured_pure_export(program, &entry).map_err(|e| { + if cancel.is_some() { + PluginError::new(PluginErrorKind::CompileFailed, e) + } else { + invalid() + } + })?; + if let Some(cancel) = cancel { + cancel + .ensure_open() + .map_err(|e| PluginError::new(PluginErrorKind::CompileFailed, e))?; + } + crate::effects::authority::ManagedToolPolicyCatalog::from_captured_value( + &package.manifest.id, + &package.artifact_id, + &package.manifest.entry.export, + &value, + ) + .map(Some) + .map_err(|_| invalid()) +} + fn scan_package( package: &InstalledPackage, selected: &[&InstalledPackage], + pure: bool, ) -> Result<(), PluginError> { let aliases: BTreeSet<&str> = package .manifest @@ -329,6 +701,17 @@ fn scan_package( let Some(first) = import.first() else { continue; }; + if pure && !matches!(first.as_str(), "json" | "bytes") { + let local = package.sources.keys().any(|path| { + path == &format!("{first}.rss") || path.starts_with(&format!("{first}/")) + }); + if roots.contains(first) || !local { + return Err(PluginError::new( + PluginErrorKind::UnauthorizedHostImport, + "harness_policy_not_pure", + )); + } + } if aliases.contains(first.as_str()) { return Err(PluginError::new( PluginErrorKind::PluginBoundaryBypass, @@ -384,6 +767,12 @@ fn scan_source( fn compile_package( package: &InstalledPackage, +) -> Result<(String, Arc), PluginError> { + compile_package_export(package, &package.manifest.entry.export) +} +fn compile_package_export( + package: &InstalledPackage, + export: &str, ) -> Result<(String, Arc), PluginError> { let host_roots = host_roots(); let catalog = agent_host_catalog(); @@ -421,7 +810,7 @@ fn compile_package( host_roots: &host_roots, catalog: Arc::clone(&catalog), dialect: snapshot_parser_dialect(), - entry_export: &package.manifest.entry.export, + entry_export: export, }, ) .map_err(|_| PluginError::new(PluginErrorKind::CompileFailed, package.manifest.id.clone()))?; @@ -570,6 +959,7 @@ fn host_roots() -> BTreeSet { #[cfg(test)] mod policy_tests { use super::*; + use ring::signature::KeyPair; #[test] fn captured_policy_closure_is_exact_and_role_and_capture_bound() { @@ -586,6 +976,9 @@ mod policy_tests { let lock = super::super::resolve::resolve_installed( std::slice::from_ref(&package), &super::super::resolve::ResolveRequest { + harness_selection: crate::plugins::HarnessSelectionRequest::ToolOnly { + tool_package: package.manifest.id.clone(), + }, roots: vec![super::super::resolve::PackageRequirement { id: package.manifest.id.clone(), version: package.manifest.version.clone(), @@ -738,4 +1131,483 @@ mod policy_tests { PluginErrorKind::UndeclaredDirectEffect ); } + + #[test] + fn signed_stateless_tool_only_captures_same_package_catalog() { + let root = + std::path::PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()).join( + format!("signed-catalog-capture-{}", uuid::Uuid::new_v4().simple()), + ); + let _ = std::fs::remove_dir_all(&root); + let store = PackageStore::open(root.join("store")).unwrap(); + let pkcs8 = + ring::signature::Ed25519KeyPair::generate_pkcs8(&ring::rand::SystemRandom::new()) + .unwrap(); + let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap(); + let auth = super::super::store::InstallAuthorization { + admin: true, + accepted_publishers: vec![super::super::store::PublisherKey { + publisher: "org.rustscript".into(), + verifying_key: pair.public_key().as_ref().to_vec(), + }], + unsigned_dev_space: None, + }; + let id = "org.rustscript.tools.catalogcapture"; + let package = root.join("package"); + std::fs::create_dir_all(&package).unwrap(); + std::fs::write( + package.join("manifest.yaml"), + format!( + "format: rustscript.plugin/v1\nid: {id}\nversion: 1.0.0\nkind: tool\nruntime: \">=0.1.0, <0.2.0\"\ncontract: agent.tool/v1\nhost_abi: agent.cap/v1\nentry:\n path: run.rss\n export: run\ndependencies: []\ncapabilities:\n - workspace.read\n - workspace.write\neffects:\n contract: agent.effects/v1\n entries:\n run:\n direct:\n - kind: workspace.fs.read\n scope: bound_workspace\n recovery: none\n - kind: workspace.fs.write\n scope: bound_workspace\n recovery: none\n delegated: []\n" + ), + ) + .unwrap(); + std::fs::write( + package.join("run.rss"), + "pub fn run(context: map) -> map { context }\n", + ) + .unwrap(); + std::fs::write( + package.join("catalog.rss"), + include_bytes!("../../rss/tools/types.rss"), + ) + .unwrap(); + let signature = pair.sign(&store.signature_payload(&package).unwrap()); + std::fs::write(package.join("signature.ed25519"), signature.as_ref()).unwrap(); + let installed = store.install(&package, &auth).unwrap(); + assert_eq!(installed.trust, PackageTrust::AcceptedPublisher); + let lock = store + .resolve(&super::super::resolve::ResolveRequest { + harness_selection: crate::plugins::HarnessSelectionRequest::ToolOnly { + tool_package: id.into(), + }, + roots: vec![super::super::resolve::PackageRequirement { + id: id.into(), + version: "1.0.0".into(), + }], + host_runtime: super::super::error::HOST_RUNTIME.into(), + }) + .unwrap(); + let prepared = store.prepare(&lock).unwrap(); + assert_eq!(prepared.trust(id), Some(PackageTrust::AcceptedPublisher)); + assert_ne!( + prepared.trust(id), + Some(PackageTrust::EmbeddingOwnedBuiltin) + ); + let catalog = prepared + .managed_policy_catalog(id) + .expect("verified AcceptedPublisher stateless ToolOnly catalog capture missing"); + let snapshot = crate::runtime::rss_runner::bundled_tool_registry() + .unwrap() + .snapshot(); + let code = crate::effects::ManagedCodeIdentity { + graph_id: "untrusted-graph".into(), + activation_generation: 0, + graph_ref_id: "untrusted-ref".into(), + package_id: id.into(), + artifact_id: installed.artifact_id.clone(), + export: "run".into(), + registry_identity: snapshot.identity().to_string(), + effect_contract_version: 1, + }; + assert!(catalog.matches_code(&code)); + let mut forged = code.clone(); + forged.artifact_id = "forged-artifact".into(); + assert!(!catalog.matches_code(&forged)); + forged.artifact_id = installed.artifact_id.clone(); + forged.package_id = "forged-package".into(); + assert!(!catalog.matches_code(&forged)); + forged.package_id = id.into(); + forged.export = "managed_authority_catalog".into(); + assert!(!catalog.matches_code(&forged)); + catalog + .seal( + &code, + &snapshot, + "write_file", + crate::capabilities::CapabilityRisk::Write, + "{\"path\":\"out.txt\",\"content\":\"A\"}", + ) + .unwrap(); + assert!(prepared.managed_contract(id).unwrap().topology.is_some()); + let _ = std::fs::remove_dir_all(&root); + } + + fn open_root(label: &str) -> std::path::PathBuf { + let root = std::path::PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()) + .join(format!("{label}-{}", uuid::Uuid::new_v4().simple())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + root + } + + fn sign_install( + root: &std::path::Path, + _id: &str, + manifest: &str, + files: &[(&str, &[u8])], + signed: bool, + ) -> ( + PackageStore, + super::super::store::InstallAuthorization, + super::super::store::PackageRevision, + ) { + let store = PackageStore::open(root.join("store")).unwrap(); + let pkcs8 = + ring::signature::Ed25519KeyPair::generate_pkcs8(&ring::rand::SystemRandom::new()) + .unwrap(); + let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap(); + let package = root.join("package"); + std::fs::create_dir_all(&package).unwrap(); + std::fs::write(package.join("manifest.yaml"), manifest).unwrap(); + for (name, bytes) in files { + std::fs::write(package.join(name), bytes).unwrap(); + } + let auth = if signed { + let signature = pair.sign(&store.signature_payload(&package).unwrap()); + std::fs::write(package.join("signature.ed25519"), signature.as_ref()).unwrap(); + super::super::store::InstallAuthorization { + admin: true, + accepted_publishers: vec![super::super::store::PublisherKey { + publisher: "org.rustscript".into(), + verifying_key: pair.public_key().as_ref().to_vec(), + }], + unsigned_dev_space: None, + } + } else { + super::super::store::InstallAuthorization { + admin: true, + accepted_publishers: vec![], + unsigned_dev_space: Some(root.to_path_buf()), + } + }; + let installed = store.install(&package, &auth).unwrap(); + (store, auth, installed) + } + + fn stateless_manifest(id: &str) -> String { + format!( + "format: rustscript.plugin/v1\nid: {id}\nversion: 1.0.0\nkind: tool\nruntime: \">=0.1.0, <0.2.0\"\ncontract: agent.tool/v1\nhost_abi: agent.cap/v1\nentry:\n path: run.rss\n export: run\ndependencies: []\ncapabilities:\n - workspace.read\n - workspace.write\neffects:\n contract: agent.effects/v1\n entries:\n run:\n direct:\n - kind: workspace.fs.read\n scope: bound_workspace\n recovery: none\n - kind: workspace.fs.write\n scope: bound_workspace\n recovery: none\n delegated: []\n" + ) + } + + fn resolve_tool(store: &PackageStore, id: &str) -> super::super::resolve::GraphLock { + store + .resolve(&super::super::resolve::ResolveRequest { + harness_selection: crate::plugins::HarnessSelectionRequest::ToolOnly { + tool_package: id.into(), + }, + roots: vec![super::super::resolve::PackageRequirement { + id: id.into(), + version: "1.0.0".into(), + }], + host_runtime: super::super::error::HOST_RUNTIME.into(), + }) + .unwrap() + } + + #[test] + fn signed_tool_without_catalog_export_prepares_without_catalog() { + let root = open_root("signed-no-catalog"); + let id = "org.rustscript.tools.nocatalog"; + let (store, _, installed) = sign_install( + &root, + id, + &stateless_manifest(id), + &[("run.rss", b"pub fn run(context: map) -> map { context }\n")], + true, + ); + assert_eq!(installed.trust, PackageTrust::AcceptedPublisher); + let prepared = store.prepare(&resolve_tool(&store, id)).unwrap(); + assert!(prepared.managed_policy_catalog(id).is_none()); + assert!(prepared.managed_contract(id).unwrap().topology.is_none()); + assert_eq!(prepared.trust(id), Some(PackageTrust::AcceptedPublisher)); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn source_edit_after_install_does_not_change_captured_catalog() { + let root = open_root("signed-source-edit"); + let id = "org.rustscript.tools.catalogfrozen"; + let (store, _, installed) = sign_install( + &root, + id, + &stateless_manifest(id), + &[ + ("run.rss", b"pub fn run(context: map) -> map { context }\n"), + ("catalog.rss", include_bytes!("../../rss/tools/types.rss")), + ], + true, + ); + std::fs::write( + root.join("package/catalog.rss"), + b"pub fn managed_authority_catalog(context: map) -> map { context }\n", + ) + .unwrap(); + std::fs::write( + root.join("package/run.rss"), + b"pub fn run(context: map) -> map { {} }\n", + ) + .unwrap(); + let prepared = store.prepare(&resolve_tool(&store, id)).unwrap(); + assert_eq!( + prepared.captured_source(id, "catalog.rss").unwrap(), + include_bytes!("../../rss/tools/types.rss") + ); + let code = crate::effects::ManagedCodeIdentity { + graph_id: "untrusted-graph".into(), + activation_generation: 0, + graph_ref_id: "untrusted-ref".into(), + package_id: id.into(), + artifact_id: installed.artifact_id, + export: "run".into(), + registry_identity: "not-used-for-match".into(), + effect_contract_version: 1, + }; + assert!( + prepared + .managed_policy_catalog(id) + .unwrap() + .matches_code(&code) + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn unsigned_stateful_library_and_edges_do_not_capture_catalog() { + let catalog = include_bytes!("../../rss/tools/types.rss"); + let run = b"pub fn run(context: map) -> map { context }\n"; + let root = open_root("signed-ineligible"); + let unsigned_id = "org.rustscript.tools.unsignedcatalog"; + let (store, _, installed) = sign_install( + &root, + unsigned_id, + &stateless_manifest(unsigned_id), + &[("run.rss", run), ("catalog.rss", catalog)], + false, + ); + assert_eq!(installed.trust, PackageTrust::DevUnsigned); + let prepared = store.prepare(&resolve_tool(&store, unsigned_id)).unwrap(); + assert!(prepared.managed_policy_catalog(unsigned_id).is_none()); + assert_ne!( + prepared.trust(unsigned_id), + Some(PackageTrust::EmbeddingOwnedBuiltin) + ); + + let stateful_id = "org.rustscript.tools.statefulcatalog"; + let stateful_root = open_root("signed-stateful"); + let (store, _, _) = sign_install( + &stateful_root, + stateful_id, + &format!( + "{}state:\n scope: run\n schema: 1\n", + stateless_manifest(stateful_id) + ), + &[("run.rss", run), ("catalog.rss", catalog)], + true, + ); + let prepared = store.prepare(&resolve_tool(&store, stateful_id)).unwrap(); + assert!(prepared.managed_policy_catalog(stateful_id).is_none()); + assert!( + prepared + .managed_contract(stateful_id) + .unwrap() + .topology + .is_none() + ); + + let library_id = "org.rustscript.lib.cataloglib"; + let library_root = open_root("signed-library"); + let library_manifest = format!( + "format: rustscript.plugin/v1\nid: {library_id}\nversion: 1.0.0\nkind: library\nruntime: \">=0.1.0, <0.2.0\"\ncontract: agent.library/v1\nhost_abi: agent.cap/v1\nentry:\n path: run.rss\n export: run\ndependencies: []\ncapabilities: []\neffects:\n contract: agent.effects/v1\n entries:\n run:\n direct: []\n delegated: []\n" + ); + let (store, _, _) = sign_install( + &library_root, + library_id, + &library_manifest, + &[("run.rss", run), ("catalog.rss", catalog)], + true, + ); + let prepared = store.prepare(&resolve_tool(&store, library_id)).unwrap(); + assert!(prepared.managed_policy_catalog(library_id).is_none()); + + let edge_id = "org.rustscript.tools.catalogedge"; + let dep_id = "org.rustscript.lib.catalogdep"; + let edge_root = open_root("signed-edge"); + let store = PackageStore::open(edge_root.join("store")).unwrap(); + let pkcs8 = + ring::signature::Ed25519KeyPair::generate_pkcs8(&ring::rand::SystemRandom::new()) + .unwrap(); + let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap(); + let auth = super::super::store::InstallAuthorization { + admin: true, + accepted_publishers: vec![super::super::store::PublisherKey { + publisher: "org.rustscript".into(), + verifying_key: pair.public_key().as_ref().to_vec(), + }], + unsigned_dev_space: None, + }; + let library = edge_root.join("library"); + std::fs::create_dir_all(&library).unwrap(); + std::fs::write( + library.join("manifest.yaml"), + format!( + "format: rustscript.plugin/v1\nid: {dep_id}\nversion: 1.0.0\nkind: library\nruntime: \">=0.1.0, <0.2.0\"\ncontract: agent.library/v1\nhost_abi: agent.cap/v1\nentry:\n path: run.rss\n export: run\ndependencies: []\ncapabilities: []\neffects:\n contract: agent.effects/v1\n entries:\n run:\n direct: []\n delegated: []\n" + ), + ) + .unwrap(); + std::fs::write(library.join("run.rss"), run).unwrap(); + std::fs::write( + library.join("signature.ed25519"), + pair.sign(&store.signature_payload(&library).unwrap()) + .as_ref(), + ) + .unwrap(); + store.install(&library, &auth).unwrap(); + let tool = edge_root.join("tool"); + std::fs::create_dir_all(&tool).unwrap(); + std::fs::write( + tool.join("manifest.yaml"), + stateless_manifest(edge_id).replace( + "dependencies: []\n", + &format!( + "dependencies:\n - alias: lib\n id: {dep_id}\n version: \"1.0.0\"\n" + ), + ), + ) + .unwrap(); + std::fs::write(tool.join("run.rss"), run).unwrap(); + std::fs::write(tool.join("catalog.rss"), catalog).unwrap(); + std::fs::write( + tool.join("signature.ed25519"), + pair.sign(&store.signature_payload(&tool).unwrap()).as_ref(), + ) + .unwrap(); + store.install(&tool, &auth).unwrap(); + let lock = resolve_tool(&store, edge_id); + assert!(!lock.edges.is_empty()); + let prepared = store.prepare(&lock).unwrap(); + assert!(prepared.managed_policy_catalog(edge_id).is_none()); + assert!( + prepared + .managed_contract(edge_id) + .unwrap() + .topology + .is_none() + ); + assert_eq!( + prepared.trust(edge_id), + Some(PackageTrust::AcceptedPublisher) + ); + let _ = std::fs::remove_dir_all(&root); + let _ = std::fs::remove_dir_all(&stateful_root); + let _ = std::fs::remove_dir_all(&library_root); + let _ = std::fs::remove_dir_all(&edge_root); + } + + #[test] + fn signed_context_prompt_does_not_validate_as_managed_topology() { + let root = open_root("signed-context"); + let id = "org.rustscript.tools.catalogprompt"; + let (store, _, _) = sign_install( + &root, + id, + &stateless_manifest(id), + &[ + ("run.rss", b"pub fn run(context: map) -> map { context }\n"), + ("catalog.rss", include_bytes!("../../rss/tools/types.rss")), + ], + true, + ); + let mut lock = resolve_tool(&store, id); + lock.harness_selection = super::super::resolve::HarnessSelection::ContextPrompt { + tool_package: id.into(), + policy: super::super::resolve::PurePolicySelection { + package_id: "org.rustscript.lib.prompt".into(), + artifact_id: "forged".into(), + entry_path: "policy.rss".into(), + prompt_export: "prompt".into(), + context_export: "context".into(), + guardian_export: "guardian".into(), + }, + }; + let installed = store + .verified_capture(&[(id, lock.packages[0].artifact_id.as_str())]) + .unwrap(); + assert!(SupportedManagedTopology::validate(&lock, &installed[0]).is_none()); + assert!(store.prepare(&lock).is_err()); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn impure_or_inconsistent_catalog_rejects_prepare() { + let root = open_root("signed-impure"); + let cases: &[(&str, &[u8])] = &[ + ( + "org.rustscript.tools.catalogcap", + b"pub fn managed_authority_catalog(context: map) -> map {\n let written: map = cap::fs_write_atomic(\"t\", \"p\", \"*\", context);\n written\n}\n", + ), + ( + "org.rustscript.tools.catalogruntime", + b"pub fn managed_authority_catalog(context: map) -> map {\n let prepared: map = agent_runtime::tool_prepare(context);\n prepared\n}\n", + ), + ( + "org.rustscript.tools.catalogrisk", + b"pub fn managed_authority_catalog(context: map) -> map {\n {\n declarations: [{ tool_name: \"write_file\", operation: \"workspace_read\" }],\n descriptors: [{\n name: \"write_file\",\n description: \"forged\",\n toolset: \"coding\",\n risk_class: \"write\",\n schema: { type: \"object\" }\n }]\n }\n}\n", + ), + ]; + for (id, catalog) in cases { + let case_root = open_root(id); + let (store, _, _) = sign_install( + &case_root, + id, + &stateless_manifest(id), + &[ + ("run.rss", b"pub fn run(context: map) -> map { context }\n"), + ("catalog.rss", catalog), + ], + true, + ); + let error = store.prepare(&resolve_tool(&store, id)).unwrap_err(); + assert_eq!( + error.kind, + PluginErrorKind::ContractIncompatible, + "{id} {:?}", + error.kind + ); + assert!( + error.to_string().contains("managed captured catalog"), + "{id} {error}" + ); + let _ = std::fs::remove_dir_all(&case_root); + } + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn cancelled_signed_prepare_does_not_return_a_catalog() { + let root = open_root("signed-cancel"); + let id = "org.rustscript.tools.catalogcancel"; + let (store, _, _) = sign_install( + &root, + id, + &stateless_manifest(id), + &[ + ("run.rss", b"pub fn run(context: map) -> map { context }\n"), + ("catalog.rss", include_bytes!("../../rss/tools/types.rss")), + ], + true, + ); + let lock = resolve_tool(&store, id); + let cancel = crate::RunCancellation::with_deadline( + std::time::Instant::now() + std::time::Duration::from_secs(30), + ); + cancel.request(crate::CancellationReason::Requested); + assert!(store.prepare_harness_graph(&lock, &cancel).is_err()); + let expired = crate::RunCancellation::with_deadline( + std::time::Instant::now() - std::time::Duration::from_secs(1), + ); + assert!(store.prepare_harness_graph(&lock, &expired).is_err()); + let _ = std::fs::remove_dir_all(&root); + } } diff --git a/src/plugins/resolve.rs b/src/plugins/resolve.rs index a75ce56..e9464fb 100644 --- a/src/plugins/resolve.rs +++ b/src/plugins/resolve.rs @@ -24,6 +24,9 @@ const MAX_EDGES: usize = 256; const MAX_DEPTH: usize = 16; const MAX_VERSIONS: usize = 32; const MAX_DECISIONS: usize = 4096; +// Per lock, before deduplication. Each identity yields at most two changes; +// the public stored-lock path also enforces its existing document byte limit. +const MAX_PERMISSION_ITEMS: usize = 4096; #[derive(Clone, Debug, PartialEq, Eq)] pub struct PackageRequirement { @@ -35,6 +38,46 @@ pub struct PackageRequirement { pub struct ResolveRequest { pub roots: Vec, pub host_runtime: String, + pub harness_selection: HarnessSelectionRequest, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum HarnessSelectionRequest { + Unselected, + ToolOnly { + tool_package: String, + }, + ContextPrompt { + tool_package: String, + policy_package: String, + prompt_export: String, + context_export: String, + guardian_export: String, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "mode", rename_all = "snake_case", deny_unknown_fields)] +pub enum HarnessSelection { + Unselected, + ToolOnly { + tool_package: String, + }, + ContextPrompt { + tool_package: String, + policy: PurePolicySelection, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PurePolicySelection { + pub package_id: String, + pub artifact_id: String, + pub entry_path: String, + pub prompt_export: String, + pub context_export: String, + pub guardian_export: String, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -46,7 +89,7 @@ pub struct LockedPackage { pub artifact_id: String, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ScopeBinding { pub kind: String, @@ -54,7 +97,7 @@ pub struct ScopeBinding { pub recovery: String, } -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct LockedEdge { pub from: String, @@ -103,6 +146,7 @@ pub struct LockedEffectItem { #[serde(deny_unknown_fields)] pub struct GraphLock { pub graph_id: String, + pub harness_selection: HarnessSelection, pub host_runtime: String, pub compile_environment: String, pub packages: Vec, @@ -110,6 +154,267 @@ pub struct GraphLock { pub closure: Vec, } +/// Permission review data only; this never grants activation authority. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PermissionDiff { + /// False means no permission expansion was found; compatibility and approval + /// remain unchecked. Neither value authorizes activation. + pub requires_reapproval: bool, + pub changes: Vec, + pub compatibility: CompatibilityCheck, + pub metadata: GraphMetadataDiff, +} + +/// Metadata changes are distinct from permission expansion. All still require +/// compatibility inspection, including pure dependency and entry changes. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GraphMetadataDiff { + pub package_revisions: Vec, + pub edges_changed: bool, + pub entries_changed: bool, + pub host_contract_changed: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PackageRevisionDiff { + pub package: String, + pub before: Option, + pub after: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CompatibilityCheck { + /// ABI, schemas, dependency calls, and administrator approval are not checked. + Unchecked, +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct PermissionIdentity { + pub location: PermissionLocation, + pub package: String, + pub package_kind: String, + pub entry: String, + pub kind: String, + pub relation: String, + pub caller_package: String, + pub caller_entry: String, + pub alias: String, + pub callee_package: String, + pub callee_entry: String, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum PermissionLocation { + Closure, + EdgeBinding, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum PermissionChangeKind { + Added, + Removed, + ScopeChanged, + RecoveryChanged, + RecoveryDowngraded, +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct PermissionChange { + pub identity: PermissionIdentity, + pub kind: PermissionChangeKind, + pub before: Vec, + pub after: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct PermissionValue { + pub scope: String, + pub recovery: String, +} + +fn permission_values(lock: &GraphLock) -> BTreeMap> { + let packages: BTreeMap<_, _> = lock.packages.iter().map(|p| (&p.id, &p.kind)).collect(); + let mut values: BTreeMap<_, BTreeSet<_>> = BTreeMap::new(); + for entry in &lock.closure { + for effect in &entry.effects { + let identity = PermissionIdentity { + location: PermissionLocation::Closure, + package: entry.package.clone(), + package_kind: packages + .get(&entry.package) + .map(|kind| (*kind).clone()) + .unwrap_or_default(), + entry: entry.entry.clone(), + kind: effect.kind.clone(), + relation: effect.via.relation.clone(), + caller_package: effect.via.caller_package.clone(), + caller_entry: effect.via.caller_entry.clone(), + alias: effect.via.alias.clone(), + callee_package: effect.via.callee_package.clone(), + callee_entry: effect.via.callee_entry.clone(), + }; + values.entry(identity).or_default().insert(PermissionValue { + scope: effect.scope.clone(), + recovery: effect.recovery.clone(), + }); + } + } + for edge in &lock.edges { + for binding in &edge.bindings { + let identity = PermissionIdentity { + location: PermissionLocation::EdgeBinding, + package: edge.from.clone(), + package_kind: packages + .get(&edge.from) + .map(|kind| (*kind).clone()) + .unwrap_or_default(), + entry: edge.from_entry.clone(), + kind: binding.kind.clone(), + relation: edge.relation.clone(), + caller_package: edge.from.clone(), + caller_entry: edge.from_entry.clone(), + alias: edge.alias.clone(), + callee_package: edge.to.clone(), + callee_entry: edge.to_entry.clone(), + }; + values.entry(identity).or_default().insert(PermissionValue { + scope: binding.scope.clone(), + recovery: binding.recovery.clone(), + }); + } + } + values +} + +pub(crate) fn compare_graph_permissions( + previous: &GraphLock, + candidate: &GraphLock, +) -> Result { + for lock in [previous, candidate] { + if lock.packages.len() > MAX_PACKAGES + || lock.edges.len() > MAX_EDGES + || lock.closure.len() > MAX_PACKAGES + { + return Err(budget("permission_diff_items")); + } + let mut items = 0usize; + for count in lock + .closure + .iter() + .map(|entry| entry.effects.len()) + .chain(lock.edges.iter().map(|edge| edge.bindings.len())) + { + items = items.saturating_add(count); + if items > MAX_PERMISSION_ITEMS { + return Err(budget("permission_diff_items")); + } + } + } + let metadata = graph_metadata_diff(previous, candidate); + let previous = permission_values(previous); + let candidate = permission_values(candidate); + let mut changes = Vec::new(); + for (identity, after) in &candidate { + let before = previous.get(identity); + let mut kinds = Vec::new(); + if let Some(before) = before { + let old_scopes: BTreeSet<_> = before.iter().map(|v| &v.scope).collect(); + let new_scopes: BTreeSet<_> = after.iter().map(|v| &v.scope).collect(); + if old_scopes != new_scopes { + kinds.push(PermissionChangeKind::ScopeChanged); + } + let old_recovery: BTreeSet<_> = before.iter().map(|v| v.recovery.as_str()).collect(); + let new_recovery: BTreeSet<_> = after.iter().map(|v| v.recovery.as_str()).collect(); + if old_recovery != new_recovery { + kinds.push( + if old_recovery.contains("checkpoint") && new_recovery.contains("none") { + PermissionChangeKind::RecoveryDowngraded + } else { + PermissionChangeKind::RecoveryChanged + }, + ); + } + } else { + kinds.push(PermissionChangeKind::Added); + } + for kind in kinds { + changes.push(PermissionChange { + identity: identity.clone(), + kind, + before: before.into_iter().flatten().cloned().collect(), + after: after.iter().cloned().collect(), + }); + } + } + for (identity, before) in &previous { + if !candidate.contains_key(identity) { + changes.push(PermissionChange { + identity: identity.clone(), + kind: PermissionChangeKind::Removed, + before: before.iter().cloned().collect(), + after: Vec::new(), + }); + } + } + changes.sort(); + Ok(PermissionDiff { + requires_reapproval: changes.iter().any(|change| { + matches!( + change.kind, + PermissionChangeKind::Added + | PermissionChangeKind::ScopeChanged + | PermissionChangeKind::RecoveryDowngraded + ) + }), + changes, + compatibility: CompatibilityCheck::Unchecked, + metadata, + }) +} + +fn graph_metadata_diff(previous: &GraphLock, candidate: &GraphLock) -> GraphMetadataDiff { + let old: BTreeMap<_, _> = previous.packages.iter().map(|p| (&p.id, p)).collect(); + let new: BTreeMap<_, _> = candidate.packages.iter().map(|p| (&p.id, p)).collect(); + let ids: BTreeSet<_> = old.keys().chain(new.keys()).copied().collect(); + let package_revisions = ids + .into_iter() + .filter_map(|id| { + let before = old.get(id).copied(); + let after = new.get(id).copied(); + (before != after).then(|| PackageRevisionDiff { + package: id.clone(), + before: before.cloned(), + after: after.cloned(), + }) + }) + .collect(); + let edges = |lock: &GraphLock| -> BTreeSet { + lock.edges + .iter() + .cloned() + .map(|mut edge| { + edge.bindings.sort(); + edge.bindings.dedup(); + edge + }) + .collect() + }; + let entries = |lock: &GraphLock| -> BTreeSet<(String, String)> { + lock.closure + .iter() + .map(|entry| (entry.package.clone(), entry.entry.clone())) + .collect() + }; + GraphMetadataDiff { + package_revisions, + edges_changed: edges(previous) != edges(candidate), + entries_changed: entries(previous) != entries(candidate), + host_contract_changed: previous.host_runtime != candidate.host_runtime + || previous.compile_environment != candidate.compile_environment + || previous.harness_selection != candidate.harness_selection, + } +} + impl GraphLock { pub fn closure_kinds(&self, package_id: &str, entry: &str) -> Vec { self.closure @@ -126,6 +431,9 @@ impl GraphLock { pub fn semantic_yaml(&self) -> String { let mut yaml = String::new(); + yaml.push_str("harness_selection: "); + yaml.push_str(&serde_json::to_string(&self.harness_selection).expect("typed selection")); + yaml.push('\n'); yaml.push_str("compile_environment: "); yaml.push_str(&self.compile_environment); yaml.push('\n'); @@ -214,6 +522,7 @@ impl GraphLock { pub(crate) fn schema_value(&self) -> Value { json!({ "graph_id": self.graph_id, + "harness_selection": self.harness_selection, "host_runtime": self.host_runtime, "compile_environment": self.compile_environment, "packages": self.packages.iter().map(|package| json!({ @@ -523,6 +832,7 @@ pub fn resolve_installed( if edges.len() > MAX_EDGES { return Err(budget("edges")); } + let harness_selection = select_harness(&ctx.selected, &request.harness_selection)?; let mut closure = Vec::new(); for package in ctx.selected.values() { let entry = package.manifest.entry.export.clone(); @@ -533,6 +843,18 @@ pub fn resolve_installed( effects, }); } + if let HarnessSelection::ContextPrompt { policy, .. } = &harness_selection { + closure.push(LockedEffect { + package: policy.package_id.clone(), + entry: policy.context_export.clone(), + effects: vec![], + }); + closure.push(LockedEffect { + package: policy.package_id.clone(), + entry: policy.guardian_export.clone(), + effects: vec![], + }); + } closure .sort_by(|left, right| (&left.package, &left.entry).cmp(&(&right.package, &right.entry))); let mut locked = ctx @@ -548,6 +870,7 @@ pub fn resolve_installed( locked.sort_by(|left, right| left.id.cmp(&right.id)); let lock = GraphLock { graph_id: Uuid::new_v4().to_string(), + harness_selection, host_runtime: request.host_runtime.clone(), compile_environment: COMPILE_ENVIRONMENT.to_string(), packages: locked, @@ -558,6 +881,90 @@ pub fn resolve_installed( Ok(lock) } +pub(crate) fn select_harness( + selected: &BTreeMap, + request: &HarnessSelectionRequest, +) -> Result { + let invalid = || { + PluginError::new( + PluginErrorKind::ContractIncompatible, + "harness_policy_selection_invalid", + ) + }; + let (tool, policy_request) = match request { + HarnessSelectionRequest::Unselected => return Ok(HarnessSelection::Unselected), + HarnessSelectionRequest::ToolOnly { tool_package } => (tool_package, None), + HarnessSelectionRequest::ContextPrompt { + tool_package, + policy_package, + prompt_export, + context_export, + guardian_export, + } => ( + tool_package, + Some(( + policy_package, + prompt_export, + context_export, + guardian_export, + )), + ), + }; + if !selected.contains_key(tool.as_str()) { + return Err(invalid()); + } + let Some((id, prompt, context, guardian)) = policy_request else { + return Ok(HarnessSelection::ToolOnly { + tool_package: tool.clone(), + }); + }; + let tool_root = selected.get(tool).ok_or_else(invalid)?; + if tool != crate::runtime::native_tools::EMBEDDING_PACKAGE_ID + || tool_root.trust != super::PackageTrust::EmbeddingOwnedBuiltin + || !tool_root.manifest.dependencies.is_empty() + { + return Err(invalid()); + } + let p = selected.get(id).ok_or_else(invalid)?; + let m = &p.manifest; + if selected.len() != 2 + || tool == id + || m.kind != "library" + || m.contract != "agent.library/v1" + || m.entry.export != *prompt + || prompt == context + || prompt == guardian + || context == guardian + || !super::manifest::safe_export(prompt) + || !super::manifest::safe_export(context) + || !super::manifest::safe_export(guardian) + || m.state.is_some() + || !m.dependencies.is_empty() + || !m.capabilities.is_empty() + || m.effects.entries.len() != 3 + || !m.effects.entries.contains_key(prompt) + || !m.effects.entries.contains_key(context) + || !m.effects.entries.contains_key(guardian) + || m.effects + .entries + .values() + .any(|e| !e.direct.is_empty() || !e.delegated.is_empty()) + { + return Err(invalid()); + } + Ok(HarnessSelection::ContextPrompt { + tool_package: tool.clone(), + policy: PurePolicySelection { + package_id: id.clone(), + artifact_id: p.artifact_id.clone(), + entry_path: m.entry.path.clone(), + prompt_export: prompt.clone(), + context_export: context.clone(), + guardian_export: guardian.clone(), + }, + }) +} + // Each recursive frame owns one newly selected package (at most MAX_PACKAGES). // The pending continuation includes later roots as well as sibling dependencies. // Snapshots contain only bounded graph state; the global work meter is not cloned. @@ -883,3 +1290,112 @@ fn yaml_scalar(value: &str) -> String { value.to_string() } } + +#[cfg(test)] +mod permission_diff_tests { + use super::*; + + #[test] + fn comparison_rejects_work_above_the_item_budget() { + let mut graph = GraphLock { + graph_id: "bounded".into(), + harness_selection: HarnessSelection::Unselected, + host_runtime: "0.1.0".into(), + compile_environment: COMPILE_ENVIRONMENT.into(), + packages: vec![], + edges: vec![], + closure: vec![], + }; + let effect = LockedEffectItem { + kind: "clock.read".into(), + scope: "bound_run".into(), + recovery: "none".into(), + via: EffectOrigin { + relation: "direct".into(), + caller_package: "example".into(), + caller_entry: "invoke".into(), + alias: String::new(), + callee_package: "example".into(), + callee_version: "1.0.0".into(), + callee_entry: "invoke".into(), + version_requirement: String::new(), + }, + }; + graph.closure.push(LockedEffect { + package: "example".into(), + entry: "invoke".into(), + effects: vec![effect; 4097], + }); + let result = compare_graph_permissions(&graph, &graph); + assert!( + format!("{result:?}").contains("BudgetExceeded"), + "budget must precede deduplication" + ); + assert_eq!( + result.expect_err("over budget").kind, + PluginErrorKind::BudgetExceeded + ); + graph.closure[0].effects.pop(); + let at_limit = compare_graph_permissions(&graph, &graph).expect("exact limit accepted"); + assert!(!at_limit.requires_reapproval); + assert!(at_limit.changes.is_empty()); + } + + #[test] + fn matched_scope_changes_are_defensive_review_data() { + // Deliberately bypasses persistence: the current catalog has only one + // valid scope per kind. This tests comparison, not validator acceptance. + let previous = GraphLock { + graph_id: "old".into(), + harness_selection: HarnessSelection::Unselected, + host_runtime: "0.1.0".into(), + compile_environment: COMPILE_ENVIRONMENT.into(), + packages: vec![LockedPackage { + id: "example".into(), + version: "1.0.0".into(), + kind: "tool".into(), + artifact_id: "artifact".into(), + }], + edges: vec![], + closure: vec![LockedEffect { + package: "example".into(), + entry: "invoke".into(), + effects: vec![LockedEffectItem { + kind: "workspace.fs.read".into(), + scope: "bound_workspace".into(), + recovery: "none".into(), + via: EffectOrigin { + relation: "direct".into(), + caller_package: "example".into(), + caller_entry: "invoke".into(), + alias: String::new(), + callee_package: "example".into(), + callee_version: "1.0.0".into(), + callee_entry: "invoke".into(), + version_requirement: String::new(), + }, + }], + }], + }; + let mut candidate = previous.clone(); + candidate.closure[0].effects[0].scope = "defensive_other_scope".into(); + let diff = compare_graph_permissions(&previous, &candidate).expect("bounded diff"); + assert!( + diff.requires_reapproval, + "matched scope changes require review: {diff:?}" + ); + assert!(format!("{diff:?}").contains("ScopeChanged")); + assert_eq!(diff.changes.len(), 1); + assert_eq!(diff.changes[0].kind, PermissionChangeKind::ScopeChanged); + assert_eq!(diff.changes[0].before[0].scope, "bound_workspace"); + assert_eq!(diff.changes[0].after[0].scope, "defensive_other_scope"); + assert_eq!(diff.compatibility, CompatibilityCheck::Unchecked); + let reverse = + compare_graph_permissions(&candidate, &previous).expect("bounded reverse diff"); + assert!( + reverse.requires_reapproval, + "scope inequality is conservative in both directions" + ); + assert_eq!(reverse.compatibility, CompatibilityCheck::Unchecked); + } +} diff --git a/src/plugins/schema.rs b/src/plugins/schema.rs index a7ca19e..37aed25 100644 --- a/src/plugins/schema.rs +++ b/src/plugins/schema.rs @@ -21,6 +21,9 @@ const GRAPH_REF_SCHEMA: &str = include_str!("../../schemas/plugin-graph-ref.sche const CHECKPOINT_SCHEMA: &str = include_str!("../../schemas/v1/checkpoint.schema.yaml"); const MANAGED_EFFECT_SCHEMA: &str = include_str!("../../schemas/managed-effect.schema.yaml"); const MANAGED_WORKSPACE_SCHEMA: &str = include_str!("../../schemas/managed-workspace.schema.yaml"); +const MANAGED_CHECKPOINT_SCHEMA: &str = + include_str!("../../schemas/managed-checkpoint.schema.yaml"); +const MANAGED_RESTORE_SCHEMA: &str = include_str!("../../schemas/managed-restore.schema.yaml"); const SCHEMA_SOURCES: &[(&str, &str)] = &[ ("plugin-manifest.schema.yaml", MANIFEST_SCHEMA), @@ -31,6 +34,8 @@ const SCHEMA_SOURCES: &[(&str, &str)] = &[ ("checkpoint.schema.yaml", CHECKPOINT_SCHEMA), ("managed-effect.schema.yaml", MANAGED_EFFECT_SCHEMA), ("managed-workspace.schema.yaml", MANAGED_WORKSPACE_SCHEMA), + ("managed-checkpoint.schema.yaml", MANAGED_CHECKPOINT_SCHEMA), + ("managed-restore.schema.yaml", MANAGED_RESTORE_SCHEMA), ]; pub fn canonical_schema_documents() -> Result, PluginError> { @@ -62,6 +67,12 @@ pub fn canonical_schema_documents() -> Result, Pl "managed-workspace.schema.json" => { documents.insert("managed-workspace.schema.json", canonical) } + "managed-checkpoint.schema.json" => { + documents.insert("managed-checkpoint.schema.json", canonical) + } + "managed-restore.schema.json" => { + documents.insert("managed-restore.schema.json", canonical) + } _ => None, }; } @@ -96,6 +107,47 @@ pub(crate) fn validate_managed_workspace(value: &Value) -> Result<(), PluginErro validate_named("managed-workspace.schema.yaml", value) } +pub(crate) fn validate_managed_maintenance( + name: &str, + definition: &str, + value: &Value, +) -> Result<(), PluginError> { + let schemas = bundled_values()?; + let mut selected = schemas.get(name).cloned().ok_or_else(|| { + PluginError::new( + PluginErrorKind::ContractIncompatible, + "managed schema unavailable", + ) + })?; + selected + .as_object_mut() + .expect("managed schema object") + .remove("oneOf"); + if selected["$defs"].get(definition).is_none() { + return Err(PluginError::new( + PluginErrorKind::ContractIncompatible, + "managed named body unavailable", + )); + } + selected["$ref"] = Value::String(format!("#/$defs/{definition}")); + let registry = registry(&schemas)?; + let validator = jsonschema::options() + .with_registry(®istry) + .build(&selected) + .map_err(|_| { + PluginError::new( + PluginErrorKind::ContractIncompatible, + "managed schema invalid", + ) + })?; + validator.validate(value).map_err(|_| { + PluginError::new( + PluginErrorKind::ContractIncompatible, + "managed schema rejected", + ) + }) +} + fn validate_named(name: &str, value: &Value) -> Result<(), PluginError> { let schemas = bundled_values()?; let registry = registry(&schemas)?; @@ -183,6 +235,12 @@ fn schema_uri(name: &str) -> &'static str { "managed-workspace.schema.yaml" => { "https://schemas.rustscript.local/v1/managed-workspace.schema.yaml" } + "managed-checkpoint.schema.yaml" => { + "https://schemas.rustscript.local/managed-checkpoint.schema.yaml" + } + "managed-restore.schema.yaml" => { + "https://schemas.rustscript.local/managed-restore.schema.yaml" + } _ => "https://schemas.rustscript.local/v1/unknown", } } diff --git a/src/plugins/store.rs b/src/plugins/store.rs index e865c78..c6e61c6 100644 --- a/src/plugins/store.rs +++ b/src/plugins/store.rs @@ -22,7 +22,16 @@ use super::resolve::GraphLock; const SIGNATURE_FILE: &str = "signature.ed25519"; const MAX_CAPTURE_FILE: usize = 1024 * 1024; const MAX_CAPTURE_TOTAL: usize = 8 * 1024 * 1024; +/// Inclusive. `manifest.yaml` and `signature.ed25519` count. The check is +/// `len >= MAX` before insert, so 64 files are legal and 65 are not. const MAX_CAPTURE_FILES: usize = 64; +/// Package-root depth is 0. A directory at this depth may be entered; its +/// child directory may not. Empty directories count. +const MAX_CAPTURE_DEPTH: usize = 32; +/// The package root plus every directory and regular file visited. `.` and +/// `..` are not nodes. Empty directories count. 4096 is legal; the next node +/// is not. +const MAX_CAPTURE_WALK_ENTRIES: usize = 4096; #[derive(Clone, Debug, PartialEq, Eq)] pub struct PublisherKey { @@ -272,6 +281,9 @@ impl PackageStore { super::resolve::resolve_installed( &[old], &super::resolve::ResolveRequest { + harness_selection: crate::plugins::HarnessSelectionRequest::ToolOnly { + tool_package: crate::runtime::native_tools::EMBEDDING_PACKAGE_ID.into(), + }, roots: vec![super::resolve::PackageRequirement { id: crate::runtime::native_tools::EMBEDDING_PACKAGE_ID.into(), version: "1.0.0".into(), @@ -391,26 +403,51 @@ impl PackageStore { { remove_tree(&self.artifacts, &temporary)?; } + let ledger_existed = list_dir(&self.ledger)? + .iter() + .any(|name| name == artifact_id); mkdir_at(&self.artifacts, &temporary)?; - let temp_dir = open_dir_at(&self.artifacts, &temporary)?; - mkdir_at(&temp_dir, "sources")?; - let sources = open_dir_at(&temp_dir, "sources")?; - for (path, bytes) in files { - if path == "manifest.yaml" { - write_file_at(&temp_dir, "manifest.yaml", bytes)?; - } else if path == SIGNATURE_FILE { - write_file_at(&temp_dir, SIGNATURE_FILE, bytes)?; + let mut published = false; + let mut ledger_written = false; + let result = (|| -> Result<(), PluginError> { + let temp_dir = open_dir_at(&self.artifacts, &temporary)?; + mkdir_at(&temp_dir, "sources")?; + let sources = open_dir_at(&temp_dir, "sources")?; + for (path, bytes) in files { + if path == "manifest.yaml" { + write_file_at(&temp_dir, "manifest.yaml", bytes)?; + } else if path == SIGNATURE_FILE { + write_file_at(&temp_dir, SIGNATURE_FILE, bytes)?; + } else { + write_relative(&sources, path, bytes)?; + } + } + sync_dir(&temp_dir)?; + sync_dir(&sources)?; + rename_at(&self.artifacts, &temporary, artifact_id)?; + published = true; + sync_dir(&self.artifacts)?; + self.forget_verified(); + self.write_ledger(artifact_id, trust, publisher, payload)?; + ledger_written = true; + let captured = self.capture(artifact_id)?; + if captured.files != *files || !self.ledger_matches(artifact_id, payload)? { + return Err(PluginError::new( + PluginErrorKind::PathEscape, + "artifact readback", + )); + } + Ok(()) + })(); + if let Err(err) = result { + if published { + let _ = remove_tree(&self.artifacts, artifact_id); + if ledger_written || !ledger_existed { + let _ = unlink_at(&self.ledger, artifact_id, 0); + } } else { - write_relative(&sources, path, bytes)?; + let _ = remove_tree(&self.artifacts, &temporary); } - } - sync_dir(&temp_dir)?; - sync_dir(&sources)?; - rename_at(&self.artifacts, &temporary, artifact_id)?; - sync_dir(&self.artifacts)?; - self.forget_verified(); - if let Err(err) = self.write_ledger(artifact_id, trust, publisher, payload) { - let _ = remove_tree(&self.artifacts, artifact_id); return Err(err); } Ok(()) @@ -574,7 +611,7 @@ impl PackageStore { fn read_ledger(&self, artifact_id: &str) -> Result { let fd = open_file_at(&self.ledger, artifact_id)?; - let bytes = read_limited(fd, MAX_CAPTURE_TOTAL)?; + let bytes = read_limited(fd, MAX_CAPTURE_TOTAL, "ledger")?; decode_ledger(&bytes) } @@ -593,13 +630,14 @@ impl PackageStore { } if name == "sources" { let sources = open_dir_at(&dir, "sources")?; - capture_sources(&sources, "", &mut files, &mut total)?; + let mut walk = 0usize; + capture_sources(&sources, "", 0, &mut files, &mut total, &mut walk)?; continue; } if name != "manifest.yaml" && name != SIGNATURE_FILE { return Err(PluginError::new(PluginErrorKind::PathEscape, name)); } - let bytes = read_limited(open_file_at(&dir, &name)?, MAX_CAPTURE_FILE)?; + let bytes = read_limited(open_file_at(&dir, &name)?, MAX_CAPTURE_FILE, &name)?; total = total.saturating_add(bytes.len()); if total > MAX_CAPTURE_TOTAL || files.len() >= MAX_CAPTURE_FILES { return Err(PluginError::new( @@ -730,8 +768,10 @@ fn payload_artifact_id(payload: &[u8]) -> String { fn read_package(package: &Path) -> Result>, PluginError> { reject_symlink_path(package)?; + let root = open_dir_nofollow(package)?; let mut files = BTreeMap::new(); - read_tree(package, package, &mut files)?; + let mut budget = CaptureBudget { walk: 0, bytes: 0 }; + read_tree_at(&root, "", 0, &mut files, &mut budget)?; if !files.contains_key("manifest.yaml") { return Err(PluginError::new( PluginErrorKind::UnknownField, @@ -741,69 +781,131 @@ fn read_package(package: &Path) -> Result>, PluginError Ok(files) } -fn read_tree( - root: &Path, - dir: &Path, +fn read_tree_at( + dir: &OwnedFd, + prefix: &str, + depth: usize, files: &mut BTreeMap>, + budget: &mut CaptureBudget, ) -> Result<(), PluginError> { - let mut entries = Vec::new(); - for entry in - fs::read_dir(dir).map_err(|_| PluginError::new(PluginErrorKind::PathEscape, "package"))? - { - entries.push(entry.map_err(|_| PluginError::new(PluginErrorKind::PathEscape, "package"))?); + charge_walk(budget)?; + if depth > MAX_CAPTURE_DEPTH { + return Err(PluginError::new( + PluginErrorKind::BudgetExceeded, + "package depth", + )); } - entries.sort_by_key(|entry| entry.file_name()); - for entry in entries { - let name = entry.file_name(); - let name = name - .to_str() - .ok_or_else(|| PluginError::new(PluginErrorKind::PathEscape, "name"))?; - if name == "." || name == ".." || name.contains('/') { - return Err(PluginError::new(PluginErrorKind::PathEscape, name)); - } - let path = entry.path(); - let meta = fs::symlink_metadata(&path) - .map_err(|_| PluginError::new(PluginErrorKind::PathEscape, name))?; - if meta.file_type().is_symlink() { - return Err(PluginError::new(PluginErrorKind::SymlinkDenied, name)); + let remaining = MAX_CAPTURE_WALK_ENTRIES.saturating_sub(budget.walk); + let names = list_bounded(dir, remaining)?; + for name in names { + let relative = if prefix.is_empty() { + name.clone() + } else { + format!("{prefix}/{name}") + }; + let stat = stat_at(dir, &name)?; + let kind = stat.st_mode & libc::S_IFMT; + if kind == libc::S_IFLNK { + return Err(PluginError::new(PluginErrorKind::SymlinkDenied, relative)); } - if meta.is_dir() { - read_tree(root, &path, files)?; + if kind == libc::S_IFDIR { + if depth == MAX_CAPTURE_DEPTH { + return Err(PluginError::new( + PluginErrorKind::BudgetExceeded, + "package depth", + )); + } + let child = open_dir_at(dir, &name)?; + read_tree_at(&child, &relative, depth + 1, files, budget)?; continue; } - if !meta.is_file() { - return Err(PluginError::new(PluginErrorKind::PathEscape, name)); + if kind != libc::S_IFREG { + return Err(PluginError::new(PluginErrorKind::PathEscape, relative)); } - let relative = path - .strip_prefix(root) - .map_err(|_| PluginError::new(PluginErrorKind::PathEscape, name))?; - let relative = relative - .to_str() - .ok_or_else(|| PluginError::new(PluginErrorKind::PathEscape, name))?; - if relative != "manifest.yaml" && relative != SIGNATURE_FILE && !safe_relative(relative) { + if relative != "manifest.yaml" && relative != SIGNATURE_FILE && !safe_relative(&relative) { return Err(PluginError::new(PluginErrorKind::PathEscape, relative)); } - let mut file = File::options() - .read(true) - .custom_flags(libc::O_NOFOLLOW) - .open(&path) - .map_err(|error| { - if error.raw_os_error() == Some(libc::ELOOP) { - PluginError::new(PluginErrorKind::SymlinkDenied, relative) - } else { - PluginError::new(PluginErrorKind::PathEscape, relative) - } - })?; - let mut bytes = Vec::new(); - file.read_to_end(&mut bytes) - .map_err(|_| PluginError::new(PluginErrorKind::PathEscape, relative))?; - if files.insert(relative.to_string(), bytes).is_some() { + if files.len() >= MAX_CAPTURE_FILES { + return Err(PluginError::new( + PluginErrorKind::BudgetExceeded, + "package files", + )); + } + let announced = stat_len(&stat)?; + if announced > MAX_CAPTURE_FILE { + return Err(PluginError::new(PluginErrorKind::BudgetExceeded, relative)); + } + if budget.bytes.saturating_add(announced) > MAX_CAPTURE_TOTAL { + return Err(PluginError::new( + PluginErrorKind::BudgetExceeded, + "package bytes", + )); + } + charge_walk(budget)?; + let fd = open_file_at(dir, &name)?; + let live = file_len(&fd)?; + if live > MAX_CAPTURE_FILE || budget.bytes.saturating_add(live) > MAX_CAPTURE_TOTAL { + return Err(PluginError::new(PluginErrorKind::BudgetExceeded, relative)); + } + let bytes = read_limited(fd, live, &relative)?; + if budget.bytes.saturating_add(bytes.len()) > MAX_CAPTURE_TOTAL { + return Err(PluginError::new( + PluginErrorKind::BudgetExceeded, + "package bytes", + )); + } + budget.bytes += bytes.len(); + if files.insert(relative.clone(), bytes).is_some() { return Err(PluginError::new(PluginErrorKind::DuplicateField, relative)); } } Ok(()) } +struct CaptureBudget { + walk: usize, + bytes: usize, +} + +fn charge_walk(budget: &mut CaptureBudget) -> Result<(), PluginError> { + if budget.walk >= MAX_CAPTURE_WALK_ENTRIES { + return Err(PluginError::new( + PluginErrorKind::BudgetExceeded, + "package walk", + )); + } + budget.walk += 1; + Ok(()) +} + +fn stat_len(stat: &libc::stat) -> Result { + if stat.st_size < 0 { + return Err(PluginError::new(PluginErrorKind::PathEscape, "size")); + } + usize::try_from(stat.st_size).map_err(|_| PluginError::new(PluginErrorKind::PathEscape, "size")) +} + +fn stat_at(dir: &OwnedFd, name: &str) -> Result { + let c_name = c_name(name)?; + let mut stat = unsafe { std::mem::zeroed::() }; + let rc = unsafe { + libc::fstatat( + dir.as_raw_fd(), + c_name.as_ptr(), + &mut stat, + libc::AT_SYMLINK_NOFOLLOW, + ) + }; + if rc != 0 { + return Err(PluginError::new(PluginErrorKind::PathEscape, name)); + } + Ok(stat) +} + +fn file_len(fd: &OwnedFd) -> Result { + stat_len(&file_stat(fd)?) +} + fn reject_symlink_path(path: &Path) -> Result<(), PluginError> { if path .components() @@ -999,20 +1101,53 @@ fn open_dir_at(dir: &OwnedFd, name: &str) -> Result { } fn open_file_at(dir: &OwnedFd, name: &str) -> Result { - let fd = open_at(dir, name, libc::O_RDONLY)?; + // O_NONBLOCK closes the stat-then-open window: a FIFO swapped in after + // fstatat must not block the caller. + let fd = open_at(dir, name, libc::O_RDONLY | libc::O_NONBLOCK)?; if !is_regular_file(&fd)? { return Err(PluginError::new(PluginErrorKind::PathEscape, name)); } Ok(fd) } -fn is_regular_file(fd: &OwnedFd) -> Result { +fn file_stat(fd: &OwnedFd) -> Result { let mut stat = unsafe { std::mem::zeroed::() }; let rc = unsafe { libc::fstat(fd.as_raw_fd(), &mut stat) }; if rc != 0 { return Err(PluginError::new(PluginErrorKind::PathEscape, "stat")); } - Ok((stat.st_mode & libc::S_IFMT) == libc::S_IFREG) + Ok(stat) +} + +fn is_regular_file(fd: &OwnedFd) -> Result { + Ok((file_stat(fd)?.st_mode & libc::S_IFMT) == libc::S_IFREG) +} + +fn list_bounded(dir: &OwnedFd, remaining: usize) -> Result, PluginError> { + let proc = format!("/proc/self/fd/{}", dir.as_raw_fd()); + let mut names = Vec::new(); + for entry in fs::read_dir(&proc) + .map_err(|_| PluginError::new(PluginErrorKind::PathEscape, "directory"))? + { + let entry = + entry.map_err(|_| PluginError::new(PluginErrorKind::PathEscape, "directory"))?; + let name = entry.file_name(); + let name = name + .to_str() + .ok_or_else(|| PluginError::new(PluginErrorKind::PathEscape, "name"))?; + if name == "." || name == ".." { + continue; + } + if names.len() >= remaining { + return Err(PluginError::new( + PluginErrorKind::BudgetExceeded, + "package walk", + )); + } + names.push(name.to_string()); + } + names.sort(); + Ok(names) } fn list_dir(dir: &OwnedFd) -> Result, PluginError> { @@ -1036,7 +1171,7 @@ fn list_dir(dir: &OwnedFd) -> Result, PluginError> { Ok(names) } -fn read_limited(fd: OwnedFd, limit: usize) -> Result, PluginError> { +fn read_limited(fd: OwnedFd, limit: usize, label: &str) -> Result, PluginError> { let mut file = File::from(fd); let mut bytes = Vec::new(); let mut buf = [0u8; 8192]; @@ -1048,10 +1183,7 @@ fn read_limited(fd: OwnedFd, limit: usize) -> Result, PluginError> { break; } if bytes.len().saturating_add(read) > limit { - return Err(PluginError::new( - PluginErrorKind::BudgetExceeded, - "artifact file", - )); + return Err(PluginError::new(PluginErrorKind::BudgetExceeded, label)); } bytes.extend_from_slice(&buf[..read]); } @@ -1149,17 +1281,39 @@ fn unlink_at(dir: &OwnedFd, name: &str, flags: i32) -> Result<(), PluginError> { fn capture_sources( dir: &OwnedFd, prefix: &str, + depth: usize, files: &mut BTreeMap>, total: &mut usize, + walk: &mut usize, ) -> Result<(), PluginError> { - for name in list_dir(dir)? { + if *walk >= MAX_CAPTURE_WALK_ENTRIES { + return Err(PluginError::new( + PluginErrorKind::BudgetExceeded, + "package walk", + )); + } + *walk += 1; + if depth > MAX_CAPTURE_DEPTH { + return Err(PluginError::new( + PluginErrorKind::BudgetExceeded, + "package depth", + )); + } + let remaining = MAX_CAPTURE_WALK_ENTRIES.saturating_sub(*walk); + for name in list_bounded(dir, remaining)? { if let Ok(child) = open_dir_at(dir, &name) { + if depth == MAX_CAPTURE_DEPTH { + return Err(PluginError::new( + PluginErrorKind::BudgetExceeded, + "package depth", + )); + } let next = if prefix.is_empty() { name } else { format!("{prefix}/{name}") }; - capture_sources(&child, &next, files, total)?; + capture_sources(&child, &next, depth + 1, files, total, walk)?; continue; } let relative = if prefix.is_empty() { @@ -1170,12 +1324,23 @@ fn capture_sources( if !safe_relative(&relative) { return Err(PluginError::new(PluginErrorKind::PathEscape, relative)); } - let bytes = read_limited(open_file_at(dir, &name)?, MAX_CAPTURE_FILE)?; + if files.len() >= MAX_CAPTURE_FILES { + return Err(PluginError::new( + PluginErrorKind::BudgetExceeded, + "package files", + )); + } + let fd = open_file_at(dir, &name)?; + let live = file_len(&fd)?; + if live > MAX_CAPTURE_FILE || total.saturating_add(live) > MAX_CAPTURE_TOTAL { + return Err(PluginError::new(PluginErrorKind::BudgetExceeded, relative)); + } + let bytes = read_limited(fd, live, &relative)?; *total = total.saturating_add(bytes.len()); - if *total > MAX_CAPTURE_TOTAL || files.len() >= MAX_CAPTURE_FILES { + if *total > MAX_CAPTURE_TOTAL { return Err(PluginError::new( PluginErrorKind::BudgetExceeded, - "artifact", + "package bytes", )); } if files.insert(relative.clone(), bytes).is_some() { diff --git a/src/plugins/yaml.rs b/src/plugins/yaml.rs index b3dd72a..1ef6596 100644 --- a/src/plugins/yaml.rs +++ b/src/plugins/yaml.rs @@ -15,6 +15,7 @@ struct StrictLoader { stack: Vec, error: Option, docs: Vec, + managed_numbers: bool, } enum Frame { @@ -151,7 +152,15 @@ impl EventReceiver for StrictLoader { *key = Some(text); return; } - self.insert(scalar_value(&text, style)); + let value = if self.managed_numbers && style == TScalarStyle::Plain { + match serde_json::from_str::(&text) { + Ok(value @ Value::Number(_)) => value, + _ => scalar_value(&text, style), + } + } else { + scalar_value(&text, style) + }; + self.insert(value); } } } @@ -202,6 +211,7 @@ fn strict_yaml_document(text: &str, allow_nested_null: bool) -> Result Vec { +pub(crate) fn load_guidance( + root: &ConfinedFsRoot, + budgets: CodingPromptBudgets, +) -> Vec { let mut loaded = Vec::new(); for name in GUIDANCE_FILE_NAMES { if let Some(file) = read_guidance_file(root, name, budgets.guidance_file_bytes) { diff --git a/src/prompt/mod.rs b/src/prompt/mod.rs index 431d71d..6ea6de7 100644 --- a/src/prompt/mod.rs +++ b/src/prompt/mod.rs @@ -13,6 +13,7 @@ mod coding; +pub(crate) use coding::load_guidance; pub use coding::{ BuildInputs, CodingPromptBudgets, DateSource, FixedDateSource, GUIDANCE_FILE_NAMES, LoadedGuidance, PromptBuildError, SystemDateSource, TRUNCATION_MARKER, UNTRUSTED_FILE_HEADER, diff --git a/src/runtime/agent_host.rs b/src/runtime/agent_host.rs index 0b9aed8..34261f0 100644 --- a/src/runtime/agent_host.rs +++ b/src/runtime/agent_host.rs @@ -37,6 +37,8 @@ const CONTROL_CHECK: &str = "agent::control_check"; const TOOL_PREPARE: &str = "agent_runtime::tool_prepare"; const TOOL_REPLAY: &str = "agent_runtime::tool_replay"; const APPROVAL_ATTEST: &str = "agent_runtime::approval_attest"; +#[cfg(test)] +thread_local! { static APPROVAL_ATTEST_CALLS: std::cell::Cell = const { std::cell::Cell::new(0) }; } const APPROVAL_STORAGE: &str = "agent::approval_storage"; const TOOL_COMMIT: &str = "agent_runtime::tool_commit"; const INVOKE_PINNED_TOOL: &str = "agent_runtime::invoke_pinned_tool"; @@ -567,6 +569,7 @@ pub type AuthHostFactory = /// retained identity. #[derive(Clone)] pub struct PinnedToolProgram { + pub(crate) harness_selection: crate::plugins::HarnessSelection, pub(crate) managed: Option>, managed_contract: Option, managed_policy_catalog: Option>, @@ -589,6 +592,7 @@ pub struct PinnedToolProgram { #[derive(Clone)] pub(crate) struct PinnedToolCode { + pub(crate) harness_selection: crate::plugins::HarnessSelection, pub(crate) managed_contract: Option, pub(crate) managed_policy_catalog: Option>, @@ -614,9 +618,76 @@ pub(crate) struct PinnedToolReceipt { } impl PinnedToolProgram { + pub(crate) fn same_native_binding(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.program, &other.program) + && self.entry == other.entry + && self.graph_id == other.graph_id + && self.ref_id == other.ref_id + && self.package_id == other.package_id + && self.artifact_id == other.artifact_id + && self.export == other.export + && self.run_id == other.run_id + && self.session_id == other.session_id + && self.profile == other.profile + && self.generation == other.generation + && self.registry_identity == other.registry_identity + && self.workspace == other.workspace + && match (&self.managed, &other.managed) { + (None, None) => true, + (Some(left), Some(right)) => Arc::ptr_eq(left, right), + _ => false, + } + } + + pub(crate) fn restore_managed_binding( + &mut self, + runtime: &Arc, + authority: Arc, + saved: &crate::effects::journal::ManagedRunQueryBinding, + registry: Arc, + cancel: RunCancellation, + ) -> Result<(), String> { + if saved.owner.run_id != self.run_id + || saved.owner.session_id != self.session_id + || saved.owner.profile != self.profile + || registry.identity() != self.registry_identity + { + return Err("plugin_graph_pin_required".into()); + } + let code = crate::effects::ManagedCodeIdentity { + graph_id: self.graph_id.clone(), + activation_generation: self.generation, + graph_ref_id: self.ref_id.clone(), + package_id: self.package_id.clone(), + artifact_id: self.artifact_id.clone(), + export: self.export.clone(), + registry_identity: self.registry_identity.clone(), + effect_contract_version: 1, + }; + self.managed = Some( + runtime.restore_run_binding( + authority, + saved, + ( + code, + self.managed_contract + .clone() + .ok_or("plugin_graph_pin_required")?, + self.managed_policy_catalog + .clone() + .ok_or("plugin_graph_pin_required")?, + registry, + ), + cancel, + )?, + ); + Ok(()) + } + pub(crate) fn seal(code: PinnedToolCode, receipt: PinnedToolReceipt) -> Self { Self { managed: None, + harness_selection: code.harness_selection, managed_contract: code.managed_contract, managed_policy_catalog: code.managed_policy_catalog, program: code.program, @@ -657,6 +728,13 @@ impl PinnedToolProgram { &self.entry } + pub(crate) fn ref_id(&self) -> &str { + &self.ref_id + } + pub(crate) fn declared_export(&self) -> &str { + &self.export + } + pub fn graph_id(&self) -> &str { &self.graph_id } @@ -1422,6 +1500,8 @@ impl AgentHostState { } fn capability_approval_attest(&self, metadata: &JsonValue) -> JsonValue { + #[cfg(test)] + APPROVAL_ATTEST_CALLS.with(|count| count.set(count.get() + 1)); let Some(lifecycle) = self.lifecycle.as_ref() else { return crate::capabilities::host::error_envelope(&LifecycleError::InvalidMetadata( "capability lifecycle is not installed".to_string(), @@ -2932,6 +3012,9 @@ const NON_RETRYABLE_ERROR_CODES: &[&str] = &[ "corrupt_provider_step", "run_terminal", "missing_tool_parent", + "plugin_graph_revoked", + "plugin_graph_pin_required", + "plugin_artifact_unavailable", ]; const TRANSIENT_ERROR_CODES: &[&str] = &[ diff --git a/src/runtime/native_tools.rs b/src/runtime/native_tools.rs index 2318348..5f69c17 100644 --- a/src/runtime/native_tools.rs +++ b/src/runtime/native_tools.rs @@ -72,6 +72,9 @@ impl NativeToolRuntime { ) -> PinnedToolProgram { PinnedToolProgram::seal( PinnedToolCode { + harness_selection: crate::plugins::HarnessSelection::ToolOnly { + tool_package: EMBEDDING_PACKAGE_ID.into(), + }, managed_contract: None, managed_policy_catalog: None, program: Arc::clone(&self.program), diff --git a/src/runtime/rss_runner.rs b/src/runtime/rss_runner.rs index dee7be2..2895ba4 100644 --- a/src/runtime/rss_runner.rs +++ b/src/runtime/rss_runner.rs @@ -753,6 +753,23 @@ pub enum RunnerPrepareFault { pub const MAX_RUN_TIMEOUT: Duration = Duration::from_secs(24 * 60 * 60); impl RunCancellation { + pub(crate) fn same_root(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.inner, &other.inner) + } + + pub(crate) fn ensure_open(&self) -> std::result::Result<(), &'static str> { + if self.has_deadline_overflow() + || self.deadline_instant().is_none() + || self.deadline_passed() + { + return Err("deadline_elapsed"); + } + if self.requested().is_some() { + return Err("cancelled"); + } + Ok(()) + } + pub fn new() -> Self { Self { inner: Arc::new(RunCancellationInner { diff --git a/src/service.rs b/src/service.rs index a0e90f6..9cf9359 100644 --- a/src/service.rs +++ b/src/service.rs @@ -59,7 +59,11 @@ use crate::config::{ validate_request_hash, validate_visible_name, }; use crate::config_file::CompactionSettings; +mod managed_branches; +mod managed_checkpoints; mod managed_effects; +mod managed_publications; +mod plugin_admin; #[cfg(test)] #[path = "service_preflight_tests.rs"] mod preflight_tests; @@ -262,8 +266,40 @@ pub struct RunHandle { /// Exclusive worker occupancy. Concurrent `run_worker` tasks cannot both /// call the provider or advance the turn. Released on Drop (error/panic). occupancy: AtomicBool, + /// One transport scheduling claim for a newly restored admission. + restore_worker_started: Option, + cleanup_outcome: Mutex>, /// Sealed tool program for this run. Never reconstructed from request JSON. pinned_tool: Option, + native_disposition: Option, +} + +// Ephemeral original-admission facts. Absence cannot select a default branch. +#[derive(Clone)] +enum NativeRuntimeDisposition { + PinnedToolOnly(crate::runtime::agent_host::PinnedToolProgram), + ExplicitStateless(NativeStatelessAdmissionProof), +} +#[derive(Clone)] +struct NativeStatelessAdmissionProof { + embedded: crate::runtime::agent_host::PinnedToolProgram, +} +impl NativeRuntimeDisposition { + fn persistent( + pin: &crate::runtime::agent_host::PinnedToolProgram, + ) -> Result { + use crate::plugins::HarnessSelection; + match &pin.harness_selection { + HarnessSelection::ToolOnly { .. } => Ok(Self::PinnedToolOnly(pin.clone())), + _ => Err("plugin_graph_pin_required"), + } + } + fn pin(&self) -> &crate::runtime::agent_host::PinnedToolProgram { + match self { + Self::PinnedToolOnly(p) => p, + Self::ExplicitStateless(p) => &p.embedded, + } + } } /// Run-scoped capability host shared by RSS dispatch and lifecycle cleanup. @@ -418,6 +454,12 @@ impl RunHandle { self.terminal_at.lock().expect("terminal lock").is_some() } + /// Test-only completion observation, distinct from terminal publication. + #[cfg(feature = "workspace-io-fault")] + pub fn worker_is_idle_for_test(&self) -> bool { + !self.occupancy.load(Ordering::Acquire) + } + /// Frozen coding system prompt captured at admission for this run. pub fn coding_system_prompt(&self) -> &str { &self.coding_system_prompt @@ -490,10 +532,15 @@ impl RunHandle { } } }; - match state { + let outcome = match state { Some(state) => state.shutdown(), None => CleanupOutcome::Clean, + }; + let mut retained = self.cleanup_outcome.lock().expect("cleanup outcome lock"); + if retained.is_none() || outcome != CleanupOutcome::Clean { + *retained = Some(outcome); } + retained.unwrap_or(CleanupOutcome::Failed) } fn capability_host_retained(&self) -> bool { @@ -577,6 +624,8 @@ pub struct AdmitRunRequest { pub platform: String, pub idempotency_key: Option, pub idempotency_hash: Option, + pub graph_id: Option, + pub workspace_branch: Option, } /// Result of an accepted (or idempotently replayed) admission. @@ -731,6 +780,10 @@ impl CompactionFailure { #[derive(Debug)] pub enum AdmitError { + HarnessPolicy(&'static str), + PluginGraphRevoked, + PluginGraphPinRequired, + PluginArtifactUnavailable, RunLimitReached, IdempotencyConflict, ParentNotFound, @@ -748,6 +801,10 @@ pub enum AdmitError { impl std::fmt::Display for AdmitError { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { + Self::HarnessPolicy(code) => formatter.write_str(code), + Self::PluginArtifactUnavailable => formatter.write_str("plugin_artifact_unavailable"), + Self::PluginGraphRevoked => formatter.write_str("plugin_graph_revoked"), + Self::PluginGraphPinRequired => formatter.write_str("plugin_graph_pin_required"), Self::RunLimitReached => formatter.write_str("maximum concurrent run limit reached"), Self::IdempotencyConflict => { formatter.write_str("idempotency key was used with a different request") @@ -851,9 +908,47 @@ struct WorkspaceRunnerTemplates { #[cfg(test)] type CompactionPolicyObserver = Arc; +#[cfg(test)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AdmissionClockPoint { + Created, + PromptReadEntered, + ExpiredAfterPrompt, +} +#[cfg(test)] +type AdmissionClockObserver = Arc; + +#[cfg(test)] +pub(crate) struct WorkerProbeGuard { + finish: Option>, +} +#[cfg(test)] +impl WorkerProbeGuard { + #[allow(dead_code)] + pub(crate) fn new(finish: impl FnOnce() + Send + 'static) -> Self { + Self { + finish: Some(Box::new(finish)), + } + } +} +#[cfg(test)] +impl Drop for WorkerProbeGuard { + fn drop(&mut self) { + if let Some(finish) = self.finish.take() { + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(finish)); + } + } +} +#[cfg(test)] +pub(crate) trait WorkerProbeInstall: Send { + fn install(self: Box) -> WorkerProbeGuard; +} + struct AgentServiceInner { config: Arc, compaction_settings: CompactionSettings, + #[cfg(test)] + restore_candidate_observer: Mutex>, store: Arc>, persistence: Option>, agent_source: Option>, @@ -868,8 +963,15 @@ struct AgentServiceInner { context_cache_capacity: usize, capacity: Arc, session_maintenance: Arc>>, + maintenance_attempts: + ParkingMutex>>, runs: Mutex>>, pending: Mutex>, + /// Original admission whose commit is unproven. The permit lives in the + /// unpublished closure, not in a second registry. + unresolved_admissions: Mutex>, + original_proof_inflight: Mutex>, + original_proof_cv: Condvar, halting: AtomicBool, janitor_stop: Notify, janitor_task: Mutex>>, @@ -879,6 +981,24 @@ struct AgentServiceInner { capability_host_shutdown: Mutex>>, capability_host_init_entered: Mutex>>, prompt_read_entered: Mutex>>, + #[cfg(test)] + admission_clock_observer: Mutex>, + /// cfg(test) rendezvous after the pending-owner check and before commit_gate. + #[cfg(test)] + admission_commit_boundary: Mutex>>, + /// cfg(test) async barrier after the pending-owner check and before a permit. + #[cfg(test)] + admission_overlap_barrier: Mutex>>, + /// cfg(test) rendezvous inside commit_gate, after unproven readback, before install. + #[cfg(test)] + unpublished_install_window: Mutex>>, + #[cfg(test)] + shutdown_entered_hook: Mutex>>, + #[cfg(test)] + shutdown_waiting_for_gate: AtomicBool, + #[cfg(test)] + #[allow(clippy::type_complexity)] + original_proof_read_hook: Mutex>>, date_source: RwLock>, /// Optional one-shot injected provider host for tests. Consumed atomically /// by the next `run_worker`; production uses RssAdapterProvider. @@ -926,7 +1046,12 @@ struct AgentServiceInner { crash_after_tool_commit: AtomicBool, provider_commit_crashed: AtomicBool, tool_packages: Mutex>>, + plugin_control: Mutex>, + prepared_plugin_graphs: Mutex>, managed_effects: RwLock>>, + /// One-shot probe consumed by the next original blocking worker. Not an Arc cycle. + #[cfg(test)] + worker_probe: Mutex>>, stateless_tools: AtomicBool, } @@ -945,6 +1070,109 @@ impl Drop for AgentServiceInner { } } +struct UnpublishedOriginalProof<'a> { + persistence: &'a GatewayPersistence, + payload: &'a JsonValue, + graph_id: &'a str, + generation: u64, + ref_id: &'a str, + harness_selection: &'a JsonValue, + binding: Option<&'a crate::effects::broker::ManagedRunBinding>, + deadline_at_ms: u64, + registry_identity: &'a str, + workspace: &'a std::path::Path, + request_selector: Option<&'a str>, +} + +#[derive(Clone)] +struct UnresolvedAdmissionProof { + payload: JsonValue, + graph_id: String, + generation: u64, + ref_id: String, + harness_selection: JsonValue, + binding: Option>, + deadline_at_ms: u64, + registry_identity: String, + workspace: PathBuf, + request_selector: Option, +} + +struct UnresolvedAdmissionIdentity { + persistence: usize, + key: String, + hash: String, + selector: Option, + #[allow(dead_code)] + cancellation: RunCancellation, + proof: UnresolvedAdmissionProof, +} + +struct OriginalProofInflight<'a> { + service: &'a AgentService, + run_id: Option, +} + +impl OriginalProofInflight<'_> { + fn arm(&mut self, run_id: &str) { + if run_id.is_empty() || self.run_id.is_some() { + return; + } + self.service + .inner + .original_proof_inflight + .lock() + .expect("original proof inflight") + .insert(run_id.to_string()); + self.run_id = Some(run_id.to_string()); + } +} + +impl Drop for OriginalProofInflight<'_> { + fn drop(&mut self) { + let Some(run_id) = self.run_id.take() else { + return; + }; + self.service + .inner + .original_proof_inflight + .lock() + .expect("original proof inflight") + .remove(&run_id); + self.service.inner.original_proof_cv.notify_all(); + } +} + +struct UnresolvedAdmissionOwner { + persistence: usize, + key: String, + hash: String, + selector: Option, + #[allow(dead_code)] + cancellation: RunCancellation, + proof: UnresolvedAdmissionProof, + publish: Box Result + Send>, +} + +fn admission_receipt_unproven(error: &crate::gateway::store::StorageError) -> bool { + matches!( + error.code.as_str(), + "failpoint_after_commit_before_publish" | "storage_unavailable" | "storage_error" + ) +} + +fn admission_pin_guard_rolled_back(error: &crate::gateway::store::StorageError) -> bool { + // sqlite::transaction aborts before commit when a pin or control guard + // inserts id=2 into a CHECK (id = 1) table. That host failure rolled the + // admission back. It is not a lost receipt. Reprepare still requires the + // activation to have drifted and the run row to be absent. + error.message.contains("CHECK constraint failed: id = 1") +} + +fn durable_admission_status_is_terminal(status: &str) -> bool { + matches!(status, "completed" | "failed" | "cancelled") +} + impl AgentService { pub(crate) fn new( config: Arc, @@ -985,8 +1213,12 @@ impl AgentService { context_cache_capacity, capacity, session_maintenance: Arc::new(ParkingMutex::new(HashSet::new())), + maintenance_attempts: ParkingMutex::new(HashMap::new()), runs: Mutex::new(HashMap::new()), pending: Mutex::new(HashMap::new()), + unresolved_admissions: Mutex::new(Vec::new()), + original_proof_inflight: Mutex::new(HashSet::new()), + original_proof_cv: Condvar::new(), halting: AtomicBool::new(false), janitor_stop: Notify::new(), janitor_task: Mutex::new(None), @@ -996,11 +1228,27 @@ impl AgentService { capability_host_shutdown: Mutex::new(None), capability_host_init_entered: Mutex::new(None), prompt_read_entered: Mutex::new(None), + #[cfg(test)] + admission_clock_observer: Mutex::new(None), + #[cfg(test)] + admission_commit_boundary: Mutex::new(None), + #[cfg(test)] + admission_overlap_barrier: Mutex::new(None), + #[cfg(test)] + unpublished_install_window: Mutex::new(None), + #[cfg(test)] + shutdown_entered_hook: Mutex::new(None), + #[cfg(test)] + shutdown_waiting_for_gate: AtomicBool::new(false), + #[cfg(test)] + original_proof_read_hook: Mutex::new(None), date_source: RwLock::new(Arc::new(SystemDateSource)), provider_host: Mutex::new(None), compaction_policy_failure: Mutex::new(None), compaction_worker_panic: Mutex::new(None), #[cfg(test)] + restore_candidate_observer: Mutex::new(None), + #[cfg(test)] compaction_maintenance_published: Mutex::new(None), #[cfg(test)] compaction_runner_lookup_entered: Mutex::new(None), @@ -1026,7 +1274,11 @@ impl AgentService { crash_after_tool_commit: AtomicBool::new(false), provider_commit_crashed: AtomicBool::new(false), tool_packages: Mutex::new(None), + plugin_control: Mutex::new(None), + prepared_plugin_graphs: Mutex::new(HashMap::new()), managed_effects: RwLock::new(None), + #[cfg(test)] + worker_probe: Mutex::new(None), stateless_tools, }); let janitor = spawn_lifecycle_janitor(Arc::clone(&inner)); @@ -1474,11 +1726,14 @@ impl AgentService { let store = crate::plugins::PackageStore::open(crate::plugins::trusted_tool_package_root( sqlite_path, ))?; - store.bind_policy(&crate::plugins::InstallAuthorization { - admin: false, - accepted_publishers: Vec::new(), - unsigned_dev_space: None, - }); + let initial_policy = self.published_install_authorization().unwrap_or( + crate::plugins::InstallAuthorization { + admin: false, + accepted_publishers: Vec::new(), + unsigned_dev_space: None, + }, + ); + store.bind_policy(&initial_policy); // Prepare the closed builtin code, never an activation or owner pin. // A later trusted plugin-store installation must still be able to // activate its first signed graph at generation zero. @@ -1491,6 +1746,24 @@ impl AgentService { store.prepare(&lock)?; *self.inner.tool_packages.lock().expect("tool packages") = Some(Arc::new(store)); self.inner.stateless_tools.store(false, Ordering::Release); + // A host binding published while this store was being replaced must + // win over the default empty publisher set. Re-prepare only when that + // sync drops the cache just built above. + if self.sync_published_package_policy() { + let store = self.tool_store().ok_or_else(|| { + crate::plugins::PluginError::new( + crate::plugins::PluginErrorKind::ContractIncompatible, + "native tool runtime disappeared while syncing host policy", + ) + })?; + let lock = Self::embedding_tool_graph(&store).map_err(|error| { + crate::plugins::PluginError::new( + crate::plugins::PluginErrorKind::CompileFailed, + error.to_string(), + ) + })?; + store.prepare(&lock)?; + } Ok(()) } @@ -1567,6 +1840,14 @@ impl AgentService { ) -> Result { managed_effects::query(self, request) } + /// Bounded historical run summaries, authorized afresh by Native source scope. + pub fn query_managed_run_effects( + &self, + request: crate::effects::ManagedRunEffectListRequest, + ) -> Result { + managed_effects::query_run(self, request) + } + pub fn read_managed_result_chunk( &self, request: crate::effects::ManagedResultReadRequest, @@ -1699,6 +1980,9 @@ impl AgentService { version: "1.0.0".to_string(), }], host_runtime: crate::plugins::HOST_RUNTIME.to_string(), + harness_selection: crate::plugins::HarnessSelectionRequest::ToolOnly { + tool_package: crate::runtime::native_tools::EMBEDDING_PACKAGE_ID.into(), + }, }, ) .map_err(|error| AdmitError::Persistence(format!("embedding resolve failed: {error}"))) @@ -1746,9 +2030,57 @@ impl AgentService { } } + fn map_pin_admit(&self, error: crate::plugins::PinnedToolAdmitError) -> AdmitError { + match error.code { + "plugin_artifact_unavailable" => AdmitError::PluginArtifactUnavailable, + "plugin_graph_pin_required" => AdmitError::PluginGraphPinRequired, + "plugin_graph_revoked" => AdmitError::PluginGraphRevoked, + "harness_policy_selection_invalid" | "harness_topology_unsupported" => { + AdmitError::HarnessPolicy(error.code) + } + "deadline_elapsed" | "cancelled" => self.admission_deadline_rejection(), + "plugin_state_unsupported" => AdmitError::Invalid("plugin_state_unsupported".into()), + _ => AdmitError::PluginGraphPinRequired, + } + } + + fn reject_unselected_graph( + &self, + persistence: &GatewayPersistence, + store: &crate::plugins::PackageStore, + graph_id: &str, + ) -> AdmitError { + let control = crate::plugins::PluginControlStore::new(persistence); + match control.graph_revocation(graph_id) { + Ok(Some(_)) => return AdmitError::PluginGraphRevoked, + Ok(None) => {} + Err(error) => return AdmitError::Persistence(error.message.to_string()), + } + let Ok(loaded) = control.load_lock(graph_id) else { + return AdmitError::PluginGraphPinRequired; + }; + if loaded.lock.packages.iter().any(|package| { + store + .read_installed(&package.artifact_id) + .ok() + .is_some_and(|installed| installed.manifest.state.is_some()) + }) { + AdmitError::Invalid("plugin_state_unsupported".into()) + } else { + AdmitError::PluginGraphPinRequired + } + } + fn restore_pinned_tool( &self, run_id: &str, + ) -> Result, AdmitError> { + self.restore_pinned_tool_inner(run_id, None) + } + fn restore_pinned_tool_inner( + &self, + run_id: &str, + cancel: Option<&RunCancellation>, ) -> Result, AdmitError> { // A live handle retains the sealed admission binding. Read-only // idempotent replay must not reopen storage or select current activation. @@ -1758,22 +2090,31 @@ impl AgentService { { return Ok(Some(pin)); } - if self.inner.stateless_tools.load(Ordering::Acquire) { - return Ok(None); - } let Some(persistence) = self.inner.persistence.as_ref() else { - return Err(AdmitError::Persistence( - "persistent tool pin is missing".to_string(), - )); + return Err(AdmitError::PluginGraphPinRequired); }; + if cancel.is_none() + && crate::plugins::validate_owned_harness_fact(persistence, run_id) + .map_err(|e| AdmitError::Persistence(e.to_string()))? + { + return Ok(None); + } let Some(store) = self.tool_store() else { return Err(AdmitError::Persistence( "validated tool store is not installed".to_string(), )); }; - crate::plugins::restore_owned_pin(persistence, store.as_ref(), run_id) - .map(Some) - .map_err(|error| AdmitError::Persistence(error.to_string())) + match cancel { + Some(cancel) => crate::plugins::restore_owned_pin_with_cancellation( + persistence, + store.as_ref(), + run_id, + cancel, + ), + None => crate::plugins::restore_owned_pin(persistence, store.as_ref(), run_id), + } + .map(Some) + .map_err(|error| AdmitError::Persistence(error.to_string())) } /// Replaces the validated limits used by future admissions. @@ -1866,12 +2207,217 @@ impl AgentService { .unwrap_or_default() } + pub(crate) fn check_harness_run( + &self, + run_id: &str, + cancel: &RunCancellation, + ) -> Result<(), &'static str> { + let handle = self.handle(run_id).ok_or("plugin_graph_pin_required")?; + self.validate_native_handle(run_id, &handle, cancel, false) + } + + // Shared disposition owner for Guardian phases and provider/refresh/checkpoint. + // A checkpoint may inspect a terminal source using its maintenance clock; + // only a live worker binding requires the exact original cancellation root. + fn validate_native_handle( + &self, + run_id: &str, + handle: &Arc, + cancel: &RunCancellation, + live_worker: bool, + ) -> Result<(), &'static str> { + cancel.ensure_open()?; + let current = self.handle(run_id).ok_or("plugin_graph_pin_required")?; + if !Arc::ptr_eq(¤t, handle) { + return Err("plugin_graph_pin_required"); + } + if live_worker && (handle.is_terminal() || !handle.cancel.same_root(cancel)) { + return Err("plugin_graph_pin_required"); + } + if !handle.is_terminal() { + handle.cancel.ensure_open()?; + } + let disposition = handle + .native_disposition + .as_ref() + .ok_or("plugin_graph_pin_required")?; + let actual = handle + .pinned_tool + .as_ref() + .ok_or("plugin_graph_pin_required")?; + let expected = disposition.pin(); + if !actual.same_native_binding(expected) { + return Err("plugin_graph_pin_required"); + } + if actual.harness_selection != expected.harness_selection { + return Err("harness_policy_selection_invalid"); + } + match disposition { + NativeRuntimeDisposition::PinnedToolOnly(_) => { + if !matches!( + actual.harness_selection, + crate::plugins::HarnessSelection::ToolOnly { .. } + ) { + return Err("harness_policy_selection_invalid"); + } + self.check_full_harness_pin(run_id, cancel, actual)?; + } + NativeRuntimeDisposition::ExplicitStateless(_) => { + if actual.managed.is_some() + || !matches!( + actual.harness_selection, + crate::plugins::HarnessSelection::ToolOnly { .. } + ) + { + return Err("harness_policy_selection_invalid"); + } + if let Some(persistence) = &self.inner.persistence { + match crate::plugins::PluginControlStore::new(persistence) + .pin_requirement_for_run(run_id) + { + Ok(None) => {} + Ok(Some(_)) | Err(_) => return Err("plugin_graph_pin_required"), + } + } + } + } + cancel.ensure_open() + } + fn check_managed_pin_identity( + &self, + run_id: &str, + cancel: &RunCancellation, + pin: &crate::runtime::agent_host::PinnedToolProgram, + ) -> Result<(), &'static str> { + let saved = match self.inner.persistence.as_ref() { + Some(p) => p + .managed_run_query_binding(run_id, cancel) + .map_err(|_| "plugin_graph_pin_required")?, + None => None, + }; + match (&pin.managed, saved) { + (Some(binding), Some(saved)) + if binding.owner == saved.owner + && binding.code.graph_id == pin.graph_id() + && binding.code.graph_ref_id == pin.ref_id() + && binding.admitted_branch == saved.workspace + && binding.admitted_epoch() == saved.epoch + && self + .handle(run_id) + .is_some_and(|handle| binding.cancel.same_root(&handle.cancel)) => + { + Ok(()) + } + (None, None) if self.inner.config.managed_workspace.is_none() => Ok(()), + _ => Err("plugin_graph_pin_required"), + } + } + + fn check_full_harness_pin( + &self, + run_id: &str, + cancel: &RunCancellation, + pin: &crate::runtime::agent_host::PinnedToolProgram, + ) -> Result<(), &'static str> { + cancel.ensure_open()?; + let persistence = self + .inner + .persistence + .as_ref() + .ok_or("plugin_graph_pin_required")?; + let control = crate::plugins::PluginControlStore::new(persistence); + if control + .graph_revocation(pin.graph_id()) + .map_err(|_| "plugin_graph_pin_required")? + .is_some() + { + return Err("plugin_graph_revoked"); + } + let requirement = control + .pin_requirement_for_run(run_id) + .map_err(|_| "plugin_graph_pin_required")? + .ok_or("plugin_graph_pin_required")?; + if requirement.graph_id != pin.graph_id() || requirement.ref_id != pin.ref_id() { + return Err("plugin_graph_pin_required"); + } + let receipt = control + .pin_for_run(run_id) + .map_err(|_| "plugin_graph_pin_required")?; + if receipt["pinned"] != true + || receipt["ref_id"] != pin.ref_id() + || receipt["graph_id"] != pin.graph_id() + { + return Err("plugin_graph_pin_required"); + } + let lock = control + .load_lock(pin.graph_id()) + .map_err(|_| "plugin_graph_pin_required")? + .lock; + let selection = serde_json::to_value(&pin.harness_selection) + .map_err(|_| "harness_policy_selection_invalid")?; + if lock.harness_selection != pin.harness_selection + || receipt["harness_selection"] != selection + { + return Err("harness_policy_selection_invalid"); + } + self.check_managed_pin_identity(run_id, cancel, pin)?; + if let Some(binding) = &pin.managed { + let deadline = self + .inner + .contexts + .lock() + .expect("contexts lock") + .get(run_id) + .and_then(|c| c.metadata["deadline_at_ms"].as_u64()) + .ok_or("plugin_graph_pin_required")?; + if binding.code.graph_id != pin.graph_id() + || binding.code.graph_ref_id != pin.ref_id() + || !control + .harness_binding_matches( + &binding.owner, + &binding.code, + &binding.admitted_branch, + binding.admitted_epoch(), + deadline, + ) + .map_err(|_| "plugin_graph_pin_required")? + { + return Err("plugin_graph_pin_required"); + } + } + let rows = persistence + .run_get(run_id) + .map_err(|_| "plugin_graph_pin_required")?; + let encoded = rows["rows"][0][4] + .as_str() + .ok_or("harness_policy_selection_invalid")?; + let outer: JsonValue = + serde_json::from_str(encoded).map_err(|_| "harness_policy_selection_invalid")?; + if outer["run_graph_selection"] + != json!({"graph_id":pin.graph_id(),"ref_id":pin.ref_id(),"harness_selection":selection}) + { + return Err("harness_policy_selection_invalid"); + } + let store = self.tool_store().ok_or("plugin_artifact_unavailable")?; + for package in &lock.packages { + store + .read_installed(&package.artifact_id) + .map_err(|_| "plugin_artifact_unavailable")?; + } + cancel.ensure_open() + } + pub(crate) fn refresh_provider_context( &self, run_id: &str, context: &JsonValue, cancellation: &RunCancellation, ) -> JsonValue { + if let Err(code) = self.check_harness_run(run_id, cancellation) { + let mut error = compaction_error(code, code); + error["storage_available"] = json!(true); + return error; + } if self.inner.persistence.is_none() { return json!({ "ok": true, "storage_available": false }); } @@ -1933,7 +2479,7 @@ impl AgentService { }) .unwrap_or_default(); if let Some(items) = compacted.as_array() { - messages.extend(items.iter().cloned()); + messages.extend(items.clone()); } json!({ "ok": true, "storage_available": true, "messages": messages }) } @@ -2820,6 +3366,14 @@ impl AgentService { self.inner.persistence.as_deref(), reserved, )?; + if let Some(binding) = self.handle(run_id).and_then(|handle| { + handle + .pinned_tool + .as_ref() + .and_then(|pin| pin.managed.clone()) + }) { + binding.provider_committed(&encoded_blocks); + } Ok(ProviderCommitOutcome::Inserted(ProviderCommit { message_id, envelope, @@ -3281,6 +3835,25 @@ impl AgentService { run_id: &str, handle: &Arc, ) -> Result { + if let Some(pin) = &handle.pinned_tool { + self.check_managed_pin_identity(run_id, &handle.cancel, pin) + .map_err(|code| invalid_context_metadata(run_id, code))?; + if handle + .native_disposition + .as_ref() + .is_some_and(|d| !pin.same_native_binding(d.pin())) + { + return Err(invalid_context_metadata( + run_id, + "plugin_graph_pin_required", + )); + } + } else if self.inner.config.managed_workspace.is_some() { + return Err(invalid_context_metadata( + run_id, + "plugin_graph_pin_required", + )); + } let context = self .run_context(run_id) .ok_or_else(|| RunContextError::Missing { @@ -3371,15 +3944,23 @@ impl AgentService { .map_err(|error| invalid_context_metadata(run_id, &error))?; let now = Instant::now(); let now_ms = timestamp(); - let deadline_ms = match handle.cancel.deadline_instant() { - Some(deadline) if deadline > now => now_ms.saturating_add( - u64::try_from(deadline.duration_since(now).as_millis()).unwrap_or(u64::MAX), - ), - Some(_) => now_ms, - None => now_ms.saturating_add( - u64::try_from(self.inner.config.run_timeout.as_millis()).unwrap_or(u64::MAX), - ), - }; + let deadline_ms = context + .metadata + .get("deadline_at_ms") + .and_then(|value| match value { + JsonValue::Number(number) => number.as_u64(), + JsonValue::String(text) => text.parse().ok(), + _ => None, + }) + .unwrap_or_else(|| match handle.cancel.deadline_instant() { + Some(deadline) if deadline > now => now_ms.saturating_add( + u64::try_from(deadline.duration_since(now).as_millis()).unwrap_or(u64::MAX), + ), + Some(_) => now_ms, + None => now_ms.saturating_add( + u64::try_from(self.inner.config.run_timeout.as_millis()).unwrap_or(u64::MAX), + ), + }); let lifecycle = CapabilityLifecycle::builder() .owner(capability_owner.clone()) .registry_identity(expected.to_string()) @@ -3793,6 +4374,63 @@ impl AgentService { Ok(()) } + /// Test fixture seam: publish the exact typed repository row into the live + /// transcript too; direct repository-only seeding leaves commit ordinals stale. + #[doc(hidden)] + pub fn append_context_fixture_message(&self, payload: &JsonValue) -> Result<(), String> { + let _serial = self.inner.commit_gate.lock(); + let persistence = self + .inner + .persistence + .as_ref() + .ok_or("storage unavailable")?; + persistence.message_append(payload).map_err(|e| e.message)?; + let session = payload["session_id"].as_str().ok_or("session missing")?; + let id = payload["id"].as_str().ok_or("id missing")?; + let data = persistence + .message_list(session, 0) + .map_err(|e| e.message)?; + let rows = crate::sqlite_storage_rows::sqlite_storage_rows(&data)?; + let row = rows + .iter() + .find(|r| r[0] == id) + .ok_or("fixture row missing")?; + let text = |i: usize| row[i].as_str().unwrap_or_default().to_string(); + let optional = |i: usize| { + row[i] + .as_str() + .filter(|s| !s.is_empty()) + .map(str::to_string) + }; + let content = serde_json::from_str(row[4].as_str().ok_or("fixture content missing")?) + .map_err(|e| e.to_string())?; + let message = SessionMessage { + id: text(0), + session_id: text(1), + role: text(3), + content: decode_message_content(&content), + created_at: row[13].as_u64().ok_or("fixture timestamp missing")?, + run_id: optional(11), + finish_reason: optional(12), + name: optional(5), + tool_call_id: optional(6), + parent_message_id: optional(7), + token_estimate: row[8].as_i64(), + metadata: serde_json::from_str(row[10].as_str().ok_or("fixture metadata missing")?) + .map_err(|e| e.to_string())?, + ordinal: row[2].as_i64(), + }; + self.inner + .store + .write() + .sessions + .get_mut(session) + .ok_or("fixture session missing")? + .messages + .push(message); + Ok(()) + } + /// Drops the live handle so `run_worker` must restore cancellation from /// frozen context metadata (restart seam). pub fn evict_run_handle(&self, run_id: &str) { @@ -3841,7 +4479,19 @@ impl AgentService { }) else { return Ok(None); }; - let cancel = RunCancellation::from_wall_deadline_ms(deadline_at_ms, timestamp()); + let now_ms = timestamp(); + let cancel = if status == "stopping" && now_ms >= deadline_at_ms { + // The original stop already won. An elapsed wall deadline needs + // no executable clock and must not replace that first reason. + RunCancellation::new() + } else { + RunCancellation::from_wall_deadline_ms(deadline_at_ms, now_ms) + }; + // A stopped reconstruction is cancellation-only. Do not prepare a new + // executable pin before restoring the original stop state. + if status == "stopping" { + cancel.request(CancellationReason::Requested); + } let prompt = context.coding_system_prompt.clone().unwrap_or_default(); // A persisted limits path is never a grant. Re-open only an originally // admitted trusted binding through the current RSS workspace policy. @@ -3867,10 +4517,77 @@ impl AgentService { } authority }); - let handle = Arc::new(RunHandle { - tool_cancel: cancel.token(), - cancel, - terminal_at: Mutex::new(None), + // An expired original run only needs a cancellation handle. Preparing + // executable code here would fail before the Native cancellation path, + // or require an unclocked fallback. Neither is needed to settle it. + let mut pinned_tool = if cancel.requested().is_some() || cancel.deadline_passed() { + None + } else { + self.restore_pinned_tool_inner(run_id, Some(&cancel))? + }; + if let Some(pin) = pinned_tool.as_mut() { + let persistence = self + .inner + .persistence + .as_ref() + .ok_or(AdmitError::PluginGraphPinRequired)?; + let saved = persistence + .managed_run_query_binding(run_id, &cancel) + .map_err(|_| AdmitError::PluginGraphPinRequired)?; + match saved { + Some(saved) => { + let runtime = self + .inner + .managed_effects + .read() + .clone() + .ok_or(AdmitError::PluginGraphPinRequired)?; + let authority = workspace_authority + .clone() + .ok_or(AdmitError::PluginGraphPinRequired)?; + let registry = self + .run_registry_snapshot(run_id) + .ok_or(AdmitError::PluginGraphPinRequired)?; + pin.restore_managed_binding( + &runtime, + authority, + &saved, + Arc::new(registry), + cancel.clone(), + ) + .map_err(|_| AdmitError::PluginGraphPinRequired)?; + let binding = pin + .managed + .as_ref() + .ok_or(AdmitError::PluginGraphPinRequired)?; + if !crate::plugins::PluginControlStore::new(persistence) + .harness_binding_matches( + &binding.owner, + &binding.code, + &binding.admitted_branch, + binding.admitted_epoch(), + deadline_at_ms, + ) + .map_err(|_| AdmitError::PluginGraphPinRequired)? + { + return Err(AdmitError::PluginGraphPinRequired); + } + } + None if self.inner.config.managed_workspace.is_some() => { + return Err(AdmitError::PluginGraphPinRequired); + } + None => {} + } + } + let native_disposition = pinned_tool + .as_ref() + .map(NativeRuntimeDisposition::persistent) + .transpose() + .map_err(AdmitError::HarnessPolicy)?; + let handle = Arc::new(RunHandle { + tool_cancel: cancel.token(), + cancel, + terminal_at: Mutex::new(None), permit: Mutex::new(None), terminal: AtomicBool::new(false), cancel_reason: Mutex::new(None), @@ -3886,15 +4603,42 @@ impl AgentService { legacy_workspace_root: None, coding_system_prompt: Arc::from(prompt), occupancy: AtomicBool::new(false), - pinned_tool: self.restore_pinned_tool(run_id)?, + restore_worker_started: None, + cleanup_outcome: Mutex::new(None), + pinned_tool, + native_disposition, }); + #[cfg(test)] + { + let observer = self + .inner + .restore_candidate_observer + .lock() + .unwrap() + .clone(); + if let Some(observer) = observer { + observer(run_id); + } + } let registered = Arc::clone( self.inner .runs .lock() .expect("runs lock") .entry(run_id.to_string()) - .or_insert_with(|| Arc::clone(&handle)), + .or_insert_with(|| { + // The runs lock keeps this handle invisible until its + // exact managed binding is registered. Losing candidates + // never replace the retained winner's weak registration. + if let Some(binding) = handle + .pinned_tool + .as_ref() + .and_then(|pin| pin.managed.as_ref()) + { + binding.runtime.publish_restored_binding(binding); + } + Arc::clone(&handle) + }), ); if status == "stopping" { registered.request_user_stop(); @@ -4695,6 +5439,18 @@ impl AgentService { .admission_rejected(AdmitRejectReason::Invalid); return Err(AdmitError::Invalid(message)); } + if self.unresolved_exact_match(&request, workspace_selector.as_deref()) { + return self + .resume_unresolved_admission(request, workspace_selector) + .await; + } + #[cfg(test)] + { + let barrier = self.inner.admission_overlap_barrier.lock().unwrap().clone(); + if let Some(barrier) = barrier { + barrier.wait().await; + } + } if let Some(model) = request.model.as_deref() && let Err(message) = validate_visible_name(model, "model", MAX_MODEL_NAME_BYTES) { @@ -4744,6 +5500,29 @@ impl AgentService { })? } + // Service-owned typed observation of the actual admission clock only. + #[cfg(test)] + pub(crate) fn available_admission_permits(&self) -> usize { + self.inner.capacity.available_permits() + } + + #[cfg(test)] + fn observe_original_proof_read(&self, op: &str) { + let hook = self.inner.original_proof_read_hook.lock().unwrap().clone(); + if let Some(hook) = hook { + let available = self.inner.commit_gate.try_lock().is_some(); + hook(op, available); + } + } + + #[cfg(test)] + fn observe_admission_clock(&self, point: AdmissionClockPoint, cancel: &RunCancellation) { + let observer = self.inner.admission_clock_observer.lock().unwrap().clone(); + if let Some(observer) = observer { + observer(point, cancel); + } + } + /// Observed original-deadline/cancellation state before any durable or /// memory linearization. The rejection follows the existing workspace-RSS /// taxonomy: generic `Invalid`, never `Persistence`, never with @@ -4768,6 +5547,27 @@ impl AgentService { allow_legacy_workspace_root: bool, capacity_permit: OwnedSemaphorePermit, ) -> Result { + self.admit_blocking_from( + request, + workspace_selector, + allow_legacy_workspace_root, + capacity_permit, + None, + ) + } + + fn admit_blocking_from( + &self, + request: AdmitRunRequest, + workspace_selector: Option, + allow_legacy_workspace_root: bool, + capacity_permit: OwnedSemaphorePermit, + restored: Option<&managed_checkpoints::ManagedRestoreAdmission>, + ) -> Result { + #[cfg(test)] + if let Some(hook) = self.inner.admission_commit_boundary.lock().unwrap().clone() { + hook(); + } // Optimistic two-phase admission: slow preparation (store snapshot // reads, RSS workspace admission, prompt observer + filesystem IO) // runs outside `commit_gate` so an unrelated admission, store writer, @@ -4777,6 +5577,19 @@ impl AgentService { // cancellation/deadline are reused across re-preparation attempts; // clocks are never reset and budgets never refreshed. let cancellation = RunCancellation::with_timeout(self.inner.config.run_timeout); + #[cfg(test)] + self.observe_admission_clock(AdmissionClockPoint::Created, &cancellation); + // Sample wall time before the remaining Native duration, conservatively + // projecting the original Instant once. Slow preparation and retries + // must consume this deadline, never create a later context clock. + let deadline_wall_ms = timestamp(); + let remaining = cancellation + .deadline_instant() + .ok_or_else(|| self.admission_deadline_rejection())? + .saturating_duration_since(Instant::now()); + let remaining_ms = u64::try_from(remaining.as_millis()) + .map_err(|_| self.admission_deadline_rejection())?; + let deadline_at_ms = deadline_wall_ms.saturating_add(remaining_ms); let run_id = Uuid::new_v4().to_string(); let now = timestamp(); let message_id = Uuid::new_v4().to_string(); @@ -4790,7 +5603,9 @@ impl AgentService { if Self::admission_cancelled_before_commit(&cancellation) { return Err(self.admission_deadline_rejection()); } - let registry = self.inner.tool_registry.read().snapshot(); + let registry = restored + .map(|origin| origin.registry.clone()) + .unwrap_or_else(|| self.inner.tool_registry.read().snapshot()); if registry.is_empty() { self.inner .metrics @@ -4817,17 +5632,21 @@ impl AgentService { .and_then(|record| store.runs.get(&record.run_id)) .map(|run| run.session_id.clone()); let has_idempotency_replay = replay_session_id.is_some(); - let session_id = match request.session_id.clone().or(replay_session_id) { - Some(session_id) => { - if !store.sessions.contains_key(&session_id) { - self.inner - .metrics - .admission_rejected(AdmitRejectReason::SessionNotFound); - return Err(AdmitError::SessionNotFound); + let session_id = if let Some(origin) = restored { + origin.session_id.clone() + } else { + match request.session_id.clone().or(replay_session_id) { + Some(session_id) => { + if !store.sessions.contains_key(&session_id) { + self.inner + .metrics + .admission_rejected(AdmitRejectReason::SessionNotFound); + return Err(AdmitError::SessionNotFound); + } + session_id } - session_id + None => Uuid::new_v4().to_string(), } - None => Uuid::new_v4().to_string(), }; let session_new = !store.sessions.contains_key(&session_id); let existing_workspace_binding = store @@ -4912,11 +5731,16 @@ impl AgentService { .admission_rejected(AdmitRejectReason::ParentNotFound); return Err(AdmitError::ParentNotFound); } - let provider_profile = self - .resolve_provider_profile(effective_provider.as_deref()) - .map_err(|error| { - AdmitError::Invalid(format!("invalid provider profile: {error}")) - })?; + let provider_profile = if let Some(origin) = restored { + ProviderProfile::new( + origin.execution_config.provider_profile.clone(), + serde_json::to_value(&origin.execution_config.provider_options) + .map_err(|_| AdmitError::Invalid("managed_state_unsupported".into()))?, + ) + } else { + self.resolve_provider_profile(effective_provider.as_deref()) + } + .map_err(|error| AdmitError::Invalid(format!("invalid provider profile: {error}")))?; let mut snapshot = RunAdmissionSnapshot { registry, provider_profile, @@ -4942,6 +5766,9 @@ impl AgentService { .get(&session_id) .map(|session| session.messages.clone()) .unwrap_or_default(); + if let Some(origin) = restored { + context_messages = origin.messages.clone(); + } context_messages.push(context_message.clone()); let context_input = ContextAdmissionInput { run_id: run_id.clone(), @@ -5019,32 +5846,216 @@ impl AgentService { .lock() .expect("prompt read observer lock") .clone(); + #[cfg(test)] + self.observe_admission_clock(AdmissionClockPoint::PromptReadEntered, &cancellation); if let Some(observer) = prompt_read_observer { observer(); } + // A slow prompt read consumes the same original admission clock. + // Refuse through the original precommit path before entering a + // separate graph preparation error category or performing more IO. + if Self::admission_cancelled_before_commit(&cancellation) { + #[cfg(test)] + self.observe_admission_clock( + AdmissionClockPoint::ExpiredAfterPrompt, + &cancellation, + ); + return Err(self.admission_deadline_rejection()); + } - let coding_system_prompt = build_coding_prompt( - &workspace_root, - &tool_descriptors, - &run_limits, - &date, - &platform, - &arch, - CodingPromptBudgets::default(), - ) - .map_err(|error| { - self.inner - .metrics - .admission_rejected(AdmitRejectReason::Invalid); - AdmitError::Invalid(error.to_string()) - })?; - let mut context = - self.make_admitted_context(&context_input, &snapshot, coding_system_prompt.clone()); + let pinned_workspace = workspace_binding + .as_ref() + .map(|binding| PathBuf::from(&binding.canonical_path)) + .or_else(|| legacy_workspace_root.clone()) + .unwrap_or_default(); + let requested_graph = match request.graph_id.as_deref() { + None => None, + Some(id) + if id.is_empty() + || id.len() > 128 + || id.contains('/') + || id.contains('\\') + || id.contains('\0') => + { + return Err(AdmitError::PluginGraphPinRequired); + } + Some(id) => Some(id.to_string()), + }; + let selected_branch = if let Some(branch_id) = request + .workspace_branch + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()) + { + if restored.is_some() { + return Err(AdmitError::Invalid("managed_source_conflict".into())); + } + let runtime = self + .inner + .managed_effects + .read() + .clone() + .ok_or_else(|| AdmitError::Invalid("managed_branch_missing".into()))?; + let authority = workspace_authority + .as_ref() + .ok_or_else(|| AdmitError::Invalid("managed_authority_required".into()))?; + Some( + runtime + .resolve_branch_selector( + authority, + ADMISSION_SESSION_PROFILE, + branch_id, + &cancellation, + ) + .map_err(AdmitError::Invalid)?, + ) + } else { + None + }; + if restored.is_some() && requested_graph.is_some() { + return Err(AdmitError::Invalid("managed_source_conflict".into())); + } + let explicit_stateless_intent = self.inner.stateless_tools.load(Ordering::Acquire); + if (requested_graph.is_some() || selected_branch.is_some()) + && (explicit_stateless_intent || self.inner.persistence.is_none()) + { + return Err(AdmitError::PluginGraphPinRequired); + } + let mut prepared_pin = + match (explicit_stateless_intent, self.inner.persistence.as_ref()) { + (false, Some(persistence)) => { + let store = self.tool_store().ok_or_else(|| { + AdmitError::Persistence( + "validated tool store is not installed".to_string(), + ) + })?; + Some(if let Some(origin) = restored { + crate::plugins::prepare_checkpoint_pin( + persistence, + store.as_ref(), + &origin.checkpoint, + &cancellation, + ) + .map_err(|error| self.map_pin_admit(error))? + } else { + let active = + self.ensure_active_tool_graph(store.as_ref(), persistence)?; + let checkpoint = selected_branch + .as_ref() + .and_then(|(_, checkpoint)| checkpoint.as_ref()); + let pin_checkpoint = match requested_graph.as_deref() { + None => checkpoint.is_some(), + Some(id) + if checkpoint.is_some_and(|checkpoint| { + checkpoint.graph.graph_id == id + }) => + { + true + } + Some(id) if id == active.graph_id => false, + Some(id) => { + return Err(self.reject_unselected_graph( + persistence, + store.as_ref(), + id, + )); + } + }; + if pin_checkpoint { + let checkpoint = + checkpoint.ok_or(AdmitError::PluginGraphPinRequired)?; + crate::plugins::prepare_checkpoint_pin( + persistence, + store.as_ref(), + checkpoint, + &cancellation, + ) + } else { + crate::plugins::prepare_production_pin( + persistence, + store.as_ref(), + &active, + &cancellation, + ) + } + .map_err(|error| self.map_pin_admit(error))? + }) + } + (true, _) => None, + (false, None) => return Err(AdmitError::PluginGraphPinRequired), + }; + let coding_system_prompt = if let Some(origin) = restored { + origin.check()?; + let budgets = CodingPromptBudgets::default(); + let guidance = origin + .branch + .frozen_guidance(budgets) + .map_err(AdmitError::Invalid)?; + let inputs = crate::prompt::BuildInputs { + workspace_root: &workspace_root.to_string_lossy(), + platform: &platform, + arch: &arch, + date: &date, + tools: &tool_descriptors, + max_turns: run_limits.max_turns, + max_tool_calls: run_limits.max_tool_calls, + max_tool_output_bytes: run_limits.max_tool_output_bytes, + guidance: &guidance, + budgets, + }; + crate::prompt::render_coding_prompt(&inputs) + .map_err(|e| AdmitError::Invalid(e.to_string()))? + } else { + build_coding_prompt( + &workspace_root, + &tool_descriptors, + &run_limits, + &date, + &platform, + &arch, + CodingPromptBudgets::default(), + ) + .map_err(|error| { + self.inner + .metrics + .admission_rejected(AdmitRejectReason::Invalid); + AdmitError::Invalid(error.to_string()) + })? + }; + let mut context = self.make_admitted_context( + &context_input, + &snapshot, + coding_system_prompt.clone(), + deadline_at_ms, + ); + if let Some(origin) = restored + && let Some(compaction) = origin.compaction.as_ref() + { + let summary: JsonValue = serde_json::from_str(&compaction.summary_json) + .map_err(|_| AdmitError::Invalid("managed_history_incomplete".into()))?; + let mut prefix = + vec![json!({"role":"user","content":[{"type":"text","text":summary["text"]}]})]; + prefix.extend( + context_input + .messages + .iter() + .filter(|message| { + message.ordinal.is_none_or(|ordinal| { + ordinal > compaction.retained_tail_ordinal as i64 + }) + }) + .map(|message| { + serde_json::to_value(message).expect("typed frozen message") + }), + ); + context.messages = json!(prefix); + } if let Some(binding) = workspace_binding.as_ref() { context.metadata["admitted_workspace_binding"] = serde_json::to_value(binding) .map_err(|error| AdmitError::Invalid(error.to_string()))?; } - let persisted_input = persisted_run_context_json(&context)?; + let graph_selection=prepared_pin.as_ref().map(|p|json!({"graph_id":p.graph_id,"ref_id":p.ref_id,"harness_selection":p.harness_selection()})); + let persisted_input = persisted_run_context_json(&context, graph_selection)?; let provider = effective_provider.clone().unwrap_or_default(); let idempotency_key = request.idempotency_key.clone().unwrap_or_default(); let estimate = estimate_admission_query_bytes(AdmissionSqliteCellLens { @@ -5083,31 +6094,6 @@ impl AgentService { AdmitError::Invalid(error.to_string()) })?; - let pinned_workspace = workspace_binding - .as_ref() - .map(|binding| PathBuf::from(&binding.canonical_path)) - .or_else(|| legacy_workspace_root.clone()) - .unwrap_or_default(); - let mut prepared_pin = match ( - self.inner.stateless_tools.load(Ordering::Acquire), - self.inner.persistence.as_ref(), - ) { - (false, Some(persistence)) => { - let store = self.tool_store().ok_or_else(|| { - AdmitError::Persistence("validated tool store is not installed".to_string()) - })?; - let active = self.ensure_active_tool_graph(store.as_ref(), persistence)?; - Some( - crate::plugins::prepare_production_pin( - persistence, - store.as_ref(), - &active, - ) - .map_err(|error| AdmitError::Persistence(error.to_string()))?, - ) - } - _ => None, - }; let mut admitted_pin = if prepared_pin.is_none() { Some(self.seal_stateless_tool( &run_id, @@ -5129,7 +6115,7 @@ impl AgentService { })?; Some( runtime - .prepare_run_binding( + .prepare_run_binding_from( authority, crate::effects::ManagedOwnerScope { profile: ADMISSION_SESSION_PROFILE.into(), @@ -5150,7 +6136,11 @@ impl AgentService { })?, Arc::new(snapshot.registry.clone()), ), - cancellation.clone(), + ( + cancellation.clone(), + restored.map(|origin| origin.branch.as_ref()), + selected_branch.as_ref().map(|(head, _)| head), + ), ) .map_err(AdmitError::Invalid)?, ) @@ -5196,20 +6186,104 @@ impl AgentService { "tool_registry_identity": snapshot.registry.identity(), "tool_workspace": pinned_workspace.display().to_string(), }); + if restored.is_none() + && let Some((_, Some(checkpoint))) = selected_branch.as_ref() + && prepared_pin + .as_ref() + .is_some_and(|pin| pin.graph_id == checkpoint.graph.graph_id) + { + payload["checkpoint_id"] = json!(checkpoint.checkpoint_id); + payload["checkpoint_graph_ref_id"] = + json!(checkpoint.graph.checkpoint_graph_ref_id); + } if let Some(binding) = &managed_binding { - let remaining = cancellation - .deadline_instant() - .ok_or_else(|| AdmitError::Invalid("managed original deadline missing".into()))? - .saturating_duration_since(Instant::now()); - payload["managed_binding_json"]=json!(json!({"record_json":json!({"session_id":session_id,"graph_ref_id":binding.code.graph_ref_id,"original_deadline_ms":timestamp().saturating_add(remaining.as_millis() as u64)}).to_string(),"prepared_json":"","workspace_json":"","projections_json":"","effect_id":"","receipt_id":"","commit_id":"","run_id":run_id,"call_id":"","store_id":binding.workspace.identity.store_id,"profile":ADMISSION_SESSION_PROFILE,"source_device":"","source_inode":"","workspace_id":binding.workspace.identity.workspace_id,"branch_id":binding.workspace.identity.branch_id,"now_ms":now,"max_events":self.inner.config.max_events_per_run,"cut":""}).to_string()); + let deadline_ms = deadline_at_ms; + let (binding_head_object_id, binding_head_version, binding_store_id) = + if let Some(origin) = restored { + ( + origin.branch.target.head_object_id.clone(), + origin.branch.target.head_version, + origin.branch.target.store_id.clone(), + ) + } else if let Some((head, _)) = selected_branch.as_ref() { + ( + head.head_object_id.clone(), + head.head_version, + head.store_id.clone(), + ) + } else { + let runtime = + self.inner.managed_effects.read().clone().ok_or_else(|| { + AdmitError::Invalid("managed_branch_missing".into()) + })?; + let head = runtime + .repository + .current_head(&binding.admitted_branch, &cancellation) + .map_err(AdmitError::Invalid)?; + (head.head_object_id, head.head_version, head.store_id) + }; + payload["managed_binding_json"]=json!(json!({"record_json":json!({"session_id":session_id,"graph_ref_id":binding.code.graph_ref_id,"epoch":binding.admitted_epoch(),"original_deadline_ms":deadline_ms,"head_object_id":binding_head_object_id,"head_version":binding_head_version,"store_id":binding_store_id}).to_string(),"prepared_json":"","workspace_json":"","projections_json":"","effect_id":"","receipt_id":"","commit_id":"","run_id":run_id,"call_id":"","store_id":binding.admitted_branch.store_id,"profile":ADMISSION_SESSION_PROFILE,"source_device":"","source_inode":"","workspace_id":binding.admitted_branch.workspace_id,"branch_id":binding.admitted_branch.branch_id,"now_ms":now,"max_events":self.inner.config.max_events_per_run,"cut":""}).to_string()); + if let Some(origin) = restored { + payload["restore_json"] = json!( + serde_json::to_string( + &origin.envelope( + &run_id, + deadline_ms, + crate::effects::journal::ManagedAdmissionPin { + harness_selection: prepared_pin + .as_ref() + .ok_or(AdmitError::PluginGraphPinRequired)? + .harness_selection() + .clone(), + graph_id: payload["tool_graph_id"] + .as_str() + .unwrap_or_default() + .into(), + ref_id: payload["tool_ref_id"] + .as_str() + .unwrap_or_default() + .into(), + generation: payload["tool_graph_generation"] + .as_u64() + .unwrap_or_default(), + run_id: run_id.clone(), + session_id: session_id.clone(), + registry_identity: snapshot.registry.identity().into(), + workspace: pinned_workspace.display().to_string(), + profile: ADMISSION_SESSION_PROFILE.into(), + pinned: true, + } + )? + ) + .map_err(|_| AdmitError::Invalid( + "managed_metadata_budget_exceeded".into() + ))? + ); + } } + if let Some(origin) = restored { + *origin.attempt.envelope.lock() = Some( + serde_json::from_str(payload["restore_json"].as_str().ok_or_else(|| { + AdmitError::Invalid("managed_metadata_budget_exceeded".into()) + })?) + .map_err(|_| AdmitError::Invalid("managed_metadata_budget_exceeded".into()))?, + ); + } // Commit phase: fresh revalidation then atomic durable + in-memory // publication while holding the gate. Unrelated global generation // drift alone never invalidates; only relevant prepared inputs // trigger a reprepare outside the gate within the original budget. { - let _admission_serial = self.inner.commit_gate.lock(); + #[cfg(test)] + if let Some(hook) = self.inner.admission_commit_boundary.lock().unwrap().clone() { + hook(); + } + let mut admission_gate = Some(self.inner.commit_gate.lock()); + let mut original_proof = OriginalProofInflight { + service: self, + run_id: None, + }; if self.inner.halting.load(Ordering::Acquire) { self.inner .metrics @@ -5338,7 +6412,7 @@ impl AgentService { } { let cur_registry_id = self.inner.tool_registry.read().identity().to_string(); - if cur_registry_id != snapshot.registry.identity() { + if restored.is_none() && cur_registry_id != snapshot.registry.identity() { continue; } let cur_base = self.inner.run_limits.read().clone(); @@ -5365,7 +6439,9 @@ impl AgentService { .map_err(|error| { AdmitError::Invalid(format!("invalid provider profile: {error}")) })?; - if cur_profile.to_json() != snapshot.provider_profile.to_json() { + if restored.is_none() + && cur_profile.to_json() != snapshot.provider_profile.to_json() + { continue; } let cur_date = self.inner.date_source.read().current_date(); @@ -5389,19 +6465,28 @@ impl AgentService { .admission_rejected(AdmitRejectReason::IdempotencyConflict); return Err(AdmitError::IdempotencyConflict); } - let store = self.inner.store.read(); - let (sid, status) = store - .runs - .get(&existing.run_id) - .map(|r| (r.session_id.clone(), r.status.clone())) - .unwrap_or_else(|| (String::new(), "unknown".to_string())); - self.restore_pinned_tool(&existing.run_id)?; - return Ok(AdmittedRun { - run_id: existing.run_id.clone(), - session_id: sid, - status, - replayed: true, - }); + let published = { + let store = self.inner.store.read(); + store.runs.get(&existing.run_id).map(|run| { + ( + run.session_id.clone(), + run.status.clone(), + existing.run_id.clone(), + ) + }) + }; + if let Some((sid, status, replayed_run_id)) = published { + self.restore_pinned_tool(&replayed_run_id)?; + return Ok(AdmittedRun { + run_id: replayed_run_id, + session_id: sid, + status, + replayed: true, + }); + } + // A cache hit without a memory run is not a successful replay. + // Durable proof below decides; an active unpublished commit + // must not become a 202. } if cur_run_exists { let store = self.inner.store.read(); @@ -5424,22 +6509,51 @@ impl AgentService { if Self::admission_cancelled_before_commit(&cancellation) { return Err(self.admission_deadline_rejection()); } + let mut retain_unresolved = false; let durable = match self.inner.persistence.as_ref() { Some(persistence) => { - let outcome = persistence.admission_create(&payload); + if let Some(origin) = restored { + origin.check()?; + } + if restored.is_none() + && let Some(binding) = &managed_binding + { + binding.check_admission().map_err(AdmitError::Invalid)?; + } + let outcome = if let Some(origin) = restored { + persistence.maintenance_submit( + &origin.attempt.claim, + &payload, + &origin.management_cancel, + ) + } else { + persistence.admission_create(&payload) + }; // Only relevant activation drift after a rolled-back // attempt may reprepare. A committed run, including a // response/publication failure, must never be retried // as a new admission. The original cancellation root // and absolute deadline remain outside this loop. if outcome.is_err() + && restored.is_none() + && outcome.as_ref().err().is_some_and(|error| { + !admission_receipt_unproven(error) + || admission_pin_guard_rolled_back(error) + }) && let Some(prepared) = prepared_pin.as_ref() { let active = crate::plugins::PluginControlStore::new(persistence) .activation() .map_err(|error| AdmitError::Persistence(error.to_string()))?; - if active.graph_id != prepared.graph_id - || active.generation != prepared.generation + let checkpoint_pin = + selected_branch.as_ref().is_some_and(|(_, checkpoint)| { + checkpoint.as_ref().is_some_and(|checkpoint| { + checkpoint.graph.graph_id == prepared.graph_id + }) + }); + if !checkpoint_pin + && (active.graph_id != prepared.graph_id + || active.generation != prepared.generation) { let existing = persistence .run_get(&run_id) @@ -5453,11 +6567,107 @@ impl AgentService { } } } + // A committed original whose live receipt was lost is not + // activation drift. Reconcile that same capture and publish + // it once; never reprepare the now-active graph. + let outcome = match outcome { + Err(error) + if restored.is_none() + && admission_receipt_unproven(&error) + && managed_binding.is_some() + && request.idempotency_key.is_some() + && effective_request_hash.is_some() => + { + let prepared = prepared_pin.as_ref().ok_or_else(|| { + AdmitError::Persistence( + "unpublished_original_admission_unresolved".into(), + ) + })?; + let selection = serde_json::to_value(prepared.harness_selection()) + .map_err(|_| { + AdmitError::Persistence( + "unpublished_original_admission_unresolved".into(), + ) + })?; + original_proof.arm( + payload + .get("run_id") + .and_then(JsonValue::as_str) + .unwrap_or(""), + ); + drop(admission_gate.take()); + let reconciled = self.reconcile_unpublished_original_admission( + UnpublishedOriginalProof { + persistence, + payload: &payload, + graph_id: &prepared.graph_id, + generation: prepared.generation, + ref_id: &prepared.ref_id, + harness_selection: &selection, + binding: managed_binding.as_deref(), + deadline_at_ms, + registry_identity: snapshot.registry.identity(), + workspace: &pinned_workspace, + request_selector: workspace_selector.as_deref(), + }, + &cancellation, + ); + admission_gate = Some(self.inner.commit_gate.lock()); + let _publish_gate = admission_gate.as_ref(); + match reconciled { + Ok(data) => Ok(data), + Err(AdmitError::Persistence(code)) + if code == "unpublished_original_admission_unresolved" => + { + retain_unresolved = true; + Err(error) + } + Err(AdmitError::Persistence(code)) + if code == "unpublished_original_admission_absent" => + { + let active = + crate::plugins::PluginControlStore::new(persistence) + .activation() + .map_err(|active_error| { + AdmitError::Persistence( + active_error.to_string(), + ) + })?; + if prepared_pin.as_ref().is_some_and(|prepared| { + let checkpoint_pin = selected_branch + .as_ref() + .is_some_and(|(_, checkpoint)| { + checkpoint.as_ref().is_some_and(|checkpoint| { + checkpoint.graph.graph_id + == prepared.graph_id + }) + }); + !checkpoint_pin + && (active.graph_id != prepared.graph_id + || active.generation != prepared.generation) + }) { + continue; + } + Err(error) + } + Err(error) => return Err(error), + } + } + other => other, + }; outcome.map_err(|error| { self.inner .metrics .admission_rejected(AdmitRejectReason::Persistence); match error.code.as_str() { + "managed_metadata_budget_exceeded" => { + AdmitError::Invalid("managed_metadata_budget_exceeded".into()) + } + "managed_submitted_unknown" => { + AdmitError::Persistence("managed_submitted_unknown".into()) + } + "plugin_graph_revoked" => AdmitError::PluginGraphRevoked, + "plugin_graph_pin_required" => AdmitError::PluginGraphPinRequired, "idempotency_key_conflict" => AdmitError::IdempotencyConflict, _ => AdmitError::Persistence(format!( "run admission could not be durably committed: {error}" @@ -5467,187 +6677,355 @@ impl AgentService { } None => Ok(JsonValue::Null), }; - let data = durable?; - if data.get("replayed") == Some(&JsonValue::Bool(true)) { - return self.finish_durable_replay(&data); - } - if admitted_pin.is_none() { - let prepared = prepared_pin.take().ok_or_else(|| { - AdmitError::Persistence("prepared tool pin was dropped".to_string()) + let durable = if retain_unresolved { + Err(AdmitError::Persistence( + "unpublished_original_admission_unresolved".into(), + )) + } else { + durable + }; + let unresolved_owner = if retain_unresolved { + let prepared = prepared_pin.as_ref().ok_or_else(|| { + AdmitError::Persistence("unpublished_original_admission_unresolved".into()) })?; - admitted_pin = Some( - crate::plugins::seal_production_receipt( - prepared, - &data, - snapshot.registry.identity().to_string(), - pinned_workspace.clone(), - ) - .map_err(|error| AdmitError::Persistence(error.to_string()))?, - ); - } - let mut admitted_pin = admitted_pin.take().ok_or_else(|| { - AdmitError::Persistence( - "tool pin was not sealed from the admission receipt".to_string(), - ) - })?; - admitted_pin.managed = managed_binding.clone(); - let mut store = self.inner.store.write(); - if store.runs.contains_key(&run_id) - && let Some(existing) = store.runs.get(&run_id) - { - self.restore_pinned_tool(&run_id)?; - return Ok(AdmittedRun { - run_id: run_id.clone(), - session_id: existing.session_id.clone(), - status: existing.status.clone(), - replayed: true, - }); - } - if let (Some(key), Some(hash)) = ( - request.idempotency_key.as_deref(), - effective_request_hash.as_deref(), - ) && let Some(existing) = store.idempotency.get(key) - { - if !self.workspace_replay_identity_matches( - existing, + let key = request.idempotency_key.clone().ok_or_else(|| { + AdmitError::Persistence("unpublished_original_admission_unresolved".into()) + })?; + let hash = effective_request_hash.clone().ok_or_else(|| { + AdmitError::Persistence("unpublished_original_admission_unresolved".into()) + })?; + let persistence_ptr = self + .inner + .persistence + .as_ref() + .map(|item| Arc::as_ptr(item) as usize) + .ok_or_else(|| { + AdmitError::Persistence( + "unpublished_original_admission_unresolved".into(), + ) + })?; + Some(UnresolvedAdmissionIdentity { + persistence: persistence_ptr, + key, hash, - replay_identity.request_selector, - &cancellation, - )? { - self.inner + selector: workspace_selector.clone(), + cancellation: cancellation.clone(), + proof: UnresolvedAdmissionProof { + payload: payload.clone(), + graph_id: prepared.graph_id.clone(), + generation: prepared.generation, + ref_id: prepared.ref_id.clone(), + harness_selection: serde_json::to_value(prepared.harness_selection()) + .map_err(|_| { + AdmitError::Persistence( + "unpublished_original_admission_unresolved".into(), + ) + })?, + binding: managed_binding.clone(), + deadline_at_ms, + registry_identity: snapshot.registry.identity().to_string(), + workspace: pinned_workspace.clone(), + request_selector: workspace_selector.clone(), + }, + }) + } else { + None + }; + let restored_messages = restored.map(|origin| origin.messages.clone()); + let restore_branch = restored.map(|origin| origin.branch.clone()); + let weak = Arc::downgrade(&self.inner); + let publication_selector = replay_identity.request_selector.map(str::to_owned); + let publish = move |data: JsonValue| -> Result { + let is_restore = restore_branch.is_some(); + let service = AgentService { + inner: weak.upgrade().ok_or(AdmitError::Halting)?, + }; + if data.get("replayed") == Some(&JsonValue::Bool(true)) { + return service.finish_durable_replay(&data); + } + if admitted_pin.is_none() { + let prepared = prepared_pin.take().ok_or_else(|| { + AdmitError::Persistence("prepared tool pin was dropped".to_string()) + })?; + admitted_pin = Some( + crate::plugins::seal_production_receipt( + prepared, + &data, + snapshot.registry.identity().to_string(), + pinned_workspace.clone(), + ) + .map_err(|error| AdmitError::Persistence(error.to_string()))?, + ); + } + let mut admitted_pin = admitted_pin.take().ok_or_else(|| { + AdmitError::Persistence( + "tool pin was not sealed from the admission receipt".to_string(), + ) + })?; + admitted_pin.managed = managed_binding.clone(); + let native_disposition = if explicit_stateless_intent { + NativeRuntimeDisposition::ExplicitStateless(NativeStatelessAdmissionProof { + embedded: admitted_pin.clone(), + }) + } else { + NativeRuntimeDisposition::persistent(&admitted_pin) + .map_err(AdmitError::HarnessPolicy)? + }; + let mut store = service.inner.store.write(); + if store.runs.contains_key(&run_id) + && let Some(existing) = store.runs.get(&run_id) + { + service.restore_pinned_tool(&run_id)?; + return Ok(AdmittedRun { + run_id: run_id.clone(), + session_id: existing.session_id.clone(), + status: existing.status.clone(), + replayed: true, + }); + } + if let (Some(key), Some(hash)) = ( + request.idempotency_key.as_deref(), + effective_request_hash.as_deref(), + ) && let Some(existing) = store.idempotency.get(key) + { + if !service.workspace_replay_identity_matches( + existing, + hash, + publication_selector.as_deref(), + &cancellation, + )? { + service + .inner + .metrics + .admission_rejected(AdmitRejectReason::IdempotencyConflict); + return Err(AdmitError::IdempotencyConflict); + } + let published = store.runs.get(&existing.run_id).map(|run| { + ( + run.session_id.clone(), + run.status.clone(), + existing.run_id.clone(), + ) + }); + if let Some((sid, status, replayed_run_id)) = published { + service.restore_pinned_tool(&replayed_run_id)?; + return Ok(AdmittedRun { + run_id: replayed_run_id, + session_id: sid, + status, + replayed: true, + }); + } + // Stale cache without a memory run must not report 202. + // Continue and publish the captured original. + } + if !session_new && !store.sessions.contains_key(&session_id) { + service + .inner .metrics - .admission_rejected(AdmitRejectReason::IdempotencyConflict); - return Err(AdmitError::IdempotencyConflict); + .admission_rejected(AdmitRejectReason::SessionNotFound); + return Err(AdmitError::SessionNotFound); } - let (sid, status) = store - .runs - .get(&existing.run_id) - .map(|r| (r.session_id.clone(), r.status.clone())) - .unwrap_or_else(|| (String::new(), "unknown".to_string())); - self.restore_pinned_tool(&existing.run_id)?; - return Ok(AdmittedRun { - run_id: existing.run_id.clone(), - session_id: sid, - status, - replayed: true, + if let Some(parent_run_id) = request.parent_run_id.as_deref() + && !store.runs.contains_key(parent_run_id) + { + service + .inner + .metrics + .admission_rejected(AdmitRejectReason::ParentNotFound); + return Err(AdmitError::ParentNotFound); + } + service + .inner + .store_generation + .fetch_add(1, Ordering::Release); + if session_new { + store.sessions.insert( + session_id.clone(), + SessionRecord { + view: new_session_view.expect("new session view was prepared"), + messages: restored_messages.unwrap_or_default(), + }, + ); + } + let session = store + .sessions + .get_mut(&session_id) + .expect("admission session exists after commit"); + if let Some(model) = request.model.clone() { + session.view.model = model; + } + if request.provider.is_some() { + session.view.provider = request.provider.clone(); + } + if request.instructions.is_some() { + session.view.system_prompt = request.instructions.clone(); + } + if let Some(binding) = workspace_binding.clone() { + session.view.workspace = Some(binding); + } + session.messages.push(context_message); + session.view.message_count = session.messages.len(); + session.view.updated_at = now; + + let (sender, _) = + tokio::sync::broadcast::channel(service.inner.config.broadcast_capacity); + let started_event = GatewayEvent { + event_id: event_id.clone(), + seq: 1, + event: "run.started".to_string(), + run_id: run_id.clone(), + timestamp: now, + data: json!({"status": "running", "session_id": session_id}), + }; + let run = RunRecord { + run_id: run_id.clone(), + session_id: session_id.clone(), + parent_run_id: request.parent_run_id.clone(), + status: "started".to_string(), + events: vec![started_event], + sender: Some(sender), + cancel_requested: Arc::new(AtomicBool::new(false)), + }; + store.runs.insert(run_id.clone(), run); + if let (Some(key), Some(hash)) = ( + request.idempotency_key.as_deref(), + effective_request_hash.as_deref(), + ) { + store.idempotency.insert( + key.to_string(), + IdempotencyRecord { + request_hash: hash.to_string(), + run_id: run_id.clone(), + workspace_identity_json: workspace_identity_json.clone(), + }, + ); + } + + let permit = permit_opt + .take() + .expect("admission capacity permit retained until commit"); + let handle = Arc::new(RunHandle { + tool_cancel: cancellation.token(), + cancel: cancellation.clone(), + terminal_at: Mutex::new(None), + permit: Mutex::new(Some(permit)), + terminal: AtomicBool::new(false), + cancel_reason: Mutex::new(None), + subscribers: Mutex::new(SubscriberState { + count: 0, + notified: false, + }), + disconnect_policy: service.inner.config.client_disconnect_policy, + started_at: Instant::now(), + capability_host: Mutex::new(CapabilityHostPhase::Empty), + capability_host_cv: Condvar::new(), + workspace_authority, + legacy_workspace_root, + coding_system_prompt: Arc::from(coding_system_prompt), + occupancy: AtomicBool::new(false), + restore_worker_started: is_restore.then(|| AtomicBool::new(false)), + cleanup_outcome: Mutex::new(None), + pinned_tool: Some(admitted_pin), + native_disposition: Some(native_disposition), }); - } - if !session_new && !store.sessions.contains_key(&session_id) { - self.inner - .metrics - .admission_rejected(AdmitRejectReason::SessionNotFound); - return Err(AdmitError::SessionNotFound); - } - if let Some(parent_run_id) = request.parent_run_id.as_deref() - && !store.runs.contains_key(parent_run_id) - { - self.inner - .metrics - .admission_rejected(AdmitRejectReason::ParentNotFound); - return Err(AdmitError::ParentNotFound); - } - self.inner.store_generation.fetch_add(1, Ordering::Release); - if session_new { - store.sessions.insert( - session_id.clone(), - SessionRecord { - view: new_session_view.expect("new session view was prepared"), - messages: Vec::new(), - }, - ); - } - let session = store - .sessions - .get_mut(&session_id) - .expect("admission session exists after commit"); - if let Some(model) = request.model.clone() { - session.view.model = model; - } - if request.provider.is_some() { - session.view.provider = request.provider.clone(); - } - if request.instructions.is_some() { - session.view.system_prompt = request.instructions.clone(); - } - if let Some(binding) = workspace_binding.clone() { - session.view.workspace = Some(binding); - } - session.messages.push(context_message); - session.view.message_count = session.messages.len(); - session.view.updated_at = now; - - let (sender, _) = - tokio::sync::broadcast::channel(self.inner.config.broadcast_capacity); - let started_event = GatewayEvent { - event_id: event_id.clone(), - seq: 1, - event: "run.started".to_string(), - run_id: run_id.clone(), - timestamp: now, - data: json!({"status": "running", "session_id": session_id}), - }; - let run = RunRecord { - run_id: run_id.clone(), - session_id: session_id.clone(), - parent_run_id: request.parent_run_id.clone(), - status: "started".to_string(), - events: vec![started_event], - sender: Some(sender), - cancel_requested: Arc::new(AtomicBool::new(false)), + service + .inner + .runs + .lock() + .expect("runs lock") + .insert(run_id.clone(), handle); + service.cache_context(context, Some(snapshot.registry)); + service.inner.metrics.admission_accepted(); + service.inner.metrics.active_runs_inc(); + Ok(AdmittedRun { + run_id: run_id.clone(), + session_id, + status: "started".to_string(), + replayed: false, + }) }; - store.runs.insert(run_id.clone(), run); - if let (Some(key), Some(hash)) = ( - request.idempotency_key.as_deref(), - effective_request_hash.as_deref(), - ) { - store.idempotency.insert( - key.to_string(), - IdempotencyRecord { - request_hash: hash.to_string(), - run_id: run_id.clone(), - workspace_identity_json: workspace_identity_json.clone(), - }, - ); + match durable { + Ok(data) => { + if data.get("replayed") == Some(&JsonValue::Bool(true)) + && !self.replay_has_memory_run(&data) + { + let waiting = data + .get("run") + .and_then(|run| { + crate::sqlite_storage_rows::sqlite_storage_first_row(run) + }) + .and_then(|row| { + admission_run_str(&row, ADMISSION_RUN_COL_ID).map(str::to_owned) + }); + if let Some(run_id) = waiting.as_deref() + && self + .inner + .original_proof_inflight + .lock() + .expect("original proof inflight") + .contains(run_id) + { + drop(admission_gate.take()); + let budget = Duration::from_secs(30); + let guard = self + .inner + .original_proof_inflight + .lock() + .expect("original proof inflight"); + let _ = self.inner.original_proof_cv.wait_timeout_while( + guard, + budget, + |inflight| inflight.contains(run_id), + ); + admission_gate = Some(self.inner.commit_gate.lock()); + let _replay_gate = admission_gate.as_ref(); + } + } + let admitted = publish(data)?; + if let Some(origin) = restored { + origin.attempt.published.store(true, Ordering::Release); + } + return Ok(admitted); + } + Err(AdmitError::Persistence(code)) + if code == "unpublished_original_admission_unresolved" => + { + let identity = unresolved_owner + .ok_or_else(|| AdmitError::Persistence(code.clone()))?; + #[cfg(test)] + if let Some(hook) = self + .inner + .unpublished_install_window + .lock() + .unwrap() + .clone() + { + hook(); + } + self.install_unresolved_admission(identity, Box::new(publish)); + return Err(AdmitError::Persistence(code)); + } + Err(AdmitError::Persistence(code)) if code == "managed_submitted_unknown" => { + let origin = + restored.ok_or_else(|| AdmitError::Persistence(code.clone()))?; + let envelope: crate::effects::journal::ManagedRestoreExecutionEnvelope = + serde_json::from_str( + payload["restore_json"] + .as_str() + .ok_or_else(|| AdmitError::Persistence(code.clone()))?, + ) + .map_err(|_| AdmitError::Persistence(code.clone()))?; + *origin.attempt.restore.lock() = + Some(managed_checkpoints::ManagedRestorePublication { + envelope, + branch: origin.branch.clone(), + publish: Box::new(publish), + }); + return Err(AdmitError::Persistence(code)); + } + Err(error) => return Err(error), } - - let permit = permit_opt - .take() - .expect("admission capacity permit retained until commit"); - let handle = Arc::new(RunHandle { - tool_cancel: cancellation.token(), - cancel: cancellation.clone(), - terminal_at: Mutex::new(None), - permit: Mutex::new(Some(permit)), - terminal: AtomicBool::new(false), - cancel_reason: Mutex::new(None), - subscribers: Mutex::new(SubscriberState { - count: 0, - notified: false, - }), - disconnect_policy: self.inner.config.client_disconnect_policy, - started_at: Instant::now(), - capability_host: Mutex::new(CapabilityHostPhase::Empty), - capability_host_cv: Condvar::new(), - workspace_authority, - legacy_workspace_root, - coding_system_prompt: Arc::from(coding_system_prompt), - occupancy: AtomicBool::new(false), - pinned_tool: Some(admitted_pin), - }); - self.inner - .runs - .lock() - .expect("runs lock") - .insert(run_id.clone(), handle); - self.cache_context(context, Some(snapshot.registry)); - self.inner.metrics.admission_accepted(); - self.inner.metrics.active_runs_inc(); - return Ok(AdmittedRun { - run_id: run_id.clone(), - session_id, - status: "started".to_string(), - replayed: false, - }); } } // Unreachable: the loop only exits via commit, replay, or a typed @@ -5797,6 +7175,427 @@ impl AgentService { }) } + fn unresolved_exact_match(&self, request: &AdmitRunRequest, selector: Option<&str>) -> bool { + let (Some(key), Some(hash)) = ( + request.idempotency_key.as_deref(), + request.idempotency_hash.as_deref(), + ) else { + return false; + }; + let Some(persistence) = self.inner.persistence.as_ref() else { + return false; + }; + let ptr = Arc::as_ptr(persistence) as usize; + self.inner + .unresolved_admissions + .lock() + .expect("unresolved admissions") + .iter() + .any(|owner| { + owner.persistence == ptr + && owner.key == key + && owner.hash == hash + && owner.selector.as_deref() == selector + }) + } + + async fn resume_unresolved_admission( + &self, + request: AdmitRunRequest, + workspace_selector: Option, + ) -> Result { + let service = self.clone(); + tokio::task::spawn_blocking(move || { + service.resume_unresolved_blocking(request, workspace_selector) + }) + .await + .map_err(|error| { + self.inner + .metrics + .admission_rejected(AdmitRejectReason::Persistence); + AdmitError::Persistence(format!("admission worker failed: {error}")) + })? + } + + fn resume_unresolved_blocking( + &self, + request: AdmitRunRequest, + workspace_selector: Option, + ) -> Result { + let mut gate = Some(self.inner.commit_gate.lock()); + if self.inner.halting.load(Ordering::Acquire) { + self.inner + .metrics + .admission_rejected(AdmitRejectReason::Halting); + return Err(AdmitError::Halting); + } + let Some(persistence) = self.inner.persistence.clone() else { + return Err(AdmitError::Persistence( + "unpublished_original_admission_unresolved".into(), + )); + }; + let ptr = Arc::as_ptr(&persistence) as usize; + let key = request.idempotency_key.clone().unwrap_or_default(); + let hash = request.idempotency_hash.clone().unwrap_or_default(); + let proof = { + let owners = self + .inner + .unresolved_admissions + .lock() + .expect("unresolved admissions"); + owners + .iter() + .find(|owner| { + owner.persistence == ptr + && owner.key == key + && owner.hash == hash + && owner.selector.as_deref() == workspace_selector.as_deref() + }) + .map(|owner| (owner.proof.clone(), owner.cancellation.clone())) + }; + let Some((proof, original)) = proof else { + drop(gate); + return self.replay_published_original(&key, &hash, workspace_selector.as_deref()); + }; + drop(gate.take()); + let reconciled = self.reconcile_owned_original(&proof, &persistence, &original); + gate = Some(self.inner.commit_gate.lock()); + match reconciled { + Ok(data) => { + let mut data = data; + let memory_published = self.replay_has_memory_run(&data); + if !memory_published && let Some(object) = data.as_object_mut() { + // The durable row exists, but publication did not. The + // captured closure must seal the original pin. Leaving + // replayed=true would skip that and report a false 202. + object.insert("replayed".to_string(), JsonValue::Bool(false)); + } + let owner = { + let mut owners = self + .inner + .unresolved_admissions + .lock() + .expect("unresolved admissions"); + let index = owners.iter().position(|owner| { + owner.persistence == ptr + && owner.key == key + && owner.hash == hash + && owner.selector.as_deref() == workspace_selector.as_deref() + }); + let Some(index) = index else { + drop(owners); + drop(gate); + return self.replay_published_original( + &key, + &hash, + workspace_selector.as_deref(), + ); + }; + owners.remove(index) + }; + (owner.publish)(data) + } + Err(AdmitError::Persistence(code)) + if code == "unpublished_original_admission_absent" => + { + self.discard_unresolved_admission(&key, &hash); + Err(AdmitError::Persistence( + "run admission could not be durably committed: unpublished_original_admission_absent".into(), + )) + } + Err(error) => Err(error), + } + } + + fn replay_published_original( + &self, + key: &str, + hash: &str, + selector: Option<&str>, + ) -> Result { + let existing = { + let store = self.inner.store.read(); + store.idempotency.get(key).cloned() + }; + let Some(existing) = existing else { + return Err(AdmitError::Persistence( + "unpublished_original_admission_unresolved".into(), + )); + }; + if existing.request_hash != hash { + self.inner + .metrics + .admission_rejected(AdmitRejectReason::IdempotencyConflict); + return Err(AdmitError::IdempotencyConflict); + } + let handle = self + .inner + .runs + .lock() + .expect("runs lock") + .get(&existing.run_id) + .cloned(); + let Some(handle) = handle else { + return Err(AdmitError::Persistence( + "unpublished_original_admission_unresolved".into(), + )); + }; + if !self.workspace_replay_identity_matches(&existing, hash, selector, &handle.cancel)? { + self.inner + .metrics + .admission_rejected(AdmitRejectReason::IdempotencyConflict); + return Err(AdmitError::IdempotencyConflict); + } + self.restore_pinned_tool(&existing.run_id)?; + let store = self.inner.store.read(); + let run = store.runs.get(&existing.run_id); + Ok(AdmittedRun { + run_id: existing.run_id, + session_id: run.map(|item| item.session_id.clone()).unwrap_or_default(), + status: run + .map(|item| item.status.clone()) + .unwrap_or_else(|| "started".to_string()), + replayed: true, + }) + } + + fn reconcile_owned_original( + &self, + proof: &UnresolvedAdmissionProof, + persistence: &GatewayPersistence, + original: &RunCancellation, + ) -> Result { + self.reconcile_unpublished_original_admission( + UnpublishedOriginalProof { + persistence, + payload: &proof.payload, + graph_id: &proof.graph_id, + generation: proof.generation, + ref_id: &proof.ref_id, + harness_selection: &proof.harness_selection, + binding: proof.binding.as_deref(), + deadline_at_ms: proof.deadline_at_ms, + registry_identity: &proof.registry_identity, + workspace: &proof.workspace, + request_selector: proof.request_selector.as_deref(), + }, + original, + ) + } + + fn install_unresolved_admission( + &self, + identity: UnresolvedAdmissionIdentity, + publish: Box Result + Send>, + ) { + let mut owners = self + .inner + .unresolved_admissions + .lock() + .expect("unresolved admissions"); + if owners + .iter() + .any(|owner| owner.persistence == identity.persistence && owner.key == identity.key) + { + return; + } + owners.push(UnresolvedAdmissionOwner { + persistence: identity.persistence, + key: identity.key, + hash: identity.hash, + selector: identity.selector, + cancellation: identity.cancellation, + proof: identity.proof, + publish, + }); + } + + fn replay_has_memory_run(&self, data: &JsonValue) -> bool { + let Some(run_row) = crate::sqlite_storage_rows::sqlite_storage_first_row( + data.get("run").unwrap_or(&JsonValue::Null), + ) else { + return false; + }; + let run_id = admission_run_str(&run_row, ADMISSION_RUN_COL_ID).unwrap_or(""); + if run_id.is_empty() { + return false; + } + self.inner.store.read().runs.contains_key(run_id) + } + + fn discard_unresolved_admission(&self, key: &str, hash: &str) { + let mut owners = self + .inner + .unresolved_admissions + .lock() + .expect("unresolved admissions"); + if let Some(index) = owners + .iter() + .position(|owner| owner.key == key && owner.hash == hash) + { + owners.remove(index); + } + } + + /// Proves a response-lost `admission.create` still committed this attempt's + /// original graph, then returns a first-publication receipt. The durable + /// lookup's `replayed` flag is not Native publication authority and must + /// not select `finish_durable_replay` while memory publication is missing. + fn reconcile_unpublished_original_admission( + &self, + proof: UnpublishedOriginalProof<'_>, + original: &RunCancellation, + ) -> Result { + let UnpublishedOriginalProof { + persistence, + payload, + graph_id, + generation, + ref_id, + harness_selection, + binding, + deadline_at_ms, + registry_identity, + workspace, + request_selector, + } = proof; + let closed = |code: &str| AdmitError::Persistence(code.to_string()); + fn text<'a>(value: &'a JsonValue, key: &str) -> Option<&'a str> { + value + .get(key) + .and_then(JsonValue::as_str) + .filter(|item| !item.is_empty()) + } + let scope = text(payload, "idempotency_scope").unwrap_or(""); + let key = text(payload, "idempotency_key").unwrap_or(""); + let request_hash = text(payload, "request_hash").unwrap_or(""); + let run_id = text(payload, "run_id").unwrap_or(""); + let session_id = text(payload, "session_id").unwrap_or(""); + let input_json = text(payload, "input_json").unwrap_or(""); + if scope != ADMISSION_IDEMPOTENCY_SCOPE + || key.is_empty() + || request_hash.is_empty() + || run_id.is_empty() + || session_id.is_empty() + || input_json.is_empty() + || text(payload, "profile") != Some(ADMISSION_SESSION_PROFILE) + { + return Err(closed("unpublished_original_admission_unresolved")); + } + let budget = GatewayPersistence::original_proof_budget(original); + #[cfg(test)] + self.observe_original_proof_read("admission.replay"); + let mut data = persistence + .admission_replay_for_proof(scope, key, request_hash, request_selector, budget) + .map_err(|error| { + if error.code == "idempotency_key_conflict" { + closed("unpublished_original_admission_mismatch") + } else { + closed("unpublished_original_admission_unresolved") + } + })?; + if data.get("conflict") == Some(&JsonValue::Bool(true)) { + return Err(closed("unpublished_original_admission_mismatch")); + } + if data.get("replayed") != Some(&JsonValue::Bool(true)) { + return Err(closed("unpublished_original_admission_absent")); + } + let pin = data + .get("pin") + .ok_or_else(|| closed("unpublished_original_admission_unresolved"))?; + let pin_selection = pin + .get("harness_selection") + .ok_or_else(|| closed("unpublished_original_admission_mismatch"))?; + let pin_workspace = pin + .get("workspace") + .and_then(JsonValue::as_str) + .map(std::path::PathBuf::from) + .ok_or_else(|| closed("unpublished_original_admission_mismatch"))?; + if text(pin, "graph_id") != Some(graph_id) + || text(pin, "ref_id") != Some(ref_id) + || pin.get("generation").and_then(JsonValue::as_u64) != Some(generation) + || pin_selection != harness_selection + || text(pin, "run_id") != Some(run_id) + || text(pin, "session_id") != Some(session_id) + || text(pin, "profile") != Some(ADMISSION_SESSION_PROFILE) + || text(pin, "registry_identity") != Some(registry_identity) + || pin_workspace != workspace + || pin.get("pinned") != Some(&JsonValue::Bool(true)) + { + return Err(closed("unpublished_original_admission_mismatch")); + } + #[cfg(test)] + self.observe_original_proof_read("run.get"); + let run = persistence + .run_get_for_proof(run_id, budget) + .map_err(|_| closed("unpublished_original_admission_unresolved"))?; + let rows = crate::sqlite_storage_rows::sqlite_storage_rows(&run) + .map_err(|_| closed("unpublished_original_admission_unresolved"))?; + let [row] = rows.as_slice() else { + return Err(closed("unpublished_original_admission_mismatch")); + }; + if admission_run_str(row, ADMISSION_RUN_COL_ID) != Some(run_id) + || admission_run_str(row, ADMISSION_RUN_COL_SESSION_ID) != Some(session_id) + || admission_run_str(row, ADMISSION_RUN_COL_INPUT_JSON) != Some(input_json) + || admission_run_str(row, ADMISSION_RUN_COL_PROVIDER) != text(payload, "provider") + || admission_run_str(row, ADMISSION_RUN_COL_MODEL) != text(payload, "model") + || admission_run_str(row, ADMISSION_RUN_COL_SCRIPT_HASH) != Some(registry_identity) + { + return Err(closed("unpublished_original_admission_mismatch")); + } + let control = crate::plugins::PluginControlStore::new(persistence); + #[cfg(test)] + self.observe_original_proof_read("plugin.pin_for_owner"); + let stored_pin = control + .pin_for_run_for_proof(run_id, budget) + .map_err(|_| closed("unpublished_original_admission_unresolved"))?; + if stored_pin.get("graph_id").and_then(JsonValue::as_str) != Some(graph_id) + || stored_pin.get("ref_id").and_then(JsonValue::as_str) != Some(ref_id) + || stored_pin.get("harness_selection") != Some(harness_selection) + || stored_pin.get("pinned") != Some(&JsonValue::Bool(true)) + { + return Err(closed("unpublished_original_admission_mismatch")); + } + #[cfg(test)] + self.observe_original_proof_read("plugin.pin_requirement_get"); + let required = control + .pin_requirement_for_run_for_proof(run_id, budget) + .map_err(|_| closed("unpublished_original_admission_unresolved"))? + .ok_or_else(|| closed("unpublished_original_admission_mismatch"))?; + if required.run_id != run_id || required.graph_id != graph_id || required.ref_id != ref_id { + return Err(closed("unpublished_original_admission_mismatch")); + } + let Some(binding) = binding else { + return Err(closed("unpublished_original_admission_mismatch")); + }; + if binding.owner.run_id != run_id + || binding.owner.session_id != session_id + || binding.owner.profile != ADMISSION_SESSION_PROFILE + || binding.code.graph_id != graph_id + || binding.code.graph_ref_id != ref_id + || binding.code.registry_identity != registry_identity + { + return Err(closed("unpublished_original_admission_mismatch")); + } + #[cfg(test)] + self.observe_original_proof_read("plugin.harness_binding_matches"); + if !control + .harness_binding_matches_for_proof( + &binding.owner, + &binding.code, + &binding.admitted_branch, + binding.admitted_epoch(), + deadline_at_ms, + budget, + ) + .map_err(|_| closed("unpublished_original_admission_unresolved"))? + { + return Err(closed("unpublished_original_admission_mismatch")); + } + data["replayed"] = JsonValue::Bool(false); + Ok(data) + } + fn finish_durable_replay(&self, data: &JsonValue) -> Result { let run_row = crate::sqlite_storage_rows::sqlite_storage_first_row( data.get("run").unwrap_or(&JsonValue::Null), @@ -5814,14 +7613,22 @@ impl AgentService { .unwrap_or("unknown") .to_string(); self.restore_pinned_tool(&replayed_run_id)?; - let store = self.inner.store.write(); - if let Some(run) = store.runs.get(&replayed_run_id) { - return Ok(AdmittedRun { - run_id: replayed_run_id, + let published = { + let store = self.inner.store.write(); + store.runs.get(&replayed_run_id).map(|run| AdmittedRun { + run_id: replayed_run_id.clone(), session_id: run.session_id.clone(), status: run.status.clone(), replayed: true, - }); + }) + }; + if let Some(admitted) = published { + return Ok(admitted); + } + if !durable_admission_status_is_terminal(&replayed_status) { + return Err(AdmitError::Persistence( + "unpublished_original_admission_unresolved".into(), + )); } Ok(AdmittedRun { run_id: replayed_run_id, @@ -5970,7 +7777,37 @@ impl AgentService { /// Stops the owned service/storage workers after tool workers have exited. /// The caller must finish or cancel/join its `run_worker` tasks first. pub async fn shutdown(&self) -> Result<(), String> { + #[cfg(test)] + if let Some(hook) = self.inner.shutdown_entered_hook.lock().unwrap().clone() { + hook(); + } + #[cfg(test)] + self.inner + .shutdown_waiting_for_gate + .store(true, Ordering::Release); + let admission_gate = self.inner.commit_gate.lock(); + #[cfg(test)] + self.inner + .shutdown_waiting_for_gate + .store(false, Ordering::Release); + let unresolved = !self + .inner + .unresolved_admissions + .lock() + .expect("unresolved admissions") + .is_empty(); + let inflight = !self + .inner + .original_proof_inflight + .lock() + .expect("original proof inflight") + .is_empty(); + if unresolved || inflight { + drop(admission_gate); + return Err("unresolved original admission remains".into()); + } self.stop_admission(); + drop(admission_gate); if self .inner .runs @@ -6017,6 +7854,13 @@ impl AgentService { return; }; handle.terminal.store(true, Ordering::Release); + if let Some(binding) = handle + .pinned_tool + .as_ref() + .and_then(|pin| pin.managed.as_ref()) + { + binding.terminal_boundary(); + } let now = Instant::now(); let mut terminal_at = handle.terminal_at.lock().expect("terminal lock"); if terminal_at.is_none() { @@ -6151,6 +7995,9 @@ impl AgentService { let Some(_occupancy) = try_occupy_run(&handle) else { return; }; + if handle.terminal.load(Ordering::Acquire) { + return; + } let session_id = { let store = self.inner.store.read(); let Some(run) = store.runs.get(&run_id) else { @@ -6277,7 +8124,10 @@ impl AgentService { artifacts, control_hook: None, pinned_tool: handle.pinned_tool.clone(), - allow_stateless_pin: self.inner.stateless_tools.load(Ordering::Acquire), + allow_stateless_pin: matches!( + handle.native_disposition, + Some(NativeRuntimeDisposition::ExplicitStateless(_)) + ), }; // One bounded delivery path: the worker blocks on this channel // when the delivery task is busy, which pauses invocation polling @@ -6313,7 +8163,16 @@ impl AgentService { return; } }; + #[cfg(test)] + let worker_probe = self + .inner + .worker_probe + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take(); let mut worker = tokio::task::spawn_blocking(move || { + #[cfg(test)] + let _worker_probe = worker_probe.map(|probe| probe.install()); runner.with_host(host).run_with_context_and_events( context, &mut sink, @@ -6892,6 +8751,7 @@ impl AgentService { admission: &ContextAdmissionInput, snapshot: &RunAdmissionSnapshot, coding_system_prompt: String, + deadline_at_ms: u64, ) -> RunContext { let provider_options = snapshot.provider_profile.options().clone(); let tool_schemas = snapshot.registry.schemas(); @@ -6931,12 +8791,10 @@ impl AgentService { JsonValue::String(admission.message_id.clone()), ); let created_at_ms = timestamp(); - let timeout_ms = - u64::try_from(self.inner.config.run_timeout.as_millis()).unwrap_or(u64::MAX); metadata.insert("created_at_ms".to_string(), JsonValue::from(created_at_ms)); metadata.insert( "deadline_at_ms".to_string(), - JsonValue::from(created_at_ms.saturating_add(timeout_ms)), + JsonValue::from(deadline_at_ms), ); RunContext { run_id: admission.run_id.clone(), @@ -7181,13 +9039,17 @@ fn admission_run_str(row: &[JsonValue], index: usize) -> Option<&str> { row.get(index).and_then(JsonValue::as_str) } -fn persisted_run_context_json(context: &RunContext) -> Result { +fn persisted_run_context_json( + context: &RunContext, + selection: Option, +) -> Result { verify_context_metadata(context).map_err(admit_context_error)?; let mut snapshot = context.clone(); snapshot.messages = JsonValue::Array(Vec::new()); let envelope = canonicalize_json_value(&json!({ "schema_version": RUN_CONTEXT_METADATA_VERSION, RUN_CONTEXT_STORAGE_KEY: snapshot, + "run_graph_selection": selection, })); let serialized = serde_json::to_string(&envelope).map_err(|error| { AdmitError::Invalid(format!("run context serialization failed: {error}")) @@ -7207,10 +9069,37 @@ fn compaction_error(code: &str, message: &str) -> JsonValue { }) } +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct NativeCommittedSummary { + count: u64, + source_start_ordinal: i64, + source_end_ordinal: i64, + retained_tail_ordinal: i64, + text: String, + truncated: bool, +} +struct DurableContextSnapshot { + generation: i64, + history: Vec, + previous_summary: String, + #[cfg_attr(not(test), allow(dead_code))] + rows: Vec<(JsonValue, String)>, + #[cfg_attr(not(test), allow(dead_code))] + summary: Option, +} fn load_durable_compaction_snapshot( persistence: &GatewayPersistence, session_id: &str, ) -> Result<(i64, Vec, String), String> { + let s = load_durable_context_snapshot(persistence, session_id, false)?; + Ok((s.generation, s.history, s.previous_summary)) +} +fn load_durable_context_snapshot( + persistence: &GatewayPersistence, + session_id: &str, + strict: bool, +) -> Result { let snapshot = persistence .compaction_context_snapshot(session_id) .map_err(|error| format!("read compaction context snapshot: {error}"))?; @@ -7222,6 +9111,8 @@ fn load_durable_compaction_snapshot( .ok_or_else(|| "compaction session generation is missing".to_string())?; let mut history = Vec::new(); + let mut native_rows = Vec::new(); + let mut committed_summary = None; let mut after_ordinal = 0_i64; let mut page = snapshot.messages; loop { @@ -7265,6 +9156,18 @@ fn load_durable_compaction_snapshot( } } } + if strict { + if !matches!(row[9].as_i64(), Some(0 | 1)) { + return Err("context_policy_candidate_invalid".into()); + } + native_rows.push(( + message.clone(), + row[11] + .as_str() + .ok_or("context_policy_candidate_invalid")? + .to_string(), + )); + } history.push(message); after_ordinal = ordinal; } @@ -7292,6 +9195,51 @@ fn load_durable_compaction_snapshot( if let Some(summary_json) = row.get(7).and_then(JsonValue::as_str) && let Ok(summary) = serde_json::from_str::(summary_json) { + if strict { + let proof: NativeCommittedSummary = serde_json::from_value(summary.clone()) + .map_err(|_| "context_policy_candidate_invalid")?; + if row + .first() + .and_then(JsonValue::as_str) + .is_none_or(str::is_empty) + || row + .get(2) + .and_then(JsonValue::as_str) + .is_none_or(str::is_empty) + || row.get(1).and_then(JsonValue::as_str) != Some(session_id) + || generation < 2 + || row.get(3).and_then(JsonValue::as_i64) != Some(generation) + || row.get(4).and_then(JsonValue::as_i64) + != Some(proof.source_start_ordinal) + || row.get(5).and_then(JsonValue::as_i64) != Some(proof.source_end_ordinal) + || row.get(6).and_then(JsonValue::as_i64) + != Some(proof.retained_tail_ordinal) + || proof.source_start_ordinal < 1 + || proof.source_end_ordinal < proof.source_start_ordinal + || proof.source_end_ordinal != proof.retained_tail_ordinal + || proof.text.is_empty() + || proof.count + != u64::try_from( + proof.source_end_ordinal - proof.source_start_ordinal + 1, + ) + .map_err(|_| "context_policy_candidate_invalid")? + || native_rows.last().is_none_or(|(row, _)| { + row["ordinal"] + .as_i64() + .is_none_or(|n| n < proof.retained_tail_ordinal) + }) + || native_rows.iter().any(|(row, _)| { + row["ordinal"].as_i64().is_none_or(|n| { + row["compacted"].as_i64() + != Some(i64::from(n <= proof.retained_tail_ordinal)) + }) + }) + { + return Err("context_policy_candidate_invalid".into()); + } + let _producer_truncated = proof.truncated; + committed_summary = Some(summary.clone()); + } previous_summary = summary .get("text") .and_then(JsonValue::as_str) @@ -7301,7 +9249,19 @@ fn load_durable_compaction_snapshot( break; } } - Ok((generation, history, previous_summary)) + if strict + && committed_summary.is_none() + && (generation != 1 || native_rows.iter().any(|(row, _)| row["compacted"] == 1)) + { + return Err("context_policy_candidate_invalid".into()); + } + Ok(DurableContextSnapshot { + generation, + history, + previous_summary, + rows: native_rows, + summary: committed_summary, + }) } #[cfg(test)] @@ -7362,6 +9322,52 @@ mod bounded_context_snapshot_tests { .unwrap(); } + #[test] + fn t14_committed_summary_requires_exact_native_range() { + for case in ["valid", "missing_range", "wrong_range", "invalid_json"] { + let (_root, p) = fixture(); + append(&p, 1); + compaction_run(&p); + let mut summary = json!({"count":1,"source_start_ordinal":1,"source_end_ordinal":1,"retained_tail_ordinal":1,"text":"PUBLIC_COMMITTED_SUMMARY","truncated":false}); + if case == "missing_range" { + summary + .as_object_mut() + .unwrap() + .remove("source_end_ordinal"); + } + if case == "wrong_range" { + summary["source_end_ordinal"] = json!(2); + summary["retained_tail_ordinal"] = json!(2); + } + let encoded = if case == "invalid_json" { + "invalid-json".into() + } else { + summary.to_string() + }; + start(&p, 2, &encoded); + commit(&p, 2); + assert_eq!( + load_durable_context_snapshot(&p, "session", true).is_ok(), + case == "valid", + "committed Native row and summary payload must agree: {case}" + ); + p.shutdown(); + } + } + + #[test] + fn t14_pending_plan_cannot_mint_compacted_input_or_summary_provenance() { + let (_root, p) = fixture(); + append(&p, 1); + compaction_run(&p); + let summary = json!({"count":1,"source_start_ordinal":1,"source_end_ordinal":1,"retained_tail_ordinal":1,"text":"UNCOMMITTED_SUMMARY","truncated":false}); + start(&p, 2, &summary.to_string()); + let snapshot = load_durable_context_snapshot(&p, "session", true).unwrap(); + assert!(snapshot.summary.is_none()); + assert_eq!(snapshot.rows[0].0["compacted"], 0); + p.shutdown(); + } + #[test] fn context_snapshot_reads_fresh_generation_messages_and_committed_summary() { let (root, p) = fixture(); @@ -8001,6 +10007,8 @@ fn map_event_commit_error(error: EventCommitError) -> LifecycleError { EventCommitError::Terminal => LifecycleError::InactiveRun, EventCommitError::Cancelled => LifecycleError::Cancelled, EventCommitError::MissingParent => LifecycleError::MissingParent, + EventCommitError::PluginGraphRevoked => LifecycleError::PluginGraphRevoked, + EventCommitError::PluginGraphPinRequired => LifecycleError::PluginGraphPinRequired, EventCommitError::PersistFailed(message) => LifecycleError::ResultCommitFailed(message), EventCommitError::Corrupt(message) => LifecycleError::ResultCommitFailed(message), } @@ -8672,7 +10680,11 @@ fn persist_and_apply( } Ok(()) } - Err(error) => Err(EventCommitError::PersistFailed(error.to_string())), + Err(error) => Err(match error.code.as_str() { + "plugin_graph_revoked" => EventCommitError::PluginGraphRevoked, + "plugin_graph_pin_required" => EventCommitError::PluginGraphPinRequired, + _ => EventCommitError::PersistFailed(error.to_string()), + }), } } @@ -8719,7 +10731,11 @@ fn persist_and_apply_pair( } Ok(()) } - Err(error) => Err(EventCommitError::PersistFailed(error.to_string())), + Err(error) => Err(match error.code.as_str() { + "plugin_graph_revoked" => EventCommitError::PluginGraphRevoked, + "plugin_graph_pin_required" => EventCommitError::PluginGraphPinRequired, + _ => EventCommitError::PersistFailed(error.to_string()), + }), } } @@ -9106,9 +11122,9 @@ impl AgentService { } } -/// Outcome of the RSS worker: a completed value, a typed cancellation, or a -/// failure string. No string matching drives control flow; the variants are -/// decided from typed run outcomes. +/// Outcome of the RSS worker. Plain failures stay a string. A structured +/// policy failure carries an exact whitelist static code plus the original +/// message. Substring matching never selects a code. enum WorkerOutcome { Completed(VmValue), Cancelled(&'static str), @@ -10352,6 +12368,8 @@ mod f3_session_maintenance_tests { platform: "f3-maintenance-replay".to_string(), idempotency_key: Some("f3-maintenance-replay-key".to_string()), idempotency_hash: Some("fnv64:0123456789abcdef".to_string()), + graph_id: None, + workspace_branch: None, ..AdmitRunRequest::default() }; let original = service @@ -11371,6 +13389,8 @@ mod f3_session_maintenance_tests { platform: "f3-durable-only-replay".to_string(), idempotency_key: Some(REPLAY_KEY.to_string()), idempotency_hash: Some("fnv64:fedcba9876543210".to_string()), + graph_id: None, + workspace_branch: None, ..AdmitRunRequest::default() }; let original = service @@ -11389,6 +13409,8 @@ mod f3_session_maintenance_tests { platform: "f3-cached-replay".to_string(), idempotency_key: Some(CACHED_KEY.to_string()), idempotency_hash: Some("fnv64:0011223344556677".to_string()), + graph_id: None, + workspace_branch: None, ..AdmitRunRequest::default() }; let cached_original = service @@ -11513,6 +13535,8 @@ mod f3_session_maintenance_tests { platform: "f3-fresh-unmatched".to_string(), idempotency_key: Some("f3-fresh-unmatched-key".to_string()), idempotency_hash: Some("fnv64:abcdef0123456789".to_string()), + graph_id: None, + workspace_branch: None, ..AdmitRunRequest::default() }) .await; @@ -11520,6 +13544,8 @@ mod f3_session_maintenance_tests { let hash_conflict = service .admit(AdmitRunRequest { idempotency_hash: Some("fnv64:0000000000000000".to_string()), + graph_id: None, + workspace_branch: None, ..replay_request.clone() }) .await; diff --git a/src/service/managed_branches.rs b/src/service/managed_branches.rs new file mode 100644 index 0000000..7c0e84e --- /dev/null +++ b/src/service/managed_branches.rs @@ -0,0 +1,225 @@ +//! Non-destructive managed branch creation. Does not retire or close the source. + +use super::*; +use crate::effects::journal::{ + ManagedBranchCreateRequest, ManagedBranchReceipt, ManagedSavedBranchReceipt, ManagedSourceCas, +}; + +fn failure(code: &'static str) -> String { + code.into() +} + +impl AgentService { + pub async fn create_managed_branch( + &self, + request: ManagedBranchCreateRequest, + ) -> Result { + let service = self.clone(); + tokio::task::spawn_blocking(move || service.create_managed_branch_blocking(request)) + .await + .map_err(|_| failure("managed_storage_unavailable"))? + } + + fn create_managed_branch_blocking( + &self, + request: ManagedBranchCreateRequest, + ) -> Result { + let config = self + .inner + .config + .managed_workspace + .as_ref() + .ok_or("managed_authority_required")?; + if self.inner.config.bearer_token.is_none() || !config.maintenance_policy.branch { + return Err(failure("managed_management_denied")); + } + crate::effects::broker::validate_id("ws", &request.workspace_id) + .map_err(|_| failure("managed_authority_required"))?; + crate::effects::broker::validate_id("br", &request.source_branch_id) + .map_err(|_| failure("managed_branch_missing"))?; + if Uuid::parse_str(&request.request_id).is_err() { + return Err(failure("managed_request_conflict")); + } + let cancellation = RunCancellation::with_timeout(Duration::from_secs(5)); + let runtime = self + .inner + .managed_effects + .read() + .clone() + .ok_or("managed_authority_required")?; + let source_root = config + .source_root + .to_str() + .ok_or("managed_authority_required")?; + let (_, authority) = self + .resolve_workspace_admission(Some(source_root), None, &cancellation, false) + .map_err(|_| failure("managed_authority_required"))?; + let authority = authority.ok_or("managed_authority_required")?; + let workspace = runtime + .authorized_workspace( + &authority, + ADMISSION_SESSION_PROFILE, + &request.workspace_id, + &cancellation, + ) + .map_err(|_| failure("managed_authority_required"))?; + let proof = serde_json::to_string(&request) + .map_err(|_| failure("managed_metadata_budget_exceeded"))?; + if let Some(saved) = runtime + .repository + .branch_receipt(&request.workspace_id, &request.request_id, &cancellation) + .map_err(|_| failure("managed_storage_unavailable"))? + { + return replayed_branch(&runtime, &workspace.identity, &proof, saved); + } + let mut source_identity = workspace.identity.clone(); + source_identity.branch_id = request.source_branch_id.clone(); + let head = runtime + .repository + .head(&source_identity, &cancellation) + .map_err(|error| { + if error.contains("missing") { + failure("managed_branch_missing") + } else { + failure("managed_storage_unavailable") + } + })?; + if head.workspace_id != request.workspace_id { + return Err(failure("managed_authority_required")); + } + if head.process_lineage_tainted { + return Err(failure("managed_process_lineage_unsupported")); + } + let source = ManagedSourceCas { + store_id: head.store_id.clone(), + workspace_id: head.workspace_id.clone(), + branch_id: head.branch_id.clone(), + epoch: head.epoch, + head_object_id: head.head_object_id.clone(), + head_version: head.head_version, + }; + if request + .expected_source + .as_ref() + .is_some_and(|expected| expected != &source) + { + return Err(failure("managed_source_conflict")); + } + let (head_object_id, checkpoint_id) = + if let Some(checkpoint_id) = request.checkpoint_id.clone() { + let lookup = runtime + .repository + .checkpoint_get(&request.workspace_id, &checkpoint_id, &cancellation) + .map_err(|_| failure("managed_checkpoint_missing"))?; + let checkpoint = lookup.checkpoint; + if checkpoint.workspace_id != request.workspace_id + || checkpoint.source_branch_id != request.source_branch_id + { + return Err(failure("managed_source_conflict")); + } + runtime + .verify_tree(&checkpoint.frozen_root.object_id) + .map_err(|_| failure("managed_artifact_unavailable"))?; + (checkpoint.frozen_root.object_id, Some(checkpoint_id)) + } else { + if request.expected_source.is_none() { + return Err(failure("managed_source_conflict")); + } + if runtime.source_has_active_writer(&request.source_branch_id) { + return Err(failure("managed_checkpoint_busy")); + } + runtime + .verify_tree(&source.head_object_id) + .map_err(|_| failure("managed_artifact_unavailable"))?; + (source.head_object_id.clone(), None) + }; + let epoch = source + .epoch + .checked_add(1) + .filter(|epoch| *epoch <= i64::MAX as u64) + .ok_or_else(|| failure("managed_source_conflict"))?; + let mut branch_identity = source_identity.clone(); + branch_identity.branch_id = crate::effects::broker::fresh("br"); + let branch = ManagedSourceCas { + store_id: source.store_id.clone(), + workspace_id: source.workspace_id.clone(), + branch_id: branch_identity.branch_id.clone(), + epoch, + head_object_id: head_object_id.clone(), + head_version: 1, + }; + let receipt = ManagedBranchReceipt { + request_id: request.request_id.clone(), + checkpoint_id: checkpoint_id.clone(), + source: source.clone(), + branch: branch.clone(), + replayed: false, + }; + let origin = json!({ + "kind": if checkpoint_id.is_some() { "checkpoint" } else { "head" }, + "workspace_id": source.workspace_id, + "source_branch_id": source.branch_id, + "checkpoint_id": checkpoint_id, + "head_object_id": head_object_id, + "source_epoch": source.epoch, + }); + let document = json!({ + "source": source, + "branch": branch, + "checkpoint_id": checkpoint_id, + "request_id": request.request_id, + "proof": proof, + "receipt": receipt, + "origin": origin, + }); + let committed = runtime + .repository + .branch_submit(&document, &cancellation) + .map_err(|error| { + if error == "managed_source_conflict" || error == "managed_metadata_budget_exceeded" + { + error + } else { + failure("managed_storage_unavailable") + } + })?; + let replayed = committed.branch.branch_id != branch.branch_id; + let mut remembered = source_identity; + remembered.branch_id = committed.branch.branch_id.clone(); + remembered.store_id = committed.branch.store_id.clone(); + runtime + .remember_branch(&remembered, committed.branch.epoch) + .map_err(|_| failure("managed_storage_unavailable"))?; + Ok(ManagedBranchReceipt { + replayed, + ..committed + }) + } +} + +fn replayed_branch( + runtime: &crate::effects::broker::ManagedEffectRuntime, + workspace: &crate::effects::journal::ManagedWorkspaceIdentity, + proof: &str, + saved: ManagedSavedBranchReceipt, +) -> Result { + if saved.proof != proof { + return Err(failure("managed_request_conflict")); + } + if saved.origin.get("workspace_id").and_then(JsonValue::as_str) + != Some(workspace.workspace_id.as_str()) + || saved.receipt.branch.workspace_id != workspace.workspace_id + { + return Err(failure("managed_authority_required")); + } + let mut identity = workspace.clone(); + identity.branch_id = saved.receipt.branch.branch_id.clone(); + identity.store_id = saved.receipt.branch.store_id.clone(); + runtime + .remember_branch(&identity, saved.receipt.branch.epoch) + .map_err(|_| failure("managed_storage_unavailable"))?; + Ok(ManagedBranchReceipt { + replayed: true, + ..saved.receipt + }) +} diff --git a/src/service/managed_checkpoints.rs b/src/service/managed_checkpoints.rs new file mode 100644 index 0000000..9ffaa83 --- /dev/null +++ b/src/service/managed_checkpoints.rs @@ -0,0 +1,1155 @@ +//! Managed checkpoint coordination; public records never carry Native authority. + +use super::*; +use crate::effects::journal::*; + +fn failure(code: &'static str) -> String { + code.into() +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct FrozenSummary { + count: u64, + source_start_ordinal: u64, + source_end_ordinal: u64, + retained_tail_ordinal: u64, + text: String, + truncated: bool, +} + +fn validate_frozen_compaction( + messages: &[ManagedFrozenMessage], + compaction: Option<&ManagedFrozenCompaction>, + generation: u64, +) -> Result<(), String> { + let boundary = if let Some(compaction) = compaction { + let summary: FrozenSummary = serde_json::from_str(&compaction.summary_json) + .map_err(|_| "managed_history_incomplete")?; + if compaction.generation != generation + || compaction.source_start_ordinal == 0 + || compaction.source_start_ordinal > compaction.source_end_ordinal + || compaction.source_end_ordinal != compaction.retained_tail_ordinal + || summary.source_start_ordinal != compaction.source_start_ordinal + || summary.source_end_ordinal != compaction.source_end_ordinal + || summary.retained_tail_ordinal != compaction.retained_tail_ordinal + || summary.count == 0 + || summary.text.is_empty() + || compaction.source_end_ordinal > messages.last().map_or(0, |m| m.ordinal) + { + return Err("managed_history_incomplete".into()); + } + // A committed extractive summary may already record its producer's truncation. + // Checkpoint capture never truncates either that summary or its retained tail. + let _producer_truncated = summary.truncated; + compaction.retained_tail_ordinal + } else { + if generation != 1 { + return Err("managed_history_incomplete".into()); + } + 0 + }; + for (index, message) in messages.iter().enumerate() { + if message.ordinal != index as u64 + 1 + || message.compacted != (message.ordinal <= boundary) + || message.parent_message_id.as_ref().is_some_and(|parent| { + !parent.is_empty() + && !messages.iter().any(|candidate| { + candidate.id == *parent && candidate.ordinal < message.ordinal + }) + }) + { + return Err("managed_history_incomplete".into()); + } + } + Ok(()) +} + +pub(super) fn validate_captured_inventory( + installed: &crate::plugins::InstalledPackage, + code: &ManagedCodeIdentity, +) -> Result<(), String> { + let embedding = code.package_id == crate::runtime::native_tools::EMBEDDING_PACKAGE_ID; + let trust_ok = match installed.trust { + crate::plugins::PackageTrust::EmbeddingOwnedBuiltin => embedding, + crate::plugins::PackageTrust::AcceptedPublisher => { + !embedding && !code.package_id.is_empty() + } + crate::plugins::PackageTrust::DevUnsigned => false, + }; + if installed.manifest.id != code.package_id + || !trust_ok + || installed.manifest.state.is_some() + || !installed.manifest.dependencies.is_empty() + || installed.manifest.effects.entries.values().any(|entry| { + !entry.delegated.is_empty() + || entry.direct.iter().any(|effect| { + !matches!( + effect.kind.as_str(), + "clock.read" | "process.spawn" | "workspace.fs.read" | "workspace.fs.write" + ) + }) + }) + { + return Err(failure("managed_state_unsupported")); + } + Ok(()) +} + +pub(super) struct ManagedMaintenanceAttempt { + pub(super) claim: Arc, + _authority: Arc, + _original_cancellation: RunCancellation, + checkpoint: ParkingMutex< + Option<( + crate::effects::broker::ManagedBranchFreeze, + ManagedCheckpointCommitInput, + )>, + >, + pub(super) restore: ParkingMutex>, + pub(super) envelope: ParkingMutex>, + pub(super) published: AtomicBool, + branch: ParkingMutex>>, +} +pub(super) struct ManagedRestorePublication { + pub(super) envelope: ManagedRestoreExecutionEnvelope, + pub(super) branch: Arc, + pub(super) publish: Box Result + Send>, +} +fn public_failure(code: String, request_id: String) -> ManagedMaintenanceFailure { + if code == "managed_submitted_unknown" { + ManagedMaintenanceFailure::SubmittedUnknown { + receipt_lookup_id: request_id.clone(), + request_id, + } + } else { + ManagedMaintenanceFailure::Rejected { code } + } +} + +pub(super) struct ManagedRestoreAdmission { + pub(super) request: ManagedRestoreAndAdmitRequest, + pub(super) checkpoint: ManagedCheckpointRef, + pub(super) session_id: String, + pub(super) messages: Vec, + pub(super) compaction: Option, + pub(super) execution_config: ManagedFrozenExecutionConfig, + pub(super) registry: ToolRegistrySnapshot, + pub(super) branch: Arc, + pub(super) attempt: Arc, + pub(super) authority: Arc, + pub(super) management_cancel: RunCancellation, +} + +impl ManagedRestoreAdmission { + pub(super) fn envelope( + &self, + run_id: &str, + deadline_ms: u64, + admission_pin: ManagedAdmissionPin, + ) -> Result { + let mut after = self.request.expected_source.clone(); + after.epoch = after + .epoch + .checked_add(1) + .ok_or_else(|| AdmitError::Invalid("managed_source_conflict".into()))?; + after.head_version = after + .head_version + .checked_add(1) + .filter(|version| *version <= i64::MAX as u64) + .ok_or_else(|| AdmitError::Invalid("managed_source_conflict".into()))?; + Ok(ManagedRestoreExecutionEnvelope { + admission_pin, + request: self.request.clone(), + original_request_proof: serde_json::to_string(&self.request) + .map_err(|_| AdmitError::Invalid("managed_metadata_budget_exceeded".into()))?, + history_root_id: self.checkpoint.history.history_root_id.clone(), + result: ManagedRestoreAndAdmitResult { + disposition: ManagedRestoreDisposition::RestoredAndAdmitted, + receipt_id: self.request.request_id.clone(), + checkpoint_id: self.checkpoint.checkpoint_id.clone(), + source_before: self.request.expected_source.clone(), + source_after: after, + new_branch: self.branch.target.clone(), + new_session_id: self.session_id.clone(), + new_run_id: run_id.into(), + new_run_deadline_at_ms: deadline_ms, + graph: self.checkpoint.graph.clone(), + publication: ManagedCheckpointPublication::ManagedOnly, + external_effects_not_reverted: self + .checkpoint + .external_boundary + .not_reverted + .clone(), + replayed: false, + }, + }) + } + pub(super) fn check(&self) -> Result<(), AdmitError> { + self.authority + .check() + .map_err(|_| AdmitError::Invalid("managed_authority_required".into()))?; + if self.management_cancel.deadline_passed() || self.management_cancel.requested().is_some() + { + return Err(AdmitError::Invalid("managed_deadline_elapsed".into())); + } + Ok(()) + } +} + +impl AgentService { + pub(crate) fn start_managed_restore_worker(self: &Arc, run_id: &str) -> bool { + let Some(handle) = self.handle(run_id) else { + return false; + }; + if handle.terminal.load(Ordering::Acquire) { + return false; + } + let Some(started) = handle.restore_worker_started.as_ref() else { + return false; + }; + if started + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return false; + } + let service = self.clone(); + let run_id = run_id.to_owned(); + tokio::spawn(async move { + service.run_worker(run_id, String::new()).await; + }); + true + } + pub(crate) fn managed_provider_enter( + &self, + run_id: &str, + ) -> Result, String> { + self.handle(run_id) + .and_then(|handle| { + handle + .pinned_tool + .as_ref() + .and_then(|pin| pin.managed.clone()) + }) + .map(|binding| binding.provider_enter()) + .transpose() + } + pub async fn restore_managed_checkpoint_and_admit( + &self, + request: ManagedRestoreAndAdmitRequest, + ) -> Result { + let service = self.clone(); + let request_id = request.request_id.clone(); + tokio::task::spawn_blocking(move || service.restore_managed_checkpoint_blocking(request)) + .await + .map_err(|_| { + public_failure(failure("managed_storage_unavailable"), request_id.clone()) + })? + .map_err(|code| public_failure(code, request_id)) + } + + fn restore_managed_checkpoint_blocking( + &self, + request: ManagedRestoreAndAdmitRequest, + ) -> Result { + validate_maintenance_value::( + &serde_json::to_value(&request).map_err(|_| "managed_metadata_budget_exceeded")?, + )?; + let config = self + .inner + .config + .managed_workspace + .as_ref() + .ok_or("managed_authority_required")?; + if self.inner.config.bearer_token.is_none() || !config.maintenance_policy.restore { + return Err(failure("managed_management_denied")); + } + if !request.cancel_active_source + || request.input.text.len() > 65536 + || request.expected_source.workspace_id != request.workspace_id + || request.expected_source.branch_id != request.source_branch_id + || Uuid::parse_str(&request.request_id).is_err() + || Uuid::parse_str(&request.checkpoint_id).is_err() + { + return Err(failure("managed_source_conflict")); + } + let cancellation = RunCancellation::with_timeout(Duration::from_secs(5)); + let (_, authority) = self + .resolve_workspace_admission(config.source_root.to_str(), None, &cancellation, false) + .map_err(|_| failure("managed_authority_required"))?; + let authority = authority.ok_or("managed_authority_required")?; + let runtime = self + .inner + .managed_effects + .read() + .clone() + .ok_or("managed_authority_required")?; + let attempt = self + .inner + .maintenance_attempts + .lock() + .get(&request.request_id) + .cloned(); + if let Some(attempt) = attempt { + let proof = + serde_json::to_string(&request).map_err(|_| "managed_metadata_budget_exceeded")?; + if attempt.claim.action != "restore" || attempt.claim.proof != proof { + return Err("managed_request_conflict".into()); + } + runtime + .authorize_query(&authority, &attempt.claim.identity) + .map_err(|_| "managed_authority_required")?; + let _gate = self.inner.commit_gate.lock(); + let mut publication = attempt.restore.lock(); + let Some(pending) = publication.as_ref() else { + if attempt.published.load(Ordering::Acquire) { + let envelope = attempt + .envelope + .lock() + .clone() + .ok_or("managed_restore_reconciliation_required")?; + let mut receipt = runtime + .repository + .lookup_restore_receipt_claim( + &request, + &cancellation, + Some(&attempt.claim), + )? + .ok_or("managed_restore_reconciliation_required")?; + if receipt != envelope.result { + return Err("managed_restore_reconciliation_required".into()); + } + runtime.repository.branch_publication_history( + &attempt.claim, + &envelope, + &cancellation, + )?; + attempt + .branch + .lock() + .as_ref() + .ok_or("managed_restore_reconciliation_required")? + .publish(); + self.inner + .maintenance_attempts + .lock() + .remove(&request.request_id); + receipt.replayed = true; + return Ok(receipt); + } + return Err("managed_submitted_unknown".into()); + }; + let Some(mut receipt) = runtime.repository.lookup_restore_receipt_claim( + &request, + &cancellation, + Some(&attempt.claim), + )? + else { + if runtime.repository.restore_rollback_confirmed( + &attempt.claim, + &pending.envelope, + &cancellation, + )? { + pending.branch.confirmed_rollback(); + publication.take(); + self.inner + .maintenance_attempts + .lock() + .remove(&request.request_id); + return Err("managed_transaction_rolled_back".into()); + } + return Err("managed_submitted_unknown".into()); + }; + if receipt != pending.envelope.result { + return Err("managed_restore_reconciliation_required".into()); + } + let page = runtime.repository.branch_publication_history( + &attempt.claim, + &pending.envelope, + &cancellation, + )?; + let pending = publication.take().expect("exact pending publication"); + (pending.publish)(json!({"pin":page.admission_pin,"replayed":false})) + .map_err(|_| "managed_restore_reconciliation_required")?; + pending.branch.publish(); + self.inner + .maintenance_attempts + .lock() + .remove(&request.request_id); + // This retry publishes the first and only run. Transport must start its worker once. + receipt.replayed = false; + return Ok(receipt); + } + runtime + .authorize_workspace_query( + &authority, + ADMISSION_SESSION_PROFILE, + &request.workspace_id, + &cancellation, + ) + .map_err(|_| failure("managed_authority_required"))?; + if let Some(mut receipt) = runtime + .repository + .lookup_restore_receipt(&request, &cancellation)? + { + receipt.replayed = true; + return Ok(receipt); + } + let lookup = runtime.repository.checkpoint_get( + &request.workspace_id, + &request.checkpoint_id, + &cancellation, + )?; + let checkpoint = lookup.checkpoint; + if checkpoint.source_branch_id != request.source_branch_id + || checkpoint.source_epoch != request.expected_source.epoch + || lookup.messages.len() != checkpoint.history.message_count + { + return Err(failure("managed_source_conflict")); + } + let context = runtime.repository.checkpoint_context( + &ManagedCheckpointCreateRequest { + source_run_id: checkpoint.parent_run_id.clone(), + workspace_id: request.workspace_id.clone(), + source_branch_id: request.source_branch_id.clone(), + expected_epoch: request.expected_source.epoch, + request_id: request.request_id.clone(), + mode: ManagedCheckpointMode::AtClosedBatch, + }, + &cancellation, + )?; + if context.head.process_lineage_tainted { + return Err(failure("managed_process_lineage_unsupported")); + } + if context.pending_compactions != 0 { + return Err(failure("managed_history_incomplete")); + } + let current = ManagedSourceCas { + store_id: context.head.store_id.clone(), + workspace_id: context.head.workspace_id.clone(), + branch_id: context.head.branch_id.clone(), + epoch: context.head.epoch, + head_object_id: context.head.head_object_id.clone(), + head_version: context.head.head_version, + }; + if current != request.expected_source { + return Err(failure("managed_source_conflict")); + } + let handle = self + .handle(&checkpoint.parent_run_id) + .ok_or("managed_state_unsupported")?; + let binding = handle + .pinned_tool + .as_ref() + .and_then(|pin| pin.managed.as_ref()) + .ok_or("managed_state_unsupported")?; + let registry = binding.captured_registry(); + if registry.identity() != checkpoint.graph.registry_identity { + return Err(failure("managed_graph_unsupported")); + } + let session_id = Uuid::new_v4().to_string(); + validate_frozen_compaction( + &lookup.messages, + lookup.compaction.as_ref(), + checkpoint.history.session_generation, + )?; + let mut messages = Vec::new(); + for message in &lookup.messages { + let parent = message + .parent_message_id + .as_ref() + .filter(|parent| !parent.is_empty()) + .map(|parent| { + lookup + .messages + .iter() + .find(|candidate| &candidate.id == parent) + .map(|candidate| format!("restored-{session_id}-{}", candidate.ordinal)) + .ok_or("managed_history_incomplete") + }) + .transpose()?; + messages.push(SessionMessage { + id: format!("restored-{session_id}-{}", message.ordinal), session_id: session_id.clone(), + role: message.role.clone(), content: serde_json::from_str(&message.content_json).map_err(|_| "managed_history_incomplete")?, + created_at: message.created_at_ms, run_id: None, finish_reason: message.finish_reason.clone(), + name: message.name.clone(), tool_call_id: message.tool_call_id.clone(), parent_message_id: parent, + token_estimate: message.token_estimate, + metadata: json!({"origin":{"checkpoint_id":checkpoint.checkpoint_id,"source_message_id":message.id}}), + ordinal: Some(message.ordinal as i64), + }); + } + let branch = { + let _gate = self.inner.commit_gate.lock(); + runtime.prepare_restore_branch( + &authority, + &context.workspace.identity, + &request.expected_source, + &checkpoint.frozen_root, + )? + }; + if context.source_runs.len() > 128 { + return Err(failure("managed_restore_busy")); + } + // Close Native resources before cancelling the original owners. + runtime.settle_restore_source(&branch, &cancellation)?; + let mut source_handles = Vec::new(); + for source_run in &context.source_runs { + if !matches!( + source_run.status.as_str(), + "completed" | "failed" | "cancelled" + ) { + let handle = self + .handle(&source_run.run_id) + .ok_or("managed_restore_busy")?; + source_handles.push((source_run.run_id.clone(), handle)); + } + } + for (_, handle) in &source_handles { + handle.request_user_stop(); + } + #[cfg(test)] + runtime.observe_restore_cancelled(); + let mut deadline = cancellation + .deadline_instant() + .ok_or("managed_deadline_elapsed")?; + for (_, handle) in &source_handles { + deadline = deadline.min( + handle + .cancel + .deadline_instant() + .ok_or("managed_deadline_elapsed")?, + ); + } + for (run_id, handle) in &source_handles { + if !handle.occupancy.load(Ordering::Acquire) && !handle.terminal.load(Ordering::Acquire) + { + tokio::runtime::Handle::current() + .block_on(self.finish_cancelled(run_id, "requested")); + } + while handle.occupancy.load(Ordering::Acquire) { + if Instant::now() >= deadline { + return Err(failure("managed_restore_busy")); + } + std::thread::sleep(Duration::from_millis(5)); + } + if !handle.terminal.load(Ordering::Acquire) + || *handle.cleanup_outcome.lock().expect("cleanup outcome lock") + != Some(CleanupOutcome::Clean) + { + return Err(failure("managed_restore_busy")); + } + } + if Instant::now() >= deadline || cancellation.requested().is_some() { + return Err(failure("managed_restore_busy")); + } + let permit = self + .inner + .capacity + .clone() + .try_acquire_owned() + .map_err(|_| "managed_restore_busy")?; + let input = request.input.text.clone(); + let request_id = request.request_id.clone(); + let proof = + serde_json::to_string(&request).map_err(|_| "managed_metadata_budget_exceeded")?; + let claim = runtime.repository.maintenance_claim( + context.workspace.identity.clone(), + proof, + request_id.clone(), + "restore", + )?; + let branch = Arc::new(branch); + let attempt = Arc::new(ManagedMaintenanceAttempt { + claim, + _authority: authority.clone(), + _original_cancellation: cancellation.clone(), + checkpoint: ParkingMutex::new(None), + restore: ParkingMutex::new(None), + envelope: ParkingMutex::new(None), + published: AtomicBool::new(false), + branch: ParkingMutex::new(Some(branch.clone())), + }); + let origin = ManagedRestoreAdmission { + request, + checkpoint, + session_id, + messages, + compaction: lookup.compaction, + execution_config: lookup.execution_config, + registry, + branch, + attempt: attempt.clone(), + authority, + management_cancel: cancellation.clone(), + }; + self.inner + .maintenance_attempts + .lock() + .insert(request_id.clone(), attempt.clone()); + let admitted = self.admit_blocking_from( + AdmitRunRequest { + input: json!({"message":input}), + model: Some(origin.checkpoint.public_config.config.model.clone()), + provider: origin.checkpoint.public_config.config.provider.clone(), + instructions: origin + .checkpoint + .public_config + .config + .system_instructions + .clone(), + platform: "api_server".into(), + ..Default::default() + }, + Some(config.source_root.to_string_lossy().into_owned()), + false, + permit, + Some(&origin), + ); + if matches!(&admitted,Err(AdmitError::Persistence(code)) if code == "managed_submitted_unknown") + { + origin.branch.submitted_unknown(); + return Err("managed_submitted_unknown".into()); + } + if admitted.is_err() { + self.inner.maintenance_attempts.lock().remove(&request_id); + if matches!(&admitted, Err(AdmitError::PluginArtifactUnavailable)) { + return Err(failure("plugin_artifact_unavailable")); + } + if matches!(&admitted, Err(AdmitError::PluginGraphRevoked)) { + return Err(failure("plugin_graph_revoked")); + } + if matches!(&admitted, Err(AdmitError::PluginGraphPinRequired)) { + return Err(failure("plugin_graph_pin_required")); + } + if matches!( + &admitted, + Err(AdmitError::HarnessPolicy( + "harness_policy_selection_invalid" + )) + ) { + return Err(failure("harness_policy_selection_invalid")); + } + if matches!(&admitted, Err(AdmitError::Invalid(code)) if code == "managed_metadata_budget_exceeded") + { + return Err(failure("managed_metadata_budget_exceeded")); + } + return Err(failure("managed_storage_unavailable")); + } + let read_cancel = RunCancellation::with_timeout(Duration::from_secs(5)); + let _publication_gate = self.inner.commit_gate.lock(); + let receipt = runtime + .repository + .lookup_restore_receipt_claim(&origin.request, &read_cancel, Some(&attempt.claim))? + .ok_or("managed_submitted_unknown")?; + let envelope = attempt + .envelope + .lock() + .clone() + .ok_or("managed_restore_reconciliation_required")?; + if receipt != envelope.result { + return Err("managed_restore_reconciliation_required".into()); + } + runtime + .repository + .branch_publication_history(&attempt.claim, &envelope, &read_cancel)?; + origin.branch.publish(); + self.inner.maintenance_attempts.lock().remove(&request_id); + Ok(receipt) + } + + pub async fn create_managed_checkpoint( + &self, + request: ManagedCheckpointCreateRequest, + ) -> Result { + let service = self.clone(); + let request_id = request.request_id.clone(); + tokio::task::spawn_blocking(move || service.create_managed_checkpoint_blocking(request)) + .await + .map_err(|_| { + public_failure(failure("managed_storage_unavailable"), request_id.clone()) + })? + .map_err(|code| public_failure(code, request_id)) + } + + fn create_managed_checkpoint_blocking( + &self, + request: ManagedCheckpointCreateRequest, + ) -> Result { + let config = self + .inner + .config + .managed_workspace + .as_ref() + .ok_or("managed_authority_required")?; + if self.inner.config.bearer_token.is_none() || !config.maintenance_policy.create_checkpoint + { + return Err(failure("managed_management_denied")); + } + crate::effects::broker::validate_id("ws", &request.workspace_id) + .map_err(|_| failure("managed_authority_required"))?; + if let Some(binding) = self + .handle(&request.source_run_id) + .as_ref() + .and_then(|handle| handle.pinned_tool.as_ref()) + .and_then(|pin| pin.managed.as_ref()) + && binding.workspace.identity.workspace_id == request.workspace_id + && binding.admitted_branch.branch_id != request.source_branch_id + { + return Err(failure("managed_checkpoint_conflict")); + } + crate::effects::broker::validate_id("br", &request.source_branch_id) + .map_err(|_| failure("managed_authority_required"))?; + if Uuid::parse_str(&request.source_run_id).is_err() + || Uuid::parse_str(&request.request_id).is_err() + || request.expected_epoch == 0 + { + return Err(failure("managed_source_conflict")); + } + let cancellation = RunCancellation::with_timeout(Duration::from_secs(5)); + let runtime = self + .inner + .managed_effects + .read() + .clone() + .ok_or("managed_authority_required")?; + let source = config + .source_root + .to_str() + .ok_or("managed_authority_required")?; + // An independent owner is issued by the existing restricted admission. + let (_, authority) = self + .resolve_workspace_admission(Some(source), None, &cancellation, false) + .map_err(|_| failure("managed_authority_required"))?; + let authority = authority.ok_or("managed_authority_required")?; + let attempt = self + .inner + .maintenance_attempts + .lock() + .get(&request.request_id) + .cloned(); + if let Some(attempt) = attempt { + let proof = + serde_json::to_string(&request).map_err(|_| "managed_metadata_budget_exceeded")?; + if attempt.claim.action != "checkpoint" || attempt.claim.proof != proof { + return Err("managed_request_conflict".into()); + } + runtime + .authorize_query(&authority, &attempt.claim.identity) + .map_err(|_| "managed_authority_required")?; + let _gate = self.inner.commit_gate.lock(); + let mut state = attempt.checkpoint.lock(); + if let Some(mut receipt) = runtime.repository.checkpoint_receipt( + &request, + &cancellation, + Some(&attempt.claim), + )? { + if let Some((_, input)) = state.as_ref() + && receipt != input.receipt + { + return Err("managed_restore_reconciliation_required".into()); + } + state.take(); + self.inner + .maintenance_attempts + .lock() + .remove(&request.request_id); + receipt.replayed = true; + return Ok(receipt); + } + if let Some((_, input)) = state.as_ref() + && runtime.repository.checkpoint_rollback_confirmed( + &attempt.claim, + input, + &cancellation, + )? + { + state.take(); + self.inner + .maintenance_attempts + .lock() + .remove(&request.request_id); + return Err("managed_transaction_rolled_back".into()); + } + return Err("managed_submitted_unknown".into()); + } + runtime + .authorize_workspace_query( + &authority, + ADMISSION_SESSION_PROFILE, + &request.workspace_id, + &cancellation, + ) + .map_err(|_| failure("managed_authority_required"))?; + if let Some(mut receipt) = + runtime + .repository + .checkpoint_receipt(&request, &cancellation, None)? + { + receipt.replayed = true; + return Ok(receipt); + } + let context = runtime + .repository + .checkpoint_context(&request, &cancellation)?; + runtime + .authorize_query(&authority, &context.workspace.identity) + .map_err(|_| failure("managed_authority_required"))?; + if context.head.epoch != request.expected_epoch + || context.head.branch_id != request.source_branch_id + || context.head.workspace_id != request.workspace_id + { + return Err(failure("managed_source_conflict")); + } + if context.head.process_lineage_tainted { + return Err(failure("managed_process_lineage_unsupported")); + } + let closed_batch = request.mode == ManagedCheckpointMode::AtClosedBatch + && !context.effects.is_empty() + && context.effects.iter().all(|effect| { + effect.outcome.is_some() + && matches!( + effect.state, + ManagedPublicationStatus::Committed | ManagedPublicationStatus::ReadOnly + ) + }); + let _pause = { + let _gate = self.inner.commit_gate.lock(); + runtime.begin_checkpoint_pause(&context.workspace.identity, context.head.epoch)? + }; + if closed_batch { + _pause.wait_closed_batch(&cancellation)?; + } else { + _pause.wait_frozen(&cancellation)?; + } + let context = runtime + .repository + .checkpoint_context(&request, &cancellation)?; + if context.pending_compactions != 0 { + return Err(failure("managed_history_incomplete")); + } + validate_frozen_compaction( + &context.messages, + context.compaction.as_ref(), + context.session_generation, + )?; + if context.messages.is_empty() + || context.messages.len() > 128 + || context.effects.len() > 128 + || context.external.len() > 32 + { + return Err(failure("managed_history_budget_exceeded")); + } + let history_bytes = serde_json::to_vec(&(&context.messages, &context.compaction)) + .map_err(|_| failure("managed_metadata_budget_exceeded"))? + .len(); + if history_bytes > 512 * 1024 { + return Err(failure("managed_history_budget_exceeded")); + } + let handle = self + .handle(&request.source_run_id) + .ok_or("managed_state_unsupported")?; + let binding = handle + .pinned_tool + .as_ref() + .and_then(|pin| pin.managed.as_ref()) + .ok_or("managed_state_unsupported")?; + let code = binding.code.clone(); + if binding.owner.session_id != context.session_id + || binding.workspace.identity != context.workspace.identity + { + return Err(failure("managed_authority_required")); + } + self.check_harness_run(&request.source_run_id, &cancellation) + .map_err(str::to_string)?; + let selected = handle + .pinned_tool + .as_ref() + .ok_or("managed_state_unsupported")? + .harness_selection + .clone(); + let packages = self.tool_store().ok_or("plugin_artifact_unavailable")?; + let installed = packages + .read_installed(&code.artifact_id) + .map_err(|_| failure("plugin_artifact_unavailable"))?; + validate_captured_inventory(&installed, &code)?; + let run_context = self + .run_context(&request.source_run_id) + .ok_or("managed_state_unsupported")?; + verify_context_metadata(&run_context).map_err(|_| "managed_state_unsupported")?; + if run_context.metadata.as_object().is_none_or(|fields| { + fields.keys().any(|key| { + !matches!( + key.as_str(), + "schema_version" + | "run_id" + | "session_id" + | "registry_identity" + | "toolset_hash" + | "approval_settings" + | "provider_profile" + | "message_id" + | "created_at_ms" + | "deadline_at_ms" + | "admitted_workspace_binding" + ) + }) + }) || run_context.limits.as_object().is_none_or(|fields| { + fields.keys().any(|key| { + !matches!( + key.as_str(), + "max_turns" + | "max_tool_calls" + | "max_tool_output_bytes" + | "workspace_root" + | "max_events" + | "max_event_bytes" + | "timeout_ms" + ) + }) + }) || run_context.tool_schemas != binding.captured_registry().schemas() + { + return Err(failure("managed_state_unsupported")); + } + let profile_name = run_context.metadata["provider_profile"] + .as_str() + .ok_or("managed_state_unsupported")?; + let profile = ProviderProfile::new(profile_name, run_context.provider_options.clone()) + .map_err(|_| "managed_state_unsupported")?; + let execution_config = ManagedFrozenExecutionConfig { + provider_profile: profile.name.clone(), + provider_options: serde_json::from_value(profile.options().clone()) + .map_err(|_| "managed_state_unsupported")?, + }; + let mut outputs = Vec::new(); + let mut parents = HashMap::new(); + for (index, message) in context.messages.iter().enumerate() { + if message.ordinal != index as u64 + 1 { + return Err(failure("managed_history_incomplete")); + } + let content: JsonValue = serde_json::from_str(&message.content_json) + .map_err(|_| failure("managed_history_incomplete"))?; + for block in content.as_array().ok_or("managed_history_incomplete")? { + if block["type"] == "tool_call" { + let call = block["tool_call_id"] + .as_str() + .ok_or("managed_history_incomplete")?; + if parents + .insert(call.to_string(), message.id.clone()) + .is_some() + { + return Err(failure("managed_history_incomplete")); + } + } + } + } + for effect in &context.effects { + let outcome = effect + .outcome + .as_ref() + .ok_or("managed_history_incomplete")?; + let prepared = effect + .prepared + .as_ref() + .ok_or("managed_history_incomplete")?; + let output = context + .messages + .iter() + .find(|message| message.id == outcome.message.id) + .ok_or("managed_history_incomplete")?; + let parent = parents + .remove(&effect.identity.call_id) + .ok_or("managed_history_incomplete")?; + if output.parent_message_id.as_deref() != Some(&parent) + || output.tool_call_id.as_deref() != Some(&effect.identity.call_id) + { + return Err(failure("managed_history_incomplete")); + } + let result = runtime + .read_result(&outcome.canonical_result_ref) + .map_err(|_| failure("managed_artifact_unavailable"))?; + if result["artifacts"] + .as_array() + .is_some_and(|artifacts| !artifacts.is_empty()) + { + return Err(failure("managed_artifact_unavailable")); + } + outputs.push(ManagedClosedOutputRef { + run_id: effect.identity.owner.run_id.clone(), + call_id: effect.identity.call_id.clone(), + parent_message_id: parent, + output_message_id: output.id.clone(), + receipt_id: prepared.effect_id.clone(), + result: outcome.canonical_result_ref.clone(), + }); + } + if !parents.is_empty() { + return Err(failure("managed_history_incomplete")); + } + let frozen_root = runtime + .repository + .object_metadata( + &ManagedObjectReferencesRequest { + store_id: context.head.store_id.clone(), + object_id: context.head.head_object_id.clone(), + }, + &cancellation, + )? + .ok_or("managed_artifact_unavailable")?; + runtime + .verify_tree(&frozen_root.object_id) + .map_err(|_| failure("managed_artifact_unavailable"))?; + let checkpoint_id = Uuid::new_v4().to_string(); + let last = context + .messages + .last() + .ok_or("managed_history_incomplete")?; + let receipt = ManagedCheckpointReceipt { + request_id: request.request_id.clone(), + checkpoint: ManagedCheckpointRef { + checkpoint_id: checkpoint_id.clone(), + workspace_id: request.workspace_id.clone(), + parent_run_id: request.source_run_id.clone(), + source_branch_id: request.source_branch_id.clone(), + source_epoch: context.head.epoch, + frozen_root, + history: ManagedFrozenHistoryRef { + history_root_id: Uuid::new_v4().to_string(), + source_session_id: context.session_id.clone(), + session_generation: context.session_generation, + last_message_id: last.id.clone(), + last_ordinal: context.last_ordinal, + last_closed_batch_id: outputs + .last() + .map(|output| output.parent_message_id.clone()) + .unwrap_or_else(|| last.id.clone()), + message_count: context.messages.len(), + byte_count: history_bytes, + closed_output_refs: outputs, + }, + graph: ManagedCheckpointGraphRef { + graph_id: code.graph_id.clone(), + harness_selection: selected.clone(), + checkpoint_graph_ref_id: Uuid::new_v4().to_string(), + captured_activation_generation: code.activation_generation, + package_id: code.package_id.clone(), + artifact_id: code.artifact_id.clone(), + export: code.export.clone(), + registry_identity: code.registry_identity.clone(), + effect_contract_version: code.effect_contract_version, + }, + public_config: ManagedPublicConfigRef { + config_ref_id: Uuid::new_v4().to_string(), + config: ManagedCheckpointPublicConfig { + model: run_context.model, + provider: run_context.provider, + system_instructions: run_context.system_prompt, + registry_identity: code.registry_identity.clone(), + tool_schemas_ref: code.artifact_id.clone(), + policy_revision: match selected { + crate::plugins::HarnessSelection::ContextPrompt { .. } => { + "stateless_builtin_with_pure_policy" + } + _ => "stateless_builtin", + } + .into(), + publication_policy: ManagedCheckpointPublication::ManagedOnly, + }, + }, + plugin_state: match &selected { + crate::plugins::HarnessSelection::ContextPrompt { policy, .. } => { + ManagedCheckpointStateCoverage::StatelessBuiltinWithPurePolicy { + graph_id: code.graph_id, + package_id: code.package_id, + artifact_id: code.artifact_id, + policy: policy.clone(), + manifest_state_absent: true, + state_effects_absent: true, + policy_host_imports_absent: true, + roots: vec![], + } + } + _ => ManagedCheckpointStateCoverage::StatelessBuiltin { + graph_id: code.graph_id, + package_id: code.package_id, + artifact_id: code.artifact_id, + manifest_state_absent: true, + state_effects_absent: true, + roots: vec![], + }, + }, + external_boundary: ManagedExternalBoundary { + high_water: context.external, + not_reverted: vec![ + ManagedExternalNotice::ProviderUsageNotReverted, + ManagedExternalNotice::DeliveredContentNotReverted, + ], + artifact_refs: Vec::new(), + }, + }, + committed_at_ms: timestamp(), + replayed: false, + }; + let original_request_proof = serde_json::to_string(&request) + .map_err(|_| failure("managed_metadata_budget_exceeded"))?; + let input = ManagedCheckpointCommitInput { + request, + original_request_proof, + captured_source: ManagedSourceCas { + store_id: context.head.store_id, + workspace_id: context.head.workspace_id, + branch_id: context.head.branch_id, + epoch: context.head.epoch, + head_object_id: context.head.head_object_id, + head_version: context.head.head_version, + }, + receipt, + messages: context.messages, + compaction: context.compaction, + execution_config, + }; + let _gate = self.inner.commit_gate.lock(); + authority + .check() + .map_err(|_| failure("managed_authority_required"))?; + if cancellation.deadline_passed() || cancellation.requested().is_some() { + return Err(failure("managed_deadline_elapsed")); + } + let request_id = input.request.request_id.clone(); + let claim = runtime.repository.maintenance_claim( + context.workspace.identity.clone(), + input.original_request_proof.clone(), + request_id.clone(), + "checkpoint", + )?; + let attempt = Arc::new(ManagedMaintenanceAttempt { + claim: claim.clone(), + _authority: authority, + _original_cancellation: cancellation.clone(), + checkpoint: ParkingMutex::new(Some((_pause, input))), + restore: ParkingMutex::new(None), + envelope: ParkingMutex::new(None), + published: AtomicBool::new(false), + branch: ParkingMutex::new(None), + }); + self.inner + .maintenance_attempts + .lock() + .insert(request_id.clone(), attempt.clone()); + let outcome = runtime.repository.checkpoint_submit( + &attempt + .checkpoint + .lock() + .as_ref() + .expect("prepared checkpoint") + .1, + &cancellation, + &claim, + ); + if !matches!(&outcome,Err(code) if code == "managed_submitted_unknown") { + attempt.checkpoint.lock().take(); + self.inner.maintenance_attempts.lock().remove(&request_id); + } + outcome + } +} diff --git a/src/service/managed_effects.rs b/src/service/managed_effects.rs index ecd9dfb..b71ece7 100644 --- a/src/service/managed_effects.rs +++ b/src/service/managed_effects.rs @@ -74,6 +74,66 @@ pub(super) fn query( )?; Ok(page) } +pub(super) fn query_run( + service: &AgentService, + request: ManagedRunEffectListRequest, +) -> Result { + use ManagedRunEffectError as E; + if service.inner.config.bearer_token.is_none() { + return Err(E::Unavailable); + } + if request.limit == 0 + || request.limit > 128 + || validate_visible_name(&request.run_id, "run_id", 128).is_err() + || request + .after_effect_id + .as_ref() + .is_some_and(|id| crate::effects::broker::validate_id("effect", id).is_err()) + { + return Err(E::InvalidRequest); + } + let (runtime, authority, cancellation) = query_context(service).map_err(|_| E::Unavailable)?; + let p = service.inner.persistence.as_ref().ok_or(E::Unavailable)?; + let binding = p + .managed_run_query_binding(&request.run_id, &cancellation) + .map_err(|_| run_query_failure(&cancellation, E::StorageUnavailable))? + .ok_or(E::Unavailable)?; + if binding.owner.run_id != request.run_id || binding.owner.profile != ADMISSION_SESSION_PROFILE + { + return Err(E::Unavailable); + } + let authorize = || { + runtime + .authorize_workspace_query( + &authority, + &binding.owner.profile, + &binding.workspace.workspace_id, + &cancellation, + ) + .and_then(|()| runtime.authorize_query(&authority, &binding.workspace)) + .map_err(|_| run_query_failure(&cancellation, E::Unavailable)) + }; + authorize()?; + let page = p + .managed_run_effect_list(&binding, &request, &cancellation) + .map_err(|error| run_query_failure(&cancellation, error))?; + authorize()?; + if cancellation.requested().is_some() || cancellation.deadline_passed() { + return Err(E::Cancelled); + } + Ok(page) +} +fn run_query_failure( + cancellation: &RunCancellation, + fallback: ManagedRunEffectError, +) -> ManagedRunEffectError { + if cancellation.requested().is_some() || cancellation.deadline_passed() { + ManagedRunEffectError::Cancelled + } else { + fallback + } +} + pub(super) fn read_chunk( service: &AgentService, request: ManagedResultReadRequest, @@ -117,7 +177,11 @@ pub(super) fn read_chunk( } fn failed(message: impl ToString) -> EventCommitError { - EventCommitError::PersistFailed(message.to_string()) + match message.to_string().as_str() { + "plugin_graph_revoked" => EventCommitError::PluginGraphRevoked, + "plugin_graph_pin_required" => EventCommitError::PluginGraphPinRequired, + other => EventCommitError::PersistFailed(other.to_string()), + } } pub(super) enum ManagedDurableReplay { Unmanaged, @@ -211,7 +275,7 @@ fn eligible( Ok(()) } fn event_draft(event: &GatewayEvent) -> JsonValue { - json!({"event_id":event.event_id,"event_type":event.event,"seq":event.seq,"payload_json":event.data.to_string()}) + json!({"event_id":event.event_id,"event_type":event.event,"seq":event.seq,"payload_json":event.data.to_string(),"created_at_ms":event.timestamp}) } fn apply( committer: &ServiceEventCommitter, @@ -341,6 +405,7 @@ pub(super) fn commit_started( json!({"normal":{"output":event_draft(&first),"terminal":event_draft(&second)}}); binding.runtime.repository.command_with_cancellation("effect.begin",&json!({"effect_id":call.effect_id,"run_id":committer.run_id,"call_id":record.call_id, "workspace_id":call.identity.workspace.workspace_id,"branch_id":call.identity.workspace.branch_id,"record_json":record_json, + "sealed_class":call.policy.class().as_str(), "commit_id":call.receipt_id,"projections_json":projection.to_string(),"max_events":committer.max_events_per_run,"cut":cut}),&binding.cancel).map_err(failed)?; apply(committer, &[first, second], None); Ok(()) @@ -511,6 +576,10 @@ pub(super) fn commit_result( .prepared .check_ready(&binding.runtime) .map_err(failed)?; + #[cfg(test)] + binding + .runtime + .observe_before_final_commit(Arc::clone(call)); let _gate = committer.commit_gate.lock(); eligible(committer, call)?; call.check_current_generation().map_err(failed)?; diff --git a/src/service/managed_effects_tests.rs b/src/service/managed_effects_tests.rs index 2e3bfc8..7160642 100644 --- a/src/service/managed_effects_tests.rs +++ b/src/service/managed_effects_tests.rs @@ -1,8 +1,15 @@ //! Native live replay through the exact private retained worker program. +#[path = "managed_graph_tests.rs"] +mod graph; +#[cfg(feature = "workspace-io-fault")] +#[path = "managed_linearization_tests.rs"] +mod linearization; #[path = "managed_protocol_tests.rs"] mod protocol; #[path = "managed_review_tests.rs"] mod review; +#[path = "managed_run_effect_tests.rs"] +mod run_query; use crate::config::{ManagedRetentionLimits, ManagedWorkspaceConfig}; use crate::effects::ResourceQuota; use crate::{AdmitRunRequest, AgentGatewayConfig, AgentGatewayState}; @@ -32,6 +39,7 @@ fn fixture_config(configure: impl FnOnce(&mut AgentGatewayConfig)) -> (PathBuf, source_root: workspace, quota: ResourceQuota::new(1000, 1048576, 16).unwrap(), retention: ManagedRetentionLimits::default(), + maintenance_policy: Default::default(), }), ..AgentGatewayConfig::default() }; diff --git a/src/service/managed_graph_tests.rs b/src/service/managed_graph_tests.rs new file mode 100644 index 0000000..f8e4556 --- /dev/null +++ b/src/service/managed_graph_tests.rs @@ -0,0 +1,857 @@ +use super::*; + +#[tokio::test(flavor = "multi_thread")] +async fn revoking_a_does_not_revoke_b_with_the_same_installed_package() { + let (_root, state) = fixture(); + let service = state.service(); + let p = state.persistence().unwrap(); + let first = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"text":"A"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + let pin = service + .handle(&first.run_id) + .unwrap() + .pinned_tool + .clone() + .unwrap(); + let a = &pin.managed.as_ref().unwrap().code; + let control = crate::plugins::PluginControlStore::new(&p); + let mut b = control.load_lock(&a.graph_id).unwrap().lock; + b.graph_id = uuid::Uuid::new_v4().to_string(); + control.store_lock(&b).unwrap(); + let refs: Vec<_> = b + .packages + .iter() + .map(|p| crate::plugins::PackageArtifactRef { + package_id: p.id.clone(), + version: p.version.clone(), + kind: p.kind.clone(), + artifact_id: p.artifact_id.clone(), + }) + .collect(); + let activated = control + .activate(&b.graph_id, a.activation_generation, &refs) + .unwrap(); + control + .revoke_graph(&service.tool_store().unwrap(), &a.graph_id) + .unwrap(); + let provider = Arc::new(crate::ScriptedProvider::new()); + provider.push_ok(response(Some( + json!({"id":"B-write","name":"write_file","arguments":{"path":"b.txt","content":"B"}}), + ))); + provider.push_ok(response(None)); + service.inject_provider_host(provider); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"text":"B after revoke A"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + let pin_b = service + .handle(&run.run_id) + .unwrap() + .pinned_tool + .clone() + .unwrap(); + let binding = pin_b.managed.as_ref().unwrap(); + assert_eq!(binding.code.graph_id, b.graph_id); + assert_eq!(binding.code.artifact_id, a.artifact_id); + service + .clone() + .run_worker(run.run_id.clone(), String::new()) + .await; + let record = p + .managed_effect_get(&crate::effects::ManagedEffectGetRequest { + owner: binding.owner.clone(), + call_id: "B-write".into(), + }) + .unwrap() + .unwrap(); + assert!(record.outcome.is_some()); + assert_eq!(control.activation().unwrap(), activated); + assert!(control.graph_revocation(&b.graph_id).unwrap().is_none()); + service.shutdown().await.unwrap(); +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn current_http_routes_preserve_missing_package_and_revoked_graph_codes() { + use std::future::IntoFuture; + let (root, state, source) = super::protocol::maintenance_source().await; + let service = state.service(); + let p = state.persistence().unwrap(); + let request = super::protocol::restore_request(&state, &source).await; + let pin = service + .handle(&source.source_run_id) + .unwrap() + .pinned_tool + .clone() + .unwrap(); + let code = &pin.managed.as_ref().unwrap().code; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (stop, stopped) = tokio::sync::oneshot::channel(); + let server = tokio::spawn( + axum::serve(listener, crate::build_agent_gateway_app(state.clone())) + .with_graceful_shutdown(async { + let _ = stopped.await; + }) + .into_future(), + ); + let path = format!("/v1/workspaces/{}/restore", source.workspace_id); + let artifact = root + .join(".gateway.sqlite.tool-packages/artifacts") + .join(&code.artifact_id); + let held = root.join("http-held-package"); + fs::rename(&artifact, &held).unwrap(); + let missing = + super::protocol::maintenance_http(address, &path, serde_json::to_value(&request).unwrap()) + .await; + fs::rename(&held, &artifact).unwrap(); + assert_eq!(missing.0, 422); + assert_eq!(missing.1["error"]["code"], "plugin_artifact_unavailable"); + let control = crate::plugins::PluginControlStore::new(&p); + let checkpoint_ref = control + .retained(&code.graph_id) + .unwrap() + .into_iter() + .find(|r| r.owner_kind == "checkpoint" && r.owner_id == request.checkpoint_id) + .unwrap(); + control.release(&checkpoint_ref).unwrap(); + let missing_pin = + super::protocol::maintenance_http(address, &path, serde_json::to_value(&request).unwrap()) + .await; + assert_eq!(missing_pin.0, 422); + assert_eq!(missing_pin.1["error"]["code"], "plugin_graph_pin_required"); + control.retain(&checkpoint_ref).unwrap(); + control + .revoke_graph(&service.tool_store().unwrap(), &code.graph_id) + .unwrap(); + let revoked = + super::protocol::maintenance_http(address, &path, serde_json::to_value(&request).unwrap()) + .await; + assert_eq!(revoked.0, 409); + assert_eq!(revoked.1["error"]["code"], "plugin_graph_revoked"); + let ordinary = super::protocol::maintenance_http( + address, + "/v1/runs", + json!({"input":"must not admit revoked graph"}), + ) + .await; + assert_eq!(ordinary.0, 409); + assert_eq!(ordinary.1["error"]["code"], "plugin_graph_revoked"); + for code in [ + "plugin_graph_revoked", + "plugin_graph_pin_required", + "plugin_artifact_unavailable", + ] { + assert!(!crate::runtime::agent_host::error_is_retryable_code(code)); + } + let _ = stop.send(()); + server.await.unwrap().unwrap(); + service.shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn already_begun_call_finishes_after_revoke_and_exact_replay_does_not_execute_again() { + let (root, state) = fixture(); + let service = state.service(); + let p = state.persistence().unwrap(); + let sentinel = root.join("revoked-entered-sentinel"); + let provider = Arc::new(ExactReplayProvider { + persistence: p.clone(), + service: Arc::downgrade(&service), + run_id: std::sync::Mutex::new(String::new()), + call: json!({"id":"once","name":"terminal","arguments":{"argv":["/bin/sh","-c",format!("printf x >> '{}'; printf observed",sentinel.display())],"timeout_ms":1000}}), + step: std::sync::atomic::AtomicUsize::new(0), + replayed: std::sync::Mutex::new(None), + }); + service.inject_provider_host(provider.clone()); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"text":"begin then revoke"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + *provider.run_id.lock().unwrap() = run.run_id.clone(); + let pin = service + .handle(&run.run_id) + .unwrap() + .pinned_tool + .clone() + .unwrap(); + let graph = pin.managed.as_ref().unwrap().code.graph_id.clone(); + let revoker = p.clone(); + let graph_copy = graph.clone(); + *p.managed_request_hook.lock().unwrap() = Some(Arc::new(move |op, _, enqueue| { + if op == "effect.prepare" && enqueue { + revoker + .plugin_control_command( + "plugin.graph_revoke", + &json!({"graph_id":graph_copy,"now_ms":100,"failpoint":""}), + ) + .unwrap(); + } + })); + service + .clone() + .run_worker(run.run_id.clone(), String::new()) + .await; + *p.managed_request_hook.lock().unwrap() = None; + assert_eq!(fs::read(&sentinel).unwrap(), b"x"); + assert_eq!( + provider.replayed.lock().unwrap().as_ref().unwrap()["ok"], + true + ); + let record = p + .managed_effect_get(&crate::effects::ManagedEffectGetRequest { + owner: pin.managed.as_ref().unwrap().owner.clone(), + call_id: "once".into(), + }) + .unwrap() + .unwrap(); + assert!(record.outcome.is_some()); + assert!( + crate::plugins::PluginControlStore::new(&p) + .graph_revocation(&graph) + .unwrap() + .is_some() + ); + service.shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn missing_requirement_binding_or_exact_ref_cannot_exempt_managed_begin() { + for mutation in ["drop_requirement", "drop_binding", "replace_ref"] { + let (_root, state) = fixture(); + let service = state.service(); + let p = state.persistence().unwrap(); + let provider = Arc::new(crate::ScriptedProvider::new()); + provider.push_ok(response(Some(json!({"id":"missing-state","name":"write_file","arguments":{"path":"never.txt","content":"deny"}})))); + provider.push_ok(response(None)); + service.inject_provider_host(provider); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"text":"missing required state"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + let pin = service + .handle(&run.run_id) + .unwrap() + .pinned_tool + .clone() + .unwrap(); + p.mutate_graph_pin_for_test(&run.run_id, mutation).unwrap(); + service + .clone() + .run_worker(run.run_id.clone(), String::new()) + .await; + let events = service.run_events(&run.run_id); + assert!( + events.iter().any(|e| e["event"] == "tool.failed" + && e["data"]["error_code"] == "plugin_graph_pin_required"), + "{mutation} must fail typed: {events:?}" + ); + assert!( + !events + .iter() + .any(|e| matches!(e["event"].as_str(), Some("tool.requested" | "tool.started"))) + ); + assert!( + p.managed_effect_get(&crate::effects::ManagedEffectGetRequest { + owner: pin.managed.as_ref().unwrap().owner.clone(), + call_id: "missing-state".into() + }) + .unwrap() + .is_none() + ); + service.shutdown().await.unwrap(); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn required_identity_is_typed_durable_state_not_caller_receipt() { + let (_root, state) = fixture(); + let service = state.service(); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"text":"requirement"}), + idempotency_key: Some("caller-key".into()), + idempotency_hash: Some("fnv64:0000000000000000".into()), + graph_id: None, + workspace_branch: None, + ..Default::default() + }, + None, + ) + .await + .unwrap(); + let p = state.persistence().unwrap(); + let pin = service + .handle(&run.run_id) + .unwrap() + .pinned_tool + .clone() + .unwrap(); + let binding = pin.managed.as_ref().unwrap(); + let result = p.plugin_control_command( + "plugin.pin_requirement_get", + &json!({"owner_id":run.run_id}), + ); + assert!( + result.is_ok(), + "required identity must be readable through the closed typed owner: {result:?}" + ); + let rows = result.unwrap(); + assert_eq!( + rows["rows"][0], + json!([ + run.run_id, + binding.code.graph_id, + binding.code.graph_ref_id, + "required" + ]) + ); + service.shutdown().await.unwrap(); +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn missing_checkpoint_package_is_distinct_from_revocation_even_with_prepared_cache() { + let (root, state, source) = super::protocol::maintenance_source().await; + let service = state.service(); + let request = super::protocol::restore_request(&state, &source).await; + let pin = service + .handle(&source.source_run_id) + .unwrap() + .pinned_tool + .clone() + .unwrap(); + let code = &pin.managed.as_ref().unwrap().code; + let path = root + .join(".gateway.sqlite.tool-packages/artifacts") + .join(&code.artifact_id); + let held = root.join("owned-held-package"); + fs::rename(&path, &held).unwrap(); + let result = service.restore_managed_checkpoint_and_admit(request).await; + fs::rename(&held, &path).unwrap(); + service.shutdown().await.unwrap(); + assert_eq!( + result.as_ref().err().map(ToString::to_string), + Some("plugin_artifact_unavailable".into()), + "missing package must not use an in-memory prepared graph: {result:?}" + ); +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn checkpoint_graph_a_survives_collection_and_b_activation_but_revoked_restore_is_atomic() { + let (_root, state, source) = super::protocol::maintenance_source().await; + let service = state.service(); + let p = state.persistence().unwrap(); + let request = super::protocol::restore_request(&state, &source).await; + let pin = service + .handle(&source.source_run_id) + .unwrap() + .pinned_tool + .clone() + .unwrap(); + let code = &pin.managed.as_ref().unwrap().code; + let control = crate::plugins::PluginControlStore::new(&p); + let a = control.load_lock(&code.graph_id).unwrap().lock; + let mut b = a.clone(); + b.graph_id = uuid::Uuid::new_v4().to_string(); + control.store_lock(&b).unwrap(); + let refs: Vec<_> = b + .packages + .iter() + .map(|p| crate::plugins::PackageArtifactRef { + package_id: p.id.clone(), + version: p.version.clone(), + kind: p.kind.clone(), + artifact_id: p.artifact_id.clone(), + }) + .collect(); + let active_b = control + .activate(&b.graph_id, code.activation_generation, &refs) + .unwrap(); + control + .release(&crate::plugins::GraphRetention { + graph_id: a.graph_id.clone(), + owner_kind: "run".into(), + owner_id: source.source_run_id.clone(), + ref_id: code.graph_ref_id.clone(), + }) + .unwrap(); + assert!( + !control + .collect_unreferenced() + .unwrap() + .contains(&a.graph_id) + ); + assert_eq!(control.load_lock(&a.graph_id).unwrap().lock, a); + assert_eq!(control.activation().unwrap(), active_b); + let ordinary = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"text":"new run uses B"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + let ordinary_pin = service + .handle(&ordinary.run_id) + .unwrap() + .pinned_tool + .clone() + .unwrap(); + assert_eq!( + ordinary_pin.managed.as_ref().unwrap().code.graph_id, + b.graph_id + ); + let provider = Arc::new(crate::ScriptedProvider::new()); + provider.push_ok(response(None)); + service.inject_provider_host(provider); + service + .clone() + .run_worker(ordinary.run_id, String::new()) + .await; + let restored = service + .restore_managed_checkpoint_and_admit(request) + .await + .unwrap(); + let restored_pin = service + .handle(&restored.new_run_id) + .unwrap() + .pinned_tool + .clone() + .unwrap(); + assert_eq!( + restored_pin.managed.as_ref().unwrap().code.graph_id, + a.graph_id + ); + let provider = Arc::new(crate::ScriptedProvider::new()); + provider.push_ok(response(Some( + json!({"id":"restored-read-a","name":"read_file","arguments":{"path":"original.txt"}}), + ))); + provider.push_ok(response(None)); + service.inject_provider_host(provider); + service + .clone() + .run_worker(restored.new_run_id.clone(), String::new()) + .await; + let record = p + .managed_effect_get(&crate::effects::ManagedEffectGetRequest { + owner: restored_pin.managed.as_ref().unwrap().owner.clone(), + call_id: "restored-read-a".into(), + }) + .unwrap() + .unwrap(); + assert_eq!(record.identity.code.graph_id, a.graph_id); + let result = service + .managed_runtime_for_test() + .unwrap() + .read_result(&record.outcome.unwrap().canonical_result_ref) + .unwrap(); + assert!(result.to_string().contains("frozen A")); + assert_eq!(control.activation().unwrap(), active_b); + service.shutdown().await.unwrap(); + let (_second_root, second, next_source) = super::protocol::maintenance_source().await; + let service = second.service(); + let p = second.persistence().unwrap(); + let next_request = super::protocol::restore_request(&second, &next_source).await; + let second_pin = service + .handle(&next_source.source_run_id) + .unwrap() + .pinned_tool + .clone() + .unwrap(); + let graph_id = &second_pin.managed.as_ref().unwrap().code.graph_id; + let control = crate::plugins::PluginControlStore::new(&p); + let runtime = service.managed_runtime_for_test().unwrap(); + let cancel = crate::RunCancellation::with_timeout(std::time::Duration::from_secs(5)); + let before = runtime + .repository + .checkpoint_context(&next_source, &cancel) + .unwrap() + .head; + p.plugin_control_command( + "plugin.graph_revoke", + &json!({"graph_id":graph_id,"now_ms":300,"failpoint":""}), + ) + .unwrap(); + let denied = service + .restore_managed_checkpoint_and_admit(next_request.clone()) + .await; + assert_eq!(denied.unwrap_err().to_string(), "plugin_graph_revoked"); + assert_eq!( + runtime + .repository + .checkpoint_context(&next_source, &cancel) + .unwrap() + .head, + before + ); + assert!( + runtime + .repository + .lookup_restore_receipt(&next_request, &cancel) + .unwrap() + .is_none() + ); + assert!(control.load_lock(graph_id).is_ok()); + assert!( + control + .retained(graph_id) + .unwrap() + .iter() + .any(|r| r.owner_id == next_source.source_run_id) + ); + service.shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn durable_graph_revoke_is_monotonic_rollback_safe_and_survives_reopen() { + let (root, state) = fixture(); + let service = state.service(); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"text":"pin"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + let pin = service + .handle(&run.run_id) + .unwrap() + .pinned_tool + .clone() + .unwrap(); + let graph = &pin.managed.as_ref().unwrap().code.graph_id; + let p = state.persistence().unwrap(); + let result = p.plugin_control_command( + "plugin.graph_revoke", + &json!({"graph_id":graph,"now_ms":100,"failpoint":""}), + ); + assert!( + result.is_ok(), + "trusted closed revoke command must persist a graph fact: {result:?}" + ); + let first = p + .plugin_control_command("plugin.graph_revocation_get", &json!({"graph_id":graph})) + .unwrap(); + assert_eq!(first["rows"][0][0], *graph); + assert_eq!(first["rows"][0][1], 100); + let control = crate::plugins::PluginControlStore::new(&p); + let typed = control + .revoke_graph(&service.tool_store().unwrap(), graph) + .unwrap(); + assert_eq!(typed.revoked_at_ms, 100); + assert_eq!(control.graph_revocation(graph).unwrap(), Some(typed)); + p.plugin_control_command( + "plugin.graph_revoke", + &json!({"graph_id":graph,"now_ms":200,"failpoint":""}), + ) + .unwrap(); + assert_eq!( + p.plugin_control_command("plugin.graph_revocation_get", &json!({"graph_id":graph})) + .unwrap(), + first + ); + assert!( + p.plugin_control_command( + "plugin.graph_revoke", + &json!({"graph_id":"rollback-graph","now_ms":300,"failpoint":"after_partial_write"}) + ) + .is_err() + ); + assert!( + p.plugin_control_command( + "plugin.graph_revocation_get", + &json!({"graph_id":"rollback-graph"}) + ) + .unwrap()["rows"] + .as_array() + .unwrap() + .is_empty() + ); + service.shutdown().await.unwrap(); + p.shutdown(); + drop(p); + drop(service); + drop(state); + let reopened = crate::GatewayPersistence::open( + &AgentGatewayConfig::default(), + &root.join("gateway.sqlite"), + ) + .unwrap(); + assert_eq!( + reopened + .plugin_control_command("plugin.graph_revocation_get", &json!({"graph_id":graph})) + .unwrap(), + first + ); + reopened.shutdown(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn revoked_retained_graph_refuses_new_native_call_after_successful_diagnostic_read() { + let (_root, state) = fixture(); + let service = state.service(); + let p = state.persistence().unwrap(); + let provider = Arc::new(crate::ScriptedProvider::new()); + provider.push_ok(response(Some(json!({"id":"revoked-call","name":"write_file","arguments":{"path":"denied.txt","content":"must not publish"}})))); + provider.push_ok(response(None)); + service.inject_provider_host(provider); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"text":"revoke before begin"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + let pin = service + .handle(&run.run_id) + .unwrap() + .pinned_tool + .clone() + .unwrap(); + let binding = pin.managed.as_ref().unwrap(); + let graph = &binding.code.graph_id; + let before = p + .plugin_control_command("plugin.graph_revocation_get", &json!({"graph_id":graph})) + .unwrap(); + assert!(before["rows"].as_array().unwrap().is_empty()); + p.plugin_control_command( + "plugin.graph_revoke", + &json!({"graph_id":graph,"now_ms":100,"failpoint":""}), + ) + .unwrap(); + service + .clone() + .run_worker(run.run_id.clone(), "ignored".into()) + .await; + let events = service.run_events(&run.run_id); + assert!( + events + .iter() + .any(|e| e["event"] == "tool.failed" + && e["data"]["error_code"] == "plugin_graph_revoked"), + "revoked retained graph must fail with safe typed code: {events:?}" + ); + assert!( + !events + .iter() + .any(|e| matches!(e["event"].as_str(), Some("tool.requested" | "tool.started"))) + ); + assert!( + p.managed_effect_get(&crate::effects::ManagedEffectGetRequest { + owner: binding.owner.clone(), + call_id: "revoked-call".into() + }) + .unwrap() + .is_none() + ); + assert!( + crate::plugins::PluginControlStore::new(&p) + .retained(graph) + .unwrap() + .iter() + .any(|r| r.owner_id == run.run_id) + ); + let pair = p.event_append_pair(&json!({"run_id":run.run_id,"event_id_a":"non-effect-a","event_type_a":"tool.requested","payload_json_a":"{}","event_id_b":"non-effect-b","event_type_b":"tool.started","payload_json_b":"{}","now_ms":200,"max_events":1})); + assert_eq!(pair.unwrap_err().code, "plugin_graph_revoked"); + service.shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn revoked_graph_fresh_admission_returns_distinct_code_without_publication() { + let (_root, state) = fixture(); + let service = state.service(); + let p = state.persistence().unwrap(); + let first = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"text":"first"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + let pin = service + .handle(&first.run_id) + .unwrap() + .pinned_tool + .clone() + .unwrap(); + let graph = &pin.managed.as_ref().unwrap().code.graph_id; + p.plugin_control_command( + "plugin.graph_revoke", + &json!({"graph_id":graph,"now_ms":100,"failpoint":""}), + ) + .unwrap(); + let captured = Arc::new(std::sync::Mutex::new(None)); + let sink = captured.clone(); + p.inject_admission_precommit_hook(move |payload| { + *sink.lock().unwrap() = Some(payload.clone()); + }); + let result = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"text":"must not admit"}), + ..Default::default() + }, + None, + ) + .await; + assert_eq!( + result.as_ref().err().map(|e| format!("{e:?}")), + Some("PluginGraphRevoked".into()), + "revocation must reject fresh admission: {result:?}" + ); + assert_eq!( + crate::plugins::PluginControlStore::new(&p) + .retained(graph) + .unwrap() + .iter() + .filter(|r| r.owner_kind == "run") + .count(), + 1 + ); + let payload = captured.lock().unwrap().clone().unwrap(); + let run_id = payload["run_id"].as_str().unwrap(); + let session_id = payload["session_id"].as_str().unwrap(); + assert!( + p.run_get(run_id).unwrap()["rows"] + .as_array() + .unwrap() + .is_empty() + ); + assert!( + p.session_get(session_id).unwrap()["rows"] + .as_array() + .unwrap() + .is_empty() + ); + assert!( + p.message_list(session_id, 0).unwrap()["rows"] + .as_array() + .unwrap() + .is_empty() + ); + assert!( + p.event_replay(&json!({"run_id":run_id,"after_seq":0,"max_events":128,"max_bytes":65536})) + .unwrap()["rows"] + .as_array() + .unwrap() + .is_empty() + ); + assert!( + crate::plugins::PluginControlStore::new(&p) + .pin_requirement_for_run(run_id) + .unwrap() + .is_none() + ); + service.shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn released_required_managed_pin_refuses_native_begin_without_intent_or_execution() { + let (root, state) = fixture(); + let service = state.service(); + let p = state.persistence().unwrap(); + let provider = Arc::new(crate::ScriptedProvider::new()); + provider.push_ok(response(Some(json!({"id":"must-not-start","name":"write_file","arguments":{"path":"denied.txt","content":"must not publish"}})))); + provider.push_ok(response(None)); + service.inject_provider_host(provider); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"text":"new managed call"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + let pin = service + .handle(&run.run_id) + .unwrap() + .pinned_tool + .clone() + .unwrap(); + let binding = pin.managed.as_ref().unwrap(); + crate::plugins::PluginControlStore::new(&p) + .release(&crate::plugins::GraphRetention { + graph_id: binding.code.graph_id.clone(), + owner_kind: "run".into(), + owner_id: run.run_id.clone(), + ref_id: binding.code.graph_ref_id.clone(), + }) + .unwrap(); + service + .clone() + .run_worker(run.run_id.clone(), "ignored".into()) + .await; + let events = service.run_events(&run.run_id); + let failed: Vec<_> = events + .iter() + .filter(|e| e["event"] == "tool.failed") + .collect(); + assert!( + failed + .iter() + .any(|e| e["data"]["error_code"] == "plugin_graph_pin_required"), + "typed pin rejection must reach real tool.failed: {events:?}" + ); + assert!( + !events + .iter() + .any(|e| matches!(e["event"].as_str(), Some("tool.requested" | "tool.started"))) + ); + let owner = crate::effects::ManagedOwnerScope { + profile: binding.owner.profile.clone(), + session_id: run.session_id.clone(), + run_id: run.run_id.clone(), + }; + assert!( + p.managed_effect_get(&crate::effects::ManagedEffectGetRequest { + owner, + call_id: "must-not-start".into() + }) + .unwrap() + .is_none() + ); + assert!(!root.join("workspace/denied.txt").exists()); + service.shutdown().await.unwrap(); +} diff --git a/src/service/managed_linearization_tests.rs b/src/service/managed_linearization_tests.rs new file mode 100644 index 0000000..9136091 --- /dev/null +++ b/src/service/managed_linearization_tests.rs @@ -0,0 +1,1205 @@ +//! Actual maintenance request linearization and original-clock boundaries. +// Provider fixtures return synthetic typed responses through the actual durable +// provider request/step/failure producer; no storage outcome is substituted. +use super::*; +use crate::effects::journal::*; +use std::time::Duration; + +#[tokio::test(flavor = "multi_thread")] +async fn management_restore_busy_is_typed_at_actual_http_boundary() { + let (state, _source, request) = race_source(2).await; + let service = state.service(); + let gate = Arc::new(Latch::default()); + let release = LatchRelease(gate.clone()); + let observer = gate.clone(); + service.inject_prompt_read_entered_observer(Arc::new(move || observer.arrive())); + let mut rival = request.clone(); + rival.request_id = uuid::Uuid::new_v4().to_string(); + let first_service = service.clone(); + let first = tokio::spawn(async move { + first_service + .restore_managed_checkpoint_and_admit(request) + .await + }); + gate.wait(1); // real restore has closed SOURCE and entered original prompt preparation + let results = http_denials( + state.clone(), + vec![( + format!("/v1/workspaces/{}/restore", rival.workspace_id), + serde_json::to_value(&rival).unwrap(), + )], + ) + .await; + drop(release); + let winner = first.await.unwrap(); + *service.inner.prompt_read_entered.lock().unwrap() = None; + let rt = service.managed_runtime_for_test().unwrap(); + let loser_absent = rt + .repository + .lookup_restore_receipt(&rival, &read_clock()) + .unwrap() + .is_none(); + service.shutdown().await.unwrap(); + assert!(winner.is_ok()); + assert!(loser_absent); + assert_eq!( + results[0].0, 409, + "HTTP restore contention retains its typed busy classification" + ); + assert_eq!(results[0].1["error"]["code"], "managed_restore_busy"); + assert_eq!(results[0].1["error"]["message"], "managed_restore_busy"); +} + +async fn http_denials(state: AgentGatewayState, cases: Vec<(String, Value)>) -> Vec<(u16, Value)> { + http_inputs( + state, + cases + .into_iter() + .map(|(path, body)| HttpInput { + path, + body: body.to_string(), + content_type: Some("application/json"), + authenticated: true, + }) + .collect(), + ) + .await + .into_iter() + .map(|(status, body)| (status, serde_json::from_slice(&body).unwrap_or(Value::Null))) + .collect() +} + +struct HttpInput { + path: String, + body: String, + content_type: Option<&'static str>, + authenticated: bool, +} + +async fn http_inputs(state: AgentGatewayState, cases: Vec) -> Vec<(u16, Vec)> { + use http_body_util::{BodyExt, Full}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (shutdown, stopped) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + axum::serve( + listener, + crate::build_agent_gateway_app(state) + .into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(async move { + let _ = stopped.await; + }) + .await + }); + let result = tokio::spawn(async move { + let client: hyper_util::client::legacy::Client<_, Full> = + hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new()) + .build_http(); + let mut results = Vec::new(); + for input in cases { + let mut req = hyper::Request::post(format!("http://{address}{}", input.path)); + if let Some(content_type) = input.content_type { + req = req.header("content-type", content_type); + } + if input.authenticated { + req = req.header("authorization", "Bearer synthetic-maintenance-fixture"); + } + let req = req.body(Full::new(bytes::Bytes::from(input.body))).unwrap(); + let response = client.request(req).await.unwrap(); + let status = response.status().as_u16(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + results.push((status, bytes.to_vec())); + } + results + }) + .await; + let _ = shutdown.send(()); + tokio::time::timeout(Duration::from_secs(5), server) + .await + .unwrap() + .unwrap() + .unwrap(); + drop(std::net::TcpListener::bind(address).expect("owned management socket released")); + match result { + Ok(result) => result, + Err(error) => std::panic::resume_unwind(error.into_panic()), + } +} +// Each case uses the production router, its configured DefaultBodyLimit and +// actual authenticated sockets. Unknown keys below prove schema rejection only. +#[derive(Clone, Copy, Debug)] +enum JsonRefusal { + Syntax, + MissingContentType, + NonJsonContentType, + UnknownField, + WrongType, + OverLimit, + MissingAuthorization, +} + +fn management_refusal_snapshot( + state: &AgentGatewayState, + source: &ManagedCheckpointCreateRequest, + restore: &ManagedRestoreAndAdmitRequest, +) -> Value { + let service = state.service(); + let rt = service.managed_runtime_for_test().unwrap(); + let context = rt + .repository + .checkpoint_context(source, &read_clock()) + .unwrap(); + let memory = service.inner.store.read(); + let mut runs = memory.runs.keys().cloned().collect::>(); + let mut sessions = memory.sessions.keys().cloned().collect::>(); + runs.sort(); + sessions.sort(); + drop(memory); + json!({ + "durable_context": context, + "checkpoint_receipt": rt.repository.checkpoint_receipt(source, &read_clock(), None).unwrap(), + "restore_receipt": rt.repository.lookup_restore_receipt(restore, &read_clock()).unwrap(), + "memory_runs": runs, + "memory_sessions": sessions, + "source_events": service.run_events(&source.source_run_id), + "maintenance_attempts": service.inner.maintenance_attempts.lock().len(), + }) +} + +async fn json_refusal_at_both_routes(case: JsonRefusal, expected_status: u16) { + let (state, mut source, mut restore) = race_source(2).await; + let service = state.service(); + const PRIVATE_MARKER: &str = "PRIVATE_JSON_REFUSAL_161"; + source.request_id = format!("{PRIVATE_MARKER}-{}", uuid::Uuid::new_v4()); + restore.request_id = format!("{PRIVATE_MARKER}-{}", uuid::Uuid::new_v4()); + let outcome = tokio::spawn(async move { + let mut statuses = Vec::new(); + for (path, mut body) in action_cases(&source, &restore, &source.workspace_id) { + let before = management_refusal_snapshot(&state, &source, &restore); + assert!(before["checkpoint_receipt"].is_null()); + assert!(before["restore_receipt"].is_null()); + let mut content_type = Some("application/json"); + match case { + JsonRefusal::MissingContentType => content_type = None, + JsonRefusal::NonJsonContentType => content_type = Some("text/plain"), + JsonRefusal::UnknownField => body["workspace_path"] = json!(PRIVATE_MARKER), + JsonRefusal::WrongType => body["source_branch_id"] = json!({"private":PRIVATE_MARKER}), + _ => {} + } + let mut body = body.to_string(); + if matches!(case, JsonRefusal::Syntax) { + body = format!("{{\"workspace_id\":\"{PRIVATE_MARKER}\","); + } + if matches!(case, JsonRefusal::OverLimit) { + // Valid JSON plus legal trailing whitespace: only the actual + // production DefaultBodyLimit can account for this rejection. + body.push_str(&" ".repeat(state.service().config().max_body_bytes + 1)); + assert!(body.len() > state.service().config().max_body_bytes); + } + let results = http_inputs(state.clone(), vec![HttpInput { + path: path.clone(), body, content_type, + authenticated: !matches!(case, JsonRefusal::MissingAuthorization), + }]).await; + let after = management_refusal_snapshot(&state, &source, &restore); + assert_eq!(after, before, "{case:?} {path}: typed head/current branch, history, journal, receipts and memory unchanged"); + let (status, response) = &results[0]; + let expected = if matches!(case, JsonRefusal::MissingAuthorization) { + json!({"error":{"code":"unauthorized","message":"missing or invalid bearer token"}}) + } else { + json!({"error":{"code":"managed_request_invalid","message":"managed_request_invalid"}}) + }; + assert_eq!(response, expected.to_string().as_bytes(), "{case:?} {path}: exact static response"); + assert!(!String::from_utf8_lossy(response).contains(PRIVATE_MARKER)); + println!("{case:?} {}: HTTP {status}; exact static body and typed no-partial-state verified", path.rsplit('/').next().unwrap()); + statuses.push(*status); + } + assert_eq!(statuses, vec![expected_status; 2], "{case:?}: each existing route preserves extractor status"); + }).await; + service.shutdown().await.unwrap(); + if let Err(error) = outcome { + std::panic::resume_unwind(error.into_panic()); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn management_json_rejection_syntax_400() { + json_refusal_at_both_routes(JsonRefusal::Syntax, 400).await; +} +#[tokio::test(flavor = "multi_thread")] +async fn management_json_rejection_missing_content_type_415() { + json_refusal_at_both_routes(JsonRefusal::MissingContentType, 415).await; +} +#[tokio::test(flavor = "multi_thread")] +async fn management_json_rejection_non_json_content_type_415() { + json_refusal_at_both_routes(JsonRefusal::NonJsonContentType, 415).await; +} +#[tokio::test(flavor = "multi_thread")] +async fn management_json_rejection_unknown_field_422() { + json_refusal_at_both_routes(JsonRefusal::UnknownField, 422).await; +} +#[tokio::test(flavor = "multi_thread")] +async fn management_json_rejection_wrong_type_422() { + json_refusal_at_both_routes(JsonRefusal::WrongType, 422).await; +} +#[tokio::test(flavor = "multi_thread")] +async fn management_json_rejection_configured_body_limit_413() { + json_refusal_at_both_routes(JsonRefusal::OverLimit, 413).await; +} +#[tokio::test(flavor = "multi_thread")] +async fn management_configured_bearer_missing_authorization_401() { + json_refusal_at_both_routes(JsonRefusal::MissingAuthorization, 401).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn management_json_and_suffix_media_types_reach_existing_business_routes() { + for content_type in ["application/json", "application/vnd.maintenance+json"] { + let (state, source, restore) = race_source(2).await; + let service = state.service(); + let provider = Arc::new(crate::ScriptedProvider::new()); + provider.push_ok(response(None)); + service.inject_provider_host(provider); + let observed = service.clone(); + let outcome = tokio::spawn(async move { + let results = http_inputs( + state.clone(), + action_cases(&source, &restore, &source.workspace_id) + .into_iter() + .map(|(path, body)| HttpInput { + path, + body: body.to_string(), + content_type: Some(content_type), + authenticated: true, + }) + .collect(), + ) + .await; + assert_eq!( + results[0].0, 201, + "{content_type}: original checkpoint receipt" + ); + assert_eq!( + results[1].0, 202, + "{content_type}: original restore admission" + ); + let receipt: Value = serde_json::from_slice(&results[1].1).unwrap(); + let new_run = receipt["new_run_id"].as_str().unwrap(); + tokio::time::timeout(Duration::from_secs(10), async { + while !observed + .run_events(new_run) + .iter() + .any(|e| e["event"] == "run.completed") + { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap(); + let rt = observed.managed_runtime_for_test().unwrap(); + assert!( + rt.repository + .checkpoint_receipt(&source, &read_clock(), None) + .unwrap() + .is_some() + ); + let durable = rt + .repository + .lookup_restore_receipt(&restore, &read_clock()) + .unwrap() + .unwrap(); + assert_eq!(durable.new_run_id, new_run); + }) + .await; + service.shutdown().await.unwrap(); + if let Err(error) = outcome { + std::panic::resume_unwind(error.into_panic()); + } + } +} + +fn action_cases( + source: &ManagedCheckpointCreateRequest, + restore: &ManagedRestoreAndAdmitRequest, + path_workspace: &str, +) -> Vec<(String, Value)> { + vec![ + ( + format!("/v1/workspaces/{path_workspace}/checkpoints"), + serde_json::to_value(source).unwrap(), + ), + ( + format!("/v1/workspaces/{path_workspace}/restore"), + serde_json::to_value(restore).unwrap(), + ), + ] +} +fn assert_static_denial(status: u16, body: &Value) { + assert!( + matches!(status, 400 | 401 | 403 | 409 | 422 | 503), + "management route must explicitly reject, never route-absent 404 or success: {status}" + ); + let code = body["error"]["code"] + .as_str() + .expect("static redacted management error code"); + assert!(matches!( + code, + "managed_authority_required" + | "managed_management_denied" + | "managed_source_conflict" + | "managed_request_invalid" + | "managed_storage_unavailable" + )); + assert_eq!(body["error"]["message"], code); +} + +#[tokio::test(flavor = "multi_thread")] +async fn management_routes_fail_closed_scope_matrix() { + let (state, mut source, restore) = race_source(2).await; + let service = state.service(); + let rt = service.managed_runtime_for_test().unwrap(); + source.request_id = uuid::Uuid::new_v4().to_string(); + let before = rt + .repository + .checkpoint_context(&source, &read_clock()) + .unwrap(); + let before_runs = service.inner.store.read().runs.len(); + let before_sessions = service.inner.store.read().sessions.len(); + let other = format!("ws-{}", uuid::Uuid::new_v4().simple()); + let mut cases = action_cases(&source, &restore, &other); // mismatched path/body identity + let mut cross = source.clone(); + cross.workspace_id = other.clone(); + let mut cross_restore = restore.clone(); + cross_restore.workspace_id = other.clone(); + cross_restore.expected_source.workspace_id = other.clone(); + cases.extend(action_cases(&cross, &cross_restore, &other)); + let (other_state, foreign_source, _) = race_source(1).await; + let mut foreign = source.clone(); + foreign.source_run_id = foreign_source.source_run_id; + cases.push(( + format!("/v1/workspaces/{}/checkpoints", source.workspace_id), + serde_json::to_value(foreign).unwrap(), + )); + for field in ["profile", "owner", "authority", "workspace_path"] { + for (path, mut body) in action_cases(&source, &restore, &source.workspace_id) { + body[field] = json!("synthetic-untrusted-scope-value"); + cases.push((path, body)); + } + } + let results = http_denials(state.clone(), cases).await; + let after = rt + .repository + .checkpoint_context(&source, &read_clock()) + .unwrap(); + let absent = rt + .repository + .checkpoint_receipt(&source, &read_clock(), None) + .unwrap() + .is_none(); + let restore_absent = rt + .repository + .lookup_restore_receipt(&restore, &read_clock()) + .unwrap() + .is_none(); + let after_runs = service.inner.store.read().runs.len(); + let after_sessions = service.inner.store.read().sessions.len(); + service.shutdown().await.unwrap(); + other_state.service().shutdown().await.unwrap(); + assert_eq!(results.len(), 13); + for (index, (status, body)) in results.into_iter().enumerate() { + let (expected_status, code) = if index < 5 { + // Path/body mismatch and real foreign workspace/run identity. + (403, "managed_authority_required") + } else { + // Additional profile/owner/path keys are DTO schema errors only. + (422, "managed_request_invalid") + }; + assert_eq!(status, expected_status, "scope matrix case {index}"); + assert_eq!(body, json!({"error":{"code":code,"message":code}})); + } + assert_eq!(before.head, after.head); + assert!(absent && restore_absent); + assert_eq!(before_runs, after_runs); + assert_eq!(before_sessions, after_sessions); +} + +#[tokio::test(flavor = "multi_thread")] +async fn management_routes_fail_closed_policy_native_and_nonmanaged_entries() { + let (source_state, source, restore) = race_source(1).await; + let (_root, policy_state) = fixture_config(|config| { + config.bearer_token = Some("synthetic-maintenance-fixture".into()); + }); + let (root, native_state) = fixture_config(management_config); + let denied_home = root.join("no-workspace-policy"); + fs::create_dir(&denied_home).unwrap(); + let native_state = native_state.with_workspace_home(denied_home); + let direct = AgentGatewayState::new(AgentGatewayConfig { + bearer_token: Some("synthetic-maintenance-fixture".into()), + ..Default::default() + }) + .unwrap(); + let mut results = Vec::new(); + for state in [&policy_state, &native_state, &direct] { + let service = state.service(); + let before_runs = service.inner.store.read().runs.len(); + let before_sessions = service.inner.store.read().sessions.len(); + results.extend( + http_denials( + state.clone(), + action_cases(&source, &restore, &source.workspace_id), + ) + .await, + ); + assert_eq!(service.inner.store.read().runs.len(), before_runs); + assert_eq!(service.inner.store.read().sessions.len(), before_sessions); + // CLI/import/stateless embeddings have no management HTTP surface of + // their own: call the original Native service owner, never invent a route. + assert!( + service + .create_managed_checkpoint(source.clone()) + .await + .is_err() + ); + assert!( + service + .restore_managed_checkpoint_and_admit(restore.clone()) + .await + .is_err() + ); + assert_eq!(service.inner.store.read().runs.len(), before_runs); + service.shutdown().await.unwrap(); + } + // Ordinary nonmanaged operation remains usable in the same default direct + // embedding after management denial. + let provider = Arc::new(crate::ScriptedProvider::new()); + provider.push_ok(response(None)); + let ordinary_state = AgentGatewayState::new(AgentGatewayConfig::default()).unwrap(); + let ordinary_service = ordinary_state.service(); + ordinary_service.inject_provider_host(provider); + let ordinary = ordinary_service + .admit(AdmitRunRequest { + input: json!({"message":"ordinary direct remains supported"}), + ..Default::default() + }) + .await + .unwrap(); + ordinary_service + .clone() + .run_worker(ordinary.run_id.clone(), String::new()) + .await; + assert!( + ordinary_service + .run_events(&ordinary.run_id) + .iter() + .any(|e| e["event"] == "run.completed") + ); + ordinary_service.shutdown().await.unwrap(); + source_state.service().shutdown().await.unwrap(); + for (status, body) in results { + assert_static_denial(status, &body); + } +} + +#[derive(Default)] +struct Latch { + state: std::sync::Mutex<(usize, bool)>, + changed: std::sync::Condvar, +} +impl Latch { + fn arrive(&self) { + let mut state = self.state.lock().unwrap(); + state.0 += 1; + self.changed.notify_all(); + while !state.1 { + let (next, timeout) = self + .changed + .wait_timeout(state, Duration::from_secs(4)) + .unwrap(); + state = next; + assert!(!timeout.timed_out(), "owned explicit latch release"); + } + } + fn wait(&self, n: usize) { + let mut state = self.state.lock().unwrap(); + while state.0 < n { + let (next, timeout) = self + .changed + .wait_timeout(state, Duration::from_secs(3)) + .unwrap(); + state = next; + assert!( + !timeout.timed_out(), + "actual linearization boundary arrival" + ); + } + } + fn release(&self) { + self.state.lock().unwrap().1 = true; + self.changed.notify_all(); + } +} +struct LatchRelease(Arc); +impl Drop for LatchRelease { + fn drop(&mut self) { + self.0.release(); + } +} + +async fn race_source( + capacity: usize, +) -> ( + AgentGatewayState, + ManagedCheckpointCreateRequest, + ManagedRestoreAndAdmitRequest, +) { + let (_root, state) = fixture_config(|config| { + management_config(config); + config.max_concurrent_runs = capacity; + }); + let service = state.service(); + let provider = Arc::new(crate::ScriptedProvider::new()); + provider.push_ok(response(Some( + json!({"id":"cas-a","name":"write_file","arguments":{"path":"value.txt","content":"A"}}), + ))); + provider.push_ok(response(None)); + service.inject_provider_host(provider); + let run = admitted(&service).await; + let bound = binding(&service, &run); + let source = checkpoint_request(&bound); + service.clone().run_worker(run.run_id, String::new()).await; + let restore = super::protocol::restore_request(&state, &source).await; + (state, source, restore) +} + +#[tokio::test(flavor = "multi_thread")] +async fn current_branch_default_and_distinct_request_cas_race() { + let (state, source, request) = race_source(1).await; + let service = state.service(); + let p = state.persistence().unwrap(); + let rt = service.managed_runtime_for_test().unwrap(); + let original_runs = service.inner.store.read().runs.len(); + let gate = Arc::new(Latch::default()); + let release = LatchRelease(gate.clone()); + let observed = gate.clone(); + *p.managed_request_hook.lock().unwrap() = Some(Arc::new(move |op, _, enqueued| { + if op == "effect.checkpoint_context" && !enqueued { + observed.arrive(); + } + })); + let mut rival = request.clone(); + rival.request_id = uuid::Uuid::new_v4().to_string(); + let s = service.clone(); + let a = request.clone(); + let first = tokio::spawn(async move { s.restore_managed_checkpoint_and_admit(a).await }); + let s = service.clone(); + let b = rival.clone(); + let second = tokio::spawn(async move { s.restore_managed_checkpoint_and_admit(b).await }); + gate.wait(2); + drop(release); + let a = first.await.unwrap(); + let b = second.await.unwrap(); + *p.managed_request_hook.lock().unwrap() = None; + assert_eq!( + usize::from(a.is_ok()) + usize::from(b.is_ok()), + 1, + "different request IDs competing on the same SOURCE yield one winner" + ); + let (winner, loser_request, loser) = match (a, b) { + (Ok(a), Err(b)) => (a, rival, b), + (Err(a), Ok(b)) => (b, request, a), + _ => unreachable!(), + }; + assert!(matches!( + loser.to_string().as_str(), + "managed_restore_busy" | "managed_source_conflict" + )); + assert!( + rt.repository + .lookup_restore_receipt(&loser_request, &read_clock()) + .unwrap() + .is_none() + ); + assert_eq!(service.inner.store.read().runs.len(), original_runs + 1); + let restored_run = service.handle(&winner.new_run_id).unwrap(); + let restored_binding = restored_run + .pinned_tool + .as_ref() + .unwrap() + .managed + .clone() + .unwrap(); + assert_eq!( + restored_binding.admitted_branch.branch_id, + winner.new_branch.branch_id + ); + let provider = Arc::new(crate::ScriptedProvider::new()); + provider.push_ok(response(None)); + service.inject_provider_host(provider); + assert!(service.start_managed_restore_worker(&winner.new_run_id)); + assert!( + !service.start_managed_restore_worker(&winner.new_run_id), + "same restored worker starts once" + ); + tokio::time::timeout(Duration::from_secs(5), async { + while !restored_run + .terminal + .load(std::sync::atomic::Ordering::Acquire) + { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .unwrap(); + let provider = Arc::new(crate::ScriptedProvider::new()); + provider.push_ok(response(Some(json!({"id":"default-new","name":"write_file","arguments":{"path":"new.txt","content":"new-current"}})))); + provider.push_ok(response(None)); + service.inject_provider_host(provider); + let ordinary = admitted(&service).await; + let ordinary_binding = binding(&service, &ordinary); + service + .clone() + .run_worker(ordinary.run_id.clone(), String::new()) + .await; + assert_eq!( + ordinary_binding.admitted_branch.branch_id, + winner.new_branch.branch_id + ); + assert_ne!( + ordinary_binding.admitted_branch.branch_id, + source.source_branch_id + ); + assert_eq!(ordinary_binding.admitted_epoch(), winner.new_branch.epoch); + let record = p + .managed_effect_get(&ManagedEffectGetRequest { + owner: ordinary_binding.owner.clone(), + call_id: "default-new".into(), + }) + .unwrap() + .unwrap(); + assert_eq!( + record.outcome.unwrap().disposition, + ManagedPublicationStatus::Committed + ); + service.shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn restore_loses_to_actual_source_commit_at_preparation_boundary() { + let (state, source, request) = race_source(2).await; + let service = state.service(); + let p = state.persistence().unwrap(); + let rt = service.managed_runtime_for_test().unwrap(); + let before_runs = service.inner.store.read().runs.len(); + let gate = Arc::new(Latch::default()); + let release = LatchRelease(gate.clone()); + let observer = gate.clone(); + *p.managed_request_hook.lock().unwrap() = Some(Arc::new(move |op, _, enqueued| { + if op == "effect.checkpoint_context" && !enqueued { + observer.arrive(); + } + })); + let s = service.clone(); + let req = request.clone(); + let restore = tokio::spawn(async move { s.restore_managed_checkpoint_and_admit(req).await }); + gate.wait(1); + let provider = Arc::new(crate::ScriptedProvider::new()); + provider.push_ok(response(Some( + json!({"id":"cas-b","name":"write_file","arguments":{"path":"value.txt","content":"B"}}), + ))); + provider.push_ok(response(None)); + service.inject_provider_host(provider); + let writer = admitted(&service).await; + let writer_binding = binding(&service, &writer); + let result = tokio::spawn( + service + .clone() + .run_worker(writer.run_id.clone(), String::new()), + ) + .await; + drop(release); + let restored = restore.await.unwrap(); + *p.managed_request_hook.lock().unwrap() = None; + result.unwrap(); + let record = p + .managed_effect_get(&ManagedEffectGetRequest { + owner: writer_binding.owner.clone(), + call_id: "cas-b".into(), + }) + .unwrap() + .unwrap(); + assert_eq!( + record.outcome.unwrap().disposition, + ManagedPublicationStatus::Committed + ); + assert_eq!(restored.unwrap_err().to_string(), "managed_source_conflict"); + assert!( + rt.repository + .lookup_restore_receipt(&request, &read_clock()) + .unwrap() + .is_none() + ); + assert_eq!(service.inner.store.read().runs.len(), before_runs + 1); + let head = rt + .repository + .checkpoint_context(&source, &read_clock()) + .unwrap() + .head; + assert_eq!(head.epoch, request.expected_source.epoch); + assert_ne!(head.head_object_id, request.expected_source.head_object_id); + service.shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn restore_races_ordinary_admission_and_rechecks_related_inputs() { + let (state, source, request) = race_source(2).await; + let service = state.service(); + let gate = Arc::new(Latch::default()); + let release = LatchRelease(gate.clone()); + let observer = gate.clone(); + let preparations = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let count = preparations.clone(); + service.inject_prompt_read_entered_observer(Arc::new(move || { + if count.fetch_add(1, std::sync::atomic::Ordering::AcqRel) == 0 { + observer.arrive(); + } + })); + let s = service.clone(); + let ordinary = tokio::spawn(async move { admitted(&s).await }); + gate.wait(1); + let first_preparation = std::time::Instant::now(); + // A related provider projection changes while ordinary prompt IO is paused. + service + .set_provider_profile( + crate::config::ProviderProfile::new( + "local-agent", + json!({"protocol":"provider","temperature":0.375}), + ) + .unwrap(), + ) + .unwrap(); + // An unrelated generation is not itself an admission failure condition. + service + .inner + .store_generation + .fetch_add(1, std::sync::atomic::Ordering::AcqRel); + let restored = service.restore_managed_checkpoint_and_admit(request).await; + drop(release); + let ordinary = ordinary.await.unwrap(); + *service.inner.prompt_read_entered.lock().unwrap() = None; + let restored = restored.unwrap(); + let bound = binding(&service, &ordinary); + assert_eq!( + bound.admitted_branch.branch_id, + restored.new_branch.branch_id + ); + assert_ne!(bound.admitted_branch.branch_id, source.source_branch_id); + assert_eq!( + service + .run_context(&ordinary.run_id) + .unwrap() + .provider_options["temperature"], + 0.375 + ); + assert!(preparations.load(std::sync::atomic::Ordering::Acquire) >= 2); + let handle = service.handle(&ordinary.run_id).unwrap(); + let clock = handle.cancel.deadline_instant().unwrap(); + assert!( + clock <= first_preparation + service.inner.config.run_timeout, + "reprepare keeps the ordinary admission's original bounded clock" + ); + // Both successful admissions own distinct current-branch bindings and a + // single original capacity permit; finish through their actual workers. + for id in [&restored.new_run_id, &ordinary.run_id] { + let provider = Arc::new(crate::ScriptedProvider::new()); + provider.push_ok(response(None)); + service.inject_provider_host(provider); + service.clone().run_worker(id.clone(), String::new()).await; + } + service.shutdown().await.unwrap(); +} + +#[derive(Default)] +struct ProviderBarrier { + entered: std::sync::atomic::AtomicBool, + fail: std::sync::atomic::AtomicBool, +} +impl crate::AgentProviderHost for ProviderBarrier { + fn call(&self, _: &Value, cancellation: &crate::RunCancellation) -> Value { + use std::sync::atomic::Ordering; + self.entered.store(true, Ordering::Release); + while !self.fail.load(Ordering::Acquire) + && cancellation.requested().is_none() + && !cancellation.deadline_passed() + { + std::thread::sleep(Duration::from_millis(2)); + } + json!({"ok":false,"error":{"code":"synthetic_provider_failed","message":"owned synthetic provider failure","retryable":false}}) + } +} +struct ProviderRelease(Arc); +impl Drop for ProviderRelease { + fn drop(&mut self) { + self.0 + .fail + .store(true, std::sync::atomic::Ordering::Release); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn checkpoint_rejects_unsettled_provider_original_deadline() { + unsettled(4, "deadline").await; +} +#[tokio::test(flavor = "multi_thread")] +async fn checkpoint_rejects_unsettled_provider_management_deadline() { + unsettled(30, "management").await; +} +#[tokio::test(flavor = "multi_thread")] +async fn checkpoint_rejects_unsettled_provider_failed() { + unsettled(30, "failed").await; +} +#[tokio::test(flavor = "multi_thread")] +async fn checkpoint_rejects_unsettled_provider_cancelled() { + unsettled(30, "cancelled").await; +} +async fn unsettled(seconds: u64, mode: &'static str) { + let (_root, state) = fixture_config(|config| { + management_config(config); + config.run_timeout = Duration::from_secs(seconds); + }); + let service = state.service(); + let provider = Arc::new(ProviderBarrier::default()); + let release = ProviderRelease(provider.clone()); + service.inject_provider_host(provider.clone()); + let run = admitted(&service).await; + let bound = binding(&service, &run); + let original = bound.cancel.clone(); + let context = service.run_context(&run.run_id).unwrap(); + let request = checkpoint_request(&bound); + let worker = tokio::spawn( + service + .clone() + .run_worker(run.run_id.clone(), String::new()), + ); + let rt = service.managed_runtime_for_test().unwrap(); + tokio::time::timeout(Duration::from_secs(3), async { + while !provider.entered.load(std::sync::atomic::Ordering::Acquire) { + tokio::time::sleep(Duration::from_millis(2)).await; + } + }) + .await + .unwrap(); + assert!( + service.has_provider_request(&run.run_id, 1), + "barrier follows real durable provider request" + ); + let before = rt + .repository + .checkpoint_context(&request, &read_clock()) + .unwrap(); + let s = service.clone(); + let req = request.clone(); + let started = std::time::Instant::now(); + let checkpoint = tokio::spawn(async move { s.create_managed_checkpoint(req).await }); + let probe = service.clone(); + let r = rt.clone(); + let branch = bound.admitted_branch.branch_id.clone(); + let handle = service.handle(&run.run_id).unwrap(); + let p = provider.clone(); + let check = tokio::spawn(async move { + paused(&r, &branch).await; + assert!(probe.inner.commit_gate.try_lock().is_some()); + if mode == "failed" { + p.fail.store(true, std::sync::atomic::Ordering::Release); + } + if mode == "cancelled" { + handle.request_user_stop(); + } + }) + .await; + let result = tokio::time::timeout(Duration::from_secs(6), checkpoint).await; + drop(release); + let worker_result = worker.await; + let after = rt + .repository + .checkpoint_context(&request, &read_clock()) + .unwrap(); + let absent = rt + .repository + .checkpoint_receipt(&request, &read_clock(), None) + .unwrap() + .is_none(); + let same_clock = bound.cancel.shares_original_clock(&original); + let same_deadline = bound.cancel.deadline_instant() == original.deadline_instant(); + let same_limits = service.run_context(&run.run_id).unwrap().limits == context.limits; + let unpaused = !rt.checkpoint_pausing_for_test(&request.source_branch_id); + service.shutdown().await.unwrap(); + if let Err(error) = check { + std::panic::resume_unwind(error.into_panic()); + } + worker_result.unwrap(); + let result = result + .expect("owned provider wait releases within management bound") + .unwrap(); + assert!( + result.is_err(), + "unsettled {mode} must reject without a checkpoint: {result:?}" + ); + assert_eq!(result.unwrap_err().to_string(), "managed_checkpoint_busy"); + assert!(absent, "unsettled provider cannot publish a receipt"); + assert!(same_clock && same_deadline && same_limits && unpaused); + assert_eq!(before.head, after.head); + assert!(after.effects.is_empty()); + assert!( + after.messages.iter().all(|m| m.tool_call_id.is_none()), + "no half tool history" + ); + if mode == "deadline" { + assert!( + started.elapsed() < Duration::from_secs(5), + "original remaining run deadline is the tighter bound" + ); + } +} + +fn management_config(config: &mut AgentGatewayConfig) { + config.bearer_token = Some("synthetic-maintenance-fixture".into()); + let policy = &mut config + .managed_workspace + .as_mut() + .unwrap() + .maintenance_policy; + policy.create_checkpoint = true; + policy.restore = true; +} +async fn admitted(service: &Arc) -> crate::AdmittedRun { + service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"synthetic actual linearization fixture"}), + ..Default::default() + }, + None, + ) + .await + .unwrap() +} +fn binding( + service: &crate::AgentService, + run: &crate::AdmittedRun, +) -> Arc { + service + .handle(&run.run_id) + .unwrap() + .pinned_tool + .as_ref() + .unwrap() + .managed + .clone() + .unwrap() +} +fn checkpoint_request( + binding: &crate::effects::broker::ManagedRunBinding, +) -> ManagedCheckpointCreateRequest { + ManagedCheckpointCreateRequest { + source_run_id: binding.owner.run_id.clone(), + workspace_id: binding.admitted_branch.workspace_id.clone(), + source_branch_id: binding.admitted_branch.branch_id.clone(), + expected_epoch: binding.admitted_epoch(), + request_id: uuid::Uuid::new_v4().to_string(), + mode: ManagedCheckpointMode::AtClosedBatch, + } +} +fn read_clock() -> crate::RunCancellation { + crate::RunCancellation::with_timeout(Duration::from_secs(5)) +} +async fn paused(runtime: &crate::effects::broker::ManagedEffectRuntime, branch: &str) { + tokio::time::timeout(Duration::from_secs(3), async { + while !runtime.checkpoint_pausing_for_test(branch) { + tokio::time::sleep(Duration::from_millis(2)).await; + } + }) + .await + .expect("actual checkpoint pause publication"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn checkpoint_waits_for_whole_tool_batch_complete() { + let (_root, state) = fixture_config(management_config); + let (_other_root, other_state) = fixture_config(management_config); + let other = other_state.service(); + let service = state.service(); + let provider = Arc::new(crate::ScriptedProvider::new()); + provider.push_ok(json!({"text":"","tool_calls":[ + {"id":"whole-first","name":"write_file","arguments":{"path":"first.txt","content":"one"}}, + {"id":"whole-second","name":"write_file","arguments":{"path":"second.txt","content":"two"}} + ],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2},"reasoning":"","stop_reason":"tool_calls"})); + provider.push_ok(response(None)); + service.inject_provider_host(provider.clone()); + let run = admitted(&service).await; + let bound = binding(&service, &run); + let original_clock = bound.cancel.clone(); + let original_context = service.run_context(&run.run_id).unwrap(); + let request = checkpoint_request(&bound); + let gate = service + .arm_managed_call_prepare_gate("whole-second") + .unwrap(); + let worker = tokio::spawn( + service + .clone() + .run_worker(run.run_id.clone(), String::new()), + ); + gate.wait_arrived(1).unwrap(); + let p = state.persistence().unwrap(); + let first_request = ManagedEffectGetRequest { + owner: bound.owner.clone(), + call_id: "whole-first".into(), + }; + let second_request = ManagedEffectGetRequest { + owner: bound.owner.clone(), + call_id: "whole-second".into(), + }; + let first = p.managed_effect_get(&first_request).unwrap().unwrap(); + let second = p.managed_effect_get(&second_request).unwrap().unwrap(); + let rt = service.managed_runtime_for_test().unwrap(); + let s = service.clone(); + let req = request.clone(); + let checkpoint = tokio::spawn(async move { s.create_managed_checkpoint(req).await }); + let other_provider = Arc::new(crate::ScriptedProvider::new()); + other_provider.push_ok(response(Some(json!({"id":"other-write","name":"write_file","arguments":{"path":"other.txt","content":"independent"}})))); + other_provider.push_ok(response(None)); + other.inject_provider_host(other_provider); + let observed = service.clone(); + let independent = other.clone(); + let branch = bound.admitted_branch.branch_id.clone(); + let request_probe = request.clone(); + let runtime = rt.clone(); + let check = tokio::spawn(async move { + paused(&runtime, &branch).await; + assert!( + first.outcome.is_some(), + "first output was durable before second real production call paused" + ); + assert!( + second.outcome.is_none(), + "second actual call has no durable output yet" + ); + assert!( + runtime + .repository + .checkpoint_receipt(&request_probe, &read_clock(), None) + .unwrap() + .is_none() + ); + let context = runtime + .repository + .checkpoint_context(&request_probe, &read_clock()) + .unwrap(); + assert_eq!( + context + .effects + .iter() + .filter(|effect| effect.outcome.is_some()) + .count(), + 1 + ); + assert!( + observed.inner.commit_gate.try_lock().is_some(), + "global commit gate is released while waiting on real tool IO" + ); + let other_run = tokio::time::timeout(Duration::from_secs(3), admitted(&independent)) + .await + .expect("other workspace admit cannot wait on batch IO"); + let other_binding = binding(&independent, &other_run); + assert_ne!( + other_binding.admitted_branch.workspace_id, + bound.admitted_branch.workspace_id + ); + tokio::time::timeout( + Duration::from_secs(3), + independent + .clone() + .run_worker(other_run.run_id.clone(), String::new()), + ) + .await + .expect("other workspace actual commit cannot wait on batch IO"); + let result = independent + .inner + .persistence + .as_ref() + .unwrap() + .managed_effect_get(&ManagedEffectGetRequest { + owner: other_binding.owner.clone(), + call_id: "other-write".into(), + }) + .unwrap() + .unwrap(); + assert_eq!( + result.outcome.unwrap().disposition, + ManagedPublicationStatus::Committed + ); + assert!(bound.cancel.shares_original_clock(&original_clock)); + assert_eq!( + bound.cancel.deadline_instant(), + original_clock.deadline_instant() + ); + assert!( + bound.cancel.requested().is_none(), + "checkpoint pause never requests user stop" + ); + assert_eq!( + observed.run_context(&run.run_id).unwrap().limits, + original_context.limits + ); + assert_eq!( + provider.requests().len(), + 1, + "no next provider invocation while original batch is draining" + ); + }) + .await; + let premature = checkpoint.is_finished(); + gate.release(); + let worker_result = worker.await; + let checkpoint_result = checkpoint.await; + let first = p.managed_effect_get(&first_request).unwrap().unwrap(); + let second = p.managed_effect_get(&second_request).unwrap().unwrap(); + let final_context = rt + .repository + .checkpoint_context(&request, &read_clock()) + .unwrap(); + service.shutdown().await.unwrap(); + other.shutdown().await.unwrap(); + other_state.service().shutdown().await.unwrap(); + if let Err(error) = check { + std::panic::resume_unwind(error.into_panic()); + } + worker_result.unwrap(); + assert!( + !premature, + "no mixed checkpoint before second durable output" + ); + let receipt = checkpoint_result + .unwrap() + .expect("same enumerated batch closes using original tokens"); + let refs = &receipt.checkpoint.history.closed_output_refs; + assert_eq!(refs.len(), 2); + for record in [first, second] { + let output = refs + .iter() + .find(|r| r.call_id == record.identity.call_id) + .unwrap(); + let outcome = record.outcome.unwrap(); + assert_eq!(output.run_id, record.identity.owner.run_id); + let message = final_context + .messages + .iter() + .find(|m| m.id == outcome.message.id) + .unwrap(); + assert_eq!( + output.parent_message_id, + message.parent_message_id.as_ref().unwrap().as_str() + ); + assert_eq!(output.output_message_id, outcome.message.id); + assert_eq!(output.receipt_id, record.prepared.unwrap().effect_id); + assert_eq!(output.result, outcome.canonical_result_ref); + } +} diff --git a/src/service/managed_protocol_tests.rs b/src/service/managed_protocol_tests.rs index a5aaaa2..c12c86f 100644 --- a/src/service/managed_protocol_tests.rs +++ b/src/service/managed_protocol_tests.rs @@ -1,6 +1,2166 @@ use super::*; +#[tokio::test(flavor = "multi_thread")] +async fn guardian207_toolonly_ref_requirement_and_artifact_consumers() { + use crate::AgentProviderHost; + for fault in ["drop_requirement", "replace_ref", "artifact"] { + let (root, state) = fixture(); + let service = state.service(); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"typed full ToolOnly guard"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + let handle = service.handle(&run.run_id).unwrap(); + let p = state.persistence().unwrap(); + let provider = Arc::new(crate::ScriptedProvider::new()); + provider.push_ok(response(None)); + provider.push_ok(response(None)); + let durable = crate::durable_provider::DurableProviderHost::new( + (*service).clone(), + run.run_id.clone(), + provider.clone(), + service.inner.metrics.clone(), + ); + let request = json!({"model":"synthetic","messages":[]}); + assert_eq!(durable.call(&request, &handle.cancel)["ok"], true); + let mut held = None; + if fault == "artifact" { + let graph = crate::plugins::PluginControlStore::new(&p) + .load_lock(handle.pinned_tool.as_ref().unwrap().graph_id()) + .unwrap() + .lock; + let path = crate::plugins::trusted_tool_package_root(&root.join("gateway.sqlite")) + .join("artifacts") + .join(&graph.packages[0].artifact_id); + let aside = root.join("held-artifact"); + fs::rename(&path, &aside).unwrap(); + held = Some((path, aside)); + } else { + p.mutate_graph_pin_for_test(&run.run_id, fault).unwrap(); + } + let expected = if fault == "artifact" { + "plugin_artifact_unavailable" + } else { + "plugin_graph_pin_required" + }; + assert_eq!( + service.check_harness_run(&run.run_id, &handle.cancel), + Err(expected), + "{fault}" + ); + assert_eq!( + durable.call(&request, &handle.cancel)["error"]["code"], + expected, + "{fault}" + ); + assert_eq!(provider.requests().len(), 1); + let context = serde_json::to_value(service.run_context(&run.run_id).unwrap()).unwrap(); + assert_eq!( + service.refresh_provider_context(&run.run_id, &context, &handle.cancel)["error"]["code"], + expected, + "{fault}" + ); + if let Some((path, aside)) = held { + fs::rename(aside, path).unwrap(); + } + service.shutdown().await.unwrap(); + } +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn guardian207_toolonly_ref_and_artifact_checkpoint_consumers() { + for fault in ["drop_requirement", "replace_ref", "artifact"] { + let (root, state, request) = maintenance_source().await; + let service = state.service(); + let handle = service.handle(&request.source_run_id).unwrap(); + let p = state.persistence().unwrap(); + let mut held = None; + if fault == "artifact" { + let graph = crate::plugins::PluginControlStore::new(&p) + .load_lock(handle.pinned_tool.as_ref().unwrap().graph_id()) + .unwrap() + .lock; + let path = crate::plugins::trusted_tool_package_root(&root.join("gateway.sqlite")) + .join("artifacts") + .join(&graph.packages[0].artifact_id); + let aside = root.join("held-artifact"); + fs::rename(&path, &aside).unwrap(); + held = Some((path, aside)); + } else { + p.mutate_graph_pin_for_test(&request.source_run_id, fault) + .unwrap(); + } + let expected = if fault == "artifact" { + "plugin_artifact_unavailable" + } else { + "plugin_graph_pin_required" + }; + assert_eq!( + service + .create_managed_checkpoint(request.clone()) + .await + .unwrap_err() + .to_string(), + expected, + "{fault}" + ); + if let Some((path, aside)) = held { + fs::rename(aside, path).unwrap(); + } + let clock = crate::RunCancellation::with_timeout(std::time::Duration::from_secs(5)); + assert!( + service + .managed_runtime_for_test() + .unwrap() + .repository + .checkpoint_receipt(&request, &clock, None) + .unwrap() + .is_none() + ); + service.shutdown().await.unwrap(); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn guardian207_managed_missing_durable_binding_or_runtime_refuses_reconstruction() { + for fault in ["drop_binding", "runtime"] { + let (_root, state) = fixture(); + let service = state.service(); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"missing original managed facts"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + let p = state.persistence().unwrap(); + if fault == "drop_binding" { + p.mutate_graph_pin_for_test(&run.run_id, "drop_binding") + .unwrap(); + } else { + *service.inner.managed_effects.write() = None; + } + service.evict_run_handle(&run.run_id); + assert!( + matches!( + service.restore_handle_from_frozen_context(&run.run_id), + Err(crate::AdmitError::PluginGraphPinRequired) + ), + "{fault}" + ); + assert!(service.handle(&run.run_id).is_none()); + service.shutdown().await.unwrap(); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn guardian207_stateless_affirmative_proof_and_storage_uncertainty() { + let (_root, state) = fixture_config(|config| config.managed_workspace = None); + let service = state.service(); + service + .inner + .stateless_tools + .store(true, std::sync::atomic::Ordering::Release); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"explicit stateless positive"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + let handle = service.handle(&run.run_id).unwrap(); + assert!(matches!( + handle.native_disposition, + Some(crate::service::NativeRuntimeDisposition::ExplicitStateless( + _ + )) + )); + assert_eq!( + service.check_harness_run(&run.run_id, &handle.cancel), + Ok(()) + ); + // Later global activation intent cannot reclassify the original proof. + service + .inner + .stateless_tools + .store(false, std::sync::atomic::Ordering::Release); + assert_eq!( + service.check_harness_run(&run.run_id, &handle.cancel), + Ok(()) + ); + state.persistence().unwrap().inject_persist_failure(); + assert_eq!( + service.check_harness_run(&run.run_id, &handle.cancel), + Err("plugin_graph_pin_required"), + "N01 typed read error is uncertainty, not physical row deletion" + ); + assert_eq!( + service.check_harness_run(&run.run_id, &handle.cancel), + Ok(()) + ); + service.shutdown().await.unwrap(); +} + +async fn guardian207_toolonly_consumer(revoke_provider: bool) { + use crate::AgentProviderHost; + let (_root, state) = fixture(); + let service = state.service(); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"guard consumer"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + let handle = service.handle(&run.run_id).unwrap(); + let p = state.persistence().unwrap(); + let control = crate::plugins::PluginControlStore::new(&p); + let provider = Arc::new(crate::ScriptedProvider::new()); + provider.push_ok(response(None)); + provider.push_ok(response(None)); + let durable = crate::durable_provider::DurableProviderHost::new( + (*service).clone(), + run.run_id.clone(), + provider.clone(), + service.inner.metrics.clone(), + ); + let request = json!({"model":"synthetic","messages":[]}); + let context = serde_json::to_value(service.run_context(&run.run_id).unwrap()).unwrap(); + if revoke_provider { + assert_eq!(durable.call(&request, &handle.cancel)["ok"], true); + assert_eq!(provider.requests().len(), 1); + } else { + assert_eq!( + service.refresh_provider_context(&run.run_id, &context, &handle.cancel)["ok"], + true + ); + } + control + .revoke_graph( + &service.tool_store().unwrap(), + handle.pinned_tool.as_ref().unwrap().graph_id(), + ) + .unwrap(); + if revoke_provider { + let denied = durable.call(&request, &handle.cancel); + assert_eq!( + denied["error"]["code"], "plugin_graph_revoked", + "F02 durable provider must refuse revoked ToolOnly before transport" + ); + assert_eq!(provider.requests().len(), 1); + } else { + let denied = service.refresh_provider_context(&run.run_id, &context, &handle.cancel); + assert_eq!( + denied["error"]["code"], "plugin_graph_revoked", + "F02 context refresh must refuse revoked ToolOnly" + ); + } + service.shutdown().await.unwrap(); +} +#[tokio::test(flavor = "multi_thread")] +async fn guardian207_toolonly_revoked_provider() { + guardian207_toolonly_consumer(true).await; +} +#[tokio::test(flavor = "multi_thread")] +async fn guardian207_toolonly_revoked_refresh() { + guardian207_toolonly_consumer(false).await; +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn guardian207_toolonly_revoked_checkpoint() { + let (_root, state, mut request) = maintenance_source().await; + let service = state.service(); + assert!( + service + .create_managed_checkpoint(request.clone()) + .await + .is_ok() + ); + let p = state.persistence().unwrap(); + let control = crate::plugins::PluginControlStore::new(&p); + let handle = service.handle(&request.source_run_id).unwrap(); + control + .revoke_graph( + &service.tool_store().unwrap(), + handle.pinned_tool.as_ref().unwrap().graph_id(), + ) + .unwrap(); + request.request_id = uuid::Uuid::new_v4().to_string(); + let result = service.create_managed_checkpoint(request.clone()).await; + assert_eq!( + result.unwrap_err().to_string(), + "plugin_graph_revoked", + "F02 checkpoint must refuse revoked ToolOnly" + ); + let clock = crate::RunCancellation::with_timeout(std::time::Duration::from_secs(5)); + assert!( + service + .managed_runtime_for_test() + .unwrap() + .repository + .checkpoint_receipt(&request, &clock, None) + .unwrap() + .is_none() + ); + service.shutdown().await.unwrap(); +} + +async fn guardian207_managed_reconstruction() { + let (root, state) = fixture(); + let service = state.service(); + let provider = Arc::new(crate::ScriptedProvider::new()); + provider.push_ok(response(Some(json!({"id":"restore-write","name":"write_file","arguments":{"path":"restored.txt","content":"original managed identity"}})))); + provider.push_ok(response(Some(json!({"id":"restore-terminal","name":"terminal","arguments":{"argv":["/bin/cat","restored.txt"],"timeout_ms":1000}})))); + provider.push_ok(response(None)); + service.inject_provider_host(provider); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"write and read original managed identity"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + let original = service.handle(&run.run_id).unwrap(); + let original_binding = original + .pinned_tool + .as_ref() + .unwrap() + .managed + .clone() + .unwrap(); + let deadline = service.run_context(&run.run_id).unwrap().metadata["deadline_at_ms"].clone(); + service.evict_run_handle(&run.run_id); + drop(original); + let restored = service + .restore_handle_from_frozen_context(&run.run_id) + .unwrap() + .unwrap(); + let binding = restored + .pinned_tool + .as_ref() + .unwrap() + .managed + .as_ref() + .expect("F01 original managed binding must survive persistent reconstruction"); + assert_eq!(binding.owner, original_binding.owner); + assert_eq!(binding.code, original_binding.code); + assert_eq!(binding.admitted_branch, original_binding.admitted_branch); + assert_eq!(binding.admitted_epoch(), original_binding.admitted_epoch()); + assert!(binding.cancel.same_root(&restored.cancel)); + assert_eq!( + service.run_context(&run.run_id).unwrap().metadata["deadline_at_ms"], + deadline + ); + let capabilities = service + .build_capability_host_state(&run.run_id, &restored) + .unwrap(); + assert!(capabilities.filesystem.is_none()); + assert!(capabilities.processes.is_none()); + drop(capabilities); + service + .clone() + .run_worker(run.run_id.clone(), String::new()) + .await; + for call_id in ["restore-write", "restore-terminal"] { + let request = crate::effects::ManagedEffectGetRequest { + owner: binding.owner.clone(), + call_id: call_id.into(), + }; + let record = state + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap_or_else(|| { + panic!( + "missing {call_id}; public events {:?}", + service + .run_events(&run.run_id) + .iter() + .map(|event| ( + event["event"].clone(), + event["data"]["error_code"].clone(), + event["data"]["code"].clone() + )) + .collect::>() + ) + }); + assert!( + record.outcome.is_some(), + "actual restored managed effect {call_id}" + ); + let crate::effects::ManagedCallReplay::Final { + canonical_result, .. + } = service.replay_managed_call(request).unwrap() + else { + panic!("terminal typed readback required") + }; + let result: Value = serde_json::from_str(canonical_result.as_str()).unwrap(); + assert_eq!(result["ok"], true); + if call_id == "restore-terminal" { + assert_eq!(result["data"]["stdout"], "original managed identity"); + assert_eq!(result["data"]["exit_code"], 0); + } + } + assert!(!root.join("workspace/restored.txt").exists()); + service.shutdown().await.unwrap(); +} +#[tokio::test(flavor = "multi_thread")] +async fn guardian207_toolonly_managed_reconstruction() { + guardian207_managed_reconstruction().await; +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn checkpoint_rejects_unknown_mutable_context_without_partial_record() { + let (_root, state, request) = maintenance_source().await; + let service = state.service(); + service + .inner + .contexts + .lock() + .unwrap() + .get_mut(&request.source_run_id) + .unwrap() + .metadata["custom_mutable_context"] = json!({"revision":7}); + let result = service.create_managed_checkpoint(request.clone()).await; + let repository = service + .managed_runtime_for_test() + .unwrap() + .repository + .clone(); + let receipt = repository + .checkpoint_receipt( + &request, + &crate::RunCancellation::with_timeout(std::time::Duration::from_secs(5)), + None, + ) + .unwrap(); + service.shutdown().await.unwrap(); + assert_eq!(result.unwrap_err().to_string(), "managed_state_unsupported"); + assert!(receipt.is_none()); +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn pending_compaction_refuses_restore_without_branch_or_admission() { + let (_root, state, source) = maintenance_source().await; + let service = state.service(); + let request = restore_request(&state, &source).await; + let p = state.persistence().unwrap(); + let session = service + .run_context(&source.source_run_id) + .unwrap() + .session_id; + p.run_create(&json!({"id":"pending-compactor","session_id":session,"parent_run_id":"","input_json":"{}","provider":"synthetic","model":"m","script_hash":"","idempotency_scope":"","idempotency_key":"","now_ms":1})).unwrap(); + for (from, to) in [("queued", "running"), ("running", "compacting")] { + p.run_transition(&json!({"run_id":"pending-compactor","from_status":from,"to_status":to,"error_code":"","error_message":"","recovery_reason":"","now_ms":2})).unwrap(); + } + p.compaction_start(&json!({"id":"pending-summary","session_id":session,"run_id":"pending-compactor","generation":2,"source_start_ordinal":1,"source_end_ordinal":1,"retained_tail_ordinal":1,"summary_json":"{}","token_estimate":0,"model":"m","now_ms":3})).unwrap(); + let result = service + .restore_managed_checkpoint_and_admit(request.clone()) + .await; + let receipt = service + .managed_runtime_for_test() + .unwrap() + .repository + .lookup_restore_receipt( + &request, + &crate::RunCancellation::with_timeout(std::time::Duration::from_secs(5)), + ) + .unwrap(); + service.shutdown().await.unwrap(); + assert_eq!( + result.unwrap_err().to_string(), + "managed_history_incomplete" + ); + assert!(receipt.is_none()); +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn composed_restore_budget_refusal_preserves_source_and_original_clock() { + let (_root, state, source) = maintenance_source().await; + let service = state.service(); + let mut request = restore_request(&state, &source).await; + request.input.text = "\n\\\"".repeat(6000); + let runtime = service.managed_runtime_for_test().unwrap(); + let cancel = crate::RunCancellation::with_timeout(std::time::Duration::from_secs(5)); + let before = runtime + .repository + .checkpoint_context(&source, &cancel) + .unwrap() + .head; + let clock = + service.run_context(&source.source_run_id).unwrap().metadata["deadline_at_ms"].clone(); + let result = service + .restore_managed_checkpoint_and_admit(request.clone()) + .await; + let after = runtime + .repository + .checkpoint_context(&source, &cancel) + .unwrap() + .head; + let receipt = runtime + .repository + .lookup_restore_receipt(&request, &cancel) + .unwrap(); + let attempts = service.inner.maintenance_attempts.lock().len(); + let after_clock = + service.run_context(&source.source_run_id).unwrap().metadata["deadline_at_ms"].clone(); + service.shutdown().await.unwrap(); + assert_eq!( + result.unwrap_err().to_string(), + "managed_metadata_budget_exceeded" + ); + assert_eq!(after, before); + assert_eq!(clock, after_clock); + assert!(receipt.is_none()); + assert_eq!(attempts, 0); +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn checkpoint_closed_inventory_and_history_refusals_have_no_partial_receipt() { + for (case, expected) in [ + ("history_rows", "managed_history_budget_exceeded"), + ("history_bytes", "managed_history_budget_exceeded"), + ("unclosed_tool", "managed_history_incomplete"), + ("missing_result", "managed_artifact_unavailable"), + ] { + let (root, state, request) = maintenance_source().await; + let service = state.service(); + let runtime = service.managed_runtime_for_test().unwrap(); + let cancel = crate::RunCancellation::with_timeout(std::time::Duration::from_secs(5)); + let before = runtime + .repository + .checkpoint_context(&request, &cancel) + .unwrap(); + let p = state.persistence().unwrap(); + let mut missing = None; + if case == "missing_result" { + let result = &before.effects[0] + .outcome + .as_ref() + .unwrap() + .canonical_result_ref + .object_id; + let path = root.join(".gateway.sqlite.effects/results").join(result); + let held = root.join("owned-unavailable-result"); + fs::rename(&path, &held).unwrap(); + missing = Some((path, held)); + } else { + for index in 0..if case == "history_rows" { + 125 + } else if case == "history_bytes" { + 9 + } else { + 1 + } { + let content = if case == "unclosed_tool" { + json!([{"type":"tool_call","tool_call_id":"unclosed","name":"write_file","arguments_json":"{}"}]) + } else { + json!([{"type":"text","text":if case=="history_bytes" {"x".repeat(62000)} else {"closed retained fact".into()}}]) + }; + p.message_append(&json!({"id":format!("extra-{index}"),"session_id":before.session_id,"role":if case=="unclosed_tool" {"assistant"} else {"user"},"content_json":content.to_string(),"name":"","tool_call_id":"","parent_message_id":"","token_estimate":1,"metadata_json":"{}","run_id":"","finish_reason":"","now_ms":index+100})).unwrap(); + } + } + let result = service.create_managed_checkpoint(request.clone()).await; + if let Some((path, held)) = missing { + fs::rename(held, path).unwrap(); + } + let cancel = crate::RunCancellation::with_timeout(std::time::Duration::from_secs(5)); + let receipt = runtime + .repository + .checkpoint_receipt(&request, &cancel, None) + .unwrap(); + let after = runtime + .repository + .checkpoint_context(&request, &cancel) + .unwrap() + .head; + service.shutdown().await.unwrap(); + assert_eq!(result.unwrap_err().to_string(), expected, "{case}"); + assert!(receipt.is_none(), "{case}"); + assert_eq!(after, before.head, "{case}"); + } +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn checkpoint_inventory_uses_actual_captured_manifest_and_complete_effect_declarations() { + let (_root, state, request) = maintenance_source().await; + let service = state.service(); + let binding = service + .retained_tool_for_test(&request.source_run_id) + .unwrap() + .managed + .clone() + .unwrap(); + let code = &binding.code; + let actual = service + .tool_store() + .unwrap() + .read_installed(&code.artifact_id) + .unwrap(); + let validate = crate::service::managed_checkpoints::validate_captured_inventory; + validate(&actual, code).unwrap(); + assert!(actual.manifest.state.is_none()); + assert!(!actual.manifest.effects.entries.is_empty()); + let mut declared = actual.clone(); + let files = crate::runtime::native_tools::embedding_package_files().unwrap(); + let manifest = String::from_utf8(files["manifest.yaml"].clone()).unwrap(); + declared.manifest = crate::plugins::parse_plugin_manifest( + &(manifest + "\nstate:\n scope: run\n schema: 1\n"), + ) + .unwrap(); + assert_eq!( + validate(&declared, code).unwrap_err(), + "managed_state_unsupported" + ); + let mut state_effect = actual.clone(); + state_effect + .manifest + .effects + .entries + .values_mut() + .find_map(|entry| entry.direct.first_mut()) + .unwrap() + .kind = "plugin_state.write".into(); + assert_eq!( + validate(&state_effect, code).unwrap_err(), + "managed_state_unsupported" + ); + service.shutdown().await.unwrap(); +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn restore_preserves_old_messages_audit_usage_approval_delivery_and_new_readonly_prefix() { + let (_root, state, source) = maintenance_source().await; + let service = state.service(); + let p = state.persistence().unwrap(); + let session = service + .run_context(&source.source_run_id) + .unwrap() + .session_id; + // Explicit synthetic accounting fact through the original fixed usage producer. + p.record_synthetic_usage_for_test(&source.source_run_id) + .unwrap(); + p.delivery_advance(&session, "synthetic-frozen-consumer", 1) + .unwrap(); + p.run_create(&json!({"id":"approval-audit-run","session_id":session,"parent_run_id":"","input_json":"{}","provider":"synthetic","model":"m","script_hash":"","idempotency_scope":"","idempotency_key":"","now_ms":1})).unwrap(); + p.run_transition(&json!({"run_id":"approval-audit-run","from_status":"queued","to_status":"running","error_code":"","error_message":"","recovery_reason":"","now_ms":2})).unwrap(); + let approval=p.approval_request(&json!({"id":"closed-old-approval","run_id":"approval-audit-run","session_id":session,"tool_call_id":"historical-call","tool_name":"write_file","arguments_json":"{}","risk_class":"write","decision_scope":"once","one_time":1,"requested_at_ms":3,"expires_at_ms":1000})).unwrap(); + assert_eq!(approval["rows"].as_array().unwrap().len(), 1); + p.approval_resolve(&json!({"id":"closed-old-approval","run_id":"approval-audit-run","state":"approved","resolver":"synthetic","decision_reason":"owned fixture","resolved_at_ms":4})).unwrap(); + p.run_transition(&json!({"run_id":"approval-audit-run","from_status":"running","to_status":"completed","error_code":"","error_message":"","recovery_reason":"","now_ms":5})).unwrap(); + let request = restore_request(&state, &source).await; + let events = + json!({"run_id":source.source_run_id,"after_seq":0,"max_events":128,"max_bytes":1048576}); + let messages = p.message_list(&session, 0).unwrap(); + let audit = p.event_replay(&events).unwrap(); + let usage = p.run_get(&source.source_run_id).unwrap(); + let approval = p.approval_get("closed-old-approval").unwrap(); + let delivery = p + .delivery_get(&session, "synthetic-frozen-consumer") + .unwrap(); + let result = service + .restore_managed_checkpoint_and_admit(request) + .await + .unwrap(); + assert_eq!(p.message_list(&session, 0).unwrap(), messages); + assert_eq!(p.event_replay(&events).unwrap(), audit); + assert_eq!(p.run_get(&source.source_run_id).unwrap(), usage); + assert_eq!(p.approval_get("closed-old-approval").unwrap(), approval); + assert_eq!( + p.delivery_get(&session, "synthetic-frozen-consumer") + .unwrap(), + delivery + ); + assert!( + result + .external_effects_not_reverted + .contains(&crate::effects::ManagedExternalNotice::ProviderUsageNotReverted) + ); + assert!( + result + .external_effects_not_reverted + .contains(&crate::effects::ManagedExternalNotice::DeliveredContentNotReverted) + ); + let inherited = p.message_list(&result.new_session_id, 0).unwrap(); + assert_eq!( + inherited["rows"].as_array().unwrap().len(), + messages["rows"].as_array().unwrap().len() + 1 + ); + let memory = service.run_context(&result.new_run_id).unwrap(); + let prefix = memory.messages.as_array().unwrap(); + assert_eq!(prefix.len(), 5); + for message in &prefix[..4] { + assert!( + message["id"] + .as_str() + .unwrap() + .starts_with(&format!("restored-{}-", result.new_session_id)) + ); + assert!(message["run_id"].is_null()); + assert!(message["metadata"]["origin"]["source_message_id"].is_string()); + if let Some(parent) = message["parent_message_id"].as_str() { + assert!(parent.starts_with(&format!("restored-{}-", result.new_session_id))); + } + } + assert_eq!( + p.approval_list(&result.new_run_id, &result.new_session_id) + .unwrap()["rows"], + json!([]) + ); + assert!( + p.managed_effect_get(&crate::effects::ManagedEffectGetRequest { + owner: crate::effects::ManagedOwnerScope { + profile: "gateway".into(), + session_id: result.new_session_id.clone(), + run_id: result.new_run_id.clone() + }, + call_id: "maintenance-write".into() + }) + .unwrap() + .is_none() + ); + let provider = Arc::new(crate::ScriptedProvider::new()); + provider.push_ok(response(None)); + service.inject_provider_host(provider.clone()); + service + .clone() + .run_worker(result.new_run_id.clone(), String::new()) + .await; + assert_eq!(provider.requests().len(), 1); + assert!( + provider.requests()[0]["messages"] + .to_string() + .contains("synthetic Native checkpoint source") + ); + service.shutdown().await.unwrap(); +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn composed_restore_budget_boundary_matches_actual_typed_admission_payload() { + use crate::gateway::store::validate_composed_restore_budget as admits; + let (_root, state, source) = maintenance_source().await; + let service = state.service(); + let request = restore_request(&state, &source).await; + let captured = Arc::new(std::sync::Mutex::new(None)); + let seen = captured.clone(); + state + .persistence() + .unwrap() + .inject_admission_precommit_hook(move |payload| { + *seen.lock().unwrap() = Some(payload.clone()); + }); + service + .restore_managed_checkpoint_and_admit(request) + .await + .unwrap(); + service.shutdown().await.unwrap(); + let original = captured.lock().unwrap().take().unwrap(); + let with_text = |count: usize| { + let mut payload = original.clone(); + let mut envelope: Value = + serde_json::from_str(payload["restore_json"].as_str().unwrap()).unwrap(); + envelope["request"]["input"]["text"] = json!("x".repeat(count)); + envelope["original_request_proof"] = + json!(serde_json::to_string(&envelope["request"]).unwrap()); + payload["restore_json"] = json!(envelope.to_string()); + let mut input: Value = + serde_json::from_str(payload["input_json"].as_str().unwrap()).unwrap(); + input["run_context"]["input"]["message"] = json!("x".repeat(count)); + payload["input_json"] = json!(input.to_string()); + payload + }; + let (mut low, mut high) = (0usize, 65536usize); + assert!(admits(&with_text(low)).is_ok()); + assert!(admits(&with_text(high)).is_err()); + while high - low > 1 { + let middle = low + (high - low) / 2; + if admits(&with_text(middle)).is_ok() { + low = middle; + } else { + high = middle; + } + } + assert_eq!(high, low + 1); + for (count, positive) in [(low, true), (high, false)] { + let (_root, state, source) = maintenance_source().await; + let service = state.service(); + let p = state.persistence().unwrap(); + let mut request = restore_request(&state, &source).await; + request.input.text = "x".repeat(count); + let payload = Arc::new(std::sync::Mutex::new(None)); + let seen = payload.clone(); + let observing = p.clone(); + p.inject_admission_precommit_hook(move |value| { + *seen.lock().unwrap() = Some((value.clone(), observing.executed_storage_commands())); + }); + let result = service + .restore_managed_checkpoint_and_admit(request.clone()) + .await; + let after = p.executed_storage_commands(); + let (actual, before) = payload.lock().unwrap().take().unwrap(); + assert_eq!( + admits(&actual).is_ok(), + positive, + "actual final payload at {count}" + ); + assert!( + serde_json::to_vec(&actual).unwrap().len() < 1048576, + "aggregate bound, not only transport" + ); + if positive { + assert!(result.is_ok(), "last admitted byte boundary: {result:?}"); + } else { + assert_eq!( + result.unwrap_err().to_string(), + "managed_metadata_budget_exceeded" + ); + assert_eq!( + after, before, + "no final admission enqueue at first rejected byte boundary" + ); + let receipt = service + .managed_runtime_for_test() + .unwrap() + .repository + .lookup_restore_receipt( + &request, + &crate::RunCancellation::with_timeout(std::time::Duration::from_secs(5)), + ) + .unwrap(); + assert!(receipt.is_none()); + } + service.shutdown().await.unwrap(); + } + eprintln!( + "actual typed restore aggregate boundary: accepted input bytes {low}, rejected input bytes {high}" + ); +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn pending_compaction_after_frozen_read_cannot_publish_restore() { + let (_root, state, source) = maintenance_source().await; + let service = state.service(); + let p = state.persistence().unwrap(); + let request = restore_request(&state, &source).await; + let session = service + .run_context(&source.source_run_id) + .unwrap() + .session_id; + p.run_create(&json!({"id":"late-compactor","session_id":session,"parent_run_id":"","input_json":"{}","provider":"synthetic","model":"m","script_hash":"","idempotency_scope":"","idempotency_key":"","now_ms":1})).unwrap(); + for (from, to) in [("queued", "running"), ("running", "compacting")] { + p.run_transition(&json!({"run_id":"late-compactor","from_status":from,"to_status":to,"error_code":"","error_message":"","recovery_reason":"","now_ms":2})).unwrap(); + } + let late = p.clone(); + p.inject_admission_precommit_hook(move |_| {late.compaction_start(&json!({"id":"late-summary","session_id":session,"run_id":"late-compactor","generation":2,"source_start_ordinal":1,"source_end_ordinal":1,"retained_tail_ordinal":1,"summary_json":"{}","token_estimate":0,"model":"m","now_ms":3})).unwrap();}); + let result = service + .restore_managed_checkpoint_and_admit(request.clone()) + .await; + let runtime = service.managed_runtime_for_test().unwrap(); + let receipt = runtime + .repository + .lookup_restore_receipt( + &request, + &crate::RunCancellation::with_timeout(std::time::Duration::from_secs(5)), + ) + .unwrap(); + if matches!( + &result, + Err(crate::effects::ManagedMaintenanceFailure::SubmittedUnknown { .. }) + ) { + let reconciled = service.restore_managed_checkpoint_and_admit(request).await; + assert!(reconciled.is_err()); + } + service.shutdown().await.unwrap(); + assert!( + result.is_err(), + "a compaction becoming pending after capture must prevent the actual restore transaction" + ); + assert!(receipt.is_none()); +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn pending_compaction_after_frozen_read_cannot_publish_checkpoint() { + let (_root, state, source) = maintenance_source().await; + let service = state.service(); + let p = state.persistence().unwrap(); + let session = service + .run_context(&source.source_run_id) + .unwrap() + .session_id; + p.run_create(&json!({"id":"late-capture-compactor","session_id":session,"parent_run_id":"","input_json":"{}","provider":"synthetic","model":"m","script_hash":"","idempotency_scope":"","idempotency_key":"","now_ms":1})).unwrap(); + for (from, to) in [("queued", "running"), ("running", "compacting")] { + p.run_transition(&json!({"run_id":"late-capture-compactor","from_status":from,"to_status":to,"error_code":"","error_message":"","recovery_reason":"","now_ms":2})).unwrap(); + } + let late = p.clone(); + let once = std::sync::atomic::AtomicBool::new(false); + *p.managed_request_hook.lock().unwrap() = Some(Arc::new(move |op, _, enqueued| { + if op == "effect.checkpoint_create" + && !enqueued + && !once.swap(true, std::sync::atomic::Ordering::AcqRel) + { + late.compaction_start(&json!({"id":"late-capture-summary","session_id":session,"run_id":"late-capture-compactor","generation":2,"source_start_ordinal":1,"source_end_ordinal":1,"retained_tail_ordinal":1,"summary_json":"{}","token_estimate":0,"model":"m","now_ms":3})).unwrap(); + } + })); + let result = service.create_managed_checkpoint(source.clone()).await; + *p.managed_request_hook.lock().unwrap() = None; + let receipt = service + .managed_runtime_for_test() + .unwrap() + .repository + .checkpoint_receipt( + &source, + &crate::RunCancellation::with_timeout(std::time::Duration::from_secs(5)), + None, + ) + .unwrap(); + if matches!( + &result, + Err(crate::effects::ManagedMaintenanceFailure::SubmittedUnknown { .. }) + ) { + assert!(service.create_managed_checkpoint(source).await.is_err()); + } + service.shutdown().await.unwrap(); + assert!( + result.is_err(), + "a compaction becoming pending after capture must prevent the actual checkpoint transaction" + ); + assert!(receipt.is_none()); +} + +#[cfg(feature = "workspace-io-fault")] +pub(super) async fn maintenance_source() -> ( + PathBuf, + AgentGatewayState, + crate::effects::ManagedCheckpointCreateRequest, +) { + maintenance_source_timeout(std::time::Duration::from_secs(300)).await +} +#[cfg(feature = "workspace-io-fault")] +async fn maintenance_source_timeout( + timeout: std::time::Duration, +) -> ( + PathBuf, + AgentGatewayState, + crate::effects::ManagedCheckpointCreateRequest, +) { + let (root, state) = fixture_config(|config| { + config.run_timeout = timeout; + config.bearer_token = Some("synthetic-maintenance-fixture".into()); + let policy = &mut config + .managed_workspace + .as_mut() + .unwrap() + .maintenance_policy; + policy.create_checkpoint = true; + policy.restore = true; + }); + let service = state.service(); + // Code-only fixture setup precedes the source admission's original short + // clock; no run, deadline, authority or admission is created here. + service + .materialize_cached_runner() + .expect("materialize maintenance fixture code"); + let provider = Arc::new(crate::ScriptedProvider::new()); + provider.push_ok(response(Some(json!({"id":"maintenance-write", "name":"write_file", "arguments":{"path":"original.txt","content":"frozen A"}})))); + provider.push_ok(response(None)); + service.inject_provider_host(provider); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"synthetic Native checkpoint source"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + service + .clone() + .run_worker(run.run_id.clone(), String::new()) + .await; + let record = state + .persistence() + .unwrap() + .managed_effect_get(&crate::effects::ManagedEffectGetRequest { + owner: crate::effects::ManagedOwnerScope { + profile: "gateway".into(), + session_id: run.session_id, + run_id: run.run_id.clone(), + }, + call_id: "maintenance-write".into(), + }) + .unwrap() + .unwrap(); + assert!( + record.outcome.is_some(), + "maintenance source stage state={:?}; event/code={:?}", + record.state, + service + .run_events(&run.run_id) + .iter() + .map(|e| (e["event"].clone(), e["data"]["error_code"].clone())) + .collect::>() + ); + let request = crate::effects::ManagedCheckpointCreateRequest { + source_run_id: run.run_id, + workspace_id: record.identity.workspace.workspace_id, + source_branch_id: record.identity.workspace.branch_id, + expected_epoch: record.identity.epoch, + request_id: uuid::Uuid::new_v4().to_string(), + mode: crate::effects::ManagedCheckpointMode::AtClosedBatch, + }; + (root, state, request) +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn restore_http_repairs_missing_initial_start_once() { + maintenance_http_once(false).await; +} +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn restore_http_unknown_retries_execute_once() { + maintenance_http_once(true).await; +} +#[cfg(feature = "workspace-io-fault")] +pub(super) async fn restore_request( + state: &AgentGatewayState, + source: &crate::effects::ManagedCheckpointCreateRequest, +) -> crate::effects::ManagedRestoreAndAdmitRequest { + let checkpoint = state + .service() + .create_managed_checkpoint(source.clone()) + .await + .unwrap() + .checkpoint; + let runtime = state.service().managed_runtime_for_test().unwrap(); + let context = runtime + .repository + .checkpoint_context( + source, + &crate::RunCancellation::with_timeout(std::time::Duration::from_secs(5)), + ) + .unwrap(); + crate::effects::ManagedRestoreAndAdmitRequest { + workspace_id: source.workspace_id.clone(), + source_branch_id: source.source_branch_id.clone(), + checkpoint_id: checkpoint.checkpoint_id, + expected_source: crate::effects::ManagedSourceCas { + store_id: context.head.store_id, + workspace_id: context.head.workspace_id, + branch_id: context.head.branch_id, + epoch: context.head.epoch, + head_object_id: context.head.head_object_id, + head_version: context.head.head_version, + }, + request_id: uuid::Uuid::new_v4().to_string(), + input: crate::effects::ManagedRestoreRunInput { + text: "synthetic restored read".into(), + }, + cancel_active_source: true, + graph_selection: crate::effects::ManagedRestoreGraphSelection::CheckpointExact, + } +} + +#[cfg(feature = "workspace-io-fault")] +pub(super) async fn maintenance_http( + address: std::net::SocketAddr, + path: &str, + body: Value, +) -> (u16, Value) { + use http_body_util::{BodyExt, Full}; + let client: hyper_util::client::legacy::Client<_, Full> = + hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new()) + .build_http(); + let request = hyper::Request::post(format!("http://{address}{path}")) + .header("content-type", "application/json") + .header("authorization", "Bearer synthetic-maintenance-fixture") + .body(Full::new(bytes::Bytes::from(body.to_string()))) + .unwrap(); + let response = client.request(request).await.unwrap(); + let status = response.status().as_u16(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + (status, serde_json::from_slice(&bytes).unwrap()) +} +#[cfg(feature = "workspace-io-fault")] +struct RestoreCountingProvider(std::sync::atomic::AtomicUsize, std::sync::Mutex>); +#[cfg(feature = "workspace-io-fault")] +impl crate::AgentProviderHost for RestoreCountingProvider { + fn call(&self, request: &Value, _: &crate::RunCancellation) -> Value { + self.1.lock().unwrap().push(request.clone()); + let step = self.0.fetch_add(1, std::sync::atomic::Ordering::AcqRel); + json!({"ok":true,"response":response(if step==0 {Some(json!({"id":"restored-once-write","name":"write_file","arguments":{"path":"restored-once.txt","content":"C"}}))}else{None}),"error":{}}) + } +} + +#[cfg(feature = "workspace-io-fault")] +async fn maintenance_http_once(ack_lost: bool) { + use std::future::IntoFuture; + + struct Cleanup { + service: Arc, + run_id: Option, + stop: Option>, + } + impl Drop for Cleanup { + fn drop(&mut self) { + if std::thread::panicking() + && let Some(run_id) = &self.run_id + { + self.service.stop(run_id); + } + if let Some(stop) = self.stop.take() { + let _ = stop.send(()); + } + } + } + let (_root, state, source) = maintenance_source().await; + let service = state.service(); + let restore = restore_request(&state, &source).await; + let initial = if ack_lost { + None + } else { + Some( + service + .restore_managed_checkpoint_and_admit(restore.clone()) + .await + .unwrap(), + ) + }; + let provider = Arc::new(RestoreCountingProvider( + std::sync::atomic::AtomicUsize::new(0), + std::sync::Mutex::new(Vec::new()), + )); + service.inject_provider_host(provider.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (stop, stopped) = tokio::sync::oneshot::channel(); + let server = tokio::spawn( + axum::serve(listener, crate::build_agent_gateway_app(state.clone())) + .with_graceful_shutdown(async { + let _ = stopped.await; + }) + .into_future(), + ); + let mut cleanup = Cleanup { + service: service.clone(), + run_id: None, + stop: Some(stop), + }; + let path = format!("/v1/workspaces/{}/restore", restore.workspace_id); + let body = serde_json::to_value(&restore).unwrap(); + let receipt = if let Some(receipt) = initial { + receipt + } else { + state + .persistence() + .unwrap() + .inject_drop_managed_commit_ack(); + let unknown = maintenance_http(address, &path, body.clone()).await; + assert_eq!(unknown.0, 202); + assert_eq!(unknown.1["disposition"], "submitted_unknown"); + assert_eq!(unknown.1["request_id"], restore.request_id); + service + .managed_runtime_for_test() + .unwrap() + .repository + .restore_receipt( + &restore, + &crate::RunCancellation::with_timeout(std::time::Duration::from_secs(5)), + ) + .unwrap() + }; + cleanup.run_id = Some(receipt.new_run_id.clone()); + let (a, b) = tokio::join!( + maintenance_http(address, &path, body.clone()), + maintenance_http(address, &path, body.clone()) + ); + assert_eq!(a.0, 202, "first exact retry {a:?}; other {b:?}"); + assert_eq!(b.0, 202, "second exact retry {b:?}"); + // Publication can first occur in these retries. Subscribe and inspect the + // retained terminal under one store lock, including an already-finished run. + let handle = service.handle(&receipt.new_run_id).unwrap(); + let original_deadline = handle.cancel.deadline_instant(); + let remaining = || { + let remaining = handle + .cancel + .remaining_deadline() + .expect("restored run must retain its original finite clock"); + assert!( + !remaining.is_zero(), + "original restore clock expired before observation" + ); + remaining + }; + let (mut events, mut terminal, mut status) = { + let store = service.inner.store.read(); + let run = store.runs.get(&receipt.new_run_id).unwrap(); + ( + run.sender.as_ref().map(|sender| sender.subscribe()), + run.events + .iter() + .rev() + .find(|event| event.is_terminal()) + .map(|event| event.event.clone()), + run.status.clone(), + ) + }; + let completed = tokio::time::timeout(remaining(), async { + loop { + if let Some(event) = terminal.take() { + break event; + } + assert!( + !matches!(status.as_str(), "completed" | "failed" | "cancelled"), + "retained terminal event missing for status {status}" + ); + match events + .as_mut() + .expect("restored run event channel missing") + .recv() + .await + { + Ok(event) if event.is_terminal() => break event.event, + Ok(_) => {} + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { + let store = service.inner.store.read(); + let run = store.runs.get(&receipt.new_run_id).unwrap(); + terminal = run + .events + .iter() + .rev() + .find(|event| event.is_terminal()) + .map(|event| event.event.clone()); + status = run.status.clone(); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + panic!("restored run event channel closed before completion"); + } + } + } + }) + .await + .expect("restored run did not complete within its original clock"); + assert_eq!(completed, "run.completed"); + // Terminal broadcast precedes worker return. Observe occupancy separately; + // shutdown is not a join and must not race an active worker. + tokio::time::timeout(remaining(), async { + while !handle.worker_is_idle_for_test() + || !handle.terminal.load(std::sync::atomic::Ordering::Acquire) + { + tokio::task::yield_now().await; + } + }) + .await + .expect("restored worker did not become idle within its original clock"); + assert!(Arc::ptr_eq( + &handle, + &service.handle(&receipt.new_run_id).unwrap() + )); + assert!( + handle + .restore_worker_started + .as_ref() + .unwrap() + .load(std::sync::atomic::Ordering::Acquire) + ); + let run_ids = || { + service + .inner + .runs + .lock() + .unwrap() + .keys() + .cloned() + .collect::>() + }; + let before_run_ids = run_ids(); + let count = provider.0.load(std::sync::atomic::Ordering::Acquire); + assert_eq!( + count, 2, + "completed restore must execute both provider turns" + ); + { + let persistence = state.persistence().unwrap(); + let before = persistence.run_get(&receipt.new_run_id).unwrap(); + let messages = persistence + .message_list(&receipt.new_session_id, 0) + .unwrap(); + let late = maintenance_http(address, &path, body).await; + assert_eq!(late.0, 202); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + assert_eq!(provider.0.load(std::sync::atomic::Ordering::Acquire), 2); + assert_eq!(persistence.run_get(&receipt.new_run_id).unwrap(), before); + assert_eq!( + persistence + .message_list(&receipt.new_session_id, 0) + .unwrap(), + messages + ); + } + assert!(Arc::ptr_eq( + &handle, + &service.handle(&receipt.new_run_id).unwrap() + )); + assert_eq!(handle.cancel.deadline_instant(), original_deadline); + assert_eq!(run_ids(), before_run_ids); + assert!(handle.worker_is_idle_for_test()); + assert!( + handle + .restore_worker_started + .as_ref() + .unwrap() + .load(std::sync::atomic::Ordering::Acquire) + ); + assert_eq!( + service.inner.store.read().runs[&receipt.new_run_id].status, + "completed" + ); + let _ = cleanup.stop.take().unwrap().send(()); + server.await.unwrap().unwrap(); + service.shutdown().await.unwrap(); + assert!( + service + .handle(&receipt.new_run_id) + .unwrap() + .terminal + .load(std::sync::atomic::Ordering::Acquire) + ); + assert_eq!( + count, 2, + "lost initial worker start must be claimed once despite concurrent exact HTTP retries" + ); +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn confirmed_commit_with_receipt_read_failure_retains_same_publication() { + let (_root, state, source) = maintenance_source().await; + let service = state.service(); + let request = restore_request(&state, &source).await; + let persistence = state.persistence().unwrap(); + let weak = Arc::downgrade(&persistence); + let reads = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let count = reads.clone(); + *persistence.managed_request_hook.lock().unwrap() = Some(Arc::new(move |op, _, enqueued| { + if !enqueued + && op == "effect.maintenance_receipt" + && count.fetch_add(1, std::sync::atomic::Ordering::AcqRel) == 1 + { + weak.upgrade().unwrap().inject_persist_failure(); + } + })); + let outcome = service + .restore_managed_checkpoint_and_admit(request.clone()) + .await; + *persistence.managed_request_hook.lock().unwrap() = None; + assert_eq!( + outcome.unwrap_err().to_string(), + "managed_submitted_unknown", + "COMMIT succeeded; only exact receipt read failed" + ); + let runtime = service.managed_runtime_for_test().unwrap(); + let durable = runtime + .repository + .restore_receipt( + &request, + &crate::RunCancellation::with_timeout(std::time::Duration::from_secs(5)), + ) + .unwrap(); + let original = service.handle(&durable.new_run_id).unwrap(); + let retry = service + .restore_managed_checkpoint_and_admit(request) + .await + .unwrap(); + assert_eq!(retry.new_run_id, durable.new_run_id); + assert!(Arc::ptr_eq( + &original, + &service.handle(&retry.new_run_id).unwrap() + )); + service.shutdown().await.unwrap(); +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn restore_ack_lost_reconciles_once() { + let (_root, state, source) = maintenance_source().await; + let service = state.service(); + let request = restore_request(&state, &source).await; + let persistence = state.persistence().unwrap(); + persistence.inject_drop_managed_commit_ack(); + let unknown = service + .restore_managed_checkpoint_and_admit(request.clone()) + .await; + assert!( + !persistence.managed_commit_ack_drop_armed(), + "real restore/admission COMMIT ack discarded" + ); + assert_eq!( + unknown.unwrap_err().to_string(), + "managed_submitted_unknown" + ); + let runtime = service.managed_runtime_for_test().unwrap(); + let cancel = crate::RunCancellation::with_timeout(std::time::Duration::from_secs(5)); + let durable = runtime + .repository + .restore_receipt(&request, &cancel) + .unwrap(); + assert!( + service.handle(&durable.new_run_id).is_none(), + "no publication before reconciliation" + ); + let restored = service + .restore_managed_checkpoint_and_admit(request.clone()) + .await + .unwrap(); + assert_eq!(restored.new_run_id, durable.new_run_id); + assert_eq!( + restored.new_run_deadline_at_ms, + durable.new_run_deadline_at_ms + ); + assert!( + service.handle(&restored.new_run_id).is_some(), + "same original admission published" + ); + let repeated = service + .restore_managed_checkpoint_and_admit(request) + .await + .unwrap(); + assert_eq!(restored.new_run_id, repeated.new_run_id); + assert_eq!(restored.source_after, repeated.source_after); + service.shutdown().await.unwrap(); +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn unfinished_original_job_and_timed_out_borrow_cannot_enqueue_more_work() { + let (_root, state, source) = maintenance_source().await; + let service = state.service(); + let request = restore_request(&state, &source).await; + let persistence = state.persistence().unwrap(); + let gate = persistence.block_maintenance_reply_for_test(); + let s = service.clone(); + let r = request.clone(); + let submitted = tokio::spawn(async move { s.restore_managed_checkpoint_and_admit(r).await }); + gate.wait_entered(); + assert_eq!( + submitted.await.unwrap().unwrap_err().to_string(), + "managed_submitted_unknown" + ); + let retry = service + .restore_managed_checkpoint_and_admit(request.clone()) + .await; + gate.release(); + assert_eq!( + retry.unwrap_err().to_string(), + "managed_submitted_unknown", + "worker still owns the unfinished original job" + ); + persistence.run_get(&source.source_run_id).unwrap(); // ordered worker acknowledgement + let attempt = service + .inner + .maintenance_attempts + .lock() + .get(&request.request_id) + .unwrap() + .clone(); + // A real bounded read timeout retains that same job ownership until worker reply finishes. + let read_gate = persistence.block_maintenance_reply_for_test(); + let claim = attempt.claim.clone(); + let p = persistence.clone(); + let payload = json!({"workspace_id":request.workspace_id,"receipt_id":request.request_id,"cut":"restore"}); + let wire = payload.clone(); + let reader = tokio::task::spawn_blocking(move || { + p.maintenance_read( + &claim, + &claim.identity, + &claim.proof, + "effect.maintenance_receipt", + &wire, + &crate::RunCancellation::with_timeout(std::time::Duration::from_millis(100)), + ) + }); + read_gate.wait_entered(); + assert!(reader.await.unwrap().is_err()); + let before = persistence.executed_storage_commands(); + for _ in 0..3 { + assert!( + persistence + .maintenance_read( + &attempt.claim, + &attempt.claim.identity, + &attempt.claim.proof, + "effect.maintenance_receipt", + &payload, + &crate::RunCancellation::with_timeout(std::time::Duration::from_millis(100)) + ) + .is_err() + ); + } + assert_eq!( + persistence.executed_storage_commands(), + before, + "one unfinished borrowed job, no replacement queue entries" + ); + read_gate.release(); + persistence.run_get(&source.source_run_id).unwrap(); + let restored = service + .restore_managed_checkpoint_and_admit(request) + .await + .unwrap(); + assert!(service.handle(&restored.new_run_id).is_some()); + service.shutdown().await.unwrap(); +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn checkpoint_rollback_releases_only_confirmed_attempt() { + let (_root, state, source) = maintenance_source().await; + let service = state.service(); + let persistence = state.persistence().unwrap(); + persistence.inject_maintenance_transaction_fault(99); + assert_eq!( + service + .create_managed_checkpoint(source.clone()) + .await + .unwrap_err() + .to_string(), + "managed_submitted_unknown" + ); + assert_eq!(persistence.pending_count_for_test(), 1); + assert_eq!( + service + .create_managed_checkpoint(source.clone()) + .await + .unwrap_err() + .to_string(), + "managed_transaction_rolled_back" + ); + assert_eq!(persistence.pending_count_for_test(), 0); + assert!( + service.create_managed_checkpoint(source).await.is_ok(), + "confirmed rollback permits a new capture" + ); + service.shutdown().await.unwrap(); +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn restore_and_admission_rollback_together() { + let (_root, state, source) = maintenance_source().await; + let service = state.service(); + let template = restore_request(&state, &source).await; + let persistence = state.persistence().unwrap(); + let runtime = service.managed_runtime_for_test().unwrap(); + let cancel = crate::RunCancellation::with_timeout(std::time::Duration::from_secs(5)); + let original = runtime + .repository + .checkpoint_context(&source, &cancel) + .unwrap(); + let old_session = original.session_id.clone(); + persistence.approval_request(&json!({"id":"rollback-audit-approval","run_id":source.source_run_id,"session_id":old_session,"tool_call_id":"maintenance-write","tool_name":"write_file","arguments_json":"{}","risk_class":"write","decision_scope":"","one_time":1,"requested_at_ms":1,"expires_at_ms":0})).unwrap(); + persistence.approval_resolve(&json!({"id":"rollback-audit-approval","run_id":source.source_run_id,"state":"approved","resolver":"synthetic-history","decision_reason":"retain historical approval","resolved_at_ms":2})).unwrap(); + persistence + .delivery_set(&old_session, "synthetic-delivery", 7) + .unwrap(); + let approval = persistence.approval_get("rollback-audit-approval").unwrap(); + let delivery = persistence + .delivery_get(&old_session, "synthetic-delivery") + .unwrap(); + let old_messages = persistence.message_list(&old_session, 0).unwrap(); + let old_run = persistence.run_get(&source.source_run_id).unwrap(); + for stage in [4, 6, 10, 12, 17, 99] { + let mut request = template.clone(); + request.request_id = uuid::Uuid::new_v4().to_string(); + let active = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"synthetic idle source to be cancelled"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + persistence.inject_maintenance_transaction_fault(stage); + assert_eq!( + service + .restore_managed_checkpoint_and_admit(request.clone()) + .await + .unwrap_err() + .to_string(), + "managed_submitted_unknown", + "actual transaction cut {stage}" + ); + let attempt = service + .inner + .maintenance_attempts + .lock() + .get(&request.request_id) + .unwrap() + .clone(); + let envelope = attempt.restore.lock().as_ref().unwrap().envelope.clone(); + assert!( + crate::plugins::PluginControlStore::new(&persistence) + .pin_requirement_for_run(&envelope.result.new_run_id) + .unwrap() + .is_none() + ); + assert!(attempt.claim.worker_failed()); + assert!(service.handle(&envelope.result.new_run_id).is_none()); + assert!( + service + .handle(&active.run_id) + .unwrap() + .terminal + .load(std::sync::atomic::Ordering::Acquire) + ); + assert_eq!( + service + .restore_managed_checkpoint_and_admit(request.clone()) + .await + .unwrap_err() + .to_string(), + "managed_transaction_rolled_back", + "cut {stage} exact absent receipt and all targets absent" + ); + assert!( + runtime + .repository + .restore_rollback_confirmed( + &attempt.claim, + &envelope, + &crate::RunCancellation::with_timeout(std::time::Duration::from_secs(5)) + ) + .unwrap() + ); + assert!( + persistence.run_get(&envelope.result.new_run_id).unwrap()["rows"] + .as_array() + .unwrap() + .is_empty() + ); + assert!( + runtime + .repository + .lookup_restore_receipt( + &request, + &crate::RunCancellation::with_timeout(std::time::Duration::from_secs(5)) + ) + .unwrap() + .is_none() + ); + let current = runtime + .repository + .checkpoint_context( + &source, + &crate::RunCancellation::with_timeout(std::time::Duration::from_secs(5)), + ) + .unwrap(); + assert_eq!(current.head, original.head); + assert_eq!( + persistence.run_get(&source.source_run_id).unwrap(), + old_run, + "old usage and original run facts remain unchanged" + ); + assert_eq!( + persistence.approval_get("rollback-audit-approval").unwrap(), + approval + ); + assert_eq!( + persistence + .delivery_get(&old_session, "synthetic-delivery") + .unwrap(), + delivery + ); + assert_eq!( + persistence.message_list(&old_session, 0).unwrap(), + old_messages + ); + let cancelled = persistence.run_get(&active.run_id).unwrap(); + let status = cancelled["columns"] + .as_array() + .unwrap() + .iter() + .position(|v| v == "status") + .unwrap(); + assert_eq!( + cancelled["rows"][0][status], "cancelled", + "old source never revived" + ); + drop(attempt); + assert_eq!(persistence.pending_count_for_test(), 0); + } + assert!( + service + .restore_managed_checkpoint_and_admit(template) + .await + .is_ok(), + "clean final restore after confirmed rollback" + ); + service.shutdown().await.unwrap(); +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn committed_publication_survives_expiry() { + let (_root, state, source) = + maintenance_source_timeout(std::time::Duration::from_secs(3)).await; + let service = state.service(); + let request = restore_request(&state, &source).await; + let persistence = state.persistence().unwrap(); + persistence.inject_drop_managed_commit_ack(); + assert_eq!( + service + .restore_managed_checkpoint_and_admit(request.clone()) + .await + .unwrap_err() + .to_string(), + "managed_submitted_unknown" + ); + let runtime = service.managed_runtime_for_test().unwrap(); + let durable = runtime + .repository + .restore_receipt( + &request, + &crate::RunCancellation::with_timeout(std::time::Duration::from_secs(5)), + ) + .unwrap(); + let now = crate::domain::timestamp(); + tokio::time::sleep(std::time::Duration::from_millis( + durable.new_run_deadline_at_ms.saturating_sub(now) + 30, + )) + .await; + let restored = service + .restore_managed_checkpoint_and_admit(request.clone()) + .await + .unwrap(); + assert_eq!(restored.new_run_id, durable.new_run_id); + assert_eq!( + restored.new_run_deadline_at_ms, + durable.new_run_deadline_at_ms + ); + let handle = service + .handle(&restored.new_run_id) + .expect("committed handle must be installed after original deadline"); + assert!(handle.cancel.deadline_passed()); + assert!(service.run_context(&restored.new_run_id).is_some()); + service + .clone() + .run_worker(restored.new_run_id.clone(), String::new()) + .await; + assert!(handle.terminal.load(std::sync::atomic::Ordering::Acquire)); + let effects = persistence + .managed_effect_list(&crate::effects::ManagedEffectListRequest { + workspace_id: source.workspace_id.clone(), + after_effect_id: None, + limit: 128, + }) + .unwrap(); + assert!( + !effects + .entries + .iter() + .any(|effect| effect.run_id == restored.new_run_id), + "expired restored run starts no effect" + ); + assert_eq!( + service + .restore_managed_checkpoint_and_admit(request) + .await + .unwrap() + .new_run_id, + restored.new_run_id + ); + service.shutdown().await.unwrap(); +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn unknown_reconciliation_reuses_pending_capacity() { + let (_root, state, source) = maintenance_source().await; + let service = state.service(); + let request = restore_request(&state, &source).await; + let persistence = state.persistence().unwrap(); + persistence.inject_drop_managed_commit_ack(); + assert_eq!( + service + .restore_managed_checkpoint_and_admit(request.clone()) + .await + .unwrap_err() + .to_string(), + "managed_submitted_unknown" + ); + assert_eq!( + persistence.pending_count_for_test(), + 1, + "settled worker leaves exactly the original slot charged" + ); + let attempt = service + .inner + .maintenance_attempts + .lock() + .get(&request.request_id) + .unwrap() + .clone(); + let full = persistence.fill_pending_for_test(); + assert_eq!(persistence.pending_count_for_test(), 32); + let cancel = crate::RunCancellation::with_timeout(std::time::Duration::from_secs(5)); + let payload = json!({"workspace_id":request.workspace_id,"receipt_id":request.request_id,"cut":"restore"}); + assert!( + persistence + .managed_effect_command_with_cancellation( + "effect.maintenance_receipt", + &payload, + &cancel + ) + .is_err(), + "ordinary reads cannot get a 33rd pending slot" + ); + let claim = &attempt.claim; + assert!( + persistence + .maintenance_read( + claim, + &claim.identity, + "wrong proof", + "effect.maintenance_receipt", + &payload, + &cancel + ) + .is_err() + ); + let mut identity = claim.identity.clone(); + identity.branch_id.push_str("-wrong"); + assert!( + persistence + .maintenance_read( + claim, + &identity, + &claim.proof, + "effect.maintenance_receipt", + &payload, + &cancel + ) + .is_err() + ); + assert!( + persistence + .maintenance_read( + claim, + &claim.identity, + &claim.proof, + "admission.create", + &payload, + &cancel + ) + .is_err(), + "borrow never admits" + ); + let (_other_root, other) = fixture(); + assert!( + other + .persistence() + .unwrap() + .maintenance_read( + claim, + &claim.identity, + &claim.proof, + "effect.maintenance_receipt", + &payload, + &cancel + ) + .is_err(), + "persistence instance cannot be replaced" + ); + other.service().shutdown().await.unwrap(); + let restored = service + .restore_managed_checkpoint_and_admit(request) + .await + .unwrap(); + assert!(service.handle(&restored.new_run_id).is_some()); + assert_eq!( + persistence.pending_count_for_test(), + 32, + "borrowed reads did not add capacity" + ); + drop(attempt); + assert_eq!(persistence.pending_count_for_test(), 31); + drop(full); + assert_eq!(persistence.pending_count_for_test(), 0); + service.shutdown().await.unwrap(); +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn checkpoint_unknown_replays_same_frozen_root() { + let (_root, state, request) = + maintenance_source_timeout(std::time::Duration::from_secs(3)).await; + let service = state.service(); + let persistence = state.persistence().unwrap(); + persistence.inject_drop_managed_commit_ack(); + let unknown = service.create_managed_checkpoint(request.clone()).await; + assert!( + !persistence.managed_commit_ack_drop_armed(), + "real COMMIT reply was discarded" + ); + assert_eq!( + unknown.unwrap_err().to_string(), + "managed_submitted_unknown" + ); + assert_eq!(persistence.pending_count_for_test(), 1); + assert!( + service + .managed_provider_enter(&request.source_run_id) + .is_err(), + "same branch remains frozen" + ); + assert!( + service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"must not enter frozen checkpoint branch"}), + ..Default::default() + }, + None + ) + .await + .is_err() + ); + let runtime = service.managed_runtime_for_test().unwrap(); + let cancellation = crate::RunCancellation::with_timeout(std::time::Duration::from_secs(5)); + let context = runtime + .repository + .checkpoint_context(&request, &cancellation) + .unwrap(); + let commands = Arc::new(std::sync::Mutex::new(Vec::new())); + let trace = commands.clone(); + *persistence.managed_request_hook.lock().unwrap() = Some(Arc::new(move |op, _, enqueued| { + if enqueued { + trace.lock().unwrap().push(op.to_owned()); + } + })); + let receipt = service + .create_managed_checkpoint(request.clone()) + .await + .unwrap(); + assert!(receipt.replayed); + assert_eq!( + receipt.checkpoint.frozen_root.object_id, + context.head.head_object_id + ); + let retry = service + .create_managed_checkpoint(request.clone()) + .await + .unwrap(); + assert_eq!(receipt.checkpoint, retry.checkpoint); + assert!( + !commands + .lock() + .unwrap() + .iter() + .any(|op| op == "effect.checkpoint_context" || op == "effect.checkpoint_create"), + "exact replay never recaptures a live head or resubmits a write" + ); + *persistence.managed_request_hook.lock().unwrap() = None; + let mut conflict = request; + conflict.expected_epoch += 1; + assert_eq!( + service + .create_managed_checkpoint(conflict) + .await + .unwrap_err() + .to_string(), + "managed_request_conflict" + ); + service.shutdown().await.unwrap(); +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn checkpoint_waits_for_whole_tool_batch() { + let (_root, state) = fixture_config(|config| { + config.bearer_token = Some("synthetic-maintenance-fixture".into()); + config + .managed_workspace + .as_mut() + .unwrap() + .maintenance_policy + .create_checkpoint = true; + }); + let service = state.service(); + let provider = Arc::new(crate::ScriptedProvider::new()); + provider.push_ok(json!({"text":"", "tool_calls":[ + {"id":"batch-first", "name":"write_file", "arguments":{"path":"first.txt","content":"one"}}, + {"id":"batch-second", "name":"write_file", "arguments":{"path":"second.txt","content":"two"}} + ],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2},"reasoning":"","stop_reason":"tool_calls"})); + provider.push_ok(response(None)); + service.inject_provider_host(provider); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"close the full synthetic two-call batch"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + let gate = service + .arm_managed_call_prepare_gate("batch-second") + .unwrap(); + let worker_service = service.clone(); + let worker_id = run.run_id.clone(); + let worker = + tokio::spawn(async move { worker_service.run_worker(worker_id, String::new()).await }); + gate.wait_arrived(1).unwrap(); + let first = state + .persistence() + .unwrap() + .managed_effect_get(&crate::effects::ManagedEffectGetRequest { + owner: crate::effects::ManagedOwnerScope { + profile: "gateway".into(), + session_id: run.session_id, + run_id: run.run_id.clone(), + }, + call_id: "batch-first".into(), + }) + .unwrap() + .unwrap(); + assert!(first.outcome.is_some(), "first output is durably closed"); + let request = crate::effects::ManagedCheckpointCreateRequest { + source_run_id: run.run_id, + workspace_id: first.identity.workspace.workspace_id, + source_branch_id: first.identity.workspace.branch_id, + expected_epoch: first.identity.epoch, + request_id: uuid::Uuid::new_v4().to_string(), + mode: crate::effects::ManagedCheckpointMode::AtClosedBatch, + }; + let maintenance_service = service.clone(); + let checkpoint = + tokio::spawn(async move { maintenance_service.create_managed_checkpoint(request).await }); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + let prematurely_finished = checkpoint.is_finished(); + gate.release(); + worker.await.unwrap(); + let checkpoint = checkpoint.await.unwrap(); + service.shutdown().await.unwrap(); + assert!( + !prematurely_finished, + "checkpoint must wait for the already enumerated batch" + ); + let checkpoint = checkpoint.expect("full original batch closes under its original tokens"); + let calls = checkpoint + .checkpoint + .history + .closed_output_refs + .iter() + .map(|output| output.call_id.as_str()) + .collect::>(); + assert_eq!( + calls, + std::collections::BTreeSet::from(["batch-first", "batch-second"]) + ); +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn foreground_intent_persists_lineage_before_native_execution() { + let (_root, state) = fixture(); + let service = state.service(); + let runtime = service.managed_runtime_for_test().unwrap(); + let provider = Arc::new(crate::ScriptedProvider::new()); + provider.push_ok(response(Some(json!({ + "id": "process-intent", + "name": "terminal", + "arguments": {"argv": ["/bin/true"], "timeout_ms": 1000} + })))); + provider.push_ok(response(None)); + service.inject_provider_host(provider); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message": "synthetic process intent; no child execution"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + runtime.arm_fault(crate::effects::ManagedEffectFault::AfterIntent); + service + .clone() + .run_worker(run.run_id.clone(), "ignored".into()) + .await; + let persistence = state.persistence().unwrap(); + let record = persistence + .managed_effect_get(&crate::effects::ManagedEffectGetRequest { + owner: crate::effects::ManagedOwnerScope { + profile: "gateway".into(), + session_id: run.session_id, + run_id: run.run_id, + }, + call_id: "process-intent".into(), + }) + .unwrap() + .expect("formal durable foreground intent"); + assert!( + record.prepared.is_none(), + "cut must precede Native execution" + ); + let result = runtime + .repository + .command_with_cancellation( + "effect.head", + &json!({ + "workspace_id": record.identity.workspace.workspace_id, + "branch_id": record.identity.workspace.branch_id + }), + &crate::RunCancellation::with_timeout(std::time::Duration::from_secs(5)), + ) + .unwrap(); + let head: Value = serde_json::from_str(result["rows"][0][0].as_str().unwrap()).unwrap(); + drop(persistence); + drop(runtime); + service.shutdown().await.unwrap(); + assert_eq!( + head["process_lineage_tainted"], true, + "the same effect.begin transaction must retain foreground intent independently of process table or prepared result" + ); +} + #[cfg(feature = "workspace-io-fault")] fn register_native_budget_intent( + persistence: &crate::GatewayPersistence, binding: &Arc, index: usize, deadline_ms: u64, @@ -23,10 +2183,60 @@ fn register_native_budget_intent( .unwrap(); let record = call.record(); let seq = (index % 128) * 2 + 1000; - let output = json!({"event_id":uuid::Uuid::new_v4().to_string(),"event_type":"tool.requested","seq":seq,"payload_json":"{}"}); - let terminal = json!({"event_id":uuid::Uuid::new_v4().to_string(),"event_type":"tool.started","seq":seq+1,"payload_json":"{}"}); + let output = json!({"event_id":uuid::Uuid::new_v4().to_string(),"event_type":"tool.requested","seq":seq,"payload_json":"{}","created_at_ms":crate::domain::timestamp()}); + let terminal = json!({"event_id":uuid::Uuid::new_v4().to_string(),"event_type":"tool.started","seq":seq+1,"payload_json":"{}","created_at_ms":crate::domain::timestamp()}); let projections = json!({"normal":{"output":output,"terminal":terminal}}); - binding.runtime.repository.command_with_cancellation("effect.begin",&json!({"effect_id":call.effect_id,"run_id":record.identity.owner.run_id,"call_id":record.identity.call_id,"workspace_id":record.identity.workspace.workspace_id,"branch_id":record.identity.workspace.branch_id,"record_json":serde_json::to_string(&record).unwrap(),"commit_id":call.receipt_id,"projections_json":projections.to_string(),"max_events":max_events}),&binding.cancel).unwrap(); + let request = json!({"effect_id":call.effect_id,"run_id":record.identity.owner.run_id,"call_id":record.identity.call_id,"workspace_id":record.identity.workspace.workspace_id,"branch_id":record.identity.workspace.branch_id,"record_json":serde_json::to_string(&record).unwrap(),"commit_id":call.receipt_id,"sealed_class":call.policy.class().as_str(),"projections_json":projections.to_string(),"max_events":max_events}); + if index == 0 { + let replay_request = json!({"run_id":record.identity.owner.run_id,"after_seq":0,"max_events":128,"max_bytes":1048576}); + let before = persistence.event_replay(&replay_request).unwrap(); + for field in ["output", "terminal"] { + for malformed in [ + Value::Null, + json!("11"), + json!(true), + json!(1.5), + json!(-1), + json!(u64::MAX), + ] { + let mut draft = projections.clone(); + if malformed.is_null() { + draft["normal"][field] + .as_object_mut() + .unwrap() + .remove("created_at_ms"); + } else { + draft["normal"][field]["created_at_ms"] = malformed; + } + let mut invalid = request.clone(); + invalid["projections_json"] = json!(draft.to_string()); + let error = binding + .runtime + .repository + .command_with_cancellation("effect.begin", &invalid, &binding.cancel) + .unwrap_err(); + assert!( + error.contains("NOT NULL"), + "actual typed transaction must refuse invalid draft clock: {error}" + ); + assert_eq!(persistence.event_replay(&replay_request).unwrap(), before); + assert!( + persistence + .managed_effect_get(&crate::effects::ManagedEffectGetRequest { + owner: record.identity.owner.clone(), + call_id: record.identity.call_id.clone() + }) + .unwrap() + .is_none() + ); + } + } + } + binding + .runtime + .repository + .command_with_cancellation("effect.begin", &request, &binding.cancel) + .unwrap(); call.interrupt_unpublished(); assert!(call.adapters("not-a-token").is_err()); } @@ -86,6 +2296,7 @@ async fn real_native_factory_and_formal_rss_512_reference_boundary_uses_sdk_trun let batch = (508 - total).min(128); for _ in 0..batch { register_native_budget_intent( + &persistence, &binding, total, deadline, @@ -111,6 +2322,7 @@ async fn real_native_factory_and_formal_rss_512_reference_boundary_uses_sdk_trun ); let (binding, deadline) = last.unwrap(); register_native_budget_intent( + &persistence, &binding, total, deadline, @@ -229,6 +2441,7 @@ async fn actual_sdk_commit_acknowledgement_loss_reads_exact_durable_outcome_with .unwrap() .with_workspace_home(root.join("home")); let service = reopened.service(); + assert_eq!(service.run_events(&run.run_id), live); assert!( serde_json::to_value( service @@ -491,7 +2704,7 @@ async fn exact_begin_and_prepared_resends_preserve_staged_receipt_without_new_au .managed_workspace_head(&before.identity.workspace) .unwrap(); let messages = persistence.message_list(&run.session_id, 0).unwrap(); - let begin = json!({"effect_id":call.effect_id,"run_id":run.run_id,"call_id":"staged","record_json":serde_json::to_string(&call.record()).unwrap()}); + let begin = json!({"effect_id":call.effect_id,"run_id":run.run_id,"call_id":"staged","sealed_class":call.policy.class().as_str(),"record_json":serde_json::to_string(&call.record()).unwrap()}); let prepared = json!({"effect_id":call.effect_id,"receipt_id":call.receipt_id,"prepared_json":serde_json::to_string(before.prepared.as_ref().unwrap()).unwrap()}); for (op, payload) in [("effect.begin", begin), ("effect.prepare", prepared)] { let reply = persistence @@ -633,3 +2846,363 @@ async fn final_formal_commit_resend_returns_same_outcome_without_events_head_or_ drop(state); assert!(root.join(".gateway.sqlite.effects").is_dir()); } + +#[cfg(feature = "workspace-io-fault")] +fn slow_deadline_reprepares(service: &Arc) -> Arc>> { + let attempts = Arc::new(std::sync::Mutex::new(Vec::new())); + let seen = attempts.clone(); + let weak = Arc::downgrade(service); + service.inject_prompt_read_entered_observer(Arc::new(move || { + let mut attempts = seen.lock().unwrap(); + attempts.push(super::super::timestamp()); + let round = attempts.len(); + drop(attempts); + if round <= 4 { + // Real preparation delay and relevant config drift, without another + // admission, capacity permit, cancellation root or fabricated row. + std::thread::sleep(std::time::Duration::from_millis(100)); + let service = weak.upgrade().unwrap(); + let mut limits = service.inner.run_limits.read().clone(); + limits.max_turns += 1; + service.set_run_limits(limits).unwrap(); + } + })); + attempts +} + +#[cfg(feature = "workspace-io-fault")] +fn assert_admission_deadline_payload(payload: &Value, context: &crate::RunContext) -> u64 { + let saved: Value = serde_json::from_str(payload["input_json"].as_str().unwrap()).unwrap(); + let binding: Value = + serde_json::from_str(payload["managed_binding_json"].as_str().unwrap()).unwrap(); + let record: Value = serde_json::from_str(binding["record_json"].as_str().unwrap()).unwrap(); + let deadline = context.metadata["deadline_at_ms"].as_u64().unwrap(); + assert_eq!(saved["run_context"]["metadata"]["deadline_at_ms"], deadline); + assert_eq!(record["original_deadline_ms"], deadline); + deadline +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn ordinary_managed_slow_reprepare_keeps_initial_context_and_binding_deadline() { + let (_root, state) = fixture(); + let service = state.service(); + let persistence = state.persistence().unwrap(); + let payload = Arc::new(std::sync::Mutex::new(None)); + let seen = payload.clone(); + persistence + .inject_admission_precommit_hook(move |value| *seen.lock().unwrap() = Some(value.clone())); + let attempts = slow_deadline_reprepares(&service); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"ordinary slow clock"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + *service.inner.prompt_read_entered.lock().unwrap() = None; + let context = service.run_context(&run.run_id).unwrap(); + let deadline = + assert_admission_deadline_payload(&payload.lock().unwrap().take().unwrap(), &context); + let attempts = attempts.lock().unwrap().clone(); + assert_eq!(attempts.len(), 5, "four relevant drifts really reprepare"); + assert!(deadline <= attempts[0] + service.inner.config.run_timeout.as_millis() as u64); + assert!(context.metadata["created_at_ms"].as_u64().unwrap() >= attempts[4]); + let handle = service.handle(&run.run_id).unwrap(); + let remaining = handle.cancel.remaining_deadline().unwrap(); + assert!(remaining < service.inner.config.run_timeout - std::time::Duration::from_millis(400)); + println!( + "ordinary initial deadline={deadline}; prompt attempts={attempts:?}; remaining={remaining:?}; context/persisted/binding exact" + ); + drop(attempts); + let provider = Arc::new(crate::ScriptedProvider::new()); + provider.push_ok(response(None)); + service.inject_provider_host(provider); + service.clone().run_worker(run.run_id, String::new()).await; + service.shutdown().await.unwrap(); +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn restored_slow_reprepare_keeps_initial_context_binding_envelope_and_receipt_deadline() { + let (_root, state, source) = maintenance_source().await; + let service = state.service(); + let request = restore_request(&state, &source).await; + let persistence = state.persistence().unwrap(); + let payload = Arc::new(std::sync::Mutex::new(None)); + let seen = payload.clone(); + persistence + .inject_admission_precommit_hook(move |value| *seen.lock().unwrap() = Some(value.clone())); + let attempts = slow_deadline_reprepares(&service); + let result = service + .restore_managed_checkpoint_and_admit(request.clone()) + .await + .unwrap(); + *service.inner.prompt_read_entered.lock().unwrap() = None; + let context = service.run_context(&result.new_run_id).unwrap(); + let payload = payload.lock().unwrap().take().unwrap(); + let deadline = assert_admission_deadline_payload(&payload, &context); + assert_eq!(deadline, result.new_run_deadline_at_ms); + let envelope: crate::effects::journal::ManagedRestoreExecutionEnvelope = + serde_json::from_str(payload["restore_json"].as_str().unwrap()).unwrap(); + assert_eq!(envelope.result.new_run_deadline_at_ms, deadline); + let attempts = attempts.lock().unwrap().clone(); + assert_eq!(attempts.len(), 5); + assert!(deadline <= attempts[0] + service.inner.config.run_timeout.as_millis() as u64); + let handle = service.handle(&result.new_run_id).unwrap(); + assert!( + handle.cancel.remaining_deadline().unwrap() + < service.inner.config.run_timeout - std::time::Duration::from_millis(400) + ); + let stored = service + .managed_runtime_for_test() + .unwrap() + .repository + .lookup_restore_receipt( + &request, + &crate::RunCancellation::with_timeout(std::time::Duration::from_secs(5)), + ) + .unwrap() + .unwrap(); + assert_eq!(stored.new_run_deadline_at_ms, deadline); + println!( + "restored initial deadline={deadline}; prompt attempts={attempts:?}; context/persisted/binding/envelope/receipt exact" + ); + drop(attempts); + let provider = Arc::new(crate::ScriptedProvider::new()); + provider.push_ok(response(None)); + service.inject_provider_host(provider); + service + .clone() + .run_worker(result.new_run_id, String::new()) + .await; + service.shutdown().await.unwrap(); +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn naturally_expired_frozen_handle_has_no_permit_or_managed_codepin_authority() { + let (_root, state) = + fixture_config(|config| config.run_timeout = std::time::Duration::from_secs(2)); + let service = state.service(); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"natural original-clock expiry"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + let before = service.run_context(&run.run_id).unwrap().metadata; + let original = service.handle(&run.run_id).unwrap(); + assert!(original.permit.lock().unwrap().is_some()); + assert!(original.pinned_tool.as_ref().unwrap().managed.is_some()); + let native_deadline = original.cancel.deadline_instant().unwrap(); + let workspace_id = original + .pinned_tool + .as_ref() + .unwrap() + .managed + .as_ref() + .unwrap() + .admitted_branch + .workspace_id + .clone(); + tokio::time::sleep( + original.cancel.remaining_deadline().unwrap() + std::time::Duration::from_millis(2), + ) + .await; + assert!(original.cancel.deadline_passed()); + assert!(super::super::timestamp() >= before["deadline_at_ms"].as_u64().unwrap()); + service.evict_run_handle(&run.run_id); + drop(original); + let rebuilt = service + .restore_handle_from_frozen_context(&run.run_id) + .unwrap() + .unwrap(); + assert!(rebuilt.permit.lock().unwrap().is_none()); + assert!(rebuilt.workspace_authority.is_none()); + assert!( + rebuilt.pinned_tool.is_none(), + "expired reconstruction must prepare no executable pin or Native branch grant" + ); + assert!(rebuilt.cancel.deadline_passed() || rebuilt.cancel.requested().is_some()); + let provider = Arc::new(crate::ScriptedProvider::new()); + service.inject_provider_host(provider.clone()); + let persistence = state.persistence().unwrap(); + let request = crate::effects::ManagedEffectListRequest { + workspace_id, + after_effect_id: None, + limit: 100, + }; + let effects = persistence.managed_effect_list(&request).unwrap(); + service + .clone() + .run_worker(run.run_id.clone(), String::new()) + .await; + assert!(provider.requests().is_empty()); + assert_eq!(persistence.managed_effect_list(&request).unwrap(), effects); + assert_eq!(service.run_context(&run.run_id).unwrap().metadata, before); + assert!( + service + .run_events(&run.run_id) + .iter() + .any(|event| event["event"] == "run.cancelled") + ); + println!( + "natural Native deadline elapsed={:?}; frozen deadline={} unchanged; permit=None managed=None authority=None provider=0", + native_deadline.elapsed(), + before["deadline_at_ms"] + ); + service.shutdown().await.unwrap(); +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn ordinary_unmanaged_slow_reprepare_exposes_original_deadline_through_typed_context() { + let (_root, state) = fixture_config(|config| config.managed_workspace = None); + let service = state.service(); + let persistence = state.persistence().unwrap(); + let payload = Arc::new(std::sync::Mutex::new(None)); + let seen = payload.clone(); + persistence + .inject_admission_precommit_hook(move |value| *seen.lock().unwrap() = Some(value.clone())); + let attempts = slow_deadline_reprepares(&service); + let run = service + .admit(AdmitRunRequest { + input: json!({"message":"ordinary unmanaged slow clock"}), + ..Default::default() + }) + .await + .unwrap(); + *service.inner.prompt_read_entered.lock().unwrap() = None; + let context = service.run_context(&run.run_id).unwrap(); + let deadline = context.metadata["deadline_at_ms"].as_u64().unwrap(); + let payload = payload.lock().unwrap().take().unwrap(); + let saved: Value = serde_json::from_str(payload["input_json"].as_str().unwrap()).unwrap(); + assert_eq!(saved["run_context"]["metadata"]["deadline_at_ms"], deadline); + assert!( + payload.get("restore_json").is_none(), + "ordinary admission has no restore receipt" + ); + let attempts = attempts.lock().unwrap().clone(); + assert_eq!(attempts.len(), 5); + assert!(deadline <= attempts[0] + service.inner.config.run_timeout.as_millis() as u64); + assert!( + service + .handle(&run.run_id) + .unwrap() + .cancel + .remaining_deadline() + .unwrap() + < service.inner.config.run_timeout - std::time::Duration::from_millis(400) + ); + println!( + "ordinary unmanaged initial deadline={deadline}; attempts={attempts:?}; typed context/persisted exact; no restore receipt" + ); + drop(attempts); + let provider = Arc::new(crate::ScriptedProvider::new()); + provider.push_ok(response(None)); + service.inject_provider_host(provider); + service.clone().run_worker(run.run_id, String::new()).await; + service.shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn t14_nonmanaged_persistent_unknown_keeps_original_storage_error() { + let (_root, state) = fixture_config(|config| { + config.managed_workspace = None; + config.max_concurrent_runs = 2; + }); + let service = state.service(); + let persistence = state.persistence().unwrap(); + assert!(service.inner.managed_effects.read().is_none()); + persistence.inject_admission_ack_loss(); + let committed = std::sync::Arc::new(std::sync::Mutex::new(String::new())); + let committed_hook = committed.clone(); + let replay = persistence.clone(); + persistence.inject_admission_postcommit_hook(move |payload| { + if let Some(id) = payload.get("run_id").and_then(|value| value.as_str()) { + *committed_hook.lock().expect("run id") = id.to_string(); + } + replay.arm_admission_replay_read_failures(1); + }); + let request = AdmitRunRequest { + input: json!({"message":"plain persistent admission"}), + idempotency_key: Some("b3-plain-unknown".into()), + idempotency_hash: Some("fnv64:0000000000000b03".into()), + graph_id: None, + workspace_branch: None, + platform: "b3-plain".into(), + ..Default::default() + }; + let admitted = service + .admit_external_with_workspace_selector(request.clone(), None) + .await; + let caller = match &admitted { + Ok(_) => "published".to_string(), + Err(crate::AdmitError::Persistence(code)) => code.clone(), + Err(other) => format!("{other:?}"), + }; + let run_id = committed.lock().expect("run id").clone(); + let rows = if run_id.is_empty() { + 0 + } else { + persistence.run_get(&run_id).expect("committed read")["rows"] + .as_array() + .expect("rows") + .len() + }; + println!( + "B3_NONMANAGED_OWNER {}", + json!({"caller":caller,"rows":rows,"permits":service.available_admission_permits(),"handle":!run_id.is_empty() && service.handle(&run_id).is_some()}) + ); + assert!( + caller.contains("storage_unavailable"), + "non-managed unknown must keep the original storage error, got {caller}" + ); + assert!( + !caller.contains("unpublished_original_admission"), + "must not install the unresolved classification: {caller}" + ); + assert_eq!( + rows, 1, + "real ack loss already committed the original durable row" + ); + assert!(service.handle(&run_id).is_none(), "no memory publication"); + assert_eq!( + service.available_admission_permits(), + 2, + "this attempt must not leak its permit" + ); + persistence.arm_admission_replay_read_failures(0); + let retry = service + .admit_external_with_workspace_selector(request, None) + .await; + assert!( + retry.is_err(), + "recovered readback must stay closed, not 202: {retry:?}" + ); + assert!( + service.handle(&run_id).is_none(), + "retry must not publish a handle" + ); + assert_eq!( + persistence.run_get(&run_id).expect("row remains")["rows"] + .as_array() + .expect("rows") + .len(), + 1, + "retry must not erase the committed row" + ); + assert_eq!(service.available_admission_permits(), 2); + service + .shutdown() + .await + .expect("no impossible owner remains"); +} diff --git a/src/service/managed_publications.rs b/src/service/managed_publications.rs new file mode 100644 index 0000000..3f65e89 --- /dev/null +++ b/src/service/managed_publications.rs @@ -0,0 +1,426 @@ +use std::time::Duration; + +use serde_json::json; + +use crate::RunCancellation; +use crate::config::ADMISSION_SESSION_PROFILE; +use crate::domain::timestamp; +use crate::effects::ManagedMaintenanceFailure; +use crate::effects::journal::{ + ManagedPublishEligibility, ManagedPublishReceipt, ManagedPublishRequest, + ManagedWorkspacePublication, +}; + +use super::AgentService; + +fn rejected(code: &str) -> ManagedMaintenanceFailure { + ManagedMaintenanceFailure::Rejected { + code: code.to_string(), + } +} + +fn unknown(request_id: &str) -> ManagedMaintenanceFailure { + ManagedMaintenanceFailure::SubmittedUnknown { + request_id: request_id.to_string(), + receipt_lookup_id: request_id.to_string(), + } +} + +fn budget_code(cancellation: &RunCancellation) -> Option<&'static str> { + if cancellation.has_deadline_overflow() || cancellation.deadline_passed() { + Some("managed_deadline_elapsed") + } else if cancellation.requested().is_some() { + Some("managed_checkpoint_busy") + } else { + None + } +} + +fn native_code(error: &str) -> &'static str { + match error { + "managed_deadline_elapsed" | "deadline_elapsed" => "managed_deadline_elapsed", + "managed_checkpoint_busy" | "cancelled" => "managed_checkpoint_busy", + "managed_request_invalid" | "managed identifier malformed" => "managed_request_invalid", + "managed_branch_missing" => "managed_branch_missing", + "managed_source_conflict" | "managed_transaction_rolled_back" => "managed_source_conflict", + "managed_request_conflict" => "managed_request_conflict", + "managed_metadata_budget_exceeded" => "managed_metadata_budget_exceeded", + "managed_process_lineage_unsupported" => "managed_process_lineage_unsupported", + "managed_authority_required" => "managed_authority_required", + "managed_management_denied" => "managed_management_denied", + "managed_storage_unavailable" => "managed_storage_unavailable", + "managed_submitted_unknown" => "managed_submitted_unknown", + other if other.contains("deadline") => "managed_deadline_elapsed", + other if other.contains("cancel") => "managed_checkpoint_busy", + other if other.contains("missing") && !other.contains('/') => "managed_branch_missing", + _ => "managed_storage_unavailable", + } +} + +fn authority_code(error: &str) -> &'static str { + if error.contains("deadline") { + "managed_deadline_elapsed" + } else if error.contains("cancel") { + "managed_checkpoint_busy" + } else if error.contains("missing") && !error.contains('/') { + "managed_branch_missing" + } else { + "managed_authority_required" + } +} + +fn eligibility_code(code: &str) -> &'static str { + match code { + "foreign" => "managed_authority_required", + "missing" => "managed_branch_missing", + "stale" => "managed_source_conflict", + "busy" => "managed_checkpoint_busy", + "conflict" => "managed_request_conflict", + _ => "managed_source_conflict", + } +} + +fn pointer_matches(pointer: &ManagedWorkspacePublication, receipt: &ManagedPublishReceipt) -> bool { + pointer.workspace_id == receipt.workspace_id + && pointer.profile == receipt.profile + && pointer.branch_id == receipt.branch_id + && pointer.epoch == receipt.epoch + && pointer.head_object_id == receipt.head_object_id + && pointer.head_version == receipt.head_version + && pointer.previous_published_head == receipt.previous_published_head + && pointer.request_id == receipt.request_id + && pointer.published_at_ms == receipt.published_at_ms +} + +impl AgentService { + pub async fn publish_managed_workspace( + &self, + request: ManagedPublishRequest, + ) -> Result { + let cancellation = RunCancellation::with_timeout(Duration::from_secs(5)); + self.publish_managed_workspace_with_cancellation(request, cancellation) + .await + } + + pub(crate) async fn publish_managed_workspace_with_cancellation( + &self, + request: ManagedPublishRequest, + cancellation: RunCancellation, + ) -> Result { + let service = self.clone(); + tokio::task::spawn_blocking(move || { + service.publish_managed_workspace_blocking(request, &cancellation) + }) + .await + .map_err(|_| rejected("managed_storage_unavailable"))? + } + + pub(crate) fn publish_managed_workspace_blocking( + &self, + request: ManagedPublishRequest, + cancellation: &RunCancellation, + ) -> Result { + crate::effects::journal::validate_maintenance_value::( + &serde_json::to_value(&request) + .map_err(|_| rejected("managed_metadata_budget_exceeded"))?, + ) + .map_err(|_| rejected("managed_request_invalid"))?; + let config = self + .inner + .config + .managed_workspace + .as_ref() + .ok_or_else(|| rejected("managed_authority_required"))?; + if self.inner.config.bearer_token.is_none() || !config.maintenance_policy.publish { + return Err(rejected("managed_management_denied")); + } + if let Some(code) = budget_code(cancellation) { + return Err(rejected(code)); + } + crate::effects::broker::validate_id("ws", &request.workspace_id) + .map_err(|_| rejected("managed_request_invalid"))?; + crate::effects::broker::validate_id("br", &request.branch_id) + .map_err(|_| rejected("managed_branch_missing"))?; + if request.workspace_id != request.expected_head.workspace_id + || request.branch_id != request.expected_head.branch_id + { + return Err(rejected("managed_authority_required")); + } + if request.expected_head.process_lineage_tainted { + return Err(rejected("managed_process_lineage_unsupported")); + } + uuid::Uuid::parse_str(&request.request_id) + .map_err(|_| rejected("managed_request_conflict"))?; + let proof = serde_json::to_string(&request) + .map_err(|_| rejected("managed_metadata_budget_exceeded"))?; + let runtime = self + .inner + .managed_effects + .read() + .clone() + .ok_or_else(|| rejected("managed_authority_required"))?; + let source_root = config + .source_root + .to_str() + .ok_or_else(|| rejected("managed_authority_required"))?; + if let Some(code) = budget_code(cancellation) { + return Err(rejected(code)); + } + let (_, authority) = self + .resolve_workspace_admission(Some(source_root), None, cancellation, false) + .map_err(|_| { + budget_code(cancellation) + .map(rejected) + .unwrap_or_else(|| rejected("managed_authority_required")) + })?; + let authority = authority.ok_or_else(|| rejected("managed_authority_required"))?; + if let Some(code) = budget_code(cancellation) { + return Err(rejected(code)); + } + runtime + .authorize_workspace_query( + &authority, + ADMISSION_SESSION_PROFILE, + &request.workspace_id, + cancellation, + ) + .map_err(|error| { + budget_code(cancellation) + .map(rejected) + .unwrap_or_else(|| rejected(authority_code(&error))) + })?; + if let Some(code) = budget_code(cancellation) { + return Err(rejected(code)); + } + let workspace = runtime + .authorized_workspace( + &authority, + ADMISSION_SESSION_PROFILE, + &request.workspace_id, + cancellation, + ) + .map_err(|error| { + budget_code(cancellation) + .map(rejected) + .unwrap_or_else(|| rejected(authority_code(&error))) + })?; + if workspace.profile != ADMISSION_SESSION_PROFILE { + return Err(rejected("managed_authority_required")); + } + if let Some(saved) = runtime + .repository + .publish_receipt(&request.workspace_id, &request.request_id, cancellation) + .map_err(|error| rejected(native_code(&error)))? + { + return if saved.proof == proof { + Ok(saved.receipt) + } else { + Err(rejected("managed_request_conflict")) + }; + } + if let Some(code) = budget_code(cancellation) { + return Err(rejected(code)); + } + let mut document = json!({ + "workspace_id": request.workspace_id, + "branch_id": request.branch_id, + "profile": workspace.profile, + "expected_head": request.expected_head, + "request_id": request.request_id, + "proof": proof, + "now_ms": timestamp(), + "expected_revision": 0, + }); + let eligibility = runtime + .repository + .publish_eligibility(&document, cancellation) + .map_err(|error| rejected(native_code(&error)))?; + if eligibility.code != "idle" { + return Err(rejected(eligibility_code(&eligibility.code))); + } + document["expected_revision"] = json!(eligibility.revision); + if let Some(code) = budget_code(cancellation) { + return Err(rejected(code)); + } + if runtime.source_has_active_writer(&request.branch_id) { + return Err(rejected("managed_checkpoint_busy")); + } + if let Some(code) = budget_code(cancellation) { + return Err(rejected(code)); + } + let mut identity = workspace.identity.clone(); + identity.branch_id = request.branch_id.clone(); + let claim = runtime + .repository + .maintenance_claim( + identity, + proof.clone(), + request.request_id.clone(), + "publish", + ) + .map_err(|_| rejected("managed_storage_unavailable"))?; + match runtime + .repository + .publish_submit(&document, cancellation, &claim) + { + Ok(receipt) => confirm_publication( + &runtime, + &request, + &workspace.profile, + eligibility.revision, + receipt, + cancellation, + claim.submitted(), + ), + Err(error) if error == "managed_submitted_unknown" || claim.submitted() => { + saved_or_unknown(&runtime, &request, &proof, cancellation) + } + Err(error) if error == "managed_transaction_rolled_back" => { + if let Some(saved) = runtime + .repository + .publish_receipt(&request.workspace_id, &request.request_id, cancellation) + .ok() + .flatten() + && saved.proof == proof + { + return Ok(saved.receipt); + } + let again = runtime + .repository + .publish_eligibility(&document, cancellation) + .unwrap_or(ManagedPublishEligibility { + code: "stale".into(), + revision: eligibility.revision, + }); + if again.code == "idle" && again.revision != eligibility.revision { + Err(rejected("managed_source_conflict")) + } else { + Err(rejected(eligibility_code(&again.code))) + } + } + Err(error) => Err(rejected(native_code(&error))), + } + } +} + +fn confirm_publication( + runtime: &crate::effects::broker::ManagedEffectRuntime, + request: &ManagedPublishRequest, + profile: &str, + expected_revision: u64, + receipt: ManagedPublishReceipt, + cancellation: &RunCancellation, + submitted: bool, +) -> Result { + let head_matches = receipt.status == "committed" + && receipt.workspace_id == request.workspace_id + && receipt.profile == profile + && receipt.branch_id == request.branch_id + && receipt.epoch == request.expected_head.epoch + && receipt.head_object_id == request.expected_head.head_object_id + && receipt.head_version == request.expected_head.head_version + && receipt.request_id == request.request_id + && (expected_revision > 0 || receipt.previous_published_head.is_none()); + let pointer = runtime + .repository + .publication(&request.workspace_id, profile, cancellation) + .ok() + .flatten(); + if head_matches && pointer.is_some_and(|pointer| pointer_matches(&pointer, &receipt)) { + Ok(receipt) + } else if submitted { + Err(unknown(&request.request_id)) + } else { + Err(rejected("managed_source_conflict")) + } +} + +fn saved_or_unknown( + runtime: &crate::effects::broker::ManagedEffectRuntime, + request: &ManagedPublishRequest, + proof: &str, + cancellation: &RunCancellation, +) -> Result { + if let Ok(Some(saved)) = + runtime + .repository + .publish_receipt(&request.workspace_id, &request.request_id, cancellation) + && saved.proof == proof + { + return Ok(saved.receipt); + } + Err(unknown(&request.request_id)) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use crate::CancellationReason; + use crate::RunCancellation; + use crate::effects::ManagedPublishRequest; + + fn request() -> ManagedPublishRequest { + ManagedPublishRequest { + workspace_id: format!("ws-{}", "ab".repeat(16)), + branch_id: format!("br-{}", "ab".repeat(16)), + expected_head: crate::effects::ManagedWorkspaceHead { + workspace_id: format!("ws-{}", "ab".repeat(16)), + branch_id: format!("br-{}", "ab".repeat(16)), + epoch: 1, + head_object_id: format!("obj-{}", "ab".repeat(16)), + head_version: 1, + store_id: format!("store-{}", "ab".repeat(16)), + schema_version: 1, + process_lineage_tainted: false, + }, + request_id: "00000000-0000-4000-8000-000000000001".into(), + } + } + + fn open(publish: bool) -> crate::AgentGatewayState { + let root = std::path::PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()) + .join(format!("publish-budget-{}", uuid::Uuid::new_v4())); + let workspace = root.join("workspace"); + std::fs::create_dir_all(&workspace).unwrap(); + let mut config = crate::AgentGatewayConfig { + bearer_token: Some("synthetic-maintenance-fixture".into()), + managed_workspace: Some( + crate::config::ManagedWorkspaceConfig::for_workspace(&workspace).unwrap(), + ), + ..Default::default() + }; + config + .managed_workspace + .as_mut() + .unwrap() + .maintenance_policy + .publish = publish; + crate::AgentGatewayState::with_sqlite_path(config, root.join("gateway.sqlite")).unwrap() + } + + #[tokio::test] + async fn disabled_publish_and_elapsed_budget_fail_closed() { + let denied = open(false) + .service() + .publish_managed_workspace_blocking( + request(), + &RunCancellation::with_timeout(Duration::from_secs(5)), + ) + .unwrap_err(); + assert_eq!(denied.to_string(), "managed_management_denied"); + let expired = RunCancellation::with_timeout(Duration::ZERO); + let deadline = open(true) + .service() + .publish_managed_workspace_blocking(request(), &expired) + .unwrap_err(); + assert_eq!(deadline.to_string(), "managed_deadline_elapsed"); + let cancelled = RunCancellation::with_timeout(Duration::from_secs(5)); + cancelled.request(CancellationReason::Requested); + let busy = open(true) + .service() + .publish_managed_workspace_blocking(request(), &cancelled) + .unwrap_err(); + assert_eq!(busy.to_string(), "managed_checkpoint_busy"); + } +} diff --git a/src/service/managed_review_tests.rs b/src/service/managed_review_tests.rs index 9be030f..38b56a2 100644 --- a/src/service/managed_review_tests.rs +++ b/src/service/managed_review_tests.rs @@ -108,6 +108,9 @@ async fn captured_a_survives_same_payload_b_activation_and_future_registry_chang let store = service.tool_store().unwrap(); let b = store .resolve(&crate::plugins::ResolveRequest { + harness_selection: crate::plugins::HarnessSelectionRequest::ToolOnly { + tool_package: crate::runtime::native_tools::EMBEDDING_PACKAGE_ID.into(), + }, roots: vec![crate::plugins::PackageRequirement { id: crate::runtime::native_tools::EMBEDDING_PACKAGE_ID.into(), version: "1.0.0".into(), @@ -468,6 +471,7 @@ async fn expired_original_lost_commit_ack_does_not_query_with_new_authority_and_ source_root: root.join("workspace"), quota: ResourceQuota::new(1000, 1048576, 16).unwrap(), retention: ManagedRetentionLimits::default(), + maintenance_policy: Default::default(), }), ..Default::default() }; diff --git a/src/service/managed_run_effect_tests.rs b/src/service/managed_run_effect_tests.rs new file mode 100644 index 0000000..39e18c8 --- /dev/null +++ b/src/service/managed_run_effect_tests.rs @@ -0,0 +1,485 @@ +//! Deterministic typed-producer fixtures; real Native HTTP production is covered separately. +use super::*; +use crate::effects::journal::*; +use http_body_util::{BodyExt, Full}; +use std::time::Duration; + +async fn get(state: AgentGatewayState, path: String) -> (u16, Value) { + let token = state.service().inner.config.bearer_token.clone().unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (shutdown, stopped) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + axum::serve( + listener, + crate::build_agent_gateway_app(state) + .into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(async move { + let _ = stopped.await; + }) + .await + }); + let result = tokio::spawn(async move { + let client: hyper_util::client::legacy::Client<_, Full> = + hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new()) + .build_http(); + let request = hyper::Request::get(format!("http://{address}{path}")) + .header(hyper::header::AUTHORIZATION, format!("Bearer {token}")) + .body(Full::new(bytes::Bytes::new())) + .unwrap(); + let response = tokio::time::timeout(Duration::from_secs(10), client.request(request)) + .await + .unwrap() + .unwrap(); + let status = response.status().as_u16(); + let body = response.into_body().collect().await.unwrap().to_bytes(); + (status, serde_json::from_slice(&body).unwrap()) + }) + .await; + let _ = shutdown.send(()); + tokio::time::timeout(Duration::from_secs(5), server) + .await + .unwrap() + .unwrap() + .unwrap(); + drop(std::net::TcpListener::bind(address).unwrap()); + result.unwrap_or_else(|error| std::panic::resume_unwind(error.into_panic())) +} +async fn admit(state: &AgentGatewayState) -> crate::AdmittedRun { + state + .service() + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"typed ordered fixture"}), + ..Default::default() + }, + None, + ) + .await + .unwrap() +} +fn binding( + state: &AgentGatewayState, + run: &crate::AdmittedRun, +) -> Arc { + state + .service() + .handle(&run.run_id) + .unwrap() + .pinned_tool + .as_ref() + .unwrap() + .managed + .clone() + .unwrap() +} +fn seed(state: &AgentGatewayState, run: &crate::AdmittedRun, index: usize) -> ManagedEffectSummary { + seed_identity(state, run, index, |_| {}) +} +fn seed_identity( + state: &AgentGatewayState, + run: &crate::AdmittedRun, + index: usize, + change: impl FnOnce(&mut ManagedCallIdentity), +) -> ManagedEffectSummary { + let host = state + .service() + .capability_host_bridges(&run.run_id) + .unwrap() + .unwrap(); + let deadline = host.lifecycle.as_ref().unwrap().original_deadline_ms(); + let binding = binding(state, run); + let args = r#"{"path":"never-executed.txt"}"#; + let call = binding + .register( + &format!("ordered-{index}"), + "read_file", + args, + binding + .seal_call_policy("read_file", crate::capabilities::CapabilityRisk::Read, args) + .unwrap(), + deadline, + ) + .unwrap(); + let mut record = call.record(); + record.effect_id = format!("effect-{index:032x}"); + change(&mut record.identity); + let event = |suffix: &str, seq: usize| json!({"event_id":format!("ordered-{index}-{suffix}"),"event_type":format!("tool.{suffix}"),"seq":seq,"payload_json":"{}","created_at_ms":crate::domain::timestamp()}); + let projections = json!({"normal":{"output":event("requested",100+index*2),"terminal":event("started",101+index*2)}}); + binding.runtime.repository.command_with_cancellation("effect.begin",&json!({"effect_id":record.effect_id,"run_id":run.run_id,"call_id":record.identity.call_id,"workspace_id":record.identity.workspace.workspace_id,"branch_id":record.identity.workspace.branch_id,"record_json":serde_json::to_string(&record).unwrap(),"commit_id":call.receipt_id,"sealed_class":call.policy.class().as_str(),"projections_json":projections.to_string()}),&binding.cancel).unwrap(); + call.interrupt_unpublished(); + let stored = state + .persistence() + .unwrap() + .managed_effect_by_id(&record.effect_id) + .unwrap() + .unwrap(); + assert_eq!(stored.effect_id, record.effect_id); + ManagedEffectSummary { + effect_id: record.effect_id, + run_id: run.run_id.clone(), + call_id: record.identity.call_id, + state: ManagedPublicationStatus::Indeterminate, + workspace_id: record.identity.workspace.workspace_id, + branch_id: record.identity.workspace.branch_id, + epoch: record.identity.epoch, + prepared_at_ms: None, + decided_at_ms: None, + } +} +#[tokio::test(flavor = "multi_thread")] +async fn journal_owner_run_identity_is_scoped_before_page_limit() { + let (_root, state) = authenticated_fixture(); + let run = admit(&state).await; + let foreign = admit(&state).await; + seed_identity(&state, &run, 0, |identity| { + identity.owner.run_id = foreign.run_id.clone() + }); + let expected = seed(&state, &run, 1); + let result = get( + state.clone(), + format!("/v1/runs/{}/effects?limit=1", run.run_id), + ) + .await; + state.service().shutdown().await.unwrap(); + assert_eq!( + result, + ( + 200, + serde_json::to_value(ManagedEffectPage { + entries: vec![expected], + next_cursor: None, + has_more: false + }) + .unwrap() + ), + "column run ID cannot substitute for the original journal owner" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn foreign_and_missing_cursors_are_denied_even_for_empty_runs() { + let (_root, state) = authenticated_fixture(); + let target = admit(&state).await; + let foreign = admit(&state).await; + let empty = admit(&state).await; + let own = seed(&state, &target, 1); + let other = seed(&state, &foreign, 2); + let mut results = Vec::new(); + for run in [&target.run_id, &empty.run_id] { + for cursor in [&other.effect_id, "effect-ffffffffffffffffffffffffffffffff"] { + results.push( + get( + state.clone(), + format!("/v1/runs/{run}/effects?after_effect_id={cursor}"), + ) + .await, + ); + } + } + let empty_page = get(state.clone(), format!("/v1/runs/{}/effects", empty.run_id)).await; + let end = get( + state.clone(), + format!( + "/v1/runs/{}/effects?after_effect_id={}", + target.run_id, own.effect_id + ), + ) + .await; + state.service().shutdown().await.unwrap(); + for result in results { + assert_eq!( + result, + ( + 403, + json!({"error":{"code":"managed_effects_unavailable","message":"managed_effects_unavailable"}}) + ), + "cursor existence must not bypass exact run scope" + ); + } + for page in [empty_page, end] { + assert_eq!( + page, + ( + 200, + json!({"entries":[],"next_cursor":null,"has_more":false}) + ) + ); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn native_source_is_revalidated_after_page_read_without_existence_disclosure() { + let (root, state) = authenticated_fixture(); + let run = admit(&state).await; + seed(&state, &run, 1); + let source = root.join("workspace"); + let moved = root.join("retired-source"); + let p = state.persistence().unwrap(); + *p.managed_request_hook.lock().unwrap() = Some(Arc::new(move |op, _, enqueued| { + if op == "effect.run_list" && !enqueued { + fs::rename(&source, &moved).unwrap(); + fs::create_dir(&source).unwrap(); + } + })); + let result = get(state.clone(), format!("/v1/runs/{}/effects", run.run_id)).await; + *p.managed_request_hook.lock().unwrap() = None; + state.service().shutdown().await.unwrap(); + assert_eq!( + result, + ( + 403, + json!({"error":{"code":"managed_effects_unavailable","message":"managed_effects_unavailable"}}) + ), + "replaced source cannot use a previously acquired query authority" + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn cancelled_run_effect_query_returns_static_timeout() { + let (_root, state) = authenticated_fixture(); + let run = admit(&state).await; + let p = state.persistence().unwrap(); + *p.managed_request_hook.lock().unwrap() = Some(Arc::new(move |op, cancel, enqueued| { + if op == "effect.run_list" && !enqueued { + cancel.request(crate::CancellationReason::Requested); + } + })); + let result = get(state.clone(), format!("/v1/runs/{}/effects", run.run_id)).await; + *p.managed_request_hook.lock().unwrap() = None; + state.service().shutdown().await.unwrap(); + assert_eq!( + result, + ( + 408, + json!({"error":{"code":"managed_query_cancelled","message":"managed_query_cancelled"}}) + ) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn formal_wrong_profile_and_binding_scopes_share_static_denial() { + let (root, state) = authenticated_fixture(); + let capture = Arc::new(std::sync::Mutex::new(Value::Null)); + let observed = capture.clone(); + state + .persistence() + .unwrap() + .inject_admission_precommit_hook(move |payload| { + *observed.lock().unwrap() = payload.clone(); + }); + let run = admit(&state).await; + let original = capture.lock().unwrap().clone(); + let p = state.persistence().unwrap(); + let mut results = Vec::new(); + for case in ["profile", "session", "workspace", "branch"] { + let mut payload = original.clone(); + let id = uuid::Uuid::new_v4().to_string(); + let session = uuid::Uuid::new_v4().to_string(); + for (key, value) in [ + ("run_id", id.as_str()), + ("session_id", session.as_str()), + ("message_id", id.as_str()), + ("message_run_id", id.as_str()), + ("event_id", id.as_str()), + ("tool_graph_id", ""), + ("tool_ref_id", ""), + ("idempotency_key", ""), + ] { + payload[key] = json!(value); + } + payload["session_new"] = json!(1); + payload["account_id"] = json!(id); + let mut foreign = binding(&state, &run).workspace.clone(); + foreign.identity.workspace_id = format!("ws-{}", uuid::Uuid::new_v4().simple()); + foreign.identity.branch_id = format!("br-{}", uuid::Uuid::new_v4().simple()); + if case == "profile" { + foreign.profile = "foreign-profile".into(); + } + let path = root.join(format!("foreign-{case}")); + fs::create_dir(&path).unwrap(); + use std::os::unix::fs::MetadataExt; + let metadata = fs::metadata(path).unwrap(); + foreign.identity.source_identity = DirectoryIdentity { + device_decimal: metadata.dev().to_string(), + inode_decimal: metadata.ino().to_string(), + }; + p.managed_effect_command("effect.workspace_register",&json!({"workspace_json":serde_json::to_string(&foreign).unwrap(),"store_id":foreign.identity.store_id,"profile":foreign.profile,"source_device":foreign.identity.source_identity.device_decimal,"source_inode":foreign.identity.source_identity.inode_decimal})).unwrap(); + let mut binding: Value = + serde_json::from_str(payload["managed_binding_json"].as_str().unwrap()).unwrap(); + binding["session_id"] = json!(session); + match case { + "profile" => { + payload["profile"] = json!("foreign-profile"); + binding["workspace_id"] = json!(foreign.identity.workspace_id); + binding["branch_id"] = json!(foreign.identity.branch_id); + } + "session" => binding["session_id"] = json!(run.session_id), + "workspace" | "branch" => { + binding["workspace_id"] = json!(foreign.identity.workspace_id); + binding["branch_id"] = json!(foreign.identity.branch_id); + } + _ => unreachable!(), + } + payload["managed_binding_json"] = json!(binding.to_string()); + p.admission_create(&payload).unwrap(); + assert!( + !p.run_get(&id).unwrap()["rows"] + .as_array() + .unwrap() + .is_empty() + ); + results.push(get(state.clone(), format!("/v1/runs/{id}/effects")).await); + } + state.service().shutdown().await.unwrap(); + for result in results { + assert_eq!( + result, + ( + 403, + json!({"error":{"code":"managed_effects_unavailable","message":"managed_effects_unavailable"}}) + ) + ); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn storage_failure_run_effect_query_uses_only_static_fields() { + let (_root, state) = authenticated_fixture(); + let run = admit(&state).await; + state.persistence().unwrap().inject_persist_failure(); + let result = get(state.clone(), format!("/v1/runs/{}/effects", run.run_id)).await; + state.service().shutdown().await.unwrap(); + assert_eq!( + result, + ( + 503, + json!({"error":{"code":"managed_storage_unavailable","message":"managed_storage_unavailable"}}) + ) + ); +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn terminal_retired_source_and_restored_run_remain_read_only_historical_queries() { + let (_root, state, source) = super::protocol::maintenance_source().await; + let service = state.service(); + let p = state.persistence().unwrap(); + // Deterministic nonzero usage through the existing formal typed fixture seam. + p.record_synthetic_usage_for_test(&source.source_run_id) + .unwrap(); + let request = super::protocol::restore_request(&state, &source).await; + let old = get( + state.clone(), + format!("/v1/runs/{}/effects", source.source_run_id), + ) + .await; + let restored = service + .restore_managed_checkpoint_and_admit(request.clone()) + .await + .unwrap(); + let snapshot = || { + json!({ + "activation":p.plugin_control_command("plugin.activation_get",&json!({})).unwrap(), + "activation_history":p.plugin_control_command("plugin.activation_history",&json!({})).unwrap(), + "source":p.run_get(&source.source_run_id).unwrap(), + "restored":p.run_get(&restored.new_run_id).unwrap(), + "messages":p.message_list(&restored.new_session_id,0).unwrap(), + "source_events":service.run_events(&source.source_run_id), + "head":p.managed_workspace_head(&ManagedWorkspaceIdentity {store_id:request.expected_source.store_id.clone(),workspace_id:source.workspace_id.clone(),branch_id:source.source_branch_id.clone(),source_identity:DirectoryIdentity {device_decimal:"unused-for-head".into(),inode_decimal:"unused-for-head".into()}}).unwrap(), + "clock":service.run_context(&source.source_run_id).unwrap().metadata["deadline_at_ms"], + "source_context":service.run_context(&source.source_run_id).unwrap(), + "closed_inventory":service.managed_runtime_for_test().unwrap().repository.checkpoint_context(&source,&crate::RunCancellation::with_timeout(Duration::from_secs(5))).unwrap(), + }) + }; + let before = snapshot(); + assert!( + before["closed_inventory"]["external"] + .as_array() + .unwrap() + .iter() + .any(|row| row["provider_request_count"].as_u64().unwrap() > 0), + "formal nonzero provider usage fixture" + ); + let historical = get( + state.clone(), + format!("/v1/runs/{}/effects", source.source_run_id), + ) + .await; + let fresh = get( + state.clone(), + format!("/v1/runs/{}/effects", restored.new_run_id), + ) + .await; + let after = snapshot(); + service.shutdown().await.unwrap(); + assert_eq!(old.0, 200); + assert_eq!( + historical, old, + "retired source stays queryable under fresh Native authorization" + ); + assert_eq!( + fresh, + ( + 200, + json!({"entries":[],"next_cursor":null,"has_more":false}) + ) + ); + assert_eq!( + before, after, + "queries cannot revive execution, change activation/head or erase retained history" + ); +} + +fn authenticated_fixture() -> (PathBuf, AgentGatewayState) { + fixture_config(|config| { + config.bearer_token = Some(uuid::Uuid::new_v4().to_string()); + config.max_concurrent_runs = 8; + }) +} +#[tokio::test(flavor = "multi_thread")] +async fn ordered_interleaved_run_pages_filter_before_limit_and_has_more() { + let (_root, state) = authenticated_fixture(); + let target = admit(&state).await; + let foreign = admit(&state).await; + seed(&state, &foreign, 0); + let first = seed(&state, &target, 1); + seed(&state, &foreign, 2); + let last = seed(&state, &target, 3); + seed(&state, &foreign, 4); + let a = get( + state.clone(), + format!("/v1/runs/{}/effects?limit=1", target.run_id), + ) + .await; + let b = get( + state.clone(), + format!( + "/v1/runs/{}/effects?limit=1&after_effect_id={}", + target.run_id, first.effect_id + ), + ) + .await; + state.service().shutdown().await.unwrap(); + assert_eq!(a.0, 200); + assert_eq!(b.0, 200); + assert_eq!( + a.1, + serde_json::to_value(ManagedEffectPage { + entries: vec![first.clone()], + next_cursor: Some(first.effect_id), + has_more: true + }) + .unwrap() + ); + assert_eq!( + b.1, + serde_json::to_value(ManagedEffectPage { + entries: vec![last], + next_cursor: None, + has_more: false + }) + .unwrap() + ); +} diff --git a/src/service/plugin_admin.rs b/src/service/plugin_admin.rs new file mode 100644 index 0000000..5e70610 --- /dev/null +++ b/src/service/plugin_admin.rs @@ -0,0 +1,662 @@ +//! Host-bound plugin graph control on the existing Native store. +//! Signatures, graph ids, and request JSON do not grant authority. + +use std::path::{Component, Path, PathBuf}; + +use serde::Deserialize; +use serde_json::{Value, json}; + +use super::AgentService; +use crate::plugins::{ + HOST_RUNTIME, HarnessSelection, HarnessSelectionRequest, InstallAuthorization, + PackageArtifactRef, PackageRequirement, PackageTrust, PluginControlStore, PluginErrorKind, + ResolveRequest, +}; +use crate::runtime::rss_runner::RunCancellation; + +pub(crate) struct PluginControlBinding { + authorization: InstallAuthorization, + source_root: PathBuf, + deployment_space: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct InstallBody { + package: String, + deployment_space: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ResolveBody { + roots: Vec, + tool_package: String, + deployment_space: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RootBody { + id: String, + version: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ActivateBody { + graph_id: String, + deployment_space: String, + expected_generation: u64, +} + +impl AgentService { + pub fn bind_plugin_control( + &self, + authorization: InstallAuthorization, + source_root: PathBuf, + deployment_space: String, + ) -> Result<(), String> { + if !authorization.admin { + return Err("plugin control requires a host admin policy".into()); + } + if !deployment_space_ok(&deployment_space) { + return Err("plugin deployment space is invalid".into()); + } + if !source_root.is_absolute() { + return Err("plugin source root must be absolute".into()); + } + let meta = std::fs::symlink_metadata(&source_root) + .map_err(|_| "plugin source root is unavailable".to_string())?; + if meta.file_type().is_symlink() || !meta.is_dir() { + return Err("plugin source root is unavailable".into()); + } + if self.inner.persistence.is_none() { + return Err("plugin control requires the native package store".into()); + } + let mut slot = lock_mut(&self.inner.plugin_control); + *slot = Some(PluginControlBinding { + authorization: authorization.clone(), + source_root, + deployment_space, + }); + let Some(store) = self.tool_store() else { + *slot = None; + return Err("plugin control requires the native package store".into()); + }; + // Same trusted authorization the host just published. Request JSON, + // signatures, and graph ids are not consulted. The control lock is + // still held, so a concurrent route cannot observe the binding before + // read/verify uses this policy. A missing store rolls the binding back + // and leaves the previous policy untouched. + store.bind_policy(&authorization); + Ok(()) + } + + pub(super) fn published_install_authorization(&self) -> Option { + lock_ref(&self.inner.plugin_control) + .as_ref() + .map(|binding| binding.authorization.clone()) + } + + /// Copies the published host authorization onto the live package store. + /// Returns whether a binding was applied. `bind_policy` drops verified + /// caches, so a true result means the caller must re-prepare code it still + /// needs. No binding leaves the store policy unchanged. + pub(super) fn sync_published_package_policy(&self) -> bool { + let Some(authorization) = self.published_install_authorization() else { + return false; + }; + let Some(store) = self.tool_store() else { + return false; + }; + store.bind_policy(&authorization); + true + } + + pub(crate) fn plugin_control_configured(&self) -> bool { + lock_ref(&self.inner.plugin_control).is_some() + } + + pub(crate) fn install_controlled_plugin(&self, body: &Value) -> Result { + let binding = self.host_binding()?; + let request: InstallBody = parse_body(body)?; + self.require_space(&binding, &request.deployment_space)?; + let package = controlled_package(&binding.source_root, &request.package)?; + let store = self.tool_store().ok_or("plugin_artifact_unavailable")?; + let revision = store + .install(&package, &binding.authorization) + .map_err(map_plugin)?; + let installed = store + .read_installed(&revision.artifact_id) + .map_err(|_| "plugin_artifact_unavailable")?; + Ok(json!({ + "artifact_id": revision.artifact_id, + "package_id": installed.manifest.id, + "version": installed.manifest.version, + "trust": trust_label(revision.trust), + })) + } + + pub(crate) fn resolve_controlled_graph(&self, body: &Value) -> Result { + let binding = self.host_binding()?; + let request: ResolveBody = parse_body(body)?; + self.require_space(&binding, &request.deployment_space)?; + if request.roots.is_empty() || request.tool_package.is_empty() { + return Err("plugin_id_invalid"); + } + let store = self.tool_store().ok_or("plugin_artifact_unavailable")?; + let persistence = self + .inner + .persistence + .as_ref() + .ok_or("plugin_control_unconfigured")?; + let control = PluginControlStore::new(persistence); + let before = control.activation().map_err(map_control)?; + let lock = store + .resolve(&ResolveRequest { + roots: request + .roots + .iter() + .map(|root| PackageRequirement { + id: root.id.clone(), + version: root.version.clone(), + }) + .collect(), + host_runtime: HOST_RUNTIME.into(), + harness_selection: HarnessSelectionRequest::ToolOnly { + tool_package: request.tool_package, + }, + }) + .map_err(map_plugin)?; + let receipt = control.store_lock(&lock).map_err(map_control)?; + let loaded = control.load_lock(&receipt.graph_id).map_err(map_control)?; + let after = control.activation().map_err(map_control)?; + if after != before { + return Err("plugin_activation_conflict"); + } + let diff = if before.graph_id.is_empty() { + None + } else { + Some( + control + .permission_diff(&before.graph_id, &loaded.lock.graph_id) + .map_err(map_control)?, + ) + }; + Ok(json!({ + "graph_id": loaded.lock.graph_id, + "activated": false, + "generation": after.generation, + "permission_diff": diff.map(|diff| json!({ + "requires_reapproval": diff.requires_reapproval, + "change_count": diff.changes.len(), + "edges_changed": diff.metadata.edges_changed, + "entries_changed": diff.metadata.entries_changed, + "host_contract_changed": diff.metadata.host_contract_changed, + })), + })) + } + + pub(crate) fn prepare_controlled_graph(&self, graph_id: &str) -> Result { + let _binding = self.host_binding()?; + let lock = self.executable_lock(graph_id)?; + let store = self.tool_store().ok_or("plugin_artifact_unavailable")?; + self.forget_prepared(graph_id); + let prepared = store.prepare(&lock).map_err(|_| "plugin_prepare_failed")?; + let tool_package = tool_package(&lock)?; + if prepared.program(tool_package).is_none() { + return Err("plugin_prepare_failed"); + } + lock_mut(&self.inner.prepared_plugin_graphs).insert(graph_id.to_string(), lock.clone()); + Ok(json!({ + "graph_id": lock.graph_id, + "executable": true, + "status": "prepared", + })) + } + + pub(crate) fn plugin_graph_view(&self, graph_id: &str) -> Result { + let _binding = self.host_binding()?; + validate_public_id(graph_id)?; + let persistence = self + .inner + .persistence + .as_ref() + .ok_or("plugin_control_unconfigured")?; + let loaded = PluginControlStore::new(persistence) + .load_lock(graph_id) + .map_err(map_control)?; + if loaded.lock.graph_id != graph_id { + return Err("plugin_graph_missing"); + } + Ok(public_graph(&loaded.lock)) + } + + pub(crate) fn activate_controlled_graph(&self, body: &Value) -> Result { + let binding = self.host_binding()?; + let request: ActivateBody = parse_body(body)?; + self.require_space(&binding, &request.deployment_space)?; + validate_public_id(&request.graph_id)?; + let lock = self.prepared_lock(&request.graph_id)?; + self.ensure_current(&lock)?; + let persistence = self + .inner + .persistence + .as_ref() + .ok_or("plugin_control_unconfigured")?; + let control = PluginControlStore::new(persistence); + let receipt = control + .activate( + &lock.graph_id, + request.expected_generation, + &artifact_refs(&lock), + ) + .map_err(map_control)?; + Ok(activation_body(&binding.deployment_space, &receipt)) + } + + pub(crate) fn rollback_controlled_activation( + &self, + activation_id: &str, + ) -> Result { + let binding = self.host_binding()?; + if activation_id.len() > 128 || activation_id.contains('/') || activation_id.contains('\\') + { + return Err("plugin_id_invalid"); + } + let generation = parse_activation_id(&binding.deployment_space, activation_id)?; + let persistence = self + .inner + .persistence + .as_ref() + .ok_or("plugin_control_unconfigured")?; + let control = PluginControlStore::new(persistence); + let history = control.activation_history().map_err(map_control)?; + let entry = history + .iter() + .find(|entry| entry.generation == generation) + .ok_or("plugin_activation_missing")?; + let lock = self.prepared_lock(&entry.graph_id)?; + self.ensure_current(&lock)?; + let current = control.activation().map_err(map_control)?; + let receipt = control + .activate(&lock.graph_id, current.generation, &artifact_refs(&lock)) + .map_err(map_control)?; + if receipt.generation <= current.generation { + return Err("plugin_activation_conflict"); + } + Ok(activation_body(&binding.deployment_space, &receipt)) + } + + pub(crate) fn captured_run_projection(&self, run_id: &str) -> Value { + let mut graph_id = Value::Null; + let mut generation = Value::Null; + let mut branch = Value::Null; + let mut epoch = Value::Null; + let mut head_object_id = Value::Null; + let mut head_version = Value::Null; + let mut publication = json!("unsupported"); + if let Some(persistence) = &self.inner.persistence { + if let Ok(pin) = PluginControlStore::new(persistence).pin_for_run(run_id) + && pin.get("pinned") == Some(&Value::Bool(true)) + { + if let Some(id) = pin.get("graph_id").and_then(Value::as_str) + && !id.is_empty() + { + graph_id = json!(id); + } + if let Some(value) = pin.get("generation").and_then(Value::as_u64) { + generation = json!(value); + } + } + if self.inner.config.managed_workspace.is_some() { + publication = Value::Null; + let cancel = RunCancellation::with_deadline( + std::time::Instant::now() + std::time::Duration::from_secs(5), + ); + if let Ok(Some(saved)) = persistence.managed_run_query_binding(run_id, &cancel) { + if !saved.workspace.branch_id.is_empty() { + branch = json!(saved.workspace.branch_id); + } + epoch = json!(saved.epoch); + if let Ok(head) = persistence + .managed_workspace_head_with_cancellation(&saved.workspace, &cancel) + && head.branch_id == saved.workspace.branch_id + && head.workspace_id == saved.workspace.workspace_id + && head.store_id == saved.workspace.store_id + { + head_object_id = json!(head.head_object_id); + head_version = json!(head.head_version); + } + } + } + } + json!({ + "graph_id": graph_id, + "activation_generation": generation, + "workspace_branch": branch, + "epoch": epoch, + "head_object_id": head_object_id, + "head_version": head_version, + "publication": publication, + }) + } + + fn host_binding(&self) -> Result { + lock_ref(&self.inner.plugin_control) + .clone() + .ok_or("plugin_control_unconfigured") + } + + fn require_space( + &self, + binding: &PluginControlBinding, + requested: &str, + ) -> Result<(), &'static str> { + if requested == binding.deployment_space { + Ok(()) + } else { + Err("plugin_space_mismatch") + } + } + + fn executable_lock(&self, graph_id: &str) -> Result { + validate_public_id(graph_id)?; + let persistence = self + .inner + .persistence + .as_ref() + .ok_or("plugin_control_unconfigured")?; + let control = PluginControlStore::new(persistence); + if control + .graph_revocation(graph_id) + .map_err(map_control)? + .is_some() + { + return Err("plugin_graph_revoked"); + } + let loaded = control.load_lock(graph_id).map_err(map_control)?; + if loaded.lock.graph_id != graph_id { + return Err("plugin_graph_missing"); + } + self.reject_stateful(&loaded.lock)?; + self.require_artifacts(&loaded.lock)?; + Ok(loaded.lock) + } + + fn prepared_lock(&self, graph_id: &str) -> Result { + let lock = self.executable_lock(graph_id)?; + let prepared = lock_ref(&self.inner.prepared_plugin_graphs) + .get(graph_id) + .cloned(); + match prepared { + Some(saved) if saved == lock => Ok(lock), + _ => Err("plugin_graph_not_prepared"), + } + } + + fn ensure_current(&self, lock: &crate::plugins::GraphLock) -> Result<(), &'static str> { + let store = self.tool_store().ok_or("plugin_artifact_unavailable")?; + self.reject_stateful(lock)?; + self.require_artifacts(lock)?; + let prepared = store.prepare(lock).map_err(|_| "plugin_prepare_failed")?; + if prepared.program(tool_package(lock)?).is_none() { + return Err("plugin_prepare_failed"); + } + Ok(()) + } + + fn reject_stateful(&self, lock: &crate::plugins::GraphLock) -> Result<(), &'static str> { + let store = self.tool_store().ok_or("plugin_artifact_unavailable")?; + for package in &lock.packages { + let installed = store + .read_installed(&package.artifact_id) + .map_err(|_| "plugin_artifact_unavailable")?; + if installed.manifest.state.is_some() { + return Err("plugin_state_unsupported"); + } + } + Ok(()) + } + + fn require_artifacts(&self, lock: &crate::plugins::GraphLock) -> Result<(), &'static str> { + let store = self.tool_store().ok_or("plugin_artifact_unavailable")?; + for package in &lock.packages { + store + .read_installed(&package.artifact_id) + .map_err(|_| "plugin_artifact_unavailable")?; + } + Ok(()) + } + + fn forget_prepared(&self, graph_id: &str) { + lock_mut(&self.inner.prepared_plugin_graphs).remove(graph_id); + } +} + +impl Clone for PluginControlBinding { + fn clone(&self) -> Self { + Self { + authorization: InstallAuthorization { + admin: self.authorization.admin, + accepted_publishers: self.authorization.accepted_publishers.clone(), + unsigned_dev_space: self.authorization.unsigned_dev_space.clone(), + }, + source_root: self.source_root.clone(), + deployment_space: self.deployment_space.clone(), + } + } +} + +fn activation_body(space: &str, receipt: &crate::plugins::ActivationPointer) -> Value { + json!({ + "graph_id": receipt.graph_id, + "generation": receipt.generation, + "activation_id": format!("{space}.{}", receipt.generation), + }) +} + +fn public_graph(lock: &crate::plugins::GraphLock) -> Value { + let nodes: Vec = lock + .packages + .iter() + .map(|package| { + json!({ + "id": package.id, + "version": package.version, + "kind": package.kind, + }) + }) + .collect(); + let edges: Vec = lock + .edges + .iter() + .map(|edge| { + json!({ + "from": edge.from, + "to": edge.to, + "relation": edge.relation, + }) + }) + .collect(); + let permissions: Vec = lock + .closure + .iter() + .map(|effect| { + json!({ + "package": effect.package, + "kinds": effect.effects.iter().map(|item| item.kind.clone()).collect::>(), + }) + }) + .collect(); + let contract = match &lock.harness_selection { + HarnessSelection::ToolOnly { tool_package } => json!({ + "mode": "tool_only", + "tool_package": tool_package, + }), + HarnessSelection::Unselected => json!({"mode": "unselected"}), + HarnessSelection::ContextPrompt { + tool_package, + policy, + } => json!({ + "mode": "context_prompt", + "tool_package": tool_package, + "policy_package": policy.package_id, + }), + }; + json!({ + "graph_id": lock.graph_id, + "nodes": nodes, + "edges": edges, + "contract": contract, + "permissions": permissions, + "errors": [], + }) +} + +fn artifact_refs(lock: &crate::plugins::GraphLock) -> Vec { + lock.packages + .iter() + .map(|package| PackageArtifactRef { + package_id: package.id.clone(), + version: package.version.clone(), + kind: package.kind.clone(), + artifact_id: package.artifact_id.clone(), + }) + .collect() +} + +fn tool_package(lock: &crate::plugins::GraphLock) -> Result<&str, &'static str> { + match &lock.harness_selection { + HarnessSelection::ToolOnly { tool_package } => Ok(tool_package.as_str()), + _ => Err("plugin_prepare_failed"), + } +} + +fn parse_body Deserialize<'de>>(body: &Value) -> Result { + serde_json::from_value(body.clone()).map_err(|_| "plugin_request_invalid") +} + +fn map_plugin(error: crate::plugins::PluginError) -> &'static str { + match error.kind { + PluginErrorKind::PathEscape | PluginErrorKind::SymlinkDenied => "plugin_source_rejected", + PluginErrorKind::PublisherUnauthorized => "plugin_publisher_unauthorized", + PluginErrorKind::MissingDependency => "plugin_artifact_unavailable", + PluginErrorKind::BudgetExceeded => "plugin_budget_exceeded", + _ => "plugin_prepare_failed", + } +} + +fn map_control(error: crate::plugins::PluginControlError) -> &'static str { + match error.code { + "plugin_activation_conflict" => "plugin_activation_conflict", + "plugin_graph_missing" => "plugin_graph_missing", + "plugin_graph_revoked" => "plugin_graph_revoked", + _ => "plugin_artifact_unavailable", + } +} + +fn trust_label(trust: PackageTrust) -> &'static str { + match trust { + PackageTrust::AcceptedPublisher => "accepted_publisher", + PackageTrust::DevUnsigned => "dev_unsigned", + PackageTrust::EmbeddingOwnedBuiltin => "embedding_builtin", + } +} + +fn deployment_space_ok(space: &str) -> bool { + let mut chars = space.chars(); + match chars.next() { + Some(first) if first.is_ascii_alphanumeric() => {} + _ => return false, + } + space.len() <= 64 && chars.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '.' | '_' | '-')) +} + +fn validate_public_id(id: &str) -> Result<(), &'static str> { + if id.is_empty() || id.len() > 128 || id.contains('/') || id.contains('\\') || id.contains('\0') + { + Err("plugin_id_invalid") + } else { + Ok(()) + } +} + +fn parse_activation_id(space: &str, label: &str) -> Result { + let Some(rest) = label.strip_prefix(space) else { + return Err("plugin_activation_missing"); + }; + let Some(rest) = rest.strip_prefix('.') else { + return Err("plugin_activation_missing"); + }; + if rest.is_empty() || rest.len() > 20 || (rest.starts_with('0') && rest != "0") { + return Err("plugin_id_invalid"); + } + if !rest.bytes().all(|byte| byte.is_ascii_digit()) { + return Err("plugin_activation_missing"); + } + rest.parse().map_err(|_| "plugin_id_invalid") +} + +fn controlled_package(root: &Path, relative: &str) -> Result { + if relative.is_empty() + || relative.len() > 128 + || relative.contains('\0') + || relative.contains('\\') + || relative.starts_with('/') + { + return Err("plugin_source_rejected"); + } + let relative_path = Path::new(relative); + if relative_path.is_absolute() { + return Err("plugin_source_rejected"); + } + let mut parts = Vec::new(); + for component in relative_path.components() { + match component { + Component::Normal(part) => { + let text = part.to_str().ok_or("plugin_source_rejected")?; + if text.starts_with('.') { + return Err("plugin_source_rejected"); + } + parts.push(part.to_os_string()); + } + _ => return Err("plugin_source_rejected"), + } + } + if parts.is_empty() { + return Err("plugin_source_rejected"); + } + let root_meta = std::fs::symlink_metadata(root).map_err(|_| "plugin_source_rejected")?; + if root_meta.file_type().is_symlink() { + return Err("plugin_source_rejected"); + } + let mut current = root.to_path_buf(); + for part in parts { + current.push(part); + let meta = std::fs::symlink_metadata(¤t).map_err(|_| "plugin_source_rejected")?; + if meta.file_type().is_symlink() { + return Err("plugin_source_rejected"); + } + } + if !current.is_dir() { + return Err("plugin_source_rejected"); + } + let root_abs = root.canonicalize().map_err(|_| "plugin_source_rejected")?; + let current_abs = current + .canonicalize() + .map_err(|_| "plugin_source_rejected")?; + if !current_abs.starts_with(&root_abs) { + return Err("plugin_source_rejected"); + } + Ok(current) +} + +fn lock_ref(mutex: &std::sync::Mutex) -> std::sync::MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(|error| error.into_inner()) +} + +fn lock_mut(mutex: &std::sync::Mutex) -> std::sync::MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(|error| error.into_inner()) +} diff --git a/tests/agent_loop_tests.rs b/tests/agent_loop_tests.rs index e103d29..c9e025e 100644 --- a/tests/agent_loop_tests.rs +++ b/tests/agent_loop_tests.rs @@ -2840,6 +2840,179 @@ fn compact_plan_full_compaction_documents_empty_tail_rule() { ); } +#[test] +fn compact_plan_native_user_results_match_only_actual_nonempty_call_parts() { + let runner = compact_runner(); + let cases = [ + ( + "canonical", + "user", + "", + json!([{"type":"tool_result","tool_call_id":"call-1","content":"result","is_error":false}]), + "call-1", + 6, + ), + ( + "different_top", + "user", + "different", + json!([{"type":"tool_result","tool_call_id":"call-1"}]), + "call-1", + 6, + ), + ( + "empty_part_matching_top", + "user", + "call-1", + json!([{"type":"tool_result","tool_call_id":""}]), + "call-1", + 4, + ), + ( + "multiple_parts", + "user", + "forged-top", + json!([{"type":"text","text":"text"},{"type":"tool_result","tool_call_id":"other","content":"other","is_error":false},{"type":"tool_result","tool_call_id":"call-1","content":"result","is_error":false}]), + "call-1", + 6, + ), + ( + "unmatched", + "user", + "call-1", + json!([{"type":"tool_result","tool_call_id":"other","content":"result","is_error":false}]), + "call-1", + 4, + ), + ( + "text_top_id", + "user", + "call-1", + json!([{"type":"text","text":"ordinary user text"}]), + "call-1", + 4, + ), + ("no_result", "user", "call-1", json!([]), "call-1", 4), + ( + "empty_ids", + "user", + "", + json!([{"type":"tool_result","tool_call_id":"","content":"result","is_error":false}]), + "", + 4, + ), + ( + "missing_id", + "user", + "call-1", + json!([{"type":"tool_result","content":"result","is_error":false}]), + "call-1", + 4, + ), + ( + "wrong_part", + "user", + "call-1", + json!([{"type":"tool_call","tool_call_id":"call-1","name":"read_file","arguments_json":"{}"}]), + "call-1", + 4, + ), + ( + "non_result_role", + "assistant", + "call-1", + json!([{"type":"tool_result","tool_call_id":"call-1","content":"result","is_error":false}]), + "call-1", + 4, + ), + ( + "tool_control", + "tool", + "call-1", + json!([{"type":"tool_result","tool_call_id":"call-1","content":"result","is_error":false}]), + "call-1", + 6, + ), + ]; + for (case, role, top_id, parts, call_id, end) in cases { + let plan = decide( + &runner, + compact_context( + json!([ + msg_user(1, "hello"), + msg_user(2, "more"), + msg_tool_call(3, call_id), + msg_user(4, "next"), + msg_user(5, "again"), + {"ordinal":6,"role":role,"tool_call_id":top_id,"content":parts} + ]), + 4, + 2, + ), + ); + assert_eq!(plan["kind"], "compact.plan", "{case}"); + assert_eq!(plan["source_end_ordinal"], end, "{case}"); + assert_eq!(plan["retained_tail_ordinal"], end, "{case}"); + assert_eq!( + plan["context_messages"].as_array().unwrap().len(), + if end == 6 { 1 } else { 3 }, + "{case}" + ); + } +} + +#[test] +fn compact_plan_native_user_absent_top_multiple_pairs_and_fixpoint() { + let runner = compact_runner(); + let result = |ordinal, id| json!({"ordinal":ordinal,"role":"user","content":[{"type":"tool_result","tool_call_id":id}]}); + for (case, messages, end) in [ + ( + "absent_top", + json!([ + msg_user(1, "a"), + msg_tool_call(2, "a"), + msg_user(3, "b"), + msg_user(4, "c"), + msg_user(5, "d"), + result(6, "a") + ]), + 6, + ), + ( + "multi_call", + json!([msg_user(1,"a"),{"ordinal":2,"role":"assistant","content":[{"type":"tool_call","tool_call_id":"a"},{"type":"tool_call","tool_call_id":"b"}]},msg_user(3,"c"),result(4,"a"),msg_user(5,"d"),result(6,"b")]), + 6, + ), + // Naive boundary=4: including result(a) at 6 introduces the call at 5, + // whose later result at 8 needs the original fixpoint's next pass. + ( + "new_prefix_call", + json!([ + msg_user(1, "a"), + msg_tool_call(2, "a"), + msg_user(3, "b"), + msg_user(4, "c"), + msg_tool_call(5, "b"), + result(6, "a"), + msg_user(7, "d"), + result(8, "b") + ]), + 8, + ), + ] { + let tail = messages.as_array().unwrap().len() as i64 - 4; + let plan = decide(&runner, compact_context(messages, 4, tail)); + assert_eq!(plan["kind"], "compact.plan", "{case}"); + assert_eq!(plan["source_end_ordinal"], end, "{case}"); + assert_eq!(plan["retained_tail_ordinal"], end, "{case}"); + assert_eq!( + plan["context_messages"].as_array().unwrap().len(), + 1, + "{case}" + ); + } +} + // --------------------------------------------------------------------------- // Post-review edge suites: compaction.start durable recovery contract // --------------------------------------------------------------------------- diff --git a/tests/coding_agent_edge_e2e_tests.rs b/tests/coding_agent_edge_e2e_tests.rs index 6054b2d..c9cd442 100644 --- a/tests/coding_agent_edge_e2e_tests.rs +++ b/tests/coding_agent_edge_e2e_tests.rs @@ -7,15 +7,19 @@ use std::fs; use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Condvar, Mutex}; use std::time::{Duration, Instant}; use rustscript_agent::config::{FileToolConfig, RunLimits}; +use rustscript_agent::effects::{ + ManagedCallReplay, ManagedEffectGetRequest, ManagedOwnerScope, ManagedRunEffectError, + ManagedRunEffectListRequest, +}; use rustscript_agent::{ AdmitRunRequest, AgentGatewayConfig, AgentGatewayState, AgentProviderHost, AgentService, - RunCancellation, ScriptedProvider, ToolCall, decode_message_blocks, + CancellationReason, RunCancellation, ScriptedProvider, ToolCall, decode_message_blocks, }; use serde_json::{Value as JsonValue, json}; use uuid::Uuid; @@ -1938,3 +1942,680 @@ fn docs_name_both_coding_e2e_commands() { ); } } + +const TOOL_EFFECT_NOTE: &str = "scope-note.txt"; +const TOOL_EFFECT_BYTES: &str = "tool-effect-scope-native-bytes"; +const TOOL_EFFECT_ADMIT_DEADLINE: Duration = Duration::from_secs(18); +const TOOL_EFFECT_PENDING_HORIZON: Duration = Duration::from_secs(12); +const TOOL_EFFECT_NATURAL_SLACK: Duration = Duration::from_millis(50); + +fn write_file_call(id: &str) -> ToolCall { + ToolCall { + id: id.to_string(), + name: "write_file".to_string(), + arguments: json!({ + "path": TOOL_EFFECT_NOTE, + "content": TOOL_EFFECT_BYTES + }), + } +} + +fn user_reviewer_config(deadline: Option) -> AgentGatewayConfig { + let mut config = AgentGatewayConfig::default(); + config.approvals.reviewer = Some("user".to_string()); + if let Some(deadline) = deadline { + config.run_timeout = deadline; + } + config +} + +fn note_path(workspace: &Path) -> PathBuf { + workspace.join(TOOL_EFFECT_NOTE) +} + +fn approval_key(run_id: &str, call_id: &str) -> String { + format!("guardian-approval:{run_id}:{call_id}") +} + +fn approval_row_state(state: &AgentGatewayState, approval_id: &str) -> Option { + let stored = state.persistence()?.approval_get(approval_id).ok()?; + stored + .pointer("/rows/0/7") + .and_then(JsonValue::as_str) + .map(str::to_string) +} + +fn observed_basis(service: &AgentService, run_id: &str) -> Option { + for event in service.run_events(run_id) { + for key in ["approval_basis", "basis", "approval_class"] { + if let Some(value) = event + .pointer(&format!("/data/{key}")) + .and_then(JsonValue::as_str) + { + return Some(value.to_string()); + } + } + } + None +} + +fn provider_profile(service: &AgentService, run_id: &str) -> String { + service + .run_context(run_id) + .and_then(|context| { + context + .metadata + .get("provider_profile") + .and_then(JsonValue::as_str) + .map(str::to_string) + }) + .filter(|profile| !profile.is_empty()) + .unwrap_or_else(|| "UNKNOWN".to_string()) +} + +fn replay_attempt( + service: &AgentService, + session_id: &str, + run_id: &str, + call_id: &str, +) -> Result { + service.replay_managed_call(ManagedEffectGetRequest { + owner: ManagedOwnerScope { + profile: provider_profile(service, run_id), + session_id: session_id.to_string(), + run_id: run_id.to_string(), + }, + call_id: call_id.to_string(), + }) +} + +fn assert_replay_does_not_revive(replay: &Result, note: &Path) { + match replay { + Ok(ManagedCallReplay::Final { .. } | ManagedCallReplay::Staged { .. }) => { + panic!( + "existing replay API must not return a successful exact replay of an unexecuted write: {replay:?}" + ); + } + Ok(ManagedCallReplay::Indeterminate { .. } | ManagedCallReplay::Unavailable { .. }) + | Err(_) => {} + } + assert!( + !note.exists(), + "replay must not create the native file: {}", + note.display() + ); +} + +fn assert_native_bytes(note: &Path) { + let bytes = fs::read(note) + .unwrap_or_else(|error| panic!("native readback missing at {}: {error}", note.display())); + assert_eq!( + bytes, + TOOL_EFFECT_BYTES.as_bytes(), + "native readback must equal the admitted write_file bytes" + ); +} + +fn assert_no_tool_effect(service: &AgentService, run_id: &str, note: &Path) { + assert_eq!( + tool_event_count(service, run_id), + 0, + "{:?}", + event_names(service, run_id) + ); + assert_eq!(service.process_owner_count(run_id), 0); + assert!( + !note.exists(), + "native file must stay absent: {}", + note.display() + ); +} + +fn assert_typed_run_query(service: &AgentService, run_id: &str) { + match service.query_managed_run_effects(ManagedRunEffectListRequest { + run_id: run_id.to_string(), + after_effect_id: None, + limit: 16, + }) { + Ok(page) => assert!( + page.entries.iter().all(|entry| entry.run_id == run_id), + "typed effect page must stay on the original run: {page:?}" + ), + Err(ManagedRunEffectError::Unavailable) => {} + Err(other) => panic!("typed run-effect query returned {other:?}"), + } +} + +#[derive(Clone)] +struct ReviewerSendObserver { + provider: ScriptedProvider, + entered: Arc, + snap: Arc>>, +} + +struct ReviewerTransportSnap { + deadline: Option, + requested: Option, + token_cancelled: bool, + deadline_passed: bool, + outcome_code: Option, +} + +impl ReviewerSendObserver { + fn new(provider: ScriptedProvider) -> Self { + Self { + provider, + entered: Arc::new(AtomicBool::new(false)), + snap: Arc::new(Mutex::new(None)), + } + } +} + +impl AgentProviderHost for ReviewerSendObserver { + fn call(&self, request: &JsonValue, cancellation: &RunCancellation) -> JsonValue { + if !is_guardian_provider_request(request) { + return self.provider.call(request, cancellation); + } + self.entered.store(true, Ordering::Release); + let outcome = self.provider.call(request, cancellation); + let outcome_code = outcome + .pointer("/error/code") + .and_then(JsonValue::as_str) + .map(str::to_string); + *self.snap.lock().expect("reviewer transport snap") = Some(ReviewerTransportSnap { + deadline: cancellation.deadline_instant(), + requested: cancellation.requested(), + token_cancelled: cancellation.token().is_cancelled(), + deadline_passed: cancellation.deadline_passed(), + outcome_code, + }); + outcome + } +} + +fn open_tool_effect_service( + config: AgentGatewayConfig, + provider: &ScriptedProvider, + fixture: &Fixture, +) -> AgentGatewayState { + let state = loop_service_sqlite(config, provider, &fixture.db_path()); + apply_workspace_limits(&state.service(), &fixture.workspace, 64 * 1024); + state +} + +#[tokio::test(flavor = "multi_thread")] +async fn tool_effect_scope_builtin_reviewer_native_write_readback() { + let mut fixture = Fixture::new("tool-effect-guardian-write"); + let note = note_path(&fixture.workspace); + assert!( + !note.exists(), + "positive control must not pre-seed the native file" + ); + let call = write_file_call("call-tool-effect-guardian-write"); + let provider = ScriptedProvider::new(); + provider.push_ok(tool_response( + "", + json!([{"id": call.id, "name": call.name, "arguments": call.arguments}]), + )); + provider.push_ok(guardian_allow_response()); + provider.push_ok(text_response("write-recorded")); + + let state = open_tool_effect_service(AgentGatewayConfig::default(), &provider, &fixture); + let service = state.service(); + let admitted = service + .admit(admit_request()) + .await + .expect("admission should succeed"); + tokio::time::timeout(WORKER_BUDGET, { + let service = service.clone(); + let run_id = admitted.run_id.clone(); + async move { + service.run_worker(run_id, "ignored".to_string()).await; + } + }) + .await + .expect("guardian write worker should finish"); + + let profile = provider_profile(&service, &admitted.run_id); + assert_eq!( + terminal_events(&service, &admitted.run_id), + vec!["run.completed".to_string()], + "profile={profile} events={:?}", + service.run_events(&admitted.run_id) + ); + let requests = provider.requests(); + assert_eq!(guardian_positions(&requests), vec![2]); + assert_guardian_request_binding( + &requests[1], + "edge-e2e", + "write_file", + "write", + &call.arguments, + &fixture.workspace, + ); + assert_eq!( + event_name_count(&service, &admitted.run_id, "tool.started"), + 1 + ); + assert_eq!( + event_name_count(&service, &admitted.run_id, "tool.completed"), + 1 + ); + assert_native_bytes(¬e); + assert_eq!( + approval_row_state(&state, &approval_key(&admitted.run_id, &call.id)).as_deref(), + Some("approved"), + "durable approval ledger must record the builtin reviewer allow" + ); + let basis = observed_basis(&service, &admitted.run_id); + assert_ne!( + basis.as_deref(), + Some("UserApproved"), + "public record must not be elevated above the actual class: {basis:?}" + ); + assert_typed_run_query(&service, &admitted.run_id); + let replay = replay_attempt(&service, &admitted.session_id, &admitted.run_id, &call.id); + assert!( + !matches!( + replay, + Ok(ManagedCallReplay::Final { .. } | ManagedCallReplay::Staged { .. }) + ), + "bundled path replay is fail-closed unless the existing API returns a legal exact replay: {replay:?}" + ); + assert_eq!(service.process_owner_count(&admitted.run_id), 0); + drop(state); + fixture.cleanup(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn tool_effect_scope_reviewer_send_owner_stop_cancels_transport() { + let mut fixture = Fixture::new("tool-effect-reviewer-stop"); + let note = note_path(&fixture.workspace); + let call = write_file_call("call-tool-effect-reviewer-stop"); + let provider = ScriptedProvider::new(); + provider.push_ok(tool_response( + "", + json!([{"id": call.id, "name": call.name, "arguments": call.arguments}]), + )); + provider.push_envelope(json!({"__hang": true})); + provider.push_ok(text_response("must-not-run")); + let observer = ReviewerSendObserver::new(provider.clone()); + let state = open_tool_effect_service(AgentGatewayConfig::default(), &provider, &fixture); + let service = state.service(); + service.inject_provider_host(Arc::new(observer.clone())); + let admitted = service + .admit(admit_request()) + .await + .expect("admission should succeed"); + let worker = tokio::spawn({ + let service = service.clone(); + let run_id = admitted.run_id.clone(); + async move { + service.run_worker(run_id, "ignored".to_string()).await; + } + }); + let entered = wait_until(WAIT_BUDGET, || { + observer.entered.load(Ordering::Acquire) + && provider.requests().iter().any(is_guardian_provider_request) + }) + .await; + assert!( + entered, + "builtin reviewer send must be waiting before the original owner stop: {:?}", + provider.requests() + ); + let handle = service + .handle(&admitted.run_id) + .expect("original owner handle"); + let root = handle.cancellation(); + let original_deadline = root.deadline_instant(); + assert!( + root.requested().is_none(), + "owner stop must be the first cancellation of the original root" + ); + assert_eq!( + service.stop(&admitted.run_id).as_deref(), + Some("stopping"), + "original owner stop must be accepted once" + ); + tokio::time::timeout(WORKER_BUDGET, worker) + .await + .expect("reviewer-send stop should finish within the fixed horizon") + .expect("worker join"); + + let snap = observer + .snap + .lock() + .expect("reviewer transport snap") + .as_ref() + .map(|snap| ReviewerTransportSnap { + deadline: snap.deadline, + requested: snap.requested, + token_cancelled: snap.token_cancelled, + deadline_passed: snap.deadline_passed, + outcome_code: snap.outcome_code.clone(), + }) + .expect("reviewer send must return the transport context it waited on"); + assert_eq!(snap.requested, Some(CancellationReason::Requested)); + assert_eq!(snap.outcome_code.as_deref(), Some("cancelled")); + assert_eq!(snap.deadline, original_deadline); + assert_eq!(root.requested(), Some(CancellationReason::Requested)); + assert_eq!(root.deadline_instant(), original_deadline); + assert!( + snap.token_cancelled, + "transport token must observe the owner stop; root pointer identity remains UNKNOWN because same_root is not public" + ); + assert!( + !snap.deadline_passed, + "owner stop must not be reported as deadline" + ); + assert_eq!( + provider.call_count(), + 2, + "no followup provider call after stop" + ); + assert_eq!(guardian_positions(&provider.requests()), vec![2]); + assert_eq!(business_requests(&provider.requests()).len(), 1); + assert_no_tool_effect(&service, &admitted.run_id, ¬e); + assert!( + event_names(&service, &admitted.run_id) + .iter() + .all(|name| !name.contains("attest")), + "public event log has no attest event; private attest count is UNKNOWN: {:?}", + event_names(&service, &admitted.run_id) + ); + assert_ne!( + approval_row_state(&state, &approval_key(&admitted.run_id, &call.id)).as_deref(), + Some("approved") + ); + let calls_before = provider.call_count(); + let replay = replay_attempt(&service, &admitted.session_id, &admitted.run_id, &call.id); + assert_replay_does_not_revive(&replay, ¬e); + tokio::time::timeout(WORKER_BUDGET, { + let service = service.clone(); + let run_id = admitted.run_id.clone(); + async move { + service.run_worker(run_id, "ignored".to_string()).await; + } + }) + .await + .expect("re-drive should return within the fixed horizon"); + assert_eq!(provider.call_count(), calls_before); + assert_no_tool_effect(&service, &admitted.run_id, ¬e); + drop(state); + fixture.cleanup(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn tool_effect_scope_user_pending_allow_native_write_readback() { + let mut fixture = Fixture::new("tool-effect-user-allow"); + let note = note_path(&fixture.workspace); + assert!( + !note.exists(), + "positive control must not pre-seed the native file" + ); + let call = write_file_call("call-tool-effect-user-allow"); + let provider = ScriptedProvider::new(); + provider.push_ok(tool_response( + "", + json!([{"id": call.id, "name": call.name, "arguments": call.arguments}]), + )); + provider.push_ok(text_response("user-write-recorded")); + let state = open_tool_effect_service(user_reviewer_config(None), &provider, &fixture); + let service = state.service(); + let admitted = service + .admit(admit_request()) + .await + .expect("admission should succeed"); + let worker = tokio::spawn({ + let service = service.clone(); + let run_id = admitted.run_id.clone(); + async move { + service.run_worker(run_id, "ignored".to_string()).await; + } + }); + let pending = wait_until(WAIT_BUDGET, || { + service + .pending_approvals(&admitted.run_id, Some(&admitted.session_id)) + .ok() + .is_some_and(|rows| !rows.is_empty()) + }) + .await; + assert!( + pending, + "user reviewer must open a pending approval before allow: {:?}", + event_names(&service, &admitted.run_id) + ); + let rows = service + .pending_approvals(&admitted.run_id, Some(&admitted.session_id)) + .expect("pending approvals"); + let approval_id = rows[0]["approval_id"] + .as_str() + .expect("approval id") + .to_string(); + assert_eq!(approval_id, approval_key(&admitted.run_id, &call.id)); + let allowed = service.resolve_pending_approval( + &admitted.run_id, + &approval_id, + "approved", + "http-bearer", + Some(&admitted.session_id), + ); + assert_eq!( + allowed, + Ok(()), + "public resolver used by the approval handler must accept the original owner allow" + ); + tokio::time::timeout(WORKER_BUDGET, worker) + .await + .expect("user allow worker should finish") + .expect("worker join"); + + let profile = provider_profile(&service, &admitted.run_id); + assert!( + guardian_positions(&provider.requests()).is_empty(), + "user reviewer path must not call the guardian provider: profile={profile} {:?}", + provider.requests() + ); + assert_eq!( + terminal_events(&service, &admitted.run_id), + vec!["run.completed".to_string()], + "profile={profile} events={:?}", + service.run_events(&admitted.run_id) + ); + assert_eq!( + event_name_count(&service, &admitted.run_id, "tool.completed"), + 1 + ); + assert_native_bytes(¬e); + assert_eq!( + approval_row_state(&state, &approval_id).as_deref(), + Some("approved") + ); + let basis = observed_basis(&service, &admitted.run_id); + assert_ne!(basis.as_deref(), Some("UserApproved"), "{basis:?}"); + assert_typed_run_query(&service, &admitted.run_id); + assert_eq!(service.process_owner_count(&admitted.run_id), 0); + drop(state); + fixture.cleanup(); +} + +async fn reject_late_allow_and_redrive( + service: &Arc, + state: &AgentGatewayState, + provider: &ScriptedProvider, + run_id: &str, + session_id: &str, + call_id: &str, + note: &Path, +) { + let approval_id = approval_key(run_id, call_id); + let late = service.resolve_pending_approval( + run_id, + &approval_id, + "approved", + "http-bearer", + Some(session_id), + ); + assert!( + matches!( + late, + Err("run_not_active" + | "approval_expired" + | "approval_not_pending" + | "approval_preview_unavailable") + ), + "same owner late allow must be rejected: {late:?}" + ); + assert_ne!( + approval_row_state(state, &approval_id).as_deref(), + Some("approved") + ); + let calls_before = provider.call_count(); + let tools_before = tool_event_count(service, run_id); + let replay = replay_attempt(service, session_id, run_id, call_id); + assert_replay_does_not_revive(&replay, note); + tokio::time::timeout(WORKER_BUDGET, { + let service = Arc::clone(service); + let run_id = run_id.to_string(); + async move { + service.run_worker(run_id, "ignored".to_string()).await; + } + }) + .await + .expect("re-drive should return within the fixed horizon"); + assert_eq!(provider.call_count(), calls_before); + assert_eq!(tool_event_count(service, run_id), tools_before); + assert_no_tool_effect(service, run_id, note); +} + +#[tokio::test(flavor = "multi_thread")] +async fn tool_effect_scope_user_pending_owner_stop_late_allow_rejected() { + let mut fixture = Fixture::new("tool-effect-user-stop"); + let note = note_path(&fixture.workspace); + let call = write_file_call("call-tool-effect-user-stop"); + let provider = ScriptedProvider::new(); + provider.push_ok(tool_response( + "", + json!([{"id": call.id, "name": call.name, "arguments": call.arguments}]), + )); + provider.push_ok(text_response("must-not-run")); + let state = open_tool_effect_service(user_reviewer_config(None), &provider, &fixture); + let service = state.service(); + let admitted = service + .admit(admit_request()) + .await + .expect("admission should succeed"); + let worker = tokio::spawn({ + let service = service.clone(); + let run_id = admitted.run_id.clone(); + async move { + service.run_worker(run_id, "ignored".to_string()).await; + } + }); + let pending = wait_until(WAIT_BUDGET, || { + service + .pending_approvals(&admitted.run_id, Some(&admitted.session_id)) + .ok() + .is_some_and(|rows| !rows.is_empty()) + }) + .await; + assert!(pending, "user pending must be visible before owner stop"); + assert_eq!(service.stop(&admitted.run_id).as_deref(), Some("stopping")); + tokio::time::timeout(WORKER_BUDGET, worker) + .await + .expect("owner stop should finish the pending wait") + .expect("worker join"); + assert_no_tool_effect(&service, &admitted.run_id, ¬e); + reject_late_allow_and_redrive( + &service, + &state, + &provider, + &admitted.run_id, + &admitted.session_id, + &call.id, + ¬e, + ) + .await; + drop(state); + fixture.cleanup(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn tool_effect_scope_user_pending_deadline_elapsed_late_allow_rejected() { + let mut fixture = Fixture::new("tool-effect-user-deadline"); + let note = note_path(&fixture.workspace); + let call = write_file_call("call-tool-effect-user-deadline"); + let provider = ScriptedProvider::new(); + provider.push_ok(tool_response( + "", + json!([{"id": call.id, "name": call.name, "arguments": call.arguments}]), + )); + provider.push_ok(text_response("must-not-run")); + let state = open_tool_effect_service( + user_reviewer_config(Some(TOOL_EFFECT_ADMIT_DEADLINE)), + &provider, + &fixture, + ); + let service = state.service(); + let admitted = service + .admit(admit_request()) + .await + .expect("admission should succeed"); + let worker = tokio::spawn({ + let service = service.clone(); + let run_id = admitted.run_id.clone(); + async move { + service.run_worker(run_id, "ignored".to_string()).await; + } + }); + let pending = wait_until(TOOL_EFFECT_PENDING_HORIZON, || { + service + .pending_approvals(&admitted.run_id, Some(&admitted.session_id)) + .ok() + .is_some_and(|rows| !rows.is_empty()) + }) + .await; + assert!( + pending, + "user pending must appear inside the fixed horizon before the original admit deadline: {:?}", + event_names(&service, &admitted.run_id) + ); + let handle = service + .handle(&admitted.run_id) + .expect("original owner handle"); + let root = handle.cancellation(); + let original = root + .deadline_instant() + .expect("admit-time deadline must be present on the original root"); + let remain = original.saturating_duration_since(Instant::now()); + assert!( + !remain.is_zero() && remain <= TOOL_EFFECT_ADMIT_DEADLINE, + "pending must be observed while the original deadline is still ahead" + ); + tokio::time::sleep(remain + TOOL_EFFECT_NATURAL_SLACK).await; + assert!( + root.deadline_passed(), + "original admit deadline must elapse without a manual Deadline request" + ); + assert_eq!(root.deadline_instant(), Some(original)); + assert_ne!(root.requested(), Some(CancellationReason::Requested)); + tokio::time::timeout(WORKER_BUDGET, worker) + .await + .expect("deadline wait should finish the worker") + .expect("worker join"); + assert_no_tool_effect(&service, &admitted.run_id, ¬e); + reject_late_allow_and_redrive( + &service, + &state, + &provider, + &admitted.run_id, + &admitted.session_id, + &call.id, + ¬e, + ) + .await; + assert_eq!(root.deadline_instant(), Some(original)); + drop(state); + fixture.cleanup(); +} diff --git a/tests/effect_admin_api_tests.rs b/tests/effect_admin_api_tests.rs new file mode 100644 index 0000000..7559b03 --- /dev/null +++ b/tests/effect_admin_api_tests.rs @@ -0,0 +1,438 @@ +//! Run effect query contracts through the production Axum app on real TCP. +use bytes::Bytes; +use http_body_util::{BodyExt, Full}; +use rustscript_agent::{AgentGatewayConfig, AgentGatewayState, build_agent_gateway_app}; +use rustscript_agent::{ + ScriptedProvider, + config::{ManagedRetentionLimits, ManagedWorkspaceConfig}, + effects::{ManagedEffectGetRequest, ManagedEffectPage, ManagedOwnerScope, ResourceQuota}, +}; +use serde_json::{Value, json}; +use std::{fs, path::PathBuf, sync::Arc}; +use std::{net::SocketAddr, time::Duration}; + +struct Fixture { + root: PathBuf, + home: PathBuf, + config: AgentGatewayConfig, + state: AgentGatewayState, + token: String, +} +impl Fixture { + fn new() -> Self { + let root = PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()) + .join(format!("q-{}", uuid::Uuid::new_v4().simple())); + let workspace = root.join("w"); + let home = root.join("h"); + fs::create_dir_all(&workspace).unwrap(); + fs::create_dir_all(&home).unwrap(); + use std::os::unix::fs::PermissionsExt; + for path in [&root, &workspace, &home] { + fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); + } + fs::write(workspace.join("seed.txt"), b"seed").unwrap(); + fs::write(home.join("config.yaml"),format!("version: 1\nmodel:\n provider: local-test\n model: synthetic\nproviders:\n local-test:\n protocol: codex-responses\n base_url: https://127.0.0.1:1\nworkspaces:\n allowed_roots:\n - '{}'\n default: '{}'\n",workspace.display(),workspace.display())).unwrap(); + let token = uuid::Uuid::new_v4().to_string(); + let mut config = AgentGatewayConfig { + bearer_token: Some(token.clone()), + managed_workspace: Some(ManagedWorkspaceConfig { + source_root: workspace, + quota: ResourceQuota::new(1000, 1048576, 16).unwrap(), + retention: ManagedRetentionLimits::default(), + maintenance_policy: Default::default(), + }), + ..Default::default() + }; + config.approvals.write = "allow".into(); + let state = AgentGatewayState::with_sqlite_path(config.clone(), root.join("g.sqlite")) + .unwrap() + .with_workspace_home(&home); + Self { + root, + home, + config, + state, + token, + } + } + async fn run(&self, http: &Http, calls: Vec) -> (String, String) { + let provider = Arc::new(ScriptedProvider::new()); + for call in calls { + provider.push_ok(json!({"text":"","tool_calls":[call],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2},"reasoning":"","stop_reason":"tool_calls"})); + } + provider.push_ok(json!({"text":"done","tool_calls":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2},"reasoning":"","stop_reason":"stop"})); + self.state.service().inject_provider_host(provider); + let (status, session) = http + .request("POST", "/api/sessions", json!({}), Some(&self.token)) + .await; + assert_eq!(status, 201); + let session = session["session"]["id"].as_str().unwrap().to_string(); + let (status, run) = http + .request( + "POST", + "/v1/runs", + json!({"session_id":session,"input":"perform fixture calls"}), + Some(&self.token), + ) + .await; + assert_eq!(status, 202, "HTTP run admission"); + let run = run["run_id"].as_str().unwrap().to_string(); + tokio::time::timeout(Duration::from_secs(30), async { + loop { + let events = self.state.service().run_events(&run); + if events.iter().any(|e| e["event"] == "run.completed") { + break; + } + assert!( + !events + .iter() + .any(|e| e["event"] == "run.failed" || e["event"] == "run.cancelled"), + "fixture run must complete" + ); + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("HTTP-created run terminal deadline"); + (session, run) + } +} + +struct Http { + address: SocketAddr, + shutdown: tokio::sync::oneshot::Sender<()>, + server: tokio::task::JoinHandle>, +} +impl Http { + async fn bind(state: AgentGatewayState) -> Self { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (shutdown, stopped) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + axum::serve( + listener, + build_agent_gateway_app(state).into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(async move { + let _ = stopped.await; + }) + .await + }); + Self { + address, + shutdown, + server, + } + } + async fn request( + &self, + method: &str, + path: &str, + body: Value, + token: Option<&str>, + ) -> (u16, Value) { + let client: hyper_util::client::legacy::Client<_, Full> = + hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new()) + .build_http(); + let mut request = hyper::Request::builder() + .method(method) + .uri(format!("http://{}{}", self.address, path)); + if let Some(token) = token { + request = request.header(hyper::header::AUTHORIZATION, format!("Bearer {token}")); + } + let request = request + .header(hyper::header::CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from(body.to_string()))) + .unwrap(); + let response = tokio::time::timeout(Duration::from_secs(15), client.request(request)) + .await + .unwrap() + .unwrap(); + let status = response.status().as_u16(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + ( + status, + serde_json::from_slice(&bytes).unwrap_or(Value::Null), + ) + } + async fn close(self) { + let _ = self.shutdown.send(()); + tokio::time::timeout(Duration::from_secs(5), self.server) + .await + .unwrap() + .unwrap() + .unwrap(); + drop(std::net::TcpListener::bind(self.address).expect("owned TCP released")); + } +} +#[tokio::test] +async fn run_effect_route_requires_configured_auth_before_query_parsing() { + let state = AgentGatewayState::new(AgentGatewayConfig::default()).unwrap(); + let http = Http::bind(state.clone()).await; + let result = http + .request( + "GET", + "/v1/runs/missing/effects?authority=untrusted", + json!(null), + None, + ) + .await; + http.close().await; + state.service().shutdown().await.unwrap(); + assert_eq!(result.0, 403, "managed route must fail closed"); + assert_eq!(result.1["error"]["code"], "managed_management_denied"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn malformed_run_path_is_a_static_typed_error() { + let fixture = Fixture::new(); + let http = Http::bind(fixture.state.clone()).await; + let result = http + .request( + "GET", + "/v1/runs/%FF/effects", + json!(null), + Some(&fixture.token), + ) + .await; + http.close().await; + fixture.state.service().shutdown().await.unwrap(); + assert_eq!( + result, + ( + 422, + json!({"error":{"code":"managed_request_invalid","message":"managed_request_invalid"}}) + ) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn strict_query_dto_and_transport_auth_are_static_over_real_http() { + let fixture = Fixture::new(); + let http = Http::bind(fixture.state.clone()).await; + let (_, run) = fixture.run(&http, vec![]).await; + let path = format!("/v1/runs/{run}/effects"); + let mut invalid = Vec::new(); + for query in [ + "limit=0", + "limit=129", + "limit=-1", + "limit=no", + "limit=18446744073709551616", + "limit=1&limit=2", + "after_effect_id=", + "after_effect_id=..%2Fprivate", + "after_effect_id=effect-0000000000000000000000000000000A", + "after_effect_id=effect-000000000000000000000000000000001", + ] { + invalid.push( + http.request( + "GET", + &format!("{path}?{query}"), + json!(null), + Some(&fixture.token), + ) + .await, + ); + } + for field in [ + "workspace_id", + "branch_id", + "profile", + "owner", + "authority", + "graph_id", + "workspace_path", + ] { + invalid.push( + http.request( + "GET", + &format!("{path}?{field}=PRIVATE_MARKER"), + json!(null), + Some(&fixture.token), + ) + .await, + ); + } + let missing = http.request("GET", &path, json!(null), None).await; + let wrong = http + .request("GET", &path, json!(null), Some("invalid-synthetic")) + .await; + let max = http + .request( + "GET", + &format!("{path}?limit=128"), + json!(null), + Some(&fixture.token), + ) + .await; + http.close().await; + fixture.state.service().shutdown().await.unwrap(); + for result in invalid { + assert_eq!( + result, + ( + 422, + json!({"error":{"code":"managed_request_invalid","message":"managed_request_invalid"}}) + ) + ); + } + for result in [missing, wrong] { + assert_eq!(result.0, 401); + assert_eq!(result.1["error"]["code"], "unauthorized"); + } + assert_eq!( + max, + ( + 200, + json!({"entries":[],"next_cursor":null,"has_more":false}) + ) + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn bearer_does_not_replace_native_authorization_or_disclose_missing_runs() { + let fixture = Fixture::new(); + let http = Http::bind(fixture.state.clone()).await; + let (session, run) = fixture.run(&http, vec![]).await; + let p = fixture.state.persistence().unwrap(); + p.run_create(&json!({"id":"formal-unmanaged","session_id":session,"parent_run_id":"","input_json":"{}","provider":"synthetic","model":"m","script_hash":"","idempotency_scope":"","idempotency_key":"","now_ms":1})).unwrap(); + let mut results = Vec::new(); + for id in ["missing", "formal-unmanaged"] { + results.push( + http.request( + "GET", + &format!("/v1/runs/{id}/effects"), + json!(null), + Some(&fixture.token), + ) + .await, + ); + } + let other = Fixture::new(); + let foreign = Http::bind(other.state.clone()).await; + results.push( + foreign + .request( + "GET", + &format!("/v1/runs/{run}/effects"), + json!(null), + Some(&other.token), + ) + .await, + ); + foreign.close().await; + other.state.service().shutdown().await.unwrap(); + http.close().await; + let denied_home = fixture.root.join("denied-home"); + fs::create_dir(&denied_home).unwrap(); + let native = Http::bind(fixture.state.clone().with_workspace_home(&denied_home)).await; + results.push( + native + .request( + "GET", + &format!("/v1/runs/{run}/effects"), + json!(null), + Some(&fixture.token), + ) + .await, + ); + native.close().await; + fixture.state.service().shutdown().await.unwrap(); + for result in results { + assert_eq!( + result, + ( + 403, + json!({"error":{"code":"managed_effects_unavailable","message":"managed_effects_unavailable"}}) + ) + ); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn http_created_native_run_effects_match_durable_summaries_and_reopen() { + let fixture = Fixture::new(); + let http = Http::bind(fixture.state.clone()).await; + let (session,run) = fixture.run(&http,vec![json!({"id":"write-one","name":"write_file","arguments":{"path":"result.txt","content":"private canonical content"}})]).await; + let p = fixture.state.persistence().unwrap(); + let record = p + .managed_effect_get(&ManagedEffectGetRequest { + owner: ManagedOwnerScope { + profile: "gateway".into(), + session_id: session.clone(), + run_id: run.clone(), + }, + call_id: "write-one".into(), + }) + .unwrap() + .expect("real Native effect persisted"); + let expected = p + .managed_effect_list(&rustscript_agent::effects::ManagedEffectListRequest { + workspace_id: record.identity.workspace.workspace_id.clone(), + after_effect_id: None, + limit: 128, + }) + .unwrap(); + assert_eq!(expected.entries.len(), 1); + assert_eq!(expected.entries[0].run_id, run); + let before = ( + p.run_get(&run).unwrap(), + fixture.state.service().session_messages(&session), + fixture.state.service().run_events(&run), + ); + let result = http + .request( + "GET", + &format!("/v1/runs/{run}/effects"), + json!(null), + Some(&fixture.token), + ) + .await; + http.close().await; + let after = ( + p.run_get(&run).unwrap(), + fixture.state.service().session_messages(&session), + fixture.state.service().run_events(&run), + ); + fixture.state.service().shutdown().await.unwrap(); + assert_eq!( + result.0, 200, + "actual planned route must expose the legitimate managed run page" + ); + let page: ManagedEffectPage = serde_json::from_value(result.1.clone()).unwrap(); + assert_eq!(page, expected); + assert!( + !result.1.to_string().contains( + fixture + .config + .managed_workspace + .as_ref() + .unwrap() + .source_root + .to_str() + .unwrap() + ) + ); + assert!(!result.1.to_string().contains("private canonical content")); + assert_eq!(before, after, "read cannot alter run/history/events"); + drop(p); + drop(fixture.state); + let reopened_state = + AgentGatewayState::with_sqlite_path(fixture.config.clone(), fixture.root.join("g.sqlite")) + .unwrap() + .with_workspace_home(&fixture.home); + let reopened = Http::bind(reopened_state.clone()).await; + let second = reopened + .request( + "GET", + &format!("/v1/runs/{run}/effects"), + json!(null), + Some(&fixture.token), + ) + .await; + reopened.close().await; + reopened_state.service().shutdown().await.unwrap(); + assert_eq!(second.0, 200); + assert_eq!(second.1, serde_json::to_value(expected).unwrap()); +} diff --git a/tests/effect_commit_recovery_tests.rs b/tests/effect_commit_recovery_tests.rs index 6ef04aa..9868540 100644 --- a/tests/effect_commit_recovery_tests.rs +++ b/tests/effect_commit_recovery_tests.rs @@ -17,6 +17,34 @@ mod schema_matrix; #[path = "effect_commit_recovery/startup_matrix.rs"] mod startup_matrix; +fn assert_managed_canonical_readback(state: &AgentGatewayState, run: &str) { + let persistence = state.persistence().unwrap(); + let service = state.service(); + let durable = persistence + .event_replay(&json!({"run_id":run,"after_seq":0,"max_events":128,"max_bytes":1048576})) + .unwrap(); + let columns = durable["columns"].as_array().unwrap(); + let cell = |row: &Value, name: &str| { + row[columns.iter().position(|column| column == name).unwrap()].clone() + }; + for event in service + .run_events(run) + .iter() + .filter(|event| event["event"].as_str().unwrap().starts_with("tool.")) + { + let row = durable["rows"] + .as_array() + .unwrap() + .iter() + .find(|row| cell(row, "event_id") == event["event_id"]) + .unwrap(); + assert_eq!( + *event, + json!({"run_id":run,"event_id":cell(row,"event_id"),"seq":cell(row,"seq"),"event":cell(row,"event_type"),"data":serde_json::from_str::(cell(row,"payload_json").as_str().unwrap()).unwrap(),"timestamp":cell(row,"created_at_ms")}) + ); + } +} + fn response(call: Option) -> Value { json!({"text":if call.is_some() { "" } else { "done" },"tool_calls":call.into_iter().collect::>(),"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2},"reasoning":"","stop_reason":"stop"}) } @@ -42,6 +70,7 @@ async fn configured_managed_write_terminal_read_and_fresh_sdk_reopen() { source_root: workspace.clone(), quota: ResourceQuota::new(1000, 1024 * 1024, 16).unwrap(), retention: ManagedRetentionLimits::default(), + maintenance_policy: Default::default(), }), ..AgentGatewayConfig::default() }; @@ -212,6 +241,7 @@ async fn configured_managed_write_terminal_read_and_fresh_sdk_reopen() { let managed_messages = |messages: Value| { messages.as_array().unwrap().iter().filter(|message|message["tool_call_id"].as_str().is_some()).map(|message|json!({"id":message["id"],"role":message["role"],"run_id":message["run_id"],"name":message["name"],"tool_call_id":message["tool_call_id"],"parent_message_id":message["parent_message_id"],"content":message["content"],"metadata":message["metadata"],"ordinal":message["ordinal"],"finish_reason":message["finish_reason"],"token_estimate":message["token_estimate"]})).collect::>() }; + assert_managed_canonical_readback(&state, &run.run_id); let live_messages = managed_messages(messages); let managed_events = |events: Vec| { events @@ -221,7 +251,6 @@ async fn configured_managed_write_terminal_read_and_fresh_sdk_reopen() { .as_str() .is_some_and(|name| name.starts_with("tool.")) }) - .map(|event| json!({"event":event["event"],"seq":event["seq"],"data":event["data"]})) .collect::>() }; let persistence = state.persistence().unwrap(); @@ -430,6 +459,27 @@ async fn intent_cut_never_issues_view_or_execution_and_reopens_indeterminate() { .count(), 1 ); + assert_managed_canonical_readback(&reopened, &request.owner.run_id); + let messages = serde_json::to_value( + reopened + .service() + .session_messages(&request.owner.session_id), + ) + .unwrap(); + let recovery = after.recovery.as_ref().unwrap(); + for event in events + .iter() + .filter(|event| event["event"] == "tool.output" || event["event"] == "tool.failed") + { + assert_eq!( + event["data"]["managed_publication"], + json!(recovery.publication) + ); + assert_eq!( + event["data"]["managed_publication"].get("candidate_head"), + Some(&Value::Null) + ); + } reopened.service().shutdown().await.unwrap(); drop(reopened); let again = AgentGatewayState::with_sqlite_path( @@ -446,6 +496,11 @@ async fn intent_cut_never_issues_view_or_execution_and_reopens_indeterminate() { .unwrap(), after ); + assert_eq!(again.service().run_events(&request.owner.run_id), events); + assert_eq!( + serde_json::to_value(again.service().session_messages(&request.owner.session_id)).unwrap(), + messages + ); again.service().shutdown().await.unwrap(); } @@ -821,6 +876,7 @@ impl ManagedFixture { source_root: workspace, quota: ResourceQuota::new(1000, 1048576, 16).unwrap(), retention: ManagedRetentionLimits::default(), + maintenance_policy: Default::default(), }), ..AgentGatewayConfig::default() }; @@ -1061,6 +1117,7 @@ async fn two_real_prepared_effects_cas_one_winner_and_persist_exact_conflict_pro 1 ); for (request, record) in requests.iter().zip(&records) { + assert_managed_canonical_readback(&fixture.state, &request.owner.run_id); let candidate = record .prepared .as_ref() @@ -1124,8 +1181,16 @@ async fn two_real_prepared_effects_cas_one_winner_and_persist_exact_conflict_pro .unwrap() .unwrap(); assert_eq!(later_record.identity.expected_head_version, 2); + let live_projections = requests + .iter() + .map(|request| fixture.state.service().run_events(&request.owner.run_id)) + .collect::>(); let reopened = fixture.reopen().await; - for (request, record) in requests.iter().zip(&records) { + for ((request, record), events) in requests.iter().zip(&records).zip(&live_projections) { + assert_eq!( + &reopened.state.service().run_events(&request.owner.run_id), + events + ); assert_eq!( reopened .state @@ -1588,6 +1653,7 @@ fn managed_startup_configuration_is_opt_in_and_rejects_invalid_source_or_retenti source_root: root, quota: ResourceQuota::new(1000, 1048576, 16).unwrap(), retention: ManagedRetentionLimits::default(), + maintenance_policy: Default::default(), }); assert!(configured.validate().is_ok()); let mut invalid = configured.clone(); @@ -2213,6 +2279,7 @@ impl CutFixture { source_root: workspace, quota: ResourceQuota::new(1000, 1048576, 16).unwrap(), retention: ManagedRetentionLimits::default(), + maintenance_policy: Default::default(), }), ..AgentGatewayConfig::default() }; @@ -2350,5 +2417,33 @@ async fn prepared_receipt_fresh_reopen_stays_staged_without_publication() { .count(), 1 ); + assert_managed_canonical_readback(&reopened, &request.owner.run_id); + let messages = serde_json::to_value( + reopened + .service() + .session_messages(&request.owner.session_id), + ) + .unwrap(); reopened.service().shutdown().await.unwrap(); + drop(reopened); + let again = AgentGatewayState::with_sqlite_path( + AgentGatewayConfig::default(), + root.join("gateway.sqlite"), + ) + .unwrap(); + assert_eq!(again.service().run_events(&request.owner.run_id), events); + assert_eq!( + serde_json::to_value(again.service().session_messages(&request.owner.session_id)).unwrap(), + messages + ); + assert_eq!( + again + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(), + after + ); + again.service().shutdown().await.unwrap(); } diff --git a/tests/gateway_tests.rs b/tests/gateway_tests.rs index a5af2da..0a22114 100644 --- a/tests/gateway_tests.rs +++ b/tests/gateway_tests.rs @@ -5351,6 +5351,8 @@ async fn halt_gates_admission_and_shutdown_makes_commands_fail_fast() { platform: "api_server".to_string(), idempotency_key: None, idempotency_hash: None, + graph_id: None, + workspace_branch: None, }) .await .expect("admission must be open before the halt"); @@ -5370,6 +5372,8 @@ async fn halt_gates_admission_and_shutdown_makes_commands_fail_fast() { platform: "api_server".to_string(), idempotency_key: None, idempotency_hash: None, + graph_id: None, + workspace_branch: None, }) .await; assert!( diff --git a/tests/gateway_workspace_tests.rs b/tests/gateway_workspace_tests.rs index 4ca0d63..bd452d6 100644 --- a/tests/gateway_workspace_tests.rs +++ b/tests/gateway_workspace_tests.rs @@ -1020,6 +1020,8 @@ async fn trusted_named_workspace_binds_session_replays_once_and_rejects_switch() session_id, idempotency_key: Some(key.to_string()), idempotency_hash: Some(hash.to_string()), + graph_id: None, + workspace_branch: None, ..AdmitRunRequest::default() }; @@ -1293,6 +1295,8 @@ async fn http_session_workspace_route_binds_an_idle_session() { session_id: Some(session_id.to_string()), idempotency_key: Some("orphan-idempotency-key".to_string()), idempotency_hash: Some("fnv64:0123456789abcdef".to_string()), + graph_id: None, + workspace_branch: None, ..AdmitRunRequest::default() }, Some("name:alias".to_string()), @@ -1598,6 +1602,8 @@ async fn workspace_idempotency_replay_is_pure_cached_and_durable() { platform: "http".to_string(), idempotency_key: Some("workspace-pure-replay".to_string()), idempotency_hash: Some("fnv64:0123456789abcdef".to_string()), + graph_id: None, + workspace_branch: None, ..AdmitRunRequest::default() }, Some("name:project".to_string()), @@ -1616,6 +1622,8 @@ async fn workspace_idempotency_replay_is_pure_cached_and_durable() { platform: "http".to_string(), idempotency_key: Some("workspace-default-replay".to_string()), idempotency_hash: Some("fnv64:0000000000000010".to_string()), + graph_id: None, + workspace_branch: None, ..AdmitRunRequest::default() }, None, @@ -1631,6 +1639,8 @@ async fn workspace_idempotency_replay_is_pure_cached_and_durable() { platform: "http".to_string(), idempotency_key: Some("workspace-default-replay".to_string()), idempotency_hash: Some("fnv64:0000000000000010".to_string()), + graph_id: None, + workspace_branch: None, ..AdmitRunRequest::default() }, None, @@ -1666,6 +1676,8 @@ async fn workspace_idempotency_replay_is_pure_cached_and_durable() { platform: "http".to_string(), idempotency_key: Some("workspace-pure-replay".to_string()), idempotency_hash: Some("fnv64:0123456789abcdef".to_string()), + graph_id: None, + workspace_branch: None, ..AdmitRunRequest::default() }, Some("name:project".to_string()), @@ -1697,6 +1709,8 @@ async fn workspace_idempotency_replay_is_pure_cached_and_durable() { platform: "http".to_string(), idempotency_key: Some("workspace-pure-replay".to_string()), idempotency_hash: Some("fnv64:0123456789abcdef".to_string()), + graph_id: None, + workspace_branch: None, ..AdmitRunRequest::default() }, Some("name:project".to_string()), @@ -1724,6 +1738,8 @@ async fn workspace_idempotency_replay_is_pure_cached_and_durable() { platform: "http".to_string(), idempotency_key: Some("workspace-default-replay".to_string()), idempotency_hash: Some("fnv64:0000000000000010".to_string()), + graph_id: None, + workspace_branch: None, ..AdmitRunRequest::default() }, None, @@ -1748,6 +1764,8 @@ async fn workspace_idempotency_replay_is_pure_cached_and_durable() { platform: "http".to_string(), idempotency_key: Some("workspace-pure-replay".to_string()), idempotency_hash: Some("fnv64:0123456789abcdef".to_string()), + graph_id: None, + workspace_branch: None, ..AdmitRunRequest::default() }, Some("name:alias".to_string()), @@ -1768,6 +1786,8 @@ async fn workspace_idempotency_replay_is_pure_cached_and_durable() { platform: "http".to_string(), idempotency_key: Some("workspace-pure-replay".to_string()), idempotency_hash: Some("fnv64:fedcba9876543210".to_string()), + graph_id: None, + workspace_branch: None, ..AdmitRunRequest::default() }, Some("name:alias".to_string()), @@ -1784,6 +1804,8 @@ async fn workspace_idempotency_replay_is_pure_cached_and_durable() { platform: "http".to_string(), idempotency_key: Some("workspace-pure-replay".to_string()), idempotency_hash: Some("fnv64:fedcba9876543210".to_string()), + graph_id: None, + workspace_branch: None, ..AdmitRunRequest::default() }, Some("name:project".to_string()), diff --git a/tests/managed_checkpoint_api_tests.rs b/tests/managed_checkpoint_api_tests.rs new file mode 100644 index 0000000..ddec702 --- /dev/null +++ b/tests/managed_checkpoint_api_tests.rs @@ -0,0 +1,66 @@ +//! Managed maintenance HTTP contracts exercised over an owned loopback socket. + +use std::{net::SocketAddr, time::Duration}; + +use rustscript_agent::{AgentGatewayConfig, AgentGatewayState, build_agent_gateway_app}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +#[tokio::test] +async fn management_routes_require_configured_authentication() { + let state = AgentGatewayState::new(AgentGatewayConfig::default()).expect("fixture gateway"); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("owned listener"); + let address = listener.local_addr().expect("listener address"); + let (shutdown, stopped) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + axum::serve( + listener, + build_agent_gateway_app(state).into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(async move { + let _ = stopped.await; + }) + .await + }); + // Always close and join the owned server before propagating an assertion. + let outcome = tokio::spawn(async move { + for action in ["checkpoints", "restore", "branches", "publish"] { + let mut connection = tokio::net::TcpStream::connect(address) + .await + .expect("connect owned listener"); + let request = format!( + "POST /v1/workspaces/workspace-fixture/{action} HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{{}}" + ); + connection + .write_all(request.as_bytes()) + .await + .expect("send management request"); + let mut response = Vec::new(); + tokio::time::timeout(Duration::from_secs(5), connection.read_to_end(&mut response)) + .await + .expect("bounded response") + .expect("read response"); + let response = String::from_utf8(response).expect("HTTP response UTF-8"); + assert!( + response.starts_with("HTTP/1.1 403"), + "unconfigured management authentication must fail closed before DTO parsing for {action}; received {}", + response.lines().next().unwrap_or_default() + ); + let (_, body) = response.split_once("\r\n\r\n").expect("HTTP body"); + let body: serde_json::Value = serde_json::from_str(body).expect("static JSON error"); + assert_eq!(body["error"]["code"], "managed_management_denied"); + } + }) + .await; + let _ = shutdown.send(()); + tokio::time::timeout(Duration::from_secs(5), server) + .await + .expect("server shutdown deadline") + .expect("server task joined") + .expect("server closed"); + drop(std::net::TcpListener::bind(address).expect("listener released")); + if let Err(error) = outcome { + std::panic::resume_unwind(error.into_panic()); + } +} diff --git a/tests/managed_checkpoint_restore_tests.rs b/tests/managed_checkpoint_restore_tests.rs new file mode 100644 index 0000000..6c3367b --- /dev/null +++ b/tests/managed_checkpoint_restore_tests.rs @@ -0,0 +1,1774 @@ +//! Production managed maintenance through HTTP, original admission and Native FS. + +use std::{net::SocketAddr, path::PathBuf, sync::Arc, time::Duration}; + +use bytes::Bytes; +use http_body_util::{BodyExt, Full}; +use hyper_util::{client::legacy::Client, rt::TokioExecutor}; +use rustscript_agent::{ + AgentGatewayConfig, AgentGatewayState, ScriptedProvider, build_agent_gateway_app, + config::ManagedWorkspaceConfig, + effects::{ManagedEffectGetRequest, ManagedOwnerScope}, +}; +use serde_json::{Value, json}; +async fn request(address: SocketAddr, path: &str, body: Value) -> (u16, Value) { + post(address, path, body, Some("synthetic-maintenance-fixture")).await +} + +async fn post(address: SocketAddr, path: &str, body: Value, bearer: Option<&str>) -> (u16, Value) { + exchange(address, "POST", path, Some(body), bearer).await +} + +async fn exchange( + address: SocketAddr, + method: &str, + path: &str, + body: Option, + bearer: Option<&str>, +) -> (u16, Value) { + let client: Client<_, Full> = Client::builder(TokioExecutor::new()).build_http(); + let mut request = hyper::Request::builder() + .method(method) + .uri(format!("http://{address}{path}")) + .header("content-type", "application/json"); + if let Some(token) = bearer { + request = request.header("authorization", format!("Bearer {token}")); + } + let request = request + .body(Full::new(Bytes::from( + body.map(|value| value.to_string()).unwrap_or_default(), + ))) + .unwrap(); + let response = client.request(request).await.unwrap(); + let status = response.status().as_u16(); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let body = if body.is_empty() { + Value::Null + } else { + serde_json::from_slice(&body).expect("HTTP JSON body") + }; + (status, body) +} + +struct CancellationGateProvider { + step: std::sync::atomic::AtomicUsize, + entered: std::sync::atomic::AtomicBool, +} +impl rustscript_agent::AgentProviderHost for CancellationGateProvider { + fn call(&self, _request: &Value, cancellation: &rustscript_agent::RunCancellation) -> Value { + use std::sync::atomic::Ordering; + if self.step.fetch_add(1, Ordering::SeqCst) == 0 { + return json!({"ok":true,"response":{"text":"", "tool_calls":[{"id":"write-b", "name":"write_file", "arguments":{"path":"value.txt", "content":"B"}}], "usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}, "reasoning":"", "stop_reason":"tool_calls"},"error":{}}); + } + self.entered.store(true, Ordering::Release); + while cancellation.requested().is_none() && !cancellation.deadline_passed() { + std::thread::sleep(Duration::from_millis(5)); + } + json!({"ok":false,"error":{"code":"cancelled","message":"owned synthetic provider observed original cancellation","retryable":false}}) + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn managed_files_checkpoint_restore_new_admission() { + roundtrip(false, false, false).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn restore_replay_uses_original_source_proof() { + roundtrip(true, false, false).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn restore_cancels_active_source_before_capacity_one_admission() { + roundtrip(false, true, false).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn committed_compaction_restores_summary_and_retained_tail_to_actual_model() { + roundtrip(false, false, true).await; +} + +async fn roundtrip(check_replay: bool, active_source: bool, compacted: bool) { + roundtrip_case(check_replay, active_source, compacted).await; +} +async fn roundtrip_case(check_replay: bool, active_source: bool, compacted: bool) { + let root = PathBuf::from(std::env::var_os("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()) + .join(format!("managed-checkpoint-{}", uuid::Uuid::new_v4())); + let workspace = root.join("workspace"); + let home = root.join("home"); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::create_dir(&home).unwrap(); + for name in ["AGENTS.md", "CLAUDE.md", ".cursorrules"] { + std::fs::write(workspace.join(name), format!("FROZEN_A_{name}")).unwrap(); + } + use std::os::unix::fs::PermissionsExt; + for directory in [&root, &workspace, &home] { + std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + std::fs::write(home.join("config.yaml"), format!("version: 1\nmodel:\n provider: local-test\n model: synthetic\nproviders:\n local-test:\n protocol: codex-responses\n base_url: https://127.0.0.1:1\nworkspaces:\n allowed_roots:\n - '{}'\n default: '{}'\n", workspace.display(), workspace.display())).unwrap(); + let mut config = AgentGatewayConfig { + bearer_token: Some("synthetic-maintenance-fixture".into()), + max_concurrent_runs: 1, + managed_workspace: Some(ManagedWorkspaceConfig::for_workspace(&workspace).unwrap()), + ..Default::default() + }; + config.approvals.write = "allow".into(); + config + .managed_workspace + .as_mut() + .unwrap() + .maintenance_policy + .create_checkpoint = true; + config + .managed_workspace + .as_mut() + .unwrap() + .maintenance_policy + .restore = true; + let compaction = rustscript_agent::config_file::CompactionSettings { + retained_tail: 1, + ..Default::default() + }; + let state = AgentGatewayState::with_agent_selection_and_compaction( + config, + json!({}), + Some(&root.join("gateway.sqlite")), + compaction, + ) + .unwrap_or_else(|error| { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("rss/storage/admission.rss"); + let diagnostic = rustscript_agent::AgentRunner::from_file( + path, + rustscript_agent::AgentConfig::default(), + ) + .err(); + panic!( + "fixture storage initialization: {error}; ordinary source compilation: {diagnostic:?}" + ); + }) + .with_workspace_home(&home); + let service = state.service(); + service + .set_provider_profile( + rustscript_agent::config::ProviderProfile::new( + "frozen-profile", + json!({"protocol":"provider","temperature":0.25,"max_output_tokens":1024}), + ) + .unwrap(), + ) + .unwrap(); + let provider = Arc::new(ScriptedProvider::new()); + for response in [ + json!({"text":"", "tool_calls":[{"id":"write-a", "name":"write_file", "arguments":{"path":"value.txt", "content":"A"}}], "usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}, "reasoning":"", "stop_reason":"tool_calls"}), + json!({"text":"closed A", "tool_calls":[], "usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}, "reasoning":"", "stop_reason":"stop"}), + ] { + provider.push_ok(response); + } + service.inject_provider_host(provider); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (shutdown, stopped) = tokio::sync::oneshot::channel(); + let app = build_agent_gateway_app(state.clone()); + let server = tokio::spawn(async move { + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(async move { + let _ = stopped.await; + }) + .await + }); + let observed = Arc::clone(&service); + let persistence = state.persistence().unwrap(); + let outcome = tokio::spawn(async move { + let session_id = uuid::Uuid::new_v4().to_string(); + let (status, _) = request(address, "/api/sessions", json!({"id":session_id})).await; + assert_eq!(status, 201, "original HTTP session creation"); + let (status, run) = request(address, "/v1/runs", json!({"input":"write A", "session_id":session_id,"provider":"frozen-profile"})).await; + assert_eq!(status, 202, "original HTTP admission: {run}"); + let run_id = run["run_id"].as_str().unwrap(); + tokio::time::timeout(Duration::from_secs(20), async { + loop { + if observed.run_events(run_id).iter().any(|event| event["event"] == "run.completed") { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }).await.expect("actual worker completes Native write"); + let record = persistence.managed_effect_get(&ManagedEffectGetRequest { + owner: ManagedOwnerScope {profile:"gateway".into(), session_id:session_id.clone(), run_id:run_id.into()}, + call_id:"write-a".into(), + }).unwrap().unwrap(); + let publication = record.outcome.as_ref().unwrap(); + let head = persistence.managed_workspace_head(&record.identity.workspace).unwrap(); + assert_eq!(Some(head.head_object_id.as_str()), publication.publication.candidate_head.as_deref()); + let workspace_id = &record.identity.workspace.workspace_id; + let frozen_messages = persistence.message_list(&session_id, 0).unwrap(); + let mut frozen_summary = None; + if compacted { + let compactor = observed.clone(); + let session = session_id.clone(); + let result = tokio::task::spawn_blocking(move || compactor.compact_session_explicit(&session)).await.unwrap().unwrap(); + assert_eq!(result["status"], "committed"); + let latest = persistence.compaction_latest(&session_id).unwrap(); + frozen_summary = Some(latest); + } + let (status, checkpoint) = request(address, &format!("/v1/workspaces/{workspace_id}/checkpoints"), json!({ + "source_run_id":run_id, + "workspace_id":workspace_id, + "source_branch_id":record.identity.workspace.branch_id, + "expected_epoch":record.identity.epoch, + "request_id":uuid::Uuid::new_v4().to_string(), + "mode":"at_closed_batch" + })).await; + assert_eq!(status, 201, "checkpoint must traverse production management: {checkpoint}"); + let checkpoint = &checkpoint["checkpoint"]; + assert_eq!(checkpoint["frozen_root"]["object_id"], head.head_object_id); + assert!(checkpoint["history"]["message_count"].as_u64().unwrap() >= 3); + assert_eq!(checkpoint["history"]["closed_output_refs"][0]["call_id"], "write-a"); + assert_eq!(checkpoint["graph"]["graph_id"], record.identity.code.graph_id); + assert_eq!(checkpoint["plugin_state"]["roots"], json!([])); + assert!(checkpoint["public_config"]["config"].get("provider_options").is_none()); + assert!(checkpoint["public_config"]["config"].get("limits").is_none()); + assert!(checkpoint["public_config"]["config"].get("metadata").is_none()); + observed.set_provider_profile(rustscript_agent::config::ProviderProfile::new("frozen-profile", json!({"protocol":"provider","temperature":0.75,"max_output_tokens":2048})).unwrap()).unwrap(); + for name in ["AGENTS.md", "CLAUDE.md", ".cursorrules"] { + std::fs::write(workspace.join(name), format!("HOST_LIVE_{name}")).unwrap(); + } + let provider = Arc::new(ScriptedProvider::new()); + let mut writes = vec![json!({"id":"write-b", "name":"write_file", "arguments":{"path":"value.txt", "content":"B"}})]; + for (index, name) in ["AGENTS.md", "CLAUDE.md", ".cursorrules"].iter().enumerate() { + writes.push(json!({"id":format!("guidance-b-{index}"), "name":"write_file", "arguments":{"path":name, "content":format!("MANAGED_B_{name}")}})); + } + provider.push_ok(json!({"text":"", "tool_calls":writes, "usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}, "reasoning":"", "stop_reason":"tool_calls"})); + provider.push_ok(json!({"text":"closed B", "tool_calls":[], "usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}, "reasoning":"", "stop_reason":"stop"})); + let source_gate = Arc::new(CancellationGateProvider { + step: std::sync::atomic::AtomicUsize::new(0), + entered: std::sync::atomic::AtomicBool::new(false), + }); + if active_source { observed.inject_provider_host(source_gate.clone()); } + else { observed.inject_provider_host(provider); } + let (status, later) = request(address, "/v1/runs", json!({"input":"advance source to B", "session_id":session_id})).await; + assert_eq!(status, 202); + let later_run = later["run_id"].as_str().unwrap(); + tokio::time::timeout(Duration::from_secs(20), async { + while if active_source { + !source_gate.entered.load(std::sync::atomic::Ordering::Acquire) + } else { + !observed.run_events(later_run).iter().any(|event| event["event"] == "run.completed") + } { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }).await.unwrap(); + let source_before = persistence.managed_workspace_head(&record.identity.workspace).unwrap(); + if !active_source { + for index in 0..3 { + let guidance_write = persistence.managed_effect_get(&ManagedEffectGetRequest { + owner: ManagedOwnerScope {profile:"gateway".into(),session_id:session_id.clone(),run_id:later_run.into()}, + call_id:format!("guidance-b-{index}"), + }).unwrap().unwrap(); + assert_eq!(guidance_write.outcome.unwrap().disposition, rustscript_agent::effects::ManagedPublicationStatus::Committed); + } + } + assert_ne!(head.head_object_id, source_before.head_object_id); + assert!(source_before.head_version > head.head_version); + let provider = Arc::new(ScriptedProvider::new()); + provider.push_ok(json!({"text":"", "tool_calls":[{"id":"restored-read-a", "name":"read_file", "arguments":{"path":"value.txt"}},{"id":"restored-write-c", "name":"write_file", "arguments":{"path":"value.txt", "content":"C"}}], "usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}, "reasoning":"", "stop_reason":"tool_calls"})); + provider.push_ok(json!({"text":"closed C", "tool_calls":[], "usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}, "reasoning":"", "stop_reason":"stop"})); + observed.inject_provider_host(provider.clone()); + let restore_request = json!({ + "workspace_id":workspace_id, + "source_branch_id":record.identity.workspace.branch_id, + "checkpoint_id":checkpoint["checkpoint_id"], + "expected_source":{ + "store_id":source_before.store_id, + "workspace_id":source_before.workspace_id, + "branch_id":source_before.branch_id, + "epoch":source_before.epoch, + "head_object_id":source_before.head_object_id, + "head_version":source_before.head_version + }, + "request_id":uuid::Uuid::new_v4().to_string(), + "input":{"text":"read restored A, then write C"}, + "cancel_active_source":true, + "graph_selection":"checkpoint_exact" + }); + let (status, restored) = request(address, &format!("/v1/workspaces/{workspace_id}/restore"), restore_request.clone()).await; + assert_eq!(status, 202, "atomic restore and original new admission: {restored}"); + if active_source { + assert!(observed.run_events(later_run).iter().any(|event| event["event"] == "run.cancelled"), + "the old source worker must exit and release its original capacity before a new run is admitted"); + } + assert_eq!(restored["source_before"]["head_object_id"], source_before.head_object_id); + assert_eq!(restored["new_branch"]["head_object_id"], head.head_object_id); + assert_eq!(restored["source_after"]["epoch"], source_before.epoch + 1); + assert_eq!(restored["new_branch"]["epoch"], source_before.epoch + 1); + assert_eq!(restored["new_branch"]["head_version"], 1); + let new_run = restored["new_run_id"].as_str().unwrap(); + let new_session = restored["new_session_id"].as_str().unwrap(); + assert_ne!(new_run, run_id); + assert_ne!(new_run, later_run); + assert_ne!(new_session, session_id); + tokio::time::timeout(Duration::from_secs(20), async { + while !observed.run_events(new_run).iter().any(|event| event["event"] == "run.completed") { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }).await.unwrap(); + let provider_requests = provider.requests(); + assert_eq!(provider_requests[0]["provider_options"]["temperature"], 0.25, "restore consumes private frozen safe options"); + assert_eq!(provider_requests[0]["provider_options"]["max_output_tokens"], 1024); + let first_request = provider_requests.first().expect("restored provider actually called").to_string(); + if let Some(summary) = frozen_summary { + let summary_json = summary["rows"][0][7].as_str().expect("typed committed summary column"); + let summary: Value = serde_json::from_str(summary_json).unwrap(); + let expected_summary = summary["text"].as_str().unwrap().to_string(); + assert!(first_request.contains(&serde_json::to_string(&expected_summary).unwrap()[1..serde_json::to_string(&expected_summary).unwrap().len()-1]), "restored model consumes exact frozen summary"); + assert!(first_request.contains("closed A"), "retained tail remains in model context"); + let boundary = summary["retained_tail_ordinal"].as_i64().unwrap(); + let mut expected = vec![json!({"role":"user","content":[{"type":"text","text":expected_summary}]})]; + let column = |page: &Value, name: &str| page["columns"].as_array().unwrap().iter().position(|value|value==name).unwrap(); + for row in frozen_messages["rows"].as_array().unwrap() { + if row[column(&frozen_messages,"ordinal")].as_i64().unwrap() > boundary { + let content: Value = serde_json::from_str(row[column(&frozen_messages,"content_json")].as_str().unwrap()).unwrap(); + expected.push(json!({"role":row[column(&frozen_messages,"role")],"content":content})); + } + } + let current = persistence.message_list(new_session, 0).unwrap(); + let input = current["rows"].as_array().unwrap().iter().find(|row|row[column(¤t,"ordinal")].as_i64()==Some(5)).unwrap(); + let input_content: Value = serde_json::from_str(input[column(¤t,"content_json")].as_str().unwrap()).unwrap(); + assert!(input_content.to_string().contains("read restored A, then write C")); + expected.push(json!({"role":"user","content":input_content})); + let actual = provider_requests[0]["messages"].as_array().unwrap().iter().skip(1).map(|message|json!({"role":message["role"],"content":message["content"]})).collect::>(); + assert_eq!(actual, expected, "actual selected frozen summary + exact retained tail + new admitted input"); + let restored_summary = persistence.compaction_latest(new_session).unwrap(); + assert_eq!(restored_summary["rows"][0][7], summary_json); + } + for name in ["AGENTS.md", "CLAUDE.md", ".cursorrules"] { + assert!(first_request.contains(&format!("FROZEN_A_{name}")), "restored model must consume frozen guidance {name}"); + } + assert!(!first_request.contains("HOST_LIVE_"), "host guidance must not enter restored model context"); + assert!(!first_request.contains("MANAGED_B_"), "current managed head must not replace frozen guidance"); + assert!(!first_request.contains("/managed-effects/"), "private store path must not enter model context"); + assert!(!first_request.contains(".gateway.sqlite.effects"), "private frozen root path must not enter model context"); + let read = observed.replay_managed_call(ManagedEffectGetRequest { + owner:ManagedOwnerScope {profile:"gateway".into(),session_id:new_session.into(),run_id:new_run.into()}, + call_id:"restored-read-a".into(), + }).unwrap(); + let rustscript_agent::effects::ManagedCallReplay::Final {canonical_result, ..} = read else { + panic!("restored read must have a durable final Native result") + }; + let read: Value = serde_json::from_str(canonical_result.as_str()).unwrap(); + assert_eq!(read["ok"], true); + assert_eq!(read["content"], "A"); + let written = persistence.managed_effect_get(&ManagedEffectGetRequest { + owner:ManagedOwnerScope {profile:"gateway".into(),session_id:new_session.into(),run_id:new_run.into()}, + call_id:"restored-write-c".into(), + }).unwrap().unwrap(); + assert_ne!(written.identity.workspace.branch_id, record.identity.workspace.branch_id); + assert_eq!(written.identity.epoch, source_before.epoch + 1); + assert_eq!(written.outcome.as_ref().unwrap().disposition, rustscript_agent::effects::ManagedPublicationStatus::Committed); + let retired = persistence.managed_workspace_head(&record.identity.workspace).unwrap(); + assert_eq!(retired.head_object_id, source_before.head_object_id); + assert_eq!(retired.epoch, source_before.epoch + 1); + if check_replay { + let before_replay_messages = persistence.message_list(new_session, 0).unwrap(); + let before_replay_context = observed.run_context(new_run).unwrap(); + let before_replay_events = observed.run_events(new_run); + let before_replay_calls = provider.requests().len(); + let before_replay_head = persistence.managed_workspace_head(&written.identity.workspace).unwrap(); + let before_replay_result = persistence.managed_effect_get(&ManagedEffectGetRequest { + owner:ManagedOwnerScope {profile:"gateway".into(),session_id:new_session.into(),run_id:new_run.into()}, + call_id:"restored-write-c".into(), + }).unwrap(); + let (status, replay) = request(address, &format!("/v1/workspaces/{workspace_id}/restore"), restore_request.clone()).await; + assert_eq!(status, 202, "exact original SOURCE request must replay: {replay}"); + assert_eq!(replay["replayed"], true); + let replay_path = format!("/v1/workspaces/{workspace_id}/restore"); + let (left, right) = tokio::join!( + request(address, &replay_path, restore_request.clone()), + request(address, &replay_path, restore_request.clone()) + ); + for (status, concurrent) in [left, right] { + assert_eq!(status, 202); + assert_eq!(concurrent, replay, "same-request concurrent exact retries are one receipt/run, never two CAS winners"); + } + for field in ["receipt_id", "new_branch", "new_run_id", "new_session_id", "new_run_deadline_at_ms", "source_before", "source_after"] { + assert_eq!(replay[field], restored[field], "exact replay preserves {field}"); + } + let mut changed = restore_request.clone(); + changed["expected_source"] = restored["source_after"].clone(); + let (status, denied) = request(address, &format!("/v1/workspaces/{workspace_id}/restore"), changed).await; + assert_eq!(status, 409); + assert_eq!(denied["error"]["code"], "managed_request_conflict"); + let mut changed_input = restore_request; + changed_input["input"]["text"] = json!("different original input"); + let (status, denied) = request(address, &format!("/v1/workspaces/{workspace_id}/restore"), changed_input).await; + assert_eq!(status, 409); + assert_eq!(denied["error"]["code"], "managed_request_conflict"); + assert_eq!(persistence.managed_workspace_head(&record.identity.workspace).unwrap(), retired); + assert_eq!(persistence.managed_workspace_head(&written.identity.workspace).unwrap(), before_replay_head); + assert_eq!(persistence.message_list(new_session, 0).unwrap(), before_replay_messages); + assert_eq!(observed.run_context(new_run).unwrap().limits, before_replay_context.limits); + assert_eq!(observed.run_context(new_run).unwrap().metadata, before_replay_context.metadata); + assert_eq!(observed.run_events(new_run), before_replay_events); + assert_eq!(provider.requests().len(), before_replay_calls, "replay/conflicts never restart the worker or issue provider/tool IO"); + assert_eq!(persistence.managed_effect_get(&ManagedEffectGetRequest { + owner:ManagedOwnerScope {profile:"gateway".into(),session_id:new_session.into(),run_id:new_run.into()}, + call_id:"restored-write-c".into(), + }).unwrap(), before_replay_result); + } + assert!(!workspace.join("value.txt").exists(), "source directory is never overwritten"); + for name in ["AGENTS.md", "CLAUDE.md", ".cursorrules"] { + assert_eq!(std::fs::read_to_string(workspace.join(name)).unwrap(), format!("HOST_LIVE_{name}")); + } + }).await; + let _ = shutdown.send(()); + tokio::time::timeout(Duration::from_secs(5), server) + .await + .unwrap() + .unwrap() + .unwrap(); + service.halt(); + let cleanup = tokio::time::timeout(Duration::from_secs(10), async { + loop { + match service.shutdown().await { + Ok(()) => break, + Err(_) => tokio::time::sleep(Duration::from_millis(10)).await, + } + } + }) + .await; + drop(std::net::TcpListener::bind(address).expect("owned listener released")); + if let Err(error) = outcome { + std::panic::resume_unwind(error.into_panic()); + } + cleanup.expect("owned worker and storage shutdown"); +} + +fn restart_fixture() -> (PathBuf, AgentGatewayConfig, AgentGatewayState) { + let root = PathBuf::from(std::env::var_os("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()) + .join(format!("managed-restart-{}", uuid::Uuid::new_v4())); + let workspace = root.join("workspace"); + let home = root.join("home"); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::create_dir(&home).unwrap(); + use std::os::unix::fs::PermissionsExt; + for path in [&root, &workspace, &home] { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + std::fs::write(home.join("config.yaml"), format!("version: 1\nmodel:\n provider: local-test\n model: synthetic\nproviders:\n local-test:\n protocol: codex-responses\n base_url: https://127.0.0.1:1\nworkspaces:\n allowed_roots:\n - '{}'\n default: '{}'\n", workspace.display(), workspace.display())).unwrap(); + let mut config = AgentGatewayConfig { + bearer_token: Some("synthetic-maintenance-fixture".into()), + max_concurrent_runs: 1, + managed_workspace: Some(ManagedWorkspaceConfig::for_workspace(&workspace).unwrap()), + ..Default::default() + }; + config.approvals.write = "allow".into(); + let policy = &mut config + .managed_workspace + .as_mut() + .unwrap() + .maintenance_policy; + policy.create_checkpoint = true; + policy.restore = true; + let state = AgentGatewayState::with_sqlite_path(config.clone(), root.join("gateway.sqlite")) + .unwrap() + .with_workspace_home(&home); + (root, config, state) +} + +struct RestartServer { + address: SocketAddr, + shutdown: tokio::sync::oneshot::Sender<()>, + task: tokio::task::JoinHandle>, +} + +impl RestartServer { + async fn start(state: AgentGatewayState) -> Self { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (shutdown, stopped) = tokio::sync::oneshot::channel(); + let app = build_agent_gateway_app(state); + let task = tokio::spawn(async move { + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(async move { + let _ = stopped.await; + }) + .await + }); + Self { + address, + shutdown, + task, + } + } + + async fn close(self, service: &rustscript_agent::AgentService) -> Result<(), String> { + let _ = self.shutdown.send(()); + tokio::time::timeout(Duration::from_secs(5), self.task) + .await + .unwrap() + .unwrap() + .unwrap(); + service.halt(); + // The first shutdown is the qualification result. On a failed fixture, + // finish cleanup separately, retaining the first error and test panic. + let first = tokio::time::timeout(Duration::from_secs(10), service.shutdown()) + .await + .expect("bounded first shutdown"); + if first.is_err() { + tokio::time::timeout(Duration::from_secs(10), async { + while service.shutdown().await.is_err() { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("failed-fixture cleanup must still join owners"); + } + drop(std::net::TcpListener::bind(self.address).expect("owned listener released")); + println!( + "restart owned server joined; first service.shutdown={first:?}; listener released" + ); + first + } +} + +async fn restart_wait_terminal(service: &rustscript_agent::AgentService, run: &str) { + tokio::time::timeout(Duration::from_secs(20), async { + loop { + let events = service.run_events(run); + if events.iter().any(|event| event["event"] == "run.completed") { + #[cfg(feature = "workspace-io-fault")] + if !service.handle(run).unwrap().worker_is_idle_for_test() { + tokio::time::sleep(Duration::from_millis(2)).await; + continue; + } + break; + } + assert!( + !events.iter().any(|event| matches!( + event["event"].as_str(), + Some("run.failed" | "run.cancelled") + )), + "unexpected synthetic worker terminal: {events:?}" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap_or_else(|error| { + panic!( + "real HTTP worker terminal: {error}; events={:?}", + service.run_events(run) + ) + }); +} + +fn restart_response(call: Option) -> Value { + json!({"text":if call.is_some() { "" } else { "closed" }, "tool_calls":call.into_iter().collect::>(), "usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}, "reasoning":"", "stop_reason":"stop"}) +} + +fn restart_run_facts(state: &AgentGatewayState, runs: &[String]) -> Value { + let service = state.service(); + let persistence = state.persistence().unwrap(); + Value::Array( + runs.iter() + .map(|run| { + let context = service.run_context(run).unwrap(); + json!({"run":persistence.run_get(run).unwrap(), "events":service.run_events(run), + "metadata":context.metadata, "limits":context.limits, + "history":persistence.message_list(&context.session_id, 0).unwrap()}) + }) + .collect(), + ) +} + +#[tokio::test(flavor = "multi_thread")] +async fn restart_restores_facts_not_old_authority() { + use rustscript_agent::effects::{ + ManagedCheckpointReceipt, ManagedEffectListRequest, ManagedRestoreAndAdmitResult, + }; + let (root, config, state) = restart_fixture(); + let service = state.service(); + service + .set_provider_profile( + rustscript_agent::config::ProviderProfile::new( + "restart-profile", + json!({"protocol":"provider"}), + ) + .unwrap(), + ) + .unwrap(); + let provider = Arc::new(ScriptedProvider::new()); + for (id, content) in [("restart-write-a", "A"), ("restart-write-b", "B")] { + provider.push_ok(restart_response(Some( + json!({"id":id,"name":"write_file","arguments":{"path":"value.txt","content":content}}), + ))); + provider.push_ok(restart_response(None)); + } + provider.push_ok(restart_response(Some( + json!({"id":"restart-read-a","name":"read_file","arguments":{"path":"value.txt"}}), + ))); + provider.push_ok(restart_response(None)); + service.inject_provider_host(provider.clone()); + let server = RestartServer::start(state.clone()).await; + let address = server.address; + let observed = state.clone(); + let scenario_provider = provider.clone(); + let outcome = tokio::spawn(async move { + let service = observed.service(); + let persistence = observed.persistence().unwrap(); + let session = uuid::Uuid::new_v4().to_string(); + assert_eq!(request(address, "/api/sessions", json!({"id":session})).await.0, 201); + let (status, a) = request(address, "/v1/runs", json!({"input":"write A","session_id":session,"provider":"restart-profile"})).await; + assert_eq!(status, 202); + let a = a["run_id"].as_str().unwrap().to_string(); + restart_wait_terminal(&service, &a).await; + let write_a = persistence.managed_effect_get(&ManagedEffectGetRequest { + owner: ManagedOwnerScope {profile:"gateway".into(),session_id:session.clone(),run_id:a.clone()},call_id:"restart-write-a".into(), + }).unwrap().unwrap(); + let old_identity = write_a.identity.workspace.clone(); + let head_a = persistence.managed_workspace_head(&old_identity).unwrap(); + let path = format!("/v1/workspaces/{}/checkpoints", old_identity.workspace_id); + let (status, checkpoint) = request(address, &path, json!({"source_run_id":a,"workspace_id":old_identity.workspace_id, + "source_branch_id":old_identity.branch_id,"expected_epoch":head_a.epoch,"request_id":uuid::Uuid::new_v4().to_string(),"mode":"at_closed_batch"})).await; + assert_eq!(status, 201, "{checkpoint}"); + let checkpoint: ManagedCheckpointReceipt = serde_json::from_value(checkpoint).unwrap(); + assert_eq!(checkpoint.checkpoint.frozen_root.object_id, head_a.head_object_id); + service.inject_provider_host(scenario_provider.clone()); + let (status, b) = request(address, "/v1/runs", json!({"input":"write B","session_id":session})).await; + assert_eq!(status, 202); + let b = b["run_id"].as_str().unwrap().to_string(); + restart_wait_terminal(&service, &b).await; + let write_b = persistence.managed_effect_get(&ManagedEffectGetRequest { + owner: ManagedOwnerScope {profile:"gateway".into(),session_id:session.clone(),run_id:b.clone()},call_id:"restart-write-b".into(), + }).unwrap().unwrap(); + assert_eq!(write_b.identity.workspace, old_identity, "B is a real writer on the same branch"); + let head_b = persistence.managed_workspace_head(&old_identity).unwrap(); + assert_ne!(head_b.head_object_id, head_a.head_object_id); + let body = json!({"workspace_id":old_identity.workspace_id,"source_branch_id":old_identity.branch_id, + "checkpoint_id":checkpoint.checkpoint.checkpoint_id,"expected_source":{"store_id":head_b.store_id,"workspace_id":head_b.workspace_id, + "branch_id":head_b.branch_id,"epoch":head_b.epoch,"head_object_id":head_b.head_object_id,"head_version":head_b.head_version}, + "request_id":uuid::Uuid::new_v4().to_string(),"input":{"text":"read frozen A"},"cancel_active_source":true,"graph_selection":"checkpoint_exact"}); + let restore_path = format!("/v1/workspaces/{}/restore", old_identity.workspace_id); + service.inject_provider_host(scenario_provider); + let (status, restored) = request(address, &restore_path, body.clone()).await; + assert_eq!(status, 202, "{restored}"); + let receipt: ManagedRestoreAndAdmitResult = serde_json::from_value(restored).unwrap(); + restart_wait_terminal(&service, &receipt.new_run_id).await; + assert_eq!(receipt.source_before.head_object_id, head_b.head_object_id); + assert_eq!(receipt.new_branch.head_object_id, head_a.head_object_id); + assert_ne!(receipt.new_session_id, session); + assert_ne!(receipt.new_run_id, a); + assert_ne!(receipt.new_run_id, b); + let context = service.run_context(&receipt.new_run_id).unwrap(); + println!("restart phase=terminal-before-reopen receipt={} context_deadline={} old_runs={:?} old_session={session}", serde_json::to_string(&receipt).unwrap(), context.metadata["deadline_at_ms"], [&a, &b]); + assert_eq!(context.metadata["deadline_at_ms"], receipt.new_run_deadline_at_ms, "formal receipt and frozen context must preserve the same original new-run deadline"); + let read_request = ManagedEffectGetRequest {owner:ManagedOwnerScope {profile:"gateway".into(),session_id:receipt.new_session_id.clone(),run_id:receipt.new_run_id.clone()},call_id:"restart-read-a".into()}; + let rustscript_agent::effects::ManagedCallReplay::Final {canonical_result, ..} = service.replay_managed_call(read_request.clone()).unwrap() else {panic!("actual Native read result")}; + let result: Value = serde_json::from_str(canonical_result.as_str()).unwrap(); + assert_eq!(result["content"], "A"); + assert_eq!(result["ok"], true); + let mut current_identity = old_identity.clone(); + current_identity.branch_id.clear(); // Existing typed head API selects durable current_branch_id. + let current = persistence.managed_workspace_head(¤t_identity).unwrap(); + let retired = persistence.managed_workspace_head(&old_identity).unwrap(); + assert_eq!(current.branch_id, receipt.new_branch.branch_id); + assert_eq!(current.head_object_id, checkpoint.checkpoint.frozen_root.object_id); + assert_eq!(retired.head_object_id, head_b.head_object_id); + assert_eq!(retired.epoch, receipt.source_after.epoch); + let runs = vec![a, b, receipt.new_run_id.clone()]; + let facts = restart_run_facts(&observed, &runs); + let effects_request = ManagedEffectListRequest {workspace_id:old_identity.workspace_id.clone(),after_effect_id:None,limit:100}; + let effects = persistence.managed_effect_list(&effects_request).unwrap(); + assert_eq!(effects.entries.len(), 3, "A/B writes and restored A read have real durable results"); + let read = persistence.managed_effect_get(&read_request).unwrap(); + println!("restart pre-close receipt={} old_runs={:?} checkpoint={}", serde_json::to_string(&receipt).unwrap(), &runs[..2], serde_json::to_string(&checkpoint).unwrap()); + (restore_path, body, receipt, checkpoint, old_identity, current_identity, current, retired, runs, facts, effects_request, effects, read_request, read) + }).await; + let cleanup = server.close(&service).await; + drop(service); + drop(state); + let ( + path, + body, + receipt, + checkpoint, + old_identity, + current_identity, + current, + retired, + runs, + facts, + effects_request, + effects, + read_request, + read, + ) = match outcome { + Ok(facts) => facts, + Err(error) => std::panic::resume_unwind(error.into_panic()), + }; + cleanup.expect("first shutdown joins service janitor/storage after worker occupancy release"); + let state = AgentGatewayState::with_sqlite_path(config, root.join("gateway.sqlite")) + .expect("same configured root reopens after actual shutdown and owner release") + .with_workspace_home(root.join("home")); + let service = state.service(); + let no_provider = Arc::new(ScriptedProvider::new()); + service.inject_provider_host(no_provider.clone()); + let server = RestartServer::start(state.clone()).await; + let address = server.address; + let observed = state.clone(); + let calls_before = provider.requests().len(); + let outcome = tokio::spawn(async move { + let service = observed.service(); + let persistence = observed.persistence().unwrap(); + let loaded_facts = restart_run_facts(&observed, &runs); + // Keep the full comparison below, after independently exercising exact + // replay and old-run refusal, so an event mismatch does not hide them. + for (loaded, original) in loaded_facts.as_array().unwrap().iter().zip(facts.as_array().unwrap()) { + for field in ["run", "metadata", "limits", "history"] { + assert_eq!(loaded[field], original[field], "reopen preserves {field}"); + } + } + assert_eq!(persistence.managed_workspace_head(&old_identity).unwrap(), retired); + assert_eq!(persistence.managed_workspace_head(¤t_identity).unwrap(), current); + assert_eq!(current.head_object_id, checkpoint.checkpoint.frozen_root.object_id); + let admissions_before = service.metrics().snapshot().admissions_accepted; + let (status, replay) = request(address, &path, body).await; + assert_eq!(status, 202, "formal exact retry after reopen: {replay}"); + let mut replay: ManagedRestoreAndAdmitResult = serde_json::from_value(replay).unwrap(); + assert!(replay.replayed); + replay.replayed = receipt.replayed; + assert_eq!(replay, receipt, "exact retry consumes original proof/receipt, without new admission or deadline"); + for old in &runs[..2] { + service.clone().run_worker(old.clone(), "public id and frozen metadata grant no continuation".into()).await; + } + let after_retry_facts = restart_run_facts(&observed, &runs); + assert_eq!(after_retry_facts, loaded_facts, "exact retry and old-run refusal change no loaded facts"); + assert_eq!(service.metrics().snapshot().admissions_accepted, admissions_before, "exact retry creates no new admission"); + assert_eq!(persistence.managed_effect_list(&effects_request).unwrap(), effects); + assert_eq!(persistence.managed_effect_get(&read_request).unwrap(), read); + assert_eq!(persistence.managed_workspace_head(&old_identity).unwrap(), retired); + assert_eq!(persistence.managed_workspace_head(¤t_identity).unwrap(), current); + assert!(no_provider.requests().is_empty(), "neither retry nor old run invokes a provider"); + println!("restart reopened: exact receipt/current/retired/frozen root, run facts/history/resultrefs unchanged; old-run provider calls=0; new deadline={}", receipt.new_run_deadline_at_ms); + for (loaded, original) in loaded_facts.as_array().unwrap().iter().zip(facts.as_array().unwrap()) { + for (after, before) in loaded["events"].as_array().unwrap().iter().zip(original["events"].as_array().unwrap()) { + if after != before { + println!("restart event mismatch={}", json!({"run":loaded["run"]["rows"][0][0],"before":before,"after":after})); + } + } + } + assert_eq!(loaded_facts, facts, "load preserves terminal status/events/history and original clocks"); + assert_eq!(after_retry_facts, facts); + }).await; + let cleanup = server.close(&service).await; + assert_eq!( + provider.requests().len(), + calls_before, + "source provider cannot be reused after reopen" + ); + if let Err(error) = outcome { + std::panic::resume_unwind(error.into_panic()); + } + cleanup.expect("first reopened shutdown joins all owned workers"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn active_run_reopen_is_gateway_restart_not_a_deadline_execution() { + let (root, config, state) = restart_fixture(); + let service = state.service(); + let run = service + .admit_external_with_workspace_selector( + rustscript_agent::AdmitRunRequest { + input: json!({"message":"active restart characterization"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + let metadata = service.run_context(&run.run_id).unwrap().metadata; + service.halt(); + service + .shutdown() + .await + .expect("first shutdown joins janitor and storage with no run worker"); + drop(service); + drop(state); + let state = AgentGatewayState::with_sqlite_path(config, root.join("gateway.sqlite")) + .unwrap() + .with_workspace_home(root.join("home")); + let service = state.service(); + let persistence = state.persistence().unwrap(); + let row = persistence.run_get(&run.run_id).unwrap(); + let columns = row["columns"].as_array().unwrap(); + let values = &row["rows"][0]; + let cell = + |name: &str| values[columns.iter().position(|column| column == name).unwrap()].clone(); + assert_eq!(cell("status"), "failed"); + assert_eq!(cell("error_code"), "gateway_restart"); + assert_eq!(service.run_context(&run.run_id).unwrap().metadata, metadata); + let provider = Arc::new(ScriptedProvider::new()); + service.inject_provider_host(provider.clone()); + service + .clone() + .run_worker( + run.run_id.clone(), + "public id cannot resume after load".into(), + ) + .await; + assert!(provider.requests().is_empty()); + assert_eq!(persistence.run_get(&run.run_id).unwrap(), row); + assert!(service.handle(&run.run_id).is_none()); + println!( + "formal active reopen: failed/gateway_restart; original deadline={} unchanged; no handle/provider; separate from clock negative", + metadata["deadline_at_ms"] + ); + service.shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn managed_branch_from_checkpoint_preserves_source() { + let root = PathBuf::from(std::env::var_os("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()) + .join(format!("managed-branch-{}", uuid::Uuid::new_v4())); + let workspace = root.join("workspace"); + let home = root.join("home"); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::create_dir(&home).unwrap(); + use std::os::unix::fs::PermissionsExt; + for directory in [&root, &workspace, &home] { + std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + std::fs::write( + home.join("config.yaml"), + format!( + "version: 1\nmodel:\n provider: local-test\n model: synthetic\nproviders:\n local-test:\n protocol: codex-responses\n base_url: https://127.0.0.1:1\nworkspaces:\n allowed_roots:\n - '{}'\n default: '{}'\n", + workspace.display(), + workspace.display() + ), + ) + .unwrap(); + let mut config = AgentGatewayConfig { + bearer_token: Some("synthetic-maintenance-fixture".into()), + max_concurrent_runs: 1, + managed_workspace: Some(ManagedWorkspaceConfig::for_workspace(&workspace).unwrap()), + ..Default::default() + }; + config.approvals.write = "allow".into(); + config + .managed_workspace + .as_mut() + .unwrap() + .maintenance_policy + .create_checkpoint = true; + config + .managed_workspace + .as_mut() + .unwrap() + .maintenance_policy + .branch = true; + let state = AgentGatewayState::with_agent_selection_and_compaction( + config, + json!({}), + Some(&root.join("gateway.sqlite")), + rustscript_agent::config_file::CompactionSettings::default(), + ) + .unwrap() + .with_workspace_home(&home); + let service = state.service(); + service + .set_provider_profile( + rustscript_agent::config::ProviderProfile::new( + "frozen-profile", + json!({"protocol":"provider","temperature":0.25,"max_output_tokens":1024}), + ) + .unwrap(), + ) + .unwrap(); + let provider = Arc::new(ScriptedProvider::new()); + provider.push_ok(json!({"text":"", "tool_calls":[{"id":"write-a", "name":"write_file", "arguments":{"path":"value.txt", "content":"A"}}], "usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}, "reasoning":"", "stop_reason":"tool_calls"})); + provider.push_ok(json!({"text":"closed A", "tool_calls":[], "usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}, "reasoning":"", "stop_reason":"stop"})); + service.inject_provider_host(provider); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (shutdown, stopped) = tokio::sync::oneshot::channel(); + let app = build_agent_gateway_app(state.clone()); + let server = tokio::spawn(async move { + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(async move { + let _ = stopped.await; + }) + .await + }); + let observed = Arc::clone(&service); + let persistence = state.persistence().unwrap(); + let outcome = tokio::spawn(async move { + let session_id = uuid::Uuid::new_v4().to_string(); + let (status, _) = request(address, "/api/sessions", json!({"id": session_id})).await; + assert_eq!(status, 201, "original HTTP session creation"); + let (status, run) = request( + address, + "/v1/runs", + json!({"input":"write A", "session_id": session_id, "provider":"frozen-profile"}), + ) + .await; + assert_eq!(status, 202, "original HTTP admission: {run}"); + let run_id = run["run_id"].as_str().unwrap().to_string(); + tokio::time::timeout(Duration::from_secs(20), async { + loop { + if observed + .run_events(&run_id) + .iter() + .any(|event| event["event"] == "run.completed") + { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("actual worker completes Native write"); + let record = persistence + .managed_effect_get(&ManagedEffectGetRequest { + owner: ManagedOwnerScope { + profile: "gateway".into(), + session_id: session_id.clone(), + run_id: run_id.clone(), + }, + call_id: "write-a".into(), + }) + .unwrap() + .unwrap(); + let head = persistence + .managed_workspace_head(&record.identity.workspace) + .unwrap(); + let workspace_id = record.identity.workspace.workspace_id.clone(); + let source_branch_id = record.identity.workspace.branch_id.clone(); + let messages_before = persistence.message_list(&session_id, 0).unwrap(); + let (status, checkpoint) = request( + address, + &format!("/v1/workspaces/{workspace_id}/checkpoints"), + json!({ + "source_run_id": run_id, + "workspace_id": workspace_id, + "source_branch_id": source_branch_id, + "expected_epoch": record.identity.epoch, + "request_id": uuid::Uuid::new_v4().to_string(), + "mode": "at_closed_batch" + }), + ) + .await; + assert_eq!(status, 201, "checkpoint before branch: {checkpoint}"); + let checkpoint_id = checkpoint["checkpoint"]["checkpoint_id"] + .as_str() + .unwrap() + .to_string(); + let frozen_root = checkpoint["checkpoint"]["frozen_root"]["object_id"] + .as_str() + .unwrap() + .to_string(); + assert_eq!(frozen_root, head.head_object_id); + let branch_request = json!({ + "workspace_id": workspace_id, + "source_branch_id": source_branch_id, + "checkpoint_id": checkpoint_id, + "request_id": uuid::Uuid::new_v4().to_string(), + "expected_source": { + "store_id": head.store_id, + "workspace_id": workspace_id, + "branch_id": source_branch_id, + "epoch": head.epoch, + "head_object_id": head.head_object_id, + "head_version": head.head_version + } + }); + let (status, created) = request( + address, + &format!("/v1/workspaces/{workspace_id}/branches"), + branch_request.clone(), + ) + .await; + assert_eq!( + status, 201, + "non-destructive branch must be created over real TCP, not left at the missing route: {created}" + ); + assert!(created.get("new_run_id").is_none(), "branch must not create a run"); + assert!( + created.get("new_session_id").is_none(), + "branch must not create a session" + ); + assert_eq!(created["source"]["branch_id"], source_branch_id); + assert_eq!(created["source"]["epoch"], head.epoch); + assert_eq!(created["source"]["head_object_id"], head.head_object_id); + assert_eq!(created["source"]["head_version"], head.head_version); + assert_ne!(created["branch"]["branch_id"], source_branch_id); + assert_eq!(created["branch"]["epoch"], head.epoch + 1); + assert_eq!(created["branch"]["head_object_id"], frozen_root); + assert_eq!(created["branch"]["head_version"], 1); + assert_eq!(created["checkpoint_id"], checkpoint_id); + assert_eq!(created["replayed"], false); + let (status, replay) = request( + address, + &format!("/v1/workspaces/{workspace_id}/branches"), + branch_request, + ) + .await; + assert_eq!(status, 201, "same proof must replay the durable receipt: {replay}"); + assert_eq!(replay["replayed"], true); + assert_eq!(replay["branch"]["branch_id"], created["branch"]["branch_id"]); + assert_eq!(replay["branch"]["epoch"], created["branch"]["epoch"]); + assert_eq!(replay["checkpoint_id"], checkpoint_id); + assert!(replay.get("new_run_id").is_none(), "replay must not create a run"); + let source_after = persistence + .managed_workspace_head(&record.identity.workspace) + .unwrap(); + assert_eq!(source_after.epoch, head.epoch); + assert_eq!(source_after.head_object_id, head.head_object_id); + assert_eq!(source_after.head_version, head.head_version); + assert_eq!(source_after.branch_id, source_branch_id); + assert_eq!(persistence.message_list(&session_id, 0).unwrap(), messages_before); + assert!(!workspace.join("value.txt").exists()); + }) + .await; + let _ = shutdown.send(()); + tokio::time::timeout(Duration::from_secs(5), server) + .await + .unwrap() + .unwrap() + .unwrap(); + service.halt(); + tokio::time::timeout(Duration::from_secs(10), async { + loop { + match service.shutdown().await { + Ok(()) => break, + Err(_) => tokio::time::sleep(Duration::from_millis(10)).await, + } + } + }) + .await + .expect("owned worker and storage shutdown"); + drop(std::net::TcpListener::bind(address).expect("owned listener released")); + if let Err(error) = outcome { + std::panic::resume_unwind(error.into_panic()); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn managed_branch_run_uses_selected_branch() { + let root = PathBuf::from(std::env::var_os("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()) + .join(format!("managed-branch-run-{}", uuid::Uuid::new_v4())); + let workspace = root.join("workspace"); + let home = root.join("home"); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::create_dir(&home).unwrap(); + use std::os::unix::fs::PermissionsExt; + for directory in [&root, &workspace, &home] { + std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + std::fs::write( + home.join("config.yaml"), + format!( + "version: 1\nmodel:\n provider: local-test\n model: synthetic\nproviders:\n local-test:\n protocol: codex-responses\n base_url: https://127.0.0.1:1\nworkspaces:\n allowed_roots:\n - '{}'\n default: '{}'\n", + workspace.display(), + workspace.display() + ), + ) + .unwrap(); + let mut config = AgentGatewayConfig { + bearer_token: Some("synthetic-maintenance-fixture".into()), + max_concurrent_runs: 1, + managed_workspace: Some(ManagedWorkspaceConfig::for_workspace(&workspace).unwrap()), + ..Default::default() + }; + config.approvals.write = "allow".into(); + config + .managed_workspace + .as_mut() + .unwrap() + .maintenance_policy + .create_checkpoint = true; + config + .managed_workspace + .as_mut() + .unwrap() + .maintenance_policy + .branch = true; + let state = AgentGatewayState::with_agent_selection_and_compaction( + config, + json!({}), + Some(&root.join("gateway.sqlite")), + rustscript_agent::config_file::CompactionSettings::default(), + ) + .unwrap() + .with_workspace_home(&home); + let service = state.service(); + service + .set_provider_profile( + rustscript_agent::config::ProviderProfile::new( + "frozen-profile", + json!({"protocol":"provider","temperature":0.25,"max_output_tokens":1024}), + ) + .unwrap(), + ) + .unwrap(); + let provider = Arc::new(ScriptedProvider::new()); + provider.push_ok(json!({"text":"", "tool_calls":[{"id":"write-a", "name":"write_file", "arguments":{"path":"value.txt", "content":"A"}}], "usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}, "reasoning":"", "stop_reason":"tool_calls"})); + provider.push_ok(json!({"text":"closed A", "tool_calls":[], "usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}, "reasoning":"", "stop_reason":"stop"})); + let branch_provider = Arc::clone(&provider); + branch_provider.push_ok(json!({"text":"", "tool_calls":[{"id":"read-a", "name":"read_file", "arguments":{"path":"value.txt"}}, {"id":"write-c", "name":"write_file", "arguments":{"path":"branch-only.txt", "content":"C"}}], "usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}, "reasoning":"", "stop_reason":"tool_calls"})); + branch_provider.push_ok(json!({"text":"closed C", "tool_calls":[], "usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}, "reasoning":"", "stop_reason":"stop"})); + service.inject_provider_host(provider); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (shutdown, stopped) = tokio::sync::oneshot::channel(); + let app = build_agent_gateway_app(state.clone()); + let server = tokio::spawn(async move { + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(async move { + let _ = stopped.await; + }) + .await + }); + let observed = Arc::clone(&service); + let persistence = state.persistence().unwrap(); + let outcome = tokio::spawn(async move { + let session_id = uuid::Uuid::new_v4().to_string(); + let (status, _) = request(address, "/api/sessions", json!({"id": session_id})).await; + assert_eq!(status, 201); + let (status, run) = request( + address, + "/v1/runs", + json!({"input":"write A", "session_id": session_id, "provider":"frozen-profile"}), + ) + .await; + assert_eq!(status, 202, "source admission: {run}"); + let run_id = run["run_id"].as_str().unwrap().to_string(); + tokio::time::timeout(Duration::from_secs(20), async { + loop { + if observed + .run_events(&run_id) + .iter() + .any(|event| event["event"] == "run.completed") + { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("source run completes"); + let record = persistence + .managed_effect_get(&ManagedEffectGetRequest { + owner: ManagedOwnerScope { + profile: "gateway".into(), + session_id: session_id.clone(), + run_id: run_id.clone(), + }, + call_id: "write-a".into(), + }) + .unwrap() + .unwrap(); + let workspace_id = record.identity.workspace.workspace_id.clone(); + let source_branch_id = record.identity.workspace.branch_id.clone(); + let head = persistence + .managed_workspace_head(&record.identity.workspace) + .unwrap(); + let (status, checkpoint) = request( + address, + &format!("/v1/workspaces/{workspace_id}/checkpoints"), + json!({ + "source_run_id": run_id, + "workspace_id": workspace_id, + "source_branch_id": source_branch_id, + "expected_epoch": record.identity.epoch, + "request_id": uuid::Uuid::new_v4().to_string(), + "mode": "at_closed_batch" + }), + ) + .await; + assert_eq!(status, 201, "checkpoint: {checkpoint}"); + let checkpoint_id = checkpoint["checkpoint"]["checkpoint_id"] + .as_str() + .unwrap() + .to_string(); + let graph_id = checkpoint["checkpoint"]["graph"]["graph_id"] + .as_str() + .unwrap() + .to_string(); + let branch_request = json!({ + "workspace_id": workspace_id, + "source_branch_id": source_branch_id, + "checkpoint_id": checkpoint_id, + "request_id": uuid::Uuid::new_v4().to_string(), + "expected_source": { + "store_id": head.store_id, + "workspace_id": workspace_id, + "branch_id": source_branch_id, + "epoch": head.epoch, + "head_object_id": head.head_object_id, + "head_version": head.head_version + } + }); + let (status, created) = request( + address, + &format!("/v1/workspaces/{workspace_id}/branches"), + branch_request.clone(), + ) + .await; + assert_eq!(status, 201, "branch: {created}"); + let selected_branch = created["branch"]["branch_id"].as_str().unwrap().to_string(); + let unknown = format!("br-{}", "ab".repeat(16)); + let (status, missing) = request( + address, + "/v1/runs", + json!({ + "input": "unknown branch must not grant", + "session_id": session_id, + "provider": "frozen-profile", + "workspace_branch": unknown, + "graph_id": graph_id + }), + ) + .await; + assert_eq!(status, 404, "unknown branch id must not grant: {missing}"); + assert_eq!(missing["error"]["code"], "managed_branch_missing"); + observed.inject_provider_host(branch_provider); + let (status, branch_run) = request( + address, + "/v1/runs", + json!({ + "input": "write C on the selected branch", + "session_id": session_id, + "provider": "frozen-profile", + "workspace_branch": selected_branch, + "graph_id": graph_id + }), + ) + .await; + assert_eq!( + status, 202, + "selected branch admission must be accepted: {branch_run}" + ); + assert_eq!( + branch_run["workspace_branch"], selected_branch, + "typed workspace_branch must be the admitted branch: {branch_run}" + ); + assert_eq!( + branch_run["head_object_id"], + created["branch"]["head_object_id"] + ); + assert_eq!( + branch_run["head_version"], + created["branch"]["head_version"] + ); + assert_eq!(branch_run["graph_id"], graph_id); + assert!(branch_run["publication"].is_null()); + let mut conflict = branch_request.clone(); + conflict["expected_source"]["epoch"] = json!(head.epoch.saturating_add(9)); + let (status, conflicted) = request( + address, + &format!("/v1/workspaces/{workspace_id}/branches"), + conflict, + ) + .await; + assert_eq!(status, 409, "different proof must conflict: {conflicted}"); + assert_eq!(conflicted["error"]["code"], "managed_request_conflict"); + let mut stale = branch_request.clone(); + stale["request_id"] = json!(uuid::Uuid::new_v4().to_string()); + stale["expected_source"]["head_object_id"] = json!(format!("obj-{}", "cd".repeat(16))); + let (status, stale_body) = request( + address, + &format!("/v1/workspaces/{workspace_id}/branches"), + stale, + ) + .await; + assert_eq!( + status, 409, + "current head CAS must reject a stale selector: {stale_body}" + ); + assert_eq!(stale_body["error"]["code"], "managed_source_conflict"); + let branch_run_id = branch_run["run_id"].as_str().unwrap().to_string(); + tokio::time::timeout(Duration::from_secs(20), async { + loop { + if observed + .run_events(&branch_run_id) + .iter() + .any(|event| event["event"] == "run.completed") + { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("selected branch run completes"); + let branch_write = persistence + .managed_effect_get(&ManagedEffectGetRequest { + owner: ManagedOwnerScope { + profile: "gateway".into(), + session_id: session_id.clone(), + run_id: branch_run_id.clone(), + }, + call_id: "write-c".into(), + }) + .unwrap() + .unwrap(); + assert_eq!(branch_write.identity.workspace.branch_id, selected_branch); + assert_ne!(branch_write.identity.workspace.branch_id, source_branch_id); + let read_request = ManagedEffectGetRequest { + owner: ManagedOwnerScope { + profile: "gateway".into(), + session_id: session_id.clone(), + run_id: branch_run_id, + }, + call_id: "read-a".into(), + }; + let rustscript_agent::effects::ManagedCallReplay::Final { + canonical_result, .. + } = observed.replay_managed_call(read_request).unwrap() + else { + panic!("selected branch must read the checkpoint tree"); + }; + let read_result: Value = serde_json::from_str(canonical_result.as_str()).unwrap(); + assert_eq!(read_result["content"], "A"); + let source_after = persistence + .managed_workspace_head(&record.identity.workspace) + .unwrap(); + assert_eq!(source_after.branch_id, source_branch_id); + assert_eq!(source_after.epoch, head.epoch); + assert_eq!(source_after.head_object_id, head.head_object_id); + assert!(!workspace.join("branch-only.txt").exists()); + assert!(!workspace.join("value.txt").exists()); + let mut selected_identity = record.identity.workspace.clone(); + selected_identity.branch_id = selected_branch; + let selected_head = persistence + .managed_workspace_head(&selected_identity) + .unwrap(); + assert_ne!(selected_head.head_object_id, source_after.head_object_id); + }) + .await; + let _ = shutdown.send(()); + tokio::time::timeout(Duration::from_secs(5), server) + .await + .unwrap() + .unwrap() + .unwrap(); + service.halt(); + tokio::time::timeout(Duration::from_secs(10), async { + loop { + match service.shutdown().await { + Ok(()) => break, + Err(_) => tokio::time::sleep(Duration::from_millis(10)).await, + } + } + }) + .await + .expect("owned worker and storage shutdown"); + drop(std::net::TcpListener::bind(address).expect("owned listener released")); + if let Err(error) = outcome { + std::panic::resume_unwind(error.into_panic()); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn explicit_workspace_publish_is_a_durable_receipt_not_an_effect_commit() { + let root = PathBuf::from(std::env::var_os("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()) + .join(format!("managed-publish-{}", uuid::Uuid::new_v4())); + let workspace = root.join("workspace"); + let home = root.join("home"); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::create_dir(&home).unwrap(); + use std::os::unix::fs::PermissionsExt; + for directory in [&root, &workspace, &home] { + std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + std::fs::write( + home.join("config.yaml"), + format!( + "version: 1\nmodel:\n provider: local-test\n model: synthetic\nproviders:\n local-test:\n protocol: codex-responses\n base_url: https://127.0.0.1:1\nworkspaces:\n allowed_roots:\n - '{}'\n default: '{}'\n", + workspace.display(), + workspace.display() + ), + ) + .unwrap(); + let mut config = AgentGatewayConfig { + bearer_token: Some("synthetic-maintenance-fixture".into()), + max_concurrent_runs: 1, + managed_workspace: Some(ManagedWorkspaceConfig::for_workspace(&workspace).unwrap()), + ..Default::default() + }; + config.approvals.write = "allow".into(); + config + .managed_workspace + .as_mut() + .unwrap() + .maintenance_policy + .publish = true; + let reopened_config = config.clone(); + let state = AgentGatewayState::with_agent_selection_and_compaction( + config, + json!({}), + Some(&root.join("gateway.sqlite")), + rustscript_agent::config_file::CompactionSettings::default(), + ) + .unwrap() + .with_workspace_home(&home); + let service = state.service(); + service + .set_provider_profile( + rustscript_agent::config::ProviderProfile::new( + "frozen-profile", + json!({"protocol":"provider","temperature":0.25,"max_output_tokens":1024}), + ) + .unwrap(), + ) + .unwrap(); + let provider = Arc::new(ScriptedProvider::new()); + provider.push_ok(json!({"text":"", "tool_calls":[{"id":"write-a", "name":"write_file", "arguments":{"path":"value.txt", "content":"A"}}], "usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}, "reasoning":"", "stop_reason":"tool_calls"})); + provider.push_ok(json!({"text":"closed A", "tool_calls":[], "usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}, "reasoning":"", "stop_reason":"stop"})); + service.inject_provider_host(provider.clone()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (shutdown, stopped) = tokio::sync::oneshot::channel(); + let app = build_agent_gateway_app(state.clone()); + let server = tokio::spawn(async move { + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(async move { + let _ = stopped.await; + }) + .await + }); + let observed = Arc::clone(&service); + let persistence = state.persistence().unwrap(); + let workspace_path = workspace.clone(); + let database = root.join("gateway.sqlite"); + let outcome = tokio::spawn(async move { + let (status, anonymous) = post( + address, + "/v1/workspaces/ws-00000000000000000000000000000001/publish", + json!({ + "workspace_id": "ws-00000000000000000000000000000001", + "branch_id": "br-00000000000000000000000000000001", + "request_id": uuid::Uuid::new_v4().to_string(), + "expected_head": { + "workspace_id": "ws-00000000000000000000000000000001", + "branch_id": "br-00000000000000000000000000000001", + "epoch": 1, + "head_object_id": format!("obj-{}", "ab".repeat(16)), + "head_version": 1, + "store_id": format!("store-{}", "ab".repeat(16)), + "schema_version": 1, + "process_lineage_tainted": false + } + }), + None, + ) + .await; + assert_eq!( + status, 401, + "missing bearer stays unauthorized: {anonymous}" + ); + let session_id = uuid::Uuid::new_v4().to_string(); + let (status, _) = request(address, "/api/sessions", json!({"id": session_id})).await; + assert_eq!(status, 201, "original HTTP session creation"); + let (status, run) = request( + address, + "/v1/runs", + json!({"input":"write A", "session_id": session_id, "provider":"frozen-profile"}), + ) + .await; + assert_eq!(status, 202, "original HTTP admission: {run}"); + assert!( + run["publication"].is_null(), + "run projection is not a publication" + ); + let run_id = run["run_id"].as_str().unwrap().to_string(); + wait_terminal(&observed, &run_id).await; + let record = persistence + .managed_effect_get(&ManagedEffectGetRequest { + owner: ManagedOwnerScope { + profile: "gateway".into(), + session_id: session_id.clone(), + run_id: run_id.clone(), + }, + call_id: "write-a".into(), + }) + .unwrap() + .unwrap(); + let head = persistence + .managed_workspace_head(&record.identity.workspace) + .unwrap(); + let workspace_id = record.identity.workspace.workspace_id.clone(); + let branch_id = record.identity.workspace.branch_id.clone(); + let publish_path = format!("/v1/workspaces/{workspace_id}/publish"); + let request_id = uuid::Uuid::new_v4().to_string(); + let body = json!({ + "workspace_id": workspace_id, + "branch_id": branch_id, + "request_id": request_id, + "expected_head": head + }); + let (status, foreign) = request( + address, + "/v1/workspaces/ws-00000000000000000000000000000001/publish", + body.clone(), + ) + .await; + assert_eq!(status, 403, "path/body workspace mismatch: {foreign}"); + assert_eq!(foreign["error"]["code"], "managed_authority_required"); + assert!( + !foreign + .to_string() + .contains(&workspace_path.display().to_string()) + ); + let mut missing_head = body["expected_head"].clone(); + missing_head["branch_id"] = json!("br-00000000000000000000000000000001"); + let (status, missing) = request( + address, + &publish_path, + json!({ + "workspace_id": workspace_id, + "branch_id": "br-00000000000000000000000000000001", + "request_id": uuid::Uuid::new_v4().to_string(), + "expected_head": missing_head + }), + ) + .await; + assert_eq!(status, 404, "missing branch: {missing}"); + assert_eq!(missing["error"]["code"], "managed_branch_missing"); + assert!( + !missing + .to_string() + .contains(&workspace_path.display().to_string()) + ); + let mut extra = body.clone(); + extra["host_path"] = json!("/tmp/not-a-grant"); + let (status, rejected) = request(address, &publish_path, extra).await; + assert_eq!(status, 422, "host path is not a publish grant: {rejected}"); + assert_eq!(rejected["error"]["code"], "managed_request_invalid"); + let messages_before = persistence.message_list(&session_id, 0).unwrap(); + let (status, published) = request(address, &publish_path, body.clone()).await; + assert_eq!( + status, 200, + "explicit publish commits a receipt: {published}" + ); + assert_eq!(published["status"], "committed"); + assert_eq!(published["workspace_id"], workspace_id); + assert_eq!(published["branch_id"], branch_id); + assert_eq!(published["epoch"], json!(head.epoch)); + assert_eq!(published["head_object_id"], head.head_object_id); + assert_eq!(published["head_version"], json!(head.head_version)); + assert!(published["previous_published_head"].is_null()); + assert_eq!(published["request_id"], request_id); + assert!(published["published_at_ms"].as_i64().unwrap_or(0) > 0); + assert!( + !published + .to_string() + .contains(&workspace_path.display().to_string()) + ); + let pointer = persistence + .managed_workspace_publication(&workspace_id, "gateway") + .unwrap() + .expect("typed publication getter"); + assert_eq!(pointer.head_object_id, head.head_object_id); + assert_eq!(pointer.head_version, head.head_version); + assert_eq!(pointer.epoch, head.epoch); + assert_eq!(pointer.request_id, request_id); + assert!(pointer.previous_published_head.is_none()); + let head_after = persistence + .managed_workspace_head(&record.identity.workspace) + .unwrap(); + assert_eq!(head_after, head, "publish must not move the branch head"); + assert_eq!( + persistence.message_list(&session_id, 0).unwrap(), + messages_before + ); + assert!(!workspace_path.join("value.txt").exists()); + let (status, replay) = request(address, &publish_path, body.clone()).await; + assert_eq!(status, 200, "same proof replays the receipt: {replay}"); + assert_eq!(replay, published); + let mut conflict = body.clone(); + conflict["expected_head"]["epoch"] = json!(head.epoch.saturating_add(1)); + let (status, conflicted) = request(address, &publish_path, conflict).await; + assert_eq!(status, 409, "different proof conflicts: {conflicted}"); + assert_eq!(conflicted["error"]["code"], "managed_request_conflict"); + let next = Arc::new(ScriptedProvider::new()); + next.push_ok(json!({"text":"", "tool_calls":[{"id":"write-c", "name":"write_file", "arguments":{"path":"other.txt", "content":"C"}}], "usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}, "reasoning":"", "stop_reason":"tool_calls"})); + next.push_ok(json!({"text":"closed C", "tool_calls":[], "usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}, "reasoning":"", "stop_reason":"stop"})); + observed.inject_provider_host(next); + let (status, advanced_run) = request( + address, + "/v1/runs", + json!({"input":"write C", "session_id": session_id, "provider":"frozen-profile"}), + ) + .await; + assert_eq!(status, 202, "head-advancing admission: {advanced_run}"); + assert!( + advanced_run["publication"].is_null(), + "a later run is not the publication" + ); + let advanced_id = advanced_run["run_id"].as_str().unwrap().to_string(); + wait_terminal(&observed, &advanced_id).await; + let advanced_head = persistence + .managed_workspace_head(&record.identity.workspace) + .unwrap(); + assert_ne!( + ( + advanced_head.head_object_id.as_str(), + advanced_head.head_version + ), + (head.head_object_id.as_str(), head.head_version), + "the later write must move the branch head before the stale publish" + ); + let (status, replay_after) = request(address, &publish_path, body.clone()).await; + assert_eq!( + status, 200, + "replay stays immutable after the head moves: {replay_after}" + ); + assert_eq!(replay_after, published); + let still = persistence + .managed_workspace_publication(&workspace_id, "gateway") + .unwrap() + .unwrap(); + assert_eq!(still.head_object_id, head.head_object_id); + let mut stale = body.clone(); + stale["request_id"] = json!(uuid::Uuid::new_v4().to_string()); + let (status, stale_body) = request(address, &publish_path, stale).await; + assert_eq!(status, 409, "old head is not republished: {stale_body}"); + assert_eq!(stale_body["error"]["code"], "managed_source_conflict"); + assert_eq!( + persistence + .managed_workspace_publication(&workspace_id, "gateway") + .unwrap() + .unwrap() + .head_object_id, + head.head_object_id + ); + provider.push_hang(); + let (status, busy_run) = request( + address, + "/v1/runs", + json!({"input":"hold the writer", "session_id": session_id, "provider":"frozen-profile"}), + ) + .await; + assert_eq!(status, 202, "busy writer admission: {busy_run}"); + let mut current = body.clone(); + current["request_id"] = json!(uuid::Uuid::new_v4().to_string()); + current["expected_head"] = json!(advanced_head); + tokio::time::timeout(Duration::from_secs(10), async { + loop { + let (status, busy) = request(address, &publish_path, current.clone()).await; + if status == 409 && busy["error"]["code"] == "managed_checkpoint_busy" { + return; + } + assert_ne!(status, 200, "active run must not publish: {busy}"); + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("active writer blocks publication"); + (workspace_id, body, published) + }); + let (workspace_id, body, published) = match outcome.await { + Ok(value) => value, + Err(error) => std::panic::resume_unwind(error.into_panic()), + }; + let _ = shutdown.send(()); + tokio::time::timeout(Duration::from_secs(5), server) + .await + .unwrap() + .unwrap() + .unwrap(); + service.halt(); + tokio::time::timeout(Duration::from_secs(10), async { + loop { + match service.shutdown().await { + Ok(()) => break, + Err(_) => tokio::time::sleep(Duration::from_millis(10)).await, + } + } + }) + .await + .expect("owned worker and storage shutdown"); + drop(std::net::TcpListener::bind(address).expect("owned listener released")); + drop(service); + drop(state); + let reopened = AgentGatewayState::with_sqlite_path(reopened_config, &database) + .unwrap() + .with_workspace_home(&home); + let reopened_service = reopened.service(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (shutdown, stopped) = tokio::sync::oneshot::channel(); + let app = build_agent_gateway_app(reopened.clone()); + let server = tokio::spawn(async move { + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(async move { + let _ = stopped.await; + }) + .await + }); + let persistence = reopened.persistence().unwrap(); + let replayed = request( + address, + &format!("/v1/workspaces/{workspace_id}/publish"), + body, + ) + .await; + assert_eq!( + replayed.0, 200, + "reopened same proof returns the original receipt: {}", + replayed.1 + ); + assert_eq!(replayed.1, published); + let pointer = persistence + .managed_workspace_publication(&workspace_id, "gateway") + .unwrap() + .unwrap(); + assert_eq!(pointer.request_id, published["request_id"]); + assert_eq!(pointer.head_object_id, published["head_object_id"]); + let _ = shutdown.send(()); + tokio::time::timeout(Duration::from_secs(5), server) + .await + .unwrap() + .unwrap() + .unwrap(); + reopened_service.halt(); + reopened_service.shutdown().await.unwrap(); +} + +async fn wait_terminal(service: &rustscript_agent::AgentService, run_id: &str) { + let run_id = run_id.to_string(); + tokio::time::timeout(Duration::from_secs(20), async move { + loop { + if service.run_events(&run_id).iter().any(|event| { + matches!( + event["event"].as_str(), + Some("run.completed" | "run.failed" | "run.cancelled") + ) + }) { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("run reaches a terminal event"); +} diff --git a/tests/plugin_admin_api_tests.rs b/tests/plugin_admin_api_tests.rs new file mode 100644 index 0000000..844d105 --- /dev/null +++ b/tests/plugin_admin_api_tests.rs @@ -0,0 +1,554 @@ +//! Plugin graph control through the production Axum app on real TCP. +//! Host binding and the verified owner credential authorize these routes. +//! Request JSON, graph ids, signatures, paths, and run identifiers do not. +use bytes::Bytes; +use http_body_util::{BodyExt, Full}; +use rustscript_agent::build_agent_gateway_app; +use rustscript_agent::plugins::PluginControlStore; +use rustscript_agent::{AgentGatewayConfig, AgentGatewayState, GatewayPersistence}; +use serde_json::{Value, json}; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::time::Duration; + +struct Http { + address: SocketAddr, + shutdown: tokio::sync::oneshot::Sender<()>, + server: tokio::task::JoinHandle>, +} + +impl Http { + async fn bind(state: AgentGatewayState) -> Self { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (shutdown, stopped) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + axum::serve( + listener, + build_agent_gateway_app(state).into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(async move { + let _ = stopped.await; + }) + .await + }); + Self { + address, + shutdown, + server, + } + } + + async fn request( + &self, + method: &str, + path: &str, + body: Value, + token: Option<&str>, + ) -> (u16, Value, String) { + let client: hyper_util::client::legacy::Client<_, Full> = + hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new()) + .build_http(); + let mut request = hyper::Request::builder() + .method(method) + .uri(format!("http://{}{path}", self.address)); + if let Some(token) = token { + request = request.header(hyper::header::AUTHORIZATION, format!("Bearer {token}")); + } + let request = request + .header(hyper::header::CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from(body.to_string()))) + .unwrap(); + let response = tokio::time::timeout(Duration::from_secs(120), client.request(request)) + .await + .unwrap() + .unwrap(); + let status = response.status().as_u16(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let raw = String::from_utf8_lossy(&bytes).into_owned(); + let parsed = serde_json::from_slice(&bytes).unwrap_or(Value::Null); + (status, parsed, raw) + } + + async fn close(self) { + let _ = self.shutdown.send(()); + tokio::time::timeout(Duration::from_secs(5), self.server) + .await + .unwrap() + .unwrap() + .unwrap(); + drop(std::net::TcpListener::bind(self.address).expect("owned TCP released")); + } +} + +fn private_root() -> PathBuf { + let root = PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()) + .join(format!("plugin-admin-{}", uuid::Uuid::new_v4().simple())); + std::fs::create_dir_all(&root).unwrap(); + root +} + +fn static_denial(body: &Value, raw: &str) { + assert_eq!(body["error"]["code"], "managed_management_denied"); + assert!( + !raw.contains('/') && !raw.contains("passwd") && !raw.contains("run-public"), + "denial must not echo paths or caller identifiers: {raw}" + ); +} + +#[tokio::test] +async fn unset_bearer_rejects_plugin_control_without_partial_native_effects() { + let root = private_root(); + let db = root.join("gateway.sqlite"); + let config = AgentGatewayConfig::default(); + let state = AgentGatewayState::with_sqlite_path(config.clone(), &db).unwrap(); + let (before_activation, before_history) = { + let before = GatewayPersistence::open(&config, &db).unwrap(); + let before_control = PluginControlStore::new(&before); + ( + before_control.activation().unwrap(), + before_control.activation_history().unwrap(), + ) + }; + let http = Http::bind(state.clone()).await; + let forged = json!({ + "admin": true, + "path": "/etc/passwd", + "graph_id": "caller-graph", + "token": "run-public", + "deployment_space": "other-space" + }); + let routes = [ + ("POST", "/v1/plugins/install"), + ("POST", "/v1/plugin-graphs/resolve"), + ("POST", "/v1/plugin-graphs/caller-graph/prepare"), + ("POST", "/v1/plugin-activations"), + ("GET", "/v1/plugin-graphs/caller-graph"), + ("POST", "/v1/plugin-activations/other-space.1/rollback"), + ]; + for (method, path) in routes { + let (status, body, raw) = http.request(method, path, forged.clone(), None).await; + assert_eq!(status, 403, "{method} {path} raw={raw}"); + static_denial(&body, &raw); + } + http.close().await; + { + let reopened = GatewayPersistence::open(&config, &db).unwrap(); + let control = PluginControlStore::new(&reopened); + let activation = control.activation().unwrap(); + assert_eq!(activation.graph_id, before_activation.graph_id); + assert_eq!(activation.generation, before_activation.generation); + assert_eq!(control.activation_history().unwrap(), before_history); + } + + let http = Http::bind(state).await; + let (session_status, session, _) = http.request("POST", "/api/sessions", json!({}), None).await; + assert_eq!(session_status, 201); + let session_id = session["session"]["id"].as_str().unwrap(); + let (run_status, run, _) = http + .request( + "POST", + "/v1/runs", + json!({"session_id": session_id, "input": "ordinary"}), + None, + ) + .await; + assert_eq!(run_status, 202); + assert!(run["run_id"].as_str().is_some()); + http.close().await; + let _ = std::fs::remove_dir_all(&root); +} + +#[allow(dead_code)] +#[path = "support/signed_tool_effects.rs"] +mod signed_tool_effects; + +#[path = "support/plugin_admin_cycle.rs"] +mod plugin_admin_cycle; + +fn no_private_text(raw: &str) { + assert!( + !raw.contains("/etc") && !raw.contains("passwd") && !raw.contains(".sqlite"), + "response leaked a private path: {raw}" + ); +} + +#[tokio::test] +async fn bearer_without_host_binding_rejects_forged_admin_and_run_identifier() { + let root = private_root(); + let db = root.join("gateway.sqlite"); + let token = "owner-control-token"; + let config = AgentGatewayConfig { + bearer_token: Some(token.into()), + ..AgentGatewayConfig::default() + }; + let state = AgentGatewayState::with_sqlite_path(config.clone(), &db).unwrap(); + let before = GatewayPersistence::open(&config, &db).unwrap(); + let before_activation = PluginControlStore::new(&before).activation().unwrap(); + drop(before); + let http = Http::bind(state).await; + let forged = json!({"admin": true, "path": "/etc/passwd", "package": "package-a", "deployment_space": "owner-space"}); + let (status, body, raw) = http + .request("POST", "/v1/plugins/install", forged, Some(token)) + .await; + assert_eq!(status, 403, "{raw}"); + assert_eq!(body["error"]["code"], "plugin_control_unconfigured"); + no_private_text(&raw); + let (status, body, raw) = http + .request( + "POST", + "/v1/plugins/install", + json!({"package": "package-a", "deployment_space": "owner-space"}), + Some("run-public-id"), + ) + .await; + assert_eq!(status, 401, "{raw}"); + assert_eq!(body["error"]["code"], "unauthorized"); + http.close().await; + let reopened = GatewayPersistence::open(&config, &db).unwrap(); + let activation = PluginControlStore::new(&reopened).activation().unwrap(); + assert_eq!(activation, before_activation); + let _ = std::fs::remove_dir_all(&root); +} + +fn gateway_bin() -> &'static str { + env!("CARGO_BIN_EXE_rustscript-agent-gateway") +} + +fn scrub_plugin_env(cmd: &mut tokio::process::Command) { + for key in [ + "RUSTSCRIPT_AGENT_PLUGIN_SOURCE_ROOT", + "RUSTSCRIPT_AGENT_PLUGIN_UNSIGNED_DEV", + "RUSTSCRIPT_AGENT_PLUGIN_DEPLOYMENT_SPACE", + "RUSTSCRIPT_AGENT_PLUGIN_PUBLISHER_DIR", + "RUSTSCRIPT_AGENT_STATE_DB", + "RUSTSCRIPT_AGENT_HOME", + "RUSTSCRIPT_AGENT_GATEWAY_ADDR", + "RUSTSCRIPT_AGENT_BEARER_TOKEN", + "RUSTSCRIPT_AGENT_ALLOW_ANONYMOUS", + "PD_EDGE_AGENT_GATEWAY_ADDR", + "PD_EDGE_AGENT_BEARER_TOKEN", + "PD_EDGE_AGENT_ALLOW_ANONYMOUS", + "PD_EDGE_AGENT_STATE_DB", + ] { + cmd.env_remove(key); + } +} + +struct GatewayChild { + child: tokio::process::Child, + stderr_path: PathBuf, +} + +impl GatewayChild { + fn spawn(root: &std::path::Path, envs: &[(&str, &std::path::Path)]) -> Self { + let stderr_path = root.join("stderr.log"); + let stderr = std::fs::File::create(&stderr_path).unwrap(); + let mut cmd = tokio::process::Command::new(gateway_bin()); + scrub_plugin_env(&mut cmd); + cmd.env("HOME", root) + .env("RUSTSCRIPT_AGENT_HOME", root) + .env("RUSTSCRIPT_AGENT_GATEWAY_ADDR", "127.0.0.1:0") + .env("RUSTSCRIPT_AGENT_BEARER_TOKEN", "owner-control-token") + .current_dir(root) + .stderr(std::process::Stdio::from(stderr)) + .stdout(std::process::Stdio::null()); + for (key, value) in envs { + cmd.env(key, value); + } + let child = cmd.spawn().expect("spawn gateway"); + Self { child, stderr_path } + } + + fn stderr_text(&self) -> String { + std::fs::read_to_string(&self.stderr_path).unwrap_or_default() + } + + async fn wait_listening(&mut self) -> String { + let deadline = std::time::Instant::now() + Duration::from_secs(45); + loop { + let text = self.stderr_text(); + if let Some(position) = text.find("listening on http://") { + let rest = &text[position + "listening on http://".len()..]; + let address = rest.lines().next().unwrap_or("").trim().to_string(); + if !address.is_empty() { + return address; + } + } + if self.child.try_wait().ok().flatten().is_some() { + panic!("gateway exited before listen: {}", self.stderr_text()); + } + if std::time::Instant::now() >= deadline { + panic!("gateway did not listen: {}", self.stderr_text()); + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + } + + async fn wait_exit_before_listen(&mut self, expected: &str) { + let status = tokio::time::timeout(Duration::from_secs(45), self.child.wait()) + .await + .expect("gateway exit") + .expect("wait gateway"); + let text = self.stderr_text(); + assert!(!status.success(), "{text}"); + assert!(!text.contains("listening on http://"), "{text}"); + assert!(text.contains(expected), "{text}"); + } + + async fn stop(&mut self) { + if let Some(pid) = self.child.id() { + let _ = std::process::Command::new("kill") + .args(["-INT", &pid.to_string()]) + .status(); + } + let _ = tokio::time::timeout(Duration::from_secs(15), self.child.wait()).await; + } +} + +async fn binary_request( + address: &str, + method: &str, + path: &str, + body: Value, +) -> (u16, Value, String) { + let client: hyper_util::client::legacy::Client<_, Full> = + hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new()) + .build_http(); + let request = hyper::Request::builder() + .method(method) + .uri(format!("http://{address}{path}")) + .header(hyper::header::AUTHORIZATION, "Bearer owner-control-token") + .header(hyper::header::CONTENT_TYPE, "application/json") + .body(Full::new(Bytes::from(body.to_string()))) + .unwrap(); + let response = tokio::time::timeout(Duration::from_secs(30), client.request(request)) + .await + .expect("gateway response timeout") + .expect("gateway response"); + let status = response.status().as_u16(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let raw = String::from_utf8_lossy(&bytes).into_owned(); + let parsed = serde_json::from_slice(&bytes).unwrap_or(Value::Null); + (status, parsed, raw) +} + +#[tokio::test] +async fn binary_missing_source_root_stays_unbound() { + let root = private_root(); + let mut child = GatewayChild::spawn(&root, &[]); + let address = child.wait_listening().await; + let (status, body, raw) = binary_request( + &address, + "POST", + "/v1/plugins/install", + json!({"package": "package-a", "deployment_space": "owner-space"}), + ) + .await; + assert_eq!(status, 403, "{raw}"); + assert_eq!(body["error"]["code"], "plugin_control_unconfigured"); + child.stop().await; + let _ = std::fs::remove_dir_all(&root); +} + +#[tokio::test] +async fn binary_missing_deployment_space_fails_before_listen() { + let root = private_root(); + let source = root.join("source"); + std::fs::create_dir_all(&source).unwrap(); + let mut child = GatewayChild::spawn( + &root, + &[("RUSTSCRIPT_AGENT_PLUGIN_SOURCE_ROOT", source.as_path())], + ); + child + .wait_exit_before_listen("plugin control deployment space is not configured") + .await; + let _ = std::fs::remove_dir_all(&root); +} + +#[tokio::test] +async fn binary_illegal_deployment_space_fails_before_listen() { + let root = private_root(); + let source = root.join("source"); + std::fs::create_dir_all(&source).unwrap(); + let stderr_path = root.join("stderr.log"); + let stderr = std::fs::File::create(&stderr_path).unwrap(); + let mut cmd = tokio::process::Command::new(gateway_bin()); + scrub_plugin_env(&mut cmd); + cmd.env("HOME", &root) + .env("RUSTSCRIPT_AGENT_HOME", &root) + .env("RUSTSCRIPT_AGENT_GATEWAY_ADDR", "127.0.0.1:0") + .env("RUSTSCRIPT_AGENT_BEARER_TOKEN", "owner-control-token") + .env("RUSTSCRIPT_AGENT_PLUGIN_SOURCE_ROOT", &source) + .env("RUSTSCRIPT_AGENT_PLUGIN_DEPLOYMENT_SPACE", "owner/space") + .current_dir(&root) + .stderr(std::process::Stdio::from(stderr)) + .stdout(std::process::Stdio::null()); + let mut child = cmd.spawn().expect("spawn gateway"); + let status = tokio::time::timeout(Duration::from_secs(45), child.wait()) + .await + .expect("gateway exit") + .expect("wait gateway"); + let text = std::fs::read_to_string(&stderr_path).unwrap_or_default(); + assert!(!status.success(), "{text}"); + assert!(!text.contains("listening on http://"), "{text}"); + assert!( + text.contains("plugin deployment space is invalid"), + "{text}" + ); + let _ = std::fs::remove_dir_all(&root); +} + +#[tokio::test] +async fn binary_unsigned_dev_rejects_signed_package() { + let root = private_root(); + let source = root.join("source"); + std::fs::create_dir_all(&source).unwrap(); + let (_auth, package_a, _, _) = signed_tool_effects::stage_write_pair(&source); + let db = root.join("gateway.sqlite"); + let mut child = GatewayChild::spawn( + &root, + &[ + ("RUSTSCRIPT_AGENT_PLUGIN_SOURCE_ROOT", source.as_path()), + ("RUSTSCRIPT_AGENT_PLUGIN_UNSIGNED_DEV", source.as_path()), + ( + "RUSTSCRIPT_AGENT_PLUGIN_DEPLOYMENT_SPACE", + std::path::Path::new("owner-space"), + ), + ("RUSTSCRIPT_AGENT_STATE_DB", db.as_path()), + ], + ); + let address = child.wait_listening().await; + let (status, body, raw) = binary_request( + &address, + "POST", + "/v1/plugins/install", + json!({"package": package_a, "deployment_space": "owner-space"}), + ) + .await; + assert_eq!(status, 403, "{raw}"); + assert_eq!(body["error"]["code"], "plugin_publisher_unauthorized"); + child.stop().await; + let _ = std::fs::remove_dir_all(&root); +} + +#[tokio::test] +async fn binary_signed_policy_survives_process_restart_without_reinstall() { + let root = private_root(); + let source = root.join("source"); + std::fs::create_dir_all(&source).unwrap(); + let (auth, package_a, _, _) = signed_tool_effects::stage_write_pair(&source); + let publishers = root.join("publishers"); + std::fs::create_dir_all(&publishers).unwrap(); + let key = &auth.accepted_publishers[0]; + std::fs::write(publishers.join(&key.publisher), &key.verifying_key).unwrap(); + let db = root.join("gateway.sqlite"); + let spawn_signed = |root: &std::path::Path| { + let stderr_path = root.join("stderr.log"); + let stderr = std::fs::File::create(&stderr_path).unwrap(); + let mut cmd = tokio::process::Command::new(gateway_bin()); + scrub_plugin_env(&mut cmd); + cmd.env("HOME", root) + .env("RUSTSCRIPT_AGENT_HOME", root) + .env("RUSTSCRIPT_AGENT_GATEWAY_ADDR", "127.0.0.1:0") + .env("RUSTSCRIPT_AGENT_BEARER_TOKEN", "owner-control-token") + .env("RUSTSCRIPT_AGENT_PLUGIN_SOURCE_ROOT", &source) + .env("RUSTSCRIPT_AGENT_PLUGIN_PUBLISHER_DIR", &publishers) + .env("RUSTSCRIPT_AGENT_PLUGIN_DEPLOYMENT_SPACE", "owner-space") + .env("RUSTSCRIPT_AGENT_STATE_DB", &db) + .current_dir(root) + .stderr(std::process::Stdio::from(stderr)) + .stdout(std::process::Stdio::null()); + GatewayChild { + child: cmd.spawn().expect("spawn signed gateway"), + stderr_path, + } + }; + let mut first = spawn_signed(&root); + let address = first.wait_listening().await; + let (status, _, raw) = binary_request( + &address, + "POST", + "/v1/plugins/install", + json!({"package": package_a, "deployment_space": "owner-space"}), + ) + .await; + assert_eq!(status, 200, "{raw}"); + let (status, resolved, raw) = binary_request( + &address, + "POST", + "/v1/plugin-graphs/resolve", + json!({ + "roots": [{"id": "org.rustscript.tools.effecta", "version": "1.0.0"}], + "tool_package": "org.rustscript.tools.effecta", + "deployment_space": "owner-space" + }), + ) + .await; + assert_eq!(status, 200, "{raw}"); + let graph_id = resolved["graph_id"].as_str().unwrap().to_string(); + let (status, _, raw) = binary_request( + &address, + "POST", + &format!("/v1/plugin-graphs/{graph_id}/prepare"), + json!({}), + ) + .await; + assert_eq!(status, 200, "{raw}"); + let (status, activated, raw) = binary_request( + &address, + "POST", + "/v1/plugin-activations", + json!({ + "graph_id": graph_id, + "deployment_space": "owner-space", + "expected_generation": 0 + }), + ) + .await; + assert_eq!(status, 200, "{raw}"); + let generation = activated["generation"].as_u64().unwrap(); + first.stop().await; + + let mut second = spawn_signed(&root); + let address = second.wait_listening().await; + let (status, prepared, raw) = binary_request( + &address, + "POST", + &format!("/v1/plugin-graphs/{graph_id}/prepare"), + json!({}), + ) + .await; + assert_eq!( + status, 200, + "process restart prepare without reinstall: {raw}" + ); + assert_eq!(prepared["graph_id"], graph_id); + assert_eq!(prepared["executable"], true); + let (status, body, raw) = binary_request( + &address, + "POST", + "/v1/plugin-activations", + json!({ + "graph_id": graph_id, + "deployment_space": "owner-space", + "expected_generation": 0 + }), + ) + .await; + assert_eq!(status, 409, "{raw}"); + assert_eq!(body["error"]["code"], "plugin_activation_conflict"); + let (status, viewed, raw) = binary_request( + &address, + "GET", + &format!("/v1/plugin-graphs/{graph_id}"), + json!({}), + ) + .await; + assert_eq!(status, 200, "{raw}"); + assert_eq!(viewed["graph_id"], graph_id); + assert_eq!(viewed["contract"]["mode"], "tool_only"); + assert!(generation > 0); + second.stop().await; + let _ = std::fs::remove_dir_all(&root); +} diff --git a/tests/plugin_effect_api_e2e_tests.rs b/tests/plugin_effect_api_e2e_tests.rs new file mode 100644 index 0000000..e42717a --- /dev/null +++ b/tests/plugin_effect_api_e2e_tests.rs @@ -0,0 +1,1658 @@ +//! T16: real-socket graph hot cut, pinned continuation, checkpoint/restore, and rollback. +//! Admission starts at POST /api/sessions and POST /v1/runs. Request JSON does not grant. +use plugin_effect_api_cycle::{ + Cycle, Http, SecondBusinessGate, activate, assert_closed, + assert_signed_write_pair_prepares_captured_catalog, assert_typed_terminal, bound_store, + contains_marker, current_generation, embedding_graph_id, event_names, explain, install_pair, + pin_for_run, revoke_graph, shutdown_when_idle, stop_turn, tool_call, wait_for, wait_until, +}; +use rustscript_agent::ScriptedProvider; +use serde_json::{Value, json}; +use std::sync::Arc; +use std::time::Duration; + +#[path = "support/plugin_effect_api_cycle.rs"] +mod plugin_effect_api_cycle; +#[allow(dead_code)] +#[path = "support/signed_tool_effects.rs"] +mod signed_tool_effects; + +async fn projected_run(http: &Http, token: &str, session_id: &str, run_id: &str) -> String { + let (status, messages) = http + .request( + "GET", + &format!("/api/sessions/{session_id}/messages"), + json!(null), + Some(token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &messages)); + messages["data"] + .as_array() + .unwrap_or(&Vec::new()) + .iter() + .filter(|message| message["run_id"].as_str() == Some(run_id)) + .map(|message| message.to_string()) + .collect() +} + +#[test] +fn signed_stateless_write_pair_prepares_captured_catalog_without_http() { + let root = std::path::PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()) + .join(format!("t16-catalog-{}", uuid::Uuid::new_v4().simple())); + let _ = std::fs::remove_dir_all(&root); + assert_signed_write_pair_prepares_captured_catalog(&root); + let _ = std::fs::remove_dir_all(&root); +} + +#[tokio::test(flavor = "multi_thread")] +async fn hot_cut_checkpoint_restore_and_rollback_keep_pinned_receipts() { + let cycle = Cycle::open(&format!("t16-hot-{}", uuid::Uuid::new_v4().simple())); + let http = Http::bind(cycle.state.clone()).await; + let hidden = [cycle.workspace.as_path(), cycle.source.as_path()]; + install_pair(&http, &cycle.token, &cycle.source).await; + let (graph_a, generation_a) = activate( + &http, + &cycle.token, + &cycle.config, + &cycle.db, + "org.rustscript.tools.effecta", + ) + .await; + let (status, view) = http + .request( + "GET", + &format!("/v1/plugin-graphs/{graph_a}"), + json!({}), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &view)); + assert_eq!(view["graph_id"], graph_a); + assert!(view["errors"].as_array().unwrap().is_empty()); + assert_closed(&view, &cycle.token, &hidden); + + let provider = ScriptedProvider::new(); + provider.push_ok(tool_call("write-a1", "write_file", "out-a.txt")); + provider.push_ok(tool_call("write-a2", "write_file", "out-a-again.txt")); + provider.push_ok(stop_turn("done-a")); + let gate = Arc::new(SecondBusinessGate::new(provider)); + cycle.state.service().inject_provider_host(gate.clone()); + let (status, session) = http + .request("POST", "/api/sessions", json!({}), Some(&cycle.token), None) + .await; + assert_eq!(status, 201, "{}", explain(status, &session)); + let session_id = session["session"]["id"].as_str().unwrap().to_string(); + let idem = uuid::Uuid::new_v4().to_string(); + let run_body = json!({ + "session_id": session_id, + "input": "write the pinned marker", + "extra": {"graph_id": "fake-graph", "admin": true} + }); + let (status, admitted) = http + .request( + "POST", + "/v1/runs", + run_body.clone(), + Some(&cycle.token), + Some(&idem), + ) + .await; + assert_eq!(status, 202, "{}", explain(status, &admitted)); + assert_eq!(admitted["graph_id"], graph_a); + assert_ne!(admitted["graph_id"], "fake-graph"); + assert!(admitted["publication"].is_null(), "publication stays null"); + assert!(admitted["workspace_branch"].is_string()); + assert!(admitted["epoch"].is_number()); + assert_closed(&admitted, &cycle.token, &hidden); + let run_a = admitted["run_id"].as_str().unwrap().to_string(); + let admission_branch = admitted["workspace_branch"].as_str().unwrap().to_string(); + let admission_epoch = admitted["epoch"].as_u64().unwrap(); + let admission_generation = admitted["activation_generation"].as_u64().unwrap(); + assert_eq!(admission_generation, generation_a); + + wait_until(|| gate.holding() && contains_marker(&cycle.state, &run_a, "A-write")).await; + assert!( + !contains_marker(&cycle.state, &run_a, "out-a-again.txt"), + "second invoke must not start before the gate releases" + ); + let (status, effects) = http + .request( + "GET", + &format!("/v1/runs/{run_a}/effects?limit=128"), + json!(null), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &effects)); + let write = effects["entries"] + .as_array() + .unwrap() + .iter() + .find(|entry| entry["call_id"] == "write-a1") + .expect("first native write receipt") + .clone(); + assert_eq!(write["state"], "committed"); + assert_eq!(write["branch_id"], admission_branch); + assert_eq!(write["run_id"], run_a); + assert!(write["epoch"].as_u64().unwrap() >= admission_epoch); + assert_closed(&effects, &cycle.token, &hidden); + let workspace_id = write["workspace_id"].as_str().unwrap().to_string(); + let epoch = write["epoch"].as_u64().unwrap(); + + let checkpoint_body = json!({ + "source_run_id": run_a, + "workspace_id": workspace_id, + "source_branch_id": admission_branch, + "expected_epoch": epoch, + "request_id": uuid::Uuid::new_v4().to_string(), + "mode": "at_closed_batch" + }); + let (status, checkpoint) = http + .request( + "POST", + &format!("/v1/workspaces/{workspace_id}/checkpoints"), + checkpoint_body.clone(), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 201, "{}", explain(status, &checkpoint)); + assert_eq!(checkpoint["replayed"], false); + assert_eq!(checkpoint["checkpoint"]["graph"]["graph_id"], graph_a); + assert_eq!(checkpoint["checkpoint"]["plugin_state"]["roots"], json!([])); + assert_closed(&checkpoint, &cycle.token, &hidden); + let checkpoint_id = checkpoint["checkpoint"]["checkpoint_id"] + .as_str() + .unwrap() + .to_string(); + let (status, replay) = http + .request( + "POST", + &format!("/v1/workspaces/{workspace_id}/checkpoints"), + checkpoint_body, + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 201, "{}", explain(status, &replay)); + assert_eq!(replay["replayed"], true); + assert_eq!(replay["checkpoint"]["checkpoint_id"], checkpoint_id); + let (status, foreign) = http + .request( + "POST", + &format!("/v1/workspaces/{workspace_id}/checkpoints"), + json!({ + "source_run_id": run_a, + "workspace_id": workspace_id, + "source_branch_id": uuid::Uuid::new_v4().to_string(), + "expected_epoch": epoch, + "request_id": uuid::Uuid::new_v4().to_string(), + "mode": "at_closed_batch" + }), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 409, "{}", explain(status, &foreign)); + assert_eq!(foreign["error"]["code"], "managed_checkpoint_conflict"); + let (status, mismatch) = http + .request( + "POST", + &format!("/v1/workspaces/{}/checkpoints", uuid::Uuid::new_v4()), + json!({ + "source_run_id": run_a, + "workspace_id": workspace_id, + "source_branch_id": admission_branch, + "expected_epoch": epoch, + "request_id": uuid::Uuid::new_v4().to_string(), + "mode": "at_closed_batch" + }), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 403, "{}", explain(status, &mismatch)); + assert_eq!(mismatch["error"]["code"], "managed_authority_required"); + + let (graph_b, generation_b) = activate( + &http, + &cycle.token, + &cycle.config, + &cycle.db, + "org.rustscript.tools.effectb", + ) + .await; + assert!(generation_b > generation_a); + let (status, reread) = http + .request( + "POST", + "/v1/runs", + run_body, + Some(&cycle.token), + Some(&idem), + ) + .await; + assert_eq!(status, 202, "{}", explain(status, &reread)); + assert_eq!(reread["run_id"], run_a); + assert_eq!(reread["graph_id"], graph_a); + assert_eq!(reread["activation_generation"], generation_a); + assert!(reread["publication"].is_null()); + let pinned = pin_for_run(&cycle.config, &cycle.db, &run_a); + assert_eq!(pinned["graph_id"], graph_a); + assert_eq!(pinned["generation"], generation_a); + let handles_before_release = cycle.state.service().handle_count(); + + gate.release(); + wait_until(|| { + contains_marker(&cycle.state, &run_a, "out-a-again.txt") + && event_names(&cycle.state, &run_a) + .iter() + .any(|name| name == "run.completed") + }) + .await; + assert!(contains_marker(&cycle.state, &run_a, "A-write")); + assert!( + !contains_marker(&cycle.state, &run_a, "B-write"), + "continued A invoke must not execute B" + ); + let events = http.typed_sse(&cycle.token, &run_a).await; + assert_typed_terminal(&events, "run.completed"); + let (status, messages) = http + .request( + "GET", + &format!("/api/sessions/{session_id}/messages"), + json!(null), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &messages)); + let frozen_messages = messages["data"].clone(); + assert!(frozen_messages.to_string().contains("A-write")); + assert!(!frozen_messages.to_string().contains("B-write")); + assert_closed(&messages, &cycle.token, &hidden); + let (status, effects_after) = http + .request( + "GET", + &format!("/v1/runs/{run_a}/effects?limit=128"), + json!(null), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &effects_after)); + let second = effects_after["entries"] + .as_array() + .unwrap() + .iter() + .find(|entry| entry["call_id"] == "write-a2") + .expect("second native write receipt"); + assert_eq!(second["state"], "committed"); + assert_eq!(second["branch_id"], admission_branch); + + let provider_b = Arc::new(ScriptedProvider::new()); + provider_b.push_ok(tool_call("write-b", "write_file", "out-b.txt")); + provider_b.push_ok(stop_turn("done-b")); + cycle.state.service().inject_provider_host(provider_b); + let (status, run_b) = http + .request( + "POST", + "/v1/runs", + json!({"session_id": session_id, "input": "write the new graph marker", "extra": {"graph_id": graph_a}}), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 202, "{}", explain(status, &run_b)); + assert_eq!(run_b["graph_id"], graph_b); + assert_eq!(run_b["activation_generation"], generation_b); + assert!(run_b["publication"].is_null()); + let run_b_id = run_b["run_id"].as_str().unwrap().to_string(); + wait_until(|| { + event_names(&cycle.state, &run_b_id) + .iter() + .any(|name| name == "run.completed") + }) + .await; + assert!(contains_marker(&cycle.state, &run_b_id, "B-write")); + assert!(!contains_marker(&cycle.state, &run_a, "B-write")); + let (status, before_restore) = http + .request( + "GET", + &format!("/api/sessions/{session_id}/messages"), + json!(null), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &before_restore)); + let session_before_restore = before_restore["data"].clone(); + + let head = current_head(&cycle, &session_id, &run_a, "write-a1"); + let restore_body = json!({ + "workspace_id": workspace_id, + "source_branch_id": admission_branch, + "checkpoint_id": checkpoint_id, + "expected_source": { + "store_id": head["store_id"], + "workspace_id": head["workspace_id"], + "branch_id": head["branch_id"], + "epoch": head["epoch"], + "head_object_id": head["head_object_id"], + "head_version": head["head_version"] + }, + "request_id": uuid::Uuid::new_v4().to_string(), + "input": {"text": "read frozen A"}, + "cancel_active_source": true, + "graph_selection": "checkpoint_exact" + }); + let restored_provider = Arc::new(ScriptedProvider::new()); + restored_provider.push_ok(json!({ + "text": "", + "tool_calls": [{"id": "read-frozen", "name": "read_file", "arguments": {"path": "out-a.txt"}}], + "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + "reasoning": "", + "stop_reason": "tool_calls" + })); + restored_provider.push_ok(json!({ + "text": "", + "tool_calls": [{"id": "read-absent", "name": "read_file", "arguments": {"path": "out-a-again.txt"}}], + "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + "reasoning": "", + "stop_reason": "tool_calls" + })); + restored_provider.push_ok(tool_call( + "write-restored", + "write_file", + "out-restored.txt", + )); + restored_provider.push_ok(stop_turn("done-restored")); + cycle + .state + .service() + .inject_provider_host(restored_provider); + let (status, restored) = http + .request( + "POST", + &format!("/v1/workspaces/{workspace_id}/restore"), + restore_body.clone(), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 202, "{}", explain(status, &restored)); + assert_eq!(restored["disposition"], "restored_and_admitted"); + assert_eq!(restored["graph"]["graph_id"], graph_a); + assert_eq!(restored["publication"], "managed_only"); + assert_ne!(restored["new_branch"]["branch_id"], admission_branch); + let new_run = restored["new_run_id"].as_str().unwrap().to_string(); + let new_session = restored["new_session_id"].as_str().unwrap().to_string(); + assert_ne!(new_run, run_a); + assert_closed(&restored, &cycle.token, &hidden); + let (status, replayed_restore) = http + .request( + "POST", + &format!("/v1/workspaces/{workspace_id}/restore"), + restore_body, + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 202, "{}", explain(status, &replayed_restore)); + assert_eq!(replayed_restore["replayed"], true); + assert_eq!(replayed_restore["new_run_id"], new_run); + let (status, messages_after) = http + .request( + "GET", + &format!("/api/sessions/{session_id}/messages"), + json!(null), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &messages_after)); + assert_eq!(messages_after["data"], session_before_restore); + + let frozen_head = restored["new_branch"]["head_object_id"] + .as_str() + .expect("frozen branch head") + .to_string(); + wait_until(|| { + typed_tool_block( + &cycle.state.service().session_messages(&new_session), + "read-frozen", + ) + .and_then(|block| block["content"].as_str().map(str::to_string)) + .as_deref() + == Some("A-write\n") + }) + .await; + assert!( + !contains_marker(&cycle.state, &new_run, "B-write"), + "restored run must execute the checkpoint graph" + ); + let restored_pin = pin_for_run(&cycle.config, &cycle.db, &new_run); + assert_eq!(restored_pin["graph_id"], graph_a); + let (status, restored_messages) = http + .request( + "GET", + &format!("/api/sessions/{new_session}/messages"), + json!(null), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &restored_messages)); + let read_block = typed_tool_block( + restored_messages["data"] + .as_array() + .expect("restored messages"), + "read-frozen", + ) + .expect("typed read_file result"); + assert_eq!(read_block["type"], "tool_result"); + assert_eq!(read_block["tool_call_id"], "read-frozen"); + assert_eq!(read_block["is_error"], false); + assert_eq!(read_block["content"], "A-write\n"); + let tree_path = cycle + .root + .join(".gateway.sqlite.effects/trees") + .join(&frozen_head) + .join("out-a.txt"); + let tree = std::fs::read(&tree_path).expect("frozen tree bytes"); + assert_eq!(tree, b"A-write\n"); + assert!( + !cycle + .root + .join(".gateway.sqlite.effects/trees") + .join(&frozen_head) + .join("out-a-again.txt") + .exists() + ); + assert_eq!( + read_block["content"].as_str().unwrap().as_bytes(), + tree.as_slice() + ); + let (status, read_effects) = http + .request( + "GET", + &format!("/v1/runs/{new_run}/effects?limit=128"), + json!(null), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &read_effects)); + let read_entry = read_effects["entries"] + .as_array() + .unwrap() + .iter() + .find(|entry| entry["call_id"] == "read-frozen") + .expect("read-frozen effect"); + assert_eq!(read_entry["state"], "read_only"); + assert_eq!(read_entry["branch_id"], restored["new_branch"]["branch_id"]); + let persistence = cycle.state.persistence().unwrap(); + let read_request = rustscript_agent::effects::ManagedEffectGetRequest { + owner: rustscript_agent::effects::ManagedOwnerScope { + profile: "gateway".into(), + session_id: new_session.clone(), + run_id: new_run.clone(), + }, + call_id: "read-frozen".into(), + }; + let record = persistence + .managed_effect_get(&read_request) + .unwrap() + .expect("durable read effect"); + assert_eq!(record.identity.tool_name, "read_file"); + assert_eq!(record.identity.approved_risk, "read"); + assert_eq!( + record.state, + rustscript_agent::effects::ManagedPublicationStatus::ReadOnly + ); + let outcome = record.outcome.as_ref().expect("completed read outcome"); + assert!(outcome.publication.candidate_head.is_none()); + assert_eq!(outcome.publication.observed_head, frozen_head); + assert_eq!(outcome.publication.base_head, frozen_head); + assert_eq!( + outcome.publication.status, + rustscript_agent::effects::ManagedPublicationStatus::ReadOnly + ); + let reread = persistence + .managed_effect_get(&read_request) + .unwrap() + .expect("reread durable effect"); + assert_eq!(reread, record); + let body = cycle + .state + .service() + .read_managed_result_chunk(rustscript_agent::effects::ManagedResultReadRequest { + effect_id: record.effect_id.clone(), + offset: 0, + limit: 4096, + }) + .expect("effect body"); + let body_json: Value = serde_json::from_slice(&body.bytes).expect("effect body json"); + assert_eq!(body_json["ok"], true); + assert_eq!(body_json["content"], "A-write\n"); + let source_after = current_head(&cycle, &session_id, &run_a, "write-a1"); + assert_eq!(source_after["head_object_id"], head["head_object_id"]); + assert_eq!( + source_after["head_version"].as_u64().unwrap(), + head["head_version"].as_u64().unwrap() + 1, + "restore CAS may bump source version once; the read must not bump it again" + ); + assert_eq!( + source_after["epoch"].as_u64().unwrap(), + head["epoch"].as_u64().unwrap() + 1 + ); + assert_closed(&restored_messages, &cycle.token, &hidden); + let (status, old_again) = http + .request( + "GET", + &format!("/api/sessions/{session_id}/messages"), + json!(null), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &old_again)); + assert_eq!(old_again["data"], session_before_restore); + + wait_until(|| { + let events = cycle.state.service().run_events(&new_run); + let absent = events.iter().any(|event| { + event["event"] == "tool.failed" + && event["data"]["tool_call_id"] == "read-absent" + && event["data"]["error_code"] == "not_found" + }); + let written = events.iter().any(|event| { + event["event"] == "tool.completed" && event["data"]["tool_call_id"] == "write-restored" + }); + let completed = events.iter().any(|event| event["event"] == "run.completed"); + absent && written && completed + }) + .await; + let restored_events = cycle.state.service().run_events(&new_run); + assert!( + restored_events + .iter() + .any(|event| event["event"] == "run.completed"), + "restored run must complete after the write commit: {restored_events:?}" + ); + assert!( + !restored_events + .iter() + .any(|event| event["event"] == "run.failed" || event["event"] == "run.cancelled"), + "{restored_events:?}" + ); + let absent_block = typed_tool_block( + &cycle.state.service().session_messages(&new_session), + "read-absent", + ) + .expect("typed absent read"); + assert_eq!(absent_block["type"], "tool_result"); + assert_eq!(absent_block["tool_call_id"], "read-absent"); + assert_eq!(absent_block["is_error"], true); + assert_eq!(absent_block["content"], ""); + assert_ne!(absent_block["content"], "A-write\n"); + let write_block = typed_tool_block( + &cycle.state.service().session_messages(&new_session), + "write-restored", + ) + .expect("typed restored write"); + assert_eq!(write_block["type"], "tool_result"); + assert_eq!(write_block["tool_call_id"], "write-restored"); + assert_eq!(write_block["is_error"], false); + assert_eq!(write_block["content"], "A-write\n"); + let (status, restored_effects) = http + .request( + "GET", + &format!("/v1/runs/{new_run}/effects?limit=128"), + json!(null), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &restored_effects)); + let absent_entry = restored_effects["entries"] + .as_array() + .unwrap() + .iter() + .find(|entry| entry["call_id"] == "read-absent") + .expect("absent read effect"); + assert_eq!(absent_entry["state"], "not_published"); + let write_entry = restored_effects["entries"] + .as_array() + .unwrap() + .iter() + .find(|entry| entry["call_id"] == "write-restored") + .expect("restored write effect"); + assert_eq!(write_entry["state"], "committed"); + assert_eq!( + write_entry["branch_id"], + restored["new_branch"]["branch_id"] + ); + let persistence = cycle.state.persistence().unwrap(); + let absent_record = persistence + .managed_effect_get(&rustscript_agent::effects::ManagedEffectGetRequest { + owner: rustscript_agent::effects::ManagedOwnerScope { + profile: "gateway".into(), + session_id: new_session.clone(), + run_id: new_run.clone(), + }, + call_id: "read-absent".into(), + }) + .unwrap() + .expect("durable absent read"); + assert_eq!(absent_record.identity.tool_name, "read_file"); + assert_eq!( + absent_record.state, + rustscript_agent::effects::ManagedPublicationStatus::NotPublished + ); + let absent_outcome = absent_record + .outcome + .as_ref() + .expect("failed read still has an outcome"); + assert!(absent_outcome.publication.candidate_head.is_none()); + assert_eq!( + absent_outcome.publication.status, + rustscript_agent::effects::ManagedPublicationStatus::NotPublished + ); + let absent_body = cycle + .state + .service() + .read_managed_result_chunk(rustscript_agent::effects::ManagedResultReadRequest { + effect_id: absent_record.effect_id.clone(), + offset: 0, + limit: 4096, + }) + .expect("absent effect body"); + let absent_json: Value = serde_json::from_slice(&absent_body.bytes).expect("absent body json"); + assert_eq!(absent_json["ok"], false); + assert_eq!(absent_json["error"]["code"], "not_found"); + assert_eq!(absent_json["content"], ""); + assert!( + !cycle + .root + .join(".gateway.sqlite.effects/trees") + .join(&frozen_head) + .join("out-a-again.txt") + .exists() + ); + let write_record = persistence + .managed_effect_get(&rustscript_agent::effects::ManagedEffectGetRequest { + owner: rustscript_agent::effects::ManagedOwnerScope { + profile: "gateway".into(), + session_id: new_session.clone(), + run_id: new_run.clone(), + }, + call_id: "write-restored".into(), + }) + .unwrap() + .expect("durable restored write"); + assert_eq!(write_record.identity.tool_name, "write_file"); + assert_eq!(write_record.identity.approved_risk, "write"); + assert_eq!( + write_record.state, + rustscript_agent::effects::ManagedPublicationStatus::Committed + ); + let write_outcome = write_record + .outcome + .as_ref() + .expect("committed write outcome"); + let candidate = write_outcome + .publication + .candidate_head + .clone() + .expect("write publishes a candidate head"); + assert_ne!(candidate, frozen_head); + assert_eq!( + write_outcome.publication.status, + rustscript_agent::effects::ManagedPublicationStatus::Committed + ); + let restored_file = cycle + .root + .join(".gateway.sqlite.effects/trees") + .join(&candidate) + .join("out-restored.txt"); + let restored_bytes = std::fs::read(&restored_file).expect("restored write bytes"); + assert_eq!(restored_bytes, b"A-write\n"); + assert_eq!( + write_block["content"].as_str().unwrap().as_bytes(), + restored_bytes.as_slice() + ); + assert!( + !cycle + .root + .join(".gateway.sqlite.effects/trees") + .join(&candidate) + .join("out-a-again.txt") + .exists(), + "absent read must not create a file on the written view" + ); + assert!(!contains_marker(&cycle.state, &new_run, "B-write")); + + let (status, rolled) = http + .request( + "POST", + &format!("/v1/plugin-activations/owner-space.{generation_a}/rollback"), + json!({"admin": true, "graph_id": graph_b}), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &rolled)); + let generation_roll = rolled["generation"].as_u64().unwrap(); + assert!(generation_roll > generation_b); + assert_eq!(rolled["graph_id"], graph_a); + let provider_roll = Arc::new(ScriptedProvider::new()); + provider_roll.push_ok(tool_call("write-roll", "write_file", "out-roll.txt")); + provider_roll.push_ok(stop_turn("done-roll")); + cycle.state.service().inject_provider_host(provider_roll); + let (status, rolled_run) = http + .request( + "POST", + "/v1/runs", + json!({"session_id": session_id, "input": "write the rolled graph"}), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 202, "{}", explain(status, &rolled_run)); + assert_eq!(rolled_run["graph_id"], graph_a); + assert_eq!(rolled_run["activation_generation"], generation_roll); + assert!(rolled_run["publication"].is_null()); + let rolled_id = rolled_run["run_id"].as_str().unwrap().to_string(); + wait_until(|| { + event_names(&cycle.state, &rolled_id) + .iter() + .any(|name| name == "run.completed") + }) + .await; + assert!(contains_marker(&cycle.state, &rolled_id, "A-write")); + assert!(!contains_marker(&cycle.state, &rolled_id, "B-write")); + let (status, final_messages) = http + .request( + "GET", + &format!("/api/sessions/{session_id}/messages"), + json!(null), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 200); + let prefix = + &final_messages["data"].as_array().unwrap()[..frozen_messages.as_array().unwrap().len()]; + assert_eq!(Value::Array(prefix.to_vec()), frozen_messages); + let (status, final_effects) = http + .request( + "GET", + &format!("/v1/runs/{run_a}/effects?limit=128"), + json!(null), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &final_effects)); + assert_eq!( + final_effects["entries"] + .as_array() + .unwrap() + .iter() + .find(|entry| entry["call_id"] == "write-a1") + .unwrap()["effect_id"], + write["effect_id"] + ); + let revoked = revoke_graph( + &cycle.config, + &cycle.db, + &bound_store(&cycle.db, &cycle.auth), + &graph_a, + ); + assert_eq!(revoked.unwrap_err().code, "plugin_lock_invalid"); + assert!(cycle.state.service().handle_count() >= handles_before_release); + http.close().await; + shutdown_when_idle(&cycle.state).await; +} + +fn typed_tool_block(messages: &[Value], call_id: &str) -> Option { + messages.iter().find_map(|message| { + if message.get("tool_call_id").and_then(Value::as_str) != Some(call_id) { + return None; + } + message + .get("content") + .and_then(Value::as_array) + .and_then(|blocks| { + blocks.iter().find(|block| { + block.get("type").and_then(Value::as_str) == Some("tool_result") + && block.get("tool_call_id").and_then(Value::as_str) == Some(call_id) + }) + }) + .cloned() + }) +} + +fn current_head(cycle: &Cycle, session_id: &str, run_id: &str, call_id: &str) -> Value { + let persistence = cycle.state.persistence().unwrap(); + let record = persistence + .managed_effect_get(&rustscript_agent::effects::ManagedEffectGetRequest { + owner: rustscript_agent::effects::ManagedOwnerScope { + profile: "gateway".into(), + session_id: session_id.into(), + run_id: run_id.into(), + }, + call_id: call_id.into(), + }) + .unwrap() + .unwrap(); + let head = persistence + .managed_workspace_head(&record.identity.workspace) + .unwrap(); + json!({ + "store_id": head.store_id, + "workspace_id": head.workspace_id, + "branch_id": head.branch_id, + "epoch": head.epoch, + "head_object_id": head.head_object_id, + "head_version": head.head_version + }) +} + +#[tokio::test(flavor = "multi_thread")] +async fn cross_workspace_and_duplicate_run_do_not_commit() { + let cycle = Cycle::open(&format!("t16-neg-{}", uuid::Uuid::new_v4().simple())); + let http = Http::bind(cycle.state.clone()).await; + install_pair(&http, &cycle.token, &cycle.source).await; + let (graph_a, _) = activate( + &http, + &cycle.token, + &cycle.config, + &cycle.db, + "org.rustscript.tools.effecta", + ) + .await; + let outside = cycle.root.join("outside"); + std::fs::create_dir_all(&outside).unwrap(); + let provider = Arc::new(ScriptedProvider::new()); + provider.push_ok(tool_call("write-a1", "write_file", "out-a.txt")); + provider.push_ok(stop_turn("done")); + cycle.state.service().inject_provider_host(provider); + let (status, session) = http + .request("POST", "/api/sessions", json!({}), Some(&cycle.token), None) + .await; + assert_eq!(status, 201, "{}", explain(status, &session)); + let session_id = session["session"]["id"].as_str().unwrap(); + let (status, rejected) = http + .request( + "POST", + "/v1/runs", + json!({"session_id": session_id, "input": "escape", "workspace": outside.display().to_string()}), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 422, "{}", explain(status, &rejected)); + assert_eq!(rejected["error"]["code"], "invalid_admission"); + assert!(!outside.join("out-a.txt").exists()); + let key = uuid::Uuid::new_v4().to_string(); + let body = json!({"session_id": session_id, "input": "write once", "extra": {"graph_id": "not-a-grant"}}); + let (status, first) = http + .request( + "POST", + "/v1/runs", + body.clone(), + Some(&cycle.token), + Some(&key), + ) + .await; + assert_eq!(status, 202, "{}", explain(status, &first)); + assert_eq!(first["graph_id"], graph_a); + let run_id = first["run_id"].as_str().unwrap().to_string(); + wait_until(|| { + event_names(&cycle.state, &run_id) + .iter() + .any(|name| name == "run.completed") + }) + .await; + let (status, effects) = http + .request( + "GET", + &format!("/v1/runs/{run_id}/effects?limit=128"), + json!(null), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &effects)); + let (status, duplicate) = http + .request("POST", "/v1/runs", body, Some(&cycle.token), Some(&key)) + .await; + assert_eq!(status, 202, "{}", explain(status, &duplicate)); + assert_eq!(duplicate["run_id"], run_id); + let (status, again) = http + .request( + "GET", + &format!("/v1/runs/{run_id}/effects?limit=128"), + json!(null), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &again)); + assert_eq!(again["entries"], effects["entries"]); + http.close().await; + shutdown_when_idle(&cycle.state).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn cancel_and_deadline_reject_late_approval_without_execution() { + let cycle = Cycle::open(&format!("t16-cancel-{}", uuid::Uuid::new_v4().simple())); + let http = Http::bind(cycle.state.clone()).await; + let provider = Arc::new(ScriptedProvider::new()); + provider.push_hang(); + cycle.state.service().inject_provider_host(provider.clone()); + let (status, session) = http + .request("POST", "/api/sessions", json!({}), Some(&cycle.token), None) + .await; + assert_eq!(status, 201, "{}", explain(status, &session)); + let session_id = session["session"]["id"].as_str().unwrap().to_string(); + let (status, run) = http + .request( + "POST", + "/v1/runs", + json!({"session_id": session_id, "input": "hang"}), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 202, "{}", explain(status, &run)); + let run_id = run["run_id"].as_str().unwrap().to_string(); + wait_until(|| provider.call_count() >= 1).await; + let (status, stopped) = http + .request( + "POST", + &format!("/v1/runs/{run_id}/stop"), + json!({}), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &stopped)); + wait_until(|| { + event_names(&cycle.state, &run_id) + .iter() + .any(|name| name == "run.cancelled") + }) + .await; + assert!( + !event_names(&cycle.state, &run_id) + .iter() + .any(|name| name == "run.completed") + ); + let (status, late) = http + .request( + "POST", + &format!("/v1/runs/{run_id}/approvals/late-approval"), + json!({"decision": "approved"}), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 409, "{}", explain(status, &late)); + assert_eq!(late["error"]["code"], "run_not_active"); + assert!(!cycle.workspace.join("out-a.txt").exists()); + http.close().await; + shutdown_when_idle(&cycle.state).await; + + let deadline = Cycle::open_timeout( + &format!("t16-deadline-{}", uuid::Uuid::new_v4().simple()), + Duration::from_secs(15), + ); + let http = Http::bind(deadline.state.clone()).await; + let provider = Arc::new(ScriptedProvider::new()); + provider.push_hang(); + deadline.state.service().inject_provider_host(provider); + let (status, session) = http + .request( + "POST", + "/api/sessions", + json!({}), + Some(&deadline.token), + None, + ) + .await; + assert_eq!(status, 201, "{}", explain(status, &session)); + let session_id = session["session"]["id"].as_str().unwrap(); + let (status, run) = http + .request( + "POST", + "/v1/runs", + json!({"session_id": session_id, "input": "expire"}), + Some(&deadline.token), + None, + ) + .await; + assert_eq!(status, 202, "{}", explain(status, &run)); + let run_id = run["run_id"].as_str().unwrap(); + wait_for( + || { + event_names(&deadline.state, run_id) + .iter() + .any(|name| name == "run.cancelled") + }, + Duration::from_secs(40), + ) + .await; + assert!( + !event_names(&deadline.state, run_id) + .iter() + .any(|name| name == "run.completed") + ); + let (status, late) = http + .request( + "POST", + &format!("/v1/runs/{run_id}/approvals/late-approval"), + json!({"decision": "approved"}), + Some(&deadline.token), + None, + ) + .await; + assert_eq!(status, 409, "{}", explain(status, &late)); + assert_eq!(late["error"]["code"], "run_not_active"); + assert!(!deadline.workspace.join("out-a.txt").exists()); + http.close().await; + shutdown_when_idle(&deadline.state).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn embedding_revoke_conflicts_on_the_next_http_admission() { + let cycle = Cycle::open(&format!("t16-revoke-{}", uuid::Uuid::new_v4().simple())); + cycle + .state + .service() + .materialize_cached_runner() + .expect("embedding runner materializes"); + let http = Http::bind(cycle.state.clone()).await; + let (status, session) = http + .request("POST", "/api/sessions", json!({}), Some(&cycle.token), None) + .await; + assert_eq!(status, 201, "{}", explain(status, &session)); + let session_id = session["session"]["id"].as_str().unwrap(); + let embedding = embedding_graph_id(&cycle.config, &cycle.db); + let generation = current_generation(&cycle.config, &cycle.db); + revoke_graph( + &cycle.config, + &cycle.db, + &bound_store(&cycle.db, &cycle.auth), + &embedding, + ) + .unwrap(); + let (status, prepared) = http + .request( + "POST", + &format!("/v1/plugin-graphs/{embedding}/prepare"), + json!({}), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 409, "{}", explain(status, &prepared)); + assert_eq!(prepared["error"]["code"], "plugin_graph_revoked"); + let (status, activated) = http + .request( + "POST", + "/v1/plugin-activations", + json!({ + "graph_id": embedding, + "deployment_space": "owner-space", + "expected_generation": generation + }), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 409, "{}", explain(status, &activated)); + assert_eq!(activated["error"]["code"], "plugin_graph_revoked"); + let provider = Arc::new(ScriptedProvider::new()); + provider.push_ok(tool_call("write-revoked", "write_file", "revoked.txt")); + provider.push_ok(stop_turn("should-not-run")); + cycle.state.service().inject_provider_host(provider); + let (status, denied) = http + .request( + "POST", + "/v1/runs", + json!({"session_id": session_id, "input": "after revoke"}), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 409, "{}", explain(status, &denied)); + assert_eq!(denied["error"]["code"], "plugin_graph_revoked"); + assert!(!cycle.workspace.join("revoked.txt").exists()); + http.close().await; + shutdown_when_idle(&cycle.state).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn checkpoint_branch_pins_origin_graph_not_the_later_active_graph() { + let cycle = Cycle::open(&format!("t16-branch-pin-{}", uuid::Uuid::new_v4().simple())); + let http = Http::bind(cycle.state.clone()).await; + install_pair(&http, &cycle.token, &cycle.source).await; + let (graph_a, generation_a) = activate( + &http, + &cycle.token, + &cycle.config, + &cycle.db, + "org.rustscript.tools.effecta", + ) + .await; + let provider = Arc::new(ScriptedProvider::new()); + provider.push_ok(tool_call("write-origin", "write_file", "out-a.txt")); + provider.push_ok(stop_turn("done-a")); + cycle.state.service().inject_provider_host(provider.clone()); + let (status, session) = http + .request("POST", "/api/sessions", json!({}), Some(&cycle.token), None) + .await; + assert_eq!(status, 201, "{}", explain(status, &session)); + let session_id = session["session"]["id"].as_str().unwrap().to_string(); + let (status, admitted) = http + .request( + "POST", + "/v1/runs", + json!({"session_id": session_id, "input": "write origin"}), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 202, "{}", explain(status, &admitted)); + assert_eq!(admitted["graph_id"], graph_a); + let run_a = admitted["run_id"].as_str().unwrap().to_string(); + let source_branch = admitted["workspace_branch"].as_str().unwrap().to_string(); + wait_until(|| { + event_names(&cycle.state, &run_a) + .iter() + .any(|name| name == "run.completed") + }) + .await; + let origin_projection = projected_run(&http, &cycle.token, &session_id, &run_a).await; + assert!( + origin_projection.contains("A-write"), + "durable message must retain the native marker: {origin_projection}" + ); + let (status, effects) = http + .request( + "GET", + &format!("/v1/runs/{run_a}/effects?limit=128"), + json!(null), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &effects)); + let write = effects["entries"] + .as_array() + .unwrap() + .iter() + .find(|entry| entry["call_id"] == "write-origin") + .expect("origin write") + .clone(); + let workspace_id = write["workspace_id"].as_str().unwrap().to_string(); + let epoch = write["epoch"].as_u64().unwrap(); + let (status, checkpoint) = http + .request( + "POST", + &format!("/v1/workspaces/{workspace_id}/checkpoints"), + json!({ + "source_run_id": run_a, + "workspace_id": workspace_id, + "source_branch_id": source_branch, + "expected_epoch": epoch, + "request_id": uuid::Uuid::new_v4().to_string(), + "mode": "at_closed_batch" + }), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 201, "{}", explain(status, &checkpoint)); + assert_eq!(checkpoint["checkpoint"]["graph"]["graph_id"], graph_a); + let checkpoint_id = checkpoint["checkpoint"]["checkpoint_id"] + .as_str() + .unwrap() + .to_string(); + let (graph_b, generation_b) = activate( + &http, + &cycle.token, + &cycle.config, + &cycle.db, + "org.rustscript.tools.effectb", + ) + .await; + assert_ne!(graph_b, graph_a); + assert!(generation_b > generation_a); + let (status, created) = http + .request( + "POST", + &format!("/v1/workspaces/{workspace_id}/branches"), + json!({ + "workspace_id": workspace_id, + "source_branch_id": source_branch, + "checkpoint_id": checkpoint_id, + "request_id": uuid::Uuid::new_v4().to_string() + }), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 201, "{}", explain(status, &created)); + let selected = created["branch"]["branch_id"].as_str().unwrap().to_string(); + let handles = cycle.state.service().handle_count(); + for graph_id in ["caller-forged-graph", " ", ""] { + let (status, denied) = http + .request( + "POST", + "/v1/runs", + json!({ + "session_id": session_id, + "input": "foreign graph", + "workspace_branch": selected, + "graph_id": graph_id + }), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 422, "{graph_id}: {}", explain(status, &denied)); + assert_eq!(denied["error"]["code"], "plugin_graph_pin_required"); + assert!(denied.get("run_id").is_none()); + } + let foreign_branch = format!("br-{}", "ab".repeat(16)); + let (status, missing) = http + .request( + "POST", + "/v1/runs", + json!({ + "session_id": session_id, + "input": "foreign branch", + "workspace_branch": foreign_branch, + "graph_id": graph_a + }), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 404, "{}", explain(status, &missing)); + assert_eq!(missing["error"]["code"], "managed_branch_missing"); + assert_eq!(cycle.state.service().handle_count(), handles); + provider.push_ok(tool_call("write-branch", "write_file", "branch-only.txt")); + provider.push_ok(stop_turn("done-branch")); + cycle.state.service().inject_provider_host(provider.clone()); + let idem = uuid::Uuid::new_v4().to_string(); + let branch_body = json!({ + "session_id": session_id, + "input": "write on checkpoint branch", + "workspace_branch": selected + }); + let (status, branch_run) = http + .request( + "POST", + "/v1/runs", + branch_body.clone(), + Some(&cycle.token), + Some(&idem), + ) + .await; + assert_eq!(status, 202, "{}", explain(status, &branch_run)); + assert_eq!(branch_run["graph_id"], graph_a); + assert_eq!(branch_run["activation_generation"], generation_a); + assert_eq!(branch_run["workspace_branch"], selected); + assert_eq!( + branch_run["head_object_id"], + created["branch"]["head_object_id"] + ); + assert_eq!( + branch_run["head_version"], + created["branch"]["head_version"] + ); + assert!(branch_run["publication"].is_null()); + let branch_run_id = branch_run["run_id"].as_str().unwrap().to_string(); + let pinned = pin_for_run(&cycle.config, &cycle.db, &branch_run_id); + assert_eq!(pinned["graph_id"], graph_a); + assert_eq!(pinned["generation"], generation_a); + let (status, replay) = http + .request( + "POST", + "/v1/runs", + branch_body, + Some(&cycle.token), + Some(&idem), + ) + .await; + assert_eq!(status, 202, "{}", explain(status, &replay)); + assert_eq!(replay["run_id"], branch_run_id); + assert_eq!(replay["graph_id"], graph_a); + wait_until(|| { + event_names(&cycle.state, &branch_run_id) + .iter() + .any(|name| name == "run.completed") + }) + .await; + let branch_projection = projected_run(&http, &cycle.token, &session_id, &branch_run_id).await; + assert!(branch_projection.contains("A-write"), "{branch_projection}"); + assert!( + !branch_projection.contains("B-write"), + "{branch_projection}" + ); + let (status, branch_effects) = http + .request( + "GET", + &format!("/v1/runs/{branch_run_id}/effects?limit=128"), + json!(null), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &branch_effects)); + let branch_writes = branch_effects["entries"] + .as_array() + .unwrap() + .iter() + .filter(|entry| entry["call_id"] == "write-branch") + .count(); + assert_eq!( + branch_writes, 1, + "replay must not duplicate the branch effect" + ); + assert_eq!( + std::fs::read(cycle.workspace.join("seed.txt")).unwrap(), + b"seed" + ); + assert!(!cycle.workspace.join("branch-only.txt").exists()); + provider.push_ok(tool_call("write-current", "write_file", "out-b.txt")); + provider.push_ok(stop_turn("done-b")); + cycle.state.service().inject_provider_host(provider.clone()); + let (status, current) = http + .request( + "POST", + "/v1/runs", + json!({"session_id": session_id, "input": "write current"}), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 202, "{}", explain(status, ¤t)); + assert_eq!(current["graph_id"], graph_b); + assert_eq!(current["activation_generation"], generation_b); + assert_eq!(current["workspace_branch"], source_branch); + let current_id = current["run_id"].as_str().unwrap().to_string(); + wait_until(|| { + event_names(&cycle.state, ¤t_id) + .iter() + .any(|name| name == "run.completed") + }) + .await; + let current_projection = projected_run(&http, &cycle.token, &session_id, ¤t_id).await; + assert!( + current_projection.contains("B-write"), + "{current_projection}" + ); + assert!( + !current_projection.contains("A-write"), + "{current_projection}" + ); + let branch_after = projected_run(&http, &cycle.token, &session_id, &branch_run_id).await; + assert!(branch_after.contains("A-write"), "{branch_after}"); + assert!(!branch_after.contains("B-write"), "{branch_after}"); + provider.push_hang(); + cycle.state.service().inject_provider_host(provider.clone()); + let calls = provider.call_count(); + let (status, hung) = http + .request( + "POST", + "/v1/runs", + json!({ + "session_id": session_id, + "input": "cancel on origin graph", + "workspace_branch": selected + }), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 202, "{}", explain(status, &hung)); + assert_eq!(hung["graph_id"], graph_a); + let hung_id = hung["run_id"].as_str().unwrap().to_string(); + wait_until(|| provider.call_count() > calls).await; + let (status, stopped) = http + .request( + "POST", + &format!("/v1/runs/{hung_id}/stop"), + json!({}), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &stopped)); + wait_until(|| { + event_names(&cycle.state, &hung_id) + .iter() + .any(|name| name == "run.cancelled") + }) + .await; + assert!( + !event_names(&cycle.state, &hung_id) + .iter() + .any(|name| name == "run.completed") + ); + let hung_projection = projected_run(&http, &cycle.token, &session_id, &hung_id).await; + assert!(!hung_projection.contains("B-write"), "{hung_projection}"); + let pinned_after = pin_for_run(&cycle.config, &cycle.db, &branch_run_id); + assert_eq!(pinned_after["graph_id"], graph_a); + assert_eq!(pinned_after["generation"], generation_a); + http.close().await; + shutdown_when_idle(&cycle.state).await; +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn fault_cut_does_not_publish_and_active_writer_blocks_checkpoint() { + use rustscript_agent::effects::ManagedEffectFault; + let cycle = Cycle::open(&format!("t16-fault-{}", uuid::Uuid::new_v4().simple())); + let http = Http::bind(cycle.state.clone()).await; + install_pair(&http, &cycle.token, &cycle.source).await; + let (_graph, _generation) = activate( + &http, + &cycle.token, + &cycle.config, + &cycle.db, + "org.rustscript.tools.effecta", + ) + .await; + cycle + .state + .service() + .inject_managed_effect_fault(ManagedEffectFault::AfterIntent) + .unwrap(); + let provider = Arc::new(ScriptedProvider::new()); + provider.push_ok(tool_call("write-cut", "write_file", "cut.txt")); + provider.push_ok(stop_turn("cut-done")); + cycle.state.service().inject_provider_host(provider); + let (status, session) = http + .request("POST", "/api/sessions", json!({}), Some(&cycle.token), None) + .await; + assert_eq!(status, 201, "{}", explain(status, &session)); + let session_id = session["session"]["id"].as_str().unwrap().to_string(); + let (status, run) = http + .request( + "POST", + "/v1/runs", + json!({"session_id": session_id, "input": "cut"}), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 202, "{}", explain(status, &run)); + let run_id = run["run_id"].as_str().unwrap().to_string(); + wait_until(|| { + event_names(&cycle.state, &run_id) + .iter() + .any(|name| name == "run.failed") + }) + .await; + let fault_events = cycle.state.service().run_events(&run_id); + let fault_names = event_names(&cycle.state, &run_id); + assert_eq!( + fault_names + .iter() + .filter(|name| { + *name == "run.completed" || *name == "run.failed" || *name == "run.cancelled" + }) + .collect::>(), + vec!["run.failed"], + "{fault_events:?}" + ); + let failed = fault_events + .iter() + .find(|event| event["event"] == "run.failed") + .expect("run.failed"); + assert_eq!(failed["data"]["error_code"], "agent_failed"); + assert_eq!( + failed["data"]["error_message"], + "tool validation failure could not be recorded" + ); + assert_eq!(failed["data"]["status"], "failed"); + assert!( + fault_events.iter().any(|event| { + event["event"] == "tool.started" && event["data"]["tool_call_id"] == "write-cut" + }), + "AfterIntent still leaves the started intent: {fault_events:?}" + ); + assert!( + !fault_events + .iter() + .any(|event| { event["event"] == "tool.completed" || event["event"] == "tool.failed" }), + "the cut is not a recorded tool terminal: {fault_events:?}" + ); + assert!( + !cycle + .state + .service() + .session_messages(&session_id) + .iter() + .any(|message| message["tool_call_id"] == "write-cut"), + "unrecorded validation failure must not publish a tool result" + ); + let (status, effects) = http + .request( + "GET", + &format!("/v1/runs/{run_id}/effects?limit=128"), + json!(null), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &effects)); + let entry = effects["entries"] + .as_array() + .unwrap() + .iter() + .find(|entry| entry["call_id"] == "write-cut") + .expect("fault leaves a durable intent"); + assert_ne!(entry["state"], "committed"); + assert_eq!(entry["state"], "indeterminate"); + let fault_record = cycle + .state + .persistence() + .unwrap() + .managed_effect_get(&rustscript_agent::effects::ManagedEffectGetRequest { + owner: rustscript_agent::effects::ManagedOwnerScope { + profile: "gateway".into(), + session_id: session_id.clone(), + run_id: run_id.clone(), + }, + call_id: "write-cut".into(), + }) + .unwrap() + .expect("durable intent record"); + assert!( + fault_record.prepared.is_none(), + "AfterIntent precedes native view construction" + ); + assert!( + fault_record.outcome.is_none(), + "cut must not publish an outcome" + ); + assert_eq!( + fault_record.state, + rustscript_agent::effects::ManagedPublicationStatus::Indeterminate + ); + assert!(!contains_marker(&cycle.state, &run_id, "A-write")); + let before = cycle.state.service().available_capacity(); + let handles = cycle.state.service().handle_count(); + assert!(before > 0); + assert!(handles > 0); + + let gate = cycle.state.service().arm_managed_prepare_gate().unwrap(); + let provider = Arc::new(ScriptedProvider::new()); + provider.push_ok(tool_call("write-busy", "write_file", "busy.txt")); + provider.push_ok(stop_turn("busy-done")); + cycle.state.service().inject_provider_host(provider); + let (status, busy_run) = http + .request( + "POST", + "/v1/runs", + json!({"session_id": session_id, "input": "hold writer"}), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 202, "{}", explain(status, &busy_run)); + gate.wait_arrived(1).unwrap(); + let head = current_head(&cycle, &session_id, &run_id, "write-cut"); + let workspace_id = head["workspace_id"].as_str().unwrap(); + let handles_before_branch = cycle.state.service().handle_count(); + let (status, head_only) = http + .request( + "POST", + &format!("/v1/workspaces/{workspace_id}/branches"), + json!({ + "workspace_id": workspace_id, + "source_branch_id": head["branch_id"], + "expected_source": head, + "request_id": uuid::Uuid::new_v4().to_string() + }), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 409, "{}", explain(status, &head_only)); + assert_eq!(head_only["error"]["code"], "managed_checkpoint_busy"); + assert_eq!(cycle.state.service().handle_count(), handles_before_branch); + let (status, busy) = http + .request( + "POST", + &format!("/v1/workspaces/{workspace_id}/checkpoints"), + json!({ + "source_run_id": busy_run["run_id"], + "workspace_id": workspace_id, + "source_branch_id": head["branch_id"], + "expected_epoch": head["epoch"], + "request_id": uuid::Uuid::new_v4().to_string(), + "mode": "at_closed_batch" + }), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 409, "{}", explain(status, &busy)); + assert_eq!(busy["error"]["code"], "managed_checkpoint_busy"); + gate.release(); + http.close().await; + shutdown_when_idle(&cycle.state).await; +} diff --git a/tests/plugin_graph_tests.rs b/tests/plugin_graph_tests.rs index f30a5f9..64e8788 100644 --- a/tests/plugin_graph_tests.rs +++ b/tests/plugin_graph_tests.rs @@ -207,6 +207,7 @@ fn missing_conflict_and_cycle_return_minimal_chains() { &[("invoke.rss", "pub fn invoke(value: int) -> int { value }\n")], ); let missing = store.resolve(&ResolveRequest { + harness_selection: rustscript_agent::plugins::HarnessSelectionRequest::Unselected, roots: vec![PackageRequirement { id: "org.rustscript.missing".to_string(), version: "1.0.0".to_string(), @@ -216,6 +217,7 @@ fn missing_conflict_and_cycle_return_minimal_chains() { assert_kind(missing.unwrap_err(), PluginErrorKind::MissingDependency); let conflict = store.resolve(&ResolveRequest { + harness_selection: rustscript_agent::plugins::HarnessSelectionRequest::Unselected, roots: vec![ PackageRequirement { id: "org.rustscript.tools.low".to_string(), @@ -266,6 +268,7 @@ fn missing_conflict_and_cycle_return_minimal_chains() { &[("invoke.rss", "pub fn invoke(value: int) -> int { value }\n")], ); let cycle = store.resolve(&ResolveRequest { + harness_selection: rustscript_agent::plugins::HarnessSelectionRequest::Unselected, roots: vec![PackageRequirement { id: "org.rustscript.tools.left".to_string(), version: "1.0.0".to_string(), @@ -310,6 +313,7 @@ fn shared_constraint_backtracks_across_later_roots() { ); } let request = ResolveRequest { + harness_selection: rustscript_agent::plugins::HarnessSelectionRequest::Unselected, roots: ["a", "b"] .iter() .map(|name| PackageRequirement { @@ -391,6 +395,7 @@ fn duplicate_version_candidates_are_ambiguous() { let err = resolve_installed( &packages, &ResolveRequest { + harness_selection: rustscript_agent::plugins::HarnessSelectionRequest::Unselected, roots: vec![PackageRequirement { id: "org.rustscript.lib.text".to_string(), version: "1.0.0".to_string(), @@ -458,6 +463,7 @@ fn install_order_does_not_change_the_lock_and_closure_is_exact() { } let lock = store .resolve(&ResolveRequest { + harness_selection: rustscript_agent::plugins::HarnessSelectionRequest::Unselected, roots: vec![PackageRequirement { id: "org.rustscript.tools.caller".to_string(), version: "1.0.0".to_string(), @@ -515,6 +521,7 @@ fn lock_persists_caller_entry_alias_requirement_and_distinct_provenance() { ); let lock = store .resolve(&ResolveRequest { + harness_selection: rustscript_agent::plugins::HarnessSelectionRequest::Unselected, roots: vec![PackageRequirement { id: "org.rustscript.tools.caller".to_string(), version: "1.0.0".to_string(), @@ -559,6 +566,7 @@ fn prepare_does_not_compile_store_bytes_replaced_after_install() { ); let lock = store .resolve(&ResolveRequest { + harness_selection: rustscript_agent::plugins::HarnessSelectionRequest::Unselected, roots: vec![PackageRequirement { id: "org.rustscript.tools.pure".to_string(), version: "1.0.0".to_string(), @@ -618,6 +626,7 @@ fn undeclared_transitive_effect_is_rejected() { ); let err = store .resolve(&ResolveRequest { + harness_selection: rustscript_agent::plugins::HarnessSelectionRequest::Unselected, roots: vec![PackageRequirement { id: "org.rustscript.tools.caller".to_string(), version: "1.0.0".to_string(), @@ -651,6 +660,7 @@ fn prepare_rejects_undeclared_direct_effect_unauthorized_host_and_pure_disguise( ); let lock = store .resolve(&ResolveRequest { + harness_selection: rustscript_agent::plugins::HarnessSelectionRequest::Unselected, roots: vec![PackageRequirement { id: "org.rustscript.tools.disguise".to_string(), version: "1.0.0".to_string(), @@ -681,6 +691,7 @@ fn prepare_rejects_undeclared_direct_effect_unauthorized_host_and_pure_disguise( ); let lock = store .resolve(&ResolveRequest { + harness_selection: rustscript_agent::plugins::HarnessSelectionRequest::Unselected, roots: vec![PackageRequirement { id: "org.rustscript.tools.sqlite".to_string(), version: "1.0.0".to_string(), @@ -721,6 +732,7 @@ fn prepare_uses_captured_source_and_compiles_the_closed_entry() { .unwrap(); let lock = store .resolve(&ResolveRequest { + harness_selection: rustscript_agent::plugins::HarnessSelectionRequest::Unselected, roots: vec![PackageRequirement { id: "org.rustscript.tools.pure".to_string(), version: "1.0.0".to_string(), @@ -783,6 +795,7 @@ fn plugin_import_cannot_bypass_the_broker_boundary() { ); let lock = store .resolve(&ResolveRequest { + harness_selection: rustscript_agent::plugins::HarnessSelectionRequest::Unselected, roots: vec![PackageRequirement { id: "org.rustscript.tools.caller".to_string(), version: "1.0.0".to_string(), @@ -812,6 +825,7 @@ fn prepare_policy_source( &[("invoke.rss", source)], ); let lock = store.resolve(&ResolveRequest { + harness_selection: rustscript_agent::plugins::HarnessSelectionRequest::Unselected, roots: vec![PackageRequirement { id: "org.rustscript.tools.policy".to_string(), version: "1.0.0".to_string(), @@ -922,6 +936,7 @@ fn graph_package(name: &str, version: &str, deps: &[(&str, &str)]) -> InstalledP fn graph_request(names: &[&str]) -> ResolveRequest { ResolveRequest { + harness_selection: rustscript_agent::plugins::HarnessSelectionRequest::Unselected, roots: names .iter() .map(|name| PackageRequirement { @@ -1332,6 +1347,7 @@ fn resolver_budget_is_a_typed_error() { let err = resolve_installed( &packages, &ResolveRequest { + harness_selection: rustscript_agent::plugins::HarnessSelectionRequest::Unselected, roots: vec![PackageRequirement { id: "org.rustscript.lib.text".to_string(), version: ">=1.0.0".to_string(), diff --git a/tests/plugin_hot_reload_tests.rs b/tests/plugin_hot_reload_tests.rs new file mode 100644 index 0000000..8fee002 --- /dev/null +++ b/tests/plugin_hot_reload_tests.rs @@ -0,0 +1,639 @@ +//! Stored graph permission review data never grants activation authority. + +use std::path::PathBuf; + +use rustscript_agent::plugins::{ + COMPILE_ENVIRONMENT, EffectOrigin, GraphLock, HOST_RUNTIME, LockedEdge, LockedEffect, + LockedEffectItem, LockedPackage, PluginControlStore, ScopeBinding, +}; +use rustscript_agent::{AgentGatewayConfig, GatewayPersistence}; + +// The typed sample_lock fixture follows plugin_store_recovery_tests. +fn sample_lock(graph_id: &str, version: &str) -> GraphLock { + let direct = EffectOrigin { + relation: "direct".into(), + caller_package: "org.example.tools".into(), + caller_entry: "invoke".into(), + alias: String::new(), + callee_package: "org.example.tools".into(), + callee_version: version.into(), + callee_entry: "invoke".into(), + version_requirement: String::new(), + }; + let delegated = EffectOrigin { + relation: "library".into(), + alias: "files".into(), + callee_package: "org.example.lib".into(), + callee_version: "0.4.1".into(), + version_requirement: "^0.4.1".into(), + ..direct.clone() + }; + GraphLock { + graph_id: graph_id.into(), + harness_selection: rustscript_agent::plugins::HarnessSelection::Unselected, + host_runtime: HOST_RUNTIME.into(), + compile_environment: COMPILE_ENVIRONMENT.into(), + packages: vec![ + LockedPackage { + id: "org.example.tools".into(), + version: version.into(), + kind: "tool".into(), + artifact_id: format!("artifact-tools-{version}"), + }, + LockedPackage { + id: "org.example.lib".into(), + version: "0.4.1".into(), + kind: "library".into(), + artifact_id: "artifact-lib-exact".into(), + }, + ], + edges: vec![LockedEdge { + from: "org.example.tools".into(), + from_entry: "invoke".into(), + alias: "files".into(), + to: "org.example.lib".into(), + to_version: "0.4.1".into(), + to_artifact_id: "artifact-lib-exact".into(), + to_entry: "invoke".into(), + relation: "library".into(), + version_requirement: "^0.4.1".into(), + bindings: vec![ScopeBinding { + kind: "workspace.fs.read".into(), + scope: "bound_workspace".into(), + recovery: "none".into(), + }], + }], + closure: vec![LockedEffect { + package: "org.example.tools".into(), + entry: "invoke".into(), + effects: vec![ + LockedEffectItem { + kind: "workspace.fs.read".into(), + scope: "bound_workspace".into(), + recovery: "none".into(), + via: direct, + }, + LockedEffectItem { + kind: "workspace.fs.read".into(), + scope: "bound_workspace".into(), + recovery: "none".into(), + via: delegated, + }, + ], + }], + } +} + +fn open() -> GatewayPersistence { + let base = PathBuf::from(std::env::var_os("RUSTSCRIPT_AGENT_TEST_TMPDIR").expect("lease TEMP")); + let dir = base.join(format!("permission-diff-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&dir).expect("fixture directory"); + GatewayPersistence::open(&AgentGatewayConfig::default(), &dir.join("gateway.db")) + .expect("production typed persistence") +} + +#[test] +fn patch_direct_effect_addition_requires_reapproval_without_writes() { + let persistence = open(); + let store = PluginControlStore::new(&persistence); + let before = sample_lock("graph-before", "1.0.0"); + let mut after = sample_lock("graph-after", "1.0.1"); + let mut added = after.closure[0].effects[0].clone(); + added.kind = "clock.read".into(); + added.scope = "bound_run".into(); + after.closure[0].effects.push(added); + store.store_lock(&before).expect("valid old graph"); + store.store_lock(&after).expect("valid patch"); + let activation = store.activation().expect("activation before"); + let history = store.activation_history().expect("history before"); + let refs = store.package_refs(&before.graph_id).expect("refs before"); + let diff = + readonly_diff(&store, &before.graph_id, &after.graph_id).expect("stored permission diff"); + assert!( + diff.requires_reapproval, + "new direct permission requires review" + ); + assert!( + format!("{diff:?}").contains("Added"), + "diff explains addition: {diff:?}" + ); + assert_eq!(diff.changes.len(), 1); + assert_eq!( + diff.changes[0].kind, + rustscript_agent::plugins::PermissionChangeKind::Added + ); + assert_eq!(diff.changes[0].identity.kind, "clock.read"); + assert_eq!(diff.changes[0].identity.relation, "direct"); + assert_eq!(store.activation().expect("activation after"), activation); + assert_eq!(store.activation_history().expect("history after"), history); + assert_eq!( + store.package_refs(&before.graph_id).expect("refs after"), + refs + ); + persistence.shutdown(); +} + +#[test] +fn callee_effect_expansion_requires_review_for_patch_and_major_versions() { + let persistence = open(); + let store = PluginControlStore::new(&persistence); + let before = sample_lock("graph-before", "1.0.0"); + store.store_lock(&before).expect("old graph"); + for (id, version) in [("graph-patch", "1.0.1"), ("graph-major", "2.0.0")] { + let mut after = sample_lock(id, version); + let mut added = after.closure[0].effects[1].clone(); + added.kind = "clock.read".into(); + added.scope = "bound_run".into(); + after.closure[0].effects.push(added); + after.edges[0].bindings.push(ScopeBinding { + kind: "clock.read".into(), + scope: "bound_run".into(), + recovery: "none".into(), + }); + store.store_lock(&after).expect("valid delegated patch"); + let diff = readonly_diff(&store, &before.graph_id, id).expect("diff"); + assert!( + diff.requires_reapproval, + "callee expansion requires review: {diff:?}" + ); + assert!(diff.changes.iter().any(|change| { + change.identity.relation == "library" && change.identity.kind == "clock.read" + })); + } + assert_eq!( + store + .activation() + .expect("no automatic activation") + .generation, + 0 + ); + assert!(store.activation_history().expect("no history").is_empty()); + persistence.shutdown(); +} + +#[test] +fn recovery_downgrade_requires_review_but_strengthening_does_not() { + let persistence = open(); + let store = PluginControlStore::new(&persistence); + let mut checkpoint = sample_lock("graph-checkpoint", "1.0.0"); + checkpoint.closure[0].effects[0].kind = "workspace.fs.write".into(); + checkpoint.closure[0].effects[0].recovery = "checkpoint".into(); + let mut none = checkpoint.clone(); + none.graph_id = "graph-none".into(); + none.packages[0].version = "1.0.1".into(); + none.closure[0].effects[0].via.callee_version = "1.0.1".into(); + none.closure[0].effects[0].recovery = "none".into(); + store + .store_lock(&checkpoint) + .expect("legal checkpoint graph"); + store.store_lock(&none).expect("legal none graph"); + let down = readonly_diff(&store, &checkpoint.graph_id, &none.graph_id).expect("downgrade"); + assert!( + down.requires_reapproval, + "recovery guarantee reduced: {down:?}" + ); + assert!(format!("{down:?}").contains("RecoveryDowngraded")); + assert_eq!( + down.changes[0].kind, + rustscript_agent::plugins::PermissionChangeKind::RecoveryDowngraded + ); + assert_eq!(down.changes[0].before[0].recovery, "checkpoint"); + assert_eq!(down.changes[0].after[0].recovery, "none"); + let up = readonly_diff(&store, &none.graph_id, &checkpoint.graph_id).expect("strengthening"); + assert!( + !up.requires_reapproval, + "strengthening does not reduce recovery" + ); + assert!(format!("{up:?}").contains("RecoveryChanged")); + assert!(!format!("{up:?}").contains("RecoveryDowngraded")); + assert_eq!( + up.changes[0].kind, + rustscript_agent::plugins::PermissionChangeKind::RecoveryChanged + ); + assert_eq!(store.activation().expect("unchanged").generation, 0); + persistence.shutdown(); +} + +#[test] +fn identical_permissions_patch_remains_unchecked_and_explicit_cas_advances() { + use rustscript_agent::plugins::{GraphRetention, PackageArtifactRef}; + let persistence = open(); + let store = PluginControlStore::new(&persistence); + let before = sample_lock("graph-old", "1.0.0"); + let mut after = sample_lock("graph-new", "1.0.1"); + after.packages.reverse(); + after.closure[0].effects.reverse(); + for lock in [&before, &after] { + store.store_lock(lock).expect("legal lock"); + } + let retention = GraphRetention { + graph_id: before.graph_id.clone(), + owner_kind: "run".into(), + owner_id: "run-kept".into(), + ref_id: "ref-kept".into(), + }; + store.retain(&retention).expect("retain previous graph"); + let old_refs = store.package_refs(&before.graph_id).expect("old refs"); + store + .activate(&before.graph_id, 0, &old_refs) + .expect("explicit initial activation"); + let activation = store.activation().expect("before"); + let history = store.activation_history().expect("history before"); + let holds = store.retained(&before.graph_id).expect("holds before"); + let new_refs: Vec = store.package_refs(&after.graph_id).expect("new refs"); + let diff = readonly_diff(&store, &before.graph_id, &after.graph_id).expect("patch diff"); + assert!(!diff.requires_reapproval); + assert!( + diff.changes.is_empty(), + "revisions are not permission changes: {diff:?}" + ); + assert!( + format!("{diff:?}").contains("Unchecked"), + "compatibility is unverified: {diff:?}" + ); + assert_eq!(store.activation().expect("after diff"), activation); + assert_eq!( + store.activation_history().expect("history after diff"), + history + ); + assert_eq!( + store.retained(&before.graph_id).expect("holds after"), + holds + ); + assert_eq!( + store + .package_refs(&before.graph_id) + .expect("old refs after"), + old_refs + ); + assert_eq!( + store.package_refs(&after.graph_id).expect("new refs after"), + new_refs + ); + let second = store + .activate(&after.graph_id, activation.generation, &new_refs) + .expect("explicit CAS"); + assert_eq!(second.generation, activation.generation + 1); + let third = store + .activate(&before.graph_id, second.generation, &old_refs) + .expect("explicit return to retained graph"); + assert_eq!(third.generation, second.generation + 1); + assert_eq!(store.activation().expect("read back"), third); + let history = store.activation_history().expect("three activations"); + assert_eq!(history.len(), 3); + assert_eq!(history[2].previous_graph_id, after.graph_id); + assert_eq!(history[2].generation, 3); + assert_eq!( + store + .activate(&after.graph_id, 1, &new_refs) + .expect_err("stale CAS") + .code, + "plugin_activation_conflict" + ); + assert_eq!( + store + .retained(&before.graph_id) + .expect("retained old graph"), + vec![retention] + ); + persistence.shutdown(); +} + +#[test] +fn removed_effects_are_reported_with_compatibility_unchecked() { + use rustscript_agent::plugins::CompatibilityCheck; + let persistence = open(); + let store = PluginControlStore::new(&persistence); + let before = sample_lock("graph-full", "1.0.0"); + let mut after = sample_lock("graph-reduced", "1.0.1"); + after.closure[0].effects.clear(); + after.edges[0].bindings.clear(); + store.store_lock(&before).expect("full"); + store.store_lock(&after).expect("reduced"); + let diff = readonly_diff(&store, &before.graph_id, &after.graph_id).expect("diff"); + assert!(!diff.requires_reapproval); + assert_eq!(diff.compatibility, CompatibilityCheck::Unchecked); + assert!( + format!("{diff:?}").contains("Removed"), + "explain removals: {diff:?}" + ); + assert_eq!( + diff.changes + .iter() + .filter(|change| change.identity.relation == "direct") + .count(), + 1 + ); + assert!( + diff.changes + .iter() + .any(|change| change.identity.relation == "library") + ); + assert_eq!(store.activation().expect("no activation").generation, 0); + persistence.shutdown(); +} + +#[test] +fn delegated_binding_expansion_is_compared_even_with_unchanged_closure() { + let persistence = open(); + let store = PluginControlStore::new(&persistence); + let before = sample_lock("graph-bind-old", "1.0.0"); + let mut after = sample_lock("graph-bind-new", "1.0.1"); + after.edges[0].bindings.push(ScopeBinding { + kind: "clock.read".into(), + scope: "bound_run".into(), + recovery: "none".into(), + }); + store.store_lock(&before).expect("old"); + store + .store_lock(&after) + .expect("expanded allowed delegation"); + let diff = readonly_diff(&store, &before.graph_id, &after.graph_id).expect("diff"); + assert!( + diff.requires_reapproval, + "edge upper bound expands: {diff:?}" + ); + assert_eq!(diff.changes.len(), 1); + assert!(format!("{diff:?}").contains("EdgeBinding")); + assert_eq!( + diff.changes[0].identity.location, + rustscript_agent::plugins::PermissionLocation::EdgeBinding + ); + assert_eq!(diff.changes[0].identity.kind, "clock.read"); + persistence.shutdown(); +} + +#[test] +fn pure_dependency_entry_and_edge_changes_remain_explicitly_unchecked() { + use rustscript_agent::plugins::CompatibilityCheck; + let persistence = open(); + let store = PluginControlStore::new(&persistence); + let mut before = sample_lock("graph-pure-old", "1.0.0"); + before.closure[0].effects.clear(); + before.edges[0].bindings.clear(); + store.store_lock(&before).expect("pure old graph"); + let mut after = before.clone(); + after.graph_id = "graph-pure-new".into(); + after.packages[1].version = "0.4.2".into(); + after.packages[1].artifact_id = "artifact-lib-new".into(); + after.edges[0].to_version = "0.4.2".into(); + after.edges[0].to_artifact_id = "artifact-lib-new".into(); + after.edges[0].alias = "renamed".into(); + after.edges[0].from_entry = "run".into(); + after.closure[0].entry = "run".into(); + store + .store_lock(&after) + .expect("valid pure dependency revision"); + let diff = readonly_diff(&store, &before.graph_id, &after.graph_id).expect("diff"); + assert!(!diff.requires_reapproval); + assert!(diff.changes.is_empty()); + assert_eq!(diff.compatibility, CompatibilityCheck::Unchecked); + let text = format!("{diff:?}"); + assert!( + text.contains("edges_changed: true"), + "edge changes must be visible: {text}" + ); + assert!( + text.contains("entries_changed: true"), + "entry changes must be visible: {text}" + ); + assert!( + text.contains("0.4.2"), + "revisions must be separate from permission changes: {text}" + ); + assert!(diff.metadata.edges_changed); + assert!(diff.metadata.entries_changed); + assert_eq!(diff.metadata.package_revisions.len(), 1); + assert_eq!( + diff.metadata.package_revisions[0] + .after + .as_ref() + .expect("candidate revision") + .version, + "0.4.2" + ); + persistence.shutdown(); +} + +fn readonly_diff( + store: &PluginControlStore<'_>, + old: &str, + new: &str, +) -> Result +{ + let snapshot = || { + let ids = store.list_locks().expect("stored ids"); + let graphs = ids + .iter() + .map(|id| { + ( + store.load_lock(id).expect("exact graph"), + store.package_refs(id).expect("package refs"), + store.retained(id).expect("run/checkpoint refs"), + ) + }) + .collect::>(); + ( + ids, + graphs, + store.activation().expect("pointer"), + store.activation_history().expect("history"), + ) + }; + let before = snapshot(); + let diff = store.permission_diff(old, new); + assert_eq!( + snapshot(), + before, + "permission comparison is read-only even on error" + ); + if let Ok(diff) = &diff { + assert_eq!( + diff.compatibility, + rustscript_agent::plugins::CompatibilityCheck::Unchecked + ); + let unique = diff + .changes + .iter() + .collect::>(); + assert_eq!( + unique.len(), + diff.changes.len(), + "no duplicate change items" + ); + assert!( + diff.changes.windows(2).all(|pair| pair[0] < pair[1]), + "deterministic ordering" + ); + assert!(diff.changes.len() <= 4 * 4096, "bounded change output"); + } + diff +} + +#[test] +fn ordering_duplicates_and_graph_ids_do_not_change_permission_semantics() { + use rustscript_agent::plugins::PermissionLocation; + let persistence = open(); + let store = PluginControlStore::new(&persistence); + let mut old = sample_lock("graph-order-old", "1.0.0"); + let mut other_entry = old.closure[0].clone(); + other_entry.package = "org.example.lib".into(); + other_entry.effects.clear(); + old.closure.push(other_entry); + let mut other_edge = old.edges[0].clone(); + other_edge.alias = "other".into(); + other_edge.bindings.clear(); + old.edges.push(other_edge); + let mut new = old.clone(); + new.graph_id = "graph-order-new".into(); + let mut added = new.closure[0].effects[0].clone(); + added.kind = "clock.read".into(); + added.scope = "bound_run".into(); + new.closure[0].effects.push(added.clone()); + let mut permuted = new.clone(); + permuted.graph_id = "graph-order-permuted".into(); + permuted.packages.reverse(); + permuted.edges.reverse(); + for edge in &mut permuted.edges { + edge.bindings.reverse(); + } + for entry in &mut permuted.closure { + entry.effects.reverse(); + } + permuted.closure.reverse(); + let mut duplicates = new.clone(); + duplicates.graph_id = "graph-order-duplicates".into(); + duplicates.closure[0].effects.push(added); + let duplicate_binding = duplicates.edges[0].bindings[0].clone(); + duplicates.edges[0].bindings.push(duplicate_binding); + for lock in [&old, &new, &permuted, &duplicates] { + store.store_lock(lock).expect("valid ordering fixture"); + } + let expected = readonly_diff(&store, &old.graph_id, &new.graph_id).expect("baseline diff"); + assert!(expected.requires_reapproval); + assert_eq!(expected.changes.len(), 1); + assert_eq!( + expected.changes[0].identity.location, + PermissionLocation::Closure + ); + assert_eq!( + readonly_diff(&store, &old.graph_id, &permuted.graph_id).expect("permuted diff"), + expected + ); + assert_eq!( + readonly_diff(&store, &old.graph_id, &duplicates.graph_id).expect("deduplicated diff"), + expected + ); + assert!( + readonly_diff(&store, &new.graph_id, &permuted.graph_id) + .expect("identity-only diff") + .changes + .is_empty() + ); + persistence.shutdown(); +} + +#[test] +fn revision_replacement_is_distinct_from_logical_callee_expansion() { + use rustscript_agent::plugins::{GraphRetention, PermissionChangeKind}; + let persistence = open(); + let store = PluginControlStore::new(&persistence); + let old = sample_lock("graph-id-old", "1.0.0"); + let mut revision = sample_lock("graph-id-revision", "1.0.1"); + revision.packages[1].version = "0.4.2".into(); + revision.packages[1].artifact_id = "artifact-lib-revision".into(); + revision.edges[0].to_version = "0.4.2".into(); + revision.edges[0].to_artifact_id = "artifact-lib-revision".into(); + revision.closure[0].effects[1].via.callee_version = "0.4.2".into(); + let mut callee = revision.clone(); + callee.graph_id = "graph-id-callee".into(); + callee.packages[1].id = "org.example.other-lib".into(); + callee.edges[0].to = "org.example.other-lib".into(); + callee.closure[0].effects[1].via.callee_package = "org.example.other-lib".into(); + let mut alias = revision.clone(); + alias.graph_id = "graph-id-alias".into(); + alias.edges[0].alias = "other".into(); + alias.closure[0].effects[1].via.alias = "other".into(); + let mut entry = revision.clone(); + entry.graph_id = "graph-id-entry".into(); + entry.edges[0].to_entry = "run".into(); + entry.closure[0].effects[1].via.callee_entry = "run".into(); + let mut relation = revision.clone(); + relation.graph_id = "graph-id-relation".into(); + relation.packages[1].kind = "tool".into(); + relation.edges[0].relation = "plugin".into(); + relation.closure[0].effects[1].via.relation = "plugin".into(); + for lock in [&old, &revision, &callee, &alias, &entry, &relation] { + store.store_lock(lock).expect("valid identity fixture"); + } + let refs = store.package_refs(&old.graph_id).expect("initial refs"); + store + .activate(&old.graph_id, 0, &refs) + .expect("explicit starting activation"); + for owner_kind in ["run", "checkpoint"] { + store + .retain(&GraphRetention { + graph_id: old.graph_id.clone(), + owner_kind: owner_kind.into(), + owner_id: format!("{owner_kind}-kept"), + ref_id: format!("ref-{owner_kind}"), + }) + .expect("existing durable ref"); + } + let diff = readonly_diff(&store, &old.graph_id, &revision.graph_id).expect("revision only"); + assert!(!diff.requires_reapproval); + assert!(diff.changes.is_empty()); + assert_eq!(diff.metadata.package_revisions.len(), 2); + for candidate in [&callee, &alias, &entry, &relation] { + let diff = readonly_diff(&store, &old.graph_id, &candidate.graph_id) + .expect("logical callee change"); + assert!( + diff.requires_reapproval, + "new logical call identity requires review" + ); + assert!( + diff.changes + .iter() + .any(|change| change.kind == PermissionChangeKind::Added) + ); + assert!( + diff.changes + .iter() + .any(|change| change.kind == PermissionChangeKind::Removed) + ); + assert!(diff.metadata.edges_changed); + } + persistence.shutdown(); +} + +#[test] +fn invalid_scope_and_missing_graph_errors_preserve_state_and_hide_paths() { + let persistence = open(); + let store = PluginControlStore::new(&persistence); + let old = sample_lock("graph-error-old", "1.0.0"); + store.store_lock(&old).expect("old"); + let mut invalid = old.clone(); + invalid.graph_id = "graph-error-invalid".into(); + invalid.closure[0].effects[0].scope = "invalid_second_scope".into(); + assert_eq!( + store + .store_lock(&invalid) + .expect_err("validator remains strict") + .code, + "plugin_lock_invalid" + ); + for (old_id, new_id) in [ + ("/private/permission-review-sentinel", old.graph_id.as_str()), + (old.graph_id.as_str(), "/private/permission-review-sentinel"), + ] { + let error = readonly_diff(&store, old_id, new_id).expect_err("missing graph"); + assert_eq!(error.code, "plugin_graph_missing"); + assert!(!format!("{error:?}").contains("/private/")); + assert!(!error.to_string().contains("permission-review-sentinel")); + } + assert_eq!( + store.list_locks().expect("no invalid graph"), + vec![old.graph_id] + ); + persistence.shutdown(); +} diff --git a/tests/plugin_manifest_tests.rs b/tests/plugin_manifest_tests.rs index 9fa4c20..da1beb3 100644 --- a/tests/plugin_manifest_tests.rs +++ b/tests/plugin_manifest_tests.rs @@ -251,14 +251,25 @@ fn remote_write_and_process_spawn_cannot_claim_checkpoint_recovery() { #[test] fn extra_public_effect_entry_is_rejected() { - let extra = pure_manifest().replace( + let dual = pure_manifest().replace( " normalize:\n direct: []\n delegated: []\n", " normalize:\n direct: []\n delegated: []\n other:\n direct: []\n delegated: []\n", ); + assert_kind(parse_plugin_manifest(&dual), PluginErrorKind::UnknownField); + let triple = format!("{dual} third:\n direct: []\n delegated: []\n"); + assert!(parse_plugin_manifest(&triple).is_ok()); + let extra = format!("{triple} fourth:\n direct: []\n delegated: []\n"); assert_kind( parse_plugin_manifest(&extra), PluginErrorKind::UncoveredExport, ); + let tool = dual + .replace("kind: library", "kind: tool") + .replace("contract: agent.library/v1", "contract: agent.tool/v1"); + assert_kind( + parse_plugin_manifest(&tool), + PluginErrorKind::UncoveredExport, + ); } #[test] diff --git a/tests/plugin_schema_sync_tests.rs b/tests/plugin_schema_sync_tests.rs index 6bdc3bc..374ae94 100644 --- a/tests/plugin_schema_sync_tests.rs +++ b/tests/plugin_schema_sync_tests.rs @@ -5,6 +5,7 @@ //! parser. Schema identifiers are local and are not fetched. use std::fs; +use std::os::unix::fs::PermissionsExt; use std::path::PathBuf; use rustscript_agent::plugins::{ @@ -16,6 +17,22 @@ fn schema_dir() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("schemas") } +#[test] +fn managed_checkpoint_and_restore_have_independent_registered_current_schemas() { + let documents = rustscript_agent::plugins::canonical_schema_documents().unwrap(); + for name in [ + "managed-checkpoint.schema.json", + "managed-restore.schema.json", + ] { + assert!( + documents.contains_key(name), + "managed current schema must be registered: {name}" + ); + let schema = load(&name.replace(".json", ".yaml")); + jsonschema::draft202012::meta::validate(&schema).unwrap(); + } +} + fn load(name: &str) -> serde_json::Value { let text = fs::read_to_string(schema_dir().join(name)) .unwrap_or_else(|err| panic!("missing {name}: {err}")); @@ -449,6 +466,10 @@ fn canonical_snapshots_match_and_a_mutated_copy_is_rejected() { "checkpoint.schema.json", ] { fs::copy(dir.join(name), drift.join(name)).unwrap(); + let copied = drift.join(name); + let mut perms = fs::metadata(&copied).unwrap().permissions(); + perms.set_mode(0o644); + fs::set_permissions(&copied, perms).unwrap(); } let mut mutated = fs::read_to_string(drift.join("plugin-manifest.schema.json")).unwrap(); mutated.push_str(" \n"); @@ -505,3 +526,63 @@ fn activation_and_graph_ref_schemas_reject_authority_and_guest_owners() { &authority )); } + +fn pure_library(entries: &str) -> String { + let mut text = r#"format: rustscript.plugin/v1 +id: org.rustscript.lib.text +version: 1.0.0 +kind: library +runtime: ">=0.1.0, <0.2.0" +contract: agent.library/v1 +host_abi: agent.cap/v1 +entry: + path: normalize.rss + export: normalize +dependencies: [] +capabilities: [] +effects: + contract: agent.effects/v1 + entries: +"# + .to_string(); + text.push_str(entries); + text +} + +#[test] +fn entry_cardinality_one_or_three_is_shared_by_schema_and_parser() { + let one_library = pure_library(" normalize:\n direct: []\n delegated: []\n"); + assert_schema_and_parser("plugin-manifest.schema.yaml", &one_library, true); + let one_tool = r#"format: rustscript.plugin/v1 +id: org.rustscript.tools.files +version: 1.0.0 +kind: tool +runtime: ">=0.1.0, <0.2.0" +contract: agent.tool/v1 +host_abi: agent.cap/v1 +entry: + path: invoke.rss + export: invoke +dependencies: [] +capabilities: + - workspace.read +effects: + contract: agent.effects/v1 + entries: + invoke: + direct: + - kind: workspace.fs.read + scope: bound_workspace + recovery: none + delegated: [] +"#; + assert_schema_and_parser("plugin-manifest.schema.yaml", one_tool, true); + let three_library = pure_library( + " normalize:\n direct: []\n delegated: []\n fold:\n direct: []\n delegated: []\n scan:\n direct: []\n delegated: []\n", + ); + assert_schema_and_parser("plugin-manifest.schema.yaml", &three_library, true); + let two_library = pure_library( + " normalize:\n direct: []\n delegated: []\n fold:\n direct: []\n delegated: []\n", + ); + assert_schema_and_parser("plugin-manifest.schema.yaml", &two_library, false); +} diff --git a/tests/plugin_store_recovery_tests.rs b/tests/plugin_store_recovery_tests.rs index 41771f8..512f945 100644 --- a/tests/plugin_store_recovery_tests.rs +++ b/tests/plugin_store_recovery_tests.rs @@ -1,21 +1,27 @@ //! Exact plugin-control persistence through the production RSS SQLite path. //! -//! These tests do not call the resolver, do not install a newer package, and -//! do not treat a persisted graph id as host-catalog authority. +//! Persistence tests do not call the resolver, do not install a newer package, +//! and do not treat a persisted graph id as host-catalog authority. Capture +//! boundary tests at the bottom use PackageStore install, resolve, and prepare. use std::fs; use std::io::{Read, Write}; use std::panic::{AssertUnwindSafe, catch_unwind}; use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; -use std::sync::{Arc, Barrier}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Barrier, mpsc}; use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; +use ring::rand::SystemRandom; +use ring::signature::{Ed25519KeyPair, KeyPair}; use rustscript_agent::plugins::{ ActivationPointer, COMPILE_ENVIRONMENT, EffectOrigin, GraphLock, GraphRetention, HOST_RUNTIME, - LockedEdge, LockedEffect, LockedEffectItem, LockedPackage, PackageArtifactRef, - PluginControlStore, ScopeBinding, ValidatedLockMetadata, + HarnessSelectionRequest, InstallAuthorization, LockedEdge, LockedEffect, LockedEffectItem, + LockedPackage, PackageArtifactRef, PackageRequirement, PackageStore, PackageTrust, + PluginControlStore, PluginError, PluginErrorKind, PublisherKey, ResolveRequest, ScopeBinding, + ValidatedLockMetadata, }; use rustscript_agent::{ AdmitRunRequest, AgentGatewayConfig, AgentGatewayState, AgentProviderHost, GatewayPersistence, @@ -68,6 +74,7 @@ fn origin(relation: &str, alias: &str, requirement: &str) -> EffectOrigin { fn sample_lock(graph_id: &str, tool_artifact: &str) -> GraphLock { GraphLock { graph_id: graph_id.into(), + harness_selection: rustscript_agent::plugins::HarnessSelection::Unselected, host_runtime: HOST_RUNTIME.into(), compile_environment: COMPILE_ENVIRONMENT.into(), packages: vec![ @@ -833,6 +840,14 @@ pub fn run(context: map) -> map { owner_id: "run-guest", ref_id: "ref-guest" }); + let revoke: map = agent::approval_storage("plugin.graph_revoke", {run_id:run_id.copy(),session_id:session_id.copy(),graph_id:"graph-guest"}); + let get: map = agent::approval_storage("plugin.graph_revocation_get", {run_id:run_id.copy(),session_id:session_id.copy(),graph_id:"graph-guest"}); + let requirement: map = agent::approval_storage("plugin.pin_requirement_get", {run_id:run_id.copy(),session_id:session_id.copy(),owner_id:run_id.copy()}); + let fixture: map = agent::approval_storage("plugin.fixture_pin_state", {run_id:run_id.copy(),session_id:session_id.copy(),owner_id:run_id.copy(),mutation:"drop_requirement"}); + let revoke_error:map=revoke["error"];let revoke_code:string=revoke_error["code"]; + let get_error:map=get["error"];let get_code:string=get_error["code"]; + let requirement_error:map=requirement["error"];let requirement_code:string=requirement_error["code"]; + let fixture_error:map=fixture["error"];let fixture_code:string=fixture_error["code"]; let listed: map = agent::approval_storage("list", { run_id: run_id.copy(), session_id: session_id.copy() @@ -850,7 +865,7 @@ pub fn run(context: map) -> map { if list_ok == true { list_flag = "1"; } - let answer: string = "retain=" + retain_code.copy() + ";activate=" + activate_code.copy() + ";lock=" + lock_code.copy() + ";release=" + release_code.copy() + ";list_ok=" + list_flag; + let answer: string = "retain=" + retain_code.copy() + ";activate=" + activate_code.copy() + ";lock=" + lock_code.copy() + ";release=" + release_code.copy() + ";list_ok=" + list_flag + ";revoke=" + revoke_code + ";get=" + get_code + ";requirement=" + requirement_code + ";fixture=" + fixture_code; { kind: "run.completed", answer: answer } } "#; @@ -901,6 +916,13 @@ pub fn run(context: map) -> map { .message_list(&admitted.session_id, 0) .expect("messages"); let rendered = messages.to_string(); + assert!(rendered.contains(";revoke=approval_operation_invalid;get=approval_operation_invalid;requirement=approval_operation_invalid;fixture=approval_operation_invalid")); + assert!( + store + .graph_revocation(&active_before.graph_id) + .unwrap() + .is_none() + ); assert!( rendered.contains("retain=approval_operation_invalid;activate=approval_operation_invalid;lock=approval_operation_invalid;release=approval_operation_invalid;list_ok=1"), "guest host did not deny plugin control ops: {rendered}" @@ -1348,3 +1370,843 @@ fn seam_activate_command_failure_drops_unconsumed_hook() { ); persistence.shutdown(); } + +fn graph_required_lock(graph_id: &str) -> GraphLock { + let mut lock = sample_lock(graph_id, "artifact-required"); + lock.harness_selection = rustscript_agent::plugins::HarnessSelection::ToolOnly { + tool_package: "org.example.tools".into(), + }; + lock +} + +fn graph_required_admission(key: &str) -> serde_json::Value { + json!({ + "session_id":"required-session", "session_new":1, "profile":"gateway", + "platform":"api_server", "account_id":"required-session", "model":"m", "provider":"p", + "system_prompt":"", "session_metadata_json":"{}", "run_id":"required-run", + "parent_run_id":"", "input_json":"{\"text\":\"call\"}", + "message_id":"required-message", "message_run_id":"required-run", "script_hash":"s", + "idempotency_scope":"api:chat", "idempotency_key":key, "request_hash":"proof", + "workspace_identity_json":"", "event_id":"required-event", "now_ms":2000000, + "expires_at_ms":0, "tool_graph_id":"graph-required", "tool_graph_generation":1, + "tool_ref_id":"required-ref", "tool_pin_now_ms":2000000, + "registry_identity":"required-registry", "workspace":"" + }) +} + +fn graph_pair() -> serde_json::Value { + json!({"run_id":"required-run", "event_id_a":"requested", "event_type_a":"tool.requested", + "payload_json_a":"{}", "event_id_b":"started", "event_type_b":"tool.started", + "payload_json_b":"{}", "now_ms":2000001, "max_events":1}) +} + +#[test] +fn required_graph_identity_survives_replacement_collection_terminal_and_session_delete() { + for key in ["", "required-key"] { + let path = db_path("identity-lifetime"); + let p = open(&path); + let control = PluginControlStore::new(&p); + let a = graph_required_lock("graph-required"); + let b = graph_required_lock("graph-other"); + control.store_lock(&a).unwrap(); + control.store_lock(&b).unwrap(); + control.activate(&a.graph_id, 0, &refs_of(&a)).unwrap(); + p.admission_create(&graph_required_admission(key)).unwrap(); + let requirement = control + .pin_requirement_for_run("required-run") + .unwrap() + .unwrap(); + assert_eq!(requirement.graph_id, a.graph_id); + assert_eq!(requirement.ref_id, "required-ref"); + let mut second = graph_required_admission(""); + second["session_new"] = json!(0); + second["tool_graph_id"] = json!(b.graph_id); + second["tool_ref_id"] = json!("different-ref"); + second["message_id"] = json!("second-message"); + let denied = p.admission_create(&second).unwrap_err(); + assert_eq!( + denied.code, "plugin_graph_pin_required", + "second identity must not publish: {denied:?}" + ); + control + .release(&GraphRetention { + graph_id: a.graph_id.clone(), + owner_kind: "run".into(), + owner_id: "required-run".into(), + ref_id: "required-ref".into(), + }) + .unwrap(); + control + .retain(&GraphRetention { + graph_id: b.graph_id.clone(), + owner_kind: "run".into(), + owner_id: "required-run".into(), + ref_id: "replacement-ref".into(), + }) + .unwrap(); + assert_eq!( + p.event_append_pair(&graph_pair()).unwrap_err().code, + "plugin_graph_pin_required" + ); + control.activate(&b.graph_id, 1, &refs_of(&b)).unwrap(); + assert!( + control + .collect_unreferenced() + .unwrap() + .contains(&a.graph_id) + ); + p.run_transition(&json!({"run_id":"required-run","from_status":"running","to_status":"completed","error_code":"","error_message":"","recovery_reason":"","now_ms":2000002})).unwrap(); + assert_eq!( + control.pin_requirement_for_run("required-run").unwrap(), + Some(requirement.clone()) + ); + p.shutdown(); + drop(p); + let reopened = open(&path); + let control = PluginControlStore::new(&reopened); + assert_eq!( + control.pin_requirement_for_run("required-run").unwrap(), + Some(requirement) + ); + reopened.session_delete("required-session").unwrap(); + assert_eq!( + control.pin_requirement_for_run("required-run").unwrap(), + None + ); + assert!( + reopened.run_get("required-run").unwrap()["rows"] + .as_array() + .unwrap() + .is_empty() + ); + reopened.shutdown(); + } +} + +#[test] +fn revoke_api_refuses_non_embedding_graphs_without_creating_a_fact() { + let path = db_path("revoke-domain"); + let p = open(&path); + let control = PluginControlStore::new(&p); + let lock = sample_lock("non-embedding", "artifact-foreign"); + control.store_lock(&lock).unwrap(); + let packages = + rustscript_agent::plugins::PackageStore::open(path.parent().unwrap().join("packages")) + .unwrap(); + assert_eq!( + control + .revoke_graph(&packages, &lock.graph_id) + .unwrap_err() + .code, + "plugin_lock_invalid" + ); + assert!(control.graph_revocation(&lock.graph_id).unwrap().is_none()); + p.shutdown(); +} + +#[test] +fn unpinned_caller_scope_collision_and_stateless_pair_do_not_create_requirement() { + for collision in [false, true] { + let p = open(&db_path("unpinned-collision")); + let mut admission = graph_required_admission(if collision { + "required-run" + } else { + "ordinary-key" + }); + admission["tool_graph_id"] = json!(""); + admission["tool_ref_id"] = json!(""); + if collision { + admission["idempotency_scope"] = json!("native.tool-pin"); + } + p.admission_create(&admission).unwrap(); + assert_eq!( + PluginControlStore::new(&p) + .pin_requirement_for_run("required-run") + .unwrap(), + None + ); + p.event_append_pair(&graph_pair()).unwrap(); + p.shutdown(); + } +} + +#[test] +fn required_graph_release_rejects_new_pair_without_cropping_or_retry_repair() { + for key in ["", "required-key"] { + let path = db_path("required-release"); + let persistence = open(&path); + let store = PluginControlStore::new(&persistence); + let lock = graph_required_lock("graph-required"); + store.store_lock(&lock).unwrap(); + store.activate(&lock.graph_id, 0, &refs_of(&lock)).unwrap(); + let admission = graph_required_admission(key); + persistence.admission_create(&admission).unwrap(); + let before = persistence.event_replay(&json!({"run_id":"required-run", "after_seq":0, "max_events":128, "max_bytes":65536})).unwrap(); + store + .release(&GraphRetention { + graph_id: lock.graph_id.clone(), + owner_kind: "run".into(), + owner_id: "required-run".into(), + ref_id: "required-ref".into(), + }) + .unwrap(); + if !key.is_empty() { + let retry = persistence.admission_create(&admission).unwrap(); + assert_eq!(retry["replayed"], true); + } + assert!(store.retained(&lock.graph_id).unwrap().is_empty()); + let result = persistence.event_append_pair(&graph_pair()); + assert_eq!( + result.as_ref().err().map(|e| e.code.as_str()), + Some("plugin_graph_pin_required"), + "released required pin must reject before event writes: {result:?}" + ); + assert_eq!(persistence.event_replay(&json!({"run_id":"required-run", "after_seq":0, "max_events":128, "max_bytes":65536})).unwrap(), before); + persistence.shutdown(); + } +} + +const CAPTURE_FILE_BYTES: usize = 1024 * 1024; +const CAPTURE_TOTAL_BYTES: usize = 8 * 1024 * 1024; +const CAPTURE_FILES: usize = 64; +const CAPTURE_DEPTH: usize = 32; +const CAPTURE_WALK_ENTRIES: usize = 4096; +const ENTRY_SOURCE: &str = "pub fn invoke(value: int) -> int { value }\n"; +static CAPTURE_SEQ: AtomicU64 = AtomicU64::new(0); + +struct CaptureDir { + path: PathBuf, +} + +impl CaptureDir { + fn new(label: &str) -> Self { + let base = PathBuf::from( + std::env::var_os("RUSTSCRIPT_AGENT_TEST_TMPDIR") + .expect("RUSTSCRIPT_AGENT_TEST_TMPDIR must point into assigned TEMP"), + ); + let dir = base.join(format!( + "capture-bound-{label}-{}-{}", + std::process::id(), + CAPTURE_SEQ.fetch_add(1, Ordering::Relaxed) + )); + fs::create_dir_all(&dir).expect("assigned temp accepts the capture fixture"); + Self { path: dir } + } + + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for CaptureDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.path); + } +} + +fn tool_manifest(id: &str) -> String { + format!( + r#"format: rustscript.plugin/v1 +id: {id} +version: 1.0.0 +kind: tool +runtime: ">=0.1.0, <0.2.0" +contract: agent.tool/v1 +host_abi: agent.cap/v1 +entry: + path: invoke.rss + export: invoke +dependencies: [] +capabilities: [] +effects: + contract: agent.effects/v1 + entries: + invoke: + direct: [] + delegated: [] +state: + scope: run + schema: 1 +"# + ) +} + +fn dev_auth(dev: &Path) -> InstallAuthorization { + InstallAuthorization { + admin: true, + accepted_publishers: vec![], + unsigned_dev_space: Some(dev.to_path_buf()), + } +} + +fn write_tool(dir: &Path, id: &str) { + fs::create_dir_all(dir).unwrap(); + fs::write(dir.join("manifest.yaml"), tool_manifest(id)).unwrap(); + fs::write(dir.join("invoke.rss"), ENTRY_SOURCE).unwrap(); +} + +fn write_extra_files(dir: &Path, count: usize) { + for index in 0..count { + fs::write(dir.join(format!("extra{index:02}.txt")), b"x").unwrap(); + } +} + +fn regular_file_count(dir: &Path) -> usize { + let mut count = 0usize; + let mut pending = vec![dir.to_path_buf()]; + while let Some(current) = pending.pop() { + for entry in fs::read_dir(¤t).unwrap() { + let entry = entry.unwrap(); + let kind = entry.file_type().unwrap(); + if kind.is_dir() { + pending.push(entry.path()); + } else if kind.is_file() { + count += 1; + } + } + } + count +} + +fn child_names(dir: &Path) -> Vec { + let mut names = Vec::new(); + if dir.is_dir() { + for entry in fs::read_dir(dir).unwrap() { + names.push(entry.unwrap().file_name().into_string().unwrap()); + } + } + names.sort(); + names +} + +fn assert_budget(err: PluginError) { + assert_eq!(err.kind, PluginErrorKind::BudgetExceeded, "{err:?}"); + assert!( + !err.chain.is_empty(), + "budget rejection must keep the typed chain: {err:?}" + ); +} + +fn resolve_tool(store: &PackageStore, id: &str) -> GraphLock { + store + .resolve(&ResolveRequest { + harness_selection: HarnessSelectionRequest::Unselected, + roots: vec![PackageRequirement { + id: id.to_string(), + version: "1.0.0".to_string(), + }], + host_runtime: HOST_RUNTIME.to_string(), + }) + .expect("healthy package resolves") +} + +fn reopen_store(store_root: &Path, auth: &InstallAuthorization) -> PackageStore { + let store = PackageStore::open(store_root).expect("formal store reopens"); + store.bind_policy(auth); + store +} + +fn signer() -> (Ed25519KeyPair, PublisherKey) { + let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap(); + let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap(); + let key = PublisherKey { + publisher: "org.rustscript".to_string(), + verifying_key: pair.public_key().as_ref().to_vec(), + }; + (pair, key) +} + +fn sign_package(store: &PackageStore, pair: &Ed25519KeyPair, dir: &Path) { + let payload = store + .signature_payload(dir) + .expect("legal package has a formal signature payload"); + fs::write(dir.join("signature.ed25519"), pair.sign(&payload).as_ref()).unwrap(); +} + +#[test] +fn capture_bound_signed_and_dev_unsigned_install_resolve_and_prepare() { + let root = CaptureDir::new("trust"); + let dev = root.path().join("dev"); + let store_root = root.path().join("store"); + let store = PackageStore::open(&store_root).unwrap(); + let (pair, key) = signer(); + let signed_dir = root.path().join("signed"); + write_tool(&signed_dir, "org.rustscript.tools.capsigned"); + sign_package(&store, &pair, &signed_dir); + let auth = InstallAuthorization { + admin: true, + accepted_publishers: vec![key], + unsigned_dev_space: Some(dev.clone()), + }; + let signed = store.install(&signed_dir, &auth).unwrap(); + assert_eq!(signed.trust, PackageTrust::AcceptedPublisher); + assert!(signed.declared_capabilities().is_empty()); + let dev_dir = dev.join("pkg"); + write_tool(&dev_dir, "org.rustscript.tools.capdev"); + let dev_revision = store.install(&dev_dir, &auth).unwrap(); + assert_eq!(dev_revision.trust, PackageTrust::DevUnsigned); + assert!(dev_revision.declared_capabilities().is_empty()); + drop(store); + let store = reopen_store(&store_root, &auth); + let loaded = store.load_installed().unwrap(); + assert_eq!(loaded.len(), 2); + let signed_lock = resolve_tool(&store, "org.rustscript.tools.capsigned"); + let dev_lock = resolve_tool(&store, "org.rustscript.tools.capdev"); + assert!( + signed_lock + .closure + .iter() + .all(|item| item.effects.is_empty()) + ); + assert!(dev_lock.closure.iter().all(|item| item.effects.is_empty())); + assert!( + store + .prepare(&signed_lock) + .unwrap() + .program("org.rustscript.tools.capsigned") + .is_some() + ); + assert!( + store + .prepare(&dev_lock) + .unwrap() + .program("org.rustscript.tools.capdev") + .is_some() + ); + assert_eq!( + store.install(&signed_dir, &auth).unwrap().artifact_id, + signed.artifact_id + ); + assert_eq!( + child_names(&store_root.join("artifacts")), + child_names(&store_root.join("ledger")) + ); +} + +#[test] +fn capture_bound_exact_file_count_installs_and_sixty_fifth_does_not_poison() { + let root = CaptureDir::new("files"); + let dev = root.path().join("dev"); + let store_root = root.path().join("store"); + let store = PackageStore::open(&store_root).unwrap(); + let auth = dev_auth(&dev); + let healthy_dir = dev.join("healthy"); + write_tool(&healthy_dir, "org.rustscript.tools.caphealthy"); + let healthy = store.install(&healthy_dir, &auth).unwrap(); + let exact_dir = dev.join("exact"); + write_tool(&exact_dir, "org.rustscript.tools.capcount"); + write_extra_files(&exact_dir, CAPTURE_FILES - 2); + assert_eq!(regular_file_count(&exact_dir), CAPTURE_FILES); + let exact = store.install(&exact_dir, &auth).unwrap(); + let loaded = store.load_installed().unwrap(); + let exact_loaded = loaded + .iter() + .find(|package| package.artifact_id == exact.artifact_id) + .unwrap(); + assert_eq!(exact_loaded.sources.len(), CAPTURE_FILES - 1); + let over_dir = dev.join("over"); + write_tool(&over_dir, "org.rustscript.tools.capover"); + write_extra_files(&over_dir, CAPTURE_FILES - 1); + assert_eq!(regular_file_count(&over_dir), CAPTURE_FILES + 1); + match store.signature_payload(&over_dir) { + Err(err) => assert_budget(err), + Ok(_) => panic!("65 files must not be read as a payload"), + } + assert_budget( + store + .install(&over_dir, &auth) + .expect_err("65 files must not be installed"), + ); + drop(store); + let store = reopen_store(&store_root, &auth); + let loaded = store + .load_installed() + .expect("healthy and exact packages stay loadable"); + assert_eq!(loaded.len(), 2); + let lock = resolve_tool(&store, "org.rustscript.tools.caphealthy"); + assert!( + store + .prepare(&lock) + .unwrap() + .program("org.rustscript.tools.caphealthy") + .is_some() + ); + assert_eq!( + store.install(&healthy_dir, &auth).unwrap().artifact_id, + healthy.artifact_id + ); + assert_eq!( + store.install(&exact_dir, &auth).unwrap().artifact_id, + exact.artifact_id + ); + let artifacts = child_names(&store_root.join("artifacts")); + let ledger = child_names(&store_root.join("ledger")); + assert_eq!(artifacts, ledger); + assert_eq!(artifacts.len(), 2); + assert!(artifacts.iter().all(|name| !name.ends_with(".tmp"))); + assert!(artifacts.contains(&healthy.artifact_id)); + assert!(artifacts.contains(&exact.artifact_id)); +} + +#[test] +fn capture_bound_exact_file_bytes_are_readable_and_one_extra_byte_is_rejected() { + let root = CaptureDir::new("file-byte"); + let dev = root.path().join("dev"); + let store_root = root.path().join("store"); + let store = PackageStore::open(&store_root).unwrap(); + let auth = dev_auth(&dev); + let healthy_dir = dev.join("healthy"); + write_tool(&healthy_dir, "org.rustscript.tools.caphealthy"); + let healthy = store.install(&healthy_dir, &auth).unwrap(); + let exact_dir = dev.join("exact"); + write_tool(&exact_dir, "org.rustscript.tools.capfile"); + let exact_pad = exact_dir.join("pad.bin"); + fs::File::create(&exact_pad) + .unwrap() + .set_len(CAPTURE_FILE_BYTES as u64) + .unwrap(); + assert_eq!( + fs::metadata(&exact_pad).unwrap().len(), + CAPTURE_FILE_BYTES as u64 + ); + assert!( + store.signature_payload(&exact_dir).is_ok(), + "exactly 1MiB is inside the file bound" + ); + let over_dir = dev.join("over"); + write_tool(&over_dir, "org.rustscript.tools.capfileover"); + let over_pad = over_dir.join("pad.bin"); + fs::File::create(&over_pad) + .unwrap() + .set_len((CAPTURE_FILE_BYTES + 1) as u64) + .unwrap(); + let over_total: u64 = fs::read_dir(&over_dir) + .unwrap() + .map(|entry| entry.unwrap().metadata().unwrap().len()) + .sum(); + assert!( + over_total < CAPTURE_TOTAL_BYTES as u64, + "file fixture must not also cross the total bound" + ); + match store.signature_payload(&over_dir) { + Err(err) => assert_budget(err), + Ok(_) => panic!("1MiB+1 must fail before an unbounded payload"), + } + assert_budget( + store + .install(&over_dir, &auth) + .expect_err("1MiB+1 must not be installed"), + ); + drop(store); + let store = reopen_store(&store_root, &auth); + let loaded = store.load_installed().unwrap(); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].artifact_id, healthy.artifact_id); + assert_eq!( + store.install(&healthy_dir, &auth).unwrap().artifact_id, + healthy.artifact_id + ); + assert_eq!( + child_names(&store_root.join("artifacts")), + vec![healthy.artifact_id.clone()] + ); + assert_eq!( + child_names(&store_root.join("ledger")), + vec![healthy.artifact_id] + ); +} + +#[test] +fn capture_bound_exact_total_bytes_are_readable_and_one_extra_byte_is_rejected() { + let root = CaptureDir::new("total-byte"); + let dev = root.path().join("dev"); + let store_root = root.path().join("store"); + let store = PackageStore::open(&store_root).unwrap(); + let auth = dev_auth(&dev); + let healthy_dir = dev.join("healthy"); + write_tool(&healthy_dir, "org.rustscript.tools.caphealthy"); + let healthy = store.install(&healthy_dir, &auth).unwrap(); + let exact_dir = dev.join("exact"); + write_tool(&exact_dir, "org.rustscript.tools.captotal"); + fill_total(&exact_dir, CAPTURE_TOTAL_BYTES); + assert!(max_regular_file(&exact_dir) <= CAPTURE_FILE_BYTES as u64); + assert_eq!(tree_bytes(&exact_dir), CAPTURE_TOTAL_BYTES as u64); + assert!(regular_file_count(&exact_dir) < CAPTURE_FILES); + assert!( + store.signature_payload(&exact_dir).is_ok(), + "exactly 8MiB is inside the total bound" + ); + let over_dir = dev.join("over"); + write_tool(&over_dir, "org.rustscript.tools.captotalover"); + fill_total(&over_dir, CAPTURE_TOTAL_BYTES + 1); + assert!( + max_regular_file(&over_dir) <= CAPTURE_FILE_BYTES as u64, + "total fixture must not also cross the file bound" + ); + assert_eq!( + regular_file_count(&over_dir), + regular_file_count(&exact_dir) + ); + match store.signature_payload(&over_dir) { + Err(err) => assert_budget(err), + Ok(_) => panic!("8MiB+1 must fail before an unbounded payload"), + } + assert_budget( + store + .install(&over_dir, &auth) + .expect_err("8MiB+1 must not be installed"), + ); + drop(store); + let store = reopen_store(&store_root, &auth); + assert_eq!(store.load_installed().unwrap().len(), 1); + assert_eq!( + store.install(&healthy_dir, &auth).unwrap().artifact_id, + healthy.artifact_id + ); + assert_eq!( + child_names(&store_root.join("artifacts")), + vec![healthy.artifact_id.clone()] + ); + assert_eq!( + child_names(&store_root.join("ledger")), + vec![healthy.artifact_id] + ); +} + +#[test] +fn capture_bound_exact_depth_installs_and_one_past_is_rejected() { + let root = CaptureDir::new("depth"); + let dev = root.path().join("dev"); + let store_root = root.path().join("store"); + let store = PackageStore::open(&store_root).unwrap(); + let auth = dev_auth(&dev); + let healthy_dir = dev.join("healthy"); + write_tool(&healthy_dir, "org.rustscript.tools.caphealthy"); + let healthy = store.install(&healthy_dir, &auth).unwrap(); + let depth_dir = dev.join("depth"); + write_tool(&depth_dir, "org.rustscript.tools.capdepth"); + nest_empty(&depth_dir, CAPTURE_DEPTH); + assert_eq!(deepest_directory(&depth_dir), CAPTURE_DEPTH); + let depth = store.install(&depth_dir, &auth).unwrap(); + let deep_over = dev.join("depth-over"); + write_tool(&deep_over, "org.rustscript.tools.capdepthover"); + nest_empty(&deep_over, CAPTURE_DEPTH + 1); + match store.signature_payload(&deep_over) { + Err(err) => assert_budget(err), + Ok(_) => panic!("depth 33 must fail before unbounded recursion"), + } + assert_budget( + store + .install(&deep_over, &auth) + .expect_err("depth 33 must not be installed"), + ); + drop(store); + let store = reopen_store(&store_root, &auth); + assert_eq!(store.load_installed().unwrap().len(), 2); + assert_eq!( + store.install(&healthy_dir, &auth).unwrap().artifact_id, + healthy.artifact_id + ); + assert_eq!( + store.install(&depth_dir, &auth).unwrap().artifact_id, + depth.artifact_id + ); + let artifacts = child_names(&store_root.join("artifacts")); + assert_eq!(artifacts.len(), 2); + assert!(artifacts.iter().all(|name| !name.ends_with(".tmp"))); + assert_eq!(artifacts, child_names(&store_root.join("ledger"))); +} + +#[test] +fn capture_bound_exact_walk_installs_and_one_past_is_rejected() { + let root = CaptureDir::new("walk"); + let dev = root.path().join("dev"); + let store_root = root.path().join("store"); + let store = PackageStore::open(&store_root).unwrap(); + let auth = dev_auth(&dev); + let healthy_dir = dev.join("healthy"); + write_tool(&healthy_dir, "org.rustscript.tools.caphealthy"); + let healthy = store.install(&healthy_dir, &auth).unwrap(); + let walk_dir = dev.join("walk"); + write_tool(&walk_dir, "org.rustscript.tools.capwalk"); + write_empty_dirs(&walk_dir, CAPTURE_WALK_ENTRIES - 3); + assert_eq!(walk_nodes(&walk_dir), CAPTURE_WALK_ENTRIES); + let walk = store.install(&walk_dir, &auth).unwrap(); + let walk_over = dev.join("walk-over"); + write_tool(&walk_over, "org.rustscript.tools.capwalkover"); + write_empty_dirs(&walk_over, CAPTURE_WALK_ENTRIES - 2); + assert_eq!(walk_nodes(&walk_over), CAPTURE_WALK_ENTRIES + 1); + match store.signature_payload(&walk_over) { + Err(err) => assert_budget(err), + Ok(_) => panic!("4097 walk nodes must fail before an unbounded listing"), + } + assert_budget( + store + .install(&walk_over, &auth) + .expect_err("4097 walk nodes must not be installed"), + ); + drop(store); + let store = reopen_store(&store_root, &auth); + assert_eq!(store.load_installed().unwrap().len(), 2); + assert_eq!( + store.install(&healthy_dir, &auth).unwrap().artifact_id, + healthy.artifact_id + ); + assert_eq!( + store.install(&walk_dir, &auth).unwrap().artifact_id, + walk.artifact_id + ); + let artifacts = child_names(&store_root.join("artifacts")); + assert_eq!(artifacts.len(), 2); + assert!(artifacts.iter().all(|name| !name.ends_with(".tmp"))); + assert_eq!(artifacts, child_names(&store_root.join("ledger"))); +} + +#[test] +fn capture_bound_fifo_and_internal_symlink_are_rejected_without_blocking_or_following() { + let root = CaptureDir::new("special"); + let dev = root.path().join("dev"); + let store_root = root.path().join("store"); + let store = PackageStore::open(&store_root).unwrap(); + let auth = dev_auth(&dev); + let healthy_dir = dev.join("healthy"); + write_tool(&healthy_dir, "org.rustscript.tools.caphealthy"); + let healthy = store.install(&healthy_dir, &auth).unwrap(); + drop(store); + let fifo_dir = dev.join("fifo"); + write_tool(&fifo_dir, "org.rustscript.tools.capfifo"); + let fifo = fifo_dir.join("pipe"); + assert!( + Command::new("mkfifo") + .arg(&fifo) + .status() + .unwrap() + .success() + ); + let (tx, rx) = mpsc::channel(); + let fifo_store = store_root.clone(); + let fifo_auth = auth.clone(); + thread::spawn(move || { + let store = PackageStore::open(&fifo_store).unwrap(); + let result = store + .install(&fifo_dir, &fifo_auth) + .map(|_| ()) + .map_err(|err| err.kind); + let _ = tx.send(result); + }); + match rx.recv_timeout(Duration::from_secs(2)) { + Ok(Err(PluginErrorKind::PathEscape)) => {} + Ok(other) => panic!("fifo must be a typed non-regular rejection, got {other:?}"), + Err(_) => panic!("fifo open blocked the formal install path"), + } + let link_dir = dev.join("link"); + write_tool(&link_dir, "org.rustscript.tools.caplink"); + std::os::unix::fs::symlink("invoke.rss", link_dir.join("alias.rss")).unwrap(); + let store = reopen_store(&store_root, &auth); + assert_eq!( + store.install(&link_dir, &auth).unwrap_err().kind, + PluginErrorKind::SymlinkDenied + ); + let loaded = store.load_installed().unwrap(); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].artifact_id, healthy.artifact_id); + assert_eq!( + child_names(&store_root.join("artifacts")), + vec![healthy.artifact_id] + ); +} + +fn fill_total(dir: &Path, total: usize) { + let fixed = fs::metadata(dir.join("manifest.yaml")).unwrap().len() as usize + + fs::metadata(dir.join("invoke.rss")).unwrap().len() as usize; + assert!(fixed < CAPTURE_FILE_BYTES && total >= fixed); + let mut remain = total - fixed; + let mut index = 0usize; + while remain > 0 { + let chunk = remain.min(CAPTURE_FILE_BYTES); + fs::File::create(dir.join(format!("pad{index:02}.bin"))) + .unwrap() + .set_len(chunk as u64) + .unwrap(); + remain -= chunk; + index += 1; + assert!( + index + 2 <= CAPTURE_FILES, + "total fixture must stay inside the file count" + ); + } +} + +fn tree_bytes(dir: &Path) -> u64 { + let mut total = 0u64; + let mut pending = vec![dir.to_path_buf()]; + while let Some(current) = pending.pop() { + for entry in fs::read_dir(¤t).unwrap() { + let entry = entry.unwrap(); + if entry.file_type().unwrap().is_dir() { + pending.push(entry.path()); + } else if entry.file_type().unwrap().is_file() { + total += entry.metadata().unwrap().len(); + } + } + } + total +} + +fn max_regular_file(dir: &Path) -> u64 { + let mut max = 0u64; + let mut pending = vec![dir.to_path_buf()]; + while let Some(current) = pending.pop() { + for entry in fs::read_dir(¤t).unwrap() { + let entry = entry.unwrap(); + if entry.file_type().unwrap().is_dir() { + pending.push(entry.path()); + } else if entry.file_type().unwrap().is_file() { + max = max.max(entry.metadata().unwrap().len()); + } + } + } + max +} + +fn nest_empty(dir: &Path, depth: usize) { + let mut cursor = dir.to_path_buf(); + for _ in 0..depth { + cursor.push("n"); + fs::create_dir(&cursor).unwrap(); + } +} + +fn deepest_directory(dir: &Path) -> usize { + let mut depth = 0usize; + let mut cursor = dir.join("n"); + while cursor.is_dir() { + depth += 1; + cursor.push("n"); + } + depth +} + +fn write_empty_dirs(dir: &Path, count: usize) { + for index in 0..count { + fs::create_dir(dir.join(format!("e{index:04}"))).unwrap(); + } +} + +fn walk_nodes(dir: &Path) -> usize { + let mut nodes = 1usize; + let mut pending = vec![dir.to_path_buf()]; + while let Some(current) = pending.pop() { + for entry in fs::read_dir(¤t).unwrap() { + let entry = entry.unwrap(); + let kind = entry.file_type().unwrap(); + if kind.is_dir() || kind.is_file() { + nodes += 1; + if kind.is_dir() { + pending.push(entry.path()); + } + } + } + } + nodes +} diff --git a/tests/prompt_tests.rs b/tests/prompt_tests.rs index 2ac9b91..b0f752d 100644 --- a/tests/prompt_tests.rs +++ b/tests/prompt_tests.rs @@ -964,6 +964,8 @@ fn admit_request_with_idempotency(key: &str) -> AdmitRunRequest { AdmitRunRequest { idempotency_key: Some(key.to_string()), idempotency_hash: Some(PROMPT_TEST_REQUEST_HASH.to_string()), + graph_id: None, + workspace_branch: None, ..admit_request() } } @@ -1561,10 +1563,23 @@ async fn expired_deadline_while_paused_rejects_before_durable_commit() { .set_run_limits(RunLimits::new(8, 16, 4096, &fixture.root).expect("limits")) .expect("run limits should apply"); service.set_date_source(Arc::new(FixedDateSource::new("2026-04-05"))); + // Compile/capture through the existing setup seam before admit constructs + // its original 200ms cancellation. The timed preparation below still uses + // that same short clock, including the natural 500ms prompt-read pause. + let setup_started = Instant::now(); + service + .materialize_cached_runner() + .expect("prepare timed fixture code"); + eprintln!( + "deadline fixture code setup elapsed={:?}", + setup_started.elapsed() + ); + let seed_started = Instant::now(); let seed = service .admit(admit_request()) .await .expect("seed admission"); + eprintln!("deadline fixture seed elapsed={:?}", seed_started.elapsed()); let session_id = seed.session_id.clone(); let messages_before = service.session_messages(&session_id).len(); let handles_before = service.handle_count(); diff --git a/tests/service_tests.rs b/tests/service_tests.rs index f8b9ae6..3998641 100644 --- a/tests/service_tests.rs +++ b/tests/service_tests.rs @@ -47,6 +47,8 @@ fn admit_request_with_idempotency_key(key: String) -> AdmitRunRequest { AdmitRunRequest { idempotency_key: Some(key), idempotency_hash: Some(TEST_REQUEST_HASH.to_string()), + graph_id: None, + workspace_branch: None, ..admit_request(None) } } @@ -553,6 +555,8 @@ async fn admission_persistence_failure_is_typed_and_leaves_no_residue() { platform: "service_tests".to_string(), idempotency_key: Some("atomic-admission-key".to_string()), idempotency_hash: Some(TEST_REQUEST_HASH.to_string()), + graph_id: None, + workspace_branch: None, ..AdmitRunRequest::default() }; let state = AgentGatewayState::with_agent_source_and_sqlite( @@ -700,6 +704,8 @@ async fn oversized_context_is_rejected_before_atomic_admission_and_leaves_no_res platform: "service_tests".to_string(), idempotency_key: Some("oversized-context-key".to_string()), idempotency_hash: Some(TEST_REQUEST_HASH.to_string()), + graph_id: None, + workspace_branch: None, ..AdmitRunRequest::default() }; let state = AgentGatewayState::with_agent_source_and_sqlite( @@ -895,6 +901,8 @@ async fn maximum_key_and_provider_context_remain_below_the_admission_output_boun provider: Some("large-provider-with-key".to_string()), idempotency_key: Some(key.clone()), idempotency_hash: Some(TEST_REQUEST_HASH.to_string()), + graph_id: None, + workspace_branch: None, platform: "service_tests".to_string(), ..AdmitRunRequest::default() }) @@ -1087,6 +1095,8 @@ async fn model_padded_max_combination_admits_at_query_budget_and_resumes() { provider: Some(provider.clone()), idempotency_key: Some(key.clone()), idempotency_hash: Some(TEST_REQUEST_HASH.to_string()), + graph_id: None, + workspace_branch: None, platform: "service_tests".to_string(), ..AdmitRunRequest::default() }) @@ -1109,6 +1119,8 @@ async fn model_padded_max_combination_admits_at_query_budget_and_resumes() { provider: Some(provider.clone()), idempotency_key: Some("e".repeat(MAX_IDEMPOTENCY_KEY_BYTES)), idempotency_hash: Some("fnv64:0123456789abcdee".to_string()), + graph_id: None, + workspace_branch: None, platform: "service_tests".to_string(), ..AdmitRunRequest::default() }) @@ -1153,6 +1165,8 @@ async fn model_padded_query_budget_one_byte_over_leaves_no_residue() { provider: Some(provider.clone()), idempotency_key: Some(key.clone()), idempotency_hash: Some(TEST_REQUEST_HASH.to_string()), + graph_id: None, + workspace_branch: None, platform: "service_tests".to_string(), ..AdmitRunRequest::default() }) @@ -1187,6 +1201,8 @@ async fn model_padded_query_budget_one_byte_over_leaves_no_residue() { provider: Some(provider), idempotency_key: Some(key), idempotency_hash: Some(TEST_REQUEST_HASH.to_string()), + graph_id: None, + workspace_branch: None, platform: "service_tests".to_string(), ..AdmitRunRequest::default() }) @@ -1495,6 +1511,8 @@ async fn invalid_request_hash_is_rejected_before_admission() { .admit(AdmitRunRequest { idempotency_key: Some("valid-key".to_string()), idempotency_hash: Some("service-test-request-hash".to_string()), + graph_id: None, + workspace_branch: None, ..admit_request(None) }) .await diff --git a/tests/support/plugin_admin_cycle.rs b/tests/support/plugin_admin_cycle.rs new file mode 100644 index 0000000..6a7d955 --- /dev/null +++ b/tests/support/plugin_admin_cycle.rs @@ -0,0 +1,1915 @@ +use super::{Http, no_private_text, private_root, signed_tool_effects}; +use rustscript_agent::plugins::{ + HarnessSelectionRequest, InstallAuthorization, PackageRequirement, PackageStore, PackageTrust, + PluginControlStore, PluginErrorKind, ResolveRequest, trusted_tool_package_root, +}; +use rustscript_agent::{ + AgentGatewayConfig, AgentGatewayState, GatewayPersistence, ScriptedProvider, +}; +use serde_json::{Value, json}; +use std::path::PathBuf; +use std::time::Duration; + +fn workspace_home(root: &std::path::Path) -> (PathBuf, PathBuf) { + let home = root.join("workspace-home"); + let workspace = root.join("workspace"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&workspace).unwrap(); + let config = format!( + "version: 1\nmodel:\n provider: local-test\n model: synthetic\nproviders:\n local-test:\n protocol: codex-responses\n base_url: https://127.0.0.1:1\nworkspaces:\n allowed_roots:\n - '{}'\n default: '{}'\n named:\n lifecycle: '{}'\n", + workspace.display(), + workspace.display(), + workspace.display() + ); + std::fs::write(home.join("config.yaml"), config).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for path in [root, &home, &workspace] { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + std::fs::set_permissions( + home.join("config.yaml"), + std::fs::Permissions::from_mode(0o600), + ) + .unwrap(); + } + (home, workspace) +} + +fn tool_then_stop(call_id: &str, path: &str) -> (Value, Value) { + ( + json!({ + "text": "", + "tool_calls": [{ + "id": call_id, + "name": "write_file", + "arguments": {"path": path} + }], + "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + "reasoning": "", + "stop_reason": "tool_calls" + }), + json!({ + "text": "done", + "tool_calls": [], + "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + "reasoning": "", + "stop_reason": "stop" + }), + ) +} + +async fn wait_marker(workspace: &std::path::Path, name: &str, marker: &str) -> String { + let path = workspace.join(name); + for _ in 0..40 { + if let Ok(text) = std::fs::read_to_string(&path) + && text.contains(marker) + { + return text; + } + tokio::time::sleep(Duration::from_millis(500)).await; + } + std::fs::read_to_string(&path) + .unwrap_or_else(|error| format!("missing {}: {error}", path.display())) +} + +async fn reject_unpinned_graphs( + http: &Http, + service: &rustscript_agent::AgentService, + token: &str, + session_id: &str, + cases: &[(&str, u16, &str)], +) { + let handles = service.handle_count(); + for (graph_id, expect, code) in cases { + let (status, body, raw) = http + .request( + "POST", + "/v1/runs", + json!({ + "session_id": session_id, + "input": "rejected graph", + "workspace": "name:lifecycle", + "graph_id": graph_id + }), + Some(token), + ) + .await; + assert_eq!(status, *expect, "{graph_id}: {raw}"); + assert_eq!(body["error"]["code"], *code, "{graph_id}: {raw}"); + assert!(body.get("run_id").is_none(), "{graph_id}: {raw}"); + } + assert_eq!( + service.handle_count(), + handles, + "a rejected graph request must not admit a run" + ); +} + +#[tokio::test] +async fn host_bound_cycle_rejects_caller_authority_and_pins_native_receipts() { + let root = private_root(); + let source = root.join("controlled-source"); + std::fs::create_dir_all(&source).unwrap(); + let (auth, package_a, package_b, package_state) = + signed_tool_effects::stage_write_pair(&source); + let outside = root.join("outside"); + std::fs::create_dir_all(&outside).unwrap(); + std::os::unix::fs::symlink(&outside, source.join("linked")).unwrap(); + let db = root.join("gateway.sqlite"); + let token = "owner-control-token"; + let mut config = AgentGatewayConfig { + bearer_token: Some(token.into()), + ..AgentGatewayConfig::default() + }; + config.approvals.write = "allow".into(); + let (home, workspace) = workspace_home(&root); + let state = AgentGatewayState::with_sqlite_path(config.clone(), &db) + .unwrap() + .with_workspace_home(&home) + .with_plugin_control(auth, source.clone(), "owner-space") + .unwrap(); + let provider = ScriptedProvider::new(); + let service = state.service(); + let http = Http::bind(state).await; + let source_text = source.display().to_string(); + + let (status, body, raw) = http + .request( + "POST", + "/v1/plugins/install", + json!({ + "admin": true, + "package": package_a, + "deployment_space": "owner-space", + "path": source_text + }), + Some(token), + ) + .await; + assert_eq!(status, 422, "{raw}"); + assert_eq!(body["error"]["code"], "plugin_request_invalid"); + assert!(!raw.contains(&source_text), "{raw}"); + + for package in [ + "../outside", + "/etc/passwd", + "linked", + "package-a/../../outside", + ] { + let (status, body, raw) = http + .request( + "POST", + "/v1/plugins/install", + json!({"package": package, "deployment_space": "owner-space"}), + Some(token), + ) + .await; + assert_eq!(status, 422, "{package}: {raw}"); + assert_eq!(body["error"]["code"], "plugin_source_rejected"); + no_private_text(&raw); + assert!(!raw.contains(&source_text), "{raw}"); + } + let (status, body, raw) = http + .request( + "POST", + "/v1/plugins/install", + json!({"package": package_a, "deployment_space": "other-space"}), + Some(token), + ) + .await; + assert_eq!(status, 403, "{raw}"); + assert_eq!(body["error"]["code"], "plugin_space_mismatch"); + no_private_text(&raw); + + let (status, installed_a, raw) = http + .request( + "POST", + "/v1/plugins/install", + json!({"package": package_a, "deployment_space": "owner-space"}), + Some(token), + ) + .await; + assert_eq!(status, 200, "{raw}"); + assert_eq!(installed_a["package_id"], "org.rustscript.tools.effecta"); + assert!(!raw.contains(&source_text), "{raw}"); + let (status, _, raw) = http + .request( + "POST", + "/v1/plugins/install", + json!({"package": package_b, "deployment_space": "owner-space"}), + Some(token), + ) + .await; + assert_eq!(status, 200, "{raw}"); + let (status, _, raw) = http + .request( + "POST", + "/v1/plugins/install", + json!({"package": package_state, "deployment_space": "owner-space"}), + Some(token), + ) + .await; + assert_eq!(status, 200, "{raw}"); + + let (session_status, session, raw) = http + .request("POST", "/api/sessions", json!({}), Some(token)) + .await; + assert_eq!(session_status, 201, "{raw}"); + let session_id = session["session"]["id"].as_str().unwrap(); + + let before = PluginControlStore::new(&GatewayPersistence::open(&config, &db).unwrap()) + .activation() + .unwrap(); + let (status, resolved, raw) = http + .request( + "POST", + "/v1/plugin-graphs/resolve", + json!({ + "roots": [{"id": "org.rustscript.tools.effecta", "version": "1.0.0"}], + "tool_package": "org.rustscript.tools.effecta", + "deployment_space": "owner-space" + }), + Some(token), + ) + .await; + assert_eq!(status, 200, "{raw}"); + assert_eq!(resolved["activated"], false); + let graph_a = resolved["graph_id"].as_str().unwrap().to_string(); + assert_ne!(graph_a, "org.rustscript.tools.effecta"); + let after_resolve = PluginControlStore::new(&GatewayPersistence::open(&config, &db).unwrap()) + .activation() + .unwrap(); + assert_eq!(after_resolve.generation, before.generation); + assert_eq!(after_resolve.graph_id, before.graph_id); + let (status, prepared, raw) = http + .request( + "POST", + &format!("/v1/plugin-graphs/{graph_a}/prepare"), + json!({}), + Some(token), + ) + .await; + assert_eq!(status, 200, "{raw}"); + assert_eq!(prepared["graph_id"], graph_a); + assert_eq!(prepared["executable"], true); + let (status, view, raw) = http + .request( + "GET", + &format!("/v1/plugin-graphs/{graph_a}"), + json!({}), + Some(token), + ) + .await; + assert_eq!(status, 200, "{raw}"); + assert_eq!(view["graph_id"], graph_a); + assert!(view["errors"].as_array().unwrap().is_empty()); + assert!( + !raw.contains(&source_text) && !raw.contains("signature") && !raw.contains("artifact"), + "{raw}" + ); + let (status, body, raw) = http + .request( + "POST", + "/v1/plugin-activations", + json!({ + "graph_id": graph_a, + "deployment_space": "other-space", + "expected_generation": before.generation + }), + Some(token), + ) + .await; + assert_eq!(status, 403, "{raw}"); + assert_eq!(body["error"]["code"], "plugin_space_mismatch"); + let (status, activated, raw) = http + .request( + "POST", + "/v1/plugin-activations", + json!({ + "graph_id": graph_a, + "deployment_space": "owner-space", + "expected_generation": before.generation + }), + Some(token), + ) + .await; + assert_eq!(status, 200, "{raw}"); + let generation_a = activated["generation"].as_u64().unwrap(); + assert!(generation_a > before.generation, "{raw}"); + assert_eq!(activated["graph_id"], graph_a); + assert_eq!( + activated["activation_id"], + format!("owner-space.{generation_a}") + ); + let native = PluginControlStore::new(&GatewayPersistence::open(&config, &db).unwrap()) + .activation() + .unwrap(); + assert_eq!(native.graph_id, activated["graph_id"]); + assert_eq!(native.generation, generation_a); + let (status, body, raw) = http + .request( + "POST", + "/v1/plugin-activations", + json!({ + "graph_id": graph_a, + "deployment_space": "owner-space", + "expected_generation": before.generation + }), + Some(token), + ) + .await; + assert_eq!(status, 409, "{raw}"); + assert_eq!(body["error"]["code"], "plugin_activation_conflict"); + + let (status, resolved_b, raw) = http + .request( + "POST", + "/v1/plugin-graphs/resolve", + json!({ + "roots": [{"id": "org.rustscript.tools.effectb", "version": "1.0.0"}], + "tool_package": "org.rustscript.tools.effectb", + "deployment_space": "owner-space" + }), + Some(token), + ) + .await; + assert_eq!(status, 200, "{raw}"); + let graph_b = resolved_b["graph_id"].as_str().unwrap().to_string(); + let (status, _, raw) = http + .request( + "POST", + &format!("/v1/plugin-graphs/{graph_b}/prepare"), + json!({}), + Some(token), + ) + .await; + assert_eq!(status, 200, "{raw}"); + let (status, activated_b, raw) = http + .request( + "POST", + "/v1/plugin-activations", + json!({ + "graph_id": graph_b, + "deployment_space": "owner-space", + "expected_generation": generation_a + }), + Some(token), + ) + .await; + assert_eq!(status, 200, "{raw}"); + let generation_b = activated_b["generation"].as_u64().unwrap(); + assert!(generation_b > generation_a); + assert_eq!(activated_b["graph_id"], graph_b); + let history_before = PluginControlStore::new(&GatewayPersistence::open(&config, &db).unwrap()) + .activation_history() + .unwrap(); + assert!( + history_before + .iter() + .any(|entry| { entry.generation == generation_a && entry.graph_id == graph_a }) + ); + let (status, rolled, raw) = http + .request( + "POST", + &format!("/v1/plugin-activations/owner-space.{generation_a}/rollback"), + json!({"admin": true, "graph_id": graph_b}), + Some(token), + ) + .await; + assert_eq!(status, 200, "{raw}"); + let generation_roll = rolled["generation"].as_u64().unwrap(); + assert!(generation_roll > generation_b, "{raw}"); + assert_eq!(rolled["graph_id"], graph_a); + assert_ne!( + rolled["activation_id"], + format!("owner-space.{generation_a}") + ); + let history_after = PluginControlStore::new(&GatewayPersistence::open(&config, &db).unwrap()) + .activation_history() + .unwrap(); + assert!( + history_after + .iter() + .any(|entry| entry.generation == generation_a) + ); + assert!(history_after.len() > history_before.len()); + let (status, body, raw) = http + .request( + "POST", + "/v1/plugin-activations/other-space.1/rollback", + json!({}), + Some(token), + ) + .await; + assert_eq!(status, 404, "{raw}"); + assert_eq!(body["error"]["code"], "plugin_activation_missing"); + let (status, _, raw) = http + .request( + "POST", + &format!("/v1/plugin-graphs/{}/prepare", "x".repeat(200)), + json!({}), + Some(token), + ) + .await; + assert!(status == 404 || status == 422, "{raw}"); + no_private_text(&raw); + let (status, body, raw) = http + .request( + "POST", + "/v1/plugin-graphs/missing-graph/prepare", + json!({}), + Some(token), + ) + .await; + assert_eq!(status, 404, "{raw}"); + assert_eq!(body["error"]["code"], "plugin_graph_missing"); + + let (status, state_resolved, raw) = http + .request( + "POST", + "/v1/plugin-graphs/resolve", + json!({ + "roots": [{"id": "org.rustscript.tools.effectstate", "version": "1.0.0"}], + "tool_package": "org.rustscript.tools.effectstate", + "deployment_space": "owner-space" + }), + Some(token), + ) + .await; + assert_eq!(status, 200, "{raw}"); + let state_graph = state_resolved["graph_id"].as_str().unwrap(); + let (status, body, raw) = http + .request( + "POST", + &format!("/v1/plugin-graphs/{state_graph}/prepare"), + json!({}), + Some(token), + ) + .await; + assert_eq!(status, 422, "{raw}"); + assert_eq!(body["error"]["code"], "plugin_state_unsupported"); + let unchanged = PluginControlStore::new(&GatewayPersistence::open(&config, &db).unwrap()) + .activation() + .unwrap(); + assert_eq!(unchanged.graph_id, graph_a); + assert_eq!(unchanged.generation, generation_roll); + + let (tool_a, stop_a) = tool_then_stop("call-a", "out-a.txt"); + provider.push_ok(tool_a); + provider.push_ok(stop_a); + service.inject_provider_host(std::sync::Arc::new(provider.clone())); + reject_unpinned_graphs( + &http, + &service, + token, + session_id, + &[ + (graph_b.as_str(), 422, "plugin_graph_pin_required"), + (state_graph, 422, "plugin_state_unsupported"), + ("caller-forged-graph", 422, "plugin_graph_pin_required"), + (" ", 422, "plugin_graph_pin_required"), + ("", 422, "plugin_graph_pin_required"), + ], + ) + .await; + let (status, run_a, raw) = http + .request( + "POST", + "/v1/runs", + json!({ + "session_id": session_id, + "input": "write a", + "workspace": "name:lifecycle", + "graph_id": graph_a + }), + Some(token), + ) + .await; + assert_eq!(status, 202, "{raw}"); + assert_eq!(run_a["graph_id"], graph_a); + assert_eq!(run_a["activation_generation"], generation_roll); + let pinned_legal = PluginControlStore::new(&GatewayPersistence::open(&config, &db).unwrap()) + .pin_for_run(run_a["run_id"].as_str().unwrap()) + .unwrap(); + assert_eq!(pinned_legal["graph_id"], graph_a); + assert_eq!(pinned_legal["generation"], generation_roll); + assert!(run_a["workspace_branch"].is_null()); + assert!(run_a["epoch"].is_null()); + assert_eq!(run_a["publication"], "unsupported"); + let run_a_id = run_a["run_id"].as_str().unwrap().to_string(); + let text_a = wait_marker(&workspace, "out-a.txt", "A-write").await; + let (_event_status, _events, event_raw) = http + .request( + "GET", + &format!("/v1/runs/{run_a_id}/events"), + json!({}), + Some(token), + ) + .await; + let listed = std::fs::read_dir(&workspace) + .map(|entries| { + entries + .filter_map(|entry| entry.ok()) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .collect::>() + .join(",") + }) + .unwrap_or_else(|error| error.to_string()); + assert!( + text_a.contains("A-write"), + "run A did not write its pinned bytes: {text_a}; workspace={listed}; events={event_raw}" + ); + + let (status, activated_b_again, raw) = http + .request( + "POST", + "/v1/plugin-activations", + json!({ + "graph_id": graph_b, + "deployment_space": "owner-space", + "expected_generation": generation_roll + }), + Some(token), + ) + .await; + assert_eq!(status, 200, "{raw}"); + let generation_b2 = activated_b_again["generation"].as_u64().unwrap(); + let (tool_b, stop_b) = tool_then_stop("call-b", "out-b.txt"); + provider.push_ok(tool_b); + provider.push_ok(stop_b); + service.inject_provider_host(std::sync::Arc::new(provider.clone())); + reject_unpinned_graphs( + &http, + &service, + token, + session_id, + &[(&graph_a, 422, "plugin_graph_pin_required")], + ) + .await; + let (status, run_b, raw) = http + .request( + "POST", + "/v1/runs", + json!({ + "session_id": session_id, + "input": "write b", + "workspace": "name:lifecycle", + "graph_id": graph_b + }), + Some(token), + ) + .await; + assert_eq!(status, 202, "{raw}"); + assert_eq!(run_b["graph_id"], graph_b); + assert_eq!(run_b["activation_generation"], generation_b2); + let pinned_b = PluginControlStore::new(&GatewayPersistence::open(&config, &db).unwrap()) + .pin_for_run(run_b["run_id"].as_str().unwrap()) + .unwrap(); + assert_eq!(pinned_b["graph_id"], graph_b); + assert_eq!(pinned_b["generation"], generation_b2); + let text_b = wait_marker(&workspace, "out-b.txt", "B-write").await; + assert!( + text_b.contains("B-write"), + "run B did not write its pinned bytes: {text_b}" + ); + let text_a_after = std::fs::read_to_string(workspace.join("out-a.txt")).unwrap(); + assert!( + text_a_after.contains("A-write"), + "run B changed run A's bytes: {text_a_after}" + ); + let pinned_a = PluginControlStore::new(&GatewayPersistence::open(&config, &db).unwrap()) + .pin_for_run(&run_a_id) + .unwrap(); + assert_eq!(pinned_a["graph_id"], graph_a); + assert_eq!(pinned_a["generation"], generation_roll); + + let artifact = installed_a["artifact_id"].as_str().unwrap(); + let artifact_dir = db + .parent() + .unwrap() + .join(".gateway.sqlite.tool-packages/artifacts") + .join(artifact); + std::fs::remove_dir_all(&artifact_dir).unwrap(); + let (status, body, raw) = http + .request( + "POST", + &format!("/v1/plugin-graphs/{graph_a}/prepare"), + json!({}), + Some(token), + ) + .await; + assert_eq!(status, 422, "{raw}"); + assert_eq!(body["error"]["code"], "plugin_artifact_unavailable"); + no_private_text(&raw); + assert!(!raw.contains("tool-packages"), "{raw}"); + http.close().await; + let _ = std::fs::remove_dir_all(&root); +} + +const EMBEDDING_PACKAGE: &str = "org.rustscript.embedding.tools"; + +fn lock_shape(lock: &rustscript_agent::plugins::GraphLock) -> String { + let packages: Vec = lock + .packages + .iter() + .map(|package| { + format!( + "{}@{}:{}:{}", + package.id, package.version, package.kind, package.artifact_id + ) + }) + .collect(); + format!( + "harness={:?} edges={} closure={} packages={packages:?}", + lock.harness_selection, + lock.edges.len(), + lock.closure.len() + ) +} + +fn write_probe(note: &str) { + if let Ok(path) = std::env::var("RUSTSCRIPT_REVOCATION_PROBE") { + let _ = std::fs::write(path, note); + } +} + +fn bound_packages(db: &std::path::Path, auth: &InstallAuthorization) -> PackageStore { + let store = PackageStore::open(trusted_tool_package_root(db)).expect("tool package root opens"); + store.bind_policy(auth); + store +} + +fn embedding_request() -> ResolveRequest { + ResolveRequest { + roots: vec![PackageRequirement { + id: EMBEDDING_PACKAGE.into(), + version: "1.0.0".into(), + }], + host_runtime: rustscript_agent::plugins::HOST_RUNTIME.into(), + harness_selection: HarnessSelectionRequest::ToolOnly { + tool_package: EMBEDDING_PACKAGE.into(), + }, + } +} + +async fn assert_reentry_rejected( + http: &Http, + token: &str, + graph_id: &str, + generation: u64, + activation_id: &str, +) { + let (status, body, raw) = http + .request( + "POST", + &format!("/v1/plugin-graphs/{graph_id}/prepare"), + json!({}), + Some(token), + ) + .await; + assert_eq!(status, 409, "{raw}"); + assert_eq!(body["error"]["code"], "plugin_graph_revoked"); + no_private_text(&raw); + let (status, body, raw) = http + .request( + "POST", + "/v1/plugin-activations", + json!({ + "graph_id": graph_id, + "deployment_space": "owner-space", + "expected_generation": generation, + }), + Some(token), + ) + .await; + assert_eq!(status, 409, "{raw}"); + assert_eq!(body["error"]["code"], "plugin_graph_revoked"); + no_private_text(&raw); + let (status, body, raw) = http + .request( + "POST", + &format!("/v1/plugin-activations/{activation_id}/rollback"), + json!({}), + Some(token), + ) + .await; + assert_eq!(status, 409, "{raw}"); + assert_eq!(body["error"]["code"], "plugin_graph_revoked"); + no_private_text(&raw); +} + +#[tokio::test] +async fn revoked_formal_graph_prepare_and_activate_return_409() { + let root = private_root(); + let source = root.join("controlled-source"); + std::fs::create_dir_all(&source).unwrap(); + let (auth, package_a, package_b, _) = signed_tool_effects::stage_write_pair(&source); + let db = root.join("gateway.sqlite"); + let token = "owner-control-token"; + let mut config = AgentGatewayConfig { + bearer_token: Some(token.into()), + ..AgentGatewayConfig::default() + }; + config.approvals.write = "allow".into(); + let (home, workspace) = workspace_home(&root); + let state = AgentGatewayState::with_sqlite_path(config.clone(), &db) + .unwrap() + .with_workspace_home(&home) + .with_plugin_control(auth.clone(), source, "owner-space") + .unwrap(); + let materialized = match state.service().materialize_cached_runner() { + Ok(_) => "materialize_ok".to_string(), + Err(err) => format!("materialize_err {err}"), + }; + let http = Http::bind(state.clone()).await; + for (name, package) in [("effect-a", &package_a), ("effect-b", &package_b)] { + let (status, _, raw) = http + .request( + "POST", + "/v1/plugins/install", + json!({"package": package, "deployment_space": "owner-space"}), + Some(token), + ) + .await; + assert_eq!(status, 200, "{name} install failed: {raw}"); + } + let packages = bound_packages(&db, &auth); + let persistence = GatewayPersistence::open(&config, &db).unwrap(); + let control = PluginControlStore::new(&persistence); + let locks = control.list_locks().unwrap(); + let mut embedding_id = None; + let mut embedding_shape = "absent".to_string(); + for graph_id in &locks { + let loaded = control.load_lock(graph_id).unwrap(); + if loaded + .lock + .packages + .iter() + .any(|package| package.id == EMBEDDING_PACKAGE) + { + embedding_id = Some(graph_id.clone()); + embedding_shape = lock_shape(&loaded.lock); + } + } + let fresh = match packages.resolve(&embedding_request()) { + Ok(lock) => format!("fresh_resolve_ok {}", lock_shape(&lock)), + Err(err) => format!("fresh_resolve_err {err}"), + }; + let embedding_id = embedding_id.unwrap_or_else(|| { + panic!( + "formal embedding lock was not stored: materialize={materialized} locks={locks:?} shape={embedding_shape} {fresh}" + ) + }); + let unbound = PackageStore::open(trusted_tool_package_root(&db)).unwrap(); + let unbound_err = control + .revoke_graph(&unbound, &embedding_id) + .expect_err("unbound store must not revoke the embedding lock"); + assert_eq!(unbound_err.code, "plugin_lock_invalid"); + assert!(control.graph_revocation(&embedding_id).unwrap().is_none()); + let fact = control + .revoke_graph(&packages, &embedding_id) + .unwrap_or_else(|err| { + panic!( + "formal embedding revoke refused {}: {err}; shape={embedding_shape} {fresh}", + err.code + ) + }); + assert_eq!(fact.graph_id, embedding_id); + assert!(fact.revoked_at_ms > 0); + let embedding_note = format!( + "materialize={materialized} embedding_id={embedding_id} shape={embedding_shape} {fresh} fact_ms={}", + fact.revoked_at_ms + ); + write_probe(&embedding_note); + + let mut signed = Vec::new(); + for (name, package_id) in [ + ("effect-a", "org.rustscript.tools.effecta"), + ("effect-b", "org.rustscript.tools.effectb"), + ] { + let (status, body, raw) = http + .request( + "POST", + "/v1/plugin-graphs/resolve", + json!({ + "tool_package": package_id, + "roots": [{"id": package_id, "version": "1.0.0"}], + "deployment_space": "owner-space", + }), + Some(token), + ) + .await; + assert_eq!(status, 200, "{name} resolve failed: {raw}"); + let graph_id = body["graph_id"].as_str().unwrap().to_string(); + let (status, _, raw) = http + .request( + "POST", + &format!("/v1/plugin-graphs/{graph_id}/prepare"), + json!({}), + Some(token), + ) + .await; + assert_eq!(status, 200, "{name} prepare failed: {raw}"); + signed.push(graph_id); + } + let graph_a = signed[0].clone(); + let graph_b = signed[1].clone(); + let signed_err = control + .revoke_graph(&packages, &graph_a) + .expect_err("signed tool graph stays outside the embedding revocation door"); + assert_eq!(signed_err.code, "plugin_lock_invalid"); + assert!(control.graph_revocation(&graph_a).unwrap().is_none()); + let current = control.activation().unwrap(); + assert_eq!(current.graph_id, embedding_id); + let target = embedding_id.clone(); + let generation = current.generation; + let activation_id = format!("owner-space.{generation}"); + let stored = control + .graph_revocation(&target) + .unwrap() + .expect("durable revocation"); + assert_eq!(stored.graph_id, target); + assert!(stored.revoked_at_ms > 0); + assert!(control.graph_revocation(&graph_b).unwrap().is_none()); + + let active_before = control.activation().unwrap(); + let history_before = control.activation_history().unwrap().len(); + let mut workspace_before: Vec<_> = std::fs::read_dir(&workspace) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + workspace_before.sort(); + let (sentinel_before, sentinel_body, _) = http + .request( + "GET", + "/v1/runs/revocation-sentinel/effects", + json!({}), + Some(token), + ) + .await; + assert_reentry_rejected(&http, token, &target, generation, &activation_id).await; + let active_after = control.activation().unwrap(); + assert_eq!(active_after.generation, active_before.generation); + assert_eq!(active_after.graph_id, active_before.graph_id); + assert_eq!(control.activation_history().unwrap().len(), history_before); + let mut workspace_after: Vec<_> = std::fs::read_dir(&workspace) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + workspace_after.sort(); + assert_eq!(workspace_before, workspace_after); + let (sentinel_after, sentinel_body_after, _) = http + .request( + "GET", + "/v1/runs/revocation-sentinel/effects", + json!({}), + Some(token), + ) + .await; + assert_eq!(sentinel_before, sentinel_after); + assert_eq!(sentinel_body, sentinel_body_after); + let reopened_persistence = GatewayPersistence::open(&config, &db).unwrap(); + let reopened = PluginControlStore::new(&reopened_persistence); + let reopened_fact = reopened + .graph_revocation(&target) + .unwrap() + .expect("revocation survives reopen"); + assert_eq!(reopened_fact.graph_id, stored.graph_id); + assert_eq!(reopened_fact.revoked_at_ms, stored.revoked_at_ms); + let (status, body, raw) = http + .request( + "POST", + &format!("/v1/plugin-graphs/{target}/prepare"), + json!({}), + Some(token), + ) + .await; + assert_eq!(status, 409, "{raw}"); + assert_eq!(body["error"]["code"], "plugin_graph_revoked"); + + let (status, body, raw) = http + .request( + "POST", + "/v1/plugin-activations", + json!({ + "graph_id": graph_b, + "deployment_space": "owner-space", + "expected_generation": active_after.generation, + }), + Some(token), + ) + .await; + assert_eq!(status, 200, "unrevoked graph activate failed: {raw}"); + let generation_b = body["generation"].as_u64().unwrap(); + assert!(generation_b > active_after.generation); + assert_eq!(body["graph_id"], graph_b); + let (status, _, raw) = http + .request( + "POST", + &format!("/v1/plugin-graphs/{graph_b}/prepare"), + json!({}), + Some(token), + ) + .await; + assert_eq!(status, 200, "unrevoked graph prepare failed: {raw}"); + let provider = ScriptedProvider::new(); + let (tool_b, stop_b) = tool_then_stop("call-b", "out-b.txt"); + provider.push_ok(tool_b); + provider.push_ok(stop_b); + state + .service() + .inject_provider_host(std::sync::Arc::new(provider)); + let (status, session, raw) = http + .request("POST", "/api/sessions", json!({}), Some(token)) + .await; + assert_eq!(status, 201, "{raw}"); + let session_id = session["session"]["id"].as_str().unwrap(); + let (status, run_b, raw) = http + .request( + "POST", + "/v1/runs", + json!({ + "session_id": session_id, + "input": "write b", + "workspace": "name:lifecycle", + }), + Some(token), + ) + .await; + assert_eq!(status, 202, "{raw}"); + assert_eq!(run_b["graph_id"], graph_b); + assert_eq!(run_b["activation_generation"], generation_b); + let text_b = wait_marker(&workspace, "out-b.txt", "B-write").await; + assert!(text_b.contains("B-write"), "{text_b}"); + let run_b_id = run_b["run_id"].as_str().unwrap(); + let pinned = control.pin_for_run(run_b_id).unwrap(); + assert_eq!(pinned["graph_id"], graph_b); + assert_eq!(pinned["generation"], generation_b); + assert_eq!(pinned["pinned"], true); + assert!(control.graph_revocation(&graph_b).unwrap().is_none()); + write_probe(&format!( + "{embedding_note}\ntarget={target}\nfact_ms={}", + stored.revoked_at_ms + )); + http.close().await; + let _ = std::fs::remove_dir_all(&root); +} + +fn open_host_state( + config: &AgentGatewayConfig, + db: &std::path::Path, + home: &std::path::Path, + auth: InstallAuthorization, + source: &std::path::Path, +) -> AgentGatewayState { + AgentGatewayState::with_sqlite_path(config.clone(), db) + .expect("open persistent state") + .with_workspace_home(home) + .with_plugin_control(auth, source.to_path_buf(), "owner-space") + .expect("bind host plugin control") +} + +async fn wait_completed(service: &rustscript_agent::AgentService, run_id: &str) { + for _ in 0..40 { + if service + .run_events(run_id) + .iter() + .any(|event| event["event"] == "run.completed") + { + return; + } + tokio::time::sleep(Duration::from_millis(250)).await; + } + panic!( + "run {run_id} did not complete: {:?}", + service.run_events(run_id) + ); +} + +async fn shutdown_owned(service: &rustscript_agent::AgentService) { + for _ in 0..40 { + match service.shutdown().await { + Ok(()) => return, + Err(err) if err.contains("still active") => { + tokio::time::sleep(Duration::from_millis(100)).await; + } + Err(err) => panic!("shutdown failed: {err}"), + } + } + panic!("owned workers did not finish"); +} + +/// Cold `AgentGatewayState` reconstruction on the same sqlite path, source +/// root, and host authorization. This is not a gateway process restart. +#[tokio::test] +async fn signed_graphs_survive_cold_state_without_reinstall() { + let root = private_root(); + let source = root.join("controlled-source"); + std::fs::create_dir_all(&source).unwrap(); + let (auth, package_a, package_b, _) = signed_tool_effects::stage_write_pair(&source); + let db = root.join("gateway.sqlite"); + let token = "owner-control-token"; + let mut config = AgentGatewayConfig { + bearer_token: Some(token.into()), + ..AgentGatewayConfig::default() + }; + config.approvals.write = "allow".into(); + let (home, workspace) = workspace_home(&root); + + let state = open_host_state(&config, &db, &home, auth.clone(), &source); + let service = state.service(); + let persistence = state.persistence().expect("persistence"); + let provider = ScriptedProvider::new(); + service.inject_provider_host(std::sync::Arc::new(provider.clone())); + let http = Http::bind(state).await; + for package in [&package_a, &package_b] { + let (status, _, raw) = http + .request( + "POST", + "/v1/plugins/install", + json!({"package": package, "deployment_space": "owner-space"}), + Some(token), + ) + .await; + assert_eq!(status, 200, "{package}: {raw}"); + } + let (status, resolved_a, raw) = http + .request( + "POST", + "/v1/plugin-graphs/resolve", + json!({ + "roots": [{"id": "org.rustscript.tools.effecta", "version": "1.0.0"}], + "tool_package": "org.rustscript.tools.effecta", + "deployment_space": "owner-space" + }), + Some(token), + ) + .await; + assert_eq!(status, 200, "{raw}"); + let graph_a = resolved_a["graph_id"].as_str().unwrap().to_string(); + let packages_a = resolved_a["packages"].clone(); + let harness_a = resolved_a["harness_selection"].clone(); + let (status, _, raw) = http + .request( + "POST", + &format!("/v1/plugin-graphs/{graph_a}/prepare"), + json!({}), + Some(token), + ) + .await; + assert_eq!(status, 200, "{raw}"); + let (status, activated_a, raw) = http + .request( + "POST", + "/v1/plugin-activations", + json!({ + "graph_id": graph_a, + "deployment_space": "owner-space", + "expected_generation": 0 + }), + Some(token), + ) + .await; + assert_eq!(status, 200, "{raw}"); + let generation_a = activated_a["generation"].as_u64().unwrap(); + let (status, resolved_b, raw) = http + .request( + "POST", + "/v1/plugin-graphs/resolve", + json!({ + "roots": [{"id": "org.rustscript.tools.effectb", "version": "1.0.0"}], + "tool_package": "org.rustscript.tools.effectb", + "deployment_space": "owner-space" + }), + Some(token), + ) + .await; + assert_eq!(status, 200, "{raw}"); + let graph_b = resolved_b["graph_id"].as_str().unwrap().to_string(); + let (status, session, raw) = http + .request("POST", "/api/sessions", json!({}), Some(token)) + .await; + assert_eq!(status, 201, "{raw}"); + let session_id = session["session"]["id"].as_str().unwrap().to_string(); + let (tool_a, stop_a) = tool_then_stop("call-a", "out-a.txt"); + provider.push_ok(tool_a); + provider.push_ok(stop_a); + reject_unpinned_graphs( + &http, + &service, + token, + &session_id, + &[("caller-forged-graph", 422, "plugin_graph_pin_required")], + ) + .await; + let (status, run_a, raw) = http + .request( + "POST", + "/v1/runs", + json!({ + "session_id": session_id, + "input": "write a", + "workspace": "name:lifecycle", + "graph_id": graph_a + }), + Some(token), + ) + .await; + assert_eq!(status, 202, "{raw}"); + let run_a_id = run_a["run_id"].as_str().unwrap().to_string(); + assert_eq!(run_a["graph_id"], graph_a); + let text_a = wait_marker(&workspace, "out-a.txt", "A-write").await; + assert!(text_a.contains("A-write"), "{text_a}"); + wait_completed(&service, &run_a_id).await; + let pin_before = PluginControlStore::new(persistence.as_ref()) + .pin_for_run(&run_a_id) + .expect("original pin receipt"); + assert_eq!(pin_before["graph_id"], graph_a); + assert_eq!(pin_before["generation"], generation_a); + assert_eq!(pin_before["harness_selection"]["mode"], "tool_only"); + drop(persistence); + http.close().await; + shutdown_owned(&service).await; + drop(service); + drop(provider); + + let reopened = open_host_state(&config, &db, &home, auth.clone(), &source); + let reopened_service = reopened.service(); + let reopened_persistence = reopened.persistence().expect("reopened persistence"); + assert!( + reopened_service.handle(&run_a_id).is_none(), + "cold state must not rebuild the old native grant" + ); + assert_eq!(reopened_service.handle_count(), 0); + let reopened_provider = ScriptedProvider::new(); + let (tool_b, stop_b) = tool_then_stop("call-b", "out-b.txt"); + reopened_provider.push_ok(tool_b); + reopened_provider.push_ok(stop_b); + reopened_service.inject_provider_host(std::sync::Arc::new(reopened_provider.clone())); + let http = Http::bind(reopened).await; + let (status, resolved_again, raw) = http + .request( + "POST", + "/v1/plugin-graphs/resolve", + json!({ + "roots": [{"id": "org.rustscript.tools.effecta", "version": "1.0.0"}], + "tool_package": "org.rustscript.tools.effecta", + "deployment_space": "owner-space" + }), + Some(token), + ) + .await; + assert_eq!( + status, 200, + "cold restart resolve must accept the host publisher, got {status} {raw}" + ); + assert_eq!(resolved_again["packages"], packages_a); + assert_eq!(resolved_again["harness_selection"], harness_a); + let (status, prepared, raw) = http + .request( + "POST", + &format!("/v1/plugin-graphs/{graph_a}/prepare"), + json!({}), + Some(token), + ) + .await; + assert_eq!( + status, 200, + "cold restart prepare of the original graph: {raw}" + ); + assert_eq!(prepared["graph_id"], graph_a); + assert_eq!(prepared["executable"], true); + assert_eq!(prepared["status"], "prepared"); + let (status, viewed, raw) = http + .request( + "GET", + &format!("/v1/plugin-graphs/{graph_a}"), + json!({}), + Some(token), + ) + .await; + assert_eq!(status, 200, "{raw}"); + assert_eq!(viewed["contract"], pin_before["harness_selection"]); + let (status, _, raw) = http + .request( + "POST", + &format!("/v1/plugin-graphs/{graph_b}/prepare"), + json!({}), + Some(token), + ) + .await; + assert_eq!(status, 200, "{raw}"); + let (status, activated_b, raw) = http + .request( + "POST", + "/v1/plugin-activations", + json!({ + "graph_id": graph_b, + "deployment_space": "owner-space", + "expected_generation": generation_a + }), + Some(token), + ) + .await; + assert_eq!(status, 200, "{raw}"); + let generation_b = activated_b["generation"].as_u64().unwrap(); + let (status, session_b, raw) = http + .request("POST", "/api/sessions", json!({}), Some(token)) + .await; + assert_eq!(status, 201, "{raw}"); + let session_b_id = session_b["session"]["id"].as_str().unwrap(); + let (status, run_b, raw) = http + .request( + "POST", + "/v1/runs", + json!({ + "session_id": session_b_id, + "input": "rejected graph", + "workspace": "name:lifecycle", + "graph_id": "caller-forged-graph" + }), + Some(token), + ) + .await; + assert_eq!(status, 422, "{raw}"); + assert_eq!(run_b["error"]["code"], "plugin_graph_pin_required"); + assert!(run_b.get("run_id").is_none(), "{raw}"); + let (status, run_b, raw) = http + .request( + "POST", + "/v1/runs", + json!({ + "session_id": session_b_id, + "input": "write b", + "workspace": "name:lifecycle", + "graph_id": graph_b + }), + Some(token), + ) + .await; + assert_eq!(status, 202, "{raw}"); + assert_eq!(run_b["graph_id"], graph_b); + assert_eq!(run_b["activation_generation"], generation_b); + assert_ne!(run_b["run_id"], run_a_id); + let text_b = wait_marker(&workspace, "out-b.txt", "B-write").await; + assert!(text_b.contains("B-write"), "{text_b}"); + let run_b_id = run_b["run_id"].as_str().unwrap(); + wait_completed(&reopened_service, run_b_id).await; + let text_a_after = std::fs::read_to_string(workspace.join("out-a.txt")).unwrap(); + assert_eq!(text_a_after, text_a); + let pin_after = PluginControlStore::new(reopened_persistence.as_ref()) + .pin_for_run(&run_a_id) + .expect("original pin after cold state"); + assert_eq!(pin_after, pin_before); + let pin_b = PluginControlStore::new(reopened_persistence.as_ref()) + .pin_for_run(run_b_id) + .expect("new pin"); + assert_eq!(pin_b["graph_id"], graph_b); + assert_eq!(pin_b["harness_selection"]["mode"], "tool_only"); + assert_eq!( + pin_b["harness_selection"]["tool_package"], + "org.rustscript.tools.effectb" + ); + drop(reopened_persistence); + assert!(reopened_service.handle(&run_a_id).is_none()); + http.close().await; + shutdown_owned(&reopened_service).await; + drop(reopened_service); + drop(reopened_provider); + + let empty = InstallAuthorization { + admin: true, + accepted_publishers: Vec::new(), + unsigned_dev_space: None, + }; + let denied = open_host_state(&config, &db, &home, empty, &source); + let denied_service = denied.service(); + let http = Http::bind(denied).await; + let (status, body, raw) = http + .request( + "POST", + "/v1/plugin-graphs/resolve", + json!({ + "roots": [{"id": "org.rustscript.tools.effecta", "version": "1.0.0"}], + "tool_package": "org.rustscript.tools.effecta", + "deployment_space": "owner-space" + }), + Some(token), + ) + .await; + assert_eq!(status, 403, "{raw}"); + assert_eq!(body["error"]["code"], "plugin_publisher_unauthorized"); + http.close().await; + shutdown_owned(&denied_service).await; + drop(denied_service); + let _ = std::fs::remove_dir_all(&root); +} + +fn dev_manifest(id: &str) -> String { + format!( + r#"format: rustscript.plugin/v1 +id: {id} +version: 1.0.0 +kind: tool +runtime: ">=0.1.0, <0.2.0" +contract: agent.tool/v1 +host_abi: agent.cap/v1 +entry: + path: invoke.rss + export: invoke +dependencies: [] +capabilities: [] +effects: + contract: agent.effects/v1 + entries: + invoke: + direct: [] + delegated: [] +"# + ) +} + +fn write_dev_package(dir: &std::path::Path, id: &str, extra_files: usize) { + std::fs::create_dir_all(dir).unwrap(); + let manifest = dev_manifest(id); + rustscript_agent::plugins::parse_plugin_manifest(&manifest).unwrap_or_else(|error| { + panic!("dev manifest must parse before the budget boundary: {error:?}") + }); + std::fs::write(dir.join("manifest.yaml"), manifest).unwrap(); + std::fs::write( + dir.join("invoke.rss"), + "pub fn invoke(value: int) -> int { value }\n", + ) + .unwrap(); + for index in 0..extra_files { + std::fs::write(dir.join(format!("pad{index:02}.txt")), b"x").unwrap(); + } +} + +fn regular_file_stats(dir: &std::path::Path) -> (usize, u64, u64) { + let mut count = 0usize; + let mut total = 0u64; + let mut max_len = 0u64; + let mut pending = vec![dir.to_path_buf()]; + while let Some(current) = pending.pop() { + for entry in std::fs::read_dir(¤t).unwrap() { + let entry = entry.unwrap(); + let meta = std::fs::symlink_metadata(entry.path()).unwrap(); + assert!( + !meta.file_type().is_symlink(), + "budget fixture must not use a symlink: {}", + entry.path().display() + ); + if meta.is_dir() { + pending.push(entry.path()); + } else if meta.is_file() { + count += 1; + total += meta.len(); + max_len = max_len.max(meta.len()); + } + } + } + (count, total, max_len) +} + +fn package_fs_identity(db: &std::path::Path) -> Vec<(String, u64, u64)> { + use std::os::unix::fs::MetadataExt; + let root = trusted_tool_package_root(db); + let mut rows = Vec::new(); + for label in ["artifacts", "ledger"] { + let dir = root.join(label); + if !dir.exists() { + continue; + } + let mut pending = vec![(dir, label.to_string())]; + while let Some((current, prefix)) = pending.pop() { + let mut children: Vec<_> = std::fs::read_dir(¤t) + .unwrap() + .map(|entry| entry.unwrap()) + .collect(); + children.sort_by_key(|entry| entry.file_name()); + for entry in children { + let path = entry.path(); + let meta = std::fs::symlink_metadata(&path).unwrap(); + assert!(!meta.file_type().is_symlink(), "{}", path.display()); + let rel = format!("{prefix}/{}", entry.file_name().to_string_lossy()); + rows.push((rel.clone(), meta.len(), meta.ino())); + if meta.is_dir() { + pending.push((path, rel)); + } + } + } + } + rows.sort(); + rows +} + +fn control_snapshot( + config: &AgentGatewayConfig, + db: &std::path::Path, +) -> ( + rustscript_agent::plugins::ActivationPointer, + Vec, +) { + let persistence = GatewayPersistence::open(config, db).unwrap(); + let control = PluginControlStore::new(&persistence); + ( + control.activation().unwrap(), + control.activation_history().unwrap(), + ) +} + +fn fresh_package_ids( + db: &std::path::Path, + auth: &InstallAuthorization, +) -> Vec<(String, String, PackageTrust)> { + let store = PackageStore::open(trusted_tool_package_root(db)).expect("fresh package store"); + store.bind_policy(auth); + let loaded = store.load_installed().expect("fresh typed load"); + let mut ids: Vec<_> = loaded + .iter() + .map(|package| { + ( + package.manifest.id.clone(), + package.artifact_id.clone(), + package.trust, + ) + }) + .collect(); + ids.sort_by(|left, right| left.0.cmp(&right.0).then(left.1.cmp(&right.1))); + let lock = store + .resolve(&ResolveRequest { + roots: vec![PackageRequirement { + id: "org.rustscript.tools.effecta".into(), + version: "1.0.0".into(), + }], + host_runtime: rustscript_agent::plugins::HOST_RUNTIME.into(), + harness_selection: HarnessSelectionRequest::ToolOnly { + tool_package: "org.rustscript.tools.effecta".into(), + }, + }) + .expect("fresh store resolves signed A"); + assert!( + store + .prepare(&lock) + .expect("fresh store prepares signed A") + .program("org.rustscript.tools.effecta") + .is_some() + ); + drop(store); + ids +} + +fn dir_names(dir: &std::path::Path) -> Vec { + let mut names: Vec<_> = std::fs::read_dir(dir) + .unwrap() + .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + names.sort(); + names +} + +/// Signed A runs through the registered Axum TCP entry. A valid unsigned +/// package inside the host dev space installs. The same manifest with only +/// the file-count dimension over the capture bound must be a static budget +/// denial and must not add activation, history, run, native effect, artifact, +/// or ledger rows. A fresh PackageStore, not the live cache, is the oracle. +#[tokio::test] +async fn http_file_count_over_budget_does_not_poison_signed_graph() { + let root = private_root(); + let source = root.join("controlled-source"); + std::fs::create_dir_all(&source).unwrap(); + let (mut auth, package_a, package_b, _) = signed_tool_effects::stage_write_pair(&source); + let dev = source.join("dev"); + std::fs::create_dir_all(&dev).unwrap(); + auth.unsigned_dev_space = Some(dev.clone()); + let legal_dir = dev.join("legal"); + let over_dir = dev.join("over"); + write_dev_package(&legal_dir, "org.rustscript.tools.budgetlegal", 0); + write_dev_package(&over_dir, "org.rustscript.tools.budgetover", 63); + let outside = source.join("outside-unsigned"); + write_dev_package(&outside, "org.rustscript.tools.budgetoutside", 0); + let (legal_count, _, _) = regular_file_stats(&legal_dir); + let (over_count, over_total, over_max) = regular_file_stats(&over_dir); + assert_eq!(legal_count, 2, "legal control must stay inside every bound"); + assert_eq!( + over_count, 65, + "only the file-count dimension is over the bound" + ); + assert!( + over_max < 1024 * 1024, + "file fixture also crossed the file bound" + ); + assert!( + over_total < 8 * 1024 * 1024, + "file fixture also crossed the total bound" + ); + let scratch = PackageStore::open(root.join("budget-kind-store")).unwrap(); + scratch.bind_policy(&auth); + let legal_kind = scratch + .install(&legal_dir, &auth) + .expect("legal dev package"); + assert_eq!(legal_kind.trust, PackageTrust::DevUnsigned); + let over_kind = scratch + .install(&over_dir, &auth) + .expect_err("65 files must be BudgetExceeded before HTTP"); + assert_eq!( + over_kind.kind, + PluginErrorKind::BudgetExceeded, + "{over_kind:?}" + ); + drop(scratch); + + let db = root.join("gateway.sqlite"); + let token = "owner-control-token"; + let mut config = AgentGatewayConfig { + bearer_token: Some(token.into()), + ..AgentGatewayConfig::default() + }; + config.approvals.write = "allow".into(); + let (home, workspace) = workspace_home(&root); + let state = open_host_state(&config, &db, &home, auth.clone(), &source); + let service = state.service(); + let provider = ScriptedProvider::new(); + service.inject_provider_host(std::sync::Arc::new(provider.clone())); + let http = Http::bind(state).await; + + let (status, installed_a, raw) = http + .request( + "POST", + "/v1/plugins/install", + json!({"package": package_a, "deployment_space": "owner-space"}), + Some(token), + ) + .await; + assert_eq!(status, 200, "{raw}"); + assert_eq!(installed_a["package_id"], "org.rustscript.tools.effecta"); + assert_eq!(installed_a["trust"], "accepted_publisher"); + let artifact_a = installed_a["artifact_id"].as_str().unwrap().to_string(); + let (status, resolved, raw) = http + .request( + "POST", + "/v1/plugin-graphs/resolve", + json!({ + "roots": [{"id": "org.rustscript.tools.effecta", "version": "1.0.0"}], + "tool_package": "org.rustscript.tools.effecta", + "deployment_space": "owner-space" + }), + Some(token), + ) + .await; + assert_eq!(status, 200, "{raw}"); + let graph_a = resolved["graph_id"].as_str().unwrap().to_string(); + let (status, prepared, raw) = http + .request( + "POST", + &format!("/v1/plugin-graphs/{graph_a}/prepare"), + json!({}), + Some(token), + ) + .await; + assert_eq!(status, 200, "{raw}"); + assert_eq!(prepared["executable"], true); + let (status, activated, raw) = http + .request( + "POST", + "/v1/plugin-activations", + json!({ + "graph_id": graph_a, + "deployment_space": "owner-space", + "expected_generation": 0 + }), + Some(token), + ) + .await; + assert_eq!(status, 200, "{raw}"); + let generation_a = activated["generation"].as_u64().unwrap(); + let (status, installed_legal, raw) = http + .request( + "POST", + "/v1/plugins/install", + json!({"package": "dev/legal", "deployment_space": "owner-space"}), + Some(token), + ) + .await; + assert_eq!(status, 200, "host unsigned dev install: {raw}"); + assert_eq!( + installed_legal["package_id"], + "org.rustscript.tools.budgetlegal" + ); + assert_eq!(installed_legal["trust"], "dev_unsigned"); + let artifact_legal = installed_legal["artifact_id"].as_str().unwrap().to_string(); + let (status, session, raw) = http + .request("POST", "/api/sessions", json!({}), Some(token)) + .await; + assert_eq!(status, 201, "{raw}"); + let session_id = session["session"]["id"].as_str().unwrap().to_string(); + let (tool_a, stop_a) = tool_then_stop("call-a", "out-a.txt"); + provider.push_ok(tool_a); + provider.push_ok(stop_a); + reject_unpinned_graphs( + &http, + &service, + token, + &session_id, + &[("caller-forged-graph", 422, "plugin_graph_pin_required")], + ) + .await; + let (status, run_a, raw) = http + .request( + "POST", + "/v1/runs", + json!({ + "session_id": session_id, + "input": "write a", + "workspace": "name:lifecycle", + "graph_id": graph_a + }), + Some(token), + ) + .await; + assert_eq!(status, 202, "{raw}"); + assert_eq!(run_a["graph_id"], graph_a); + let run_a_id = run_a["run_id"].as_str().unwrap().to_string(); + let text_a = wait_marker(&workspace, "out-a.txt", "A-write").await; + assert!(text_a.contains("A-write"), "{text_a}"); + wait_completed(&service, &run_a_id).await; + let pin_before = { + let persistence = GatewayPersistence::open(&config, &db).unwrap(); + PluginControlStore::new(&persistence) + .pin_for_run(&run_a_id) + .unwrap() + }; + let run_before = GatewayPersistence::open(&config, &db) + .unwrap() + .run_get(&run_a_id) + .unwrap(); + let control_before = control_snapshot(&config, &db); + let fs_before = package_fs_identity(&db); + let ids_before = fresh_package_ids(&db, &auth); + assert!(ids_before.iter().any(|(id, artifact, trust)| { + id == "org.rustscript.tools.effecta" + && artifact == &artifact_a + && *trust == PackageTrust::AcceptedPublisher + })); + assert!(ids_before.iter().any(|(id, artifact, trust)| { + id == "org.rustscript.tools.budgetlegal" + && artifact == &artifact_legal + && *trust == PackageTrust::DevUnsigned + })); + assert!( + ids_before + .iter() + .all(|(id, _, _)| id != "org.rustscript.tools.budgetover") + ); + let handles_before = service.handle_count(); + let workspace_before = dir_names(&workspace); + + let (status, body, raw) = http + .request( + "POST", + "/v1/plugins/install", + json!({"package": "outside-unsigned", "deployment_space": "owner-space"}), + Some(token), + ) + .await; + assert_eq!(status, 403, "within-budget unsigned outside dev: {raw}"); + assert_eq!(body["error"]["code"], "plugin_publisher_unauthorized"); + assert_eq!(body["error"]["message"], body["error"]["code"]); + no_private_text(&raw); + assert_eq!(package_fs_identity(&db), fs_before); + assert_eq!(fresh_package_ids(&db, &auth), ids_before); + + let (over_status, over_body, over_raw) = http + .request( + "POST", + "/v1/plugins/install", + json!({"package": "dev/over", "deployment_space": "owner-space"}), + Some(token), + ) + .await; + let over_code = over_body["error"]["code"] + .as_str() + .unwrap_or("") + .to_string(); + let over_message = over_body["error"]["message"] + .as_str() + .unwrap_or("") + .to_string(); + assert!( + (400..500).contains(&over_status), + "over-limit install must be 4xx, got {over_status} {over_raw}" + ); + assert_eq!(over_message, over_code, "denial must be static: {over_raw}"); + assert_ne!(over_code, "plugin_publisher_unauthorized", "{over_raw}"); + assert_ne!(over_code, "plugin_request_invalid", "{over_raw}"); + assert_ne!(over_code, "plugin_source_rejected", "{over_raw}"); + no_private_text(&over_raw); + assert!( + !over_raw.contains('/') && !over_raw.contains("pad") && !over_raw.contains("manifest"), + "budget denial leaked a path or package name: {over_raw}" + ); + assert!( + !over_raw.contains(&source.display().to_string()), + "{over_raw}" + ); + assert!( + !over_raw.contains("package files"), + "budget denial leaked the store chain: {over_raw}" + ); + assert_eq!(control_snapshot(&config, &db), control_before); + assert_eq!(package_fs_identity(&db), fs_before); + assert!( + package_fs_identity(&db) + .iter() + .all(|(name, _, _)| !name.ends_with(".tmp")) + ); + assert_eq!(fresh_package_ids(&db, &auth), ids_before); + assert_eq!(service.handle_count(), handles_before); + assert_eq!(dir_names(&workspace), workspace_before); + assert_eq!( + std::fs::read_to_string(workspace.join("out-a.txt")).unwrap(), + text_a + ); + let pin_after = { + let persistence = GatewayPersistence::open(&config, &db).unwrap(); + PluginControlStore::new(&persistence) + .pin_for_run(&run_a_id) + .unwrap() + }; + assert_eq!(pin_after, pin_before); + assert_eq!( + GatewayPersistence::open(&config, &db) + .unwrap() + .run_get(&run_a_id) + .unwrap(), + run_before + ); + + let (status, _, raw) = http + .request( + "POST", + "/v1/plugins/install", + json!({"package": package_b, "deployment_space": "owner-space"}), + Some(token), + ) + .await; + assert_eq!(status, 200, "healthy signed install after rejection: {raw}"); + let (status, resolved_b, raw) = http + .request( + "POST", + "/v1/plugin-graphs/resolve", + json!({ + "roots": [{"id": "org.rustscript.tools.effectb", "version": "1.0.0"}], + "tool_package": "org.rustscript.tools.effectb", + "deployment_space": "owner-space" + }), + Some(token), + ) + .await; + assert_eq!(status, 200, "{raw}"); + let graph_b = resolved_b["graph_id"].as_str().unwrap().to_string(); + let (status, _, raw) = http + .request( + "POST", + &format!("/v1/plugin-graphs/{graph_a}/prepare"), + json!({}), + Some(token), + ) + .await; + assert_eq!(status, 200, "signed A still prepares: {raw}"); + let (status, _, raw) = http + .request( + "POST", + &format!("/v1/plugin-graphs/{graph_b}/prepare"), + json!({}), + Some(token), + ) + .await; + assert_eq!(status, 200, "{raw}"); + let (status, activated_b, raw) = http + .request( + "POST", + "/v1/plugin-activations", + json!({ + "graph_id": graph_b, + "deployment_space": "owner-space", + "expected_generation": generation_a + }), + Some(token), + ) + .await; + assert_eq!(status, 200, "{raw}"); + let generation_b = activated_b["generation"].as_u64().unwrap(); + let (tool_b, stop_b) = tool_then_stop("call-b", "out-b.txt"); + provider.push_ok(tool_b); + provider.push_ok(stop_b); + service.inject_provider_host(std::sync::Arc::new(provider.clone())); + let (status, forged, raw) = http + .request( + "POST", + "/v1/runs", + json!({ + "session_id": session_id, + "input": "rejected graph", + "workspace": "name:lifecycle", + "graph_id": "caller-forged-graph" + }), + Some(token), + ) + .await; + assert_eq!(status, 422, "{raw}"); + assert_eq!(forged["error"]["code"], "plugin_graph_pin_required"); + assert!(forged.get("run_id").is_none(), "{raw}"); + let (status, run_b, raw) = http + .request( + "POST", + "/v1/runs", + json!({ + "session_id": session_id, + "input": "write b", + "workspace": "name:lifecycle", + "graph_id": graph_b + }), + Some(token), + ) + .await; + assert_eq!(status, 202, "{raw}"); + assert_eq!(run_b["graph_id"], graph_b); + assert_eq!(run_b["activation_generation"], generation_b); + let text_b = wait_marker(&workspace, "out-b.txt", "B-write").await; + assert!(text_b.contains("B-write"), "{text_b}"); + assert_eq!( + std::fs::read_to_string(workspace.join("out-a.txt")).unwrap(), + text_a + ); + let run_b_id = run_b["run_id"].as_str().unwrap().to_string(); + wait_completed(&service, &run_b_id).await; + let fs_live = package_fs_identity(&db); + http.close().await; + shutdown_owned(&service).await; + drop(service); + drop(provider); + + let reopened = open_host_state(&config, &db, &home, auth.clone(), &source); + let reopened_service = reopened.service(); + assert!(reopened_service.handle(&run_a_id).is_none()); + assert_eq!(reopened_service.handle_count(), 0); + let http = Http::bind(reopened).await; + let (status, prepared, raw) = http + .request( + "POST", + &format!("/v1/plugin-graphs/{graph_a}/prepare"), + json!({}), + Some(token), + ) + .await; + assert_eq!(status, 200, "cold prepare without reinstall: {raw}"); + assert_eq!(prepared["graph_id"], graph_a); + assert_eq!(prepared["executable"], true); + let (status, _, raw) = http + .request( + "POST", + &format!("/v1/plugin-graphs/{graph_b}/prepare"), + json!({}), + Some(token), + ) + .await; + assert_eq!(status, 200, "{raw}"); + assert_eq!( + std::fs::read_to_string(workspace.join("out-a.txt")).unwrap(), + text_a + ); + assert_eq!( + std::fs::read_to_string(workspace.join("out-b.txt")).unwrap(), + text_b + ); + assert_eq!(package_fs_identity(&db), fs_live); + let ids_cold = fresh_package_ids(&db, &auth); + assert!(ids_cold.iter().any(|(id, artifact, _)| { + id == "org.rustscript.tools.effecta" && artifact == &artifact_a + })); + assert!( + ids_cold + .iter() + .all(|(id, _, _)| id != "org.rustscript.tools.budgetover") + ); + http.close().await; + shutdown_owned(&reopened_service).await; + drop(reopened_service); + assert_eq!( + over_status, 422, + "budget denial must be 422; store kind was {over_kind:?}; got {over_status} {over_raw}" + ); + assert_eq!( + over_code, "plugin_budget_exceeded", + "HTTP code must be the static budget boundary; store kind was {over_kind:?}; got {over_status} {over_raw}" + ); + assert_eq!( + over_message, "plugin_budget_exceeded", + "HTTP message must equal the static budget code; got {over_status} {over_raw}" + ); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/tests/support/plugin_effect_api_cycle.rs b/tests/support/plugin_effect_api_cycle.rs new file mode 100644 index 0000000..62d6122 --- /dev/null +++ b/tests/support/plugin_effect_api_cycle.rs @@ -0,0 +1,664 @@ +//! T16 HTTP cycle helpers. Production routes and the signed-tool producer stay unchanged. +use super::signed_tool_effects; +use http_body_util::BodyExt; +use rustscript_agent::plugins::{InstallAuthorization, PackageStore, PluginControlStore}; +use rustscript_agent::{ + AgentGatewayConfig, AgentGatewayState, AgentProviderHost, GatewayPersistence, RunCancellation, + ScriptedProvider, + config::{ManagedMaintenancePolicy, ManagedRetentionLimits, ManagedWorkspaceConfig}, + effects::ResourceQuota, +}; +use serde_json::{Value, json}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::{Duration, Instant}; + +pub struct Http { + address: std::net::SocketAddr, + shutdown: tokio::sync::oneshot::Sender<()>, + server: tokio::task::JoinHandle>, +} + +impl Http { + pub async fn bind(state: AgentGatewayState) -> Self { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let (shutdown, stopped) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + axum::serve( + listener, + rustscript_agent::build_agent_gateway_app(state) + .into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(async move { + let _ = stopped.await; + }) + .await + }); + Self { + address, + shutdown, + server, + } + } + + pub async fn request( + &self, + method: &str, + path: &str, + body: Value, + token: Option<&str>, + idempotency: Option<&str>, + ) -> (u16, Value) { + let client: hyper_util::client::legacy::Client<_, http_body_util::Full> = + hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new()) + .build_http(); + let mut request = hyper::Request::builder() + .method(method) + .uri(format!("http://{}{path}", self.address)); + if let Some(token) = token { + request = request.header(hyper::header::AUTHORIZATION, format!("Bearer {token}")); + } + if let Some(key) = idempotency { + request = request.header("idempotency-key", key); + } + let request = request + .header(hyper::header::CONTENT_TYPE, "application/json") + .body(http_body_util::Full::new(bytes::Bytes::from( + body.to_string(), + ))) + .unwrap(); + let response = tokio::time::timeout(Duration::from_secs(20), client.request(request)) + .await + .expect("HTTP deadline") + .expect("HTTP transport"); + let status = response.status().as_u16(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + ( + status, + serde_json::from_slice(&bytes).unwrap_or(Value::Null), + ) + } + + pub async fn typed_sse(&self, token: &str, run_id: &str) -> Vec { + let client: hyper_util::client::legacy::Client<_, http_body_util::Full> = + hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new()) + .build_http(); + let request = hyper::Request::builder() + .method("GET") + .uri(format!("http://{}/v1/runs/{run_id}/events", self.address)) + .header(hyper::header::AUTHORIZATION, format!("Bearer {token}")) + .header(hyper::header::ACCEPT, "text/event-stream") + .body(http_body_util::Full::new(bytes::Bytes::new())) + .unwrap(); + let response = tokio::time::timeout(Duration::from_secs(20), client.request(request)) + .await + .expect("SSE deadline") + .expect("SSE transport"); + assert_eq!(response.status().as_u16(), 200, "SSE status"); + let content_type = response + .headers() + .get(hyper::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + assert!( + content_type.starts_with("text/event-stream"), + "SSE content type" + ); + let bytes = tokio::time::timeout(Duration::from_secs(20), response.into_body().collect()) + .await + .expect("SSE body deadline") + .expect("SSE body") + .to_bytes(); + let text = String::from_utf8(bytes.to_vec()).expect("SSE utf-8"); + let events = parse_typed_events(&text); + drop(text); + events + } + + pub async fn close(self) { + let _ = self.shutdown.send(()); + tokio::time::timeout(Duration::from_secs(5), self.server) + .await + .unwrap() + .unwrap() + .unwrap(); + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct TypedEvent { + pub name: String, + pub seq: u64, + pub terminal: bool, +} + +fn parse_typed_events(text: &str) -> Vec { + let mut events = Vec::new(); + let mut sse_name = String::new(); + for line in text.lines() { + if let Some(name) = line.strip_prefix("event: ") { + sse_name = name.to_string(); + continue; + } + let Some(data) = line.strip_prefix("data: ") else { + continue; + }; + let Ok(value) = serde_json::from_str::(data) else { + continue; + }; + let name = value["event"] + .as_str() + .unwrap_or(sse_name.as_str()) + .to_string(); + if !sse_name.is_empty() { + assert_eq!(sse_name, name, "SSE event name diverges from typed payload"); + } + let seq = value["seq"].as_u64().expect("typed SSE seq"); + let terminal = matches!( + name.as_str(), + "run.completed" | "run.cancelled" | "run.failed" + ); + events.push(TypedEvent { + name, + seq, + terminal, + }); + sse_name.clear(); + } + events +} + +pub fn assert_typed_terminal(events: &[TypedEvent], terminal: &str) { + assert!(!events.is_empty(), "SSE replay was empty"); + for pair in events.windows(2) { + assert!(pair[1].seq > pair[0].seq, "SSE seq must increase"); + } + assert_eq!(events[0].seq, 1, "SSE seq origin"); + let terminals: Vec<_> = events.iter().filter(|event| event.terminal).collect(); + assert_eq!(terminals.len(), 1, "one terminal event"); + assert_eq!( + events.last().map(|event| event.name.as_str()), + Some(terminal) + ); + assert!(events.iter().any(|event| event.name == "run.started")); +} + +/// Same hold as the existing second-call provider gate: after the second business +/// response is computed, before the agent loop observes it. Guardian turns do not count. +pub struct SecondBusinessGate { + provider: ScriptedProvider, + release: Arc<(Mutex, Condvar)>, + holding: AtomicBool, +} + +impl SecondBusinessGate { + pub fn new(provider: ScriptedProvider) -> Self { + Self { + provider, + release: Arc::new((Mutex::new(false), Condvar::new())), + holding: AtomicBool::new(false), + } + } + + pub fn holding(&self) -> bool { + self.holding.load(Ordering::Acquire) + } + + pub fn release(&self) { + let (flag, cv) = &*self.release; + *flag.lock().expect("gate flag") = true; + cv.notify_all(); + } +} + +impl AgentProviderHost for SecondBusinessGate { + fn call(&self, request: &Value, cancellation: &RunCancellation) -> Value { + let outcome = self.provider.call(request, cancellation); + if is_guardian(request) || business_len(&self.provider.requests()) < 2 { + return outcome; + } + self.holding.store(true, Ordering::Release); + let (flag, cv) = &*self.release; + let mut ready = flag.lock().expect("gate flag"); + let deadline = Instant::now() + Duration::from_secs(15); + while !*ready { + if cancellation.requested().is_some() || cancellation.deadline_passed() { + break; + } + let now = Instant::now(); + if now >= deadline { + break; + } + let (guard, _) = cv + .wait_timeout(ready, deadline.saturating_duration_since(now)) + .expect("gate wait"); + ready = guard; + } + self.holding.store(false, Ordering::Release); + outcome + } +} + +fn is_guardian(request: &Value) -> bool { + request["messages"] + .as_array() + .and_then(|messages| messages.first()) + .and_then(|message| message["content"].as_str()) + .is_some_and(|text| { + text.starts_with("You are Guardian, reviewing one proposed tool action.") + }) + || request + .to_string() + .contains("You are Guardian, reviewing one proposed tool action.") +} + +fn business_len(requests: &[Value]) -> usize { + requests + .iter() + .filter(|request| !is_guardian(request)) + .count() +} + +pub struct Cycle { + pub root: PathBuf, + pub workspace: PathBuf, + pub source: PathBuf, + pub db: PathBuf, + pub token: String, + pub config: AgentGatewayConfig, + pub auth: InstallAuthorization, + pub state: AgentGatewayState, +} + +impl Cycle { + pub fn open(label: &str) -> Self { + Self::open_timeout(label, Duration::from_secs(900)) + } + + pub fn open_timeout(label: &str, run_timeout: Duration) -> Self { + let root = PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()).join(label); + let _ = std::fs::remove_dir_all(&root); + let workspace = root.join("workspace"); + let home = root.join("workspace-home"); + let source = root.join("controlled-source"); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&source).unwrap(); + std::fs::write(workspace.join("seed.txt"), b"seed").unwrap(); + let yaml = format!( + "version: 1\nmodel:\n provider: local-test\n model: synthetic\nproviders:\n local-test:\n protocol: codex-responses\n base_url: https://127.0.0.1:1\nworkspaces:\n allowed_roots:\n - '{}'\n default: '{}'\n named:\n lifecycle: '{}'\n", + workspace.display(), + workspace.display(), + workspace.display() + ); + std::fs::write(home.join("config.yaml"), yaml).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for path in [&root, &workspace, &home, &source] { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + std::fs::set_permissions( + home.join("config.yaml"), + std::fs::Permissions::from_mode(0o600), + ) + .unwrap(); + } + let token = uuid::Uuid::new_v4().to_string(); + let mut config = AgentGatewayConfig { + bearer_token: Some(token.clone()), + managed_workspace: Some(ManagedWorkspaceConfig { + source_root: workspace.clone(), + quota: ResourceQuota::new(1000, 1_048_576, 16).unwrap(), + retention: ManagedRetentionLimits::default(), + maintenance_policy: ManagedMaintenancePolicy { + create_checkpoint: true, + restore: true, + branch: true, + publish: false, + }, + }), + ..AgentGatewayConfig::default() + }; + config.approvals.write = "allow".into(); + config.run_timeout = run_timeout; + let db = root.join("gateway.sqlite"); + let (auth, _, _, _) = signed_tool_effects::stage_write_pair(&source); + let bound = InstallAuthorization { + admin: auth.admin, + accepted_publishers: auth.accepted_publishers.clone(), + unsigned_dev_space: auth.unsigned_dev_space.clone(), + }; + let state = AgentGatewayState::with_sqlite_path(config.clone(), &db) + .unwrap() + .with_workspace_home(&home) + .with_plugin_control(auth, source.clone(), "owner-space") + .unwrap(); + Self { + root, + workspace, + source, + db, + token, + config, + auth: bound, + state, + } + } +} + +pub fn tool_call(id: &str, name: &str, path: &str) -> Value { + json!({ + "text": "", + "tool_calls": [{"id": id, "name": name, "arguments": {"path": path, "content": "ignored"}}], + "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + "reasoning": "", + "stop_reason": "tool_calls" + }) +} + +pub fn stop_turn(text: &str) -> Value { + json!({ + "text": text, + "tool_calls": [], + "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + "reasoning": "", + "stop_reason": "stop" + }) +} + +pub fn explain(status: u16, body: &Value) -> String { + format!( + "status={status} code={} message={}", + body["error"]["code"], body["error"]["message"] + ) +} + +pub fn assert_closed(body: &Value, token: &str, hidden: &[&Path]) { + let text = body.to_string(); + assert!( + !text.contains(token), + "response leaked the owner credential" + ); + for path in hidden { + let rendered = path.display().to_string(); + assert!( + !rendered.is_empty() && !text.contains(&rendered), + "response leaked a host path" + ); + } +} + +pub async fn install_pair(http: &Http, token: &str, source: &Path) { + let (status, body) = http + .request( + "POST", + "/v1/plugins/install", + json!({"admin": true, "package": "package-a", "deployment_space": "owner-space", "path": source.display().to_string()}), + Some(token), + None, + ) + .await; + assert_eq!(status, 422, "{}", explain(status, &body)); + assert_eq!(body["error"]["code"], "plugin_request_invalid"); + let (status, body) = http + .request( + "POST", + "/v1/plugins/install", + json!({"package": "package-a", "deployment_space": "owner-space"}), + None, + None, + ) + .await; + assert_eq!(status, 401, "{}", explain(status, &body)); + assert_eq!(body["error"]["code"], "unauthorized"); + for package in ["package-a", "package-b"] { + let (status, body) = http + .request( + "POST", + "/v1/plugins/install", + json!({"package": package, "deployment_space": "owner-space"}), + Some(token), + None, + ) + .await; + assert_eq!(status, 200, "{package}: {}", explain(status, &body)); + } +} + +pub async fn activate( + http: &Http, + token: &str, + config: &AgentGatewayConfig, + db: &Path, + package_id: &str, +) -> (String, u64) { + let (status, resolved) = http + .request( + "POST", + "/v1/plugin-graphs/resolve", + json!({"roots": [{"id": package_id, "version": "1.0.0"}], "tool_package": package_id, "deployment_space": "owner-space"}), + Some(token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &resolved)); + assert_eq!(resolved["activated"], false); + let graph = resolved["graph_id"].as_str().unwrap().to_string(); + assert_ne!(graph, package_id); + let (status, prepared) = http + .request( + "POST", + &format!("/v1/plugin-graphs/{graph}/prepare"), + json!({}), + Some(token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &prepared)); + assert_eq!(prepared["executable"], true); + let expected = current_generation(config, db); + let (status, activated) = http + .request( + "POST", + "/v1/plugin-activations", + json!({"graph_id": graph, "deployment_space": "owner-space", "expected_generation": expected}), + Some(token), + None, + ) + .await; + assert_eq!( + status, + 200, + "expected={expected} {}", + explain(status, &activated) + ); + let (status, conflict) = http + .request( + "POST", + "/v1/plugin-activations", + json!({"graph_id": graph, "deployment_space": "owner-space", "expected_generation": expected}), + Some(token), + None, + ) + .await; + assert_eq!(status, 409, "{}", explain(status, &conflict)); + assert_eq!(conflict["error"]["code"], "plugin_activation_conflict"); + let generation = activated["generation"].as_u64().unwrap(); + assert!(generation > expected); + assert_eq!(activated["graph_id"], graph); + (graph, generation) +} + +pub async fn wait_until(ready: impl FnMut() -> bool) { + wait_for(ready, Duration::from_secs(20)).await; +} + +pub async fn wait_for(mut ready: impl FnMut() -> bool, bound: Duration) { + let deadline = Instant::now() + bound; + while !ready() { + assert!(Instant::now() < deadline, "fixture wait exceeded {bound:?}"); + tokio::time::sleep(Duration::from_millis(20)).await; + } +} + +pub fn event_names(state: &AgentGatewayState, run_id: &str) -> Vec { + state + .service() + .run_events(run_id) + .iter() + .filter_map(|event| event["event"].as_str().map(str::to_string)) + .collect() +} + +pub fn contains_marker(state: &AgentGatewayState, run_id: &str, marker: &str) -> bool { + let events = state.service().run_events(run_id); + if events + .iter() + .any(|event| event.to_string().contains(marker)) + { + return true; + } + // Managed tool.output events keep publication metadata only. The output + // text is published on the same-run tool-result message before the next + // provider response is observed. + let Some(session_id) = events.iter().find_map(|event| { + event["data"]["session_id"] + .as_str() + .or_else(|| event["session_id"].as_str()) + }) else { + return false; + }; + state + .service() + .session_messages(session_id) + .iter() + .any(|message| { + message["run_id"].as_str() == Some(run_id) && message.to_string().contains(marker) + }) +} + +pub async fn shutdown_when_idle(state: &AgentGatewayState) { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match state.service().shutdown().await { + Ok(()) => return, + Err(message) if message.contains("still active") && Instant::now() < deadline => { + tokio::time::sleep(Duration::from_millis(20)).await; + } + Err(message) => panic!("{message}"), + } + } +} + +pub fn current_generation(config: &AgentGatewayConfig, db: &Path) -> u64 { + let persistence = GatewayPersistence::open(config, db).unwrap(); + PluginControlStore::new(&persistence) + .activation() + .unwrap() + .generation +} + +pub fn embedding_graph_id(config: &AgentGatewayConfig, db: &Path) -> String { + let persistence = GatewayPersistence::open(config, db).unwrap(); + let control = PluginControlStore::new(&persistence); + let locks = control.list_locks().unwrap(); + locks + .iter() + .find(|graph_id| { + control + .load_lock(graph_id) + .unwrap() + .lock + .packages + .iter() + .any(|package| package.id == "org.rustscript.embedding.tools") + }) + .cloned() + .unwrap_or_else(|| panic!("formal embedding lock missing from {locks:?}")) +} + +pub fn bound_store(db: &Path, auth: &InstallAuthorization) -> PackageStore { + let store = PackageStore::open(rustscript_agent::plugins::trusted_tool_package_root(db)) + .expect("tool package root opens"); + store.bind_policy(auth); + store +} + +pub fn pin_for_run(config: &AgentGatewayConfig, db: &Path, run_id: &str) -> Value { + let persistence = GatewayPersistence::open(config, db).unwrap(); + PluginControlStore::new(&persistence) + .pin_for_run(run_id) + .unwrap() +} + +pub fn revoke_graph( + config: &AgentGatewayConfig, + db: &Path, + packages: &PackageStore, + graph_id: &str, +) -> Result<(), rustscript_agent::plugins::PluginControlError> { + let persistence = GatewayPersistence::open(config, db).unwrap(); + PluginControlStore::new(&persistence) + .revoke_graph(packages, graph_id) + .map(|_| ()) +} + +pub fn assert_signed_write_pair_prepares_captured_catalog(root: &Path) { + let (auth, package_a, package_b, package_state) = signed_tool_effects::stage_write_pair(root); + let store = PackageStore::open(root.join("store")).unwrap(); + for (dir, id, marker, expect_catalog) in [ + (&package_a, "org.rustscript.tools.effecta", "A-write", true), + (&package_b, "org.rustscript.tools.effectb", "B-write", true), + ( + &package_state, + "org.rustscript.tools.effectstate", + "S-write", + false, + ), + ] { + let installed = store.install(&root.join(dir), &auth).unwrap(); + assert_eq!( + installed.trust, + rustscript_agent::plugins::PackageTrust::AcceptedPublisher + ); + let lock = store + .resolve(&rustscript_agent::plugins::ResolveRequest { + harness_selection: rustscript_agent::plugins::HarnessSelectionRequest::ToolOnly { + tool_package: id.into(), + }, + roots: vec![rustscript_agent::plugins::PackageRequirement { + id: id.into(), + version: "1.0.0".into(), + }], + host_runtime: rustscript_agent::plugins::HOST_RUNTIME.into(), + }) + .unwrap(); + let prepared = store.prepare(&lock).unwrap(); + assert_eq!( + prepared.trust(id), + Some(rustscript_agent::plugins::PackageTrust::AcceptedPublisher) + ); + assert_ne!( + prepared.trust(id), + Some(rustscript_agent::plugins::PackageTrust::EmbeddingOwnedBuiltin) + ); + let run = std::str::from_utf8(prepared.captured_source(id, "run.rss").unwrap()).unwrap(); + assert!(run.contains("cap::fs_write_atomic"), "{id}"); + assert!(run.contains(marker), "{id}"); + if expect_catalog { + assert_eq!( + prepared.captured_source(id, "catalog.rss").unwrap(), + include_bytes!("../../rss/tools/types.rss"), + "{id}" + ); + } else { + assert!( + prepared.captured_source(id, "catalog.rss").is_none(), + "{id}" + ); + } + } +} diff --git a/tests/support/signed_pinned_variants.rs b/tests/support/signed_pinned_variants.rs index eab80c1..309595e 100644 --- a/tests/support/signed_pinned_variants.rs +++ b/tests/support/signed_pinned_variants.rs @@ -96,6 +96,9 @@ pub fn run(context: map) -> map { store.install(&package, &authorization).unwrap(); let lock = store .resolve(&ResolveRequest { + harness_selection: rustscript_agent::plugins::HarnessSelectionRequest::ToolOnly { + tool_package: "org.rustscript.tools.varianta".to_string(), + }, roots: vec![PackageRequirement { id: "org.rustscript.tools.varianta".to_string(), version: "1.0.0".to_string(), diff --git a/tests/support/signed_tool_effects.rs b/tests/support/signed_tool_effects.rs index 025c715..e2a4f44 100644 --- a/tests/support/signed_tool_effects.rs +++ b/tests/support/signed_tool_effects.rs @@ -125,6 +125,9 @@ pub fn install_pair( store.install(&package, &auth).unwrap(); let lock = store .resolve(&ResolveRequest { + harness_selection: rustscript_agent::plugins::HarnessSelectionRequest::ToolOnly { + tool_package: id.clone(), + }, roots: vec![PackageRequirement { id: id.clone(), version: "1.0.0".into(), @@ -146,3 +149,193 @@ pub fn install_pair( } (store, locks.remove(0), locks.remove(0)) } + +#[allow(dead_code)] +pub fn stage_write_pair(root: &Path) -> (InstallAuthorization, String, String, String) { + let scratch = PackageStore::open(root.join(".sign-scratch")).unwrap(); + let key = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap(); + let pair = Ed25519KeyPair::from_pkcs8(key.as_ref()).unwrap(); + let auth = InstallAuthorization { + admin: true, + accepted_publishers: vec![PublisherKey { + publisher: "org.rustscript".into(), + verifying_key: pair.public_key().as_ref().to_vec(), + }], + unsigned_dev_space: None, + }; + for (name, id, marker, state) in [ + ("package-a", "org.rustscript.tools.effecta", "A-write", ""), + ("package-b", "org.rustscript.tools.effectb", "B-write", ""), + ( + "package-state", + "org.rustscript.tools.effectstate", + "S-write", + "state:\n scope: run\n schema: 1\n", + ), + ] { + let package = root.join(name); + fs::create_dir_all(&package).unwrap(); + fs::write( + package.join("manifest.yaml"), + format!( + "format: rustscript.plugin/v1\nid: {id}\nversion: 1.0.0\nkind: tool\nruntime: \">=0.1.0, <0.2.0\"\ncontract: agent.tool/v1\nhost_abi: agent.cap/v1\nentry:\n path: run.rss\n export: run\ndependencies: []\ncapabilities:\n - workspace.read\n - workspace.write\neffects:\n contract: agent.effects/v1\n entries:\n run:\n direct:\n - kind: workspace.fs.read\n scope: bound_workspace\n recovery: none\n - kind: workspace.fs.write\n scope: bound_workspace\n recovery: none\n delegated: []\n{state}" + ), + ) + .unwrap(); + fs::write(package.join("run.rss"), write_tool_source(marker)).unwrap(); + if state.is_empty() { + fs::write( + package.join("catalog.rss"), + include_bytes!("../../rss/tools/types.rss"), + ) + .unwrap(); + } + let signature = pair.sign(&scratch.signature_payload(&package).unwrap()); + fs::write(package.join("signature.ed25519"), signature.as_ref()).unwrap(); + } + ( + auth, + "package-a".into(), + "package-b".into(), + "package-state".into(), + ) +} + +#[allow(dead_code)] +fn write_tool_source(marker: &str) -> String { + format!( + r#" +fn map_string(value: map, key: string, fallback: string) -> string {{ + let mut result: string = fallback; + if value.has(key) {{ + if type(value[key]) == "string" {{ + let coerced: string = value[key]; + result = coerced; + }} + }} + result +}} + +fn map_map(value: map, key: string) -> map {{ + let mut result: map = {{}}; + if value.has(key) {{ + if type(value[key]) == "map" {{ + let coerced: map = value[key]; + result = coerced; + }} + }} + result +}} + +fn map_int(value: map, key: string, fallback: int) -> int {{ + let mut result: int = fallback; + if value.has(key) {{ + if type(value[key]) == "int" {{ + let coerced: int = value[key]; + result = coerced; + }} + }} + result +}} + +fn map_bool(value: map, key: string, fallback: bool) -> bool {{ + let mut result: bool = fallback; + if value.has(key) {{ + if type(value[key]) == "bool" {{ + let coerced: bool = value[key]; + result = coerced; + }} + }} + result +}} + +fn fail_result(value: map) -> map {{ + let error: map = map_map(value, "error"); + let code: string = map_string(error, "code", "read_failed"); + let message: string = map_string(error, "message", "read failed"); + let err: map = {{ code: code, message: message }}; + {{ ok: false, content: "", data: {{}}, error: err, truncated: false, artifacts: [] }} +}} + +fn commit_result(token: string, result: map) -> map {{ + let committed: map = agent_runtime::tool_commit(token, result); + if map_string(committed, "kind", "") == "committed" => {{ + map_map(committed, "result") + }} else if map_bool(committed, "ok", false) == false => {{ + committed + }} else => {{ + result + }} +}} + +fn read_file_call(context: map, prepare: map) -> map {{ + let prepared: map = agent_runtime::tool_prepare(prepare); + if map_bool(prepared, "ok", false) == false => {{ + prepared + }} else => {{ + let token: string = map_string(prepared, "execution_token", ""); + let arguments: map = map_map(context, "arguments"); + let path: string = map_string(arguments, "path", ""); + let meta: map = cap::fs_metadata(token, path); + if map_bool(meta, "ok", false) == false => {{ + commit_result(token, fail_result(meta)) + }} else => {{ + let file_len: int = map_int(meta, "len", 0); + let read: map = cap::fs_read_range(token, path, 0, file_len); + if map_bool(read, "ok", false) == false => {{ + commit_result(token, fail_result(read)) + }} else => {{ + let text: string = bytes::to_utf8(read.bytes); + let result: map = {{ ok: true, content: text, data: {{}}, error: null, truncated: false, artifacts: [] }}; + commit_result(token, result) + }} + }} + }} +}} + +fn write_file_call(context: map, prepare: map) -> map {{ + let prepared: map = agent_runtime::tool_prepare(prepare); + if map_bool(prepared, "ok", false) == false => {{ + prepared + }} else => {{ + let token: string = map_string(prepared, "execution_token", ""); + let arguments: map = map_map(context, "arguments"); + let path: string = map_string(arguments, "path", ""); + let payload: bytes = bytes::from_utf8("{marker}\n"); + let written: map = cap::fs_write_atomic(token, path, "*", payload); + if map_bool(written, "ok", false) == false => {{ + written + }} else => {{ + let meta: map = cap::fs_metadata(token, path); + if map_bool(meta, "ok", false) == false => {{ + meta + }} else => {{ + let file_len: int = map_int(meta, "len", 0); + let read: map = cap::fs_read_range(token, path, 0, file_len); + if map_bool(read, "ok", false) == false => {{ + read + }} else => {{ + let text: string = bytes::to_utf8(read.bytes); + let result: map = {{ ok: true, content: text, data: {{}}, error: null, truncated: false, artifacts: [] }}; + commit_result(token, result) + }} + }} + }} + }} +}} + +pub fn run(context: map) -> map {{ + let prepare: map = map_map(context, "prepare"); + let name: string = map_string(prepare, "name", ""); + if name == "read_file" => {{ + read_file_call(context, prepare) + }} else if name == "write_file" => {{ + write_file_call(context, prepare) + }} else => {{ + let err: map = {{ code: "tool_not_supported", message: "signed fixture rejects undeclared tool name" }}; + {{ ok: false, content: "", data: {{}}, error: err, truncated: false, artifacts: [] }} + }} +}} +"#, + ) +} diff --git a/tests/tools_pin_invoke_tracer.rs b/tests/tools_pin_invoke_tracer.rs index 75f7b19..8dc9dbf 100644 --- a/tests/tools_pin_invoke_tracer.rs +++ b/tests/tools_pin_invoke_tracer.rs @@ -284,6 +284,9 @@ fn pinned_read_file_bytes_stay_on_program_a_after_activation_of_b() { ); let lock_a = store .resolve(&ResolveRequest { + harness_selection: rustscript_agent::plugins::HarnessSelectionRequest::ToolOnly { + tool_package: "org.rustscript.tools.reada".to_string(), + }, roots: vec![PackageRequirement { id: "org.rustscript.tools.reada".to_string(), version: "1.0.0".to_string(), @@ -293,6 +296,9 @@ fn pinned_read_file_bytes_stay_on_program_a_after_activation_of_b() { .unwrap(); let lock_b = store .resolve(&ResolveRequest { + harness_selection: rustscript_agent::plugins::HarnessSelectionRequest::ToolOnly { + tool_package: "org.rustscript.tools.readb".to_string(), + }, roots: vec![PackageRequirement { id: "org.rustscript.tools.readb".to_string(), version: "1.0.0".to_string(), @@ -376,6 +382,7 @@ fn pinned_read_file_bytes_stay_on_program_a_after_activation_of_b() { &store, &caller, &rustscript_agent::plugins::PinnedAdmitContext { + original_cancellation: None, registry_identity: "pin-tracer-registry".to_string(), workspace: workspace.clone(), }, @@ -565,6 +572,9 @@ fn pinned_handle_rejects_other_owner_without_started_effect() { ); let lock_a = store .resolve(&ResolveRequest { + harness_selection: rustscript_agent::plugins::HarnessSelectionRequest::ToolOnly { + tool_package: "org.rustscript.tools.reada".to_string(), + }, roots: vec![PackageRequirement { id: "org.rustscript.tools.reada".to_string(), version: "1.0.0".to_string(), @@ -597,6 +607,7 @@ fn pinned_handle_rejects_other_owner_without_started_effect() { &store, &caller_json("run-pin-a", "", ""), &rustscript_agent::plugins::PinnedAdmitContext { + original_cancellation: None, registry_identity: "pin-tracer-registry".to_string(), workspace: workspace.clone(), }, @@ -778,6 +789,9 @@ fn pinned_replay_keeps_program_a_after_b_activation() { ); let lock_a = store .resolve(&ResolveRequest { + harness_selection: rustscript_agent::plugins::HarnessSelectionRequest::ToolOnly { + tool_package: "org.rustscript.tools.reada".to_string(), + }, roots: vec![PackageRequirement { id: "org.rustscript.tools.reada".to_string(), version: "1.0.0".to_string(), @@ -787,6 +801,9 @@ fn pinned_replay_keeps_program_a_after_b_activation() { .unwrap(); let lock_b = store .resolve(&ResolveRequest { + harness_selection: rustscript_agent::plugins::HarnessSelectionRequest::ToolOnly { + tool_package: "org.rustscript.tools.readb".to_string(), + }, roots: vec![PackageRequirement { id: "org.rustscript.tools.readb".to_string(), version: "1.0.0".to_string(), @@ -803,6 +820,7 @@ fn pinned_replay_keeps_program_a_after_b_activation() { let workspace = root.join("workspace"); fs::create_dir(&workspace).unwrap(); let context = rustscript_agent::plugins::PinnedAdmitContext { + original_cancellation: None, registry_identity: "pin-tracer-registry".to_string(), workspace: workspace.clone(), }; @@ -871,6 +889,9 @@ effects: store.install(&dir, &auth(&signer)).unwrap(); let lock = store .resolve(&ResolveRequest { + harness_selection: rustscript_agent::plugins::HarnessSelectionRequest::ToolOnly { + tool_package: "org.rustscript.tools.lib".to_string(), + }, roots: vec![PackageRequirement { id: "org.rustscript.tools.lib".to_string(), version: "1.0.0".to_string(), @@ -891,6 +912,7 @@ effects: &store, &caller_json("run-lib", "", ""), &rustscript_agent::plugins::PinnedAdmitContext { + original_cancellation: None, registry_identity: "pin-tracer-registry".to_string(), workspace, }, diff --git a/tests/tools_pin_production_ab.rs b/tests/tools_pin_production_ab.rs index 698d55d..a5c1eff 100644 --- a/tests/tools_pin_production_ab.rs +++ b/tests/tools_pin_production_ab.rs @@ -1,9 +1,10 @@ //! Production admission must pin the active signed graph, not the embedded default. //! //! The same caller arguments produce different file bytes when graph A or B is -//! active. A request field naming the other graph is ignored. An already -//! admitted run keeps A's program after B is activated and after the service -//! is reopened against a fresh package store. +//! active. A typed `graph_id` naming an inactive graph is rejected. Fields +//! inside the input message do not grant a graph. An already admitted run +//! keeps A's program after B is activated and after the service is reopened +//! against a fresh package store. use std::fs; use std::path::{Path, PathBuf}; @@ -190,6 +191,9 @@ fn install_tool(store: &PackageStore, signer: &Signer, dir: &Path, id: &str, off fn resolve_tool(store: &PackageStore, id: &str) -> GraphLock { store .resolve(&ResolveRequest { + harness_selection: rustscript_agent::plugins::HarnessSelectionRequest::ToolOnly { + tool_package: id.to_string(), + }, roots: vec![PackageRequirement { id: id.to_string(), version: "1.0.0".to_string(), @@ -337,6 +341,8 @@ async fn production_signed_a_survives_activation_of_b_and_reopen() { }), idempotency_key: Some("signed-a-key".to_string()), idempotency_hash: Some("fnv64:0000000000000001".to_string()), + graph_id: None, + workspace_branch: None, platform: "tools_pin_production_ab".to_string(), ..AdmitRunRequest::default() }, @@ -418,6 +424,8 @@ async fn production_signed_a_survives_activation_of_b_and_reopen() { input: json!({ "message":"read note", "graph_id":lock_b.graph_id, "tool_graph_id":lock_b.graph_id }), idempotency_key: Some("signed-a-key".to_string()), idempotency_hash: Some("fnv64:0000000000000001".to_string()), + graph_id: None, + workspace_branch: None, platform: "tools_pin_production_ab".to_string(), ..Default::default() }; @@ -513,7 +521,8 @@ async fn production_signed_a_survives_activation_of_b_and_reopen() { let mut displayed = rustscript_agent::plugins::admit_pinned_tool_run( persistence.as_ref(), &fresh, &json!({"idempotency_scope":"api:chat", "idempotency_key":"signed-a-key", "request_hash":"fnv64:0000000000000001", "workspace_identity_json":json!({"request_selector_present":true,"request_selector":"name:lifecycle"}).to_string()}), - &rustscript_agent::plugins::PinnedAdmitContext { registry_identity:"ignored-replay-context".to_string(), workspace:workspace.clone() }, + &rustscript_agent::plugins::PinnedAdmitContext { + original_cancellation:None, registry_identity:"ignored-replay-context".to_string(), workspace:workspace.clone() }, ).unwrap(); displayed.graph_id = lock_b.graph_id.clone(); displayed.generation = next.generation; @@ -874,7 +883,7 @@ async fn session_delete_releases_native_run_refs_without_collecting_other_owners } #[tokio::test(flavor = "multi_thread")] -async fn http_signed_graphs_ignore_request_graph_and_differ() { +async fn http_explicit_inactive_graph_is_rejected_and_selected_graph_emits_native_bytes() { let root = temp_root("http"); let signer = signer(); let store = Arc::new(PackageStore::open(root.join("packages")).unwrap()); @@ -933,23 +942,108 @@ async fn http_signed_graphs_ignore_request_graph_and_differ() { .expect("session") .to_string(); - let (status, run_a) = json_request( + let handles_before = state.service().handle_count(); + let retained_a = control.retained(&lock_a.graph_id).unwrap(); + let retained_b = control.retained(&lock_b.graph_id).unwrap(); + let (_, messages_before) = json_request( + &app, + "GET", + &format!("/api/sessions/{session_id}/messages"), + Value::Null, + ) + .await; + let (status, denied_b) = json_request( &app, "POST", "/v1/runs", json!({ "session_id": session_id, - "input": {"message": "read note", "graph_id": lock_b.graph_id}, + "input": { + "message": "read note", + "graph_id": lock_a.graph_id, + "tool_graph_id": lock_a.graph_id + }, "workspace": "name:lifecycle", "graph_id": lock_b.graph_id, + "tool_graph_id": lock_a.graph_id + }), + ) + .await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{denied_b}"); + assert_ne!(status, StatusCode::ACCEPTED); + assert_eq!(denied_b["error"]["code"], "plugin_graph_pin_required"); + assert!(denied_b.get("run_id").is_none(), "{denied_b}"); + for key in ["effect", "handle", "graph_ref", "graph_id", "ref_id"] { + assert!( + denied_b.get(key).is_none(), + "{key} partial write: {denied_b}" + ); + } + assert_eq!( + state.service().handle_count(), + handles_before, + "inactive explicit graph must not publish a handle" + ); + assert_eq!( + control.retained(&lock_a.graph_id).unwrap(), + retained_a, + "inactive explicit graph must not write a graph ref" + ); + assert_eq!(control.retained(&lock_b.graph_id).unwrap(), retained_b); + let (_, messages_denied) = json_request( + &app, + "GET", + &format!("/api/sessions/{session_id}/messages"), + Value::Null, + ) + .await; + assert_eq!( + messages_denied, messages_before, + "inactive explicit graph must not write a run or effect" + ); + assert_eq!(control.activation().unwrap().graph_id, lock_a.graph_id); + + let (status, run_a) = json_request( + &app, + "POST", + "/v1/runs", + json!({ + "session_id": session_id, + "input": { + "message": "read note", + "graph_id": lock_b.graph_id, + "tool_graph_id": lock_b.graph_id + }, + "workspace": "name:lifecycle", + "graph_id": lock_a.graph_id, "tool_graph_id": lock_b.graph_id }), ) .await; assert_eq!(status, StatusCode::ACCEPTED, "{run_a}"); + assert_ne!(status, StatusCode::UNPROCESSABLE_ENTITY); let run_a_id = run_a["run_id"].as_str().expect("run a").to_string(); + let active_a = control.activation().unwrap(); + let pin_a = control.pin_for_run(&run_a_id).unwrap(); + assert_eq!(pin_a["pinned"], true); + assert_eq!(pin_a["graph_id"], lock_a.graph_id); + assert_eq!(pin_a["graph_id"], run_a["graph_id"]); + assert_eq!(pin_a["graph_id"], active_a.graph_id); + assert_eq!(pin_a["generation"], active_a.generation); + assert_eq!(pin_a["run_id"], run_a_id); + let registry_a = pin_a["registry_identity"].as_str().expect("registry"); + assert!(!registry_a.is_empty()); + assert_ne!(registry_a, lock_a.graph_id); + assert_ne!(registry_a, lock_b.graph_id); + let handle_a = state.service().handle(&run_a_id).expect("admitted handle"); + assert!( + handle_a.cancellation().deadline_instant().is_some(), + "admission keeps the original clock" + ); let events_a = sse_body(&app, &run_a_id).await; assert!(events_a.contains("tool.completed"), "tool must commit"); + assert!(events_a.contains("run.completed"), "{events_a}"); + assert!(!events_a.contains("run.cancelled"), "{events_a}"); let (_, messages_a) = json_request( &app, "GET", @@ -960,32 +1054,135 @@ async fn http_signed_graphs_ignore_request_graph_and_differ() { let body_a = messages_a.to_string(); assert!( body_a.contains("AB-marker-bytes"), - "HTTP run must emit signed A despite request graph B: {body_a}" + "HTTP run must emit signed A bytes: {body_a}" + ); + assert!( + control + .retained(&lock_a.graph_id) + .unwrap() + .iter() + .any(|reference| { + reference.owner_kind == "run" + && reference.owner_id == run_a_id + && !reference.ref_id.is_empty() + }), + "selected A must retain its run graph ref" + ); + assert!( + !control + .retained(&lock_b.graph_id) + .unwrap() + .iter() + .any(|reference| reference.owner_id == run_a_id), + "message graph_id must not retain B for run A" ); - let provider_b = ScriptedProvider::new(); - queue_read(&provider_b, "http-b"); - state.service().inject_provider_host(Arc::new(provider_b)); + assert_eq!(control.pin_for_run(&run_a_id).unwrap(), pin_a); + let handles_after_a = state.service().handle_count(); + let retained_a_after = control.retained(&lock_a.graph_id).unwrap(); + let retained_b_after = control.retained(&lock_b.graph_id).unwrap(); let active = control.activation().unwrap(); control .activate(&lock_b.graph_id, active.generation, &artifact_refs(&lock_b)) .unwrap(); + assert_eq!(control.pin_for_run(&run_a_id).unwrap(), pin_a); + let (status, denied_a) = json_request( + &app, + "POST", + "/v1/runs", + json!({ + "session_id": session_id, + "input": { + "message": "read note", + "graph_id": lock_b.graph_id, + "tool_graph_id": lock_b.graph_id + }, + "workspace": "name:lifecycle", + "graph_id": lock_a.graph_id, + "tool_graph_id": lock_b.graph_id + }), + ) + .await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{denied_a}"); + assert_ne!(status, StatusCode::ACCEPTED); + assert_eq!(denied_a["error"]["code"], "plugin_graph_pin_required"); + assert!(denied_a.get("run_id").is_none(), "{denied_a}"); + for key in ["effect", "handle", "graph_ref", "graph_id", "ref_id"] { + assert!( + denied_a.get(key).is_none(), + "{key} partial write: {denied_a}" + ); + } + assert_eq!( + state.service().handle_count(), + handles_after_a, + "inactive A must not publish a handle" + ); + assert_eq!( + control.retained(&lock_a.graph_id).unwrap(), + retained_a_after + ); + assert_eq!( + control.retained(&lock_b.graph_id).unwrap(), + retained_b_after + ); + assert_eq!(control.pin_for_run(&run_a_id).unwrap(), pin_a); + let (_, messages_after_denial) = json_request( + &app, + "GET", + &format!("/api/sessions/{session_id}/messages"), + Value::Null, + ) + .await; + assert_eq!( + messages_after_denial, messages_a, + "inactive A must not write a run or effect" + ); + + let provider_b = ScriptedProvider::new(); + queue_read(&provider_b, "http-b"); + state.service().inject_provider_host(Arc::new(provider_b)); let (status, run_b) = json_request( &app, "POST", "/v1/runs", json!({ "session_id": session_id, - "input": {"message": "read note", "graph_id": lock_a.graph_id}, + "input": { + "message": "read note", + "graph_id": lock_a.graph_id, + "tool_graph_id": lock_a.graph_id + }, "workspace": "name:lifecycle", - "graph_id": lock_a.graph_id + "graph_id": lock_b.graph_id, + "tool_graph_id": lock_a.graph_id }), ) .await; assert_eq!(status, StatusCode::ACCEPTED, "{run_b}"); + assert_ne!(status, StatusCode::UNPROCESSABLE_ENTITY); let run_b_id = run_b["run_id"].as_str().expect("run b").to_string(); + let active_b = control.activation().unwrap(); + let pin_b = control.pin_for_run(&run_b_id).unwrap(); + assert_eq!(pin_b["pinned"], true); + assert_eq!(pin_b["graph_id"], lock_b.graph_id); + assert_eq!(pin_b["graph_id"], run_b["graph_id"]); + assert_eq!(pin_b["graph_id"], active_b.graph_id); + assert_eq!(pin_b["generation"], active_b.generation); + assert_eq!(pin_b["run_id"], run_b_id); + let registry_b = pin_b["registry_identity"].as_str().expect("registry"); + assert!(!registry_b.is_empty()); + assert_ne!(registry_b, lock_a.graph_id); + assert_ne!(registry_b, lock_b.graph_id); + let handle_b = state.service().handle(&run_b_id).expect("admitted handle"); + assert!( + handle_b.cancellation().deadline_instant().is_some(), + "admission keeps the original clock" + ); let events_b = sse_body(&app, &run_b_id).await; assert!(events_b.contains("tool.completed"), "tool must commit"); + assert!(events_b.contains("run.completed"), "{events_b}"); + assert!(!events_b.contains("run.cancelled"), "{events_b}"); let (_, messages_b) = json_request( &app, "GET", @@ -1012,6 +1209,35 @@ async fn http_signed_graphs_ignore_request_graph_and_differ() { !body_b.contains("AB-marker-bytes"), "request graph A must not select A while B is active: {body_b}" ); + assert_eq!(control.pin_for_run(&run_a_id).unwrap(), pin_a); + assert_eq!( + control.load_lock(&lock_a.graph_id).unwrap().lock.graph_id, + lock_a.graph_id + ); + assert_eq!( + control.load_lock(&lock_b.graph_id).unwrap().lock.graph_id, + lock_b.graph_id + ); + assert!( + control + .retained(&lock_b.graph_id) + .unwrap() + .iter() + .any(|reference| { + reference.owner_kind == "run" + && reference.owner_id == run_b_id + && !reference.ref_id.is_empty() + }), + "selected B must retain its run graph ref" + ); + assert!( + !control + .retained(&lock_a.graph_id) + .unwrap() + .iter() + .any(|reference| reference.owner_id == run_b_id), + "message graph_id must not retain A for run B" + ); println!( "PRODUCTION_HTTP {}", json!({"run_a":run_a_id,"messages_a":messages_a,"run_b":run_b_id,"messages_b":messages_b}) @@ -1179,6 +1405,8 @@ async fn unkeyed_pin_receipt_does_not_enter_public_idempotency_namespace() { input: json!({"message":"second"}), idempotency_key: Some(original.run_id.clone()), idempotency_hash: Some("fnv64:0000000000000002".to_string()), + graph_id: None, + workspace_branch: None, ..Default::default() }, None, diff --git a/tests/tools_pinned_commit_outcome.rs b/tests/tools_pinned_commit_outcome.rs index 1e57da9..43cc48d 100644 --- a/tests/tools_pinned_commit_outcome.rs +++ b/tests/tools_pinned_commit_outcome.rs @@ -243,6 +243,9 @@ impl Fixture { store.install(&dir, &auth(&signer)).unwrap(); let lock = store .resolve(&ResolveRequest { + harness_selection: rustscript_agent::plugins::HarnessSelectionRequest::ToolOnly { + tool_package: "org.rustscript.tools.outcome".to_string(), + }, roots: vec![PackageRequirement { id: "org.rustscript.tools.outcome".to_string(), version: "1.0.0".to_string(), @@ -266,6 +269,7 @@ impl Fixture { &store, &caller, &rustscript_agent::plugins::PinnedAdmitContext { + original_cancellation: None, registry_identity: "outcome-registry".to_string(), workspace: workspace.clone(), }, From c926734111da01ea808e2b2ee4eb79da1e9bad49 Mon Sep 17 00:00:00 2001 From: fffonion Date: Mon, 2 Nov 2026 17:37:06 +0800 Subject: [PATCH 18/21] feat(plugins): add reviewed versioned state lifecycle Publish the exact accepted T12 snapshot with run, session and invocation state, pure migration, exact checkpoint roots, Native ownership and bounded worker-ordered collection. Scoped review and 18 final commands cover 213 unique target/test pairs. Full remote CI qualification remains pending. --- ...2026-10-17_plugin-graphs-effect-sandbox.md | 4 +- rss/storage/admission.rss | 24 + rss/storage/effects.rss | 4 + rss/storage/main.rss | 35 + rss/storage/plugins.rss | 504 +- rss/storage/schema.rss | 25 + .../canonical/managed-checkpoint.schema.json | 157 + schemas/canonical/managed-effect.schema.json | 86 + schemas/canonical/plugin-manifest.schema.json | 88 +- schemas/managed-checkpoint.schema.yaml | 154 + schemas/managed-effect.schema.yaml | 86 + schemas/plugin-manifest.schema.yaml | 37 +- src/effects/broker.rs | 9 +- src/effects/journal.rs | 80 + src/effects/managed_authority_tests.rs | 1 + .../managed_checkpoint_schema_tests.rs | 19 +- src/gateway/managed_storage_budget_tests.rs | 2 + src/gateway/store.rs | 864 ++- src/plugins/manifest.rs | 90 +- src/plugins/mod.rs | 17 +- src/plugins/pin.rs | 185 +- src/plugins/prepare.rs | 248 +- src/plugins/state.rs | 2110 ++++++ src/runtime/agent_host.rs | 535 +- src/runtime/host_types.rs | 23 + src/runtime/native_tools.rs | 1 + src/runtime/rss_runner.rs | 17 +- src/service.rs | 173 +- src/service/managed_checkpoints.rs | 160 +- src/service/managed_effects.rs | 19 +- src/service/managed_protocol_tests.rs | 5 +- src/service/plugin_admin.rs | 517 +- tests/plugin_state_migration_tests.rs | 6726 +++++++++++++++++ tests/support/plugin_admin_cycle.rs | 7 +- 34 files changed, 12864 insertions(+), 148 deletions(-) create mode 100644 src/plugins/state.rs create mode 100644 tests/plugin_state_migration_tests.rs diff --git a/plans/2026-10-17_plugin-graphs-effect-sandbox.md b/plans/2026-10-17_plugin-graphs-effect-sandbox.md index 4e83803..d385abf 100644 --- a/plans/2026-10-17_plugin-graphs-effect-sandbox.md +++ b/plans/2026-10-17_plugin-graphs-effect-sandbox.md @@ -555,7 +555,9 @@ T02 和 T09 已移出本计划。编号不回收,也不另开替代任务。 **T12 有版本的插件状态** -- 维护 `src/plugins/pin.rs`、`rss/storage/plugins.rss` 中的 plugin state root。测试使用 `tests/plugin_state_migration_tests.rs`;该文件尚不存在,是本任务的原定测试目标,不另起名称。 +- 维护 `src/plugins/state.rs`、`src/plugins/pin.rs`、`src/plugins/manifest.rs`、`rss/storage/plugins.rss`、`src/effects/journal.rs` 与 `src/service/managed_checkpoints.rs` 中的版本化 state root。测试使用 `tests/plugin_state_migration_tests.rs`;该文件是本任务的原定测试目标,不另起名称。 +- 状态对象是有界 canonical JSON,先持久化再以 root revision CAS 发布;run binding 固定该 run 可见的 root revision,session 切换 schema 前取得既有 session maintenance lease 并等待该 session 旧 run terminal。 +- 纯迁移是 manifest `state.migration.entry` 指向的同包 RSS entry,仅允许 `json`/`bytes` 与包内模块;生成器与 `canonical` schema 同步。`state::read`/`state::write` 复用 workspace effect 声明与 sealed Native pin,不新增 effect kind。 - RED:无 inverse 的破坏性迁移不能声称可 graph rollback。旧 run 不观察新 schema。迁移崩溃不切换 active ref。迁移入口若执行文件、网络或 SQL,则拒绝。 - GREEN:纯迁移、新状态对象、session 维护 lease 和 checkpoint 兼容检查。 - Gate:`cargo test --locked --test plugin_state_migration_tests`。 diff --git a/rss/storage/admission.rss b/rss/storage/admission.rss index 188496b..1ce664c 100644 --- a/rss/storage/admission.rss +++ b/rss/storage/admission.rss @@ -276,6 +276,30 @@ pub fn storage_admission_create(db_id: resource, payload_json query: false, limits: sql_limits(1000, 4194304) }; + if raw_payload.has("plugin_state_migration_json") { + let state_json: string = raw_payload["plugin_state_migration_json"].copy(); + let state_statements: array = plugins::storage_admission_state_statements(state_json); + let mut state_index: int = 0; + while state_index < state_statements.length { + statements[statements.length] = state_statements[state_index].copy(); + state_index = state_index + 1; + } + } + if raw_payload.has("plugin_state_guard_json") { + let guard_json: string = raw_payload["plugin_state_guard_json"].copy(); + let guard_statements: array = plugins::storage_admission_state_guard_statements(guard_json); + let mut guard_index: int = 0; + while guard_index < guard_statements.length { + statements[statements.length] = guard_statements[guard_index].copy(); + guard_index = guard_index + 1; + } + } + if raw_payload.has("plugin_state_failpoint") { + let state_failpoint: string = raw_payload["plugin_state_failpoint"].copy(); + if state_failpoint.copy() == "after_root_cas" { + statements[statements.length] = {sql:"INSERT INTO plugin_control_guard(id) VALUES(2)",params:[],query:false,limits:sql_limits(1,4096)}; + } + } if failpoint.copy() == "managed_statement_12" && restore_json.copy() != "" { statements[statements.length] = {sql:"INSERT INTO managed_effect_guard(id) SELECT 2 WHERE (? = '' OR (NOT EXISTS (SELECT 1 FROM plugin_graph_revocations WHERE graph_id = ?) AND NOT EXISTS (SELECT 1 FROM plugin_graph_pin_requirements q WHERE q.run_id = ? AND (q.graph_id <> ? OR q.ref_id <> ?)))) ",params:[sql_text(input.tool_graph_id.copy()), sql_text(input.tool_graph_id.copy()), sql_text(input.run_id.copy()), sql_text(input.tool_graph_id.copy()), sql_text(input.tool_ref_id.copy())],query:false,limits:sql_limits(1,4096)}; } diff --git a/rss/storage/effects.rss b/rss/storage/effects.rss index 284eb44..49a16dd 100644 --- a/rss/storage/effects.rss +++ b/rss/storage/effects.rss @@ -179,6 +179,10 @@ fn commit_new(db: resource, payload: string) -> map { { sql: "INSERT INTO managed_effect_guard(id) SELECT 2 WHERE ? = 'CommitAfterDecision'", params: [text(input.cut.copy())], query: false, limits: limits() }, { sql: "UPDATE managed_branches SET head_object_id=json_extract(?,'$.candidate_root.object_id'),head_version=head_version+1 WHERE workspace_id=? AND branch_id=? AND epoch=json_extract(?,'$.identity.epoch') AND head_object_id=json_extract(?,'$.identity.expected_head') AND head_version=json_extract(?,'$.identity.expected_head_version') AND EXISTS(SELECT 1 FROM managed_effect_calls WHERE effect_id=? AND state='committed' AND commit_id=?)", params: [text(input.prepared_json.copy()), text(input.workspace_id.copy()), text(input.branch_id.copy()), text(input.prepared_json.copy()), text(input.prepared_json.copy()), text(input.prepared_json.copy()), text(input.effect_id.copy()), text(input.commit_id.copy())], query: false, limits: limits() }, { sql: "INSERT INTO managed_effect_guard(id) SELECT 2 WHERE (SELECT state FROM managed_effect_calls WHERE effect_id=?)='committed' AND changes()<>1", params: [text(input.effect_id.copy())], query: false, limits: limits() }, +{ sql: "UPDATE plugin_state_run_bindings SET package_id=json_extract(?,'$.plugin_state.package_id'),artifact_id=json_extract(?,'$.plugin_state.artifact_id'),schema_version=json_extract(?,'$.plugin_state.schema_version'),revision=json_extract(?,'$.plugin_state.expected_revision')+1,object_id=json_extract(?,'$.plugin_state.object_id') WHERE run_id=json_extract(?,'$.identity.owner.run_id') AND revision=json_extract(?,'$.plugin_state.expected_revision') AND object_id=json_extract(?,'$.plugin_state.expected_object_id') AND owner_kind=json_extract(?,'$.plugin_state.owner_kind') AND owner_id=json_extract(?,'$.plugin_state.owner_id') AND workspace_id=json_extract(?,'$.plugin_state.workspace_id') AND branch_id=json_extract(?,'$.plugin_state.branch_id') AND plugin_id=json_extract(?,'$.plugin_state.plugin_id') AND EXISTS(SELECT 1 FROM plugin_state_roots WHERE owner_kind=json_extract(?,'$.plugin_state.owner_kind') AND owner_id=json_extract(?,'$.plugin_state.owner_id') AND workspace_id=json_extract(?,'$.plugin_state.workspace_id') AND branch_id=json_extract(?,'$.plugin_state.branch_id') AND plugin_id=json_extract(?,'$.plugin_state.plugin_id') AND revision=json_extract(?,'$.plugin_state.expected_revision') AND object_id=json_extract(?,'$.plugin_state.expected_object_id')) AND EXISTS(SELECT 1 FROM plugin_state_objects WHERE object_id=json_extract(?,'$.plugin_state.object_id') AND package_id=json_extract(?,'$.plugin_state.package_id') AND artifact_id=json_extract(?,'$.plugin_state.artifact_id') AND schema_version=json_extract(?,'$.plugin_state.schema_version'))", params: [text(input.prepared_json.copy()), text(input.prepared_json.copy()), text(input.prepared_json.copy()), text(input.prepared_json.copy()), text(input.prepared_json.copy()), text(input.prepared_json.copy()), text(input.prepared_json.copy()), text(input.prepared_json.copy()), text(input.prepared_json.copy()), text(input.prepared_json.copy()), text(input.prepared_json.copy()), text(input.prepared_json.copy()), text(input.prepared_json.copy()), text(input.prepared_json.copy()), text(input.prepared_json.copy()), text(input.prepared_json.copy()), text(input.prepared_json.copy()), text(input.prepared_json.copy()), text(input.prepared_json.copy()), text(input.prepared_json.copy()), text(input.prepared_json.copy()), text(input.prepared_json.copy()), text(input.prepared_json.copy()), text(input.prepared_json.copy())], query: false, limits: limits() }, +{ sql: "INSERT INTO managed_effect_guard(id) SELECT 2 WHERE json_extract(?,'$.plugin_state') IS NOT NULL AND json_extract(?,'$.plugin_state.ephemeral')<>1 AND changes()<>1", params: [text(input.prepared_json.copy()), text(input.prepared_json.copy())], query: false, limits: limits() }, +{ sql: "UPDATE plugin_state_roots SET schema_version=json_extract(?,'$.plugin_state.schema_version'),revision=json_extract(?,'$.plugin_state.expected_revision')+1,object_id=json_extract(?,'$.plugin_state.object_id'),updated_at_ms=? WHERE owner_kind=json_extract(?,'$.plugin_state.owner_kind') AND owner_id=json_extract(?,'$.plugin_state.owner_id') AND workspace_id=json_extract(?,'$.plugin_state.workspace_id') AND branch_id=json_extract(?,'$.plugin_state.branch_id') AND plugin_id=json_extract(?,'$.plugin_state.plugin_id') AND revision=json_extract(?,'$.plugin_state.expected_revision') AND object_id=json_extract(?,'$.plugin_state.expected_object_id') AND EXISTS(SELECT 1 FROM plugin_state_run_bindings WHERE run_id=json_extract(?,'$.identity.owner.run_id') AND revision=json_extract(?,'$.plugin_state.expected_revision')+1 AND object_id=json_extract(?,'$.plugin_state.object_id'))", params: [text(input.prepared_json.copy()), text(input.prepared_json.copy()), text(input.prepared_json.copy()), integer(input.now_ms.copy()), text(input.prepared_json.copy()), text(input.prepared_json.copy()), text(input.prepared_json.copy()), text(input.prepared_json.copy()), text(input.prepared_json.copy()), text(input.prepared_json.copy()), text(input.prepared_json.copy()), text(input.prepared_json.copy()), text(input.prepared_json.copy()), text(input.prepared_json.copy())], query: false, limits: limits() }, +{ sql: "INSERT INTO managed_effect_guard(id) SELECT 2 WHERE json_extract(?,'$.plugin_state') IS NOT NULL AND json_extract(?,'$.plugin_state.ephemeral')<>1 AND changes()<>1", params: [text(input.prepared_json.copy()), text(input.prepared_json.copy())], query: false, limits: limits() }, { sql: "INSERT INTO managed_head_history(workspace_id,branch_id,head_version,epoch,effect_id,previous_head,head_object_id,schema_version) SELECT b.workspace_id,b.branch_id,b.head_version,b.epoch,c.effect_id,c.observed_head,b.head_object_id,1 FROM managed_effect_calls c JOIN managed_branches b ON b.workspace_id=c.workspace_id AND b.branch_id=c.branch_id WHERE c.effect_id=? AND c.state='committed' AND c.commit_id=?", params: [text(input.effect_id.copy()), text(input.commit_id.copy())], query: false, limits: limits() }, { sql: "INSERT INTO managed_effect_guard(id) SELECT 2 WHERE ? = 'CommitAfterHead'", params: [text(input.cut.copy())], query: false, limits: limits() }, { sql: "UPDATE managed_effect_calls SET record_json=json_set(record_json,'$.state',state,'$.outcome',json_extract(?,CASE WHEN state='conflict' THEN '$.conflict.outcome' ELSE '$.normal.outcome' END),'$.outcome.disposition',state,'$.outcome.publication.status',state,'$.outcome.publication.observed_head',observed_head,'$.outcome.publication.observed_head_version',observed_version) WHERE effect_id=? AND commit_id=?", params: [text(input.projections_json.copy()), text(input.effect_id.copy()), text(input.commit_id.copy())], query: false, limits: limits() }, diff --git a/rss/storage/main.rss b/rss/storage/main.rss index 464bd75..0a61fdd 100644 --- a/rss/storage/main.rss +++ b/rss/storage/main.rss @@ -180,6 +180,11 @@ fn storage_apply_migrations(db_id: resource, now_ms: int) { } migration_index += 1; } + let mut state_table_index = 0; + while state_table_index < schema::plugin_state_table_count() { + sqlite::execute(&db_id, schema::plugin_state_table_statement(state_table_index), []); + state_table_index += 1; + } true } @@ -749,6 +754,36 @@ fn storage_dispatch(db_id: resource, command: StorageCommand) result = storage_unwrap(request_id, op, plugins::storage_plugin_graph_revocation_get(db_id, command.payload_json)); } else if command.op == "plugin.collect" { result = storage_unwrap(request_id, op, plugins::storage_plugin_collect(db_id, command.payload_json)); + } else if command.op == "plugin.state_object_put" { + result = storage_unwrap(request_id, op, plugins::storage_plugin_state_object_put(db_id, command.payload_json)); + } else if command.op == "plugin.state_root_put" { + result = storage_unwrap(request_id, op, plugins::storage_plugin_state_root_put(db_id, command.payload_json)); + } else if command.op == "plugin.state_root_get" { + result = storage_unwrap(request_id, op, plugins::storage_plugin_state_root_get(db_id, command.payload_json)); + } else if command.op == "plugin.state_bind_run" { + result = storage_unwrap(request_id, op, plugins::storage_plugin_state_bind_run(db_id, command.payload_json)); + } else if command.op == "plugin.state_binding_get" { + result = storage_unwrap(request_id, op, plugins::storage_plugin_state_binding_get(db_id, command.payload_json)); + } else if command.op == "plugin.state_binding_advance" { + result = storage_unwrap(request_id, op, plugins::storage_plugin_state_binding_advance(db_id, command.payload_json)); + } else if command.op == "plugin.state_roots_for_checkpoint" { + result = storage_unwrap(request_id, op, plugins::storage_plugin_state_roots_for_checkpoint(db_id, command.payload_json)); + } else if command.op == "plugin.state_session_nonterminal" { + result = storage_unwrap(request_id, op, plugins::storage_plugin_state_session_nonterminal(db_id, command.payload_json)); + } else if command.op == "plugin.state_session_max_schema" { + result = storage_unwrap(request_id, op, plugins::storage_plugin_state_session_max_schema(db_id, command.payload_json)); + } else if command.op == "plugin.state_commit" { + result = storage_unwrap(request_id, op, plugins::storage_plugin_state_commit(db_id, command.payload_json)); + } else if command.op == "plugin.state_authorize" { + result = storage_unwrap(request_id, op, plugins::storage_plugin_state_authorize(db_id, command.payload_json)); + } else if command.op == "plugin.state_session_key" { + result = storage_unwrap(request_id, op, plugins::storage_plugin_state_session_key(db_id, command.payload_json)); + } else if command.op == "plugin.state_session_key_for" { + result = storage_unwrap(request_id, op, plugins::storage_plugin_state_session_key_for(db_id, command.payload_json)); + } else if command.op == "plugin.state_object_get" { + result = storage_unwrap(request_id, op, plugins::storage_plugin_state_object_get(db_id, command.payload_json)); + } else if command.op == "plugin.state_collect" { + result = storage_unwrap(request_id, op, plugins::storage_plugin_state_collect(db_id, command.payload_json)); } result } diff --git a/rss/storage/plugins.rss b/rss/storage/plugins.rss index 78acb4f..2d18e99 100644 --- a/rss/storage/plugins.rss +++ b/rss/storage/plugins.rss @@ -17,7 +17,7 @@ fn plugin_limits() -> SqliteLimits { max_connections: 16, max_statements: 128, max_rows: 512, - max_columns: 16, + max_columns: 24, max_result_bytes: 1048576, max_statement_bytes: 1048576, max_parameters: 128, @@ -555,3 +555,505 @@ pub fn storage_pin_by_owner(db_id: resource, owner_id: string pinned: pinned } } + +// T12 versioned plugin state. Objects are immutable and bounded; roots advance +// only by an exact revision CAS in the same typed transaction. A binding is the +// run's pinned view (run-owned root, or a session root revision). +struct PluginStateObjectInput { + object_id: string, + package_id: string, + artifact_id: string, + schema_version: int, + document_json: string, + document_bytes: int, + now_ms: int, +} + +struct PluginStateRootInput { + owner_kind: string, + owner_id: string, + workspace_id: string, + branch_id: string, + plugin_id: string, + package_id: string, + artifact_id: string, + schema_version: int, + expected_revision: int, + object_id: string, + now_ms: int, +} + +struct PluginStateRootQuery { + owner_kind: string, + owner_id: string, + workspace_id: string, + branch_id: string, + plugin_id: string, +} + +struct PluginStateBindingInput { + run_id: string, + session_id: string, + workspace_id: string, + branch_id: string, + plugin_id: string, + package_id: string, + artifact_id: string, + owner_kind: string, + owner_id: string, + graph_id: string, + generation: int, + ref_id: string, + epoch: int, + schema_version: int, + revision: int, + object_id: string, + checkpoint_id: string, + now_ms: int, +} + +struct PluginStateAdvanceInput { + run_id: string, + expected_revision: int, + schema_version: int, + revision: int, + object_id: string, + now_ms: int, +} + +struct PluginStateSessionInput { + session_id: string, +} + +struct PluginStateRunInput { + run_id: string, +} + +struct PluginStatePluginInput { + plugin_id: string, +} + +struct PluginStateCommitInput { + run_id: string, + owner_kind: string, + owner_id: string, + workspace_id: string, + branch_id: string, + plugin_id: string, + package_id: string, + artifact_id: string, + expected_revision: int, + expected_object_id: string, + schema_version: int, + object_id: string, + now_ms: int, +} + +struct PluginStateAuthorizeInput { + run_id: string, + session_id: string, + profile: string, + plugin_id: string, + package_id: string, + artifact_id: string, + owner_kind: string, + owner_id: string, + graph_id: string, + workspace_id: string, + branch_id: string, + epoch: int, + now_ms: int, +} + +fn plugin_state_object_matches(entry: SqliteTransactionResult, input: PluginStateObjectInput) -> bool { + let object_id: string = plugin_query_text(entry.copy(), 0); + let package_id: string = plugin_query_text(entry.copy(), 1); + let artifact_id: string = plugin_query_text(entry.copy(), 2); + let schema_version: int = plugin_query_int(entry.copy(), 3); + let document_bytes: int = plugin_query_int(entry.copy(), 4); + object_id.copy() == input.object_id.copy() + && package_id.copy() == input.package_id.copy() + && artifact_id.copy() == input.artifact_id.copy() + && schema_version == input.schema_version.copy() + && document_bytes == input.document_bytes.copy() +} + +pub fn storage_plugin_state_object_put(db_id: resource, payload_json: string) -> map { + let input: PluginStateObjectInput = json::decode::(payload_json); + let statements: array = [ + { + sql: "INSERT INTO plugin_state_objects(object_id,package_id,artifact_id,schema_version,document_json,document_bytes,created_at_ms) SELECT ?,?,?,?,?,?,? WHERE NOT EXISTS(SELECT 1 FROM plugin_state_objects WHERE object_id=?)", + params: [sql_text(input.object_id.copy()), sql_text(input.package_id.copy()), sql_text(input.artifact_id.copy()), sql_int(input.schema_version.copy()), sql_text(input.document_json.copy()), sql_int(input.document_bytes.copy()), sql_int(input.now_ms.copy()), sql_text(input.object_id.copy())], + query: false, + limits: plugin_limits(), + }, + { + sql: "SELECT object_id,package_id,artifact_id,schema_version,document_bytes FROM plugin_state_objects WHERE object_id=?", + params: [sql_text(input.object_id.copy())], + query: true, + limits: plugin_limits(), + } + ]; + let result: array = sqlite::transaction(&db_id, statements); + let mut response: map = plugin_control_error("plugin_state_unavailable", "plugin state storage is unavailable"); + if result.length >= 2 { + let inserted: int = plugin_tx_rows(result[0].copy()); + let existing: SqliteTransactionResult = result[1].copy(); + if inserted == 1 { + response = ok_value({ + object_id: input.object_id.copy(), + schema_version: input.schema_version.copy(), + document_bytes: input.document_bytes.copy(), + idempotent: false + }); + } else if plugin_state_object_matches(existing.copy(), input.copy()) { + response = ok_value({ + object_id: input.object_id.copy(), + schema_version: input.schema_version.copy(), + document_bytes: input.document_bytes.copy(), + idempotent: true + }); + } else { + response = plugin_control_error("plugin_state_object_conflict", "plugin state object identity is immutable"); + } + } + response +} + +fn plugin_state_root_matches(entry: SqliteTransactionResult, input: PluginStateRootInput) -> bool { + let owner_kind: string = plugin_query_text(entry.copy(), 0); + let owner_id: string = plugin_query_text(entry.copy(), 1); + let workspace_id: string = plugin_query_text(entry.copy(), 2); + let branch_id: string = plugin_query_text(entry.copy(), 3); + let plugin_id: string = plugin_query_text(entry.copy(), 4); + let package_id: string = plugin_query_text(entry.copy(), 5); + let artifact_id: string = plugin_query_text(entry.copy(), 6); + let schema_version: int = plugin_query_int(entry.copy(), 7); + let revision: int = plugin_query_int(entry.copy(), 8); + let object_id: string = plugin_query_text(entry.copy(), 9); + owner_kind.copy() == input.owner_kind.copy() + && owner_id.copy() == input.owner_id.copy() + && workspace_id.copy() == input.workspace_id.copy() + && branch_id.copy() == input.branch_id.copy() + && plugin_id.copy() == input.plugin_id.copy() + && package_id.copy() == input.package_id.copy() + && artifact_id.copy() == input.artifact_id.copy() + && schema_version == input.schema_version.copy() + && revision == input.expected_revision.copy() + 1 + && object_id.copy() == input.object_id.copy() +} + +pub fn storage_plugin_state_root_put(db_id: resource, payload_json: string) -> map { + let input: PluginStateRootInput = json::decode::(payload_json); + let statements: array = [ + { + sql: "INSERT INTO plugin_state_roots(owner_kind,owner_id,workspace_id,branch_id,plugin_id,package_id,artifact_id,schema_version,revision,object_id,updated_at_ms) SELECT ?,?,?,?,?,?,?,?,1,?,? WHERE ?=0 AND NOT EXISTS(SELECT 1 FROM plugin_state_roots WHERE owner_kind=? AND owner_id=? AND workspace_id=? AND branch_id=? AND plugin_id=?) AND EXISTS(SELECT 1 FROM plugin_state_objects WHERE object_id=? AND package_id=? AND artifact_id=? AND schema_version=?)", + params: [sql_text(input.owner_kind.copy()), sql_text(input.owner_id.copy()), sql_text(input.workspace_id.copy()), sql_text(input.branch_id.copy()), sql_text(input.plugin_id.copy()), sql_text(input.package_id.copy()), sql_text(input.artifact_id.copy()), sql_int(input.schema_version.copy()), sql_text(input.object_id.copy()), sql_int(input.now_ms.copy()), sql_int(input.expected_revision.copy()), sql_text(input.owner_kind.copy()), sql_text(input.owner_id.copy()), sql_text(input.workspace_id.copy()), sql_text(input.branch_id.copy()), sql_text(input.plugin_id.copy()), sql_text(input.object_id.copy()), sql_text(input.package_id.copy()), sql_text(input.artifact_id.copy()), sql_int(input.schema_version.copy())], + query: false, + limits: plugin_limits(), + }, + { + sql: "UPDATE plugin_state_roots SET package_id=?,artifact_id=?,schema_version=?,revision=revision+1,object_id=?,updated_at_ms=? WHERE owner_kind=? AND owner_id=? AND workspace_id=? AND branch_id=? AND plugin_id=? AND revision=? AND EXISTS(SELECT 1 FROM plugin_state_objects WHERE object_id=? AND package_id=? AND artifact_id=? AND schema_version=?)", + params: [sql_text(input.package_id.copy()), sql_text(input.artifact_id.copy()), sql_int(input.schema_version.copy()), sql_text(input.object_id.copy()), sql_int(input.now_ms.copy()), sql_text(input.owner_kind.copy()), sql_text(input.owner_id.copy()), sql_text(input.workspace_id.copy()), sql_text(input.branch_id.copy()), sql_text(input.plugin_id.copy()), sql_int(input.expected_revision.copy()), sql_text(input.object_id.copy()), sql_text(input.package_id.copy()), sql_text(input.artifact_id.copy()), sql_int(input.schema_version.copy())], + query: false, + limits: plugin_limits(), + }, + { + sql: "SELECT owner_kind,owner_id,workspace_id,branch_id,plugin_id,package_id,artifact_id,schema_version,revision,object_id FROM plugin_state_roots WHERE owner_kind=? AND owner_id=? AND workspace_id=? AND branch_id=? AND plugin_id=?", + params: [sql_text(input.owner_kind.copy()), sql_text(input.owner_id.copy()), sql_text(input.workspace_id.copy()), sql_text(input.branch_id.copy()), sql_text(input.plugin_id.copy())], + query: true, + limits: plugin_limits(), + } + ]; + let result: array = sqlite::transaction(&db_id, statements); + let mut response: map = plugin_control_error("plugin_state_unavailable", "plugin state storage is unavailable"); + if result.length >= 3 { + let created: int = plugin_tx_rows(result[0].copy()); + let updated: int = plugin_tx_rows(result[1].copy()); + let root: SqliteTransactionResult = result[2].copy(); + if plugin_state_root_matches(root.copy(), input.copy()) && (created == 1 || updated == 1) { + response = ok_value({ + owner_kind: input.owner_kind.copy(), + owner_id: input.owner_id.copy(), + schema_version: input.schema_version.copy(), + revision: input.expected_revision.copy() + 1, + object_id: input.object_id.copy() + }); + } else { + let live_revision: int = plugin_query_int(root.copy(), 8); + if live_revision < 0 { + response = plugin_control_error("plugin_state_root_missing", "plugin state root is not stored"); + } else { + response = plugin_control_error("plugin_state_conflict", "plugin state root revision does not match"); + } + } + } + response +} + +pub fn storage_plugin_state_root_get(db_id: resource, payload_json: string) -> map { + let input: PluginStateRootQuery = json::decode::(payload_json); + ok_query(sqlite::query(&db_id, "SELECT owner_kind,owner_id,workspace_id,branch_id,plugin_id,package_id,artifact_id,schema_version,revision,object_id FROM plugin_state_roots WHERE owner_kind=? AND owner_id=? AND workspace_id=? AND branch_id=? AND plugin_id=?", [sql_text(input.owner_kind.copy()), sql_text(input.owner_id.copy()), sql_text(input.workspace_id.copy()), sql_text(input.branch_id.copy()), sql_text(input.plugin_id.copy())], plugin_limits())) +} + +fn plugin_state_binding_matches(entry: SqliteTransactionResult, input: PluginStateBindingInput) -> bool { + let run_id: string = plugin_query_text(entry.copy(), 0); + let session_id: string = plugin_query_text(entry.copy(), 1); + let workspace_id: string = plugin_query_text(entry.copy(), 2); + let branch_id: string = plugin_query_text(entry.copy(), 3); + let plugin_id: string = plugin_query_text(entry.copy(), 4); + let package_id: string = plugin_query_text(entry.copy(), 5); + let artifact_id: string = plugin_query_text(entry.copy(), 6); + let owner_kind: string = plugin_query_text(entry.copy(), 7); + let owner_id: string = plugin_query_text(entry.copy(), 8); + let schema_version: int = plugin_query_int(entry.copy(), 9); + let revision: int = plugin_query_int(entry.copy(), 10); + let object_id: string = plugin_query_text(entry.copy(), 11); + run_id.copy() == input.run_id.copy() + && session_id.copy() == input.session_id.copy() + && workspace_id.copy() == input.workspace_id.copy() + && branch_id.copy() == input.branch_id.copy() + && plugin_id.copy() == input.plugin_id.copy() + && package_id.copy() == input.package_id.copy() + && artifact_id.copy() == input.artifact_id.copy() + && owner_kind.copy() == input.owner_kind.copy() + && owner_id.copy() == input.owner_id.copy() + && schema_version == input.schema_version.copy() + && revision == input.revision.copy() + && object_id.copy() == input.object_id.copy() +} + +pub fn storage_plugin_state_bind_run(db_id: resource, payload_json: string) -> map { + let input: PluginStateBindingInput = json::decode::(payload_json); + let statements: array = [ + { + sql: "INSERT INTO plugin_state_run_bindings(run_id,session_id,workspace_id,branch_id,plugin_id,package_id,artifact_id,owner_kind,owner_id,graph_id,epoch,schema_version,revision,object_id,checkpoint_id,created_at_ms) SELECT ?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,? WHERE EXISTS(SELECT 1 FROM runs WHERE id=? AND session_id=?) AND EXISTS(SELECT 1 FROM plugin_state_objects WHERE object_id=? AND schema_version=?) AND NOT EXISTS(SELECT 1 FROM plugin_state_run_bindings WHERE run_id=?)", + params: [sql_text(input.run_id.copy()), sql_text(input.session_id.copy()), sql_text(input.workspace_id.copy()), sql_text(input.branch_id.copy()), sql_text(input.plugin_id.copy()), sql_text(input.package_id.copy()), sql_text(input.artifact_id.copy()), sql_text(input.owner_kind.copy()), sql_text(input.owner_id.copy()), sql_text(input.graph_id.copy()), sql_int(input.epoch.copy()), sql_int(input.schema_version.copy()), sql_int(input.revision.copy()), sql_text(input.object_id.copy()), sql_text(input.checkpoint_id.copy()), sql_int(input.now_ms.copy()), sql_text(input.run_id.copy()), sql_text(input.session_id.copy()), sql_text(input.object_id.copy()), sql_int(input.schema_version.copy()), sql_text(input.run_id.copy())], + query: false, + limits: plugin_limits(), + }, + { + sql: "SELECT run_id,session_id,workspace_id,branch_id,plugin_id,package_id,artifact_id,owner_kind,owner_id,schema_version,revision,object_id FROM plugin_state_run_bindings WHERE run_id=?", + params: [sql_text(input.run_id.copy())], + query: true, + limits: plugin_limits(), + } + ]; + let result: array = sqlite::transaction(&db_id, statements); + let mut response: map = plugin_control_error("plugin_state_unavailable", "plugin state storage is unavailable"); + if result.length >= 2 { + let inserted: int = plugin_tx_rows(result[0].copy()); + let binding: SqliteTransactionResult = result[1].copy(); + if plugin_state_binding_matches(binding.copy(), input.copy()) { + response = ok_value({ + run_id: input.run_id.copy(), + owner_kind: input.owner_kind.copy(), + owner_id: input.owner_id.copy(), + schema_version: input.schema_version.copy(), + revision: input.revision.copy(), + object_id: input.object_id.copy(), + idempotent: inserted == 0 + }); + } else { + let live_run: string = plugin_query_text(binding.copy(), 0); + if live_run.copy() == "" { + response = plugin_control_error("plugin_state_binding_invalid", "run or state object is not available for binding"); + } else { + response = plugin_control_error("plugin_state_binding_conflict", "run state binding is immutable"); + } + } + } + response +} + +pub fn storage_plugin_state_binding_get(db_id: resource, payload_json: string) -> map { + let input: PluginStateRunInput = json::decode::(payload_json); + ok_query(sqlite::query(&db_id, "SELECT b.run_id,b.session_id,b.workspace_id,b.branch_id,b.plugin_id,b.package_id,b.artifact_id,b.owner_kind,b.owner_id,b.graph_id,b.epoch,b.schema_version,b.revision,b.object_id,b.checkpoint_id,o.document_json,o.document_bytes FROM plugin_state_run_bindings b JOIN plugin_state_objects o ON o.object_id=b.object_id WHERE b.run_id=?", [sql_text(input.run_id.copy())], plugin_limits())) +} + +pub fn storage_plugin_state_binding_advance(db_id: resource, payload_json: string) -> map { + let input: PluginStateAdvanceInput = json::decode::(payload_json); + let statements: array = [ + { + sql: "UPDATE plugin_state_run_bindings SET schema_version=?,revision=?,object_id=? WHERE run_id=? AND revision=? AND EXISTS(SELECT 1 FROM plugin_state_objects WHERE object_id=? AND schema_version=?)", + params: [sql_int(input.schema_version.copy()), sql_int(input.revision.copy()), sql_text(input.object_id.copy()), sql_text(input.run_id.copy()), sql_int(input.expected_revision.copy()), sql_text(input.object_id.copy()), sql_int(input.schema_version.copy())], + query: false, + limits: plugin_limits(), + }, + { + sql: "SELECT schema_version,revision,object_id FROM plugin_state_run_bindings WHERE run_id=?", + params: [sql_text(input.run_id.copy())], + query: true, + limits: plugin_limits(), + } + ]; + let result: array = sqlite::transaction(&db_id, statements); + let mut response: map = plugin_control_error("plugin_state_unavailable", "plugin state storage is unavailable"); + if result.length >= 2 { + let changed: int = plugin_tx_rows(result[0].copy()); + let binding: SqliteTransactionResult = result[1].copy(); + let schema_version: int = plugin_query_int(binding.copy(), 0); + let revision: int = plugin_query_int(binding.copy(), 1); + let object_id: string = plugin_query_text(binding.copy(), 2); + if changed == 1 && revision == input.revision.copy() && object_id.copy() == input.object_id.copy() { + response = ok_value({ run_id: input.run_id.copy(), schema_version: schema_version, revision: revision, object_id: object_id.copy() }); + } else { + response = plugin_control_error("plugin_state_conflict", "run state binding revision does not match"); + } + } + response +} + +struct PluginStateAdmissionMigrationInput { + run_id: string, + session_id: string, + owner_kind: string, + workspace_id: string, + branch_id: string, + plugin_id: string, + package_id: string, + artifact_id: string, + graph_id: string, + epoch: int, + old_schema_version: int, + schema_version: int, + expected_revision: int, + expected_object_id: string, + object_id: string, + now_ms: int, +} + +pub fn storage_admission_state_statements(payload_json: string) -> array { + let input: PluginStateAdmissionMigrationInput = json::decode::(payload_json); + let next_revision: int = input.expected_revision.copy() + 1; + [ + {sql:"INSERT INTO plugin_control_guard(id) SELECT 2 WHERE NOT EXISTS(SELECT 1 FROM plugin_state_roots WHERE owner_kind='session' AND owner_id=? AND workspace_id=? AND branch_id=? AND plugin_id=? AND revision=? AND object_id=? AND schema_version=?)",params:[sql_text(input.session_id.copy()),sql_text(input.workspace_id.copy()),sql_text(input.branch_id.copy()),sql_text(input.plugin_id.copy()),sql_int(input.expected_revision.copy()),sql_text(input.expected_object_id.copy()),sql_int(input.old_schema_version.copy())],query:false,limits:plugin_limits()}, + {sql:"UPDATE plugin_state_roots SET package_id=?,artifact_id=?,schema_version=?,revision=revision+1,object_id=?,updated_at_ms=? WHERE owner_kind='session' AND owner_id=? AND workspace_id=? AND branch_id=? AND plugin_id=? AND revision=? AND object_id=? AND EXISTS(SELECT 1 FROM plugin_state_objects WHERE object_id=? AND package_id=? AND artifact_id=? AND schema_version=?)",params:[sql_text(input.package_id.copy()),sql_text(input.artifact_id.copy()),sql_int(input.schema_version.copy()),sql_text(input.object_id.copy()),sql_int(input.now_ms.copy()),sql_text(input.session_id.copy()),sql_text(input.workspace_id.copy()),sql_text(input.branch_id.copy()),sql_text(input.plugin_id.copy()),sql_int(input.expected_revision.copy()),sql_text(input.expected_object_id.copy()),sql_text(input.object_id.copy()),sql_text(input.package_id.copy()),sql_text(input.artifact_id.copy()),sql_int(input.schema_version.copy())],query:false,limits:plugin_limits()}, + {sql:"INSERT INTO plugin_control_guard(id) SELECT 2 WHERE NOT EXISTS(SELECT 1 FROM plugin_state_roots WHERE owner_kind='session' AND owner_id=? AND workspace_id=? AND branch_id=? AND plugin_id=? AND revision=? AND object_id=? AND schema_version=?)",params:[sql_text(input.session_id.copy()),sql_text(input.workspace_id.copy()),sql_text(input.branch_id.copy()),sql_text(input.plugin_id.copy()),sql_int(next_revision.copy()),sql_text(input.object_id.copy()),sql_int(input.schema_version.copy())],query:false,limits:plugin_limits()}, + {sql:"INSERT INTO plugin_state_run_bindings(run_id,session_id,workspace_id,branch_id,plugin_id,package_id,artifact_id,owner_kind,owner_id,graph_id,epoch,schema_version,revision,object_id,checkpoint_id,created_at_ms) SELECT ?,?,?,?,?,?,?,'session',?,?,?,?,?,?,'',? WHERE EXISTS(SELECT 1 FROM runs WHERE id=? AND session_id=?) AND EXISTS(SELECT 1 FROM plugin_state_roots WHERE owner_kind='session' AND owner_id=? AND workspace_id=? AND branch_id=? AND plugin_id=? AND revision=? AND object_id=? AND schema_version=?) AND NOT EXISTS(SELECT 1 FROM plugin_state_run_bindings WHERE run_id=?)",params:[sql_text(input.run_id.copy()),sql_text(input.session_id.copy()),sql_text(input.workspace_id.copy()),sql_text(input.branch_id.copy()),sql_text(input.plugin_id.copy()),sql_text(input.package_id.copy()),sql_text(input.artifact_id.copy()),sql_text(input.session_id.copy()),sql_text(input.graph_id.copy()),sql_int(input.epoch.copy()),sql_int(input.schema_version.copy()),sql_int(next_revision.copy()),sql_text(input.object_id.copy()),sql_int(input.now_ms.copy()),sql_text(input.run_id.copy()),sql_text(input.session_id.copy()),sql_text(input.session_id.copy()),sql_text(input.workspace_id.copy()),sql_text(input.branch_id.copy()),sql_text(input.plugin_id.copy()),sql_int(next_revision.copy()),sql_text(input.object_id.copy()),sql_int(input.schema_version.copy()),sql_text(input.run_id.copy())],query:false,limits:plugin_limits()}, + {sql:"INSERT INTO plugin_control_guard(id) SELECT 2 WHERE NOT EXISTS(SELECT 1 FROM plugin_state_run_bindings WHERE run_id=? AND session_id=? AND schema_version=? AND revision=? AND object_id=?)",params:[sql_text(input.run_id.copy()),sql_text(input.session_id.copy()),sql_int(input.schema_version.copy()),sql_int(next_revision.copy()),sql_text(input.object_id.copy())],query:false,limits:plugin_limits()} + ] +} + +struct PluginStateAdmissionGuardInput { + session_id: string, + workspace_id: string, + branch_id: string, + plugin_id: string, + package_id: string, + artifact_id: string, + revision: int, + object_id: string, + schema_version: int, +} + +pub fn storage_admission_state_guard_statements(payload_json: string) -> array { + let input: PluginStateAdmissionGuardInput = json::decode::(payload_json); + [ + {sql:"INSERT INTO plugin_control_guard(id) SELECT 2 WHERE NOT EXISTS(SELECT 1 FROM plugin_state_roots WHERE owner_kind='session' AND owner_id=? AND workspace_id=? AND branch_id=? AND plugin_id=? AND package_id=? AND artifact_id=? AND revision=? AND object_id=? AND schema_version=?)",params:[sql_text(input.session_id.copy()),sql_text(input.workspace_id.copy()),sql_text(input.branch_id.copy()),sql_text(input.plugin_id.copy()),sql_text(input.package_id.copy()),sql_text(input.artifact_id.copy()),sql_int(input.revision.copy()),sql_text(input.object_id.copy()),sql_int(input.schema_version.copy())],query:false,limits:plugin_limits()} + ] +} + +pub fn storage_plugin_state_roots_for_checkpoint(db_id: resource, payload_json: string) -> map { + let input: PluginStateRunInput = json::decode::(payload_json); + ok_query(sqlite::query(&db_id, "SELECT owner_kind,owner_id,workspace_id,branch_id,plugin_id,package_id,artifact_id,schema_version,revision,object_id FROM plugin_state_run_bindings WHERE run_id=?", [sql_text(input.run_id.copy())], plugin_limits())) +} + +pub fn storage_plugin_state_session_nonterminal(db_id: resource, payload_json: string) -> map { + let input: PluginStateSessionInput = json::decode::(payload_json); + ok_query(sqlite::query(&db_id, "SELECT COUNT(*) FROM runs WHERE session_id=? AND status IN ('queued','running','waiting_approval','compacting')", [sql_text(input.session_id.copy())], plugin_limits())) +} + +pub fn storage_plugin_state_session_max_schema(db_id: resource, payload_json: string) -> map { + let input: PluginStatePluginInput = json::decode::(payload_json); + ok_query(sqlite::query(&db_id, "SELECT COALESCE(MAX(schema_version),0) FROM plugin_state_roots WHERE owner_kind='session' AND plugin_id=?", [sql_text(input.plugin_id.copy())], plugin_limits())) +} + +pub fn storage_plugin_state_commit(db_id: resource, payload_json: string) -> map { + let input: PluginStateCommitInput = json::decode::(payload_json); + let statements: array = [ + { + sql: "UPDATE plugin_state_run_bindings SET package_id=?,artifact_id=?,schema_version=?,revision=?,object_id=? WHERE run_id=? AND revision=? AND object_id=? AND owner_kind=? AND owner_id=? AND workspace_id=? AND branch_id=? AND plugin_id=? AND EXISTS(SELECT 1 FROM plugin_state_roots WHERE owner_kind=? AND owner_id=? AND workspace_id=? AND branch_id=? AND plugin_id=? AND revision=? AND object_id=?) AND EXISTS(SELECT 1 FROM plugin_state_objects WHERE object_id=? AND package_id=? AND artifact_id=? AND schema_version=?)", + params: [sql_text(input.package_id.copy()), sql_text(input.artifact_id.copy()), sql_int(input.schema_version.copy()), sql_int(input.expected_revision.copy() + 1), sql_text(input.object_id.copy()), sql_text(input.run_id.copy()), sql_int(input.expected_revision.copy()), sql_text(input.expected_object_id.copy()), sql_text(input.owner_kind.copy()), sql_text(input.owner_id.copy()), sql_text(input.workspace_id.copy()), sql_text(input.branch_id.copy()), sql_text(input.plugin_id.copy()), sql_text(input.owner_kind.copy()), sql_text(input.owner_id.copy()), sql_text(input.workspace_id.copy()), sql_text(input.branch_id.copy()), sql_text(input.plugin_id.copy()), sql_int(input.expected_revision.copy()), sql_text(input.expected_object_id.copy()), sql_text(input.object_id.copy()), sql_text(input.package_id.copy()), sql_text(input.artifact_id.copy()), sql_int(input.schema_version.copy())], + query: false, + limits: plugin_limits(), + }, + { + sql: "UPDATE plugin_state_roots SET schema_version=?,revision=revision+1,object_id=?,updated_at_ms=? WHERE owner_kind=? AND owner_id=? AND workspace_id=? AND branch_id=? AND plugin_id=? AND revision=? AND object_id=? AND EXISTS(SELECT 1 FROM plugin_state_run_bindings WHERE run_id=? AND revision=? AND object_id=?)", + params: [sql_int(input.schema_version.copy()), sql_text(input.object_id.copy()), sql_int(input.now_ms.copy()), sql_text(input.owner_kind.copy()), sql_text(input.owner_id.copy()), sql_text(input.workspace_id.copy()), sql_text(input.branch_id.copy()), sql_text(input.plugin_id.copy()), sql_int(input.expected_revision.copy()), sql_text(input.expected_object_id.copy()), sql_text(input.run_id.copy()), sql_int(input.expected_revision.copy() + 1), sql_text(input.object_id.copy())], + query: false, + limits: plugin_limits(), + }, + { + sql: "SELECT schema_version,revision,object_id FROM plugin_state_run_bindings WHERE run_id=?", + params: [sql_text(input.run_id.copy())], + query: true, + limits: plugin_limits(), + } + ]; + let result: array = sqlite::transaction(&db_id, statements); + let mut response: map = plugin_control_error("plugin_state_unavailable", "plugin state storage is unavailable"); + if result.length >= 3 { + let binding_changed: int = plugin_tx_rows(result[0].copy()); + let root_changed: int = plugin_tx_rows(result[1].copy()); + let binding: SqliteTransactionResult = result[2].copy(); + let revision: int = plugin_query_int(binding.copy(), 1); + let object_id: string = plugin_query_text(binding.copy(), 2); + if root_changed == 1 && binding_changed == 1 && revision == input.expected_revision.copy() + 1 && object_id.copy() == input.object_id.copy() { + response = ok_value({ run_id: input.run_id.copy(), schema_version: input.schema_version.copy(), revision: revision, object_id: object_id.copy() }); + } else { + response = plugin_control_error("plugin_state_conflict", "plugin state root or run binding revision does not match"); + } + } + response +} + + +pub fn storage_plugin_state_authorize(db_id: resource, payload_json: string) -> map { + let input: PluginStateAuthorizeInput = json::decode::(payload_json); + ok_query(sqlite::query(&db_id, "SELECT CASE WHEN NOT EXISTS(SELECT 1 FROM plugin_state_run_bindings b JOIN runs r ON r.id=b.run_id WHERE b.run_id=? AND b.session_id=? AND b.plugin_id=? AND b.package_id=? AND b.artifact_id=? AND b.owner_kind=? AND b.owner_id=? AND b.graph_id=? AND b.workspace_id=? AND b.branch_id=?) THEN 'plugin_state_binding_mismatch' WHEN NOT EXISTS(SELECT 1 FROM runs r JOIN sessions s ON s.id=r.session_id WHERE r.id=? AND r.session_id=? AND s.profile=? AND r.status IN ('queued','running')) THEN 'plugin_state_run_inactive' WHEN EXISTS(SELECT 1 FROM plugin_graph_revocations WHERE graph_id=?) THEN 'plugin_graph_revoked' WHEN EXISTS(SELECT 1 FROM managed_run_bindings WHERE run_id=?) AND NOT EXISTS(SELECT 1 FROM managed_run_bindings WHERE run_id=? AND workspace_id=? AND branch_id=? AND epoch=? AND original_deadline_ms>?) THEN 'plugin_state_epoch_mismatch' ELSE 'ok' END", [sql_text(input.run_id.copy()), sql_text(input.session_id.copy()), sql_text(input.plugin_id.copy()), sql_text(input.package_id.copy()), sql_text(input.artifact_id.copy()), sql_text(input.owner_kind.copy()), sql_text(input.owner_id.copy()), sql_text(input.graph_id.copy()), sql_text(input.workspace_id.copy()), sql_text(input.branch_id.copy()), sql_text(input.run_id.copy()), sql_text(input.session_id.copy()), sql_text(input.profile.copy()), sql_text(input.graph_id.copy()), sql_text(input.run_id.copy()), sql_text(input.run_id.copy()), sql_text(input.workspace_id.copy()), sql_text(input.branch_id.copy()), sql_int(input.epoch.copy()), sql_int(input.now_ms.copy())], plugin_limits())) +} + +struct PluginStateSessionKeyInput { session_id: string, plugin_id: string } +pub fn storage_plugin_state_session_key(db_id: resource, payload_json: string) -> map { + let input: PluginStateSessionKeyInput = json::decode::(payload_json); + ok_query(sqlite::query(&db_id, "SELECT workspace_id,branch_id,plugin_id FROM plugin_state_run_bindings WHERE session_id=? AND plugin_id=? AND owner_kind='session' ORDER BY created_at_ms DESC LIMIT 1", [sql_text(input.session_id.copy()), sql_text(input.plugin_id.copy())], plugin_limits())) +} + +struct PluginStateSessionKeyExactInput { session_id: string, plugin_id: string, workspace_id: string, branch_id: string } +pub fn storage_plugin_state_session_key_for(db_id: resource, payload_json: string) -> map { + let input: PluginStateSessionKeyExactInput = json::decode::(payload_json); + ok_query(sqlite::query(&db_id, "SELECT workspace_id,branch_id,plugin_id FROM plugin_state_roots WHERE owner_kind='session' AND owner_id=? AND plugin_id=? AND workspace_id=? AND branch_id=? LIMIT 1", [sql_text(input.session_id.copy()), sql_text(input.plugin_id.copy()), sql_text(input.workspace_id.copy()), sql_text(input.branch_id.copy())], plugin_limits())) +} + +struct PluginStateObjectKeyInput { object_id: string } +pub fn storage_plugin_state_object_get(db_id: resource, payload_json: string) -> map { + let input: PluginStateObjectKeyInput = json::decode::(payload_json); + ok_query(sqlite::query(&db_id, "SELECT object_id,schema_version,document_json FROM plugin_state_objects WHERE object_id=?", [sql_text(input.object_id.copy())], plugin_limits())) +} + +struct PluginStateCollectInput { failpoint: string, protected_ids_json: string } + +pub fn storage_plugin_state_collect(db_id: resource, payload_json: string) -> map { + let input: PluginStateCollectInput = json::decode::(payload_json); + let statements: array = [ + { + sql: "DELETE FROM plugin_state_objects WHERE object_id NOT IN (SELECT object_id FROM plugin_state_roots UNION SELECT object_id FROM plugin_state_run_bindings UNION SELECT json_extract(value,'$.immutable_root_id') FROM managed_checkpoints, json_each(json_extract(receipt_json,'$.checkpoint.plugin_state.roots')) UNION SELECT json_extract(prepared.prepared_record_json,'$.plugin_state.object_id') FROM managed_effect_receipts prepared JOIN managed_effect_calls prepared_call ON prepared_call.effect_id=prepared.effect_id WHERE prepared_call.state='prepared' AND json_extract(prepared.prepared_record_json,'$.plugin_state.object_id') IS NOT NULL) AND object_id NOT IN (SELECT value FROM json_each(?)) AND object_id NOT IN (SELECT object_id FROM plugin_state_objects WHERE object_id NOT IN (SELECT object_id FROM plugin_state_roots UNION SELECT object_id FROM plugin_state_run_bindings UNION SELECT json_extract(value,'$.immutable_root_id') FROM managed_checkpoints, json_each(json_extract(receipt_json,'$.checkpoint.plugin_state.roots')) UNION SELECT json_extract(prepared.prepared_record_json,'$.plugin_state.object_id') FROM managed_effect_receipts prepared JOIN managed_effect_calls prepared_call ON prepared_call.effect_id=prepared.effect_id WHERE prepared_call.state='prepared' AND json_extract(prepared.prepared_record_json,'$.plugin_state.object_id') IS NOT NULL) ORDER BY created_at_ms DESC LIMIT 64)", + params: [sql_text(input.protected_ids_json.copy())], + query: false, + limits: plugin_limits(), + }, + { + sql: "INSERT INTO plugin_control_guard (id) SELECT 2 WHERE ? = 'after_partial_write'", + params: [sql_text(input.failpoint.copy())], + query: false, + limits: plugin_limits(), + } + ]; + let result = sqlite::transaction(&db_id, statements); + let mut response: map = plugin_control_error("plugin_state_unavailable", "plugin state collection is unavailable"); + if result.length >= 2 { + response = ok_value({}); + } + response +} diff --git a/rss/storage/schema.rss b/rss/storage/schema.rss index d59b127..890fca7 100644 --- a/rss/storage/schema.rss +++ b/rss/storage/schema.rss @@ -344,3 +344,28 @@ pub fn event_retention_limit(requested: int) -> int { let value = if requested <= 0 => { 256 } else => { requested }; if value > 2048 => { 2048 } else => { value } } + +// T12 formal-store initialization for versioned plugin state. These are +// idempotent CREATE IF NOT EXISTS statements executed by every migrate/open. +// They do not add a product schema version or re-run data migrations. +pub fn plugin_state_table_count() -> int { 5 } + +pub fn plugin_state_table_statement(statement_index: int) -> string { + if statement_index == 0 => { + "CREATE TABLE IF NOT EXISTS plugin_state_objects(object_id TEXT PRIMARY KEY,package_id TEXT NOT NULL,artifact_id TEXT NOT NULL,schema_version INTEGER NOT NULL CHECK(schema_version BETWEEN 1 AND 64),document_json TEXT NOT NULL CHECK(json_valid(document_json) AND length(document_json)<=65536),document_bytes INTEGER NOT NULL CHECK(document_bytes>=0 AND document_bytes<=65536),created_at_ms INTEGER NOT NULL)" + } else => { + if statement_index == 1 => { + "CREATE TABLE IF NOT EXISTS plugin_state_roots(owner_kind TEXT NOT NULL CHECK(owner_kind IN ('run','session')),owner_id TEXT NOT NULL,workspace_id TEXT NOT NULL,branch_id TEXT NOT NULL,plugin_id TEXT NOT NULL,package_id TEXT NOT NULL,artifact_id TEXT NOT NULL,schema_version INTEGER NOT NULL CHECK(schema_version BETWEEN 1 AND 64),revision INTEGER NOT NULL CHECK(revision>=1),object_id TEXT NOT NULL,updated_at_ms INTEGER NOT NULL,PRIMARY KEY(owner_kind,owner_id,workspace_id,branch_id,plugin_id))" + } else => { + if statement_index == 2 => { + "CREATE TABLE IF NOT EXISTS plugin_state_run_bindings(run_id TEXT PRIMARY KEY REFERENCES runs(id),session_id TEXT NOT NULL,workspace_id TEXT NOT NULL,branch_id TEXT NOT NULL,plugin_id TEXT NOT NULL,package_id TEXT NOT NULL,artifact_id TEXT NOT NULL,owner_kind TEXT NOT NULL CHECK(owner_kind IN ('run','session')),owner_id TEXT NOT NULL,graph_id TEXT NOT NULL,epoch INTEGER NOT NULL DEFAULT 0,schema_version INTEGER NOT NULL CHECK(schema_version BETWEEN 1 AND 64),revision INTEGER NOT NULL CHECK(revision>=1),object_id TEXT NOT NULL,checkpoint_id TEXT NOT NULL DEFAULT '',created_at_ms INTEGER NOT NULL)" + } else => { + if statement_index == 3 => { + "CREATE INDEX IF NOT EXISTS plugin_state_root_owner ON plugin_state_roots(owner_kind,owner_id,plugin_id)" + } else => { + "CREATE INDEX IF NOT EXISTS plugin_state_binding_session ON plugin_state_run_bindings(session_id,plugin_id)" + } + } + } + } +} diff --git a/schemas/canonical/managed-checkpoint.schema.json b/schemas/canonical/managed-checkpoint.schema.json index 8d54ca4..6d7f103 100644 --- a/schemas/canonical/managed-checkpoint.schema.json +++ b/schemas/canonical/managed-checkpoint.schema.json @@ -594,6 +594,49 @@ }, "ManagedCheckpointStateCoverage": { "oneOf": [ + { + "additionalProperties": false, + "properties": { + "artifact_id": { + "maxLength": 256, + "type": "string" + }, + "graph_id": { + "maxLength": 256, + "type": "string" + }, + "invocation_excluded_from_checkpoint": { + "type": "boolean" + }, + "kind": { + "const": "stateful" + }, + "manifest_state_present": { + "const": true + }, + "package_id": { + "maxLength": 256, + "type": "string" + }, + "roots": { + "items": { + "$ref": "#/$defs/ManagedPluginStateRootRef" + }, + "maxItems": 8, + "type": "array" + } + }, + "required": [ + "kind", + "graph_id", + "package_id", + "artifact_id", + "manifest_state_present", + "invocation_excluded_from_checkpoint", + "roots" + ], + "type": "object" + }, { "additionalProperties": false, "properties": { @@ -1440,14 +1483,33 @@ "maxLength": 256, "type": "string" }, + "branch_id": { + "maxLength": 256, + "type": "string" + }, "immutable_root_id": { "maxLength": 256, "type": "string" }, + "owner_id": { + "maxLength": 256, + "type": "string" + }, + "owner_kind": { + "enum": [ + "run", + "session" + ], + "type": "string" + }, "package_id": { "maxLength": 256, "type": "string" }, + "plugin_id": { + "maxLength": 256, + "type": "string" + }, "revision": { "maximum": 9223372036854775807, "minimum": 0, @@ -1463,9 +1525,18 @@ "session" ], "type": "string" + }, + "workspace_id": { + "maxLength": 256, + "type": "string" } }, "required": [ + "owner_kind", + "owner_id", + "workspace_id", + "branch_id", + "plugin_id", "package_id", "artifact_id", "scope", @@ -1524,6 +1595,92 @@ "parent_root": { "$ref": "#/$defs/ManagedObjectRef" }, + "plugin_state": { + "anyOf": [ + { + "type": "null" + }, + { + "additionalProperties": false, + "properties": { + "artifact_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "branch_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "ephemeral": { + "type": "boolean" + }, + "expected_object_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "expected_revision": { + "maximum": 9223372036854775807, + "minimum": 0, + "type": "integer" + }, + "object_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "owner_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "owner_kind": { + "enum": [ + "invocation", + "run", + "session" + ] + }, + "package_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "plugin_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "schema_version": { + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "workspace_id": { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "owner_kind", + "owner_id", + "workspace_id", + "branch_id", + "plugin_id", + "package_id", + "artifact_id", + "schema_version", + "expected_revision", + "expected_object_id", + "object_id" + ], + "type": "object" + } + ] + }, "prepared_at_ms": { "maximum": 9223372036854775807, "minimum": 0, diff --git a/schemas/canonical/managed-effect.schema.json b/schemas/canonical/managed-effect.schema.json index 1653aec..b84fe1c 100644 --- a/schemas/canonical/managed-effect.schema.json +++ b/schemas/canonical/managed-effect.schema.json @@ -399,6 +399,92 @@ "parent_root": { "$ref": "#/$defs/object" }, + "plugin_state": { + "anyOf": [ + { + "type": "null" + }, + { + "additionalProperties": false, + "properties": { + "artifact_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "branch_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "ephemeral": { + "type": "boolean" + }, + "expected_object_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "expected_revision": { + "maximum": 9007199254740991, + "minimum": 1, + "type": "integer" + }, + "object_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "owner_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "owner_kind": { + "enum": [ + "invocation", + "run", + "session" + ] + }, + "package_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "plugin_id": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "schema_version": { + "maximum": 64, + "minimum": 1, + "type": "integer" + }, + "workspace_id": { + "maxLength": 512, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "owner_kind", + "owner_id", + "workspace_id", + "branch_id", + "plugin_id", + "package_id", + "artifact_id", + "schema_version", + "expected_revision", + "expected_object_id", + "object_id" + ], + "type": "object" + } + ] + }, "prepared_at_ms": { "maximum": 9007199254740991, "minimum": 1, diff --git a/schemas/canonical/plugin-manifest.schema.json b/schemas/canonical/plugin-manifest.schema.json index 5bb33df..381fbfd 100644 --- a/schemas/canonical/plugin-manifest.schema.json +++ b/schemas/canonical/plugin-manifest.schema.json @@ -5,11 +5,26 @@ "allOf": [ { "if": { + "not": { + "properties": { + "state": { + "required": [ + "migration" + ] + } + }, + "required": [ + "state" + ] + }, "properties": { "kind": { "const": "tool" } - } + }, + "required": [ + "kind" + ] }, "then": { "properties": { @@ -25,15 +40,63 @@ }, { "if": { + "properties": { + "kind": { + "const": "tool" + }, + "state": { + "required": [ + "migration" + ] + } + }, + "required": [ + "kind", + "state" + ] + }, + "then": { "properties": { "effects": { "properties": { "entries": { - "minProperties": 2 + "maxProperties": 2 } } } } + } + }, + { + "if": { + "not": { + "properties": { + "kind": { + "const": "tool" + }, + "state": { + "required": [ + "migration" + ] + } + }, + "required": [ + "kind", + "state" + ] + }, + "properties": { + "effects": { + "properties": { + "entries": { + "minProperties": 2 + } + } + } + }, + "required": [ + "effects" + ] }, "then": { "not": { @@ -167,12 +230,31 @@ "state": { "additionalProperties": false, "properties": { + "migration": { + "additionalProperties": false, + "properties": { + "entry": { + "pattern": "^[A-Za-z_][A-Za-z0-9_]*$", + "type": "string" + } + }, + "required": [ + "entry" + ], + "type": "object" + }, "schema": { + "maximum": 64, "minimum": 1, "type": "integer" }, "scope": { - "const": "run" + "enum": [ + "invocation", + "run", + "session" + ], + "type": "string" } }, "required": [ diff --git a/schemas/managed-checkpoint.schema.yaml b/schemas/managed-checkpoint.schema.yaml index 74bc3d4..b743035 100644 --- a/schemas/managed-checkpoint.schema.yaml +++ b/schemas/managed-checkpoint.schema.yaml @@ -626,6 +626,49 @@ }, "ManagedCheckpointStateCoverage": { "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "graph_id", + "package_id", + "artifact_id", + "manifest_state_present", + "invocation_excluded_from_checkpoint", + "roots" + ], + "properties": { + "kind": { + "const": "stateful" + }, + "graph_id": { + "type": "string", + "maxLength": 256 + }, + "package_id": { + "type": "string", + "maxLength": 256 + }, + "artifact_id": { + "type": "string", + "maxLength": 256 + }, + "manifest_state_present": { + "const": true + }, + "invocation_excluded_from_checkpoint": { + "type": "boolean" + }, + "roots": { + "type": "array", + "maxItems": 8, + "items": { + "$ref": "#/$defs/ManagedPluginStateRootRef" + } + } + } + }, { "type": "object", "additionalProperties": false, @@ -1469,6 +1512,11 @@ "type": "object", "additionalProperties": false, "required": [ + "owner_kind", + "owner_id", + "workspace_id", + "branch_id", + "plugin_id", "package_id", "artifact_id", "scope", @@ -1477,6 +1525,26 @@ "immutable_root_id" ], "properties": { + "owner_kind": { + "type": "string", + "enum": ["run", "session"] + }, + "owner_id": { + "type": "string", + "maxLength": 256 + }, + "workspace_id": { + "type": "string", + "maxLength": 256 + }, + "branch_id": { + "type": "string", + "maxLength": 256 + }, + "plugin_id": { + "type": "string", + "maxLength": 256 + }, "package_id": { "type": "string", "maxLength": 256 @@ -1582,6 +1650,92 @@ }, "external_effects": { "$ref": "#/$defs/ManagedExternalEffects" + }, + "plugin_state": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "owner_kind", + "owner_id", + "workspace_id", + "branch_id", + "plugin_id", + "package_id", + "artifact_id", + "schema_version", + "expected_revision", + "expected_object_id", + "object_id" + ], + "properties": { + "owner_kind": { + "enum": [ + "invocation", + "run", + "session" + ] + }, + "owner_id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "workspace_id": { + "type": "string", + "maxLength": 512, + "minLength": 1 + }, + "branch_id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "plugin_id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "package_id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "artifact_id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "schema_version": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "expected_revision": { + "type": "integer", + "minimum": 0, + "maximum": 9223372036854775807 + }, + "expected_object_id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "object_id": { + "type": "string", + "maxLength": 256, + "minLength": 1 + }, + "ephemeral": { + "type": "boolean" + } + } + } + ] } } }, diff --git a/schemas/managed-effect.schema.yaml b/schemas/managed-effect.schema.yaml index e74e399..53a6400 100644 --- a/schemas/managed-effect.schema.yaml +++ b/schemas/managed-effect.schema.yaml @@ -385,6 +385,92 @@ "none_claimed", "outside_tree_not_reverted" ] + }, + "plugin_state": { + "anyOf": [ + { + "type": "null" + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "owner_kind", + "owner_id", + "workspace_id", + "branch_id", + "plugin_id", + "package_id", + "artifact_id", + "schema_version", + "expected_revision", + "expected_object_id", + "object_id" + ], + "properties": { + "owner_kind": { + "enum": [ + "invocation", + "run", + "session" + ] + }, + "owner_id": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "workspace_id": { + "type": "string", + "minLength": 1, + "maxLength": 512 + }, + "branch_id": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "plugin_id": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "package_id": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "artifact_id": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "schema_version": { + "type": "integer", + "minimum": 1, + "maximum": 64 + }, + "expected_revision": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "expected_object_id": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "object_id": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "ephemeral": { + "type": "boolean" + } + } + } + ] } } }, diff --git a/schemas/plugin-manifest.schema.yaml b/schemas/plugin-manifest.schema.yaml index 55e9388..bc3ee4d 100644 --- a/schemas/plugin-manifest.schema.yaml +++ b/schemas/plugin-manifest.schema.yaml @@ -19,16 +19,37 @@ allOf: - if: properties: kind: {const: tool} + required: [kind] + not: + properties: + state: {required: [migration]} + required: [state] then: properties: effects: properties: entries: {maxProperties: 1} + - if: + properties: + kind: {const: tool} + state: {required: [migration]} + required: [kind, state] + then: + properties: + effects: + properties: + entries: {maxProperties: 2} - if: properties: effects: properties: entries: {minProperties: 2} + required: [effects] + not: + properties: + kind: {const: tool} + state: {required: [migration]} + required: [kind, state] then: properties: kind: {const: library} @@ -122,10 +143,24 @@ properties: - schema properties: scope: - const: run + type: string + enum: + - invocation + - run + - session schema: type: integer minimum: 1 + maximum: 64 + migration: + type: object + additionalProperties: false + required: + - entry + properties: + entry: + type: string + pattern: "^[A-Za-z_][A-Za-z0-9_]*$" version: type: string pattern: "^[0-9]+\\.[0-9]+\\.[0-9]+$" diff --git a/src/effects/broker.rs b/src/effects/broker.rs index cb58cee..1358f6b 100644 --- a/src/effects/broker.rs +++ b/src/effects/broker.rs @@ -2778,6 +2778,8 @@ impl ManagedCallContext { &self, token: &str, result: &Value, + plugin_state: Option, + state_owner: Option, ) -> Result, String> { let binding = self.binding()?; binding.check()?; @@ -2855,6 +2857,7 @@ impl ManagedCallContext { prepared_at_ms: crate::domain::timestamp(), observation_ok: ok, external_effects: self.policy.class().external_effects(), + plugin_state, }; self.reservation.release_unspent(); let mut objects = vec![original]; @@ -2888,7 +2891,7 @@ impl ManagedCallContext { "" } }; - binding.runtime.repository.command_with_cancellation("effect.prepare",&json!({"effect_id":self.effect_id,"receipt_id":self.receipt_id,"prepared_json":serde_json::to_string(&record).map_err(error)?,"cut":cut}),&binding.cancel)?; + binding.runtime.repository.command_with_cancellation_and_owner("effect.prepare",&json!({"effect_id":self.effect_id,"receipt_id":self.receipt_id,"prepared_json":serde_json::to_string(&record).map_err(error)?,"cut":cut}),&binding.cancel,state_owner.clone())?; #[cfg(test)] let observer = binding.runtime.before_prepared_install.lock().clone(); #[cfg(test)] @@ -2905,6 +2908,10 @@ impl ManagedCallContext { conflict, objects, }); + // The staged owner is intentionally not stored in the witness: the + // prepared receipt is durable now, so the typed collector reference set + // (not memory) protects the object until the atomic commit handoff. + // Storing it here would pin it in the never-evicted call map. let state = binding.branch.state.lock(); let permitted = state.phase == ManagedBranchPhase::Active || (state.phase == ManagedBranchPhase::CheckpointPausing diff --git a/src/effects/journal.rs b/src/effects/journal.rs index 7e8caef..3ddbb45 100644 --- a/src/effects/journal.rs +++ b/src/effects/journal.rs @@ -130,6 +130,11 @@ pub struct ManagedPublicConfigRef { #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ManagedPluginStateRootRef { + pub owner_kind: String, + pub owner_id: String, + pub workspace_id: String, + pub branch_id: String, + pub plugin_id: String, pub package_id: String, pub artifact_id: String, pub scope: String, @@ -141,6 +146,14 @@ pub struct ManagedPluginStateRootRef { #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] pub enum ManagedCheckpointStateCoverage { + Stateful { + graph_id: String, + package_id: String, + artifact_id: String, + manifest_state_present: bool, + invocation_excluded_from_checkpoint: bool, + roots: Vec, + }, StatelessBuiltinWithPurePolicy { graph_id: String, package_id: String, @@ -448,6 +461,24 @@ pub enum ManagedExternalEffects { NoneClaimed, OutsideTreeNotReverted, } +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ManagedPreparedStatePublication { + pub owner_kind: String, + pub owner_id: String, + pub workspace_id: String, + pub branch_id: String, + pub plugin_id: String, + pub package_id: String, + pub artifact_id: String, + pub schema_version: u32, + pub expected_revision: u64, + pub expected_object_id: String, + pub object_id: String, + #[serde(default)] + pub ephemeral: bool, +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ManagedPreparedRecord { @@ -465,6 +496,8 @@ pub struct ManagedPreparedRecord { pub prepared_at_ms: u64, pub observation_ok: bool, pub external_effects: ManagedExternalEffects, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugin_state: Option, } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -907,6 +940,53 @@ impl ManagedEffectRepository { _ => e.to_string(), }) } + pub(crate) fn command_with_cancellation_and_owner( + &self, + op: &str, + payload: &Value, + cancellation: &crate::RunCancellation, + state_owner: Option, + ) -> Result { + for field in ["record_json", "prepared_json", "workspace_json"] { + if let Some(text) = payload + .get(field) + .and_then(Value::as_str) + .filter(|text| !text.is_empty()) + { + let validation = managed_document(text); + #[cfg(feature = "workspace-io-fault")] + let validation = validation.inspect_err(|error| { + eprintln!("managed document validation rejected {field}: {error}"); + }); + validation?; + } + } + self.persistence + .managed_effect_command_with_cancellation_and_owner( + op, + payload, + cancellation, + state_owner, + ) + .map_err(|e| match e.code.as_str() { + "plugin_graph_revoked" | "plugin_graph_pin_required" => e.code, + _ => e.to_string(), + }) + } + /// Settle sidecar for commit requests: re-reserves the already-durable + /// prepared state object for the duration of one commit request. Never + /// fails the caller: if the registry is full the commit still proceeds, + /// protected by the atomic prepared-to-root/binding handoff executed by + /// the same single worker. + pub(crate) fn try_reserve_state_owner( + &self, + object_id: &str, + ) -> Option { + self.persistence + .plugin_state_candidates + .reserve(object_id) + .ok() + } pub(crate) fn checkpoint_receipt( &self, request: &ManagedCheckpointCreateRequest, diff --git a/src/effects/managed_authority_tests.rs b/src/effects/managed_authority_tests.rs index ac720b1..1b6497e 100644 --- a/src/effects/managed_authority_tests.rs +++ b/src/effects/managed_authority_tests.rs @@ -336,6 +336,7 @@ async fn fresh_host_rejects_wrong_risk_name_arguments_and_registry_then_same_vm_ pinned_tool: host.pinned_tool.clone(), allow_stateless_pin: false, expected_call: None, + plugin_state_call: None, provider_arguments: Default::default(), }; let forged = json!({"tool_name":"write_file","call_id":"second","canonical_arguments":canonical,"canonical_input":context["input"],"run_id":call.identity.owner.run_id,"registry_identity":call.identity.code.registry_identity,"argument_digest":canonical,"risk_class":"read","summary":"original","untrusted_graph_id":"","operation":"workspace_read","permissions":["workspace_write","foreground_process"]}); diff --git a/src/effects/managed_checkpoint_schema_tests.rs b/src/effects/managed_checkpoint_schema_tests.rs index 8b7a824..ed1aa95 100644 --- a/src/effects/managed_checkpoint_schema_tests.rs +++ b/src/effects/managed_checkpoint_schema_tests.rs @@ -53,7 +53,13 @@ fn sample(schema: &Value, body: &Value) -> Value { .map(|(key, value)| (key.clone(), sample(schema, value))) .collect(), ), - "array" => json!([]), + "array" => { + if body.get("minItems").and_then(Value::as_u64).unwrap_or(0) > 0 { + json!([sample(schema, &body["items"])]) + } else { + json!([]) + } + } "boolean" => json!(false), "integer" => body.get("minimum").cloned().unwrap_or(json!(0)), "number" => json!(0.25), @@ -189,10 +195,17 @@ fn managed_public_parsers_reject_duplicates_aliases_tags_unknown_and_enum_drift( let schema = load_schema("managed-checkpoint.schema.yaml"); let receipt = sample(&schema, &schema["$defs"]["ManagedCheckpointReceipt"]); parse_managed_checkpoint_yaml(&receipt.to_string()).unwrap(); - let mut state = receipt.clone(); + let stateless_state = sample( + &schema, + &schema["$defs"]["ManagedCheckpointStateCoverage"]["oneOf"][1], + ); + let mut stateless = receipt.clone(); + stateless["checkpoint"]["plugin_state"] = stateless_state; + parse_managed_checkpoint_yaml(&stateless.to_string()).unwrap(); + let mut state = stateless.clone(); state["checkpoint"]["plugin_state"]["manifest_state_absent"] = json!(false); assert!(parse_managed_checkpoint_yaml(&state.to_string()).is_err()); - let mut roots = receipt.clone(); + let mut roots = stateless; roots["checkpoint"]["plugin_state"]["roots"] = json!([sample( &schema, &schema["$defs"]["ManagedPluginStateRootRef"] diff --git a/src/gateway/managed_storage_budget_tests.rs b/src/gateway/managed_storage_budget_tests.rs index 347c2e3..3ee9c14 100644 --- a/src/gateway/managed_storage_budget_tests.rs +++ b/src/gateway/managed_storage_budget_tests.rs @@ -652,6 +652,8 @@ fn managed_native_permit_is_owned_by_queued_request_until_worker_acknowledgement _managed_permit: Some(Arc::new(permit)), maintenance: None, _maintenance_read: None, + state_owner: None, + report_health: true, reply_block: None, }) .unwrap(); diff --git a/src/gateway/store.rs b/src/gateway/store.rs index db165d8..ab31b73 100644 --- a/src/gateway/store.rs +++ b/src/gateway/store.rs @@ -148,6 +148,12 @@ pub struct GatewayPersistence { #[cfg(test)] maintenance_reply_block: Mutex>>, managed_pending: Arc, + /// Per-root state writer leases shared by every store opened on this + /// persistence. Checkpoint freezes exact roots here, not on the branch gate. + pub(crate) plugin_state_writers: Arc, + /// In-flight migration candidate ids protected from the bounded orphan + /// collector until their admission settles. + pub(crate) plugin_state_candidates: Arc, worker: StorageWorker, max_events: i64, broadcast_capacity: usize, @@ -177,7 +183,7 @@ pub struct GatewayPersistence { admission_result_observer: Mutex>, /// One-shot drop of a successful admission command ack. The transaction is /// not submitted again. - #[cfg(test)] + #[cfg(any(test, feature = "workspace-io-fault"))] admission_ack_loss: std::sync::atomic::AtomicBool, /// One-shot post-commit secondary-decode loss. Same seam as ack loss, with /// the existing `storage_error` code. Not a second RSS failpoint. @@ -187,6 +193,55 @@ pub struct GatewayPersistence { /// is committed. #[cfg(test)] admission_precommit_fail: std::sync::atomic::AtomicBool, + /// One-shot durable admission failpoint after the plugin state root CAS + /// and binding statements. Arming it proves the transaction rolls the + /// prepared migration back instead of publishing a root before admission. + #[cfg(any(test, feature = "workspace-io-fault"))] + admission_state_fail: std::sync::atomic::AtomicBool, + /// Test-only observer immediately before the caller issues the typed + /// plugin state collection command. No behavior change. + #[cfg(any(test, feature = "workspace-io-fault"))] + plugin_state_collect_observer: Mutex>, + /// One-shot real SQL failpoint for the typed plugin state collector. The + /// same RSS transaction aborts; the collector surfaces the error. + #[cfg(any(test, feature = "workspace-io-fault"))] + plugin_state_collect_failpoint: std::sync::atomic::AtomicBool, + /// One-shot loss of a successful `plugin.state_object_put` reply after the + /// durable object transaction. The transaction is not repeated. + #[cfg(any(test, feature = "workspace-io-fault"))] + plugin_state_put_ack_loss: std::sync::atomic::AtomicBool, + /// Fixture-only blocking observer after a real `plugin.state_object_put` + /// is durable and before the producer returns. It observes the existing + /// Native producer seam; it changes no put, reservation, or SQL state. + #[cfg(feature = "workspace-io-fault")] + plugin_state_object_put_observer: Mutex>, + /// Fixture-only blocking observer after the typed collector command + /// returns its real result and before the caller records collection + /// health. No command, SQL, or debt state is changed by the observer. + #[cfg(feature = "workspace-io-fault")] + plugin_state_collect_result_observer: Mutex>, + /// Fixture-only observer at the caller's collector enqueue seam: after the + /// execution epoch is snapshotted and before the typed request is issued. + /// It reports the explicit/quiet health flag and changes no command, + /// epoch, health, or SQL state. + #[cfg(any(test, feature = "workspace-io-fault"))] + plugin_state_collect_enqueue_observer: Mutex>, + /// Fixture-only blocking hook at the exact storage-worker execution + /// boundary, after a queued request is dequeued and before the protected + /// set is read or any RSS/SQL command runs. It observes queue order only. + #[cfg(feature = "workspace-io-fault")] + worker_execute_gate: StorageExecuteGate, + /// Fixture-only blocking observer right after the worker takes one + /// `plugin.state_collect` snapshot (protected set + dirty generation) + /// and before its RSS/SQL runs. Stored as the worker-thread slot below; + /// a test parks the worker here to land a last-owner release mid-sweep + /// and prove the later success clears just the covered generation. + #[cfg(feature = "workspace-io-fault")] + worker_collect_snapshot_gate: StorageSnapshotGate, + /// Fixture-only shorter reply window for a controlled real RPC timeout. + /// It never changes a command, deadline, cancellation, or protection rule. + #[cfg(feature = "workspace-io-fault")] + control_reply_timeout_override: Mutex>, /// Bounded `admission_replay` failures. Armed only after the original /// postcommit hook so precommit reads cannot consume them. #[cfg(test)] @@ -524,6 +579,9 @@ impl GatewayPersistence { pub(crate) fn pending_count_for_test(&self) -> usize { self.managed_pending.load(Ordering::Acquire) } + pub(crate) fn plugin_state_writers(&self) -> &crate::plugins::PluginStateWriters { + &self.plugin_state_writers + } pub(crate) fn maintenance_claim( &self, identity: crate::effects::ManagedWorkspaceIdentity, @@ -650,6 +708,7 @@ impl GatewayPersistence { payload, Some(cancellation), Some(claim), + None, ) } else { self.command_with_claim( @@ -657,7 +716,10 @@ impl GatewayPersistence { payload, Some(cancellation), Some(claim), - None, + StorageRequestOptions { + report_health: true, + ..StorageRequestOptions::default() + }, ) .map_err(|message| StorageError { code: "storage_unavailable".into(), @@ -734,7 +796,7 @@ impl GatewayPersistence { return Err("managed_restore_reconciliation_required".into()); } } - self.managed_effect_command_claim(op, payload, Some(cancellation), Some(claim)) + self.managed_effect_command_claim(op, payload, Some(cancellation), Some(claim), None) .map_err(|_| "managed_submitted_unknown".into()) } } @@ -765,10 +827,54 @@ struct StorageRequest { _managed_permit: Option>, maintenance: Option>, _maintenance_read: Option, + /// Strong Native reservation sidecar: keeps every unsettled object this + /// request references protected until the worker settles it. The caller + /// releases only its own reference; ownership is never handed off with a + /// flag and never rebuilt from JSON or guest ids. Pure identity, held + /// beside the VM/guest/grant state but never serialized into it. + state_owner: Option, + /// Whether this collect updates worker-ordered GC health. Explicit + /// collector invocations report; backpressure-path reclamation sweeps do + /// not clear debt on their own. + report_health: bool, #[cfg(test)] reply_block: Option>, } +/// Named aliases for the fixture observers/gates. Keeps the optional worker +/// and observer declarations readable without a broad `type_complexity` allow. +#[cfg(any(test, feature = "workspace-io-fault"))] +type StorageUnitHook = Arc; + +#[cfg(any(test, feature = "workspace-io-fault"))] +type StorageStringHook = Arc; + +#[cfg(any(test, feature = "workspace-io-fault"))] +type StorageBoolHook = Arc; + +/// Fixture-only observer of one worker `plugin.state_collect` snapshot: the +/// dirty generation the sweep covers. It reports the already-taken snapshot +/// value and can never change the snapshot, the payload, or any health state. +#[cfg(feature = "workspace-io-fault")] +type StorageGenerationHook = Arc; + +/// Named alias for the optional fixture-only worker execution gate slot. +#[cfg(feature = "workspace-io-fault")] +type StorageExecuteGate = Arc>>; + +/// Named alias for the optional fixture-only worker collect-snapshot slot. +#[cfg(feature = "workspace-io-fault")] +type StorageSnapshotGate = Arc>>; + +/// Optional per-request storage-command settings. Bundling these keeps the +/// internal claim/owner/report switches out of the positional signature. +#[derive(Default)] +struct StorageRequestOptions { + read_timeout: Option, + state_owner: Option, + report_health: bool, +} + /// The dedicated storage worker: owns the compiled RSS program and executes /// every command on its own thread. struct StorageWorker { @@ -776,7 +882,7 @@ struct StorageWorker { shutdown: Sender<()>, thread: std::sync::Mutex>>, closed: std::sync::atomic::AtomicBool, - #[cfg(test)] + #[cfg(any(test, feature = "workspace-io-fault"))] executed_commands: Arc, } @@ -784,7 +890,7 @@ struct StorageRunner { runner: AgentRunner, db_path: String, max_events: i64, - #[cfg(test)] + #[cfg(any(test, feature = "workspace-io-fault"))] executed_commands: Arc, } @@ -808,7 +914,7 @@ impl StorageRunner { runner, db_path, max_events: config.max_events_per_run as i64, - #[cfg(test)] + #[cfg(any(test, feature = "workspace-io-fault"))] executed_commands: Arc::default(), }) } @@ -824,7 +930,7 @@ impl StorageRunner { { return Err("managed storage original deadline/cancellation".into()); } - #[cfg(test)] + #[cfg(any(test, feature = "workspace-io-fault"))] self.executed_commands.fetch_add(1, Ordering::Relaxed); let input = VmValue::map(vec![ (VmValue::string("op"), VmValue::string(op)), @@ -885,12 +991,143 @@ fn storage_worker_loop( requests: Receiver, shutdown: Receiver<()>, _drop_next_managed_commit_ack: Arc, + state_candidates: Arc, + #[cfg(feature = "workspace-io-fault")] execute_gate: StorageExecuteGate, + #[cfg(feature = "workspace-io-fault")] collect_snapshot_gate: StorageSnapshotGate, ) { loop { match requests.recv_timeout(Duration::from_millis(200)) { Ok(mut request) => { + // Fixture-only observation of the exact worker execution + // boundary. Production builds never block here. + #[cfg(feature = "workspace-io-fault")] + if let Some(gate) = execute_gate + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + { + gate(&request.op); + } + // The caller health check is only early refusal, never an + // execution permit: every `plugin.state_object_put` re-applies + // the current worker-ordered health/dirty decision at this + // exact execution boundary, before any durable write. A caller + // that passed an older Healthy check cannot cross a newer + // Debt or a pending dirty generation. Refusal carries the + // exact typed code and never becomes durable. + if request.op == "plugin.state_object_put" { + let (health_state, pending) = state_candidates.admission_snapshot(); + if health_state != crate::plugins::CollectorHealthState::Healthy || pending { + let _ = request.respond.send(Ok(serde_json::json!({ + "ok": false, + "code": "plugin_state_unavailable", + "message": "state collection is not proven or has pending dirty objects; new objects are refused", + }))); + drop(request.state_owner.take()); + continue; + } + } + // The protected candidate set and the dirty generation are + // read together at this exact execution boundary under one + // registry lock. A caller-supplied snapshot can be stale; the + // worker order (register-before-put, FIFO) makes this pair + // authoritative. A later success only clears the generation + // observed here. + let mut collect_generation: Option = None; + if request.op == "plugin.state_collect" { + let (protected, dirty_generation) = state_candidates.snapshot_for_collect(); + collect_generation = Some(dirty_generation); + // Fixture-only observation of the covered generation. The + // snapshot is already taken; the observer only reads it. + #[cfg(feature = "workspace-io-fault")] + if let Some(observer) = collect_snapshot_gate + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + { + observer(dirty_generation); + } + match serde_json::to_string(&protected) { + Ok(protected_json) => { + request.payload["protected_ids_json"] = + Value::String(protected_json.clone()); + // Native budget on the injected payload, before any + // RSS/SQL runs: count, full wire bytes, and + // aggregate parameter-repeat weight. The RSS 1 MiB + // SQL parameter limit is not a substitute for this + // Native injection accounting. Overbudget is a typed + // fail-closed rejection: no SQL executes, the + // protected set stays readable, and GC health keeps + // debt until an explicit sweep recovers. + let wire_len = serde_json::to_vec(&request.payload) + .map(|wire| wire.len()) + .unwrap_or(usize::MAX); + let aggregate = protected_json + .len() + .saturating_mul(2) + .saturating_add(protected.len().saturating_mul(64)) + .saturating_add(wire_len); + #[cfg(any(test, feature = "workspace-io-fault"))] + state_candidates.report_collect_budget_inputs( + protected.len(), + wire_len, + aggregate, + ); + if protected.len() > crate::plugins::PLUGIN_STATE_MAX_RESERVATIONS + || wire_len > crate::effects::journal::METADATA_BYTES + || aggregate > crate::effects::journal::METADATA_BYTES + { + // Native overbudget is a real collector + // failure: keep worker-ordered Debt. No SQL + // executes and the protected set stays + // readable until an explicit sweep recovers. + state_candidates.record_collect_failure(); + let _ = request.respond.send(Ok(serde_json::json!({ + "ok": false, + "code": "plugin_state_budget_exceeded", + "message": "injected collector payload exceeds the Native budget", + }))); + continue; + } + } + Err(_) => { + // Fail closed: never fall back to an empty + // protection set. No SQL executes, and the + // unevaluated sweep is recorded as a real + // worker-ordered failure, never as success. + state_candidates.record_collect_failure(); + let _ = request.respond.send(Ok(serde_json::json!({ + "ok": false, + "code": "plugin_state_unavailable", + "message": "collector protection set is not encodable", + }))); + continue; + } + } + } let result = runner.command(&request.op, &request.payload, request.cancellation.as_ref()); + // Every collect outcome is recorded by this same worker in + // execution order. A typed `ok: false` envelope is a real + // failure even when the transport succeeded; `result.is_ok` + // alone must never mark it success. Quiet sweeps report + // failures too (a newer explicit success cannot be shadowed + // by an older quiet result because the worker order is total), + // while a quiet success never clears existing Debt: only an + // explicit successful sweep proves recovery. + if request.op == "plugin.state_collect" { + let succeeded = result + .as_ref() + .is_ok_and(|value| value.get("ok") == Some(&Value::Bool(true))); + if succeeded { + state_candidates.record_collect_success( + collect_generation.unwrap_or(0), + request.report_health, + ); + } else { + state_candidates.record_collect_failure(); + } + } #[cfg(test)] if let Some(block) = request.reply_block.take() { block.enter_and_wait(); @@ -930,6 +1167,11 @@ fn storage_worker_loop( continue; } let _ = request.respond.send(result); + // The request is settled: explicitly release the reservation + // sidecar now instead of at the loop's drop. Callers already + // released their own references; this was the last one the + // worker held for them. + drop(request.state_owner.take()); } Err(RecvTimeoutError::Timeout) => { if shutdown.try_recv().is_ok() { @@ -1089,7 +1331,7 @@ fn managed_parameter_bindings(op: &str) -> &'static [(&'static str, usize)] { ("branch_id", 2), ("commit_id", 8), ("receipt_id", 1), - ("prepared_json", 9), + ("prepared_json", 50), ("cut", 5), ("projections_json", 15), ], @@ -1497,15 +1739,37 @@ impl GatewayPersistence { metrics: Arc, ) -> Result { let runner = StorageRunner::open(config, path)?; - #[cfg(test)] + #[cfg(any(test, feature = "workspace-io-fault"))] let executed_commands = Arc::clone(&runner.executed_commands); let (sender, requests) = mpsc::channel::(); let (shutdown_sender, shutdown) = mpsc::channel::<()>(); let drop_next_managed_commit_ack = Arc::new(AtomicBool::new(false)); let worker_ack_fault = Arc::clone(&drop_next_managed_commit_ack); + let plugin_state_candidates = Arc::new(crate::plugins::PluginStateCandidates::default()); + let worker_state_candidates = Arc::clone(&plugin_state_candidates); + #[cfg(feature = "workspace-io-fault")] + let worker_execute_gate: StorageExecuteGate = Arc::new(Mutex::new(None)); + #[cfg(feature = "workspace-io-fault")] + let worker_execute_gate_for_thread = Arc::clone(&worker_execute_gate); + #[cfg(feature = "workspace-io-fault")] + let worker_collect_snapshot_gate: StorageSnapshotGate = Arc::new(Mutex::new(None)); + #[cfg(feature = "workspace-io-fault")] + let worker_collect_snapshot_gate_for_thread = Arc::clone(&worker_collect_snapshot_gate); let thread = std::thread::Builder::new() .name("gateway-storage-worker".to_string()) - .spawn(move || storage_worker_loop(runner, requests, shutdown, worker_ack_fault)) + .spawn(move || { + storage_worker_loop( + runner, + requests, + shutdown, + worker_ack_fault, + worker_state_candidates, + #[cfg(feature = "workspace-io-fault")] + worker_execute_gate_for_thread, + #[cfg(feature = "workspace-io-fault")] + worker_collect_snapshot_gate_for_thread, + ) + }) .map_err(|error| format!("spawn gateway storage worker: {error}"))?; Ok(Self { #[cfg(all(test, feature = "workspace-io-fault"))] @@ -1515,6 +1779,8 @@ impl GatewayPersistence { #[cfg(test)] maintenance_reply_block: Mutex::new(None), managed_pending: Arc::new(std::sync::atomic::AtomicUsize::new(0)), + plugin_state_writers: Arc::new(crate::plugins::PluginStateWriters::default()), + plugin_state_candidates, #[cfg(test)] managed_request_hook: Mutex::new(None), worker: StorageWorker { @@ -1522,7 +1788,7 @@ impl GatewayPersistence { shutdown: shutdown_sender, thread: std::sync::Mutex::new(Some(thread)), closed: std::sync::atomic::AtomicBool::new(false), - #[cfg(test)] + #[cfg(any(test, feature = "workspace-io-fault"))] executed_commands, }, max_events: config.max_events_per_run as i64, @@ -1544,12 +1810,32 @@ impl GatewayPersistence { admission_payload_transform: Mutex::new(None), #[cfg(test)] admission_result_observer: Mutex::new(None), - #[cfg(test)] + #[cfg(any(test, feature = "workspace-io-fault"))] admission_ack_loss: std::sync::atomic::AtomicBool::new(false), #[cfg(test)] admission_secondary_decode_loss: std::sync::atomic::AtomicBool::new(false), #[cfg(test)] admission_precommit_fail: std::sync::atomic::AtomicBool::new(false), + #[cfg(any(test, feature = "workspace-io-fault"))] + admission_state_fail: std::sync::atomic::AtomicBool::new(false), + #[cfg(any(test, feature = "workspace-io-fault"))] + plugin_state_collect_observer: Mutex::new(None), + #[cfg(any(test, feature = "workspace-io-fault"))] + plugin_state_collect_failpoint: std::sync::atomic::AtomicBool::new(false), + #[cfg(any(test, feature = "workspace-io-fault"))] + plugin_state_put_ack_loss: std::sync::atomic::AtomicBool::new(false), + #[cfg(feature = "workspace-io-fault")] + plugin_state_object_put_observer: Mutex::new(None), + #[cfg(feature = "workspace-io-fault")] + plugin_state_collect_result_observer: Mutex::new(None), + #[cfg(any(test, feature = "workspace-io-fault"))] + plugin_state_collect_enqueue_observer: Mutex::new(None), + #[cfg(feature = "workspace-io-fault")] + worker_execute_gate, + #[cfg(feature = "workspace-io-fault")] + worker_collect_snapshot_gate, + #[cfg(feature = "workspace-io-fault")] + control_reply_timeout_override: Mutex::new(None), #[cfg(test)] admission_replay_fail_remaining: Mutex::new(0), #[cfg(feature = "config-fixture")] @@ -1568,8 +1854,34 @@ impl GatewayPersistence { self.worker.shutdown(); } - #[cfg(test)] - pub(crate) fn executed_storage_commands(&self) -> usize { + /// Whether the storage worker thread can still execute queued requests. + /// A caller that timed out waiting for a collect reply must not mark + /// collection debt while the worker is alive: its request may still be + /// queued (parked or backlogged) and the worker records the real outcome + /// itself in execution order. Only a gone worker — whose queued requests + /// can never execute — lets a caller mark debt for an unknown outcome. + pub(crate) fn is_storage_worker_running(&self) -> bool { + if self + .worker + .closed + .load(std::sync::atomic::Ordering::Acquire) + { + return false; + } + !self + .worker + .thread + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .as_ref() + .is_some_and(|thread| thread.is_finished()) + } + + /// Number of real RSS storage commands executed by the dedicated worker. + /// It increments before the RSS program runs; a command rejected by a + /// caller-side budget never reaches this counter. + #[cfg(any(test, feature = "workspace-io-fault"))] + pub fn executed_storage_commands(&self) -> usize { self.worker.executed_commands.load(Ordering::Relaxed) } @@ -1603,7 +1915,57 @@ impl GatewayPersistence { payload: &Value, parent: Option<&crate::RunCancellation>, ) -> Result { - self.command_with_claim(op, payload, parent, None, None) + self.command_with_claim( + op, + payload, + parent, + None, + StorageRequestOptions { + report_health: true, + ..StorageRequestOptions::default() + }, + ) + } + fn command_with_cancellation_and_owner( + &self, + op: &str, + payload: &Value, + parent: Option<&crate::RunCancellation>, + state_owner: Option, + ) -> Result { + self.command_with_claim( + op, + payload, + parent, + None, + StorageRequestOptions { + state_owner, + report_health: true, + ..StorageRequestOptions::default() + }, + ) + } + fn command_with_owner( + &self, + op: &str, + payload: &Value, + state_owner: Option, + ) -> Result { + // Same finite context as `command`: independent trusted + // metadata/startup/maintenance operations own one; live effect callers + // use the explicit parent path. + let context = op.starts_with("effect.").then(managed_metadata_context); + self.command_with_claim( + op, + payload, + context.as_ref(), + None, + StorageRequestOptions { + state_owner, + report_health: true, + ..StorageRequestOptions::default() + }, + ) } fn command_with_claim( &self, @@ -1611,8 +1973,13 @@ impl GatewayPersistence { payload: &Value, parent: Option<&crate::RunCancellation>, claim: Option<&Arc>, - read_timeout: Option, + options: StorageRequestOptions, ) -> Result { + let StorageRequestOptions { + read_timeout, + state_owner, + report_health, + } = options; validate_graph_parameter_budget(op, payload)?; if op == "admission.create" { validate_composed_restore_budget(payload)?; @@ -1708,6 +2075,12 @@ impl GatewayPersistence { remaining.min(STORAGE_COMMAND_TIMEOUT) }) }; + #[cfg(feature = "workspace-io-fault")] + let response_timeout = self + .control_reply_timeout_override + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .map_or(response_timeout, |timeout| response_timeout.min(timeout)); #[cfg(test)] if let Some(cancel) = &cancellation { let hook = self @@ -1727,6 +2100,8 @@ impl GatewayPersistence { _managed_permit: managed_permit, maintenance: claim.cloned(), _maintenance_read: maintenance_read, + state_owner, + report_health, #[cfg(test)] reply_block: if claim.is_some() { self.maintenance_reply_block.lock().unwrap().take() @@ -1815,6 +2190,65 @@ impl GatewayPersistence { decode_command_data(result.get("data").cloned().unwrap_or(Value::Null)) } + fn command_data_with_owner( + &self, + op: &str, + payload: &Value, + state_owner: Option, + ) -> Result { + let result = self + .command_with_owner(op, payload, state_owner) + .map_err(|message| StorageError { + code: "storage_unavailable".to_string(), + message, + })?; + if result.get("ok") != Some(&Value::Bool(true)) { + return Err(StorageError { + code: result + .get("code") + .and_then(Value::as_str) + .unwrap_or("storage_error") + .to_string(), + message: result + .get("message") + .and_then(Value::as_str) + .unwrap_or("storage command failed") + .to_string(), + }); + } + decode_command_data(result.get("data").cloned().unwrap_or(Value::Null)) + } + + fn command_data_with_cancellation_and_owner( + &self, + op: &str, + payload: &Value, + cancellation: &crate::RunCancellation, + state_owner: Option, + ) -> Result { + let result = self + .command_with_cancellation_and_owner(op, payload, Some(cancellation), state_owner) + .map_err(|message| StorageError { + code: "storage_unavailable".into(), + message, + })?; + if result.get("ok") != Some(&Value::Bool(true)) { + return Err(StorageError { + code: result + .get("code") + .and_then(Value::as_str) + .unwrap_or("storage_error") + .into(), + message: result + .get("message") + .and_then(Value::as_str) + .unwrap_or("storage command failed") + .into(), + }); + } + decode_command_data(result.get("data").cloned().unwrap_or(Value::Null)) + } + /// Private original-owner proof read. `Some(until)` shares one window of /// the original remaining deadline, capped by the existing command timeout. /// `None` is an already-expired confirmation: the existing command timeout @@ -1839,7 +2273,17 @@ impl GatewayPersistence { None }; let result = self - .command_with_claim(op, payload, None, None, read_timeout) + .command_with_claim( + op, + payload, + None, + None, + StorageRequestOptions { + read_timeout, + report_health: true, + ..StorageRequestOptions::default() + }, + ) .map_err(|message| StorageError { code: "storage_unavailable".into(), message, @@ -1889,6 +2333,15 @@ impl GatewayPersistence { ) -> Result { self.managed_effect_command_inner(op, payload, Some(cancellation)) } + pub(crate) fn managed_effect_command_with_cancellation_and_owner( + &self, + op: &str, + payload: &Value, + cancellation: &crate::RunCancellation, + state_owner: Option, + ) -> Result { + self.managed_effect_command_inner_with_owner(op, payload, Some(cancellation), state_owner) + } pub(crate) fn managed_effect_command( &self, op: &str, @@ -1903,7 +2356,16 @@ impl GatewayPersistence { payload: &Value, cancellation: Option<&crate::RunCancellation>, ) -> Result { - self.managed_effect_command_claim(op, payload, cancellation, None) + self.managed_effect_command_claim(op, payload, cancellation, None, None) + } + fn managed_effect_command_inner_with_owner( + &self, + op: &str, + payload: &Value, + cancellation: Option<&crate::RunCancellation>, + state_owner: Option, + ) -> Result { + self.managed_effect_command_claim(op, payload, cancellation, None, state_owner) } fn managed_effect_command_claim( &self, @@ -1911,6 +2373,7 @@ impl GatewayPersistence { payload: &Value, cancellation: Option<&crate::RunCancellation>, claim: Option<&Arc>, + state_owner: Option, ) -> Result { use crate::effects::journal::{METADATA_BYTES, storage_error}; const OPS: &[&str] = &[ @@ -2003,16 +2466,26 @@ impl GatewayPersistence { } if let Some(claim) = claim { let result = self - .command_with_claim(op, &wire, cancellation, Some(claim), None) + .command_with_claim( + op, + &wire, + cancellation, + Some(claim), + StorageRequestOptions { + state_owner, + report_health: true, + ..StorageRequestOptions::default() + }, + ) .map_err(|message| StorageError { code: "managed_submitted_unknown".into(), message, })?; maintenance_data(result) } else if let Some(cancellation) = cancellation { - self.command_data_with_cancellation(op, &wire, cancellation) + self.command_data_with_cancellation_and_owner(op, &wire, cancellation, state_owner) } else { - self.command_data(op, &wire) + self.command_data_with_owner(op, &wire, state_owner) } } @@ -2023,7 +2496,7 @@ impl GatewayPersistence { cancellation: &crate::RunCancellation, claim: Option<&Arc>, ) -> Result { - self.managed_effect_command_claim(op, payload, Some(cancellation), claim) + self.managed_effect_command_claim(op, payload, Some(cancellation), claim, None) } /// Trusted SDK metadata lookup. IDs never create workspace authority. @@ -2427,6 +2900,21 @@ impl GatewayPersistence { "plugin.collect", "plugin.graph_revoke", "plugin.graph_revocation_get", + "plugin.state_object_put", + "plugin.state_root_put", + "plugin.state_root_get", + "plugin.state_bind_run", + "plugin.state_binding_get", + "plugin.state_binding_advance", + "plugin.state_roots_for_checkpoint", + "plugin.state_session_nonterminal", + "plugin.state_session_max_schema", + "plugin.state_commit", + "plugin.state_authorize", + "plugin.state_session_key", + "plugin.state_session_key_for", + "plugin.state_object_get", + "plugin.state_collect", ]; if !OPS.contains(&op) { return Err(StorageError { @@ -2434,7 +2922,121 @@ impl GatewayPersistence { message: "plugin control operation is not registered".to_string(), }); } - self.command_data(op, payload) + self.plugin_state_collect_or_control(op, payload, None) + } + + pub(crate) fn plugin_control_command_with_owner( + &self, + op: &str, + payload: &Value, + state_owner: Option, + ) -> Result { + const OPS: &[&str] = &[ + "plugin.lock_put", + "plugin.harness_binding_matches", + "plugin.lock_get", + "plugin.lock_list", + "plugin.ref_list", + "plugin.activate", + "plugin.activation_get", + "plugin.activation_history", + "plugin.retain", + "plugin.retention_get", + "plugin.release", + "plugin.pin_for_owner", + "plugin.pin_requirement_get", + "plugin.collect", + "plugin.graph_revoke", + "plugin.graph_revocation_get", + "plugin.state_object_put", + "plugin.state_root_put", + "plugin.state_root_get", + "plugin.state_bind_run", + "plugin.state_binding_get", + "plugin.state_binding_advance", + "plugin.state_roots_for_checkpoint", + "plugin.state_session_nonterminal", + "plugin.state_session_max_schema", + "plugin.state_commit", + "plugin.state_authorize", + "plugin.state_session_key", + "plugin.state_session_key_for", + "plugin.state_object_get", + "plugin.state_collect", + ]; + if !OPS.contains(&op) { + return Err(StorageError { + code: "plugin_control_unavailable".to_string(), + message: "plugin control operation is not registered".to_string(), + }); + } + self.plugin_state_collect_or_control(op, payload, state_owner) + } + + fn plugin_state_collect_or_control( + &self, + op: &str, + payload: &Value, + state_owner: Option, + ) -> Result { + if op == "plugin.state_collect" { + return self.plugin_state_collect_command(payload, true); + } + let result = self.command_data_with_owner(op, payload, state_owner); + #[cfg(any(test, feature = "workspace-io-fault"))] + if op == "plugin.state_object_put" + && result.is_ok() + && self + .plugin_state_put_ack_loss + .swap(false, std::sync::atomic::Ordering::AcqRel) + { + return Err(StorageError { + code: "storage_unavailable".to_string(), + message: "gateway storage worker exited unexpectedly".to_string(), + }); + } + result + } + + /// Typed `plugin.state_collect` with an explicit health-report flag. Only + /// explicit collector invocations report; backpressure-path reclamation + /// passes `false` so it never clears debt on its own. + pub(crate) fn plugin_state_collect_command( + &self, + payload: &Value, + report_health: bool, + ) -> Result { + const OP: &str = "plugin.state_collect"; + let result = self + .command_with_claim( + OP, + payload, + None, + None, + StorageRequestOptions { + report_health, + ..StorageRequestOptions::default() + }, + ) + .map_err(|message| StorageError { + code: "storage_unavailable".to_string(), + message, + })?; + if result.get("ok") != Some(&Value::Bool(true)) { + return Err(StorageError { + code: result + .get("code") + .and_then(Value::as_str) + .unwrap_or("storage_error") + .to_string(), + message: result + .get("message") + .and_then(Value::as_str) + .unwrap_or("storage command failed") + .to_string(), + }); + } + decode_command_data(result.get("data").cloned().unwrap_or(Value::Null)) } pub(crate) fn plugin_control_command_for_proof( @@ -2466,6 +3068,18 @@ impl GatewayPersistence { /// retention floor commit in a single transaction. The returned data /// carries `replayed`, `run`, `session`, `message`, and `idempotency`. pub fn admission_create(&self, payload: &Value) -> Result { + self.admission_create_with_owner(payload, None) + } + + /// Admission carrying the unsettled migration candidate owner. The + /// reservation sidecar travels with the request until the worker settles + /// it, so a caller timeout cannot unprotect an admission that is still + /// queued or executing behind an earlier collector. + pub(crate) fn admission_create_with_owner( + &self, + payload: &Value, + state_owner: Option, + ) -> Result { let hook = self .admission_precommit_hook .lock() @@ -2499,7 +3113,19 @@ impl GatewayPersistence { let command_payload = transformed.as_ref().unwrap_or(payload); #[cfg(not(test))] let command_payload = payload; - let result = self.command_data("admission.create", command_payload); + #[cfg(any(test, feature = "workspace-io-fault"))] + let state_fail_payload = self + .admission_state_fail + .swap(false, std::sync::atomic::Ordering::AcqRel) + .then(|| { + let mut shadow = payload.clone(); + shadow["plugin_state_failpoint"] = json!("after_root_cas"); + shadow + }); + #[cfg(not(any(test, feature = "workspace-io-fault")))] + let state_fail_payload: Option = None; + let command_payload = state_fail_payload.as_ref().unwrap_or(command_payload); + let result = self.command_data_with_owner("admission.create", command_payload, state_owner); #[cfg(test)] { let observer = self @@ -2528,7 +3154,7 @@ impl GatewayPersistence { hook(payload); } } - #[cfg(test)] + #[cfg(any(test, feature = "workspace-io-fault"))] if result.as_ref().is_ok() && self .admission_ack_loss @@ -2556,8 +3182,8 @@ impl GatewayPersistence { /// Drops one successful admission command ack after the real transaction /// and postcommit hook. The returned error is the existing response-loss /// code. The original payload and transaction are unchanged. - #[cfg(test)] - pub(crate) fn inject_admission_ack_loss(&self) { + #[cfg(any(test, feature = "workspace-io-fault"))] + pub fn inject_admission_ack_loss(&self) { self.admission_ack_loss .store(true, std::sync::atomic::Ordering::Release); } @@ -2587,6 +3213,190 @@ impl GatewayPersistence { .expect("admission hook") = Some(Box::new(hook)); } + /// Fixture-only one-shot failure after the prepared state root CAS inside + /// the durable admission transaction. No authority or storage handle is + /// exposed; the transaction is the real guarded admission command. + #[cfg(any(test, feature = "workspace-io-fault"))] + pub fn inject_admission_state_fail(&self) { + self.admission_state_fail + .store(true, std::sync::atomic::Ordering::Release); + } + + /// Fixture-only one-shot loss of a successful `plugin.state_object_put` + /// reply after the real durable object transaction. + #[cfg(any(test, feature = "workspace-io-fault"))] + pub fn inject_plugin_state_put_ack_loss(&self) { + self.plugin_state_put_ack_loss + .store(true, std::sync::atomic::Ordering::Release); + } + + /// Fixture-only one-shot real SQL failpoint for the typed state collector. + #[cfg(any(test, feature = "workspace-io-fault"))] + pub fn inject_plugin_state_collect_failpoint(&self) { + self.plugin_state_collect_failpoint + .store(true, std::sync::atomic::Ordering::Release); + } + + #[cfg(any(test, feature = "workspace-io-fault"))] + pub(crate) fn take_plugin_state_collect_failpoint(&self) -> bool { + self.plugin_state_collect_failpoint + .swap(false, std::sync::atomic::Ordering::AcqRel) + } + + #[cfg(any(test, feature = "workspace-io-fault"))] + pub(crate) fn plugin_state_collect_observer_hook(&self) -> Option { + self.plugin_state_collect_observer + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + } + + /// Fixture-only observer immediately before the caller issues the typed + /// state collection command. It never changes the protected set or SQL. + #[cfg(any(test, feature = "workspace-io-fault"))] + pub fn inject_plugin_state_collect_observer(&self, observer: StorageUnitHook) { + *self + .plugin_state_collect_observer + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(observer); + } + + /// Fixture-only observer of candidate reservations established before the + /// durable put. It never changes the producer. + #[cfg(any(test, feature = "workspace-io-fault"))] + pub fn observe_plugin_state_candidate_reservations(&self, observer: StorageStringHook) { + self.plugin_state_candidates.set_observer(observer); + } + + /// Fixture-only blocking observer after a real `plugin.state_object_put` + /// became durable and before its producer returns. It does not alter the + /// put, the candidate registry, or any collector input. + #[cfg(feature = "workspace-io-fault")] + pub fn inject_plugin_state_object_put_observer(&self, observer: StorageStringHook) { + *self + .plugin_state_object_put_observer + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(observer); + } + + #[cfg(feature = "workspace-io-fault")] + pub(crate) fn plugin_state_object_put_observer_hook(&self) -> Option { + self.plugin_state_object_put_observer + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + } + + /// Fixture-only blocking observer after the typed collector command + /// returned and before the caller records collection health. + #[cfg(feature = "workspace-io-fault")] + pub fn inject_plugin_state_collect_result_observer(&self, observer: StorageBoolHook) { + *self + .plugin_state_collect_result_observer + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(observer); + } + + #[cfg(feature = "workspace-io-fault")] + pub(crate) fn plugin_state_collect_result_observer_hook(&self) -> Option { + self.plugin_state_collect_result_observer + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + } + + /// Fixture-only observer at the caller's collector enqueue seam, after the + /// epoch snapshot and before the typed request. It reports whether the + /// collect reports health (explicit) or not (backpressure-path quiet) and + /// changes no command, epoch, or health state. + #[cfg(any(test, feature = "workspace-io-fault"))] + pub fn inject_plugin_state_collect_enqueue_observer(&self, observer: StorageBoolHook) { + *self + .plugin_state_collect_enqueue_observer + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(observer); + } + + #[cfg(any(test, feature = "workspace-io-fault"))] + pub(crate) fn plugin_state_collect_enqueue_observer_hook(&self) -> Option { + self.plugin_state_collect_enqueue_observer + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + } + + /// Fixture-only observation of the collector injection budget inputs + /// (protected count, full wire bytes, aggregate weight). The values are + /// computed by the existing worker code; the observer cannot change the + /// fail-closed decision or the payload. + #[cfg(any(test, feature = "workspace-io-fault"))] + pub fn observe_plugin_state_collect_budget( + &self, + observer: Arc, + ) { + self.plugin_state_candidates.set_budget_observer(observer); + } + + /// Fixture-only blocking hook at the storage worker's execution boundary. + /// Armed only after the requests under test are already queued in the + /// controlled order. + #[cfg(feature = "workspace-io-fault")] + pub fn inject_worker_execute_gate(&self, gate: StorageStringHook) { + *self + .worker_execute_gate + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(gate); + } + + #[cfg(feature = "workspace-io-fault")] + pub fn clear_worker_execute_gate(&self) { + *self + .worker_execute_gate + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; + } + + /// Fixture-only blocking observer right after the worker takes one + /// `plugin.state_collect` snapshot and before its RSS/SQL runs. The + /// reported generation is the sweep's covered generation; the observer + /// can park the worker there but never changes the snapshot, the + /// payload, or any health state. Armed only while the sweep under test + /// is already queued. + #[cfg(feature = "workspace-io-fault")] + pub fn inject_plugin_state_collect_snapshot_observer(&self, observer: StorageGenerationHook) { + *self + .worker_collect_snapshot_gate + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(observer); + } + + #[cfg(feature = "workspace-io-fault")] + pub fn clear_plugin_state_collect_snapshot_observer(&self) { + *self + .worker_collect_snapshot_gate + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; + } + + /// Fixture-only shorter real RPC reply window. The queued worker request + /// and its durable effect are unchanged; only how long a caller waits for + /// the reply is bounded below the normal storage timeout. + #[cfg(feature = "workspace-io-fault")] + pub fn inject_control_reply_timeout_for_test(&self, timeout: Duration) { + *self + .control_reply_timeout_override + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(timeout); + } + + #[cfg(feature = "workspace-io-fault")] + pub fn clear_control_reply_timeout_for_test(&self) { + *self + .control_reply_timeout_override + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; + } + /// Reads one durable admission identity without mutating repository state. pub fn admission_replay( &self, diff --git a/src/plugins/manifest.rs b/src/plugins/manifest.rs index 9b49807..440b9cb 100644 --- a/src/plugins/manifest.rs +++ b/src/plugins/manifest.rs @@ -49,10 +49,16 @@ pub struct PluginEntry { pub export: String, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StateMigration { + pub entry: String, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct StateDeclaration { pub scope: String, pub schema: u64, + pub migration: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -145,10 +151,19 @@ pub fn parse_plugin_manifest(text: &str) -> Result .get("capabilities") .ok_or_else(|| PluginError::new(PluginErrorKind::UnknownField, "capabilities"))?, )?; + let state = match object.get("state") { + Some(value) => Some(parse_state(value)?), + None => None, + }; let effects = parse_effects(object.get("effects").unwrap(), &entry.export, &dependencies)?; - if effects.entries.len() != 1 + let migration_entries = usize::from( + state + .as_ref() + .is_some_and(|state| state.migration.is_some()), + ); + if effects.entries.len() != 1 + migration_entries && (kind != "library" - || effects.entries.len() != 3 + || effects.entries.len() != 3 + migration_entries || !dependencies.is_empty() || !capabilities.is_empty() || object.contains_key("state") @@ -162,10 +177,18 @@ pub fn parse_plugin_manifest(text: &str) -> Result "entry cardinality", )); } - let state = match object.get("state") { - Some(value) => Some(parse_state(value)?), - None => None, - }; + if let Some(migration) = state.as_ref().and_then(|state| state.migration.as_ref()) { + let declared = effects + .entries + .get(&migration.entry) + .ok_or_else(|| PluginError::new(PluginErrorKind::UncoveredExport, &migration.entry))?; + if !declared.direct.is_empty() || !declared.delegated.is_empty() { + return Err(PluginError::new( + PluginErrorKind::UncoveredExport, + "state migration must be pure", + )); + } + } let required = required_capabilities(&effects); if capabilities.iter().collect::>() != required.iter().collect::>() { return Err(PluginError::new( @@ -402,9 +425,9 @@ fn parse_state(value: &Value) -> Result { let object = value .as_object() .ok_or_else(|| PluginError::new(PluginErrorKind::UnknownField, "state"))?; - reject_unknown(object, &["scope", "schema"], "state")?; + reject_unknown_optional(object, &["scope", "schema", "migration"], "state")?; let scope = string_field(object, "scope")?; - if scope != "run" { + if scope != "run" && scope != "session" && scope != "invocation" { return Err(PluginError::new( PluginErrorKind::UnknownField, "state.scope", @@ -414,13 +437,44 @@ fn parse_state(value: &Value) -> Result { .get("schema") .and_then(Value::as_u64) .ok_or_else(|| PluginError::new(PluginErrorKind::UnknownField, "state.schema"))?; - if schema < 1 { + if !(1..=64).contains(&schema) { return Err(PluginError::new( PluginErrorKind::UnknownField, "state.schema", )); } - Ok(StateDeclaration { scope, schema }) + let migration = match object.get("migration") { + None => None, + Some(value) => { + let migration = value.as_object().ok_or_else(|| { + PluginError::new(PluginErrorKind::UnknownField, "state.migration") + })?; + reject_unknown(migration, &["entry"], "state.migration")?; + let entry = string_field(migration, "entry")?; + if schema <= 1 || !valid_export_name(&entry) { + return Err(PluginError::new( + PluginErrorKind::UnknownField, + "state.migration.entry", + )); + } + Some(StateMigration { entry }) + } + }; + Ok(StateDeclaration { + scope, + schema, + migration, + }) +} + +fn valid_export_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 128 + && name.bytes().enumerate().all(|(index, byte)| match byte { + b'a'..=b'z' | b'A'..=b'Z' | b'_' => true, + b'0'..=b'9' => index > 0, + _ => false, + }) } fn require( @@ -444,6 +498,22 @@ fn string_field(object: &serde_json::Map, key: &str) -> Result, + allowed: &[&str], + label: &str, +) -> Result<(), PluginError> { + for key in object.keys() { + if !allowed.contains(&key.as_str()) { + return Err(PluginError::new( + PluginErrorKind::UnknownField, + format!("{label}.{key}"), + )); + } + } + Ok(()) +} + fn reject_unknown( object: &serde_json::Map, allowed: &[&str], diff --git a/src/plugins/mod.rs b/src/plugins/mod.rs index 9a98500..872eb10 100644 --- a/src/plugins/mod.rs +++ b/src/plugins/mod.rs @@ -10,6 +10,7 @@ mod pin; mod prepare; mod resolve; mod schema; +mod state; mod store; mod yaml; @@ -23,14 +24,14 @@ pub use error::{ COMPILE_ENVIRONMENT, EFFECT_KINDS, EFFECT_RECOVERY, GRAPH_LOCK_FIELDS, HOST_RUNTIME, MANIFEST_FIELDS, PLUGIN_KINDS, PluginError, PluginErrorKind, }; -pub use manifest::parse_plugin_manifest; +pub use manifest::{StateDeclaration, StateMigration, parse_plugin_manifest}; pub use pin::{ PinnedAdmitContext, PinnedToolAdmission, PinnedToolAdmitError, admit_pinned_tool_run, restore_owned_pin, }; pub(crate) use pin::{ - prepare_checkpoint_pin, prepare_production_pin, restore_owned_pin_with_cancellation, - seal_production_receipt, validate_owned_harness_fact, + PreparedProductionPin, prepare_checkpoint_pin, prepare_production_pin, + restore_owned_pin_with_cancellation, seal_production_receipt, validate_owned_harness_fact, }; pub(crate) use prepare::SupportedManagedTopology; pub use resolve::{ @@ -44,6 +45,16 @@ pub use schema::{canonical_schema_documents, schema_snapshot_drift}; pub(crate) use schema::{ validate_managed_effect, validate_managed_maintenance, validate_managed_workspace, }; +pub(crate) use state::{ + CollectorHealthState, PLUGIN_STATE_MAX_RESERVATIONS, PluginStateCandidateLease, + PluginStateCandidates, PluginStateWriterLease, PluginStateWriters, +}; +pub use state::{ + PluginStateBindRequest, PluginStateBinding, PluginStateError, PluginStateMigrationProgram, + PluginStateObject, PluginStatePayload, PluginStateRestoreRequest, PluginStateRoot, + PluginStateRootKey, PluginStateRootRef, PluginStateScope, PluginStateSnapshot, + PluginStateStore, STATE_DOCUMENT_MAX_BYTES, validate_state_migration_source, +}; pub(crate) use store::embedding_payload_bytes; pub use store::{ InstallAuthorization, InstalledPackage, PackageRevision, PackageStore, PackageTrust, diff --git a/src/plugins/pin.rs b/src/plugins/pin.rs index 275dbbe..e5531a8 100644 --- a/src/plugins/pin.rs +++ b/src/plugins/pin.rs @@ -416,6 +416,7 @@ struct CapturedTool { harness_selection: super::resolve::HarnessSelection, managed_policy_catalog: Option>, managed_contract: Option, + state: Option, program: Arc, invoke_entry: String, export: String, @@ -454,6 +455,7 @@ fn capture_tool( harness_selection: prepared.harness_selection.clone(), managed_contract: prepared.managed_contract(&package.id), managed_policy_catalog: prepared.managed_policy_catalog(&package.id), + state: prepared.state_declaration(&package.id), registry: prepared .registry(&package.id) .ok_or_else(|| PinnedToolAdmitError::new("prepared graph dropped tool registry"))?, @@ -471,6 +473,7 @@ fn binding_from_capture(captured: CapturedTool, receipt: PinnedToolReceipt) -> P harness_selection: captured.harness_selection, managed_contract: captured.managed_contract, managed_policy_catalog: captured.managed_policy_catalog, + state: captured.state, program: captured.program, entry: captured.invoke_entry, package_id: captured.package_id.clone(), @@ -500,13 +503,33 @@ pub(crate) struct PreparedProductionPin { pub(crate) graph_id: String, pub(crate) generation: u64, pub(crate) ref_id: String, + pub(crate) checkpoint_id: Option, + pub(crate) checkpoint_state_roots: Vec, captured: CapturedTool, + migration: Option>, } impl PreparedProductionPin { pub(crate) fn harness_selection(&self) -> &super::resolve::HarnessSelection { &self.captured.harness_selection } + pub(crate) fn state(&self) -> Option<&crate::plugins::StateDeclaration> { + self.captured.state.as_ref() + } + pub(crate) fn migration(&self) -> Option> { + self.migration.clone() + } + /// A checkpoint pin restores exact captured roots; it never prepares a + /// schema migration against the live branch. + pub(crate) fn has_checkpoint_state(&self) -> bool { + !self.checkpoint_state_roots.is_empty() + } + pub(crate) fn package_id(&self) -> &str { + &self.captured.package_id + } + pub(crate) fn artifact_id(&self) -> &str { + &self.captured.artifact_id + } pub(crate) fn managed_policy_catalog( &self, ) -> Option> { @@ -592,6 +615,40 @@ pub(crate) fn prepare_checkpoint_pin( } use crate::effects::journal::ManagedCheckpointStateCoverage as Coverage; let covered = match (&loaded.lock.harness_selection, &checkpoint.plugin_state) { + ( + _, + Coverage::Stateful { + graph_id, + package_id, + artifact_id, + manifest_state_present, + invocation_excluded_from_checkpoint, + roots, + }, + ) => { + graph_id == &graph.graph_id + && package_id == &package.id + && artifact_id == &package.artifact_id + && *manifest_state_present + && if *invocation_excluded_from_checkpoint { + roots.is_empty() + } else { + !roots.is_empty() + } + && roots.iter().all(|root| { + root.package_id == package.id + && root.artifact_id == package.artifact_id + && root.plugin_id == package.id + && root.owner_kind == root.scope + && matches!(root.owner_kind.as_str(), "run" | "session") + && root + .schema + .parse::() + .is_ok_and(|schema| (1..=64).contains(&schema)) + && root.revision >= 1 + && !root.immutable_root_id.is_empty() + }) + } ( super::HarnessSelection::ToolOnly { .. }, Coverage::StatelessBuiltin { @@ -647,12 +704,24 @@ pub(crate) fn prepare_checkpoint_pin( } // A compiled capture does not replace the checkpoint package's required // installed artifact on a fresh restore admission. - store - .read_installed(&package.artifact_id) - .map_err(|_| PinnedToolAdmitError { - code: "plugin_artifact_unavailable", - message: "plugin artifact is unavailable".into(), - })?; + let installed = + store + .read_installed(&package.artifact_id) + .map_err(|_| PinnedToolAdmitError { + code: "plugin_artifact_unavailable", + message: "plugin artifact is unavailable".into(), + })?; + if let Coverage::Stateful { roots, .. } = &checkpoint.plugin_state { + let state = installed + .manifest + .state + .as_ref() + .ok_or_else(|| PinnedToolAdmitError::new("plugin_state_root_mismatch"))?; + let schema = state.schema.to_string(); + if roots.iter().any(|root| root.schema != schema) { + return Err(PinnedToolAdmitError::new("plugin_state_root_mismatch")); + } + } let prepared = store .prepare_harness_graph(&loaded.lock, cancellation) .map_err(PinnedToolAdmitError::plugin)?; @@ -660,11 +729,23 @@ pub(crate) fn prepare_checkpoint_pin( if captured.export != graph.export { return Err(PinnedToolAdmitError::new("checkpoint export mismatch")); } + let checkpoint_state_roots = match &checkpoint.plugin_state { + Coverage::Stateful { roots, .. } => roots.clone(), + _ => Vec::new(), + }; + let checkpoint_id = if checkpoint_state_roots.is_empty() { + None + } else { + Some(checkpoint.checkpoint_id.clone()) + }; Ok(PreparedProductionPin { graph_id: graph.graph_id.clone(), generation: graph.captured_activation_generation, ref_id: Uuid::new_v4().to_string(), + checkpoint_id, + checkpoint_state_roots, captured, + migration: None, }) } @@ -699,11 +780,15 @@ pub(crate) fn prepare_production_pin( .prepare_harness_graph(&lock, cancellation) .map_err(PinnedToolAdmitError::plugin)?; let captured = capture_tool(&prepared, package)?; + let migration = prepared.state_migration(&package.id); Ok(PreparedProductionPin { graph_id: active.graph_id.clone(), generation: active.generation, ref_id: Uuid::new_v4().to_string(), + checkpoint_id: None, + checkpoint_state_roots: Vec::new(), captured, + migration, }) } @@ -712,6 +797,7 @@ pub(crate) fn seal_production_receipt( receipt: &Value, registry_identity: String, workspace: PathBuf, + state_store: Option, ) -> Result { let pin = receipt .get("pin") @@ -746,7 +832,9 @@ pub(crate) fn seal_production_receipt( "admission pin Native context does not match its own receipt", )); } - Ok(binding_from_capture( + let checkpoint_state_roots = prepared.checkpoint_state_roots.clone(); + let checkpoint_id = prepared.checkpoint_id.clone(); + let mut pin = binding_from_capture( prepared.captured, PinnedToolReceipt { graph_id: prepared.graph_id, @@ -759,7 +847,88 @@ pub(crate) fn seal_production_receipt( workspace, }, ) - .program_handle()) + .program_handle(); + if let Some(state) = pin.state.clone() { + let store = + state_store.ok_or_else(|| PinnedToolAdmitError::new("plugin_state_binding_missing"))?; + let scope = crate::plugins::PluginStateScope::parse(&state.scope) + .ok_or_else(|| PinnedToolAdmitError::new("plugin_state_invalid"))?; + let schema = u32::try_from(state.schema) + .map_err(|_| PinnedToolAdmitError::new("plugin_state_invalid"))?; + if scope == crate::plugins::PluginStateScope::Invocation { + pin.state_store = Some(store); + return Ok(pin); + } + let (workspace_id, branch_id, epoch) = state_workspace_identity(&pin, &store); + let binding = if checkpoint_state_roots.is_empty() { + store.bind_run(&crate::plugins::PluginStateBindRequest { + run_id: pin.run_id.clone(), + session_id: pin.session_id.clone(), + workspace_id, + branch_id, + plugin_id: pin.package_id.clone(), + package_id: pin.package_id.clone(), + artifact_id: pin.artifact_id.clone(), + scope, + graph_id: pin.graph_id.clone(), + epoch, + schema, + checkpoint_id: String::new(), + }) + } else { + let roots = checkpoint_state_roots + .iter() + .map(|root| { + Ok(crate::plugins::PluginStateRootRef { + owner_kind: root.owner_kind.clone(), + owner_id: root.owner_id.clone(), + workspace_id: root.workspace_id.clone(), + branch_id: root.branch_id.clone(), + plugin_id: root.plugin_id.clone(), + package_id: root.package_id.clone(), + artifact_id: root.artifact_id.clone(), + schema: root + .schema + .parse::() + .map_err(|_| PinnedToolAdmitError::new("plugin_state_root_mismatch"))?, + revision: root.revision, + object_id: root.immutable_root_id.clone(), + }) + }) + .collect::, PinnedToolAdmitError>>()?; + store.restore_binding(&crate::plugins::PluginStateRestoreRequest { + scope, + run_id: pin.run_id.clone(), + session_id: pin.session_id.clone(), + workspace_id, + branch_id, + plugin_id: pin.package_id.clone(), + package_id: pin.package_id.clone(), + artifact_id: pin.artifact_id.clone(), + graph_id: pin.graph_id.clone(), + epoch, + checkpoint_id: checkpoint_id.clone().unwrap_or_default(), + roots, + }) + } + .map_err(|error| PinnedToolAdmitError::new(error.message))?; + pin.state_binding = Some(binding.binding); + pin.state_store = Some(store); + } + Ok(pin) +} + +fn state_workspace_identity( + pin: &crate::runtime::agent_host::PinnedToolProgram, + store: &crate::plugins::PluginStateStore, +) -> (String, String, u64) { + store.managed_run_identity(&pin.run_id).unwrap_or_else(|| { + ( + pin.workspace.to_string_lossy().to_string(), + "default".to_string(), + 0, + ) + }) } pub fn restore_owned_pin( diff --git a/src/plugins/prepare.rs b/src/plugins/prepare.rs index 59d5aee..065d1e2 100644 --- a/src/plugins/prepare.rs +++ b/src/plugins/prepare.rs @@ -38,7 +38,21 @@ impl SupportedManagedTopology { HarnessSelection::ToolOnly { tool_package } => { tool_package == &tool.manifest.id && lock.packages.len() == 1 - && lock.closure.len() == 1 + && lock + .closure + .iter() + .all(|item| item.package == tool.manifest.id) + && lock + .closure + .iter() + .filter(|item| item.entry == tool.manifest.entry.export) + .count() + == 1 + && lock + .closure + .iter() + .filter(|item| item.entry != tool.manifest.entry.export) + .all(|item| item.effects.is_empty()) && (embedding || catalog_export_paths(tool).len() == 1) } HarnessSelection::ContextPrompt { @@ -90,6 +104,8 @@ pub struct PreparedGraph { managed_contracts: BTreeMap, managed_policy_catalogs: BTreeMap>, + state_migrations: BTreeMap>, + state_declarations: BTreeMap, programs: BTreeMap>, // Prepared with the captured code, never with a run or capability owner. registries: BTreeMap, String>>, @@ -153,6 +169,21 @@ impl PreparedGraph { pub fn program(&self, package_id: &str) -> Option> { self.programs.get(package_id).cloned() } + + /// Compiled pure migration entry for a stateful package, if declared. + pub fn state_migration( + &self, + package_id: &str, + ) -> Option> { + self.state_migrations.get(package_id).cloned() + } + + pub(crate) fn state_declaration( + &self, + package_id: &str, + ) -> Option { + self.state_declarations.get(package_id).cloned() + } } impl PackageStore { @@ -284,6 +315,8 @@ impl PackageStore { trust: BTreeMap::new(), managed_contracts: BTreeMap::new(), managed_policy_catalogs: BTreeMap::new(), + state_migrations: BTreeMap::new(), + state_declarations: BTreeMap::new(), programs: BTreeMap::new(), registries: BTreeMap::new(), }; @@ -333,6 +366,16 @@ impl PackageStore { prepared .trust .insert(package.manifest.id.clone(), package.trust); + if let Some(migration) = capture_state_migration_program(package, lock, cancel)? { + prepared + .state_migrations + .insert(package.manifest.id.clone(), migration); + } + if let Some(state) = package.manifest.state.as_ref() { + prepared + .state_declarations + .insert(package.manifest.id.clone(), state.clone()); + } prepared.exports.insert( package.manifest.id.clone(), package.manifest.entry.export.clone(), @@ -504,27 +547,54 @@ const MANAGED_CATALOG_EXPORT: &str = "managed_authority_catalog"; fn signed_stateless_tool_only(lock: &GraphLock, package: &InstalledPackage) -> bool { use super::resolve::HarnessSelection; + package.trust == PackageTrust::AcceptedPublisher && is_tool_role(package) - && package.manifest.state.is_none() && package.manifest.dependencies.is_empty() && lock.edges.is_empty() && lock.packages.len() == 1 - && lock.closure.len() == 1 + && lock + .closure + .iter() + .all(|item| item.package == package.manifest.id) + && lock + .closure + .iter() + .filter(|item| item.entry == package.manifest.entry.export) + .count() + == 1 + && lock + .closure + .iter() + .filter(|item| item.entry != package.manifest.entry.export) + .all(|item| item.effects.is_empty()) && matches!( &lock.harness_selection, HarnessSelection::ToolOnly { tool_package } if tool_package == &package.manifest.id ) - && package.manifest.effects.entries.len() == 1 + && !package.manifest.effects.entries.is_empty() && package .manifest .effects .entries - .get(&package.manifest.entry.export) - .is_some_and(|entry| entry.delegated.is_empty()) + .iter() + .all(|(entry, effects)| { + effects.delegated.is_empty() + && (entry == &package.manifest.entry.export + || (effects.direct.is_empty() && effects.delegated.is_empty())) + }) + && package + .manifest + .effects + .entries + .contains_key(&package.manifest.entry.export) } fn catalog_export_paths(package: &InstalledPackage) -> Vec { + export_paths(package, MANAGED_CATALOG_EXPORT) +} + +fn export_paths(package: &InstalledPackage, export_name: &str) -> Vec { let mut found = Vec::new(); for (path, bytes) in &package.sources { let Ok(text) = std::str::from_utf8(bytes) else { @@ -547,7 +617,7 @@ fn catalog_export_paths(package: &InstalledPackage) -> Vec { if ir .functions .iter() - .any(|function| function.exported && function.name == MANAGED_CATALOG_EXPORT) + .any(|function| function.exported && function.name == export_name) { found.push(path.clone()); } @@ -677,6 +747,166 @@ fn capture_signed_stateless_catalog( .map_err(|_| invalid()) } +fn capture_state_migration_program( + package: &InstalledPackage, + lock: &GraphLock, + cancel: Option<&crate::RunCancellation>, +) -> Result>, PluginError> { + let Some(state) = package.manifest.state.as_ref() else { + return Ok(None); + }; + let Some(migration) = state.migration.as_ref() else { + return Ok(None); + }; + let entry_effects = package + .manifest + .effects + .entries + .get(&migration.entry) + .ok_or_else(|| { + PluginError::new( + PluginErrorKind::ContractIncompatible, + "plugin_state_migration_undeclared", + ) + })?; + if !entry_effects.direct.is_empty() || !entry_effects.delegated.is_empty() { + return Err(PluginError::new( + PluginErrorKind::ContractIncompatible, + "plugin_state_migration_impure", + )); + } + crate::plugins::validate_state_migration_source(package, &migration.entry) + .map_err(|code| PluginError::new(PluginErrorKind::ContractIncompatible, code))?; + check_cancel(cancel)?; + let rebuilt = super::resolve::resolve_installed( + std::slice::from_ref(package), + &super::resolve::ResolveRequest { + roots: vec![super::resolve::PackageRequirement { + id: package.manifest.id.clone(), + version: package.manifest.version.clone(), + }], + host_runtime: lock.host_runtime.clone(), + harness_selection: super::resolve::HarnessSelectionRequest::ToolOnly { + tool_package: package.manifest.id.clone(), + }, + }, + )?; + if rebuilt.packages + != lock + .packages + .iter() + .filter(|item| item.id == package.manifest.id) + .cloned() + .collect::>() + || rebuilt.closure + != lock + .closure + .iter() + .filter(|item| item.package == package.manifest.id) + .cloned() + .collect::>() + { + return Err(PluginError::new( + PluginErrorKind::ContractIncompatible, + "plugin_state_migration_graph_mismatch", + )); + } + let paths = export_paths(package, &migration.entry); + let entry_path = match paths.len() { + 0 => { + return Err(PluginError::new( + PluginErrorKind::ContractIncompatible, + "plugin_state_migration_missing", + )); + } + 1 => paths[0].clone(), + _ => { + return Err(PluginError::new( + PluginErrorKind::ContractIncompatible, + "plugin_state_migration_ambiguous", + )); + } + }; + let sources: Vec<(&str, &str)> = package + .sources + .iter() + .map(|(key, bytes)| { + std::str::from_utf8(bytes) + .map(|text| (key.as_str(), text)) + .map_err(|_| { + PluginError::new( + PluginErrorKind::ContractIncompatible, + "plugin_state_migration_source", + ) + }) + }) + .collect::>()?; + let host_roots: BTreeSet = BTreeSet::from(["json".to_string(), "bytes".to_string()]); + let selected = closed_bundle::select_catalog_closure( + &sources, + agent_host_catalog().as_ref(), + &entry_path, + &host_roots, + ) + .map_err(|_| { + PluginError::new( + PluginErrorKind::UnauthorizedHostImport, + "plugin_state_migration_impure", + ) + })?; + let selected_sources: Vec<(&str, &str)> = sources + .into_iter() + .filter(|(path, _)| selected.iter().any(|item| item == path)) + .collect(); + check_cancel(cancel)?; + let compiled = closed_bundle::compile_bundled_closed_bundle( + &entry_path, + &selected_sources, + ClosedBundleOptions { + limits: ClosedBundleLimits { + max_files: 64, + max_depth: 16, + max_file_bytes: 1024 * 1024, + max_total_bytes: 8 * 1024 * 1024, + }, + virtual_root: Path::new(VIRTUAL_ROOT), + host_roots: &host_roots, + catalog: agent_host_catalog(), + dialect: snapshot_parser_dialect(), + entry_export: &migration.entry, + }, + ) + .map_err(|_| { + PluginError::new( + PluginErrorKind::ContractIncompatible, + "plugin_state_migration_compile_failed", + ) + })?; + let entry = compiled.entry_name.ok_or_else(|| { + PluginError::new( + PluginErrorKind::ContractIncompatible, + "plugin_state_migration_missing", + ) + })?; + let program = Arc::new(compiled.program); + if !program.imports.is_empty() { + return Err(PluginError::new( + PluginErrorKind::UnauthorizedHostImport, + "plugin_state_migration_impure", + )); + } + check_cancel(cancel)?; + Ok(Some(Arc::new( + crate::plugins::state::PluginStateMigrationProgram::new( + program, + entry, + u32::try_from(state.schema).map_err(|_| { + PluginError::new(PluginErrorKind::ContractIncompatible, "plugin_state_schema") + })?, + ), + ))) +} + fn scan_package( package: &InstalledPackage, selected: &[&InstalledPackage], @@ -878,6 +1108,8 @@ fn charge_name(name: &str) -> Result, PluginError> { Some("process.spawn") } "cap::clock_monotonic_ms" | "agent::sleep_ms" => Some("clock.read"), + "state::read" => Some("workspace.fs.read"), + "state::write" => Some("workspace.fs.write"), "agent::model_request" => Some("model.request"), "stream::emit" => Some("event.emit"), _ if name.starts_with("http::") => Some("remote.write"), @@ -932,7 +1164,7 @@ fn is_pure(name: &str) -> bool { fn is_allowed_host_root(root: &str) -> bool { matches!( root, - "json" | "bytes" | "cap" | "agent" | "agent_runtime" | "http" | "stream" + "json" | "bytes" | "cap" | "agent" | "agent_runtime" | "http" | "stream" | "state" ) } diff --git a/src/plugins/state.rs b/src/plugins/state.rs new file mode 100644 index 0000000..04d897a --- /dev/null +++ b/src/plugins/state.rs @@ -0,0 +1,2110 @@ +//! Bounded, versioned Native plugin state. +//! +//! Objects are immutable canonical JSON documents. A root is a revisioned +//! pointer owned by exactly one run or session. A run binding pins the exact +//! root revision it may read or advance, so a later session schema switch does +//! not move an older run onto new data. Storage commands are the existing +//! closed typed RSS worker; object persistence and the root/binding CAS are +//! separate transactions. + +use std::collections::{BTreeMap, HashMap}; +use std::sync::{Arc, Condvar, Mutex}; +use std::time::{Duration, Instant}; + +use serde_json::Value; + +use crate::GatewayPersistence; + +pub const STATE_DOCUMENT_MAX_BYTES: usize = 64 * 1024; +const STATE_MAX_DEPTH: usize = 8; +const STATE_MAX_ENTRIES: usize = 256; +const STATE_MAX_ARRAY: usize = 128; +const STATE_MAX_KEY_BYTES: usize = 64; +const STATE_MAX_STRING_BYTES: usize = 4096; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PluginStateScope { + Invocation, + Run, + Session, +} + +impl PluginStateScope { + pub fn as_str(self) -> &'static str { + match self { + Self::Invocation => "invocation", + Self::Run => "run", + Self::Session => "session", + } + } + + pub fn parse(value: &str) -> Option { + match value { + "invocation" => Some(Self::Invocation), + "run" => Some(Self::Run), + "session" => Some(Self::Session), + _ => None, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PluginStateError { + pub code: &'static str, + pub message: String, +} + +impl PluginStateError { + pub fn new(code: &'static str, message: impl Into) -> Self { + Self { + code, + message: message.into(), + } + } + + fn storage(error: crate::gateway::store::StorageError) -> Self { + let code = match error.code.as_str() { + "plugin_state_object_conflict" => "plugin_state_object_conflict", + "plugin_state_root_missing" => "plugin_state_root_missing", + "plugin_state_conflict" => "plugin_state_conflict", + "plugin_state_binding_invalid" => "plugin_state_binding_invalid", + "plugin_state_binding_conflict" => "plugin_state_binding_conflict", + "plugin_state_unavailable" => "plugin_state_unavailable", + _ => "plugin_state_unavailable", + }; + Self::new(code, error.message) + } +} + +impl std::fmt::Display for PluginStateError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{}: {}", self.code, self.message) + } +} + +impl std::error::Error for PluginStateError {} + +/// Canonical, resource-free JSON document. Construction validates the bounds +/// and normalizes object key order; the public schema never exposes a raw map. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PluginStatePayload { + canonical_json: String, +} + +impl PluginStatePayload { + pub fn parse(input: &str) -> Result { + let value: Value = serde_json::from_str(input).map_err(|_| { + PluginStateError::new( + "plugin_state_invalid_document", + "state document is not JSON", + ) + })?; + if !value.is_object() { + return Err(PluginStateError::new( + "plugin_state_invalid_document", + "state document must be a JSON object", + )); + } + let mut entries = 0usize; + validate_value(&value, 0, &mut entries)?; + let canonical_json = serde_json::to_string(&value).map_err(|_| { + PluginStateError::new( + "plugin_state_invalid_document", + "state document is not encodable", + ) + })?; + if canonical_json.len() > STATE_DOCUMENT_MAX_BYTES { + return Err(PluginStateError::new( + "plugin_state_budget_exceeded", + "state document exceeds the byte budget", + )); + } + Ok(Self { canonical_json }) + } + + pub fn empty() -> Self { + Self { + canonical_json: "{}".into(), + } + } + + pub fn as_str(&self) -> &str { + &self.canonical_json + } + + pub fn byte_len(&self) -> usize { + self.canonical_json.len() + } + + pub fn to_json(&self) -> Value { + serde_json::from_str(&self.canonical_json).expect("validated state document") + } +} + +fn validate_value( + value: &Value, + depth: usize, + entries: &mut usize, +) -> Result<(), PluginStateError> { + if depth > STATE_MAX_DEPTH { + return Err(PluginStateError::new( + "plugin_state_budget_exceeded", + "state document depth exceeds the budget", + )); + } + match value { + Value::Null | Value::Bool(_) => Ok(()), + Value::Number(number) => { + if number.as_i64().is_none() { + return Err(PluginStateError::new( + "plugin_state_invalid_document", + "state numbers must be signed integers", + )); + } + Ok(()) + } + Value::String(text) => { + if text.len() > STATE_MAX_STRING_BYTES { + return Err(PluginStateError::new( + "plugin_state_budget_exceeded", + "state string exceeds the budget", + )); + } + Ok(()) + } + Value::Array(items) => { + if items.len() > STATE_MAX_ARRAY { + return Err(PluginStateError::new( + "plugin_state_budget_exceeded", + "state array exceeds the budget", + )); + } + for item in items { + validate_value(item, depth + 1, entries)?; + } + Ok(()) + } + Value::Object(fields) => { + for (key, item) in fields { + *entries += 1; + if *entries > STATE_MAX_ENTRIES { + return Err(PluginStateError::new( + "plugin_state_budget_exceeded", + "state entries exceed the budget", + )); + } + if key.len() > STATE_MAX_KEY_BYTES { + return Err(PluginStateError::new( + "plugin_state_budget_exceeded", + "state key exceeds the budget", + )); + } + validate_value(item, depth + 1, entries)?; + } + Ok(()) + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PluginStateObject { + pub object_id: String, + pub package_id: String, + pub artifact_id: String, + pub schema: u32, + pub payload: PluginStatePayload, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PluginStateRoot { + pub owner_kind: String, + pub owner_id: String, + pub workspace_id: String, + pub branch_id: String, + pub plugin_id: String, + pub package_id: String, + pub artifact_id: String, + pub schema: u32, + pub revision: u64, + pub object_id: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PluginStateBinding { + pub run_id: String, + pub session_id: String, + pub workspace_id: String, + pub branch_id: String, + pub plugin_id: String, + pub package_id: String, + pub artifact_id: String, + pub owner_kind: String, + pub owner_id: String, + pub graph_id: String, + pub epoch: u64, + pub schema: u32, + pub revision: u64, + pub object_id: String, + pub checkpoint_id: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PluginStateSnapshot { + pub binding: PluginStateBinding, + pub payload: PluginStatePayload, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PluginStateRootKey { + pub owner_kind: String, + pub owner_id: String, + pub workspace_id: String, + pub branch_id: String, + pub plugin_id: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PluginStateBindRequest { + pub run_id: String, + pub session_id: String, + pub workspace_id: String, + pub branch_id: String, + pub plugin_id: String, + pub package_id: String, + pub artifact_id: String, + pub scope: PluginStateScope, + pub graph_id: String, + pub epoch: u64, + pub schema: u32, + pub checkpoint_id: String, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PluginStateRestoreRequest { + pub scope: PluginStateScope, + pub run_id: String, + pub session_id: String, + pub workspace_id: String, + pub branch_id: String, + pub plugin_id: String, + pub package_id: String, + pub artifact_id: String, + pub graph_id: String, + pub epoch: u64, + pub checkpoint_id: String, + pub roots: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct PluginStateRootRef { + pub owner_kind: String, + pub owner_id: String, + pub workspace_id: String, + pub branch_id: String, + pub plugin_id: String, + pub package_id: String, + pub artifact_id: String, + pub schema: u32, + pub revision: u64, + pub object_id: String, +} + +/// A compiled pure migration entry from one signed package. The program has no +/// host imports and receives only the old schema plus its bounded document. +#[derive(Clone)] +pub struct PluginStateMigrationProgram { + program: Arc, + entry: String, + pub target_schema: u32, +} + +impl std::fmt::Debug for PluginStateMigrationProgram { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("PluginStateMigrationProgram") + .field("entry", &self.entry) + .field("target_schema", &self.target_schema) + .finish() + } +} + +impl PluginStateMigrationProgram { + pub fn new(program: Arc, entry: String, target_schema: u32) -> Self { + Self { + program, + entry, + target_schema, + } + } + + pub fn evaluate( + &self, + from_schema: u32, + payload: &PluginStatePayload, + cancellation: &crate::RunCancellation, + ) -> Result { + cancellation + .ensure_open() + .map_err(|error| PluginStateError::new("cancelled", error))?; + if from_schema == 0 || from_schema >= self.target_schema { + return Err(PluginStateError::new( + "state_incompatible", + "migration source schema is not older than the target", + )); + } + let input = serde_json::json!({ + "from_schema": from_schema, + "to_schema": self.target_schema, + "document": payload.as_str(), + }); + let value = crate::runtime::rss_runner::eval_captured_pure_export_with_input( + Arc::clone(&self.program), + &self.entry, + input, + ) + .map_err(|error| PluginStateError::new("plugin_state_migration_failed", error))?; + cancellation + .ensure_open() + .map_err(|error| PluginStateError::new("cancelled", error))?; + let decoded = crate::domain::vm_value_to_json(&value); + let object = decoded.as_object().ok_or_else(|| { + PluginStateError::new( + "plugin_state_migration_failed", + "migration result is not a map", + ) + })?; + let schema = object + .get("schema") + .and_then(Value::as_u64) + .and_then(|value| u32::try_from(value).ok()) + .ok_or_else(|| { + PluginStateError::new( + "plugin_state_migration_failed", + "migration result has no integer schema", + ) + })?; + if schema != self.target_schema { + return Err(PluginStateError::new( + "plugin_state_migration_failed", + "migration returned the wrong schema", + )); + } + let document = object + .get("document") + .and_then(Value::as_str) + .ok_or_else(|| { + PluginStateError::new( + "plugin_state_migration_failed", + "migration result has no string document", + ) + })?; + PluginStatePayload::parse(document) + .map_err(|error| PluginStateError::new("plugin_state_migration_failed", error.message)) + } +} + +/// Per-root writer fence. State commits enter a shared lease; a checkpoint +/// freezes the exact root it captures, waits for earlier writers outside the +/// global commit gate, and blocks later writers until its commit point. +#[derive(Default)] +pub(crate) struct PluginStateWriters { + slots: Mutex>>, +} + +#[derive(Default)] +struct PluginStateWriterSlot { + state: Mutex, + settled: Condvar, +} + +#[derive(Default)] +struct PluginStateWriterSlotState { + frozen: bool, + active: usize, +} + +pub(crate) struct PluginStateWriterLease { + slot: Arc, +} + +impl Drop for PluginStateWriterLease { + fn drop(&mut self) { + let mut state = self + .slot + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state.active = state.active.saturating_sub(1); + self.slot.settled.notify_all(); + } +} + +pub(crate) struct PluginStateWriterFreeze { + slot: Arc, +} + +/// Fixture-only observer of Native reservation establishment. It fires after +/// the id is registered and before the durable write; it never mutates the +/// producer, the reservation, or any collector input. +#[cfg(any(test, feature = "workspace-io-fault"))] +pub(crate) type PluginStateReservationObserver = Arc; + +/// Native hard bounds for the unified reservation registry. These are explicit +/// Native capacity limits: they are independent of the admission +/// `max_concurrent_runs` setting (which only checks positivity) and of the +/// short-RPC pending permit, so reservations never consume either. A full +/// registry refuses new objects with a typed budget error while already-held +/// commit/abandon owners still settle. +pub(crate) const PLUGIN_STATE_MAX_RESERVATIONS: usize = 65_536; +pub(crate) const PLUGIN_STATE_MAX_RESERVATION_BYTES: usize = 8 * 1024 * 1024; + +/// Collector health state. A fresh worker starts at `NeedsSweep`: it never +/// defaults to a `false` debt flag that pretends a sweep already happened. The +/// first related put proves one bounded sweep before the registry may report +/// `Healthy`; any collector error, timeout, or overbudget keeps `Debt` until an +/// explicit successful sweep recovers. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum CollectorHealthState { + NeedsSweep, + Healthy, + Debt, +} + +/// Worker-ordered collector health plus the collect execution epoch. Health is +/// written only by the storage worker at the command execution boundary in +/// FIFO order, never by callers from reply arrival order, so a delayed older +/// success can never clear a newer failure. The epoch moves forward on every +/// worker collect execution; a caller-side timeout marks debt only when the +/// worker recorded nothing newer since its request was sent. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) struct CollectorHealth { + state: CollectorHealthState, + epoch: u64, +} + +impl Default for CollectorHealth { + fn default() -> Self { + Self { + state: CollectorHealthState::NeedsSweep, + epoch: 0, + } + } +} + +/// Unified in-process registry of Native reservations for every plugin state +/// object that has not reached a formal reference yet: guest staged objects, +/// migration candidates, trusted switch outputs, and the initial/maintenance +/// puts staged toward prepared/commit/abandon. Each entry is reference-counted: +/// the producer owner, every unsettled storage request sidecar, and each +/// staged/prepared holder own one count, and the id stays protected until the +/// last owner settles. Counts and bytes are hard-bounded (see +/// [`PLUGIN_STATE_MAX_RESERVATIONS`]); health is worker-ordered (see +/// [`CollectorHealth`]). Entries are opaque object identities only: the +/// registry never serializes VM state, guest resources, grants, or authority, +/// and reservations are never rebuilt from JSON or guest ids. +#[derive(Default)] +pub(crate) struct PluginStateCandidates { + state: Mutex, + #[cfg(any(test, feature = "workspace-io-fault"))] + observer: Mutex>, + /// Fixture-only observation of the collector injection budget inputs. It + /// reports the already-computed (count, wire bytes, aggregate weight) and + /// never changes the fail-closed decision. + #[cfg(any(test, feature = "workspace-io-fault"))] + budget_observer: Mutex>, +} + +/// Fixture-only reporter of the collector injection budget inputs. +#[cfg(any(test, feature = "workspace-io-fault"))] +pub(crate) type PluginStateCollectBudgetObserver = Arc; + +#[derive(Default)] +struct PluginStateCandidateState { + owners: BTreeMap, + bytes: usize, + health: CollectorHealth, + /// Monotonic count of last-owner releases (formal commit/abandon + /// settlements) not yet covered by a successful sweep. Fixed-size; never + /// a per-id table. An ordinary release only advances this counter and + /// never writes health: it makes the registry dirty (sweep-pending), not + /// failure Debt. + dirty_generation: u64, + /// Newest dirty generation covered by a successful worker sweep. A sweep + /// only clears the generation it actually observed; releases that land + /// while the sweep runs (or after) stay pending for the next sweep. + swept_generation: u64, + /// Fail-closed latch for counter overflow. While set, no sweep may report + /// clean and every new put is refused; it can never wrap or saturate + /// into a false clean claim. + dirty_sealed: bool, +} + +/// One strong Native reservation owner: a counted reference to a single +/// registry id. Cloning shares ownership (the count grows); dropping releases +/// exactly one count. Dropping never enqueues storage work, never waits on the +/// worker, and never synthesizes authority: it only releases this reference. +/// Storage requests carry a clone as an explicit sidecar so the id stays +/// protected until the worker settles that request, even after the caller has +/// released its own reference or timed out waiting for the reply. +pub(crate) struct PluginStateCandidateLease { + candidates: Arc, + object_id: String, +} + +impl Clone for PluginStateCandidateLease { + fn clone(&self) -> Self { + self.candidates.add_owner(&self.object_id); + Self { + candidates: Arc::clone(&self.candidates), + object_id: self.object_id.clone(), + } + } +} + +impl Drop for PluginStateCandidateLease { + fn drop(&mut self) { + // Release only this reference. Settlement-time reclamation is explicit + // at the abandonment points (never on the worker thread, never while + // a global gate is held). + self.candidates.release(&self.object_id); + } +} + +impl PluginStateCandidates { + fn add_owner(&self, object_id: &str) { + let mut state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(count) = state.owners.get_mut(object_id) { + *count = count.saturating_add(1); + } + } + + pub(crate) fn reserve( + self: &Arc, + object_id: &str, + ) -> Result { + let mut state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if !state.owners.contains_key(object_id) { + if state.owners.len() >= PLUGIN_STATE_MAX_RESERVATIONS + || state.bytes.saturating_add(object_id.len()) > PLUGIN_STATE_MAX_RESERVATION_BYTES + { + return Err(PluginStateError::new( + "plugin_state_budget_exceeded", + "state reservation registry is full; new objects are refused", + )); + } + state.bytes = state.bytes.saturating_add(object_id.len()); + state.owners.insert(object_id.to_string(), 1); + } else { + let count = state + .owners + .get_mut(object_id) + .expect("reserved state object owner"); + *count = count.saturating_add(1); + } + drop(state); + #[cfg(any(test, feature = "workspace-io-fault"))] + if let Some(observer) = self + .observer + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + { + observer(object_id); + } + Ok(PluginStateCandidateLease { + candidates: Arc::clone(self), + object_id: object_id.to_string(), + }) + } + + fn release(&self, object_id: &str) { + let mut state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let last = state + .owners + .get_mut(object_id) + .map(|count| { + *count = count.saturating_sub(1); + *count == 0 + }) + .unwrap_or(true); + if last && state.owners.remove(object_id).is_some() { + state.bytes = state.bytes.saturating_sub(object_id.len()); + // The last Native owner settled (commit or abandon, durable or + // not): record one bounded dirty generation for later + // worker-ordered reclamation. Same-registry-lock update, Drop + // context only: no RPC, no enqueue, no waiting, no global gate. + // Health is untouched here; the registry simply becomes + // sweep-pending, never failure Debt. + match state.dirty_generation.checked_add(1) { + Some(next) => state.dirty_generation = next, + None => state.dirty_sealed = true, + } + } + } + + /// Storage-worker only snapshot for one `plugin.state_collect` + /// execution: the protection set plus the dirty generation it covers. + /// Both are read under the same registry lock so a later successful + /// sweep only clears the generation it actually observed; releases that + /// land while the sweep runs stay pending. + pub(crate) fn snapshot_for_collect(&self) -> (Vec, u64) { + let state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + ( + state.owners.keys().cloned().collect(), + state.dirty_generation, + ) + } + + /// Pure capacity peek for one id: true when registering a NEW id would + /// exceed the Native count/byte bounds. Existing ids only add an owner + /// and never hit the bounds. No mutation, no health effect: the caller + /// reports the exact budget code without spending a sweep that a full + /// registry would fail anyway. + pub(crate) fn would_exceed_bounds(&self, object_id: &str) -> bool { + let state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + !state.owners.contains_key(object_id) + && (state.owners.len() >= PLUGIN_STATE_MAX_RESERVATIONS + || state.bytes.saturating_add(object_id.len()) > PLUGIN_STATE_MAX_RESERVATION_BYTES) + } + + /// Single-lock admission view for the put path: current worker-ordered + /// health plus whether a dirty generation still needs a bounded sweep. + /// A sealed overflow reports Debt so every new put is refused fail-closed + /// without ever claiming a sweep cleared it. + pub(crate) fn admission_snapshot(&self) -> (CollectorHealthState, bool) { + let state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if state.dirty_sealed { + return (CollectorHealthState::Debt, true); + } + ( + state.health.state, + state.swept_generation != state.dirty_generation, + ) + } + + pub(crate) fn health(&self) -> CollectorHealth { + self.state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .health + } + + #[cfg(any(test, feature = "workspace-io-fault"))] + pub(crate) fn collection_debt(&self) -> bool { + self.health().state == CollectorHealthState::Debt + } + + /// Storage-worker only: record one `plugin.state_collect` execution that + /// really succeeded (transport ok AND the typed envelope carries + /// `ok: true`). The sweep covers exactly `snapshot_generation`; newer + /// releases stay pending for the next sweep. Only an explicit sweep + /// proves recovery and clears Debt/NeedsSweep; a quiet success advances + /// the swept marker but never recovers health on its own. A sealed + /// overflow keeps Debt fail-closed and never reports clean. + pub(crate) fn record_collect_success(&self, snapshot_generation: u64, report_health: bool) { + let mut state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state.health.epoch = state.health.epoch.saturating_add(1); + if state.dirty_sealed { + state.health.state = CollectorHealthState::Debt; + return; + } + if snapshot_generation > state.swept_generation { + state.swept_generation = snapshot_generation; + } + if report_health { + state.health.state = CollectorHealthState::Healthy; + } + } + + /// Storage-worker only: record one `plugin.state_collect` execution that + /// failed for any reason (transport/IO/runner error, typed `ok: false`, + /// Native overbudget, unencodable protection set). Called in worker + /// execution order for explicit AND quiet sweeps alike, so a real quiet + /// failure always keeps Debt even when its caller already moved on. + pub(crate) fn record_collect_failure(&self) { + let mut state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state.health.epoch = state.health.epoch.saturating_add(1); + state.health.state = CollectorHealthState::Debt; + } + + /// Caller-side collect with an unknown outcome (reply timeout or transport + /// loss): fail-safe debt, but only when the worker recorded no newer + /// execution since this request was sent. A late worker success is never + /// shadowed by a stale caller timeout. + pub(crate) fn note_collect_outcome_unknown(&self, since_epoch: u64) { + let mut state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if state.health.epoch == since_epoch { + state.health.state = CollectorHealthState::Debt; + } + } + + #[cfg(any(test, feature = "workspace-io-fault"))] + pub(crate) fn set_observer(&self, observer: PluginStateReservationObserver) { + *self + .observer + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(observer); + } + + /// Fixture-only observation of the collector budget inputs. Read-only: the + /// values are already computed by the worker and the decision is unchanged. + #[cfg(any(test, feature = "workspace-io-fault"))] + pub(crate) fn set_budget_observer(&self, observer: PluginStateCollectBudgetObserver) { + *self + .budget_observer + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(observer); + } + + #[cfg(any(test, feature = "workspace-io-fault"))] + pub(crate) fn report_collect_budget_inputs(&self, count: usize, wire: usize, aggregate: usize) { + if let Some(observer) = self + .budget_observer + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + { + observer(count, wire, aggregate); + } + } + + #[cfg(feature = "workspace-io-fault")] + pub(crate) fn len(&self) -> usize { + self.state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .owners + .len() + } + + /// Fixture-only holder of a real Native reservation. It adds no durable + /// object and grants no authority; it only keeps an id protected until the + /// returned lease is dropped. It releases exactly its own reference, like + /// every production owner. + #[cfg(feature = "workspace-io-fault")] + pub(crate) fn reserve_for_test(self: &Arc, object_id: &str) -> PluginStateCandidateLease { + self.reserve(object_id) + .expect("test reservation fits the Native registry bounds") + } +} + +impl Drop for PluginStateWriterFreeze { + fn drop(&mut self) { + let mut state = self + .slot + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state.frozen = false; + self.slot.settled.notify_all(); + } +} + +impl PluginStateWriters { + fn slot(&self, key: &str) -> Arc { + let mut slots = self + .slots + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + Arc::clone(slots.entry(key.to_string()).or_default()) + } + + fn enter( + &self, + key: &str, + deadline: Instant, + cancelled: impl Fn() -> bool, + ) -> Result { + let slot = self.slot(key); + let mut state = slot + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + while state.frozen { + if cancelled() { + return Err(PluginStateError::new( + "cancelled", + "state writer was cancelled before publication", + )); + } + let now = Instant::now(); + if now >= deadline { + return Err(PluginStateError::new( + "plugin_state_busy", + "state root is frozen by a checkpoint", + )); + } + let wait = deadline + .saturating_duration_since(now) + .min(Duration::from_millis(50)); + let (next, _) = slot + .settled + .wait_timeout(state, wait) + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state = next; + } + state.active += 1; + drop(state); + Ok(PluginStateWriterLease { slot }) + } + + fn freeze( + &self, + key: &str, + deadline: Instant, + cancelled: impl Fn() -> bool, + ) -> Result { + let slot = self.slot(key); + let mut state = slot + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + while state.active > 0 { + if cancelled() { + return Err(PluginStateError::new( + "cancelled", + "checkpoint cancelled while waiting for state writers", + )); + } + let now = Instant::now(); + if now >= deadline { + return Err(PluginStateError::new( + "plugin_state_busy", + "state writer did not settle before the checkpoint deadline", + )); + } + let wait = deadline + .saturating_duration_since(now) + .min(Duration::from_millis(50)); + let (next, _) = slot + .settled + .wait_timeout(state, wait) + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state = next; + } + state.frozen = true; + drop(state); + Ok(PluginStateWriterFreeze { slot }) + } +} + +pub(crate) fn plugin_state_writer_key(binding: &PluginStateBinding) -> PluginStateRootKey { + PluginStateRootKey { + owner_kind: binding.owner_kind.clone(), + owner_id: binding.owner_id.clone(), + workspace_id: binding.workspace_id.clone(), + branch_id: binding.branch_id.clone(), + plugin_id: binding.plugin_id.clone(), + } +} + +fn writer_key_id(key: &PluginStateRootKey) -> String { + format!( + "{}\u{1f}{}\u{1f}{}\u{1f}{}\u{1f}{}", + key.owner_kind, key.owner_id, key.workspace_id, key.branch_id, key.plugin_id + ) +} + +pub struct PluginStateStore { + persistence: Arc, +} + +#[cfg(feature = "workspace-io-fault")] +pub struct PluginStateWriterHold { + _lease: PluginStateWriterLease, +} + +impl Clone for PluginStateStore { + fn clone(&self) -> Self { + Self { + persistence: Arc::clone(&self.persistence), + } + } +} + +impl PluginStateStore { + pub fn open(persistence: Arc) -> Self { + Self { persistence } + } + + fn command(&self, op: &str, payload: Value) -> Result { + self.command_with_owner(op, payload, None) + } + + fn command_with_owner( + &self, + op: &str, + payload: Value, + owner: Option, + ) -> Result { + self.persistence + .plugin_control_command_with_owner(op, &payload, owner) + .map_err(PluginStateError::storage) + } + + pub fn put_object( + &self, + package_id: &str, + artifact_id: &str, + schema: u32, + payload: &PluginStatePayload, + ) -> Result { + // The immediate owner is released on return; the request sidecar keeps + // the id protected until the worker settles the put. Callers that need + // protection past settle (staged, prepared, migration, switch) use + // `put_object_reserved` and hold the returned owner across commit or + // abandonment instead. + let (object, _immediate) = + self.put_object_reserved(package_id, artifact_id, schema, payload)?; + Ok(object) + } + + /// Writes one durable object through the unified Native reservation + /// lifecycle: health is proven first, the reservation is registered before + /// the durable write, and the returned owner keeps the id protected until + /// the caller settles (commit) or abandons it. The full reservation owner + /// (id, registry membership, request sidecar) is established before the + /// durable write, so a put whose reply was lost still has a bounded + /// settlement path instead of leaking an unprotected object. + pub(crate) fn put_object_reserved( + &self, + package_id: &str, + artifact_id: &str, + schema: u32, + payload: &PluginStatePayload, + ) -> Result<(PluginStateObject, PluginStateCandidateLease), PluginStateError> { + let object_id = uuid::Uuid::new_v4().to_string(); + self.put_object_reserved_with_id(&object_id, package_id, artifact_id, schema, payload) + } + + pub(crate) fn put_object_reserved_with_id( + &self, + object_id: &str, + package_id: &str, + artifact_id: &str, + schema: u32, + payload: &PluginStatePayload, + ) -> Result<(PluginStateObject, PluginStateCandidateLease), PluginStateError> { + if schema == 0 || schema > 64 { + return Err(PluginStateError::new( + "plugin_state_invalid_schema", + "state schema is outside the accepted range", + )); + } + // Refuse new objects while the existing bounded collector cannot prove + // a sweep. A fresh worker proves one sweep first; debt refuses until + // an explicit successful sweep recovers. + // + // The Native registry bound is checked first as a pure peek: a full + // registry reports its exact budget code without spending a sweep + // (whose injected protection set would exceed the same budget while + // full and mask the precise capacity reason with Debt). + if self + .persistence + .plugin_state_candidates + .would_exceed_bounds(object_id) + { + return Err(PluginStateError::new( + "plugin_state_budget_exceeded", + "state reservation registry is full; new objects are refused", + )); + } + self.ensure_object_capacity()?; + let reservation = self + .persistence + .plugin_state_candidates + .reserve(object_id)?; + let object = match self.put_object_with_owner_id( + object_id, + package_id, + artifact_id, + schema, + payload, + Some(reservation.clone()), + ) { + Ok(object) => object, + Err(error) => { + // The put may have become durable even when its reply was lost. + // Release this producer's owner, then enqueue the same bounded + // worker-ordered reclamation from this caller thread so the + // abandoned object cannot accumulate across unknown puts. The + // request sidecar in the storage worker kept it protected until + // the request settled; this sweep runs after that settle. + drop(reservation); + let _ = self.collect_orphans_quiet(); + return Err(error); + } + }; + Ok((object, reservation)) + } + + fn ensure_object_capacity(&self) -> Result<(), PluginStateError> { + // The caller check is only early refusal, never an execution permit: + // the worker re-applies the same decision at its own put boundary. + // Debt refuses with typed backpressure (one bounded quiet sweep still + // runs so refused traffic cannot grow the disk, but it never clears + // Debt itself). A fresh worker or any pending dirty generation proves + // one bounded explicit sweep before the put is enqueued. + let (health_state, pending) = self + .persistence + .plugin_state_candidates + .admission_snapshot(); + match health_state { + CollectorHealthState::Debt => { + // Typed backpressure: refuse the new object. One bounded + // worker-ordered reclamation still runs so refused traffic + // cannot grow the disk while degraded; that sweep never clears + // debt itself. Only an explicit successful collector recovers. + let _ = self.collect_orphans_quiet(); + Err(PluginStateError::new( + "plugin_state_unavailable", + "state collection is degraded; new objects are refused", + )) + } + CollectorHealthState::Healthy if !pending => Ok(()), + _ => { + // A fresh worker never defaults to swept: the first related + // put proves one bounded sweep before going Healthy, and any + // pending dirty generation is reclaimed first. Any error, + // timeout, or overbudget keeps Debt and refuses. + self.collect_orphans().map_err(|_| { + PluginStateError::new( + "plugin_state_unavailable", + "state collection is not proven; new objects are refused", + ) + }) + } + } + } + + /// Builds one candidate through the real typed producer. The attempted id + /// is returned even when the write is refused, so a caller can prove that a + /// backpressured attempt never became durable. + #[cfg(feature = "workspace-io-fault")] + pub fn candidate_put_for_test( + &self, + package_id: &str, + artifact_id: &str, + schema: u32, + payload: &PluginStatePayload, + ) -> (String, Result) { + let object_id = uuid::Uuid::new_v4().to_string(); + let result = self + .put_object_reserved_with_id(&object_id, package_id, artifact_id, schema, payload) + .map(|(object, lease)| { + drop(lease); + object + }); + (object_id, result) + } + + #[cfg(feature = "workspace-io-fault")] + pub fn candidate_registry_len_for_test(&self) -> usize { + self.persistence.plugin_state_candidates.len() + } + + /// Holds one real candidate reservation until the returned lease drops. + /// The id need not be a durable object; the collector's protected set is + /// the same Native registry used by the production producer. + #[cfg(feature = "workspace-io-fault")] + pub fn hold_candidate_reservation_for_test(&self, object_id: &str) -> impl Drop + use<> { + self.persistence + .plugin_state_candidates + .reserve_for_test(object_id) + } + + #[cfg(feature = "workspace-io-fault")] + pub fn collection_debt_for_test(&self) -> bool { + self.persistence.plugin_state_candidates.collection_debt() + } + + /// Fixture-only view of the bounded dirty bookkeeping: (dirty + /// generation, swept generation, sealed-overflow latch). A test asserts + /// progress as `swept` covering the `dirty` observed at abandon time; + /// per-id tables are never exposed. + #[cfg(feature = "workspace-io-fault")] + pub fn candidate_dirty_generations_for_test(&self) -> (u64, u64, bool) { + let state = self + .persistence + .plugin_state_candidates + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + ( + state.dirty_generation, + state.swept_generation, + state.dirty_sealed, + ) + } + + fn put_object_with_owner_id( + &self, + object_id: &str, + package_id: &str, + artifact_id: &str, + schema: u32, + payload: &PluginStatePayload, + owner: Option, + ) -> Result { + let data = self.command_with_owner( + "plugin.state_object_put", + serde_json::json!({ + "object_id": object_id, + "package_id": package_id, + "artifact_id": artifact_id, + "schema_version": schema, + "document_json": payload.as_str(), + "document_bytes": payload.byte_len(), + "now_ms": now_ms(), + }), + owner, + )?; + if data.get("object_id").and_then(Value::as_str) != Some(object_id) { + return Err(PluginStateError::new( + "plugin_state_object_missing", + "stored state object identity did not match", + )); + } + #[cfg(feature = "workspace-io-fault")] + if let Some(observer) = self.persistence.plugin_state_object_put_observer_hook() { + observer(object_id); + } + Ok(PluginStateObject { + object_id: object_id.to_string(), + package_id: package_id.to_string(), + artifact_id: artifact_id.to_string(), + schema, + payload: payload.clone(), + }) + } + + pub fn object_payload(&self, object_id: &str) -> Result { + let data = self.command( + "plugin.state_object_get", + serde_json::json!({"object_id": object_id}), + )?; + let rows = crate::sqlite_storage_rows::sqlite_storage_rows(&data).map_err(|_| { + PluginStateError::new("plugin_state_unavailable", "state object row is invalid") + })?; + let row = rows.first().ok_or_else(|| { + PluginStateError::new("plugin_state_object_missing", "state object is missing") + })?; + let document = text_at(row, 2)?; + PluginStatePayload::parse(&document) + } + + pub fn root( + &self, + key: &PluginStateRootKey, + ) -> Result, PluginStateError> { + let data = self.command( + "plugin.state_root_get", + serde_json::json!({ + "owner_kind": key.owner_kind, + "owner_id": key.owner_id, + "workspace_id": key.workspace_id, + "branch_id": key.branch_id, + "plugin_id": key.plugin_id, + }), + )?; + let rows = crate::sqlite_storage_rows::sqlite_storage_rows(&data).map_err(|_| { + PluginStateError::new("plugin_state_unavailable", "state root row is invalid") + })?; + let Some(row) = rows.first() else { + return Ok(None); + }; + Ok(Some(root_from_row(row)?)) + } + + pub fn cas_root( + &self, + key: &PluginStateRootKey, + expected_revision: u64, + object: &PluginStateObject, + ) -> Result { + self.cas_root_with_owner(key, expected_revision, object, None) + } + + pub(crate) fn cas_root_with_owner( + &self, + key: &PluginStateRootKey, + expected_revision: u64, + object: &PluginStateObject, + owner: Option, + ) -> Result { + let data = self.command_with_owner( + "plugin.state_root_put", + serde_json::json!({ + "owner_kind": key.owner_kind, + "owner_id": key.owner_id, + "workspace_id": key.workspace_id, + "branch_id": key.branch_id, + "plugin_id": key.plugin_id, + "package_id": object.package_id, + "artifact_id": object.artifact_id, + "schema_version": object.schema, + "expected_revision": expected_revision, + "object_id": object.object_id, + "now_ms": now_ms(), + }), + owner, + )?; + let revision = data + .get("revision") + .and_then(Value::as_u64) + .ok_or_else(|| { + PluginStateError::new("plugin_state_unavailable", "state CAS returned no revision") + })?; + Ok(PluginStateRoot { + owner_kind: key.owner_kind.clone(), + owner_id: key.owner_id.clone(), + workspace_id: key.workspace_id.clone(), + branch_id: key.branch_id.clone(), + plugin_id: key.plugin_id.clone(), + package_id: object.package_id.clone(), + artifact_id: object.artifact_id.clone(), + schema: object.schema, + revision, + object_id: object.object_id.clone(), + }) + } + + fn ensure_root( + &self, + key: &PluginStateRootKey, + request: &PluginStateBindRequest, + ) -> Result { + if let Some(root) = self.root(key)? { + if root.schema != request.schema { + return Err(PluginStateError::new( + "state_incompatible", + "existing state schema differs from the admitted graph schema", + )); + } + return Ok(root); + } + let payload = PluginStatePayload::empty(); + let (object, owner) = self.put_object_reserved( + &request.package_id, + &request.artifact_id, + request.schema, + &payload, + )?; + match self.cas_root_with_owner(key, 0, &object, Some(owner)) { + Ok(root) => Ok(root), + Err(error) if error.code == "plugin_state_conflict" => { + let root = self.root(key)?.ok_or(error)?; + if root.schema != request.schema { + return Err(PluginStateError::new( + "state_incompatible", + "concurrent state root schema differs from the admitted graph schema", + )); + } + Ok(root) + } + Err(error) => Err(error), + } + } + + pub fn binding(&self, run_id: &str) -> Result, PluginStateError> { + let data = self.command( + "plugin.state_binding_get", + serde_json::json!({ "run_id": run_id }), + )?; + let rows = crate::sqlite_storage_rows::sqlite_storage_rows(&data).map_err(|_| { + PluginStateError::new("plugin_state_unavailable", "state binding row is invalid") + })?; + let Some(row) = rows.first() else { + return Ok(None); + }; + snapshot_from_row(row) + } + + pub fn bind_run( + &self, + request: &PluginStateBindRequest, + ) -> Result { + if let Some(existing) = self.binding(&request.run_id)? { + validate_binding(&existing.binding, request)?; + return Ok(existing); + } + let owner_id = match request.scope { + PluginStateScope::Run => request.run_id.clone(), + PluginStateScope::Session => request.session_id.clone(), + PluginStateScope::Invocation => { + return Err(PluginStateError::new( + "plugin_state_invalid", + "invocation state has no durable run binding", + )); + } + }; + let key = PluginStateRootKey { + owner_kind: request.scope.as_str().to_string(), + owner_id: owner_id.clone(), + workspace_id: request.workspace_id.clone(), + branch_id: request.branch_id.clone(), + plugin_id: request.plugin_id.clone(), + }; + let root = self.ensure_root(&key, request)?; + let data = self.command( + "plugin.state_bind_run", + serde_json::json!({ + "run_id": request.run_id, + "session_id": request.session_id, + "workspace_id": request.workspace_id, + "branch_id": request.branch_id, + "plugin_id": request.plugin_id, + "package_id": root.package_id, + "artifact_id": root.artifact_id, + "owner_kind": request.scope.as_str(), + "owner_id": owner_id, + "graph_id": request.graph_id, + "epoch": request.epoch, + "schema_version": root.schema, + "revision": root.revision, + "object_id": root.object_id, + "checkpoint_id": request.checkpoint_id, + "now_ms": now_ms(), + }), + )?; + if data.get("run_id").and_then(Value::as_str) != Some(request.run_id.as_str()) { + return Err(PluginStateError::new( + "plugin_state_binding_invalid", + "run state binding did not persist", + )); + } + self.binding(&request.run_id)?.ok_or_else(|| { + PluginStateError::new( + "plugin_state_binding_missing", + "run state binding is missing", + ) + }) + } + + pub fn commit( + &self, + snapshot: &PluginStateSnapshot, + payload: &PluginStatePayload, + ) -> Result { + let deadline = Instant::now() + Duration::from_secs(30); + self.commit_with_deadline(snapshot, payload, deadline, None) + } + + pub fn commit_with_cancellation( + &self, + snapshot: &PluginStateSnapshot, + payload: &PluginStatePayload, + cancellation: &crate::RunCancellation, + ) -> Result { + let deadline = cancellation + .deadline_instant() + .unwrap_or_else(|| Instant::now() + Duration::from_secs(30)); + self.commit_with_deadline(snapshot, payload, deadline, Some(cancellation)) + } + + fn commit_with_deadline( + &self, + snapshot: &PluginStateSnapshot, + payload: &PluginStatePayload, + deadline: Instant, + cancellation: Option<&crate::RunCancellation>, + ) -> Result { + let binding = &snapshot.binding; + let key = plugin_state_writer_key(binding); + let _writer = self.persistence.plugin_state_writers().enter( + &writer_key_id(&key), + deadline, + || cancellation.is_some_and(|cancel| cancel.requested().is_some()), + )?; + let (object, owner) = self.put_object_reserved( + &binding.package_id, + &binding.artifact_id, + binding.schema, + payload, + )?; + let data = match self.command_with_owner( + "plugin.state_commit", + serde_json::json!({ + "run_id": binding.run_id, + "owner_kind": binding.owner_kind, + "owner_id": binding.owner_id, + "workspace_id": binding.workspace_id, + "branch_id": binding.branch_id, + "plugin_id": binding.plugin_id, + "package_id": binding.package_id, + "artifact_id": binding.artifact_id, + "expected_revision": binding.revision, + "expected_object_id": binding.object_id, + "schema_version": object.schema, + "object_id": object.object_id, + "now_ms": now_ms(), + }), + Some(owner), + ) { + Ok(data) => data, + Err(error) => { + let _ = self.collect_orphans(); + return Err(error); + } + }; + let _ = self.collect_orphans(); + let revision = data + .get("revision") + .and_then(Value::as_u64) + .ok_or_else(|| { + PluginStateError::new("plugin_state_conflict", "state commit returned no revision") + })?; + let mut binding = binding.clone(); + binding.revision = revision; + binding.object_id = object.object_id; + binding.schema = object.schema; + Ok(PluginStateSnapshot { + binding, + payload: payload.clone(), + }) + } + + pub(crate) fn acquire_writer_key( + &self, + key: &PluginStateRootKey, + cancellation: &crate::RunCancellation, + ) -> Result { + let deadline = cancellation + .deadline_instant() + .unwrap_or_else(|| Instant::now() + Duration::from_secs(30)); + self.persistence + .plugin_state_writers() + .enter(&writer_key_id(key), deadline, || { + cancellation.requested().is_some() + }) + } + + pub(crate) fn freeze_writer( + &self, + binding: &PluginStateBinding, + cancellation: &crate::RunCancellation, + ) -> Result { + let deadline = cancellation + .deadline_instant() + .unwrap_or_else(|| Instant::now() + Duration::from_secs(30)); + let key = plugin_state_writer_key(binding); + self.persistence + .plugin_state_writers() + .freeze(&writer_key_id(&key), deadline, || { + cancellation.requested().is_some() + }) + } + + #[cfg(feature = "workspace-io-fault")] + pub fn hold_writer_for_test( + &self, + binding: &PluginStateBinding, + cancellation: &crate::RunCancellation, + ) -> Result { + let deadline = cancellation + .deadline_instant() + .unwrap_or_else(|| Instant::now() + Duration::from_secs(30)); + let key = plugin_state_writer_key(binding); + let lease = self.persistence.plugin_state_writers().enter( + &writer_key_id(&key), + deadline, + || cancellation.requested().is_some(), + )?; + Ok(PluginStateWriterHold { _lease: lease }) + } + + pub fn migrate_root( + &self, + key: &PluginStateRootKey, + expected_revision: u64, + object: &PluginStateObject, + ) -> Result { + self.migrate_root_with_owner(key, expected_revision, object, None) + } + + pub(crate) fn migrate_root_with_owner( + &self, + key: &PluginStateRootKey, + expected_revision: u64, + object: &PluginStateObject, + owner: Option, + ) -> Result { + let deadline = Instant::now() + Duration::from_secs(30); + let _writer = + self.persistence + .plugin_state_writers() + .enter(&writer_key_id(key), deadline, || false)?; + self.cas_root_with_owner(key, expected_revision, object, owner) + } + + pub fn checkpoint_roots( + &self, + run_id: &str, + ) -> Result, PluginStateError> { + let data = self.command( + "plugin.state_roots_for_checkpoint", + serde_json::json!({ "run_id": run_id }), + )?; + let rows = crate::sqlite_storage_rows::sqlite_storage_rows(&data).map_err(|_| { + PluginStateError::new("plugin_state_unavailable", "state root rows are invalid") + })?; + rows.iter().map(|row| root_ref_from_row(row)).collect() + } + + pub fn restore_binding( + &self, + request: &PluginStateRestoreRequest, + ) -> Result { + let selected = select_restore_root(request)?; + let selected = selected.clone(); + let owner_id = match request.scope { + PluginStateScope::Run => request.run_id.clone(), + PluginStateScope::Session => request.session_id.clone(), + PluginStateScope::Invocation => { + return Err(PluginStateError::new( + "plugin_state_invalid", + "invocation state has no durable restore root", + )); + } + }; + if selected.owner_kind != request.scope.as_str() { + return Err(PluginStateError::new( + "plugin_state_root_mismatch", + "checkpoint state root owner kind does not match the restored graph", + )); + } + let key = PluginStateRootKey { + owner_kind: request.scope.as_str().to_string(), + owner_id: owner_id.clone(), + workspace_id: request.workspace_id.clone(), + branch_id: request.branch_id.clone(), + plugin_id: request.plugin_id.clone(), + }; + let object = PluginStateObject { + object_id: selected.object_id.clone(), + package_id: selected.package_id.clone(), + artifact_id: selected.artifact_id.clone(), + schema: selected.schema, + payload: PluginStatePayload::empty(), + }; + let root = match self.root(&key)? { + Some(root) => { + if root.schema != selected.schema + || root.object_id != selected.object_id + || root.package_id != selected.package_id + || root.artifact_id != selected.artifact_id + { + return Err(PluginStateError::new( + "plugin_state_root_mismatch", + "restored branch already has different state root", + )); + } + root + } + None => self.cas_root(&key, 0, &object)?, + }; + let snapshot = self.bind_run(&PluginStateBindRequest { + run_id: request.run_id.clone(), + session_id: request.session_id.clone(), + workspace_id: request.workspace_id.clone(), + branch_id: request.branch_id.clone(), + plugin_id: request.plugin_id.clone(), + package_id: request.package_id.clone(), + artifact_id: request.artifact_id.clone(), + scope: request.scope, + graph_id: request.graph_id.clone(), + epoch: request.epoch, + schema: selected.schema, + checkpoint_id: request.checkpoint_id.clone(), + })?; + if snapshot.binding.revision != root.revision + || snapshot.binding.object_id != root.object_id + { + return Err(PluginStateError::new( + "plugin_state_root_mismatch", + "restored run binding does not reference the restored root", + )); + } + Ok(snapshot) + } + + pub fn authorize( + &self, + binding: &PluginStateBinding, + profile: &str, + graph_id: &str, + now_ms: u64, + ) -> Result<(), PluginStateError> { + let data = self.command( + "plugin.state_authorize", + serde_json::json!({ + "run_id": binding.run_id, + "session_id": binding.session_id, + "profile": profile, + "plugin_id": binding.plugin_id, + "package_id": binding.package_id, + "artifact_id": binding.artifact_id, + "owner_kind": binding.owner_kind, + "owner_id": binding.owner_id, + "graph_id": graph_id, + "workspace_id": binding.workspace_id, + "branch_id": binding.branch_id, + "epoch": binding.epoch, + "now_ms": now_ms, + }), + )?; + let rows = crate::sqlite_storage_rows::sqlite_storage_rows(&data).map_err(|_| { + PluginStateError::new( + "plugin_state_unavailable", + "state authorization row is invalid", + ) + })?; + let status = rows + .first() + .and_then(|row| row.first()) + .and_then(Value::as_str) + .unwrap_or(""); + if status == "ok" { + Ok(()) + } else { + Err(PluginStateError::new( + "plugin_state_unauthorized", + if status.is_empty() { + "state authorization denied".to_string() + } else { + status.to_string() + }, + )) + } + } + + pub fn session_root_key( + &self, + session_id: &str, + plugin_id: &str, + ) -> Result, PluginStateError> { + let data = self.command( + "plugin.state_session_key", + serde_json::json!({"session_id": session_id, "plugin_id": plugin_id}), + )?; + let rows = crate::sqlite_storage_rows::sqlite_storage_rows(&data).map_err(|_| { + PluginStateError::new( + "plugin_state_unavailable", + "session state key row is invalid", + ) + })?; + let Some(row) = rows.first() else { + return Ok(None); + }; + Ok(Some(PluginStateRootKey { + owner_kind: "session".into(), + owner_id: session_id.into(), + workspace_id: text_at(row, 0)?, + branch_id: text_at(row, 1)?, + plugin_id: text_at(row, 2)?, + })) + } + + pub fn session_root_key_for( + &self, + session_id: &str, + plugin_id: &str, + workspace_id: &str, + branch_id: &str, + ) -> Result, PluginStateError> { + let data = self.command( + "plugin.state_session_key_for", + serde_json::json!({ + "session_id": session_id, + "plugin_id": plugin_id, + "workspace_id": workspace_id, + "branch_id": branch_id, + }), + )?; + let rows = crate::sqlite_storage_rows::sqlite_storage_rows(&data).map_err(|_| { + PluginStateError::new( + "plugin_state_unavailable", + "session state key row is invalid", + ) + })?; + let Some(row) = rows.first() else { + return Ok(None); + }; + Ok(Some(PluginStateRootKey { + owner_kind: "session".into(), + owner_id: session_id.into(), + workspace_id: text_at(row, 0)?, + branch_id: text_at(row, 1)?, + plugin_id: text_at(row, 2)?, + })) + } + + pub fn managed_run_identity(&self, run_id: &str) -> Option<(String, String, u64)> { + let cancellation = crate::RunCancellation::with_deadline( + std::time::Instant::now() + std::time::Duration::from_secs(5), + ); + self.persistence + .managed_run_query_binding(run_id, &cancellation) + .ok() + .flatten() + .map(|binding| { + ( + binding.workspace.workspace_id, + binding.workspace.branch_id, + binding.epoch, + ) + }) + } + + /// Bounded cleanup of unreferenced objects. Root, run-binding, + /// checkpoint-receipt, durable prepared-receipt, and in-flight reservation + /// refs are preserved; at most 64 recent orphans remain. The protected set + /// is read by the same existing storage worker at the command's execution + /// boundary, never from a caller snapshot. Health is recorded by the worker + /// in execution order: a failed sweep keeps bounded debt so later object + /// writes are refused with typed backpressure until an explicit successful + /// sweep recovers, instead of accumulating durable orphans. + pub fn collect_orphans(&self) -> Result<(), PluginStateError> { + self.collect_orphans_inner(true) + } + + /// Backpressure-path reclamation: runs the same bounded worker-ordered + /// collection but never clears debt on its own. Used when refusing new + /// objects so refused traffic cannot grow the disk while degraded. + pub(crate) fn collect_orphans_quiet(&self) -> Result<(), PluginStateError> { + self.collect_orphans_inner(false) + } + + fn collect_orphans_inner(&self, report_health: bool) -> Result<(), PluginStateError> { + #[cfg(any(test, feature = "workspace-io-fault"))] + if let Some(observer) = self.persistence.plugin_state_collect_observer_hook() { + observer(); + } + #[cfg(any(test, feature = "workspace-io-fault"))] + let failpoint = if self.persistence.take_plugin_state_collect_failpoint() { + "after_partial_write" + } else { + "" + }; + #[cfg(not(any(test, feature = "workspace-io-fault")))] + let failpoint = ""; + let since_epoch = self.persistence.plugin_state_candidates.health().epoch; + #[cfg(any(test, feature = "workspace-io-fault"))] + if let Some(observer) = self + .persistence + .plugin_state_collect_enqueue_observer_hook() + { + observer(report_health); + } + let result = self.persistence.plugin_state_collect_command( + &serde_json::json!({ "failpoint": failpoint, "protected_ids_json": "[]" }), + report_health, + ); + #[cfg(feature = "workspace-io-fault")] + if let Some(observer) = self.persistence.plugin_state_collect_result_observer_hook() { + observer(result.is_ok()); + } + match result { + Ok(_) => Ok(()), + Err(error) => { + // Unknown outcome (reply timeout or transport loss): mark debt + // only when the worker is gone and the request can never still + // execute. While the worker is alive, a timed-out request may + // still be queued (parked or backlogged) and the worker + // records its real outcome in execution order; a caller + // timeout must never fabricate debt, nor clear anything. A + // delayed older success can never clear a newer failure: the + // worker already recorded its own result in execution order. + if !self.persistence.is_storage_worker_running() { + self.persistence + .plugin_state_candidates + .note_collect_outcome_unknown(since_epoch); + } + Err(PluginStateError::storage(error)) + } + } + } + + #[cfg(feature = "workspace-io-fault")] + pub fn collect_orphans_result_for_test(&self) -> Result<(), PluginStateError> { + self.collect_orphans() + } + + pub fn session_nonterminal_runs(&self, session_id: &str) -> Result { + let data = self.command( + "plugin.state_session_nonterminal", + serde_json::json!({ "session_id": session_id }), + )?; + first_u64(&data, "plugin_state_unavailable") + } + + pub fn session_max_schema(&self, plugin_id: &str) -> Result { + let data = self.command( + "plugin.state_session_max_schema", + serde_json::json!({ "plugin_id": plugin_id }), + )?; + first_u64(&data, "plugin_state_unavailable") + } +} + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0) +} + +fn text_at(row: &[Value], index: usize) -> Result { + row.get(index) + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .ok_or_else(|| { + PluginStateError::new( + "plugin_state_unavailable", + "state row text field is invalid", + ) + }) +} + +fn u64_at(row: &[Value], index: usize) -> Result { + row.get(index) + .and_then(Value::as_u64) + .or_else(|| { + row.get(index) + .and_then(Value::as_i64) + .and_then(|value| u64::try_from(value).ok()) + }) + .ok_or_else(|| { + PluginStateError::new( + "plugin_state_unavailable", + "state row integer field is invalid", + ) + }) +} + +fn u32_at(row: &[Value], index: usize) -> Result { + u32::try_from(u64_at(row, index)?).map_err(|_| { + PluginStateError::new("plugin_state_invalid_schema", "state schema is invalid") + }) +} + +fn root_from_row(row: &[Value]) -> Result { + Ok(PluginStateRoot { + owner_kind: text_at(row, 0)?, + owner_id: text_at(row, 1)?, + workspace_id: text_at(row, 2)?, + branch_id: text_at(row, 3)?, + plugin_id: text_at(row, 4)?, + package_id: text_at(row, 5)?, + artifact_id: text_at(row, 6)?, + schema: u32_at(row, 7)?, + revision: u64_at(row, 8)?, + object_id: text_at(row, 9)?, + }) +} + +fn root_ref_from_row(row: &[Value]) -> Result { + Ok(PluginStateRootRef { + owner_kind: text_at(row, 0)?, + owner_id: text_at(row, 1)?, + workspace_id: text_at(row, 2)?, + branch_id: text_at(row, 3)?, + plugin_id: text_at(row, 4)?, + package_id: text_at(row, 5)?, + artifact_id: text_at(row, 6)?, + schema: u32_at(row, 7)?, + revision: u64_at(row, 8)?, + object_id: text_at(row, 9)?, + }) +} + +fn snapshot_from_row(row: &[Value]) -> Result, PluginStateError> { + if row.len() < 17 { + return Err(PluginStateError::new( + "plugin_state_unavailable", + "state binding row is truncated", + )); + } + let binding = PluginStateBinding { + run_id: text_at(row, 0)?, + session_id: text_at(row, 1)?, + workspace_id: text_at(row, 2)?, + branch_id: text_at(row, 3)?, + plugin_id: text_at(row, 4)?, + package_id: text_at(row, 5)?, + artifact_id: text_at(row, 6)?, + owner_kind: text_at(row, 7)?, + owner_id: text_at(row, 8)?, + graph_id: text_at(row, 9)?, + epoch: u64_at(row, 10)?, + schema: u32_at(row, 11)?, + revision: u64_at(row, 12)?, + object_id: text_at(row, 13)?, + checkpoint_id: text_at(row, 14)?, + }; + let document = text_at(row, 15)?; + let payload = PluginStatePayload::parse(&document)?; + Ok(Some(PluginStateSnapshot { binding, payload })) +} + +fn validate_binding( + binding: &PluginStateBinding, + request: &PluginStateBindRequest, +) -> Result<(), PluginStateError> { + let owner = match request.scope { + PluginStateScope::Run => request.run_id.as_str(), + PluginStateScope::Session => request.session_id.as_str(), + PluginStateScope::Invocation => { + return Err(PluginStateError::new( + "plugin_state_binding_conflict", + "invocation state has no durable binding", + )); + } + }; + if binding.session_id != request.session_id + || binding.workspace_id != request.workspace_id + || binding.branch_id != request.branch_id + || binding.plugin_id != request.plugin_id + || binding.package_id != request.package_id + || binding.artifact_id != request.artifact_id + || binding.owner_kind != request.scope.as_str() + || binding.owner_id != owner + || binding.graph_id != request.graph_id + || binding.schema != request.schema + { + return Err(PluginStateError::new( + "plugin_state_binding_conflict", + "existing run state binding belongs to another owner or graph", + )); + } + Ok(()) +} + +fn select_restore_root( + request: &PluginStateRestoreRequest, +) -> Result<&PluginStateRootRef, PluginStateError> { + let mut candidates = request.roots.iter().filter(|root| { + root.owner_kind == request.scope.as_str() + && root.plugin_id == request.plugin_id + && root.workspace_id == request.workspace_id + }); + let selected = candidates.next().ok_or_else(|| { + PluginStateError::new( + "plugin_state_root_missing", + "checkpoint has no root for this run or session", + ) + })?; + if candidates.next().is_some() { + return Err(PluginStateError::new( + "plugin_state_root_mismatch", + "checkpoint has multiple state roots for this owner and plugin", + )); + } + if selected.package_id != request.package_id || selected.artifact_id != request.artifact_id { + return Err(PluginStateError::new( + "plugin_state_root_mismatch", + "checkpoint state root package does not match", + )); + } + if selected.owner_id.is_empty() + || selected.branch_id.is_empty() + || selected.object_id.is_empty() + { + return Err(PluginStateError::new( + "plugin_state_root_mismatch", + "checkpoint state root owner, branch, or object is empty", + )); + } + if selected.schema == 0 || selected.schema > 64 || selected.revision == 0 { + return Err(PluginStateError::new( + "plugin_state_root_mismatch", + "checkpoint state root schema or revision is invalid", + )); + } + Ok(selected) +} + +fn first_u64(data: &Value, code: &'static str) -> Result { + let rows = crate::sqlite_storage_rows::sqlite_storage_rows(data) + .map_err(|_| PluginStateError::new(code, "state query row is invalid"))?; + rows.first() + .and_then(|row| row.first()) + .and_then(|value| { + value + .as_u64() + .or_else(|| value.as_i64().and_then(|value| u64::try_from(value).ok())) + }) + .ok_or_else(|| PluginStateError::new(code, "state query returned no value")) +} + +/// A pure migration entry must be exported by exactly one package source and +/// that file may import only `json` and `bytes` plus package-local modules. +pub fn validate_state_migration_source( + package: &crate::plugins::InstalledPackage, + entry: &str, +) -> Result<(), &'static str> { + let marker = format!("pub fn {entry}("); + let mut found: Option<&str> = None; + for (path, bytes) in &package.sources { + let Ok(source) = std::str::from_utf8(bytes) else { + continue; + }; + if source.contains(&marker) { + if found.is_some() { + return Err("plugin_state_migration_ambiguous"); + } + found = Some(path.as_str()); + } + } + let path = found.ok_or("plugin_state_migration_missing")?; + let source = package + .sources + .get(path) + .and_then(|bytes| std::str::from_utf8(bytes).ok()) + .ok_or("plugin_state_migration_missing")?; + for line in source.lines() { + let line = line.trim_start(); + let Some(rest) = line.strip_prefix("use ") else { + continue; + }; + let module = rest.split([';', ':', ' ', '{']).next().unwrap_or("").trim(); + if module.is_empty() { + continue; + } + let local = package + .sources + .keys() + .any(|key| key == &format!("{module}.rss") || key.starts_with(&format!("{module}/"))); + if !local && module != "json" && module != "bytes" { + return Err("plugin_state_migration_impure"); + } + } + Ok(()) +} diff --git a/src/runtime/agent_host.rs b/src/runtime/agent_host.rs index 34261f0..0822e37 100644 --- a/src/runtime/agent_host.rs +++ b/src/runtime/agent_host.rs @@ -60,6 +60,8 @@ const CAP_ARTIFACT_REFERENCE: &str = "cap::artifact_reference"; const CAP_CLOCK_MONOTONIC_MS: &str = "cap::clock_monotonic_ms"; const PARSE_JSON_OBJECT: &str = "agent::parse_json_object"; const PARSE_JSON_OBJECT_MAX_BYTES: usize = 64 * 1024; +const STATE_READ: &str = "state::read"; +const STATE_WRITE: &str = "state::write"; /// Combined catalog: standard host surfaces plus the agent loop bridges. pub fn agent_host_catalog() -> Arc { @@ -99,6 +101,8 @@ const AGENT_HOST_FUNCTIONS: &[fn() -> HostFunctionDescriptor] = &[ cap_artifact_reference_descriptor, cap_clock_monotonic_ms_descriptor, parse_json_object_descriptor, + state_read_descriptor, + state_write_descriptor, ]; pub fn agent_host_module() -> HostModuleDescriptor { @@ -137,6 +141,8 @@ pub(crate) fn retained_tool_host_module() -> HostModuleDescriptor { cap_artifact_reference_descriptor, cap_clock_monotonic_ms_descriptor, parse_json_object_descriptor, + state_read_descriptor, + state_write_descriptor, ], resources: &[], } @@ -504,6 +510,27 @@ fn parse_json_object_descriptor() -> HostFunctionDescriptor { ) } +fn state_read_descriptor() -> HostFunctionDescriptor { + stack_desc( + STATE_READ, + vec![], + super::host_types::AgentPluginStateSnapshot::host_type_schema(), + state_read_adapter, + ) +} + +fn state_write_descriptor() -> HostFunctionDescriptor { + stack_desc( + STATE_WRITE, + vec![ + HostParamSchema::value("expected_revision", HostTypeSchema::Int), + HostParamSchema::value("document", HostTypeSchema::String), + ], + super::host_types::AgentPluginStateWriteResult::host_type_schema(), + state_write_adapter, + ) +} + const SLEEP_CHUNK_MS: u64 = 10; const SLEEP_CAP_MS: u64 = 60_000; const SLEEP_LOG_CAP: usize = 32; @@ -573,19 +600,22 @@ pub struct PinnedToolProgram { pub(crate) managed: Option>, managed_contract: Option, managed_policy_catalog: Option>, + pub(crate) state: Option, + pub(crate) state_binding: Option, + pub(crate) state_store: Option, program: Arc, entry: String, - graph_id: String, - package_id: String, - artifact_id: String, + pub(crate) graph_id: String, + pub(crate) package_id: String, + pub(crate) artifact_id: String, export: String, - run_id: String, - session_id: String, + pub(crate) run_id: String, + pub(crate) session_id: String, profile: String, ref_id: String, generation: u64, registry_identity: String, - workspace: std::path::PathBuf, + pub(crate) workspace: std::path::PathBuf, registry: Result, String>, expects_dispatch_input: bool, } @@ -596,6 +626,7 @@ pub(crate) struct PinnedToolCode { pub(crate) managed_contract: Option, pub(crate) managed_policy_catalog: Option>, + pub(crate) state: Option, pub(crate) program: Arc, pub(crate) entry: String, pub(crate) package_id: String, @@ -632,6 +663,12 @@ impl PinnedToolProgram { && self.generation == other.generation && self.registry_identity == other.registry_identity && self.workspace == other.workspace + && self.state == other.state + && match (&self.state_binding, &other.state_binding) { + (None, None) => true, + (Some(left), Some(right)) => left == right, + _ => false, + } && match (&self.managed, &other.managed) { (None, None) => true, (Some(left), Some(right)) => Arc::ptr_eq(left, right), @@ -690,6 +727,9 @@ impl PinnedToolProgram { harness_selection: code.harness_selection, managed_contract: code.managed_contract, managed_policy_catalog: code.managed_policy_catalog, + state: code.state, + state_binding: None, + state_store: None, program: code.program, entry: code.entry, package_id: code.package_id, @@ -767,6 +807,7 @@ pub(crate) struct ExpectedCallGrant { pub risk_class: String, pub summary: String, outcome: Arc>, + pub(crate) plugin_state_call: Option, } /// Native-only evidence for one child invocation. Guest return values cannot @@ -883,6 +924,324 @@ pub struct AgentHostBridges { pub allow_stateless_pin: bool, } +/// One sealed state call. It is created only inside `invoke_pinned_tool` from +/// an already sealed pin and the run's durable binding, never from request JSON. +#[derive(Clone)] +pub struct PluginStateCall { + pub store: crate::plugins::PluginStateStore, + pub run_id: String, + pub session_id: String, + pub profile: String, + pub graph_id: String, + pub package_id: String, + pub artifact_id: String, + pub scope: crate::plugins::PluginStateScope, + pub schema: u32, + pub call_id: String, + pub can_read: bool, + pub can_write: bool, + /// True only when admission sealed a managed effect binding for this run. + /// Direct mode never has one, so guest state cannot be committed without a + /// Native effect receipt. + pub managed_effect_commit: bool, + pub cancellation: crate::RunCancellation, + staged: Arc>>, +} + +#[derive(Clone)] +pub(crate) struct StagedPluginState { + payload: crate::plugins::PluginStatePayload, + revision: u64, + publication: crate::effects::journal::ManagedPreparedStatePublication, + /// Native reservation owner for the staged object. Held from the + /// reserve-before-put until the publication is taken for prepare; the + /// prepare/commit path then carries it until the run settles. + reservation: Option, +} + +impl PluginStateCall { + fn snapshot(&self) -> JsonValue { + if !self.can_read { + return state_read_failure( + "plugin_state_undeclared", + "state read requires a declared workspace.fs.read effect", + ); + } + let staged = self + .staged + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .as_ref() + .cloned(); + if self.scope == crate::plugins::PluginStateScope::Invocation { + return json!({ + "ok": true, + "schema": self.schema, + "revision": staged.as_ref().map_or(1, |staged| staged.revision), + "document": staged.as_ref().map_or("{}", |staged| staged.payload.as_str()), + "durable": false, + "error": null, + }); + } + let binding = match self.store.binding(&self.run_id) { + Ok(Some(snapshot)) => snapshot, + Ok(None) => { + return state_read_failure( + "plugin_state_binding_missing", + "run state binding is missing", + ); + } + Err(error) => return state_read_failure(error.code, &error.message), + }; + if !self.matches(&binding.binding) { + return state_read_failure( + "plugin_state_unauthorized", + "run state binding does not match the sealed Native identity", + ); + } + if let Err(error) = + self.store + .authorize(&binding.binding, &self.profile, &self.graph_id, now_ms()) + { + return state_read_failure(error.code, &error.message); + } + json!({ + "ok": true, + "schema": staged.as_ref().map_or(binding.binding.schema, |staged| staged.publication.schema_version), + "revision": staged.as_ref().map_or(binding.binding.revision, |staged| staged.revision), + "document": staged.as_ref().map_or_else(|| binding.payload.as_str().to_string(), |staged| staged.payload.as_str().to_string()), + "error": null, + }) + } + + fn write(&self, expected_revision: i64, document: &str) -> JsonValue { + if !self.can_write { + return state_write_failure( + "plugin_state_undeclared", + "state write requires a declared workspace.fs.write effect", + ); + } + if !self.managed_effect_commit { + return state_write_failure( + "plugin_state_effect_unavailable", + "state write requires the managed effect commit", + ); + } + let Ok(expected_revision) = u64::try_from(expected_revision) else { + return state_write_failure("plugin_state_conflict", "state revision is invalid"); + }; + let payload = match crate::plugins::PluginStatePayload::parse(document) { + Ok(payload) => payload, + Err(error) => return state_write_failure(error.code, &error.message), + }; + if self.scope == crate::plugins::PluginStateScope::Invocation { + let current = self + .staged + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .as_ref() + .map_or(1, |staged| staged.revision); + if current != expected_revision { + return state_write_failure( + "plugin_state_conflict", + "state revision does not match", + ); + } + let revision = 2; + let publication = crate::effects::journal::ManagedPreparedStatePublication { + owner_kind: "invocation".into(), + owner_id: self.call_id.clone(), + workspace_id: "invocation".into(), + branch_id: "invocation".into(), + plugin_id: self.package_id.clone(), + package_id: self.package_id.clone(), + artifact_id: self.artifact_id.clone(), + schema_version: self.schema, + expected_revision: 1, + expected_object_id: "ephemeral".into(), + object_id: format!("ephemeral:{}:{}:{}", self.run_id, self.call_id, revision), + ephemeral: true, + }; + *self + .staged + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(StagedPluginState { + payload, + revision, + publication, + reservation: None, + }); + return json!({ + "ok": true, + "schema": self.schema, + "revision": revision, + "durable": false, + "error": null, + }); + } + let binding = match self.store.binding(&self.run_id) { + Ok(Some(snapshot)) => snapshot, + Ok(None) => { + return state_write_failure( + "plugin_state_binding_missing", + "run state binding is missing", + ); + } + Err(error) => return state_write_failure(error.code, &error.message), + }; + if !self.matches(&binding.binding) { + return state_write_failure( + "plugin_state_unauthorized", + "run state binding does not match the sealed Native identity", + ); + } + if let Err(error) = + self.store + .authorize(&binding.binding, &self.profile, &self.graph_id, now_ms()) + { + return state_write_failure(error.code, &error.message); + } + let current_revision = self + .staged + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .as_ref() + .map_or(binding.binding.revision, |staged| staged.revision); + if current_revision != expected_revision { + return state_write_failure("plugin_state_conflict", "state revision does not match"); + } + let (object, reservation) = match self.store.put_object_reserved( + &binding.binding.package_id, + &binding.binding.artifact_id, + binding.binding.schema, + &payload, + ) { + Ok(owned) => owned, + Err(error) => return state_write_failure(error.code, &error.message), + }; + let revision = binding.binding.revision.saturating_add(1); + let publication = crate::effects::journal::ManagedPreparedStatePublication { + owner_kind: binding.binding.owner_kind.clone(), + owner_id: binding.binding.owner_id.clone(), + workspace_id: binding.binding.workspace_id.clone(), + branch_id: binding.binding.branch_id.clone(), + plugin_id: binding.binding.plugin_id.clone(), + package_id: object.package_id.clone(), + artifact_id: object.artifact_id.clone(), + schema_version: object.schema, + expected_revision: binding.binding.revision, + expected_object_id: binding.binding.object_id.clone(), + object_id: object.object_id, + ephemeral: false, + }; + *self + .staged + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(StagedPluginState { + payload, + revision, + publication, + reservation: Some(reservation), + }); + json!({ + "ok": true, + "schema": binding.binding.schema, + "revision": revision, + "durable": false, + "error": null, + }) + } + + pub(crate) fn acquire_state_writer( + &self, + publication: &crate::effects::journal::ManagedPreparedStatePublication, + ) -> Result { + let key = crate::plugins::PluginStateRootKey { + owner_kind: publication.owner_kind.clone(), + owner_id: publication.owner_id.clone(), + workspace_id: publication.workspace_id.clone(), + branch_id: publication.branch_id.clone(), + plugin_id: publication.plugin_id.clone(), + }; + self.store.acquire_writer_key(&key, &self.cancellation) + } + + fn commit_staged_if_any(&self) -> Result { + let staged = self + .staged + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take(); + let Some(staged) = staged else { + return Ok(false); + }; + if !self.managed_effect_commit || staged.publication.ephemeral { + return Err(crate::plugins::PluginStateError::new( + "plugin_state_effect_unavailable", + "state requires the managed effect commit", + )); + } + let binding = self.store.binding(&self.run_id)?.ok_or_else(|| { + crate::plugins::PluginStateError::new( + "plugin_state_binding_missing", + "run state binding is missing", + ) + })?; + self.store + .commit_with_cancellation(&binding, &staged.payload, &self.cancellation)?; + Ok(true) + } + + pub(crate) fn take_state_publication( + &self, + ) -> Option<( + crate::effects::journal::ManagedPreparedStatePublication, + Option, + )> { + self.staged + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take() + .map(|staged| (staged.publication, staged.reservation)) + } + + fn matches(&self, binding: &crate::plugins::PluginStateBinding) -> bool { + binding.run_id == self.run_id + && binding.session_id == self.session_id + && binding.plugin_id == self.package_id + && binding.package_id == self.package_id + && binding.artifact_id == self.artifact_id + && binding.owner_kind == self.scope.as_str() + && binding.graph_id == self.graph_id + } +} + +fn state_read_failure(code: &str, message: &str) -> JsonValue { + json!({ + "ok": false, + "schema": 0, + "revision": 0, + "document": "", + "error": { "code": code, "message": message }, + }) +} + +fn state_write_failure(code: &str, message: &str) -> JsonValue { + json!({ + "ok": false, + "schema": 0, + "revision": 0, + "error": { "code": code, "message": message }, + }) +} + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0) +} + /// Per-VM state installed before `run(context)`. #[derive(Clone)] pub struct AgentHostState { @@ -902,6 +1261,7 @@ pub struct AgentHostState { pub(crate) pinned_tool: Option, pub(crate) allow_stateless_pin: bool, pub(crate) expected_call: Option, + pub plugin_state_call: Option, pub(crate) provider_arguments: Arc>>, } @@ -1021,6 +1381,7 @@ impl AgentHostState { risk_class: call.policy.expected_risk().as_str().into(), summary: "probe".into(), outcome: Arc::new(Mutex::new(PinnedCallOutcome::Pending)), + plugin_state_call: None, managed_call: Some(call), }); child.capability_prepare(changed) @@ -1282,7 +1643,7 @@ impl AgentHostState { }) }; let outcome = Arc::new(Mutex::new(PinnedCallOutcome::Pending)); - let grant = ExpectedCallGrant { + let mut grant = ExpectedCallGrant { managed_call: match pinned.managed.as_ref() { Some(binding) => match binding.register( &call_id, @@ -1312,17 +1673,37 @@ impl AgentHostState { }, managed_policy, run_id: pinned.run_id.clone(), - call_id, + call_id: call_id.clone(), tool_name, argument_digest: canonical, registry_identity: pinned.registry_identity.clone(), risk_class, summary, outcome: Arc::clone(&outcome), + plugin_state_call: None, }; if grant.call_id.is_empty() || grant.tool_name.is_empty() { return pinned_call_mismatch(); } + let plugin_state_call = if pinned.state.is_some() { + match self.plugin_state_call_for(&pinned, &call_id, grant.managed_call.is_some()) { + Some(call) => Some(call), + None => { + return pinned_outcome( + false, + "", + "", + "plugin_state_binding_missing", + "sealed run has no durable plugin state binding", + "", + ); + } + } + } else { + None + }; + grant.plugin_state_call = plugin_state_call; + let managed_call = grant.managed_call.clone(); let host = AgentHostBridges { provider: None, auth_host_factory: None, @@ -1339,7 +1720,6 @@ impl AgentHostState { pinned_tool: None, allow_stateless_pin: false, }; - let managed_call = grant.managed_call.clone(); match super::rss_runner::invoke_retained_tool( Arc::clone(&pinned.program), &pinned.entry, @@ -1363,6 +1743,62 @@ impl AgentHostState { } } + fn plugin_state_call_for( + &self, + pinned: &PinnedToolProgram, + call_id: &str, + managed_effect_commit: bool, + ) -> Option { + let store = pinned.state_store.clone()?; + let declaration = pinned.state.as_ref()?; + let scope = crate::plugins::PluginStateScope::parse(&declaration.scope)?; + let declared = |kind: &str| { + pinned.managed_contract.as_ref().is_some_and(|contract| { + contract + .effects + .iter() + .any(|effect| effect.kind == kind && effect.scope == "bound_workspace") + }) + }; + if scope == crate::plugins::PluginStateScope::Invocation { + return Some(PluginStateCall { + store, + run_id: pinned.run_id.clone(), + session_id: pinned.session_id.clone(), + profile: pinned.profile.clone(), + graph_id: pinned.graph_id.clone(), + package_id: pinned.package_id.clone(), + artifact_id: pinned.artifact_id.clone(), + scope, + schema: u32::try_from(declaration.schema).ok()?, + call_id: call_id.to_string(), + can_read: declared("workspace.fs.read"), + can_write: declared("workspace.fs.write"), + managed_effect_commit, + cancellation: self.cancellation.clone(), + staged: Arc::new(Mutex::new(None)), + }); + } + let binding = pinned.state_binding.as_ref()?; + Some(PluginStateCall { + store, + run_id: pinned.run_id.clone(), + session_id: pinned.session_id.clone(), + profile: pinned.profile.clone(), + graph_id: pinned.graph_id.clone(), + package_id: binding.package_id.clone(), + artifact_id: binding.artifact_id.clone(), + scope, + schema: binding.schema, + call_id: call_id.to_string(), + can_read: declared("workspace.fs.read"), + can_write: declared("workspace.fs.write"), + managed_effect_commit: pinned.managed.is_some(), + cancellation: self.cancellation.clone(), + staged: Arc::new(Mutex::new(None)), + }) + } + fn stateless_default_binding(&self) -> Option { let owner = self.capability_owner.as_ref()?; let lifecycle = self.lifecycle.as_ref()?; @@ -1555,12 +1991,44 @@ impl AgentHostState { *outcome = PinnedCallOutcome::Closing; } drop(outcome); + let state_call = self + .expected_call + .as_ref() + .and_then(|grant| grant.plugin_state_call.as_ref()); + let managed_state_commit = self + .expected_call + .as_ref() + .and_then(|grant| grant.managed_call.as_ref()) + .is_some(); + if !managed_state_commit + && let Some(call) = state_call + && let Err(error) = call.commit_staged_if_any() + { + return typed_fail(error.code, &error.message); + } + let (state_publication, state_owner) = if managed_state_commit { + state_call + .and_then(|call| call.take_state_publication()) + .map(|(publication, owner)| (Some(publication), owner)) + .unwrap_or((None, None)) + } else { + (None, None) + }; + let _state_writer = match (&state_publication, state_call) { + (Some(publication), Some(call)) if !publication.ephemeral => { + match call.acquire_state_writer(publication) { + Ok(lease) => Some(lease), + Err(error) => return typed_fail(error.code, &error.message), + } + } + _ => None, + }; let envelope = if let Some(call) = self .expected_call .as_ref() .and_then(|grant| grant.managed_call.as_ref()) { - match call.prepare_result(token, result) { + match call.prepare_result(token, result, state_publication, state_owner) { Ok(prepared) => tool_commit(lifecycle, owner, token, prepared.original.clone()), Err(message) => typed_fail("managed_prepare_failed", &message), } @@ -2148,6 +2616,52 @@ fn parse_json_object_adapter(_vm: &mut Vm, args: &[Value]) -> VmResult VmResult { + let state = installed_state(vm)?; + if let Some(error) = state.control_error() { + return return_json(error); + } + let Some(call) = state.plugin_state_call.as_ref() else { + return return_json(state_read_failure( + "plugin_state_unauthorized", + "no sealed Native state call is installed", + )); + }; + return_json(call.snapshot()) +} + +fn state_write_adapter(vm: &mut Vm, args: &[Value]) -> VmResult { + let state = installed_state(vm)?; + if let Some(error) = state.control_error() { + return return_json(error); + } + let expected_revision = match args.first() { + Some(Value::Int(value)) => *value, + _ => { + return return_json(state_write_failure( + "plugin_state_conflict", + "expected revision is not an integer", + )); + } + }; + let document = match args.get(1) { + Some(Value::String(value)) => value.as_str(), + _ => { + return return_json(state_write_failure( + "plugin_state_invalid_document", + "state document is not a string", + )); + } + }; + let Some(call) = state.plugin_state_call.as_ref() else { + return return_json(state_write_failure( + "plugin_state_unauthorized", + "no sealed Native state call is installed", + )); + }; + return_json(call.write(expected_revision, document)) +} + fn malformed_json_object() -> JsonValue { json!({ "ok": false, @@ -3261,6 +3775,7 @@ pub fn b1_send( pinned_tool: None, allow_stateless_pin: false, expected_call: None, + plugin_state_call: None, provider_arguments: Default::default(), }); let context = auth_host.bind_vm(&mut vm).expect("bind genuine auth owner"); diff --git a/src/runtime/host_types.rs b/src/runtime/host_types.rs index 471fe97..c5f884e 100644 --- a/src/runtime/host_types.rs +++ b/src/runtime/host_types.rs @@ -285,3 +285,26 @@ named_struct!( ("error", optional(named::())) ] ); + +named_struct!( + AgentPluginStateSnapshot, + "AgentPluginStateSnapshot", + [ + ("ok", HostTypeSchema::Bool), + ("schema", HostTypeSchema::Int), + ("revision", HostTypeSchema::Int), + ("document", HostTypeSchema::String), + ("error", optional(named::())) + ] +); + +named_struct!( + AgentPluginStateWriteResult, + "AgentPluginStateWriteResult", + [ + ("ok", HostTypeSchema::Bool), + ("schema", HostTypeSchema::Int), + ("revision", HostTypeSchema::Int), + ("error", optional(named::())) + ] +); diff --git a/src/runtime/native_tools.rs b/src/runtime/native_tools.rs index 5f69c17..cbaf944 100644 --- a/src/runtime/native_tools.rs +++ b/src/runtime/native_tools.rs @@ -77,6 +77,7 @@ impl NativeToolRuntime { }, managed_contract: None, managed_policy_catalog: None, + state: None, program: Arc::clone(&self.program), entry: self.entry.clone(), package_id: "embedding".to_string(), diff --git a/src/runtime/rss_runner.rs b/src/runtime/rss_runner.rs index 2895ba4..39241f7 100644 --- a/src/runtime/rss_runner.rs +++ b/src/runtime/rss_runner.rs @@ -1484,6 +1484,10 @@ impl AgentRunner { pinned_tool: self.host.pinned_tool.clone(), allow_stateless_pin: self.host.allow_stateless_pin, expected_call: self.expected_call.clone(), + plugin_state_call: self + .expected_call + .as_ref() + .and_then(|grant| grant.plugin_state_call.clone()), provider_arguments: Default::default(), }); let auth_binding = if let Some(factory) = &self.host.auth_host_factory { @@ -1700,6 +1704,17 @@ thread_local! { pub(crate) fn eval_captured_pure_export( program: Arc, entry: &str, +) -> std::result::Result { + eval_captured_pure_export_with_input(program, entry, serde_json::json!({})) +} + +/// Pure evaluation with one typed, resource-free input record. The compiler +/// still sees an empty import table; callers cannot smuggle host capabilities +/// through the argument. +pub(crate) fn eval_captured_pure_export_with_input( + program: Arc, + entry: &str, + input: serde_json::Value, ) -> std::result::Result { if !program.imports.is_empty() { return Err("managed pure export imports denied".into()); @@ -1721,7 +1736,7 @@ pub(crate) fn eval_captured_pure_export( .resolve_exported_callable(entry) .map_err(|e| e.to_string())?; let mut invocation = vm - .start_invocation(callable, vec![json_to_vm_value(&serde_json::json!({}))]) + .start_invocation(callable, vec![json_to_vm_value(&input)]) .map_err(|e| e.to_string())?; let mut cx = Context::from_waker(Waker::noop()); match invocation diff --git a/src/service.rs b/src/service.rs index 9cf9359..93cf011 100644 --- a/src/service.rs +++ b/src/service.rs @@ -918,6 +918,10 @@ enum AdmissionClockPoint { #[cfg(test)] type AdmissionClockObserver = Arc; +/// Test-only seam for observing prepared migration candidates without +/// changing any producer behavior. +type StateCandidateObserver = Arc; + #[cfg(test)] pub(crate) struct WorkerProbeGuard { finish: Option>, @@ -981,10 +985,14 @@ struct AgentServiceInner { capability_host_shutdown: Mutex>>, capability_host_init_entered: Mutex>>, prompt_read_entered: Mutex>>, + /// Test seam: observes every prepared session state migration candidate + /// object id before its durable admission transaction. It never changes + /// preparation, admission, or collection behavior. + state_candidate_observer: Mutex>, #[cfg(test)] admission_clock_observer: Mutex>, /// cfg(test) rendezvous after the pending-owner check and before commit_gate. - #[cfg(test)] + #[cfg(any(test, feature = "workspace-io-fault"))] admission_commit_boundary: Mutex>>, /// cfg(test) async barrier after the pending-owner check and before a permit. #[cfg(test)] @@ -996,7 +1004,7 @@ struct AgentServiceInner { shutdown_entered_hook: Mutex>>, #[cfg(test)] shutdown_waiting_for_gate: AtomicBool, - #[cfg(test)] + #[cfg(any(test, feature = "workspace-io-fault"))] #[allow(clippy::type_complexity)] original_proof_read_hook: Mutex>>, date_source: RwLock>, @@ -1228,9 +1236,10 @@ impl AgentService { capability_host_shutdown: Mutex::new(None), capability_host_init_entered: Mutex::new(None), prompt_read_entered: Mutex::new(None), + state_candidate_observer: Mutex::new(None), #[cfg(test)] admission_clock_observer: Mutex::new(None), - #[cfg(test)] + #[cfg(any(test, feature = "workspace-io-fault"))] admission_commit_boundary: Mutex::new(None), #[cfg(test)] admission_overlap_barrier: Mutex::new(None), @@ -1240,7 +1249,7 @@ impl AgentService { shutdown_entered_hook: Mutex::new(None), #[cfg(test)] shutdown_waiting_for_gate: AtomicBool::new(false), - #[cfg(test)] + #[cfg(any(test, feature = "workspace-io-fault"))] original_proof_read_hook: Mutex::new(None), date_source: RwLock::new(Arc::new(SystemDateSource)), provider_host: Mutex::new(None), @@ -1304,6 +1313,42 @@ impl AgentService { *self.inner.provider_host.lock().expect("provider host lock") = Some(host); } + /// Test seam: observes each prepared session state migration candidate + /// object id after `put_object` and before the durable admission + /// transaction. It is inert until armed and never mutates admission. + pub fn observe_state_candidates(&self, observer: StateCandidateObserver) { + *self + .inner + .state_candidate_observer + .lock() + .expect("state candidate observer lock") = Some(observer); + } + + /// Test seam: rendezvous before the commit gate (and once at admission + /// start). Inert until armed; never changes admission state. + #[cfg(any(test, feature = "workspace-io-fault"))] + pub fn observe_admission_commit_boundary(&self, hook: Arc) { + *self + .inner + .admission_commit_boundary + .lock() + .expect("admission commit boundary") = Some(hook); + } + + /// Test seam: observes each read performed by the original-proof + /// reconstruction after a response-lost admission. The second argument is + /// true when the global commit gate is free at that read. It never changes + /// the proof, the transaction, or publication. + #[cfg(any(test, feature = "workspace-io-fault"))] + #[allow(clippy::type_complexity)] + pub fn observe_original_proof_reads(&self, hook: Arc) { + *self + .inner + .original_proof_read_hook + .lock() + .expect("original proof read hook") = Some(hook); + } + /// Test seam: inject one private RSS-policy diagnostic at compaction. pub fn inject_compaction_policy_failure(&self, detail: impl Into) { *self @@ -2047,7 +2092,7 @@ impl AgentService { fn reject_unselected_graph( &self, persistence: &GatewayPersistence, - store: &crate::plugins::PackageStore, + _store: &crate::plugins::PackageStore, graph_id: &str, ) -> AdmitError { let control = crate::plugins::PluginControlStore::new(persistence); @@ -2056,19 +2101,10 @@ impl AgentService { Ok(None) => {} Err(error) => return AdmitError::Persistence(error.message.to_string()), } - let Ok(loaded) = control.load_lock(graph_id) else { + let Ok(_loaded) = control.load_lock(graph_id) else { return AdmitError::PluginGraphPinRequired; }; - if loaded.lock.packages.iter().any(|package| { - store - .read_installed(&package.artifact_id) - .ok() - .is_some_and(|installed| installed.manifest.state.is_some()) - }) { - AdmitError::Invalid("plugin_state_unsupported".into()) - } else { - AdmitError::PluginGraphPinRequired - } + AdmitError::PluginGraphPinRequired } fn restore_pinned_tool( @@ -5506,7 +5542,7 @@ impl AgentService { self.inner.capacity.available_permits() } - #[cfg(test)] + #[cfg(any(test, feature = "workspace-io-fault"))] fn observe_original_proof_read(&self, op: &str) { let hook = self.inner.original_proof_read_hook.lock().unwrap().clone(); if let Some(hook) = hook { @@ -5564,7 +5600,7 @@ impl AgentService { capacity_permit: OwnedSemaphorePermit, restored: Option<&managed_checkpoints::ManagedRestoreAdmission>, ) -> Result { - #[cfg(test)] + #[cfg(any(test, feature = "workspace-io-fault"))] if let Some(hook) = self.inner.admission_commit_boundary.lock().unwrap().clone() { hook(); } @@ -6147,6 +6183,59 @@ impl AgentService { } else { None }; + let state_admission = if let Some(pin) = prepared_pin.as_ref() { + if pin.has_checkpoint_state() { + None + } else { + let (state_workspace_id, state_branch_id, state_epoch) = + if let Some(owner) = managed_binding.as_ref() { + ( + owner.admitted_branch.workspace_id.clone(), + owner.admitted_branch.branch_id.clone(), + owner.admitted_epoch(), + ) + } else { + ( + pinned_workspace.to_string_lossy().into_owned(), + "default".to_string(), + 0, + ) + }; + self.prepare_admitted_pin_state( + &plugin_admin::StateMigrationIdentity { + session_id: &session_id, + run_id: &run_id, + graph_id: &pin.graph_id, + generation: pin.generation, + ref_id: &pin.ref_id, + epoch: state_epoch, + }, + pin, + &state_workspace_id, + &state_branch_id, + &cancellation, + )? + } + } else { + None + }; + let state_migration = state_admission + .as_ref() + .and_then(plugin_admin::PreparedStateAdmission::migration); + if let Some(candidate) = state_admission + .as_ref() + .and_then(plugin_admin::PreparedStateAdmission::candidate_object_id) + { + let observer = self + .inner + .state_candidate_observer + .lock() + .expect("state candidate observer lock") + .clone(); + if let Some(observer) = observer { + observer(candidate); + } + } let mut payload = json!({ "session_id": session_id, "session_new": if session_new { 1 } else { 0 }, @@ -6186,6 +6275,26 @@ impl AgentService { "tool_registry_identity": snapshot.registry.identity(), "tool_workspace": pinned_workspace.display().to_string(), }); + if let Some(migration) = state_migration + && migration.has_root_cas() + { + payload["plugin_state_migration_json"] = json!( + migration + .wire_json() + .map_err(|code| AdmitError::Invalid(code.into()))? + .to_string() + ); + } + if let Some(guard) = state_admission + .as_ref() + .and_then(plugin_admin::PreparedStateAdmission::guard_json) + { + payload["plugin_state_guard_json"] = json!( + guard + .map_err(|code| AdmitError::Invalid(code.into()))? + .to_string() + ); + } if restored.is_none() && let Some((_, Some(checkpoint))) = selected_branch.as_ref() && prepared_pin @@ -6275,7 +6384,7 @@ impl AgentService { // drift alone never invalidates; only relevant prepared inputs // trigger a reprepare outside the gate within the original budget. { - #[cfg(test)] + #[cfg(any(test, feature = "workspace-io-fault"))] if let Some(hook) = self.inner.admission_commit_boundary.lock().unwrap().clone() { hook(); } @@ -6356,7 +6465,10 @@ impl AgentService { .admission_rejected(AdmitRejectReason::ParentNotFound); return Err(AdmitError::ParentNotFound); } - if cur_maintenance { + if cur_maintenance + && !state_migration + .is_some_and(|migration| migration.session_id() == session_id) + { return Err(AdmitError::SessionMaintenanceBusy( CompactionError::from_rss("busy", "session maintenance is in progress"), )); @@ -6527,7 +6639,12 @@ impl AgentService { &origin.management_cancel, ) } else { - persistence.admission_create(&payload) + persistence.admission_create_with_owner( + &payload, + state_admission.as_ref().and_then( + plugin_admin::PreparedStateAdmission::candidate_owner, + ), + ) }; // Only relevant activation drift after a rolled-back // attempt may reprepare. A committed run, including a @@ -6567,6 +6684,7 @@ impl AgentService { } } } + // A committed original whose live receipt was lost is not // activation drift. Reconcile that same capture and publish // it once; never reprepare the now-active graph. @@ -6753,6 +6871,9 @@ impl AgentService { &data, snapshot.registry.identity().to_string(), pinned_workspace.clone(), + service.inner.persistence.as_ref().map(|persistence| { + crate::plugins::PluginStateStore::open(Arc::clone(persistence)) + }), ) .map_err(|error| AdmitError::Persistence(error.to_string()))?, ); @@ -7483,7 +7604,7 @@ impl AgentService { return Err(closed("unpublished_original_admission_unresolved")); } let budget = GatewayPersistence::original_proof_budget(original); - #[cfg(test)] + #[cfg(any(test, feature = "workspace-io-fault"))] self.observe_original_proof_read("admission.replay"); let mut data = persistence .admission_replay_for_proof(scope, key, request_hash, request_selector, budget) @@ -7524,7 +7645,7 @@ impl AgentService { { return Err(closed("unpublished_original_admission_mismatch")); } - #[cfg(test)] + #[cfg(any(test, feature = "workspace-io-fault"))] self.observe_original_proof_read("run.get"); let run = persistence .run_get_for_proof(run_id, budget) @@ -7544,7 +7665,7 @@ impl AgentService { return Err(closed("unpublished_original_admission_mismatch")); } let control = crate::plugins::PluginControlStore::new(persistence); - #[cfg(test)] + #[cfg(any(test, feature = "workspace-io-fault"))] self.observe_original_proof_read("plugin.pin_for_owner"); let stored_pin = control .pin_for_run_for_proof(run_id, budget) @@ -7556,7 +7677,7 @@ impl AgentService { { return Err(closed("unpublished_original_admission_mismatch")); } - #[cfg(test)] + #[cfg(any(test, feature = "workspace-io-fault"))] self.observe_original_proof_read("plugin.pin_requirement_get"); let required = control .pin_requirement_for_run_for_proof(run_id, budget) @@ -7577,7 +7698,7 @@ impl AgentService { { return Err(closed("unpublished_original_admission_mismatch")); } - #[cfg(test)] + #[cfg(any(test, feature = "workspace-io-fault"))] self.observe_original_proof_read("plugin.harness_binding_matches"); if !control .harness_binding_matches_for_proof( diff --git a/src/service/managed_checkpoints.rs b/src/service/managed_checkpoints.rs index 9ffaa83..8756339 100644 --- a/src/service/managed_checkpoints.rs +++ b/src/service/managed_checkpoints.rs @@ -79,7 +79,6 @@ pub(super) fn validate_captured_inventory( }; if installed.manifest.id != code.package_id || !trust_ok - || installed.manifest.state.is_some() || !installed.manifest.dependencies.is_empty() || installed.manifest.effects.entries.values().any(|entry| { !entry.delegated.is_empty() @@ -999,6 +998,134 @@ impl AgentService { runtime .verify_tree(&frozen_root.object_id) .map_err(|_| failure("managed_artifact_unavailable"))?; + let state_declaration = installed.manifest.state.as_ref(); + let stateful = state_declaration.is_some(); + let invocation_stateful = + state_declaration.is_some_and(|state| state.scope == "invocation"); + if stateful + && !invocation_stateful + && !matches!( + state_declaration.map(|state| state.scope.as_str()), + Some("run" | "session") + ) + { + return Err(failure("managed_state_unsupported")); + } + let state_binding = handle + .pinned_tool + .as_ref() + .and_then(|pin| pin.state_binding.as_ref()); + if stateful && !invocation_stateful && state_binding.is_none() { + return Err(failure("managed_plugin_state_unavailable")); + } + let _state_freeze = if stateful && !invocation_stateful { + let state_store = self + .inner + .persistence + .as_ref() + .map(|persistence| crate::plugins::PluginStateStore::open(Arc::clone(persistence))) + .ok_or_else(|| failure("managed_plugin_state_unavailable"))?; + Some( + state_store + .freeze_writer( + state_binding.ok_or("managed_plugin_state_unavailable")?, + &cancellation, + ) + .map_err(|_| failure("managed_checkpoint_busy"))?, + ) + } else { + None + }; + let state_roots = self + .inner + .persistence + .as_ref() + .map(|persistence| { + crate::plugins::PluginStateStore::open(Arc::clone(persistence)) + .checkpoint_roots(&request.source_run_id) + }) + .transpose() + .map_err(|_| failure("managed_plugin_state_unavailable"))? + .unwrap_or_default(); + if stateful && !invocation_stateful && state_roots.is_empty() { + return Err(failure("managed_plugin_state_unavailable")); + } + if !stateful && !state_roots.is_empty() { + return Err(failure("managed_plugin_state_unavailable")); + } + if invocation_stateful && !state_roots.is_empty() { + return Err(failure("managed_plugin_state_unavailable")); + } + let plugin_state_coverage = if stateful { + ManagedCheckpointStateCoverage::Stateful { + graph_id: code.graph_id.clone(), + package_id: code.package_id.clone(), + artifact_id: code.artifact_id.clone(), + manifest_state_present: true, + invocation_excluded_from_checkpoint: invocation_stateful, + roots: state_roots + .into_iter() + .map(|root| { + let scope = match root.owner_kind.as_str() { + "run" => "run".to_string(), + _ => "session".to_string(), + }; + ManagedPluginStateRootRef { + owner_kind: root.owner_kind, + owner_id: root.owner_id, + workspace_id: root.workspace_id, + branch_id: root.branch_id, + plugin_id: root.plugin_id, + package_id: root.package_id, + artifact_id: root.artifact_id, + scope, + schema: root.schema.to_string(), + revision: root.revision, + immutable_root_id: root.object_id, + } + }) + .collect(), + } + } else { + match &selected { + crate::plugins::HarnessSelection::ContextPrompt { policy, .. } => { + ManagedCheckpointStateCoverage::StatelessBuiltinWithPurePolicy { + graph_id: code.graph_id.clone(), + package_id: code.package_id.clone(), + artifact_id: code.artifact_id.clone(), + policy: policy.clone(), + manifest_state_absent: true, + state_effects_absent: true, + policy_host_imports_absent: true, + roots: vec![], + } + } + _ => ManagedCheckpointStateCoverage::StatelessBuiltin { + graph_id: code.graph_id.clone(), + package_id: code.package_id.clone(), + artifact_id: code.artifact_id.clone(), + manifest_state_absent: true, + state_effects_absent: true, + roots: vec![], + }, + } + }; + let policy_revision: String = if invocation_stateful { + "stateful_invocation_ephemeral".into() + } else if stateful { + if state_declaration.map(|state| state.scope.as_str()) == Some("run") { + "stateful_run".into() + } else { + "stateful_session".into() + } + } else { + match &selected { + crate::plugins::HarnessSelection::ContextPrompt { .. } => { + "stateless_builtin_with_pure_policy".into() + } + _ => "stateless_builtin".into(), + } + }; let checkpoint_id = Uuid::new_v4().to_string(); let last = context .messages @@ -1046,38 +1173,11 @@ impl AgentService { system_instructions: run_context.system_prompt, registry_identity: code.registry_identity.clone(), tool_schemas_ref: code.artifact_id.clone(), - policy_revision: match selected { - crate::plugins::HarnessSelection::ContextPrompt { .. } => { - "stateless_builtin_with_pure_policy" - } - _ => "stateless_builtin", - } - .into(), + policy_revision: policy_revision.clone(), publication_policy: ManagedCheckpointPublication::ManagedOnly, }, }, - plugin_state: match &selected { - crate::plugins::HarnessSelection::ContextPrompt { policy, .. } => { - ManagedCheckpointStateCoverage::StatelessBuiltinWithPurePolicy { - graph_id: code.graph_id, - package_id: code.package_id, - artifact_id: code.artifact_id, - policy: policy.clone(), - manifest_state_absent: true, - state_effects_absent: true, - policy_host_imports_absent: true, - roots: vec![], - } - } - _ => ManagedCheckpointStateCoverage::StatelessBuiltin { - graph_id: code.graph_id, - package_id: code.package_id, - artifact_id: code.artifact_id, - manifest_state_absent: true, - state_effects_absent: true, - roots: vec![], - }, - }, + plugin_state: plugin_state_coverage, external_boundary: ManagedExternalBoundary { high_water: context.external, not_reverted: vec![ diff --git a/src/service/managed_effects.rs b/src/service/managed_effects.rs index b71ece7..0ae9635 100644 --- a/src/service/managed_effects.rs +++ b/src/service/managed_effects.rs @@ -645,10 +645,27 @@ pub(super) fn commit_result( let request = json!({"effect_id":call.effect_id,"receipt_id":call.receipt_id,"commit_id":call.receipt_id,"run_id":committer.run_id,"cut":cut, "workspace_id":call.identity.workspace.workspace_id,"branch_id":call.identity.workspace.branch_id,"prepared_json":prepared_json, "projections_json":projections.to_string(),"max_events":committer.max_events_per_run}); + let commit_owner = witness + .prepared + .record + .plugin_state + .as_ref() + .filter(|publication| !publication.ephemeral) + .and_then(|publication| { + binding + .runtime + .repository + .try_reserve_state_owner(&publication.object_id) + }); let outcome = match binding .runtime .repository - .command_with_cancellation("effect.commit", &request, &binding.cancel) + .command_with_cancellation_and_owner( + "effect.commit", + &request, + &binding.cancel, + commit_owner, + ) .and_then(|value| crate::effects::journal::decode_record::(&value)) { Ok(Some(record)) => record.outcome, diff --git a/src/service/managed_protocol_tests.rs b/src/service/managed_protocol_tests.rs index c12c86f..7ed0ff2 100644 --- a/src/service/managed_protocol_tests.rs +++ b/src/service/managed_protocol_tests.rs @@ -633,10 +633,7 @@ async fn checkpoint_inventory_uses_actual_captured_manifest_and_complete_effect_ &(manifest + "\nstate:\n scope: run\n schema: 1\n"), ) .unwrap(); - assert_eq!( - validate(&declared, code).unwrap_err(), - "managed_state_unsupported" - ); + validate(&declared, code).expect("declared run state is now versioned inventory"); let mut state_effect = actual.clone(); state_effect .manifest diff --git a/src/service/plugin_admin.rs b/src/service/plugin_admin.rs index 5e70610..c3bc815 100644 --- a/src/service/plugin_admin.rs +++ b/src/service/plugin_admin.rs @@ -2,15 +2,17 @@ //! Signatures, graph ids, and request JSON do not grant authority. use std::path::{Component, Path, PathBuf}; +use std::sync::Arc; use serde::Deserialize; use serde_json::{Value, json}; -use super::AgentService; +use super::{AdmitError, AgentService}; +use crate::GatewayPersistence; use crate::plugins::{ HOST_RUNTIME, HarnessSelection, HarnessSelectionRequest, InstallAuthorization, PackageArtifactRef, PackageRequirement, PackageTrust, PluginControlStore, PluginErrorKind, - ResolveRequest, + PluginStateObject, ResolveRequest, }; use crate::runtime::rss_runner::RunCancellation; @@ -20,6 +22,202 @@ pub(crate) struct PluginControlBinding { deployment_space: String, } +/// Prepared (never published) session schema migration for one admission. +/// The lease keeps another same-session admission or maintenance action out +/// until the durable admission transaction succeeds or fails. +pub(crate) struct PreparedStateMigration { + session_id: String, + run_id: String, + workspace_id: String, + branch_id: String, + plugin_id: String, + package_id: String, + artifact_id: String, + graph_id: String, + generation: u64, + ref_id: String, + epoch: u64, + old_schema: u32, + expected_revision: u64, + expected_object_id: String, + object: Option, + state_store: crate::plugins::PluginStateStore, + _candidate: Option, + _lease: Option, +} + +impl Drop for PreparedStateMigration { + fn drop(&mut self) { + // Settle order is explicit: release the session lease, drop the + // candidate protection, then run the existing bounded typed collector + // for the now-abandoned object. This drop runs only after the + // admission's commit-gate guard has been released: the guard is + // declared in a later inner scope, and every return/continue path + // leaves that scope before this loop-body local is dropped. The + // collection therefore never blocks while the global gate is held. + let _ = self._lease.take(); + let _ = self._candidate.take(); + if self.object.is_some() { + let _ = self.state_store.collect_orphans(); + } + } +} + +impl PreparedStateMigration { + pub(crate) fn session_id(&self) -> &str { + &self.session_id + } + /// Clone of the unsettled migration candidate owner for the admission + /// request sidecar. The migration holder keeps its own reference; the + /// request sidecar keeps the id protected until the worker settles the + /// admission even after this holder is released by a caller timeout. + pub(crate) fn owner(&self) -> Option { + self._candidate.clone() + } + pub(crate) fn has_root_cas(&self) -> bool { + self.object.is_some() + } + + pub(crate) fn object_id(&self) -> Option<&str> { + self.object.as_ref().map(|object| object.object_id.as_str()) + } + + pub(crate) fn wire_json(&self) -> Result { + let object = self + .object + .as_ref() + .ok_or("plugin_state_migration_missing")?; + let epoch = i64::try_from(self.epoch).map_err(|_| "plugin_state_invalid")?; + let generation = i64::try_from(self.generation).map_err(|_| "plugin_state_invalid")?; + Ok(json!({ + "run_id": self.run_id, + "session_id": self.session_id, + "owner_kind": "session", + "workspace_id": self.workspace_id, + "branch_id": self.branch_id, + "plugin_id": self.plugin_id, + "package_id": self.package_id, + "artifact_id": self.artifact_id, + "graph_id": self.graph_id, + "generation": generation, + "ref_id": self.ref_id, + "epoch": epoch, + "old_schema_version": self.old_schema, + "schema_version": object.schema, + "expected_revision": self.expected_revision, + "expected_object_id": self.expected_object_id, + "object_id": object.object_id, + "now_ms": now_ms(), + })) + } +} + +/// Same-schema session state admission guard. The exact live root identity is +/// captured from the actual root read during preparation and re-verified inside +/// the durable admission transaction, so a real schema switch that lands +/// between preparation and commit fails the admission closed instead of +/// admitting a run against a root whose schema no longer matches the pinned +/// graph. The expected identity is the live root's package/artifact/revision/ +/// object, never the new pin's artifact: a compatible new artifact revision of +/// the same package and schema must keep reusing the existing root and data. +/// Guarded admissions never take the maintenance lease or wait for other runs. +pub(crate) struct PluginStateGuard { + session_id: String, + workspace_id: String, + branch_id: String, + plugin_id: String, + package_id: String, + artifact_id: String, + revision: u64, + object_id: String, + schema: u32, +} + +impl PluginStateGuard { + fn from_live_root( + session_id: &str, + workspace_id: &str, + branch_id: &str, + root: &crate::plugins::PluginStateRoot, + ) -> Self { + Self { + session_id: session_id.to_string(), + workspace_id: workspace_id.to_string(), + branch_id: branch_id.to_string(), + plugin_id: root.plugin_id.clone(), + package_id: root.package_id.clone(), + artifact_id: root.artifact_id.clone(), + revision: root.revision, + object_id: root.object_id.clone(), + schema: root.schema, + } + } + + pub(crate) fn wire_json(&self) -> Result { + let revision = i64::try_from(self.revision).map_err(|_| "plugin_state_invalid")?; + Ok(json!({ + "session_id": self.session_id, + "workspace_id": self.workspace_id, + "branch_id": self.branch_id, + "plugin_id": self.plugin_id, + "package_id": self.package_id, + "artifact_id": self.artifact_id, + "revision": revision, + "object_id": self.object_id, + "schema_version": self.schema, + })) + } +} + +/// What one admission must do with the session state root: verify it is still +/// the same schema (no maintenance lease), or perform a real migration under +/// the maintained lease. +pub(crate) enum PreparedStateAdmission { + Guard(PluginStateGuard), + Migration(Box), +} + +impl PreparedStateAdmission { + pub(crate) fn migration(&self) -> Option<&PreparedStateMigration> { + match self { + Self::Guard(_) => None, + Self::Migration(migration) => Some(migration.as_ref()), + } + } + + pub(crate) fn guard_json(&self) -> Option> { + match self { + Self::Guard(guard) => Some(guard.wire_json()), + Self::Migration(_) => None, + } + } + + pub(crate) fn candidate_object_id(&self) -> Option<&str> { + self.migration().and_then(PreparedStateMigration::object_id) + } + + pub(crate) fn candidate_owner(&self) -> Option { + self.migration().and_then(PreparedStateMigration::owner) + } +} + +/// Admission identity selected for the exact authorized session branch. +pub(crate) struct StateMigrationIdentity<'a> { + pub session_id: &'a str, + pub run_id: &'a str, + pub graph_id: &'a str, + pub generation: u64, + pub ref_id: &'a str, + pub epoch: u64, +} + +fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0) +} + #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] struct InstallBody { @@ -279,6 +477,7 @@ impl AgentService { .ok_or("plugin_activation_missing")?; let lock = self.prepared_lock(&entry.graph_id)?; self.ensure_current(&lock)?; + self.require_rollback_state_compatible(&lock, persistence)?; let current = control.activation().map_err(map_control)?; let receipt = control .activate(&lock.graph_id, current.generation, &artifact_refs(&lock)) @@ -380,7 +579,7 @@ impl AgentService { if loaded.lock.graph_id != graph_id { return Err("plugin_graph_missing"); } - self.reject_stateful(&loaded.lock)?; + self.validate_stateful(&loaded.lock)?; self.require_artifacts(&loaded.lock)?; Ok(loaded.lock) } @@ -398,7 +597,7 @@ impl AgentService { fn ensure_current(&self, lock: &crate::plugins::GraphLock) -> Result<(), &'static str> { let store = self.tool_store().ok_or("plugin_artifact_unavailable")?; - self.reject_stateful(lock)?; + self.validate_stateful(lock)?; self.require_artifacts(lock)?; let prepared = store.prepare(lock).map_err(|_| "plugin_prepare_failed")?; if prepared.program(tool_package(lock)?).is_none() { @@ -407,14 +606,318 @@ impl AgentService { Ok(()) } - fn reject_stateful(&self, lock: &crate::plugins::GraphLock) -> Result<(), &'static str> { + pub fn switch_plugin_state_schema( + &self, + session_id: &str, + graph_id: &str, + cancellation: &RunCancellation, + ) -> Result, &'static str> { + validate_public_id(session_id)?; + let lock = self.prepared_lock(graph_id)?; + let package_id = tool_package(&lock)?.to_string(); let store = self.tool_store().ok_or("plugin_artifact_unavailable")?; + let installed = store + .read_installed( + &lock + .packages + .iter() + .find(|package| package.id == package_id) + .ok_or("plugin_artifact_unavailable")? + .artifact_id, + ) + .map_err(|_| "plugin_artifact_unavailable")?; + let Some(state) = installed.manifest.state.as_ref() else { + return Ok(None); + }; + if state.scope != "session" { + return Ok(None); + } + let target_schema = u32::try_from(state.schema).map_err(|_| "plugin_state_invalid")?; + // Same linearization rule as admission migration: publish the lease in + // the short commit_gate section, then do every read/write outside it. + let _lease = { + let _gate = self.inner.commit_gate.lock(); + cancellation.ensure_open().map_err(|_| "cancelled")?; + super::SessionMaintenanceLease::acquire( + Arc::clone(&self.inner.session_maintenance), + session_id, + ) + .ok_or("session_busy")? + }; + let persistence = self + .inner + .persistence + .as_ref() + .ok_or("plugin_control_unconfigured")?; + let state_store = crate::plugins::PluginStateStore::open(Arc::clone(persistence)); + loop { + cancellation.ensure_open().map_err(|_| "cancelled")?; + if state_store + .session_nonterminal_runs(session_id) + .map_err(|_| "plugin_state_unavailable")? + == 0 + { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + let Some(key) = state_store + .session_root_key(session_id, &package_id) + .map_err(|_| "plugin_state_unavailable")? + else { + return Ok(None); + }; + let root = state_store + .root(&key) + .map_err(|_| "plugin_state_unavailable")? + .ok_or("plugin_state_root_missing")?; + if root.schema == target_schema { + return Ok(Some(root.revision)); + } + if root.schema > target_schema { + return Err("state_incompatible"); + } + let prepared = store.prepare(&lock).map_err(|_| "plugin_prepare_failed")?; + let migration = prepared + .state_migration(&package_id) + .ok_or("state_incompatible")?; + let old = state_store + .object_payload(&root.object_id) + .map_err(|_| "plugin_state_object_missing")?; + let payload = migration + .evaluate(root.schema, &old, cancellation) + .map_err(|_| "plugin_state_migration_failed")?; + cancellation.ensure_open().map_err(|_| "cancelled")?; + let (object, owner) = state_store + .put_object_reserved(&package_id, &installed.artifact_id, target_schema, &payload) + .map_err(|_| "plugin_state_unavailable")?; + cancellation.ensure_open().map_err(|_| "cancelled")?; + let switched = state_store + .migrate_root_with_owner(&key, root.revision, &object, Some(owner)) + .map_err(|error| error.code)?; + Ok(Some(switched.revision)) + } + + /// Prepares the pure migration for the exact authorized session root of the + /// run being admitted. The payload and its immutable object are written + /// outside the global admission gate; the root CAS and run binding are + /// published by the durable admission transaction itself. The session + /// maintenance lease is returned with the plan and held until admission + /// succeeds or fails. + pub(crate) fn prepare_admitted_pin_state( + &self, + identity: &StateMigrationIdentity<'_>, + pin: &crate::plugins::PreparedProductionPin, + workspace_id: &str, + branch_id: &str, + cancellation: &RunCancellation, + ) -> Result, AdmitError> { + let Some(state) = pin.state() else { + return Ok(None); + }; + if state.scope != "session" { + return Ok(None); + } + let target = u32::try_from(state.schema) + .map_err(|_| AdmitError::Invalid("plugin_state_invalid".into()))?; + if target == 0 || target > 64 { + return Err(AdmitError::Invalid("plugin_state_invalid".into())); + } + let persistence = self + .inner + .persistence + .clone() + .ok_or_else(|| AdmitError::Invalid("plugin_state_unavailable".into()))?; + let state_store = crate::plugins::PluginStateStore::open(Arc::clone(&persistence)); + let Some(key) = state_store + .session_root_key_for( + identity.session_id, + pin.package_id(), + workspace_id, + branch_id, + ) + .map_err(|error| AdmitError::Invalid(error.code.into()))? + else { + return Ok(None); + }; + let Some(live) = state_store + .root(&key) + .map_err(|error| AdmitError::Invalid(error.code.into()))? + else { + return Ok(None); + }; + if live.schema > target { + return Err(AdmitError::Invalid("state_incompatible".into())); + } + if live.schema == target { + return Ok(Some(PreparedStateAdmission::Guard( + PluginStateGuard::from_live_root( + identity.session_id, + workspace_id, + branch_id, + &live, + ), + ))); + } + // Only a real schema switch takes the session maintenance lease and + // waits for every nonterminal run of the session. Same-schema + // admissions above never enter this path, so they cannot be blocked + // by, or block on, an unrelated run or another branch. + // Publish the maintenance lease inside the short commit_gate critical + // section, then release the gate before waiting, reading or writing. + // The admission maintenance check and this lease publication therefore + // share one linearization point: an admission that already passed its + // in-gate check must complete its durable commit before this lease is + // visible, and any later admission observes the lease and is rejected. + let lease = { + let _gate = self.inner.commit_gate.lock(); + cancellation + .ensure_open() + .map_err(|_| AdmitError::Invalid("cancelled".into()))?; + super::SessionMaintenanceLease::acquire( + Arc::clone(&self.inner.session_maintenance), + identity.session_id, + ) + .ok_or(AdmitError::SessionMaintenanceBusy( + crate::service::CompactionError::from_rss( + "busy", + "session maintenance is in progress", + ), + ))? + }; + loop { + cancellation + .ensure_open() + .map_err(|_| AdmitError::Invalid("cancelled".into()))?; + if state_store + .session_nonterminal_runs(identity.session_id) + .map_err(|error| AdmitError::Invalid(error.code.into()))? + == 0 + { + break; + } + std::thread::sleep(std::time::Duration::from_millis(10)); + } + // The lease excludes a concurrent switch, but a switch that completed + // while this admission was waiting may already have reached the target + // schema. Decide from this fresh read, never from the pre-lease one. + let Some(live) = state_store + .root(&key) + .map_err(|error| AdmitError::Invalid(error.code.into()))? + else { + return Ok(None); + }; + if live.schema > target { + return Err(AdmitError::Invalid("state_incompatible".into())); + } + if live.schema == target { + return Ok(Some(PreparedStateAdmission::Guard( + PluginStateGuard::from_live_root( + identity.session_id, + workspace_id, + branch_id, + &live, + ), + ))); + } + let migration = pin + .migration() + .ok_or_else(|| AdmitError::Invalid("state_incompatible".into()))?; + let payload = state_store + .object_payload(&live.object_id) + .map_err(|error| AdmitError::Invalid(error.code.into()))?; + let migrated = migration + .evaluate(live.schema, &payload, cancellation) + .map_err(|error| AdmitError::Invalid(error.code.into()))?; + cancellation + .ensure_open() + .map_err(|_| AdmitError::Invalid("cancelled".into()))?; + let (object, owner) = state_store + .put_object_reserved(pin.package_id(), pin.artifact_id(), target, &migrated) + .map_err(|error| AdmitError::Invalid(error.code.into()))?; + Ok(Some(PreparedStateAdmission::Migration(Box::new( + PreparedStateMigration { + session_id: identity.session_id.to_string(), + run_id: identity.run_id.to_string(), + workspace_id: workspace_id.to_string(), + branch_id: branch_id.to_string(), + plugin_id: key.plugin_id, + package_id: pin.package_id().to_string(), + artifact_id: pin.artifact_id().to_string(), + graph_id: identity.graph_id.to_string(), + generation: identity.generation, + ref_id: identity.ref_id.to_string(), + epoch: identity.epoch, + old_schema: live.schema, + expected_revision: live.revision, + expected_object_id: live.object_id, + object: Some(object), + state_store: state_store.clone(), + _candidate: Some(owner), + _lease: Some(lease), + }, + )))) + } + + fn require_rollback_state_compatible( + &self, + lock: &crate::plugins::GraphLock, + _persistence: &GatewayPersistence, + ) -> Result<(), &'static str> { + let store = self.tool_store().ok_or("plugin_artifact_unavailable")?; + let state_store = crate::plugins::PluginStateStore::open(Arc::clone( + &self + .inner + .persistence + .clone() + .ok_or("plugin_control_unconfigured")?, + )); for package in &lock.packages { let installed = store .read_installed(&package.artifact_id) .map_err(|_| "plugin_artifact_unavailable")?; - if installed.manifest.state.is_some() { - return Err("plugin_state_unsupported"); + let target = installed + .manifest + .state + .as_ref() + .map(|state| state.schema) + .unwrap_or(0); + let live = state_store + .session_max_schema(&installed.manifest.id) + .map_err(|_| "plugin_state_unavailable")?; + if live > target { + return Err("state_incompatible"); + } + } + Ok(()) + } + + fn validate_stateful(&self, lock: &crate::plugins::GraphLock) -> Result<(), &'static str> { + let store = self.tool_store().ok_or("plugin_artifact_unavailable")?; + for package in &lock.packages { + let installed = store + .read_installed(&package.artifact_id) + .map_err(|_| "plugin_artifact_unavailable")?; + let Some(state) = installed.manifest.state.as_ref() else { + continue; + }; + if state.scope != "run" && state.scope != "session" && state.scope != "invocation" { + return Err("plugin_state_invalid"); + } + if state.schema == 0 || state.schema > 64 { + return Err("plugin_state_invalid"); + } + if let Some(migration) = state.migration.as_ref() { + let entry = installed + .manifest + .effects + .entries + .get(&migration.entry) + .ok_or("plugin_state_migration_undeclared")?; + if !entry.direct.is_empty() || !entry.delegated.is_empty() { + return Err("plugin_state_migration_impure"); + } + crate::plugins::validate_state_migration_source(&installed, &migration.entry)?; } } Ok(()) diff --git a/tests/plugin_state_migration_tests.rs b/tests/plugin_state_migration_tests.rs new file mode 100644 index 0000000..d22ad21 --- /dev/null +++ b/tests/plugin_state_migration_tests.rs @@ -0,0 +1,6726 @@ +//! T12: versioned Native plugin state, pure migration, and checkpoint coverage. +//! +//! Every positive path compiles a signed ToolOnly package and goes through the +//! formal package store, graph lock, Native pin, and RSS storage worker. Empty +//! roots or a type-only assertion are not accepted as state support. +#[cfg(feature = "workspace-io-fault")] +use rustscript_agent::AdmitRunRequest; +use rustscript_agent::plugins::{ + HarnessSelectionRequest, InstallAuthorization, PackageRequirement, PackageStore, + PluginStateBindRequest, PluginStatePayload, PluginStateRestoreRequest, PluginStateRootKey, + PluginStateRootRef, PluginStateScope, PluginStateSnapshot, PluginStateStore, ResolveRequest, +}; +use rustscript_agent::{ + AgentGatewayConfig, AgentGatewayState, GatewayPersistence, + config::{ManagedMaintenancePolicy, ManagedRetentionLimits, ManagedWorkspaceConfig}, + effects::ResourceQuota, +}; +use serde_json::json; +use std::sync::Arc; + +use ring::rand::SystemRandom; +use ring::signature::{Ed25519KeyPair, KeyPair}; +use rustscript_agent::plugins::{HOST_RUNTIME, PublisherKey}; +use std::path::{Path, PathBuf}; + +#[allow(dead_code)] +#[path = "support/plugin_effect_api_cycle.rs"] +mod plugin_effect_api_cycle; +#[allow(dead_code)] +#[path = "support/signed_tool_effects.rs"] +mod signed_tool_effects; + +use plugin_effect_api_cycle::{Http, explain}; + +fn private_root(label: &str) -> PathBuf { + let root = PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()) + .join(format!("{label}-{}", uuid::Uuid::new_v4().simple())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + root +} + +fn workspace_home(root: &Path) -> (PathBuf, PathBuf) { + let home = root.join("workspace-home"); + let workspace = root.join("workspace"); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::write(workspace.join("seed.txt"), b"seed").unwrap(); + let yaml = format!( + "version: 1\nmodel:\n provider: local-test\n model: synthetic\nproviders:\n local-test:\n protocol: codex-responses\n base_url: https://127.0.0.1:1\nworkspaces:\n allowed_roots:\n - '{}'\n default: '{}'\n named:\n lifecycle: '{}'\n", + workspace.display(), + workspace.display(), + workspace.display() + ); + std::fs::write(home.join("config.yaml"), yaml).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for path in [&home, &workspace] { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + std::fs::set_permissions( + home.join("config.yaml"), + std::fs::Permissions::from_mode(0o600), + ) + .unwrap(); + } + (home, workspace) +} + +struct Fixture { + root: PathBuf, + source: PathBuf, + db: PathBuf, + token: String, + auth: InstallAuthorization, + state: AgentGatewayState, +} + +fn open_managed_run_state_fixture(label: &str) -> Fixture { + build_run_state_fixture(label, true) +} + +fn build_run_state_fixture(label: &str, managed: bool) -> Fixture { + build_run_state_fixture_with_timeout(label, managed, std::time::Duration::from_secs(900)) +} + +fn build_run_state_fixture_with_timeout( + label: &str, + managed: bool, + run_timeout: std::time::Duration, +) -> Fixture { + let root = private_root(label); + let source = root.join("controlled-source"); + std::fs::create_dir_all(&source).unwrap(); + let (pair, auth) = migration_auth(); + let directory = source.join("package-staterun"); + std::fs::create_dir_all(&directory).unwrap(); + std::fs::write( + directory.join("manifest.yaml"), + state_manifest("org.rustscript.test.staterun", 1, None, ""), + ) + .unwrap(); + std::fs::write(directory.join("run.rss"), RUN_STATE_SOURCE).unwrap(); + std::fs::write( + directory.join("catalog.rss"), + include_bytes!("../rss/tools/types.rss"), + ) + .unwrap(); + let directory2 = source.join("package-staterun2"); + std::fs::create_dir_all(&directory2).unwrap(); + std::fs::write( + directory2.join("manifest.yaml"), + state_manifest("org.rustscript.test.staterun", 2, Some("migrate"), "[]") + .replace("version: 1.0.0", "version: 2.0.0"), + ) + .unwrap(); + std::fs::write(directory2.join("run.rss"), RUN_STATE_SOURCE).unwrap(); + std::fs::write(directory2.join("migrate.rss"), MIGRATION_SOURCE).unwrap(); + std::fs::write( + directory2.join("catalog.rss"), + include_bytes!("../rss/tools/types.rss"), + ) + .unwrap(); + let signer = PackageStore::open(root.join(".sign")).unwrap(); + let signature = pair.sign(&signer.signature_payload(&directory).unwrap()); + std::fs::write(directory.join("signature.ed25519"), signature.as_ref()).unwrap(); + let signature2 = pair.sign(&signer.signature_payload(&directory2).unwrap()); + std::fs::write(directory2.join("signature.ed25519"), signature2.as_ref()).unwrap(); + let directory3 = source.join("package-staterun3"); + std::fs::create_dir_all(&directory3).unwrap(); + std::fs::write( + directory3.join("manifest.yaml"), + state_manifest("org.rustscript.test.staterun", 3, None, "") + .replace("version: 1.0.0", "version: 3.0.0"), + ) + .unwrap(); + std::fs::write(directory3.join("run.rss"), RUN_STATE_SOURCE).unwrap(); + std::fs::write( + directory3.join("catalog.rss"), + include_bytes!("../rss/tools/types.rss"), + ) + .unwrap(); + let signature3 = pair.sign(&signer.signature_payload(&directory3).unwrap()); + std::fs::write(directory3.join("signature.ed25519"), signature3.as_ref()).unwrap(); + // Same package id and same session schema, different artifact revision. + let directory1b = source.join("package-staterun1b"); + std::fs::create_dir_all(&directory1b).unwrap(); + std::fs::write( + directory1b.join("manifest.yaml"), + state_manifest("org.rustscript.test.staterun", 1, None, "") + .replace("version: 1.0.0", "version: 1.1.0"), + ) + .unwrap(); + std::fs::write(directory1b.join("run.rss"), RUN_STATE_SOURCE).unwrap(); + std::fs::write( + directory1b.join("catalog.rss"), + include_bytes!("../rss/tools/types.rss"), + ) + .unwrap(); + let signature1b = pair.sign(&signer.signature_payload(&directory1b).unwrap()); + std::fs::write(directory1b.join("signature.ed25519"), signature1b.as_ref()).unwrap(); + let (home, workspace) = workspace_home(&root); + let db = root.join("gateway.sqlite"); + let token = uuid::Uuid::new_v4().to_string(); + let mut config = AgentGatewayConfig { + bearer_token: Some(token.clone()), + run_timeout, + managed_workspace: managed.then(|| ManagedWorkspaceConfig { + source_root: workspace, + quota: ResourceQuota::new(1000, 1_048_576, 16).unwrap(), + retention: ManagedRetentionLimits::default(), + maintenance_policy: ManagedMaintenancePolicy { + create_checkpoint: true, + restore: true, + branch: true, + publish: false, + }, + }), + ..AgentGatewayConfig::default() + }; + config.approvals.write = "allow".into(); + let state = AgentGatewayState::with_sqlite_path(config, &db) + .unwrap() + .with_workspace_home(&home) + .with_plugin_control(auth.clone(), source.clone(), "owner-space") + .unwrap(); + Fixture { + root, + source, + db, + token, + auth, + state, + } +} + +fn open_fixture(label: &str) -> Fixture { + let root = private_root(label); + let source = root.join("controlled-source"); + std::fs::create_dir_all(&source).unwrap(); + let (auth, _, _, _) = signed_tool_effects::stage_write_pair(&source); + let (home, _workspace) = workspace_home(&root); + let db = root.join("gateway.sqlite"); + let token = uuid::Uuid::new_v4().to_string(); + let mut config = AgentGatewayConfig { + bearer_token: Some(token.clone()), + ..AgentGatewayConfig::default() + }; + config.approvals.write = "allow".into(); + let state = AgentGatewayState::with_sqlite_path(config, &db) + .unwrap() + .with_workspace_home(&home) + .with_plugin_control(auth.clone(), source.clone(), "owner-space") + .unwrap(); + Fixture { + root, + source, + db, + token, + auth, + state, + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn stateful_signed_tool_graph_prepares_and_activates() { + let fixture = open_fixture("t12-state-activate"); + let http = Http::bind(fixture.state.clone()).await; + for package in ["package-state"] { + let (status, body) = http + .request( + "POST", + "/v1/plugins/install", + json!({"package": package, "deployment_space": "owner-space"}), + Some(&fixture.token), + None, + ) + .await; + assert_eq!(status, 200, "{package}: {}", explain(status, &body)); + } + let (status, resolved) = http + .request( + "POST", + "/v1/plugin-graphs/resolve", + json!({ + "roots": [{"id": "org.rustscript.tools.effectstate", "version": "1.0.0"}], + "tool_package": "org.rustscript.tools.effectstate", + "deployment_space": "owner-space" + }), + Some(&fixture.token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &resolved)); + let graph = resolved["graph_id"].as_str().unwrap().to_string(); + let (status, prepared) = http + .request( + "POST", + &format!("/v1/plugin-graphs/{graph}/prepare"), + json!({}), + Some(&fixture.token), + None, + ) + .await; + assert_eq!( + status, + 200, + "stateful signed ToolOnly graph must prepare: {}", + explain(status, &prepared) + ); + assert_eq!(prepared["graph_id"], graph); + assert_eq!(prepared["executable"], true); + let (status, activated) = http + .request( + "POST", + "/v1/plugin-activations", + json!({ + "graph_id": graph, + "deployment_space": "owner-space", + "expected_generation": 0 + }), + Some(&fixture.token), + None, + ) + .await; + assert_eq!( + status, + 200, + "stateful signed ToolOnly graph must activate: {}", + explain(status, &activated) + ); + assert_eq!(activated["graph_id"], graph); + // Keep the fixture alive through the assertions; Drop owns cleanup. + let _ = (&fixture.source, &fixture.db, &fixture.auth); +} + +fn seed_session(persistence: &GatewayPersistence, session_id: &str, now_ms: i64) { + persistence + .session_create(&json!({ + "id": session_id, + "profile": "default", + "platform": "plugin-state-test", + "account_id": session_id, + "chat_id": session_id, + "thread_id": "", + "user_id": "", + "generation": 1, + "system_prompt": "", + "model": "test-model", + "provider": "test-provider", + "toolset_hash": "test-tools", + "metadata_json": "{}", + "title": "", + "end_reason": "", + "now_ms": now_ms, + })) + .expect("seed session"); +} + +fn seed_run(persistence: &GatewayPersistence, session_id: &str, run_id: &str, now_ms: i64) { + persistence + .run_create(&json!({ + "id": run_id, + "session_id": session_id, + "parent_run_id": "", + "input_json": "{}", + "provider": "test-provider", + "model": "test-model", + "script_hash": "", + "idempotency_scope": "", + "idempotency_key": "", + "now_ms": now_ms, + })) + .expect("seed run"); +} + +fn bind_request( + run_id: &str, + session_id: &str, + plugin_id: &str, + scope: PluginStateScope, + schema: u32, +) -> PluginStateBindRequest { + PluginStateBindRequest { + run_id: run_id.into(), + session_id: session_id.into(), + workspace_id: "workspace-1".into(), + branch_id: "branch-1".into(), + plugin_id: plugin_id.into(), + package_id: "org.rustscript.test.state".into(), + artifact_id: "artifact-state".into(), + scope, + graph_id: "graph-state-1".into(), + epoch: 1, + schema, + checkpoint_id: String::new(), + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn run_state_objects_are_immutable_and_roots_advance_by_cas() { + let fixture = open_fixture("t12-run-state"); + let persistence = fixture.state.persistence().expect("native persistence"); + seed_session(&persistence, "session-run", 10); + seed_run(&persistence, "session-run", "run-1", 11); + seed_run(&persistence, "session-run", "run-2", 12); + let store = PluginStateStore::open(Arc::clone(&persistence)); + let first = store + .bind_run(&bind_request( + "run-1", + "session-run", + "org.rustscript.test.state", + PluginStateScope::Run, + 1, + )) + .expect("bind run state root"); + assert_eq!(first.binding.schema, 1); + assert_eq!(first.binding.revision, 1); + assert_eq!(first.payload.as_str(), "{}"); + + let one = PluginStatePayload::parse(r#"{"count":1,"name":"alpha"}"#).unwrap(); + let second = store + .commit(&first, &one) + .expect("commit first state object"); + assert_eq!(second.binding.revision, 2); + assert_eq!(second.binding.object_id, second.binding.object_id); + assert_eq!(second.payload.as_str(), one.as_str()); + + let stale = store + .commit( + &first, + &PluginStatePayload::parse(r#"{"count":99}"#).unwrap(), + ) + .expect_err("stale revision must not advance the root"); + assert_eq!(stale.code, "plugin_state_conflict"); + let observed = store + .binding("run-1") + .expect("read binding") + .expect("run binding"); + assert_eq!(observed.binding.revision, 2); + assert_eq!(observed.payload.as_str(), one.as_str()); + + let other = store + .bind_run(&bind_request( + "run-2", + "session-run", + "org.rustscript.test.state", + PluginStateScope::Run, + 1, + )) + .expect("bind second run root"); + assert_eq!(other.binding.revision, 1); + assert_eq!(other.payload.as_str(), "{}"); + let other_written = store + .commit( + &other, + &PluginStatePayload::parse(r#"{"count":9}"#).unwrap(), + ) + .expect("second run owns a separate root"); + assert_eq!(other_written.binding.revision, 2); + let first_again = store + .binding("run-1") + .expect("read first binding") + .expect("first binding"); + assert_eq!(first_again.payload.as_str(), one.as_str()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn session_state_pins_old_runs_across_schema_migration() { + let fixture = open_fixture("t12-session-state"); + let persistence = fixture.state.persistence().expect("native persistence"); + seed_session(&persistence, "session-shared", 20); + seed_run(&persistence, "session-shared", "run-old", 21); + seed_run(&persistence, "session-shared", "run-new", 22); + let store = PluginStateStore::open(Arc::clone(&persistence)); + let old = store + .bind_run(&bind_request( + "run-old", + "session-shared", + "org.rustscript.test.state", + PluginStateScope::Session, + 1, + )) + .expect("bind session state root"); + let old_data = store + .commit(&old, &PluginStatePayload::parse(r#"{"v":"old"}"#).unwrap()) + .expect("write session state"); + assert_eq!(old_data.binding.revision, 2); + + let key = PluginStateRootKey { + owner_kind: "session".into(), + owner_id: "session-shared".into(), + workspace_id: "workspace-1".into(), + branch_id: "branch-1".into(), + plugin_id: "org.rustscript.test.state".into(), + }; + let root = store.root(&key).expect("read root").expect("session root"); + assert_eq!(root.schema, 1); + let migrated = store + .put_object( + "org.rustscript.test.state", + "artifact-state", + 2, + &PluginStatePayload::parse(r#"{"v":"old","schema":2}"#).unwrap(), + ) + .expect("persist migrated object"); + let new_root = store + .migrate_root(&key, root.revision, &migrated) + .expect("switch session root schema"); + assert_eq!(new_root.schema, 2); + assert_eq!(new_root.revision, 3); + + let new = store + .bind_run(&bind_request( + "run-new", + "session-shared", + "org.rustscript.test.state", + PluginStateScope::Session, + 2, + )) + .expect("new admission binds new schema"); + assert_eq!(new.binding.schema, 2); + assert_eq!(new.payload.as_str(), r#"{"schema":2,"v":"old"}"#); + let old_again = store + .binding("run-old") + .expect("read old binding") + .expect("old binding"); + assert_eq!(old_again.binding.schema, 1); + assert_eq!(old_again.payload.as_str(), r#"{"v":"old"}"#); +} + +fn migration_auth() -> (Ed25519KeyPair, InstallAuthorization) { + let key = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap(); + let pair = Ed25519KeyPair::from_pkcs8(key.as_ref()).unwrap(); + let auth = InstallAuthorization { + admin: true, + accepted_publishers: vec![PublisherKey { + publisher: "org.rustscript".into(), + verifying_key: pair.public_key().as_ref().to_vec(), + }], + unsigned_dev_space: None, + }; + (pair, auth) +} + +fn state_manifest( + id: &str, + schema: u32, + migration: Option<&str>, + migration_effects: &str, +) -> String { + let migration_block = migration + .map(|entry| format!(" migration:\n entry: {entry}\n")) + .unwrap_or_default(); + let migration_entry = migration + .map(|entry| { + format!(" {entry}:\n direct: {migration_effects}\n delegated: []\n") + }) + .unwrap_or_default(); + format!( + "format: rustscript.plugin/v1\nid: {id}\nversion: 1.0.0\nkind: tool\nruntime: \">=0.1.0, <0.2.0\"\ncontract: agent.tool/v1\nhost_abi: agent.cap/v1\nentry:\n path: run.rss\n export: run\ndependencies: []\ncapabilities:\n - workspace.read\n - workspace.write\nstate:\n scope: session\n schema: {schema}\n{migration_block}effects:\n contract: agent.effects/v1\n entries:\n run:\n direct:\n - kind: workspace.fs.read\n scope: bound_workspace\n recovery: none\n - kind: workspace.fs.write\n scope: bound_workspace\n recovery: none\n delegated: []\n{migration_entry}" + ) +} + +fn open_managed_state_fixture(label: &str) -> Fixture { + open_state_fixture(label, "session") +} + +fn open_state_fixture(label: &str, state_scope: &str) -> Fixture { + let root = private_root(label); + let source = root.join("controlled-source"); + std::fs::create_dir_all(&source).unwrap(); + let (pair, auth) = migration_auth(); + let directory = source.join("package-staterun"); + std::fs::create_dir_all(&directory).unwrap(); + std::fs::write( + directory.join("manifest.yaml"), + state_manifest("org.rustscript.test.staterun", 1, None, "") + .replace("scope: session", &format!("scope: {state_scope}")), + ) + .unwrap(); + std::fs::write(directory.join("run.rss"), RUN_STATE_SOURCE).unwrap(); + std::fs::write( + directory.join("catalog.rss"), + include_bytes!("../rss/tools/types.rss"), + ) + .unwrap(); + let signer = PackageStore::open(root.join(".sign")).unwrap(); + let signature = pair.sign(&signer.signature_payload(&directory).unwrap()); + std::fs::write(directory.join("signature.ed25519"), signature.as_ref()).unwrap(); + let (home, workspace) = workspace_home(&root); + let db = root.join("gateway.sqlite"); + let token = uuid::Uuid::new_v4().to_string(); + let mut config = AgentGatewayConfig { + bearer_token: Some(token.clone()), + managed_workspace: Some(ManagedWorkspaceConfig { + source_root: workspace, + quota: ResourceQuota::new(1000, 1_048_576, 16).unwrap(), + retention: ManagedRetentionLimits::default(), + maintenance_policy: ManagedMaintenancePolicy { + create_checkpoint: true, + restore: true, + branch: true, + publish: false, + }, + }), + ..AgentGatewayConfig::default() + }; + config.approvals.write = "allow".into(); + let state = AgentGatewayState::with_sqlite_path(config, &db) + .unwrap() + .with_workspace_home(&home) + .with_plugin_control(auth.clone(), source.clone(), "owner-space") + .unwrap(); + Fixture { + root, + source, + db, + token, + auth, + state, + } +} + +fn open_direct_state_fixture(label: &str, state_scope: &str) -> Fixture { + let root = private_root(label); + let source = root.join("controlled-source"); + std::fs::create_dir_all(&source).unwrap(); + let (pair, auth) = migration_auth(); + let directory = source.join("package-staterun"); + std::fs::create_dir_all(&directory).unwrap(); + std::fs::write( + directory.join("manifest.yaml"), + state_manifest("org.rustscript.test.staterun", 1, None, "") + .replace("scope: session", &format!("scope: {state_scope}")), + ) + .unwrap(); + std::fs::write(directory.join("run.rss"), RUN_STATE_FAILFAST_SOURCE).unwrap(); + std::fs::write( + directory.join("catalog.rss"), + include_bytes!("../rss/tools/types.rss"), + ) + .unwrap(); + let signer = PackageStore::open(root.join(".sign")).unwrap(); + let signature = pair.sign(&signer.signature_payload(&directory).unwrap()); + std::fs::write(directory.join("signature.ed25519"), signature.as_ref()).unwrap(); + let (home, _workspace) = workspace_home(&root); + let db = root.join("gateway.sqlite"); + let token = uuid::Uuid::new_v4().to_string(); + let mut config = AgentGatewayConfig { + bearer_token: Some(token.clone()), + ..AgentGatewayConfig::default() + }; + config.approvals.write = "allow".into(); + let state = AgentGatewayState::with_sqlite_path(config, &db) + .unwrap() + .with_workspace_home(&home) + .with_plugin_control(auth.clone(), source.clone(), "owner-space") + .unwrap(); + Fixture { + root, + source, + db, + token, + auth, + state, + } +} + +const RUN_STATE_FAILFAST_SOURCE: &str = r#" +pub fn run(context: map) -> map { + let prepare: map = context.prepare; + let prepared: map = agent_runtime::tool_prepare(prepare); + let mut output: map = prepared; + if prepared.ok { + let token: string = prepared.execution_token; + let current: map = state::read(); + let decoded: map = json::decode(current.document); + let mut count: int = 0; + if decoded.has("count") { + count = decoded.count; + } + count = count + 1; + let document: string = json::encode({ count: count }); + let written: map = state::write(current.revision, document); + if written.ok { + let result: map = { ok: true, content: "state", data: { revision: written.revision, schema: written.schema, durable: written.durable }, error: {}, truncated: false, artifacts: [] }; + output = agent_runtime::tool_commit(token, result); + } else { + let result: map = { ok: false, content: "", data: {}, error: written.error, truncated: false, artifacts: [] }; + output = agent_runtime::tool_commit(token, result); + } + } + output +} +"#; + +const RUN_SOURCE: &str = r#" +pub fn run(context: map) -> map { + { ok: true, content: "state", data: {}, error: {}, truncated: false, artifacts: [] } +} +"#; + +const RUN_STATE_SOURCE: &str = r#" +pub fn run(context: map) -> map { + let prepare: map = context.prepare; + let prepared: map = agent_runtime::tool_prepare(prepare); + let mut output: map = prepared; + if prepared.ok { + let token: string = prepared.execution_token; + let meta: map = cap::fs_metadata(token, "seed.txt"); + if meta.ok { + let current: map = state::read(); + let decoded: map = json::decode(current.document); + let mut count: int = 0; + if decoded.has("count") { + count = decoded.count; + } + count = count + 1; + let document: string = json::encode({ count: count }); + let written: map = state::write(current.revision, document); + let reread: map = state::read(); + let written_ok: bool = written.ok; + let reread_ok: bool = reread.ok; + let written_revision: int = written.revision; + let reread_revision: int = reread.revision; + let reread_document: string = reread.document; + let same: bool = written_ok && reread_ok && reread_revision == written_revision && reread_document == document; + let mut durable: bool = false; + if written_ok { + let coerced: bool = written.durable; + durable = coerced; + } + let mut error: map = {}; + if written_ok { + error = {}; + } else { + let coerced_error: map = written.error; + error = coerced_error; + } + let result: map = { ok: same, content: "state", data: { revision: written_revision, schema: written.schema, durable: durable }, error: error, truncated: false, artifacts: [] }; + output = agent_runtime::tool_commit(token, result); + } + } + output +} +"#; + +const MIGRATION_SOURCE: &str = r#" +use json; +pub fn migrate(input: map) -> map { + let document: string = input["document"]; + let mut decoded: map = json::decode(document); + decoded.version = 2; + { schema: 2, document: json::encode(decoded) } +} +"#; + +fn try_install_state_package( + root: &std::path::Path, + id: &str, + schema: u32, + migration: Option<&str>, + migration_source: &str, + migration_effects: &str, +) -> Result<(PackageStore, InstallAuthorization), rustscript_agent::plugins::PluginError> { + let (pair, auth) = migration_auth(); + let packages = root.join("packages"); + let store = PackageStore::open(&packages).unwrap(); + store.bind_policy(&auth); + let directory = root.join(format!("package-{id}")); + std::fs::create_dir_all(&directory).unwrap(); + std::fs::write( + directory.join("manifest.yaml"), + state_manifest(id, schema, migration, migration_effects), + ) + .unwrap(); + std::fs::write(directory.join("run.rss"), RUN_SOURCE).unwrap(); + if migration.is_some() { + std::fs::write(directory.join("migrate.rss"), migration_source).unwrap(); + } + let signature = pair.sign(&store.signature_payload(&directory).unwrap()); + std::fs::write(directory.join("signature.ed25519"), signature.as_ref()).unwrap(); + store.install(&directory, &auth)?; + Ok((store, auth)) +} + +fn install_state_package( + root: &std::path::Path, + id: &str, + schema: u32, + migration: Option<&str>, + migration_source: &str, + migration_effects: &str, +) -> (PackageStore, InstallAuthorization) { + try_install_state_package( + root, + id, + schema, + migration, + migration_source, + migration_effects, + ) + .unwrap_or_else(|error| panic!("state package {id} installs: {error:?}")) +} + +fn prepare_state_package( + store: &PackageStore, + id: &str, +) -> Result { + let lock = store + .resolve(&ResolveRequest { + harness_selection: HarnessSelectionRequest::ToolOnly { + tool_package: id.into(), + }, + roots: vec![PackageRequirement { + id: id.into(), + version: "1.0.0".into(), + }], + host_runtime: HOST_RUNTIME.into(), + }) + .unwrap(); + store.prepare(&lock)?; + Ok(lock) +} + +#[test] +fn pure_rss_migration_is_compiled_and_executed_without_host_imports() { + let root = private_root("t12-migration-pure"); + let id = "org.rustscript.test.state2"; + let (store, _auth) = + install_state_package(&root, id, 2, Some("migrate"), MIGRATION_SOURCE, "[]"); + let lock = store + .resolve(&ResolveRequest { + harness_selection: HarnessSelectionRequest::ToolOnly { + tool_package: id.into(), + }, + roots: vec![PackageRequirement { + id: id.into(), + version: "1.0.0".into(), + }], + host_runtime: HOST_RUNTIME.into(), + }) + .unwrap(); + let prepared = store.prepare(&lock).expect("stateful graph prepares"); + let migration = prepared + .state_migration(id) + .expect("compiled pure migration entry"); + assert_eq!(migration.target_schema, 2); + let old = PluginStatePayload::parse(r#"{"count":1}"#).unwrap(); + let cancellation = rustscript_agent::RunCancellation::with_deadline( + std::time::Instant::now() + std::time::Duration::from_secs(5), + ); + let new = migration + .evaluate(1, &old, &cancellation) + .expect("real RSS migration runs"); + assert_eq!(new.as_str(), r#"{"count":1,"version":2}"#); + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn migration_with_sql_or_effectful_host_import_is_rejected_before_execution() { + let root = private_root("t12-migration-impure"); + let id = "org.rustscript.test.stateimpure"; + let impure = r#" +use sqlite as sqlite; +pub fn migrate(input: map) -> map { + let rows = sqlite::query("select 1", [], {}); + { schema: 2, document: input.document } +} +"#; + let (store, _auth) = install_state_package(&root, id, 2, Some("migrate"), impure, "[]"); + let error = prepare_state_package(&store, id).expect_err("impure migration must not prepare"); + let rendered = format!("{error:?}"); + assert!( + rendered.contains("plugin_state_migration_impure"), + "unexpected rejection: {rendered}" + ); + let _ = std::fs::remove_dir_all(root); + + let root = private_root("t12-migration-effect"); + let id = "org.rustscript.test.stateeffect"; + let error = match try_install_state_package( + &root, + id, + 2, + Some("migrate"), + MIGRATION_SOURCE, + "[{kind: workspace.fs.write, scope: bound_workspace, recovery: none}]", + ) { + Ok(_) => panic!("effectful migration must be rejected before execution"), + Err(error) => error, + }; + assert!( + format!("{error:?}").contains("state migration must be pure"), + "unexpected rejection: {error:?}" + ); + let _ = std::fs::remove_dir_all(root); +} + +async fn run_state_plugin(http: &Http, token: &str) -> String { + let (status, body) = http + .request( + "POST", + "/v1/plugins/install", + json!({"package": "package-staterun", "deployment_space": "owner-space"}), + Some(token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &body)); + let (status, resolved) = http + .request( + "POST", + "/v1/plugin-graphs/resolve", + json!({ + "roots": [{"id": "org.rustscript.test.staterun", "version": "1.0.0"}], + "tool_package": "org.rustscript.test.staterun", + "deployment_space": "owner-space" + }), + Some(token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &resolved)); + let graph = resolved["graph_id"].as_str().unwrap().to_string(); + let (status, prepared) = http + .request( + "POST", + &format!("/v1/plugin-graphs/{graph}/prepare"), + json!({}), + Some(token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &prepared)); + let (status, activated) = http + .request( + "POST", + "/v1/plugin-activations", + json!({"graph_id": graph, "deployment_space": "owner-space", "expected_generation": 0}), + Some(token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &activated)); + graph +} + +async fn admit_state_run( + http: &Http, + token: &str, + session_id: &str, + provider: &rustscript_agent::ScriptedProvider, +) -> String { + let (status, admitted) = + admit_state_run_response(http, token, session_id, None, None, provider).await; + assert_eq!(status, 202, "{}", explain(status, &admitted)); + admitted["run_id"].as_str().unwrap().to_string() +} + +async fn admit_state_run_on_branch( + http: &Http, + token: &str, + session_id: &str, + branch: &str, + graph: &str, + provider: &rustscript_agent::ScriptedProvider, +) -> String { + let (status, admitted) = + admit_state_run_response(http, token, session_id, Some(branch), Some(graph), provider) + .await; + assert_eq!(status, 202, "{}", explain(status, &admitted)); + admitted["run_id"].as_str().unwrap().to_string() +} + +async fn admit_state_run_response( + http: &Http, + token: &str, + session_id: &str, + branch: Option<&str>, + graph: Option<&str>, + provider: &rustscript_agent::ScriptedProvider, +) -> (u16, serde_json::Value) { + provider.push_ok(json!({ + "text": "", + "tool_calls": [{"id": "state-call", "name": "read_file", "arguments": {"path": "seed.txt"}}], + "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + "reasoning": "", + "stop_reason": "tool_calls" + })); + provider.push_ok(json!({ + "text": "done", + "tool_calls": [], + "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + "reasoning": "", + "stop_reason": "stop" + })); + let mut body = json!({"session_id": session_id, "input": "state run"}); + if let Some(branch) = branch { + body["workspace_branch"] = json!(branch); + } + if let Some(graph) = graph { + body["graph_id"] = json!(graph); + } + http.request( + "POST", + "/v1/runs", + body, + Some(token), + Some(&uuid::Uuid::new_v4().to_string()), + ) + .await +} + +#[cfg(feature = "workspace-io-fault")] +async fn admit_run_with_identity( + http: &Http, + token: &str, + session_id: &str, + input: &str, + graph: Option<&str>, + idempotency_key: &str, +) -> (u16, serde_json::Value) { + let mut body = json!({"session_id": session_id, "input": input}); + if let Some(graph) = graph { + body["graph_id"] = json!(graph); + } + http.request("POST", "/v1/runs", body, Some(token), Some(idempotency_key)) + .await +} + +fn script_state_turns(provider: &rustscript_agent::ScriptedProvider) { + provider.push_ok(json!({ + "text": "", + "tool_calls": [{"id": "state-call", "name": "read_file", "arguments": {"path": "seed.txt"}}], + "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + "reasoning": "", + "stop_reason": "tool_calls" + })); + provider.push_ok(json!({ + "text": "done", + "tool_calls": [], + "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + "reasoning": "", + "stop_reason": "stop" + })); +} + +/// Installs one signed package version, prepares its graph, and activates it. +async fn activate_state_package( + http: &Http, + token: &str, + package: &str, + version: &str, +) -> (String, u64) { + let (status, body) = http + .request( + "POST", + "/v1/plugins/install", + json!({"package": package, "deployment_space": "owner-space"}), + Some(token), + None, + ) + .await; + assert_eq!(status, 200, "{package}: {}", explain(status, &body)); + let (status, resolved) = http + .request( + "POST", + "/v1/plugin-graphs/resolve", + json!({ + "roots": [{"id": "org.rustscript.test.staterun", "version": version}], + "tool_package": "org.rustscript.test.staterun", + "deployment_space": "owner-space" + }), + Some(token), + None, + ) + .await; + assert_eq!(status, 200, "{version}: {}", explain(status, &resolved)); + let graph = resolved["graph_id"].as_str().unwrap().to_string(); + let (status, prepared) = http + .request( + "POST", + &format!("/v1/plugin-graphs/{graph}/prepare"), + json!({}), + Some(token), + None, + ) + .await; + assert_eq!(status, 200, "{version}: {}", explain(status, &prepared)); + let (status, activated) = http + .request( + "POST", + "/v1/plugin-activations", + json!({ + "graph_id": graph, + "deployment_space": "owner-space", + "expected_generation": resolved["generation"].as_u64().unwrap_or(0) + }), + Some(token), + None, + ) + .await; + assert_eq!(status, 200, "{version}: {}", explain(status, &activated)); + (graph, activated["generation"].as_u64().unwrap_or_default()) +} + +#[tokio::test(flavor = "multi_thread")] +async fn signed_tool_state_read_write_uses_the_native_bridge_and_durable_cas() { + let fixture = open_managed_run_state_fixture("t12-native-read-write"); + let http = Http::bind(fixture.state.clone()).await; + run_state_plugin(&http, &fixture.token).await; + let provider = rustscript_agent::ScriptedProvider::new(); + let (status, session) = http + .request( + "POST", + "/api/sessions", + json!({}), + Some(&fixture.token), + None, + ) + .await; + assert_eq!(status, 201, "{}", explain(status, &session)); + let session_id = session["session"]["id"].as_str().unwrap().to_string(); + fixture + .state + .service() + .inject_provider_host(Arc::new(provider.clone())); + let run_id = admit_state_run(&http, &fixture.token, &session_id, &provider).await; + let persistence = fixture.state.persistence().unwrap(); + let store = PluginStateStore::open(Arc::clone(&persistence)); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + let run = persistence.run_get(&run_id).unwrap_or_default(); + let status = run["rows"][0][3].as_str().unwrap_or(""); + if status == "completed" { + break; + } + if status == "failed" { + let events = fixture.state.service().run_events(&run_id); + panic!("state run failed: events={events:?}"); + } + if std::time::Instant::now() >= deadline { + let events = fixture.state.service().run_events(&run_id); + panic!("state run did not finish: run={run} events={events:?}"); + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + let first = store + .binding(&run_id) + .expect("state binding") + .expect("run state binding"); + assert_eq!(first.binding.revision, 2); + assert_eq!(first.payload.as_str(), r#"{"count":1}"#); + let record = ["local-agent", "gateway", "default"] + .iter() + .find_map(|profile| { + persistence + .managed_effect_get(&rustscript_agent::effects::ManagedEffectGetRequest { + owner: rustscript_agent::effects::ManagedOwnerScope { + profile: (*profile).into(), + session_id: session_id.clone(), + run_id: run_id.clone(), + }, + call_id: "state-call".into(), + }) + .ok() + .flatten() + }) + .expect("managed state effect receipt"); + let publication = record + .prepared + .as_ref() + .expect("prepared managed receipt") + .plugin_state + .as_ref() + .expect("state publication in the receipt"); + assert!(!publication.ephemeral); + assert_eq!(publication.owner_kind, "session"); + assert_eq!(publication.object_id, first.binding.object_id); + assert_eq!(publication.schema_version, first.binding.schema); + fixture + .state + .service() + .inject_provider_host(Arc::new(provider.clone())); + let second_run = admit_state_run(&http, &fixture.token, &session_id, &provider).await; + loop { + let run = persistence.run_get(&second_run).unwrap_or_default(); + let status = run["rows"][0][3].as_str().unwrap_or(""); + if status == "completed" { + break; + } + if status == "failed" { + let events = fixture.state.service().run_events(&second_run); + panic!("second state run failed: events={events:?}"); + } + assert!( + std::time::Instant::now() < deadline, + "second state run did not finish" + ); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + let second = store + .binding(&second_run) + .expect("second state binding") + .expect("second run state binding"); + assert_eq!(second.binding.revision, 3); + assert_eq!(second.payload.as_str(), r#"{"count":2}"#); + assert_ne!(first.binding.object_id, second.binding.object_id); +} + +#[tokio::test(flavor = "multi_thread")] +async fn direct_mode_state_write_without_managed_receipt_fails_closed() { + for scope in ["run", "session", "invocation"] { + let fixture = open_direct_state_fixture(&format!("t12-direct-deny-{scope}"), scope); + let http = Http::bind(fixture.state.clone()).await; + run_state_plugin(&http, &fixture.token).await; + let (status, session) = http + .request( + "POST", + "/api/sessions", + json!({}), + Some(&fixture.token), + None, + ) + .await; + assert_eq!(status, 201, "{scope}: {}", explain(status, &session)); + let session_id = session["session"]["id"].as_str().unwrap().to_string(); + let provider = rustscript_agent::ScriptedProvider::new(); + fixture + .state + .service() + .inject_provider_host(Arc::new(provider.clone())); + let run_id = admit_state_run(&http, &fixture.token, &session_id, &provider).await; + wait_run_terminal(&fixture.state, &run_id).await; + let events = fixture.state.service().run_events(&run_id); + let failed = events + .iter() + .find(|event| event["event"] == "tool.failed") + .unwrap_or_else(|| panic!("{scope}: state write must fail the tool call: {events:?}")); + assert_eq!( + failed["data"]["error_code"], "plugin_state_effect_unavailable", + "{scope}: {events:?}" + ); + let persistence = fixture.state.persistence().unwrap(); + let store = PluginStateStore::open(Arc::clone(&persistence)); + let binding = store.binding(&run_id).expect("binding read"); + if scope == "invocation" { + assert!(binding.is_none(), "invocation state must not persist"); + assert_eq!( + store + .session_max_schema("org.rustscript.test.staterun") + .unwrap(), + 0 + ); + } else { + let binding = binding.expect("durable run binding"); + assert_eq!(binding.binding.schema, 1); + assert_eq!(binding.binding.revision, 1); + assert_eq!(binding.payload.as_str(), "{}"); + let key = PluginStateRootKey { + owner_kind: scope.to_string(), + owner_id: if scope == "run" { + run_id.clone() + } else { + session_id.clone() + }, + workspace_id: binding.binding.workspace_id.clone(), + branch_id: binding.binding.branch_id.clone(), + plugin_id: binding.binding.plugin_id.clone(), + }; + let root = store.root(&key).expect("root read").expect("state root"); + assert_eq!(root.schema, 1); + assert_eq!(root.revision, 1); + assert_eq!(root.object_id, binding.binding.object_id); + let receipt = ["local-agent", "gateway", "default"] + .iter() + .find_map(|profile| { + persistence + .managed_effect_get(&rustscript_agent::effects::ManagedEffectGetRequest { + owner: rustscript_agent::effects::ManagedOwnerScope { + profile: (*profile).into(), + session_id: session_id.clone(), + run_id: run_id.clone(), + }, + call_id: "state-call".into(), + }) + .ok() + .flatten() + }); + assert!( + receipt.is_none(), + "{scope}: direct mode cannot own a managed effect receipt" + ); + } + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn checkpoint_roots_restore_exact_state_into_a_new_branch_and_run() { + let fixture = open_fixture("t12-checkpoint-state"); + let persistence = fixture.state.persistence().expect("native persistence"); + seed_session(&persistence, "session-cp", 30); + seed_run(&persistence, "session-cp", "run-source", 31); + seed_run(&persistence, "session-cp", "run-restored", 32); + let store = PluginStateStore::open(Arc::clone(&persistence)); + let source = store + .bind_run(&bind_request( + "run-source", + "session-cp", + "org.rustscript.test.state", + PluginStateScope::Session, + 1, + )) + .expect("bind source state"); + let written = store + .commit( + &source, + &PluginStatePayload::parse(r#"{"count":1}"#).unwrap(), + ) + .expect("write source state"); + assert_eq!(written.binding.revision, 2); + let roots = store + .checkpoint_roots("run-source") + .expect("checkpoint roots"); + assert!(!roots.is_empty()); + assert_eq!( + roots + .iter() + .find(|root| root.owner_kind == "session") + .expect("session root") + .object_id, + written.binding.object_id + ); + + let restore_roots: Vec = roots + .iter() + .map(|root| PluginStateRootRef { + owner_kind: root.owner_kind.clone(), + owner_id: root.owner_id.clone(), + workspace_id: root.workspace_id.clone(), + branch_id: root.branch_id.clone(), + plugin_id: root.plugin_id.clone(), + package_id: root.package_id.clone(), + artifact_id: root.artifact_id.clone(), + schema: root.schema, + revision: root.revision, + object_id: root.object_id.clone(), + }) + .collect(); + let restored = store + .restore_binding(&PluginStateRestoreRequest { + scope: PluginStateScope::Session, + run_id: "run-restored".into(), + session_id: "session-cp".into(), + workspace_id: "workspace-1".into(), + branch_id: "branch-restored".into(), + plugin_id: "org.rustscript.test.state".into(), + package_id: "org.rustscript.test.state".into(), + artifact_id: "artifact-state".into(), + graph_id: "graph-state-1".into(), + epoch: 1, + checkpoint_id: "checkpoint-1".into(), + roots: restore_roots.clone(), + }) + .expect("restore exact state root"); + assert_eq!(restored.binding.revision, 1); + assert_eq!(restored.binding.checkpoint_id, "checkpoint-1"); + assert_eq!(restored.binding.object_id, written.binding.object_id); + assert_eq!(restored.payload.as_str(), r#"{"count":1}"#); + let source_again = store + .binding("run-source") + .expect("source binding") + .expect("source state binding"); + assert_eq!(source_again.binding.revision, 2); + assert_eq!(source_again.payload.as_str(), r#"{"count":1}"#); + + let advanced = store + .commit( + &restored, + &PluginStatePayload::parse(r#"{"count":2}"#).unwrap(), + ) + .expect("restored branch advances independently"); + assert_eq!(advanced.binding.revision, 2); + let source_final = store + .binding("run-source") + .expect("source binding") + .expect("source state binding"); + assert_eq!(source_final.binding.object_id, written.binding.object_id); + + let forged = store + .restore_binding(&PluginStateRestoreRequest { + scope: PluginStateScope::Session, + run_id: "run-restored".into(), + session_id: "session-cp".into(), + workspace_id: "workspace-1".into(), + branch_id: "branch-forged".into(), + plugin_id: "org.rustscript.test.state".into(), + package_id: "org.rustscript.test.state".into(), + artifact_id: "artifact-state".into(), + graph_id: "graph-state-1".into(), + epoch: 1, + checkpoint_id: "checkpoint-forged".into(), + roots: restore_roots + .iter() + .map(|root| PluginStateRootRef { + plugin_id: "org.rustscript.test.foreign".into(), + ..root.clone() + }) + .collect(), + }) + .expect_err("foreign checkpoint root must be rejected"); + assert_eq!(forged.code, "plugin_state_root_missing"); + assert_eq!( + store + .binding("run-restored") + .expect("binding") + .expect("binding") + .binding + .checkpoint_id, + "checkpoint-1" + ); +} + +fn complete_durable_run(persistence: &GatewayPersistence, run_id: &str) { + persistence + .run_transition(&json!({ + "run_id": run_id, + "from_status": "queued", + "to_status": "running", + "error_code": "", + "error_message": "", + "recovery_reason": "", + "now_ms": 40 + })) + .expect("start durable blocker run"); +} + +fn terminate_durable_run(persistence: &GatewayPersistence, run_id: &str) { + persistence + .run_terminal(&json!({ + "run_id": run_id, + "to_status": "completed", + "error_code": "", + "error_message": "", + "event_1_id": "", + "event_1_type": "", + "event_1_payload": "", + "event_2_id": "", + "event_2_type": "", + "event_2_payload": "", + "event_count": 0, + "message_id": "", + "message_session_id": "", + "message_role": "", + "message_content_json": "", + "message_run_id": "", + "message_finish_reason": "", + "now_ms": 41 + })) + .expect("terminate durable blocker run"); +} + +fn wait_approval_durable_run(persistence: &GatewayPersistence, run_id: &str) { + persistence + .run_transition(&json!({ + "run_id": run_id, + "from_status": "running", + "to_status": "waiting_approval", + "error_code": "", + "error_message": "", + "recovery_reason": "", + "now_ms": 43 + })) + .expect("park durable blocker run"); +} + +fn complete_waiting_durable_run(persistence: &GatewayPersistence, run_id: &str) { + persistence + .run_transition(&json!({ + "run_id": run_id, + "from_status": "waiting_approval", + "to_status": "completed", + "error_code": "", + "error_message": "", + "recovery_reason": "", + "now_ms": 44 + })) + .expect("complete parked blocker run"); +} + +async fn wait_run_terminal(state: &AgentGatewayState, run_id: &str) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + let persistence = state.persistence().unwrap(); + let run = persistence.run_get(run_id).unwrap_or_default(); + let status = run["rows"][0][3].as_str().unwrap_or(""); + if status == "completed" { + return; + } + if status == "failed" { + let events = state.service().run_events(run_id); + panic!("run {run_id} failed: events={events:?}"); + } + assert!( + std::time::Instant::now() < deadline, + "run {run_id} did not finish" + ); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } +} + +async fn wait_run_any_terminal(state: &AgentGatewayState, run_id: &str) -> String { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + let persistence = state.persistence().unwrap(); + let run = persistence.run_get(run_id).unwrap_or_default(); + let status = run["rows"][0][3].as_str().unwrap_or("").to_string(); + if matches!(status.as_str(), "completed" | "failed" | "cancelled") { + if status == "failed" { + panic!( + "run {run_id} failed: {:?}", + state.service().run_events(run_id) + ); + } + return status; + } + assert!( + std::time::Instant::now() < deadline, + "run {run_id} did not reach a terminal status, last={status}" + ); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } +} + +async fn wait_provider_call(provider: &rustscript_agent::ScriptedProvider) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15); + while provider.call_count() == 0 { + assert!( + std::time::Instant::now() < deadline, + "hanging provider was never entered" + ); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } +} + +async fn create_session(http: &Http, token: &str) -> String { + let (status, session) = http + .request("POST", "/api/sessions", json!({}), Some(token), None) + .await; + assert_eq!(status, 201, "{}", explain(status, &session)); + session["session"]["id"].as_str().unwrap().to_string() +} + +/// R1 direct regression: a same-schema session-state admission must not wait +/// for another nonterminal run, keep the maintenance lease, or consume the +/// deadline; a real switch must still wait, cancel on the original deadline, +/// release the lease, and succeed on retry. +#[tokio::test(flavor = "multi_thread")] +async fn same_schema_admission_does_not_wait_for_nonterminal_run() { + let fixture = build_run_state_fixture_with_timeout( + "t12-same-schema-nonblocking", + true, + std::time::Duration::from_secs(10), + ); + let http = Http::bind(fixture.state.clone()).await; + run_state_plugin(&http, &fixture.token).await; + let session_id = create_session(&http, &fixture.token).await; + let persistence = fixture.state.persistence().unwrap(); + let store = PluginStateStore::open(Arc::clone(&persistence)); + + let seed_provider = rustscript_agent::ScriptedProvider::new(); + fixture + .state + .service() + .inject_provider_host(Arc::new(seed_provider.clone())); + let baseline_run = admit_state_run(&http, &fixture.token, &session_id, &seed_provider).await; + assert_eq!( + wait_run_any_terminal(&fixture.state, &baseline_run).await, + "completed" + ); + let key = store + .session_root_key(&session_id, "org.rustscript.test.staterun") + .expect("session key") + .expect("schema1 session root"); + let root_before = store.root(&key).expect("root").expect("schema1 root"); + assert_eq!(root_before.schema, 1); + + let holder = rustscript_agent::ScriptedProvider::new(); + holder.push_hang(); + fixture + .state + .service() + .inject_provider_host(Arc::new(holder.clone())); + let blocking_run = admit_state_run(&http, &fixture.token, &session_id, &holder).await; + wait_provider_call(&holder).await; + let blocking_status = persistence.run_get(&blocking_run).unwrap()["rows"][0][3] + .as_str() + .unwrap_or("") + .to_string(); + assert_eq!(blocking_status, "running"); + + let same_provider = rustscript_agent::ScriptedProvider::new(); + fixture + .state + .service() + .inject_provider_host(Arc::new(same_provider.clone())); + let started = std::time::Instant::now(); + let (status, body) = admit_state_run_response( + &http, + &fixture.token, + &session_id, + None, + None, + &same_provider, + ) + .await; + let elapsed = started.elapsed(); + assert_eq!( + status, + 202, + "same-schema admission must not wait for a nonterminal run: {elapsed:?} {}", + explain(status, &body) + ); + assert!( + elapsed < std::time::Duration::from_millis(1500), + "same-schema admission consumed the run deadline: {elapsed:?}" + ); + assert_eq!( + persistence.run_get(&blocking_run).unwrap()["rows"][0][3] + .as_str() + .unwrap_or(""), + "running", + "the same-schema admission must not wait for or terminate the blocker" + ); + let same_run = body["run_id"].as_str().unwrap().to_string(); + assert_eq!( + wait_run_any_terminal(&fixture.state, &same_run).await, + "completed" + ); + + let other_session = create_session(&http, &fixture.token).await; + let other_provider = rustscript_agent::ScriptedProvider::new(); + fixture + .state + .service() + .inject_provider_host(Arc::new(other_provider.clone())); + let other_run = admit_state_run(&http, &fixture.token, &other_session, &other_provider).await; + assert_eq!( + wait_run_any_terminal(&fixture.state, &other_run).await, + "completed" + ); + + let (status, stopped) = http + .request( + "POST", + &format!("/v1/runs/{blocking_run}/stop"), + json!({}), + Some(&fixture.token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &stopped)); + assert_eq!( + wait_run_any_terminal(&fixture.state, &blocking_run).await, + "cancelled" + ); + + let (graph2, _) = + activate_state_package(&http, &fixture.token, "package-staterun2", "2.0.0").await; + let switch_blocker = "switch-approval-blocker"; + seed_run(&persistence, &session_id, switch_blocker, 77); + complete_durable_run(&persistence, switch_blocker); + wait_approval_durable_run(&persistence, switch_blocker); + let switch_provider = rustscript_agent::ScriptedProvider::new(); + fixture + .state + .service() + .inject_provider_host(Arc::new(switch_provider.clone())); + let switch_started = std::time::Instant::now(); + let (status, busy) = admit_state_run_response( + &http, + &fixture.token, + &session_id, + None, + Some(&graph2), + &switch_provider, + ) + .await; + let switch_elapsed = switch_started.elapsed(); + assert_eq!(status, 422, "{}", explain(status, &busy)); + assert_eq!(busy["error"]["code"], "invalid_admission"); + assert!( + switch_elapsed >= std::time::Duration::from_millis(3000), + "a real schema switch must wait for the nonterminal run: {switch_elapsed:?}" + ); + assert_eq!( + persistence.run_get(switch_blocker).unwrap()["rows"][0][3] + .as_str() + .unwrap_or(""), + "waiting_approval", + "the switch attempt must not terminate the parked blocker" + ); + complete_waiting_durable_run(&persistence, switch_blocker); + + let retry_provider = rustscript_agent::ScriptedProvider::new(); + fixture + .state + .service() + .inject_provider_host(Arc::new(retry_provider.clone())); + let (status, retried) = admit_state_run_response( + &http, + &fixture.token, + &session_id, + None, + Some(&graph2), + &retry_provider, + ) + .await; + assert_eq!(status, 202, "{}", explain(status, &retried)); + let retry_run = retried["run_id"].as_str().unwrap().to_string(); + assert_eq!( + wait_run_any_terminal(&fixture.state, &retry_run).await, + "completed" + ); + let root_after = store.root(&key).expect("root").expect("schema2 root"); + assert_eq!(root_after.schema, 2); + assert_eq!( + store + .binding(&retry_run) + .expect("binding") + .expect("schema2 binding") + .binding + .schema, + 2 + ); +} + +/// 401-R1-A: an existing session root was created by artifact A of a package. +/// A compatible new artifact revision B of the same package declares the same +/// session schema. While an old A-pinned run is still nonterminal, admitting a +/// run pinned to B must reuse the live root (package/artifact A) and its data +/// instead of rejecting the root identity. The guard still re-verifies the exact +/// live root inside the durable transaction, so a real schema change between +/// preparation and commit fails closed (separate assertion below). +#[tokio::test(flavor = "multi_thread")] +async fn same_schema_new_artifact_reuses_live_root_and_fails_closed_on_schema_switch() { + let fixture = open_managed_run_state_fixture("t12-same-schema-artifact-upgrade"); + let http = Http::bind(fixture.state.clone()).await; + let graph1 = run_state_plugin(&http, &fixture.token).await; + let session_id = create_session(&http, &fixture.token).await; + let persistence = fixture.state.persistence().unwrap(); + let store = PluginStateStore::open(Arc::clone(&persistence)); + + let baseline_provider = rustscript_agent::ScriptedProvider::new(); + script_state_turns(&baseline_provider); + fixture + .state + .service() + .inject_provider_host(Arc::new(baseline_provider.clone())); + let baseline_run = + admit_state_run(&http, &fixture.token, &session_id, &baseline_provider).await; + assert_eq!( + wait_run_any_terminal(&fixture.state, &baseline_run).await, + "completed" + ); + let key = store + .session_root_key(&session_id, "org.rustscript.test.staterun") + .expect("session key") + .expect("schema1 session root"); + let root_before = store.root(&key).expect("root").expect("schema1 root"); + assert_eq!(root_before.schema, 1); + let baseline_binding = store + .binding(&baseline_run) + .expect("baseline binding") + .expect("baseline state binding"); + assert_eq!(baseline_binding.payload.as_str(), r#"{"count":1}"#); + assert_eq!( + baseline_binding.binding.artifact_id, + root_before.artifact_id + ); + + // An A-pinned run stays nonterminal: the same-schema guard must not wait. + let holder = rustscript_agent::ScriptedProvider::new(); + holder.push_hang(); + fixture + .state + .service() + .inject_provider_host(Arc::new(holder.clone())); + let holder_run = admit_state_run(&http, &fixture.token, &session_id, &holder).await; + wait_provider_call(&holder).await; + assert_eq!( + persistence.run_get(&holder_run).unwrap()["rows"][0][3] + .as_str() + .unwrap_or(""), + "running" + ); + + // Signed install + activation of the compatible same-schema new artifact. + let (graph_b, _) = + activate_state_package(&http, &fixture.token, "package-staterun1b", "1.1.0").await; + + let upgrade_provider = rustscript_agent::ScriptedProvider::new(); + script_state_turns(&upgrade_provider); + fixture + .state + .service() + .inject_provider_host(Arc::new(upgrade_provider.clone())); + let started = std::time::Instant::now(); + let (status, upgraded) = admit_state_run_response( + &http, + &fixture.token, + &session_id, + None, + Some(&graph_b), + &upgrade_provider, + ) + .await; + let elapsed = started.elapsed(); + + // Always settle the hanging old run before asserting so a RED failure can + // still tear the fixture down (the provider only returns on cancellation). + let (stop_status, stopped) = http + .request( + "POST", + &format!("/v1/runs/{holder_run}/stop"), + json!({}), + Some(&fixture.token), + None, + ) + .await; + assert_eq!(stop_status, 200, "{}", explain(stop_status, &stopped)); + assert_eq!( + wait_run_any_terminal(&fixture.state, &holder_run).await, + "cancelled" + ); + + assert_eq!( + status, + 202, + "same-schema new artifact admission must reuse the live root: {}", + explain(status, &upgraded) + ); + assert!( + elapsed < std::time::Duration::from_millis(1500), + "compatible same-schema admission must not wait for the old run: {elapsed:?}" + ); + let upgraded_run = upgraded["run_id"].as_str().unwrap().to_string(); + assert_eq!( + wait_run_any_terminal(&fixture.state, &upgraded_run).await, + "completed" + ); + let upgraded_binding = store + .binding(&upgraded_run) + .expect("upgraded binding") + .expect("schema1 upgraded binding"); + assert_eq!(upgraded_binding.binding.schema, 1); + assert_eq!( + upgraded_binding.binding.artifact_id, root_before.artifact_id, + "the compatible admission continues on the live root artifact identity" + ); + // count 1 from the baseline; the hanging holder never reached its tool. + assert_eq!( + upgraded_binding.payload.as_str(), + r#"{"count":2}"#, + "the new artifact run reads the durable data created under artifact A" + ); + let root_after_upgrade = store.root(&key).expect("root").expect("schema1 root"); + assert_eq!(root_after_upgrade.schema, 1); + assert!(!graph1.is_empty()); +} + +/// 401-R1-A orthogonal guard: a real session schema switch that lands between +/// preparation and the durable admission must still fail the admission closed. +/// The switch runs through the production `switch_plugin_state_schema` path +/// inside the real guarded transaction's precommit seam. +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn same_schema_guard_fails_closed_on_real_schema_switch_before_commit() { + let fixture = open_managed_run_state_fixture("t12-guard-schema-switch-failclosed"); + let http = Http::bind(fixture.state.clone()).await; + let graph1 = run_state_plugin(&http, &fixture.token).await; + assert!(!graph1.is_empty()); + let (status, session) = http + .request( + "POST", + "/api/sessions", + json!({}), + Some(&fixture.token), + None, + ) + .await; + assert_eq!(status, 201, "{}", explain(status, &session)); + let session_id = session["session"]["id"].as_str().unwrap().to_string(); + let persistence = fixture.state.persistence().unwrap(); + let store = PluginStateStore::open(Arc::clone(&persistence)); + + let baseline_provider = rustscript_agent::ScriptedProvider::new(); + script_state_turns(&baseline_provider); + fixture + .state + .service() + .inject_provider_host(Arc::new(baseline_provider.clone())); + let baseline_run = + admit_state_run(&http, &fixture.token, &session_id, &baseline_provider).await; + assert_eq!( + wait_run_any_terminal(&fixture.state, &baseline_run).await, + "completed" + ); + let key = store + .session_root_key(&session_id, "org.rustscript.test.staterun") + .expect("session key") + .expect("schema1 session root"); + assert_eq!(store.root(&key).expect("root").expect("root").schema, 1); + + // Install/resolve/prepare schema2 without activating it: the running + // admission keeps the active graph1 pin it prepared. + let (status, installed) = http + .request( + "POST", + "/v1/plugins/install", + json!({"package": "package-staterun2", "deployment_space": "owner-space"}), + Some(&fixture.token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &installed)); + let (status, resolved2) = http + .request( + "POST", + "/v1/plugin-graphs/resolve", + json!({ + "roots": [{"id": "org.rustscript.test.staterun", "version": "2.0.0"}], + "tool_package": "org.rustscript.test.staterun", + "deployment_space": "owner-space" + }), + Some(&fixture.token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &resolved2)); + let graph2 = resolved2["graph_id"].as_str().unwrap().to_string(); + let (status, prepared2) = http + .request( + "POST", + &format!("/v1/plugin-graphs/{graph2}/prepare"), + json!({}), + Some(&fixture.token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &prepared2)); + + let service = fixture.state.service().clone(); + let switch_session = session_id.clone(); + let switch_graph = graph2.clone(); + #[allow(clippy::type_complexity)] + let switch_result: Arc, &'static str>>>> = + Arc::new(std::sync::Mutex::new(None)); + let recorded = Arc::clone(&switch_result); + // The boundary hook fires once at admission start and once immediately + // before the real commit gate. Only the latter is the prepare->commit + // window this test exercises. + let boundary_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let calls = Arc::clone(&boundary_calls); + fixture + .state + .service() + .observe_admission_commit_boundary(Arc::new(move || { + if calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 { + return; + } + let cancellation = rustscript_agent::RunCancellation::with_deadline( + std::time::Instant::now() + std::time::Duration::from_secs(15), + ); + let result = + service.switch_plugin_state_schema(&switch_session, &switch_graph, &cancellation); + *recorded.lock().expect("switch result") = Some(result); + })); + + let admission_provider = rustscript_agent::ScriptedProvider::new(); + script_state_turns(&admission_provider); + fixture + .state + .service() + .inject_provider_host(Arc::new(admission_provider.clone())); + let (status, body) = admit_state_run_response( + &http, + &fixture.token, + &session_id, + None, + None, + &admission_provider, + ) + .await; + assert_eq!( + status, + 503, + "a root schema change between preparation and commit must fail closed: {}", + explain(status, &body) + ); + assert_eq!(body["error"]["code"], "persistence_unavailable"); + let switched = (*switch_result.lock().expect("switch result")).expect("precommit switch ran"); + assert!( + switched.is_ok(), + "the real switch itself succeeds: {switched:?}" + ); + assert_eq!( + store.root(&key).expect("root").expect("root").schema, + 2, + "the concurrent switch published schema2" + ); + // The guarded admission rolled back: no run row was committed for it. + assert!( + store + .session_root_key(&session_id, "org.rustscript.test.staterun") + .expect("session key") + .is_some() + ); + assert!(!graph1.is_empty()); +} + +/// 401-R1-B: the session maintenance lease must publish inside the same short +/// commit-gate critical section as the admission maintenance check. A +/// same-schema admission A that already passed that check must complete its +/// durable commit before a schema-switch admission B can observe an empty +/// nonterminal set. The test parks A inside the real guarded transaction, starts +/// B for schema2 on the same session/root, then lets A commit while its reply is +/// lost and its memory publication is deliberately deferred at the original +/// proof seam. B must not publish schema2 while A's run is durably nonterminal. +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn maintenance_lease_linearizes_with_guarded_commit_and_lost_reply() { + use std::sync::atomic::{AtomicBool, Ordering}; + + async fn wait_flag(flag: &Arc, what: &str) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + while !flag.load(Ordering::SeqCst) { + assert!( + std::time::Instant::now() < deadline, + "{what} was not reached" + ); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + } + + let fixture = open_managed_run_state_fixture("t12-lease-linearization"); + let http = Http::bind(fixture.state.clone()).await; + let _graph1 = run_state_plugin(&http, &fixture.token).await; + let session_id = create_session(&http, &fixture.token).await; + let persistence = fixture.state.persistence().unwrap(); + let store = PluginStateStore::open(Arc::clone(&persistence)); + + let baseline_provider = rustscript_agent::ScriptedProvider::new(); + script_state_turns(&baseline_provider); + fixture + .state + .service() + .inject_provider_host(Arc::new(baseline_provider.clone())); + let baseline_run = + admit_state_run(&http, &fixture.token, &session_id, &baseline_provider).await; + assert_eq!( + wait_run_any_terminal(&fixture.state, &baseline_run).await, + "completed" + ); + let key_b1 = store + .session_root_key(&session_id, "org.rustscript.test.staterun") + .expect("session key") + .expect("schema1 session root"); + assert_eq!(store.root(&key_b1).expect("root").expect("root").schema, 1); + let baseline_binding = store + .binding(&baseline_run) + .expect("baseline binding") + .expect("baseline state binding"); + let workspace_id = baseline_binding.binding.workspace_id.clone(); + let (status, checkpoint) = http + .request( + "POST", + &format!("/v1/workspaces/{workspace_id}/checkpoints"), + json!({ + "source_run_id": baseline_run, + "workspace_id": workspace_id, + "source_branch_id": baseline_binding.binding.branch_id, + "expected_epoch": baseline_binding.binding.epoch, + "request_id": uuid::Uuid::new_v4().to_string(), + "mode": "at_closed_batch" + }), + Some(&fixture.token), + None, + ) + .await; + assert_eq!(status, 201, "{}", explain(status, &checkpoint)); + let checkpoint_id = checkpoint["checkpoint"]["checkpoint_id"] + .as_str() + .unwrap() + .to_string(); + let (status, created) = http + .request( + "POST", + &format!("/v1/workspaces/{workspace_id}/branches"), + json!({ + "workspace_id": workspace_id, + "source_branch_id": baseline_binding.binding.branch_id, + "checkpoint_id": checkpoint_id, + "request_id": uuid::Uuid::new_v4().to_string() + }), + Some(&fixture.token), + None, + ) + .await; + assert_eq!(status, 201, "{}", explain(status, &created)); + let branch_b2 = created["branch"]["branch_id"].as_str().unwrap().to_string(); + assert_ne!(branch_b2, baseline_binding.binding.branch_id); + // The branch materializes its state root on its first admission. Admit a + // graph1 run through the checkpoint pin so branch b2 holds a schema1 root. + let key_b2 = PluginStateRootKey { + branch_id: branch_b2.clone(), + ..key_b1.clone() + }; + let b2_seed_provider = rustscript_agent::ScriptedProvider::new(); + script_state_turns(&b2_seed_provider); + fixture + .state + .service() + .inject_provider_host(Arc::new(b2_seed_provider.clone())); + let b2_seed_run = admit_state_run_on_branch( + &http, + &fixture.token, + &session_id, + &branch_b2, + &_graph1, + &b2_seed_provider, + ) + .await; + assert_eq!( + wait_run_any_terminal(&fixture.state, &b2_seed_run).await, + "completed" + ); + assert_eq!( + store + .root(&key_b2) + .expect("root") + .expect("branch b2 root") + .schema, + 1 + ); + + // Activate schema2 and migrate only branch b1. Branch b2 keeps the old + // schema1 root, so a schema2 admission on b2 is a real migration while a + // schema2 admission on b1 takes the same-schema guard path. + let (graph2, _) = + activate_state_package(&http, &fixture.token, "package-staterun2", "2.0.0").await; + let migrate_provider = rustscript_agent::ScriptedProvider::new(); + script_state_turns(&migrate_provider); + fixture + .state + .service() + .inject_provider_host(Arc::new(migrate_provider.clone())); + let migrated_run = admit_state_run(&http, &fixture.token, &session_id, &migrate_provider).await; + assert_eq!( + wait_run_any_terminal(&fixture.state, &migrated_run).await, + "completed" + ); + assert_eq!(store.root(&key_b1).expect("root").expect("root").schema, 2); + assert_eq!( + store + .root(&key_b2) + .expect("root") + .expect("branch b2 root") + .schema, + 1 + ); + + let candidates = Arc::new(std::sync::Mutex::new(Vec::new())); + let recorded_candidates = Arc::clone(&candidates); + fixture + .state + .service() + .observe_state_candidates(Arc::new(move |object_id: &str| { + recorded_candidates + .lock() + .expect("candidate log") + .push(object_id.to_string()); + })); + + let precommit_run_id: Arc>> = + Arc::new(std::sync::Mutex::new(None)); + let precommit_run_slot = Arc::clone(&precommit_run_id); + let a_precommit = Arc::new(AtomicBool::new(false)); + let a_release = Arc::new(AtomicBool::new(false)); + let a_precommit_seen = Arc::clone(&a_precommit); + let a_release_slot = Arc::clone(&a_release); + persistence.inject_admission_precommit_hook(move |payload| { + *precommit_run_slot.lock().expect("A precommit run") = + payload["run_id"].as_str().map(str::to_string); + a_precommit_seen.store(true, Ordering::SeqCst); + while !a_release_slot.load(Ordering::SeqCst) { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + }); + + let proof_entered = Arc::new(AtomicBool::new(false)); + let proof_release = Arc::new(AtomicBool::new(false)); + let proof_entered_seen = Arc::clone(&proof_entered); + let proof_release_slot = Arc::clone(&proof_release); + fixture + .state + .service() + .observe_original_proof_reads(Arc::new(move |op: &str, _gate_free: bool| { + if op == "admission.replay" { + proof_entered_seen.store(true, Ordering::SeqCst); + while !proof_release_slot.load(Ordering::SeqCst) { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + } + })); + + let a_provider = rustscript_agent::ScriptedProvider::new(); + script_state_turns(&a_provider); + fixture + .state + .service() + .inject_provider_host(Arc::new(a_provider.clone())); + persistence.inject_admission_ack_loss(); + let service_a = fixture.state.service().clone(); + let a_session = session_id.clone(); + let a_task = tokio::spawn(async move { + service_a + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!("lease linearization A"), + session_id: Some(a_session), + idempotency_key: Some("t12-lease-linearization-a".into()), + idempotency_hash: Some("fnv64:0000000000000c03".into()), + platform: "test".into(), + ..AdmitRunRequest::default() + }, + None, + ) + .await + }); + wait_flag(&a_precommit, "A precommit").await; + + // A is parked inside its real gate after the maintenance check. Start B on + // the sibling branch whose live root is still schema1, so B must migrate. + let b_provider = rustscript_agent::ScriptedProvider::new(); + script_state_turns(&b_provider); + fixture + .state + .service() + .inject_provider_host(Arc::new(b_provider.clone())); + let service_b = fixture.state.service().clone(); + let b_session = session_id.clone(); + let b_graph = graph2.clone(); + let b_branch = branch_b2.clone(); + let b_task = tokio::spawn(async move { + service_b + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!("lease linearization B"), + session_id: Some(b_session), + graph_id: Some(b_graph), + workspace_branch: Some(b_branch), + platform: "test".into(), + ..AdmitRunRequest::default() + }, + None, + ) + .await + }); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + // In the pre-fix ordering B reaches its candidate put (and therefore its + // nonterminal read) before A commits. Wait for that signal when it appears; + // in the fixed ordering the lease publication is serialized by the gate, so + // B cannot produce a candidate until after A settles and this bounded wait + // simply expires. + let candidate_deadline = std::time::Instant::now() + std::time::Duration::from_secs(3); + while std::time::Instant::now() < candidate_deadline { + if !candidates.lock().expect("candidate log").is_empty() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + assert!( + !b_task.is_finished(), + "B cannot publish while A is still inside its commit gate" + ); + + // A commits durably, loses its reply, drops the gate, and parks at the + // first original-proof read. Its admission message is not in memory yet. + a_release.store(true, Ordering::SeqCst); + let proof_deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + while !proof_entered.load(Ordering::SeqCst) { + if a_task.is_finished() { + let result = a_task.await.expect("A admission join"); + let run_id = precommit_run_id + .lock() + .expect("A precommit run") + .clone() + .unwrap_or_default(); + panic!("A finished before original proof read: {result:?} run={run_id}"); + } + if std::time::Instant::now() >= proof_deadline { + panic!("A original proof read was not reached and A is still running"); + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + let schema_in_window = store.root(&key_b2).expect("root").expect("root").schema; + let b_finished_in_window = b_task.is_finished(); + + // Cross-session positive control while B waits: an unrelated session is + // not blocked by this session's maintenance lease. + let other_session = create_session(&http, &fixture.token).await; + let other_provider = rustscript_agent::ScriptedProvider::new(); + script_state_turns(&other_provider); + fixture + .state + .service() + .inject_provider_host(Arc::new(other_provider.clone())); + let other_started = std::time::Instant::now(); + let (other_status, other_body) = admit_state_run_response( + &http, + &fixture.token, + &other_session, + None, + Some(&graph2), + &other_provider, + ) + .await; + let other_elapsed = other_started.elapsed(); + assert_eq!(other_status, 202, "{}", explain(other_status, &other_body)); + assert!( + other_elapsed < std::time::Duration::from_millis(1500), + "another session must not wait on this session's lease: {other_elapsed:?}" + ); + let other_run = other_body["run_id"].as_str().unwrap().to_string(); + assert_eq!( + wait_run_any_terminal(&fixture.state, &other_run).await, + "completed" + ); + wait_provider_call(&other_provider).await; + + // A's delayed memory publication is released here. A's run is durably + // nonterminal until the explicit typed terminal transition below; B must + // keep waiting until then. + proof_release.store(true, Ordering::SeqCst); + let a_admitted = tokio::time::timeout(std::time::Duration::from_secs(20), a_task) + .await + .expect("A admission task completes") + .expect("A admission task join") + .expect("A delayed publication succeeds"); + let a_status = persistence + .run_get(&a_admitted.run_id) + .ok() + .and_then(|data| data["rows"][0][3].as_str().map(str::to_string)) + .unwrap_or_default(); + assert_eq!( + a_status, "running", + "A's run is durably nonterminal when its memory publication lands" + ); + // Settle A's run through the real typed terminal transition. Its worker is + // not part of this lease-ordering assertion. + terminate_durable_run(&persistence, &a_admitted.run_id); + let b_admitted = tokio::time::timeout(std::time::Duration::from_secs(30), b_task) + .await + .expect("B admission completes after A settles") + .expect("B admission task join") + .expect("B migration admission succeeds"); + + let root_after = store.root(&key_b2).expect("root").expect("schema2 root"); + assert_eq!(root_after.schema, 2); + assert_eq!( + store.root(&key_b1).expect("root").expect("b1 root").schema, + 2, + "A's branch root stays at the schema it admitted" + ); + let b_binding = store + .binding(&b_admitted.run_id) + .expect("B binding") + .expect("schema2 binding"); + assert_eq!(b_binding.binding.schema, 2); + assert_eq!(b_binding.binding.branch_id, branch_b2); + assert_eq!(b_binding.binding.object_id, root_after.object_id); + // Decisive assertions, evaluated after all tasks settled so a RED run can + // still tear down deterministically. + assert_eq!( + schema_in_window, 1, + "B published schema2 while A's durable run was nonterminal (B done={b_finished_in_window})" + ); + assert!( + !b_finished_in_window, + "B admission finished while A's durable run was still nonterminal" + ); + let observed = candidates.lock().expect("candidate log").clone(); + assert!( + observed.len() >= 2, + "ordinary memory drift must reprepare B's candidate, observed={observed:?}" + ); + assert!(!a_admitted.run_id.is_empty()); +} + +/// R2 direct regression: failed or re-prepared migration candidates are +/// deterministically settled through the existing typed bounded collector +/// outside the global gate. Old abandoned objects disappear, the retained set +/// is bounded, prepared root/binding objects stay readable, and a candidate +/// that is still in flight is protected so its admission can commit. +/// +/// The repeated failure uses the existing one-shot post-CAS admission guard +/// failpoint so every attempt deterministically rolls back after the candidate +/// exists. It is a fixture seam on the real guarded transaction, not a +/// production behavior change. +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn abandoned_migration_candidates_are_bounded_and_protected() { + let fixture = open_managed_run_state_fixture("t12-candidate-settle"); + let http = Http::bind(fixture.state.clone()).await; + run_state_plugin(&http, &fixture.token).await; + let session_id = create_session(&http, &fixture.token).await; + let persistence = fixture.state.persistence().unwrap(); + let store = PluginStateStore::open(Arc::clone(&persistence)); + let identity_key = "t12-candidate-settlement-key"; + + let baseline_provider = rustscript_agent::ScriptedProvider::new(); + script_state_turns(&baseline_provider); + fixture + .state + .service() + .inject_provider_host(Arc::new(baseline_provider.clone())); + let (status, baseline) = admit_run_with_identity( + &http, + &fixture.token, + &session_id, + "candidate baseline", + None, + identity_key, + ) + .await; + assert_eq!(status, 202, "{}", explain(status, &baseline)); + let baseline_run = baseline["run_id"].as_str().unwrap().to_string(); + assert_eq!( + wait_run_any_terminal(&fixture.state, &baseline_run).await, + "completed" + ); + let key = store + .session_root_key(&session_id, "org.rustscript.test.staterun") + .expect("session key") + .expect("schema1 session root"); + let root_before = store.root(&key).expect("root").expect("schema1 root"); + assert_eq!(root_before.schema, 1); + let baseline_binding = store + .binding(&baseline_run) + .expect("baseline binding") + .expect("baseline state binding"); + assert_eq!( + baseline_binding.binding.object_id, root_before.object_id, + "the published root and run binding reference the same durable object" + ); + assert!(store.object_payload(&root_before.object_id).is_ok()); + + let candidates = Arc::new(std::sync::Mutex::new(Vec::new())); + let recorded = Arc::clone(&candidates); + fixture + .state + .service() + .observe_state_candidates(Arc::new(move |object_id: &str| { + recorded + .lock() + .expect("candidate log") + .push(object_id.to_string()); + })); + + let (graph2, _) = + activate_state_package(&http, &fixture.token, "package-staterun2", "2.0.0").await; + for attempt in 0..70 { + persistence.inject_admission_state_fail(); + let attempt_key = format!("t12-candidate-failure-{attempt}"); + let (status, body) = admit_run_with_identity( + &http, + &fixture.token, + &session_id, + "candidate failure", + Some(&graph2), + &attempt_key, + ) + .await; + assert_eq!( + status, + 503, + "failed admission {attempt}: {}", + explain(status, &body) + ); + assert_eq!(body["error"]["code"], "persistence_unavailable"); + } + let ids = candidates.lock().expect("candidate log").clone(); + assert_eq!( + ids.len(), + 70, + "each failed admission prepared one candidate" + ); + let existing = ids + .iter() + .filter(|object_id| store.object_payload(object_id).is_ok()) + .count(); + assert!( + existing < ids.len(), + "abandoned candidates must be reclaimed" + ); + assert_eq!( + existing, 64, + "the existing collector retains a bounded recent orphan set" + ); + assert!( + store.object_payload(&root_before.object_id).is_ok(), + "the published root object must never be collected" + ); + assert!( + store + .object_payload(&baseline_binding.binding.object_id) + .is_ok(), + "the run binding object must never be collected" + ); + let root_mid = store.root(&key).expect("root").expect("schema1 root"); + assert_eq!(root_mid.schema, 1); + assert_eq!(root_mid.revision, root_before.revision); + + let (candidate_tx, candidate_rx) = std::sync::mpsc::channel::(); + let release = Arc::new((std::sync::Mutex::new(false), std::sync::Condvar::new())); + let release_observer = Arc::clone(&release); + fixture + .state + .service() + .observe_state_candidates(Arc::new(move |object_id: &str| { + candidate_tx + .send(object_id.to_string()) + .expect("candidate id"); + let (lock, cvar) = &*release_observer; + let mut released = lock.lock().expect("candidate release"); + while !*released { + released = cvar.wait(released).expect("candidate release wait"); + } + })); + let live_provider = rustscript_agent::ScriptedProvider::new(); + script_state_turns(&live_provider); + fixture + .state + .service() + .inject_provider_host(Arc::new(live_provider.clone())); + let service = fixture.state.service().clone(); + let live_session = session_id.clone(); + let live_graph = graph2.clone(); + let live_admission = tokio::spawn(async move { + service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!("candidate live"), + session_id: Some(live_session), + graph_id: Some(live_graph), + platform: "test".into(), + ..AdmitRunRequest::default() + }, + None, + ) + .await + }); + let live_candidate = tokio::task::spawn_blocking(move || { + candidate_rx + .recv_timeout(std::time::Duration::from_secs(20)) + .expect("live candidate prepared") + }) + .await + .unwrap(); + assert!( + store.object_payload(&live_candidate).is_ok(), + "the in-flight candidate exists before collection" + ); + let _ = store.collect_orphans(); + assert!( + store.object_payload(&live_candidate).is_ok(), + "an unsettled candidate must be protected from concurrent collection" + ); + { + let (lock, cvar) = &*release; + *lock.lock().expect("candidate release") = true; + cvar.notify_all(); + } + let live_run = live_admission + .await + .expect("live admission task") + .expect("live candidate admission commits"); + assert!(!live_run.replayed); + let root_after = store.root(&key).expect("root").expect("schema2 root"); + assert_eq!(root_after.schema, 2); + assert_eq!( + root_after.object_id, live_candidate, + "the protected candidate becomes the published root" + ); + let live_binding = store + .binding(&live_run.run_id) + .expect("live binding") + .expect("schema2 binding"); + assert_eq!(live_binding.binding.object_id, live_candidate); + assert_eq!(live_binding.binding.schema, 2); +} + +#[tokio::test(flavor = "multi_thread")] +async fn session_schema_switch_waits_under_lease_without_blocking_other_sessions() { + let fixture = open_managed_run_state_fixture("t12-session-switch"); + let http = Http::bind(fixture.state.clone()).await; + let graph1 = run_state_plugin(&http, &fixture.token).await; + assert!(!graph1.is_empty()); + let (status, body) = http + .request( + "POST", + "/v1/plugins/install", + json!({"package": "package-staterun2", "deployment_space": "owner-space"}), + Some(&fixture.token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &body)); + let (status, resolved2) = http + .request( + "POST", + "/v1/plugin-graphs/resolve", + json!({ + "roots": [{"id": "org.rustscript.test.staterun", "version": "2.0.0"}], + "tool_package": "org.rustscript.test.staterun", + "deployment_space": "owner-space" + }), + Some(&fixture.token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &resolved2)); + let graph2 = resolved2["graph_id"].as_str().unwrap().to_string(); + let (status, prepared2) = http + .request( + "POST", + &format!("/v1/plugin-graphs/{graph2}/prepare"), + json!({}), + Some(&fixture.token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &prepared2)); + + let (status, session) = http + .request( + "POST", + "/api/sessions", + json!({}), + Some(&fixture.token), + None, + ) + .await; + assert_eq!(status, 201, "{}", explain(status, &session)); + let session_id = session["session"]["id"].as_str().unwrap().to_string(); + let first_provider = rustscript_agent::ScriptedProvider::new(); + fixture + .state + .service() + .inject_provider_host(Arc::new(first_provider.clone())); + let first_run = admit_state_run(&http, &fixture.token, &session_id, &first_provider).await; + let persistence = fixture.state.persistence().unwrap(); + wait_run_terminal(&fixture.state, &first_run).await; + let store = PluginStateStore::open(Arc::clone(&persistence)); + let first_binding = store + .binding(&first_run) + .expect("binding") + .expect("state binding"); + assert_eq!(first_binding.binding.schema, 1); + + let blocker = "blocker-run"; + seed_run(&persistence, &session_id, blocker, 42); + complete_durable_run(&persistence, blocker); + let cancellation = rustscript_agent::RunCancellation::with_deadline( + std::time::Instant::now() + std::time::Duration::from_secs(15), + ); + let service = fixture.state.service().clone(); + let session_for_switch = session_id.clone(); + let graph_for_switch = graph2.clone(); + let (sender, receiver) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let result = service.switch_plugin_state_schema( + &session_for_switch, + &graph_for_switch, + &cancellation, + ); + sender.send(result).unwrap(); + }); + tokio::time::sleep(std::time::Duration::from_millis(150)).await; + let (status, busy) = http + .request( + "POST", + "/v1/runs", + json!({"session_id": session_id, "input": "must not race schema switch"}), + Some(&fixture.token), + Some(&uuid::Uuid::new_v4().to_string()), + ) + .await; + assert_eq!(status, 409, "{}", explain(status, &busy)); + assert_eq!(busy["error"]["code"], "session_busy"); + + let (status, other_session) = http + .request( + "POST", + "/api/sessions", + json!({}), + Some(&fixture.token), + None, + ) + .await; + assert_eq!(status, 201, "{}", explain(status, &other_session)); + let other_id = other_session["session"]["id"].as_str().unwrap().to_string(); + let other_provider = rustscript_agent::ScriptedProvider::new(); + fixture + .state + .service() + .inject_provider_host(Arc::new(other_provider.clone())); + let other_run = admit_state_run(&http, &fixture.token, &other_id, &other_provider).await; + wait_run_terminal(&fixture.state, &other_run).await; + + terminate_durable_run(&persistence, blocker); + let switched = receiver + .recv_timeout(std::time::Duration::from_secs(10)) + .expect("switch thread completed"); + let revision = switched.expect("session schema switch succeeds"); + assert!(revision.is_some()); + let root_key = store + .session_root_key(&session_id, "org.rustscript.test.staterun") + .expect("session key") + .expect("switch session key"); + let root = store.root(&root_key).expect("root").expect("root"); + assert_eq!(root.schema, 2); + + let expired = rustscript_agent::RunCancellation::with_deadline( + std::time::Instant::now() - std::time::Duration::from_secs(1), + ); + let error = fixture + .state + .service() + .switch_plugin_state_schema(&session_id, &graph2, &expired) + .expect_err("expired switch must not hold the session lease"); + assert_eq!(error, "cancelled"); + let (status, activated2) = http + .request( + "POST", + "/v1/plugin-activations", + json!({ + "graph_id": graph2, + "deployment_space": "owner-space", + "expected_generation": resolved2["generation"].as_u64().unwrap_or(0) + }), + Some(&fixture.token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &activated2)); + let final_provider = rustscript_agent::ScriptedProvider::new(); + fixture + .state + .service() + .inject_provider_host(Arc::new(final_provider.clone())); + let final_run = admit_state_run(&http, &fixture.token, &session_id, &final_provider).await; + wait_run_terminal(&fixture.state, &final_run).await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn managed_checkpoint_records_real_stateful_roots() { + let fixture = open_managed_state_fixture("t12-managed-state-checkpoint"); + let http = Http::bind(fixture.state.clone()).await; + run_state_plugin(&http, &fixture.token).await; + let (status, session) = http + .request( + "POST", + "/api/sessions", + json!({}), + Some(&fixture.token), + None, + ) + .await; + assert_eq!(status, 201, "{}", explain(status, &session)); + let session_id = session["session"]["id"].as_str().unwrap().to_string(); + let provider = rustscript_agent::ScriptedProvider::new(); + fixture + .state + .service() + .inject_provider_host(Arc::new(provider.clone())); + let run_id = admit_state_run(&http, &fixture.token, &session_id, &provider).await; + wait_run_terminal(&fixture.state, &run_id).await; + let persistence = fixture.state.persistence().unwrap(); + let store = PluginStateStore::open(Arc::clone(&persistence)); + let binding = store + .binding(&run_id) + .expect("state binding") + .expect("state binding"); + assert_eq!(binding.payload.as_str(), r#"{"count":1}"#); + let (status, effects) = http + .request( + "GET", + &format!("/v1/runs/{run_id}/effects?limit=128"), + json!(null), + Some(&fixture.token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &effects)); + let entry = effects["entries"] + .as_array() + .and_then(|entries| entries.first()) + .expect("state run emitted a managed read receipt") + .clone(); + let workspace_id = entry["workspace_id"].as_str().unwrap().to_string(); + let branch_id = entry["branch_id"].as_str().unwrap().to_string(); + let epoch = entry["epoch"].as_u64().unwrap(); + let (status, checkpoint) = http + .request( + "POST", + &format!("/v1/workspaces/{workspace_id}/checkpoints"), + json!({ + "source_run_id": run_id, + "workspace_id": workspace_id, + "source_branch_id": branch_id, + "expected_epoch": epoch, + "request_id": uuid::Uuid::new_v4().to_string(), + "mode": "at_closed_batch" + }), + Some(&fixture.token), + None, + ) + .await; + assert_eq!(status, 201, "{}", explain(status, &checkpoint)); + assert_eq!(checkpoint["checkpoint"]["plugin_state"]["kind"], "stateful"); + assert_eq!( + checkpoint["checkpoint"]["public_config"]["config"]["policy_revision"], + "stateful_session" + ); + assert_eq!( + checkpoint["checkpoint"]["plugin_state"]["manifest_state_present"], + true + ); + assert_eq!( + checkpoint["checkpoint"]["plugin_state"]["invocation_excluded_from_checkpoint"], + false + ); + let roots = checkpoint["checkpoint"]["plugin_state"]["roots"] + .as_array() + .expect("stateful roots"); + assert!(!roots.is_empty()); + assert_eq!(roots[0]["plugin_id"], "org.rustscript.test.staterun"); + assert_eq!(roots[0]["schema"], "1"); + assert!( + roots + .iter() + .any(|root| root["immutable_root_id"] == binding.binding.object_id) + ); + + let call_id = entry["call_id"].as_str().unwrap().to_string(); + let record = ["local-agent", "gateway", "default"] + .iter() + .find_map(|profile| { + persistence + .managed_effect_get(&rustscript_agent::effects::ManagedEffectGetRequest { + owner: rustscript_agent::effects::ManagedOwnerScope { + profile: (*profile).into(), + session_id: session_id.clone(), + run_id: run_id.clone(), + }, + call_id: call_id.clone(), + }) + .ok() + .flatten() + }) + .expect("managed effect receipt for the state run"); + assert_eq!(record.identity.call_id, call_id); + let prepared = record.prepared.as_ref().expect("prepared effect receipt"); + let publication = prepared + .plugin_state + .as_ref() + .expect("state write must carry its Native effect publication"); + assert_eq!(publication.object_id, binding.binding.object_id); + assert_eq!(publication.expected_revision, 1); + assert_eq!(publication.owner_kind, "session"); + let head = persistence + .managed_workspace_head(&record.identity.workspace) + .expect("managed workspace head"); + let checkpoint_id = checkpoint["checkpoint"]["checkpoint_id"] + .as_str() + .unwrap() + .to_string(); + let restore_provider = rustscript_agent::ScriptedProvider::new(); + restore_provider.push_ok(json!({ + "text": "restored", + "tool_calls": [], + "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + "reasoning": "", + "stop_reason": "stop" + })); + fixture + .state + .service() + .inject_provider_host(Arc::new(restore_provider)); + let (status, restored) = http + .request( + "POST", + &format!("/v1/workspaces/{workspace_id}/restore"), + json!({ + "workspace_id": workspace_id, + "source_branch_id": head.branch_id, + "checkpoint_id": checkpoint_id, + "expected_source": { + "store_id": head.store_id, + "workspace_id": head.workspace_id, + "branch_id": head.branch_id, + "epoch": head.epoch, + "head_object_id": head.head_object_id, + "head_version": head.head_version + }, + "request_id": uuid::Uuid::new_v4().to_string(), + "input": {"text": "restore exact state"}, + "cancel_active_source": true, + "graph_selection": "checkpoint_exact" + }), + Some(&fixture.token), + None, + ) + .await; + assert_eq!(status, 202, "{}", explain(status, &restored)); + assert_eq!(restored["disposition"], "restored_and_admitted"); + let restored_run = restored["new_run_id"].as_str().unwrap().to_string(); + assert_ne!(restored_run, run_id); + wait_run_terminal(&fixture.state, &restored_run).await; + let restored_binding = store + .binding(&restored_run) + .expect("restored binding") + .expect("restored binding"); + assert_eq!(restored_binding.binding.schema, 1); + assert_eq!(restored_binding.payload.as_str(), r#"{"count":1}"#); + let source_binding = store + .binding(&run_id) + .expect("source binding") + .expect("source binding"); + assert_eq!(source_binding.payload.as_str(), r#"{"count":1}"#); + assert_ne!( + restored_binding.binding.branch_id, + source_binding.binding.branch_id + ); +} + +#[tokio::test(flavor = "multi_thread")] +async fn state_authorization_rejects_wrong_profile_and_inactive_run_without_pointer_change() { + let fixture = open_fixture("t12-state-authorize"); + let persistence = fixture.state.persistence().expect("native persistence"); + seed_session(&persistence, "session-auth", 50); + seed_run(&persistence, "session-auth", "run-auth", 51); + let store = PluginStateStore::open(Arc::clone(&persistence)); + let bound = store + .bind_run(&bind_request( + "run-auth", + "session-auth", + "org.rustscript.test.state", + PluginStateScope::Session, + 1, + )) + .expect("bind state"); + let written = store + .commit( + &bound, + &PluginStatePayload::parse(r#"{"count":1}"#).unwrap(), + ) + .expect("write state"); + let wrong = store + .authorize(&written.binding, "other-profile", "graph-state-1", 52) + .expect_err("wrong profile must not authorize state"); + assert_eq!(wrong.code, "plugin_state_unauthorized"); + complete_durable_run(&persistence, "run-auth"); + terminate_durable_run(&persistence, "run-auth"); + let inactive = store + .authorize(&written.binding, "default", "graph-state-1", 53) + .expect_err("terminal run must not authorize state"); + assert_eq!(inactive.code, "plugin_state_unauthorized"); + let unchanged = store + .binding("run-auth") + .expect("binding") + .expect("binding"); + assert_eq!(unchanged.binding.revision, written.binding.revision); + assert_eq!(unchanged.binding.object_id, written.binding.object_id); +} + +fn bind_branch_request( + run_id: &str, + session_id: &str, + branch_id: &str, + schema: u32, +) -> PluginStateBindRequest { + PluginStateBindRequest { + branch_id: branch_id.into(), + ..bind_request( + run_id, + session_id, + "org.rustscript.test.state", + PluginStateScope::Session, + schema, + ) + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn stale_session_commit_rejects_root_and_binding_together() { + let fixture = open_fixture("t12-session-cas-atomic"); + let persistence = fixture.state.persistence().expect("native persistence"); + seed_session(&persistence, "session-cas", 60); + seed_run(&persistence, "session-cas", "run-cas-a", 61); + seed_run(&persistence, "session-cas", "run-cas-b", 62); + let store = PluginStateStore::open(Arc::clone(&persistence)); + let a = store + .bind_run(&bind_branch_request( + "run-cas-a", + "session-cas", + "branch-1", + 1, + )) + .expect("bind first run"); + let b = store + .bind_run(&bind_branch_request( + "run-cas-b", + "session-cas", + "branch-1", + 1, + )) + .expect("bind second run"); + assert_eq!(a.binding.object_id, b.binding.object_id); + let winner = store + .commit(&a, &PluginStatePayload::parse(r#"{"writer":"a"}"#).unwrap()) + .expect("first commit wins the session root CAS"); + assert_eq!(winner.binding.revision, 2); + + let loser_payload = PluginStatePayload::parse(r#"{"writer":"b"}"#).unwrap(); + let conflict = store + .commit(&b, &loser_payload) + .expect_err("stale second commit must conflict"); + assert_eq!(conflict.code, "plugin_state_conflict"); + + let key = PluginStateRootKey { + owner_kind: "session".into(), + owner_id: "session-cas".into(), + workspace_id: "workspace-1".into(), + branch_id: "branch-1".into(), + plugin_id: "org.rustscript.test.state".into(), + }; + let root = store.root(&key).expect("root").expect("session root"); + assert_eq!(root.revision, 2); + assert_eq!(root.object_id, winner.binding.object_id); + let loser = store + .binding("run-cas-b") + .expect("loser binding") + .expect("loser binding"); + assert_eq!(loser.binding.revision, 1); + assert_eq!(loser.binding.object_id, b.binding.object_id); + assert_eq!(loser.payload.as_str(), "{}"); + assert_ne!(loser.binding.object_id, winner.binding.object_id); + + // Root CAS matches but the run binding does not: the root must not move. + let mut forged = winner.binding.clone(); + forged.run_id = "run-cas-b".into(); + let forged_snapshot = PluginStateSnapshot { + binding: forged, + payload: winner.payload.clone(), + }; + let reverse = store + .commit( + &forged_snapshot, + &PluginStatePayload::parse(r#"{"writer":"forged"}"#).unwrap(), + ) + .expect_err("binding CAS mismatch must reject the whole commit"); + assert_eq!(reverse.code, "plugin_state_conflict"); + let root = store.root(&key).expect("root").expect("session root"); + assert_eq!(root.revision, 2); + assert_eq!(root.object_id, winner.binding.object_id); + let loser = store + .binding("run-cas-b") + .expect("loser binding") + .expect("loser binding"); + assert_eq!(loser.binding.revision, 1); + assert_eq!(loser.payload.as_str(), "{}"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn checkpoint_roots_restore_exact_source_branch_and_reject_ambiguity() { + let fixture = open_fixture("t12-checkpoint-exact-branch"); + let persistence = fixture.state.persistence().expect("native persistence"); + seed_session(&persistence, "session-exact", 70); + seed_run(&persistence, "session-exact", "run-a", 71); + seed_run(&persistence, "session-exact", "run-b", 72); + seed_run(&persistence, "session-exact", "run-restored", 73); + let store = PluginStateStore::open(Arc::clone(&persistence)); + let a = store + .bind_run(&bind_branch_request( + "run-a", + "session-exact", + "branch-1", + 1, + )) + .expect("bind branch a"); + let a_written = store + .commit(&a, &PluginStatePayload::parse(r#"{"branch":"a"}"#).unwrap()) + .expect("write branch a"); + let b = store + .bind_run(&bind_branch_request( + "run-b", + "session-exact", + "branch-2", + 1, + )) + .expect("bind branch b"); + let mut b_written = b.clone(); + for index in 2..=5 { + b_written = store + .commit( + &b_written, + &PluginStatePayload::parse(&format!(r#"{{"branch":"b","n":{index}}}"#)).unwrap(), + ) + .expect("write branch b"); + } + assert_eq!(b_written.binding.revision, 5); + assert!(b_written.binding.revision > a_written.binding.revision); + + let roots = store + .checkpoint_roots("run-a") + .expect("checkpoint roots for a"); + assert_eq!(roots.len(), 1, "checkpoint must capture exactly run a root"); + assert_eq!(roots[0].branch_id, "branch-1"); + assert_eq!(roots[0].object_id, a_written.binding.object_id); + assert_eq!(roots[0].revision, a_written.binding.revision); + let restore_roots: Vec = roots + .iter() + .map(|root| PluginStateRootRef { + owner_kind: root.owner_kind.clone(), + owner_id: root.owner_id.clone(), + workspace_id: root.workspace_id.clone(), + branch_id: root.branch_id.clone(), + plugin_id: root.plugin_id.clone(), + package_id: root.package_id.clone(), + artifact_id: root.artifact_id.clone(), + schema: root.schema, + revision: root.revision, + object_id: root.object_id.clone(), + }) + .collect(); + let restored = store + .restore_binding(&PluginStateRestoreRequest { + scope: PluginStateScope::Session, + run_id: "run-restored".into(), + session_id: "session-exact".into(), + workspace_id: "workspace-1".into(), + branch_id: "branch-restored".into(), + plugin_id: "org.rustscript.test.state".into(), + package_id: "org.rustscript.test.state".into(), + artifact_id: "artifact-state".into(), + graph_id: "graph-state-1".into(), + epoch: 1, + checkpoint_id: "checkpoint-exact".into(), + roots: restore_roots.clone(), + }) + .expect("restore branch a exact root"); + assert_eq!(restored.payload.as_str(), r#"{"branch":"a"}"#); + assert_eq!(restored.binding.object_id, a_written.binding.object_id); + assert_eq!(restored.binding.schema, 1); + + // A later session migration must not change the old run's captured root. + let migrated_object = store + .put_object( + "org.rustscript.test.state", + "artifact-state", + 2, + &PluginStatePayload::parse(r#"{"branch":"a","schema":2}"#).unwrap(), + ) + .expect("persist migrated object"); + let session_key = PluginStateRootKey { + owner_kind: "session".into(), + owner_id: "session-exact".into(), + workspace_id: "workspace-1".into(), + branch_id: "branch-1".into(), + plugin_id: "org.rustscript.test.state".into(), + }; + let live = store + .root(&session_key) + .expect("root") + .expect("branch a root"); + store + .migrate_root(&session_key, live.revision, &migrated_object) + .expect("migrate branch a session root"); + let old = store + .checkpoint_roots("run-a") + .expect("checkpoint roots after migration"); + assert_eq!(old.len(), 1); + assert_eq!(old[0].schema, 1); + assert_eq!(old[0].revision, a_written.binding.revision); + assert_eq!(old[0].object_id, a_written.binding.object_id); + + let ambiguous: Vec = restore_roots + .iter() + .cloned() + .chain(std::iter::once(PluginStateRootRef { + owner_id: "session-exact".into(), + branch_id: "branch-2".into(), + schema: b_written.binding.schema, + revision: b_written.binding.revision, + object_id: b_written.binding.object_id.clone(), + ..restore_roots[0].clone() + })) + .collect(); + let rejected = store + .restore_binding(&PluginStateRestoreRequest { + scope: PluginStateScope::Session, + run_id: "run-restored".into(), + session_id: "session-exact".into(), + workspace_id: "workspace-1".into(), + branch_id: "branch-ambiguous".into(), + plugin_id: "org.rustscript.test.state".into(), + package_id: "org.rustscript.test.state".into(), + artifact_id: "artifact-state".into(), + graph_id: "graph-state-1".into(), + epoch: 1, + checkpoint_id: "checkpoint-ambiguous".into(), + roots: ambiguous, + }) + .expect_err("multiple checkpoint roots are ambiguous"); + assert!( + rejected.code == "plugin_state_root_mismatch" + || rejected.code == "plugin_state_root_ambiguous" + ); + let existing = store + .binding("run-restored") + .expect("restored binding") + .expect("restored binding"); + assert_eq!(existing.binding.checkpoint_id, "checkpoint-exact"); + assert_eq!(existing.binding.object_id, a_written.binding.object_id); +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn checkpoint_waits_for_state_writer_busy_then_succeeds_after_settle() { + let fixture = open_managed_state_fixture("t12-state-writer-fence"); + let http = Http::bind(fixture.state.clone()).await; + run_state_plugin(&http, &fixture.token).await; + let (status, session) = http + .request( + "POST", + "/api/sessions", + json!({}), + Some(&fixture.token), + None, + ) + .await; + assert_eq!(status, 201, "{}", explain(status, &session)); + let session_id = session["session"]["id"].as_str().unwrap().to_string(); + let provider = rustscript_agent::ScriptedProvider::new(); + fixture + .state + .service() + .inject_provider_host(Arc::new(provider.clone())); + let run_id = admit_state_run(&http, &fixture.token, &session_id, &provider).await; + wait_run_terminal(&fixture.state, &run_id).await; + let persistence = fixture.state.persistence().unwrap(); + let store = PluginStateStore::open(Arc::clone(&persistence)); + let binding = store + .binding(&run_id) + .expect("state binding") + .expect("state binding"); + let (status, effects) = http + .request( + "GET", + &format!("/v1/runs/{run_id}/effects?limit=128"), + json!(null), + Some(&fixture.token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &effects)); + let entry = effects["entries"] + .as_array() + .and_then(|entries| entries.first()) + .expect("managed read receipt") + .clone(); + let workspace_id = entry["workspace_id"].as_str().unwrap().to_string(); + let branch_id = entry["branch_id"].as_str().unwrap().to_string(); + let epoch = entry["epoch"].as_u64().unwrap(); + let hold_cancel = rustscript_agent::RunCancellation::with_deadline( + std::time::Instant::now() + std::time::Duration::from_secs(30), + ); + let hold = store + .hold_writer_for_test(&binding.binding, &hold_cancel) + .expect("hold the formal state writer lease"); + let started = std::time::Instant::now(); + let (status, busy) = http + .request( + "POST", + &format!("/v1/workspaces/{workspace_id}/checkpoints"), + json!({ + "source_run_id": run_id, + "workspace_id": workspace_id, + "source_branch_id": branch_id, + "expected_epoch": epoch, + "request_id": uuid::Uuid::new_v4().to_string(), + "mode": "at_closed_batch" + }), + Some(&fixture.token), + None, + ) + .await; + assert_eq!(status, 409, "{}", explain(status, &busy)); + assert_eq!(busy["error"]["code"], "managed_checkpoint_busy"); + assert!(started.elapsed() >= std::time::Duration::from_secs(4)); + drop(hold); + let (status, checkpoint) = http + .request( + "POST", + &format!("/v1/workspaces/{workspace_id}/checkpoints"), + json!({ + "source_run_id": run_id, + "workspace_id": workspace_id, + "source_branch_id": branch_id, + "expected_epoch": epoch, + "request_id": uuid::Uuid::new_v4().to_string(), + "mode": "at_closed_batch" + }), + Some(&fixture.token), + None, + ) + .await; + assert_eq!(status, 201, "{}", explain(status, &checkpoint)); + let roots = checkpoint["checkpoint"]["plugin_state"]["roots"] + .as_array() + .expect("stateful roots"); + assert!(!roots.is_empty()); + assert_eq!(roots[0]["immutable_root_id"], binding.binding.object_id); + assert_eq!(roots[0]["revision"], binding.binding.revision); +} + +#[tokio::test(flavor = "multi_thread")] +async fn admission_migrates_each_real_branch_only_on_its_own_commit() { + let fixture = open_managed_run_state_fixture("t12-admission-dual-branch"); + let http = Http::bind(fixture.state.clone()).await; + let graph1 = run_state_plugin(&http, &fixture.token).await; + let (status, session) = http + .request( + "POST", + "/api/sessions", + json!({}), + Some(&fixture.token), + None, + ) + .await; + assert_eq!(status, 201, "{}", explain(status, &session)); + let session_id = session["session"]["id"].as_str().unwrap().to_string(); + let persistence = fixture.state.persistence().unwrap(); + let store = PluginStateStore::open(Arc::clone(&persistence)); + let provider = rustscript_agent::ScriptedProvider::new(); + fixture + .state + .service() + .inject_provider_host(Arc::new(provider.clone())); + let run_a = admit_state_run(&http, &fixture.token, &session_id, &provider).await; + wait_run_terminal(&fixture.state, &run_a).await; + let binding_a = store + .binding(&run_a) + .expect("branch a binding") + .expect("branch a binding"); + assert_eq!(binding_a.binding.schema, 1); + assert_eq!(binding_a.binding.revision, 2); + assert_eq!(binding_a.payload.as_str(), r#"{"count":1}"#); + + let workspace_id = binding_a.binding.workspace_id.clone(); + let (status, checkpoint) = http + .request( + "POST", + &format!("/v1/workspaces/{workspace_id}/checkpoints"), + json!({ + "source_run_id": run_a, + "workspace_id": workspace_id, + "source_branch_id": binding_a.binding.branch_id, + "expected_epoch": binding_a.binding.epoch, + "request_id": uuid::Uuid::new_v4().to_string(), + "mode": "at_closed_batch" + }), + Some(&fixture.token), + None, + ) + .await; + assert_eq!(status, 201, "{}", explain(status, &checkpoint)); + let checkpoint_id = checkpoint["checkpoint"]["checkpoint_id"] + .as_str() + .unwrap() + .to_string(); + let (status, created) = http + .request( + "POST", + &format!("/v1/workspaces/{workspace_id}/branches"), + json!({ + "workspace_id": workspace_id, + "source_branch_id": binding_a.binding.branch_id, + "checkpoint_id": checkpoint_id, + "request_id": uuid::Uuid::new_v4().to_string() + }), + Some(&fixture.token), + None, + ) + .await; + assert_eq!(status, 201, "{}", explain(status, &created)); + let branch_b = created["branch"]["branch_id"].as_str().unwrap().to_string(); + assert_ne!(branch_b, binding_a.binding.branch_id); + + let provider_b = rustscript_agent::ScriptedProvider::new(); + fixture + .state + .service() + .inject_provider_host(Arc::new(provider_b.clone())); + let run_b = admit_state_run_on_branch( + &http, + &fixture.token, + &session_id, + &branch_b, + &graph1, + &provider_b, + ) + .await; + wait_run_terminal(&fixture.state, &run_b).await; + let binding_b = store + .binding(&run_b) + .expect("branch b binding") + .expect("branch b binding"); + assert_eq!(binding_b.binding.branch_id, branch_b); + assert_eq!(binding_b.binding.schema, 1); + assert_eq!(binding_b.binding.revision, 2); + assert_eq!(binding_b.payload.as_str(), r#"{"count":2}"#); + + let key_a = PluginStateRootKey { + owner_kind: "session".into(), + owner_id: session_id.clone(), + workspace_id: workspace_id.clone(), + branch_id: binding_a.binding.branch_id.clone(), + plugin_id: "org.rustscript.test.staterun".into(), + }; + let key_b = PluginStateRootKey { + branch_id: branch_b.clone(), + ..key_a.clone() + }; + let root_a_before = store.root(&key_a).expect("root a").expect("root a"); + let root_b_before = store.root(&key_b).expect("root b").expect("root b"); + assert_eq!(root_a_before.schema, 1); + assert_eq!(root_a_before.revision, 2); + assert_eq!(root_b_before.schema, 1); + assert_eq!(root_b_before.revision, 2); + assert_ne!(root_a_before.object_id, root_b_before.object_id); + + let (graph2, _) = + activate_state_package(&http, &fixture.token, "package-staterun2", "2.0.0").await; + let provider_a2 = rustscript_agent::ScriptedProvider::new(); + fixture + .state + .service() + .inject_provider_host(Arc::new(provider_a2.clone())); + let run_a2 = admit_state_run(&http, &fixture.token, &session_id, &provider_a2).await; + wait_run_terminal(&fixture.state, &run_a2).await; + let binding_a2 = store + .binding(&run_a2) + .expect("branch a migrated binding") + .expect("branch a migrated binding"); + assert_eq!(binding_a2.binding.schema, 2); + assert_eq!(binding_a2.payload.as_str(), r#"{"count":2}"#); + let root_a_after = store.root(&key_a).expect("root a").expect("root a"); + assert_eq!(root_a_after.schema, 2); + assert_eq!(root_a_after.object_id, binding_a2.binding.object_id); + assert_eq!(root_a_after.revision, binding_a2.binding.revision); + let root_b_after_a = store.root(&key_b).expect("root b").expect("root b"); + assert_eq!(root_b_after_a.schema, 1); + assert_eq!(root_b_after_a.revision, root_b_before.revision); + assert_eq!(root_b_after_a.object_id, root_b_before.object_id); + let binding_b_after_a = store + .binding(&run_b) + .expect("branch b old binding") + .expect("branch b old binding"); + assert_eq!(binding_b_after_a.binding.schema, 1); + assert_eq!(binding_b_after_a.binding.revision, 2); + + let provider_b2 = rustscript_agent::ScriptedProvider::new(); + fixture + .state + .service() + .inject_provider_host(Arc::new(provider_b2.clone())); + let run_b2 = admit_state_run_on_branch( + &http, + &fixture.token, + &session_id, + &branch_b, + &graph2, + &provider_b2, + ) + .await; + wait_run_terminal(&fixture.state, &run_b2).await; + let binding_b2 = store + .binding(&run_b2) + .expect("branch b migrated binding") + .expect("branch b migrated binding"); + assert_eq!(binding_b2.binding.schema, 2); + assert_eq!(binding_b2.payload.as_str(), r#"{"count":3}"#); + let root_b_after = store.root(&key_b).expect("root b").expect("root b"); + assert_eq!(root_b_after.schema, 2); + assert_eq!(root_b_after.object_id, binding_b2.binding.object_id); + let root_a_after_b = store.root(&key_a).expect("root a").expect("root a"); + assert_eq!(root_a_after_b.object_id, root_a_after.object_id); + assert_eq!(root_a_after_b.revision, root_a_after.revision); + assert_eq!(store.binding(&run_a).unwrap().unwrap().binding.schema, 1); + assert_eq!(store.binding(&run_b).unwrap().unwrap().binding.schema, 1); +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn managed_commit_guard_failure_rolls_back_state_root_binding_and_receipt() { + let fixture = open_managed_run_state_fixture("t12-managed-commit-state-guard"); + let http = Http::bind(fixture.state.clone()).await; + run_state_plugin(&http, &fixture.token).await; + let (status, session) = http + .request( + "POST", + "/api/sessions", + json!({}), + Some(&fixture.token), + None, + ) + .await; + assert_eq!(status, 201, "{}", explain(status, &session)); + let session_id = session["session"]["id"].as_str().unwrap().to_string(); + let persistence = fixture.state.persistence().unwrap(); + let store = PluginStateStore::open(Arc::clone(&persistence)); + let first_provider = rustscript_agent::ScriptedProvider::new(); + fixture + .state + .service() + .inject_provider_host(Arc::new(first_provider.clone())); + let first_run = admit_state_run(&http, &fixture.token, &session_id, &first_provider).await; + wait_run_terminal(&fixture.state, &first_run).await; + let first_binding = store + .binding(&first_run) + .expect("first binding") + .expect("first binding"); + assert_eq!(first_binding.binding.schema, 1); + assert_eq!(first_binding.binding.revision, 2); + assert_eq!(first_binding.payload.as_str(), r#"{"count":1}"#); + let key = PluginStateRootKey { + owner_kind: "session".into(), + owner_id: session_id.clone(), + workspace_id: first_binding.binding.workspace_id.clone(), + branch_id: first_binding.binding.branch_id.clone(), + plugin_id: "org.rustscript.test.staterun".into(), + }; + let root_before = store.root(&key).expect("root").expect("root"); + assert_eq!(root_before.schema, 1); + assert_eq!(root_before.revision, 2); + + fixture + .state + .service() + .inject_managed_effect_fault(rustscript_agent::effects::ManagedEffectFault::CommitAfterHead) + .unwrap(); + let guard_provider = rustscript_agent::ScriptedProvider::new(); + fixture + .state + .service() + .inject_provider_host(Arc::new(guard_provider.clone())); + let guard_run = admit_state_run(&http, &fixture.token, &session_id, &guard_provider).await; + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + let run = persistence.run_get(&guard_run).unwrap_or_default(); + let status = run["rows"][0][3].as_str().unwrap_or(""); + if status == "completed" || status == "failed" { + break; + } + assert!( + std::time::Instant::now() < deadline, + "guarded commit run did not finish" + ); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + + let reopened = PluginStateStore::open(Arc::clone(&persistence)); + let guard_binding = reopened + .binding(&guard_run) + .expect("guard binding") + .expect("guard binding"); + assert_eq!(guard_binding.binding.schema, 1); + assert_eq!(guard_binding.binding.revision, root_before.revision); + assert_eq!(guard_binding.payload.as_str(), r#"{"count":1}"#); + let root_after = reopened.root(&key).expect("root").expect("root"); + assert_eq!(root_after.schema, 1); + assert_eq!(root_after.revision, root_before.revision); + assert_eq!(root_after.object_id, root_before.object_id); + + let record = ["local-agent", "gateway", "default"] + .iter() + .find_map(|profile| { + persistence + .managed_effect_get(&rustscript_agent::effects::ManagedEffectGetRequest { + owner: rustscript_agent::effects::ManagedOwnerScope { + profile: (*profile).into(), + session_id: session_id.clone(), + run_id: guard_run.clone(), + }, + call_id: "state-call".into(), + }) + .ok() + .flatten() + }) + .expect("guarded managed receipt"); + assert!( + record.prepared.is_some(), + "prepared state object survives rollback" + ); + assert!( + record.outcome.is_none(), + "guard failure must not finalize the receipt" + ); + let head = persistence + .managed_workspace_head(&record.identity.workspace) + .expect("branch head"); + assert_eq!(head.head_object_id, record.identity.expected_head); + assert_eq!(head.head_version, record.identity.expected_head_version); + + let retry_provider = rustscript_agent::ScriptedProvider::new(); + fixture + .state + .service() + .inject_provider_host(Arc::new(retry_provider.clone())); + let retry_run = admit_state_run(&http, &fixture.token, &session_id, &retry_provider).await; + wait_run_terminal(&fixture.state, &retry_run).await; + let retry_binding = reopened + .binding(&retry_run) + .expect("retry binding") + .expect("retry binding"); + assert_eq!(retry_binding.binding.schema, 1); + assert_eq!(retry_binding.binding.revision, root_before.revision + 1); + assert_eq!(retry_binding.payload.as_str(), r#"{"count":2}"#); + let root_retry = reopened.root(&key).expect("root").expect("root"); + assert_eq!(root_retry.revision, root_before.revision + 1); + assert_eq!(root_retry.object_id, retry_binding.binding.object_id); +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn admission_failure_after_prepare_keeps_root_and_binding_values() { + let fixture = open_managed_run_state_fixture("t12-admission-failure-boundary"); + let http = Http::bind(fixture.state.clone()).await; + run_state_plugin(&http, &fixture.token).await; + let (status, session) = http + .request( + "POST", + "/api/sessions", + json!({}), + Some(&fixture.token), + None, + ) + .await; + assert_eq!(status, 201, "{}", explain(status, &session)); + let session_id = session["session"]["id"].as_str().unwrap().to_string(); + let persistence = fixture.state.persistence().unwrap(); + let store = PluginStateStore::open(Arc::clone(&persistence)); + let provider = rustscript_agent::ScriptedProvider::new(); + fixture + .state + .service() + .inject_provider_host(Arc::new(provider.clone())); + let first_run = admit_state_run(&http, &fixture.token, &session_id, &provider).await; + wait_run_terminal(&fixture.state, &first_run).await; + let first_binding = store + .binding(&first_run) + .expect("first binding") + .expect("first binding"); + assert_eq!(first_binding.binding.schema, 1); + assert_eq!(first_binding.binding.revision, 2); + assert_eq!(first_binding.payload.as_str(), r#"{"count":1}"#); + let key = PluginStateRootKey { + owner_kind: "session".into(), + owner_id: session_id.clone(), + workspace_id: first_binding.binding.workspace_id.clone(), + branch_id: first_binding.binding.branch_id.clone(), + plugin_id: "org.rustscript.test.staterun".into(), + }; + let root_before = store.root(&key).expect("root").expect("root"); + assert_eq!(root_before.schema, 1); + assert_eq!(root_before.revision, 2); + let messages_before = fixture.state.service().session_messages(&session_id).len(); + + activate_state_package(&http, &fixture.token, "package-staterun2", "2.0.0").await; + fixture + .state + .persistence() + .unwrap() + .inject_admission_state_fail(); + let fail_provider = rustscript_agent::ScriptedProvider::new(); + fixture + .state + .service() + .inject_provider_host(Arc::new(fail_provider.clone())); + let (status, body) = admit_state_run_response( + &http, + &fixture.token, + &session_id, + None, + None, + &fail_provider, + ) + .await; + assert_eq!(status, 503, "{}", explain(status, &body)); + assert_eq!(body["error"]["code"], "persistence_unavailable"); + + let reopened = PluginStateStore::open(Arc::clone(&persistence)); + let root_after = reopened.root(&key).expect("root").expect("root"); + assert_eq!(root_after.schema, 1); + assert_eq!(root_after.revision, root_before.revision); + assert_eq!(root_after.object_id, root_before.object_id); + let binding_after = reopened + .binding(&first_run) + .expect("binding") + .expect("binding"); + assert_eq!(binding_after.binding.schema, 1); + assert_eq!(binding_after.binding.revision, 2); + assert_eq!(binding_after.payload.as_str(), r#"{"count":1}"#); + assert_eq!( + fixture.state.service().session_messages(&session_id).len(), + messages_before, + "a failed admission must not publish a user message or run" + ); + + let retry_provider = rustscript_agent::ScriptedProvider::new(); + fixture + .state + .service() + .inject_provider_host(Arc::new(retry_provider.clone())); + let retry_run = admit_state_run(&http, &fixture.token, &session_id, &retry_provider).await; + wait_run_terminal(&fixture.state, &retry_run).await; + let retry_binding = reopened + .binding(&retry_run) + .expect("retry binding") + .expect("retry binding"); + assert_eq!(retry_binding.binding.schema, 2); + assert_eq!(retry_binding.payload.as_str(), r#"{"count":2}"#); + let root_retry = reopened.root(&key).expect("root").expect("root"); + assert_eq!(root_retry.schema, 2); + assert_eq!(root_retry.object_id, retry_binding.binding.object_id); + assert_eq!(root_retry.revision, root_before.revision + 2); + assert_eq!( + reopened + .binding(&first_run) + .expect("old binding") + .expect("old binding") + .binding + .schema, + 1 + ); + + let (status, other_session) = http + .request( + "POST", + "/api/sessions", + json!({}), + Some(&fixture.token), + None, + ) + .await; + assert_eq!(status, 201, "{}", explain(status, &other_session)); + let other_session_id = other_session["session"]["id"].as_str().unwrap().to_string(); + let other_provider = rustscript_agent::ScriptedProvider::new(); + fixture + .state + .service() + .inject_provider_host(Arc::new(other_provider.clone())); + let other_run = + admit_state_run(&http, &fixture.token, &other_session_id, &other_provider).await; + wait_run_terminal(&fixture.state, &other_run).await; + let other_binding = reopened + .binding(&other_run) + .expect("other binding") + .expect("other binding"); + assert_eq!(other_binding.binding.schema, 2); + assert_eq!(other_binding.payload.as_str(), r#"{"count":1}"#); +} + +#[tokio::test(flavor = "multi_thread")] +async fn invocation_state_lives_only_for_its_call_and_never_becomes_durable() { + let fixture = open_state_fixture("t12-invocation-state", "invocation"); + let http = Http::bind(fixture.state.clone()).await; + run_state_plugin(&http, &fixture.token).await; + let (status, session) = http + .request( + "POST", + "/api/sessions", + json!({}), + Some(&fixture.token), + None, + ) + .await; + assert_eq!(status, 201, "{}", explain(status, &session)); + let session_id = session["session"]["id"].as_str().unwrap().to_string(); + let persistence = fixture.state.persistence().unwrap(); + let store = PluginStateStore::open(Arc::clone(&persistence)); + let mut published = Vec::new(); + for index in 0..2 { + let provider = rustscript_agent::ScriptedProvider::new(); + fixture + .state + .service() + .inject_provider_host(Arc::new(provider.clone())); + let run_id = admit_state_run(&http, &fixture.token, &session_id, &provider).await; + wait_run_terminal(&fixture.state, &run_id).await; + assert!( + store.binding(&run_id).expect("binding read").is_none(), + "invocation state must not create a durable run binding" + ); + assert!( + store + .checkpoint_roots(&run_id) + .expect("checkpoint roots") + .is_empty(), + "invocation state must not enter checkpoint roots" + ); + let (status, effects) = http + .request( + "GET", + &format!("/v1/runs/{run_id}/effects?limit=128"), + json!(null), + Some(&fixture.token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &effects)); + let entry = effects["entries"] + .as_array() + .and_then(|entries| entries.first()) + .cloned() + .expect("state call entry"); + let call_id = entry["call_id"].as_str().expect("call id").to_string(); + let workspace_id = entry["workspace_id"] + .as_str() + .expect("workspace") + .to_string(); + let branch_id = entry["branch_id"].as_str().expect("branch").to_string(); + let epoch = entry["epoch"].as_u64().expect("epoch"); + let record = ["local-agent", "gateway", "default"] + .iter() + .find_map(|profile| { + persistence + .managed_effect_get(&rustscript_agent::effects::ManagedEffectGetRequest { + owner: rustscript_agent::effects::ManagedOwnerScope { + profile: (*profile).into(), + session_id: session_id.clone(), + run_id: run_id.clone(), + }, + call_id: call_id.clone(), + }) + .ok() + .flatten() + }) + .expect("invocation state effect receipt"); + let publication = record + .prepared + .as_ref() + .expect("prepared receipt") + .plugin_state + .as_ref() + .expect("invocation state audit"); + assert!(publication.ephemeral); + assert_eq!(publication.owner_kind, "invocation"); + assert!( + publication.object_id.ends_with(":2"), + "each call starts from an empty invocation root: {}", + publication.object_id + ); + if index == 0 { + let (status, checkpoint) = http + .request( + "POST", + &format!("/v1/workspaces/{workspace_id}/checkpoints"), + json!({ + "source_run_id": run_id, + "workspace_id": workspace_id, + "source_branch_id": branch_id, + "expected_epoch": epoch, + "request_id": uuid::Uuid::new_v4().to_string(), + "mode": "at_closed_batch" + }), + Some(&fixture.token), + None, + ) + .await; + assert_eq!(status, 201, "{}", explain(status, &checkpoint)); + let coverage = &checkpoint["checkpoint"]["plugin_state"]; + assert_eq!(coverage["kind"], "stateful"); + assert_eq!(coverage["manifest_state_present"], true); + assert_eq!(coverage["invocation_excluded_from_checkpoint"], true); + assert!(coverage["roots"].as_array().unwrap().is_empty()); + assert_eq!( + checkpoint["checkpoint"]["public_config"]["config"]["policy_revision"], + "stateful_invocation_ephemeral" + ); + } + published.push(publication.object_id.clone()); + } + assert_ne!(published[0], published[1]); + assert_eq!( + store + .session_max_schema("org.rustscript.test.staterun") + .unwrap(), + 0 + ); +} + +/// 401-R2-A: the collector's protected set must be read at the actual typed +/// worker execution boundary. A caller that captured the set before a candidate +/// P was registered must not be able to delete P with that stale snapshot. +/// P stays prepared (admission parked after its durable put) while more than 64 +/// later failed candidates push it out of the recent-orphan window. +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn stale_collector_snapshot_cannot_delete_prepared_candidate() { + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + + let fixture = open_managed_run_state_fixture("t12-r2a-stale-snapshot"); + let http = Http::bind(fixture.state.clone()).await; + let _graph1 = run_state_plugin(&http, &fixture.token).await; + let session_id = create_session(&http, &fixture.token).await; + let other_session = create_session(&http, &fixture.token).await; + let persistence = fixture.state.persistence().unwrap(); + let store = PluginStateStore::open(Arc::clone(&persistence)); + + for session in [&session_id, &other_session] { + let provider = rustscript_agent::ScriptedProvider::new(); + script_state_turns(&provider); + fixture + .state + .service() + .inject_provider_host(Arc::new(provider.clone())); + let run = admit_state_run(&http, &fixture.token, session, &provider).await; + assert_eq!( + wait_run_any_terminal(&fixture.state, &run).await, + "completed" + ); + } + let (graph2, _) = + activate_state_package(&http, &fixture.token, "package-staterun2", "2.0.0").await; + + // C: park one real collector after it captured the caller-side ids and + // before it issues the typed command. + let collect_entered = Arc::new(AtomicBool::new(false)); + let collect_release = Arc::new(AtomicBool::new(false)); + let collect_calls = Arc::new(AtomicUsize::new(0)); + let entered = Arc::clone(&collect_entered); + let release = Arc::clone(&collect_release); + let calls = Arc::clone(&collect_calls); + persistence.inject_plugin_state_collect_observer(Arc::new(move || { + if calls.fetch_add(1, Ordering::SeqCst) == 0 { + entered.store(true, Ordering::SeqCst); + while !release.load(Ordering::SeqCst) { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + } + })); + let collector_store = store.clone(); + let collector = std::thread::spawn(move || { + let _ = collector_store.collect_orphans_result_for_test(); + }); + let entered_deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + while !collect_entered.load(Ordering::SeqCst) { + assert!( + std::time::Instant::now() < entered_deadline, + "collector did not reach the stale snapshot" + ); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + + // P: a real prepared migration candidate parked after its durable put, not + // the latest object after the later orphans are produced. + let (candidate_tx, candidate_rx) = std::sync::mpsc::channel::(); + let candidate_release = Arc::new(AtomicBool::new(false)); + let first_candidate = Arc::new(AtomicBool::new(true)); + let release_slot = Arc::clone(&candidate_release); + let first_slot = Arc::clone(&first_candidate); + fixture + .state + .service() + .observe_state_candidates(Arc::new(move |object_id: &str| { + if first_slot.swap(false, Ordering::SeqCst) { + candidate_tx + .send(object_id.to_string()) + .expect("candidate id"); + while !release_slot.load(Ordering::SeqCst) { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + } + })); + let p_provider = rustscript_agent::ScriptedProvider::new(); + script_state_turns(&p_provider); + fixture + .state + .service() + .inject_provider_host(Arc::new(p_provider.clone())); + let service = fixture.state.service().clone(); + let p_session = session_id.clone(); + let p_graph = graph2.clone(); + let p_task = tokio::spawn(async move { + service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!("r2a prepared candidate"), + session_id: Some(p_session), + graph_id: Some(p_graph), + platform: "test".into(), + ..AdmitRunRequest::default() + }, + None, + ) + .await + }); + let p_candidate = tokio::task::spawn_blocking(move || { + candidate_rx + .recv_timeout(std::time::Duration::from_secs(20)) + .expect("prepared candidate id") + }) + .await + .unwrap(); + assert!(store.object_payload(&p_candidate).is_ok()); + + // More than 64 later orphans, each through the real failed-migration path. + for attempt in 0..65 { + persistence.inject_admission_state_fail(); + let (status, body) = admit_run_with_identity( + &http, + &fixture.token, + &other_session, + "r2a later orphan", + Some(&graph2), + &format!("t12-r2a-orphan-{attempt}"), + ) + .await; + assert_eq!(status, 503, "{}", explain(status, &body)); + } + assert!(store.object_payload(&p_candidate).is_ok()); + + // Release the stale collector. The worker must use the current protected + // set, not C's pre-P snapshot. + collect_release.store(true, Ordering::SeqCst); + collector.join().expect("collector thread"); + let p_readable_after_stale_collect = store.object_payload(&p_candidate).is_ok(); + + candidate_release.store(true, Ordering::SeqCst); + let p_admitted = tokio::time::timeout(std::time::Duration::from_secs(30), p_task) + .await + .expect("P admission settles") + .expect("P admission join"); + let p_result_ok = p_admitted.is_ok(); + + // Decisive assertions after cleanup. + assert!( + p_readable_after_stale_collect, + "a stale collector snapshot deleted a live prepared candidate" + ); + let p_admitted = p_admitted.expect("P admission commits with its candidate intact"); + let root = store + .session_root_key(&session_id, "org.rustscript.test.staterun") + .expect("session key") + .expect("root"); + let root = store.root(&root).expect("root").expect("root"); + assert_eq!(root.schema, 2); + assert_eq!(root.object_id, p_candidate); + let binding = store + .binding(&p_admitted.run_id) + .expect("P binding") + .expect("P binding"); + assert_eq!(binding.binding.object_id, p_candidate); + assert!(p_result_ok); +} + +/// 401-R2-B (unknown put): a durable `plugin.state_object_put` whose reply is +/// lost must still settle through the candidate owner created before the put. +/// Repeating it more than 64 times must not accumulate durable orphans. +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn unknown_put_reply_is_bounded_by_the_pre_put_candidate_owner() { + let fixture = open_managed_run_state_fixture("t12-r2b-unknown-put"); + let http = Http::bind(fixture.state.clone()).await; + let _graph1 = run_state_plugin(&http, &fixture.token).await; + let session_id = create_session(&http, &fixture.token).await; + let persistence = fixture.state.persistence().unwrap(); + let store = PluginStateStore::open(Arc::clone(&persistence)); + + let baseline_provider = rustscript_agent::ScriptedProvider::new(); + script_state_turns(&baseline_provider); + fixture + .state + .service() + .inject_provider_host(Arc::new(baseline_provider.clone())); + let baseline_run = + admit_state_run(&http, &fixture.token, &session_id, &baseline_provider).await; + assert_eq!( + wait_run_any_terminal(&fixture.state, &baseline_run).await, + "completed" + ); + let key = store + .session_root_key(&session_id, "org.rustscript.test.staterun") + .expect("session key") + .expect("schema1 root"); + let root_before = store.root(&key).expect("root").expect("root"); + let (graph2, _) = + activate_state_package(&http, &fixture.token, "package-staterun2", "2.0.0").await; + + let reserved = Arc::new(std::sync::Mutex::new(Vec::new())); + let recorded = Arc::clone(&reserved); + persistence.observe_plugin_state_candidate_reservations(Arc::new(move |object_id: &str| { + recorded + .lock() + .expect("reserved candidates") + .push(object_id.to_string()); + })); + + for attempt in 0..70 { + persistence.inject_plugin_state_put_ack_loss(); + let (status, body) = admit_run_with_identity( + &http, + &fixture.token, + &session_id, + "r2b unknown put", + Some(&graph2), + &format!("t12-r2b-unknown-{attempt}"), + ) + .await; + assert_eq!(status, 422, "{}", explain(status, &body)); + assert_eq!(body["error"]["code"], "invalid_admission"); + } + let ids = reserved.lock().expect("reserved candidates").clone(); + assert_eq!(ids.len(), 70, "each attempt reserved one candidate owner"); + let existing = ids + .iter() + .filter(|object_id| store.object_payload(object_id).is_ok()) + .count(); + assert!( + existing <= 64, + "unknown puts accumulated {existing} durable candidates" + ); + assert_eq!( + store.candidate_registry_len_for_test(), + 0, + "every pre-put owner settled" + ); + assert!(store.object_payload(&root_before.object_id).is_ok()); + assert!( + store + .object_payload( + &store + .binding(&baseline_run) + .expect("baseline binding") + .expect("binding") + .binding + .object_id + ) + .is_ok() + ); + + let recovered_provider = rustscript_agent::ScriptedProvider::new(); + script_state_turns(&recovered_provider); + fixture + .state + .service() + .inject_provider_host(Arc::new(recovered_provider.clone())); + let (status, recovered) = admit_run_with_identity( + &http, + &fixture.token, + &session_id, + "r2b recovered", + Some(&graph2), + "t12-r2b-recovered", + ) + .await; + assert_eq!(status, 202, "{}", explain(status, &recovered)); + let recovered_run = recovered["run_id"].as_str().unwrap().to_string(); + assert_eq!( + wait_run_any_terminal(&fixture.state, &recovered_run).await, + "completed" + ); + assert_eq!(store.root(&key).expect("root").expect("root").schema, 2); +} + +/// 401-R2-B (collector error): a real collector SQL failure must not be +/// swallowed while candidates keep being created. Bounded debt refuses new +/// candidate writes until the same collector succeeds again. +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn collector_failure_backpressures_new_candidates_and_recovers() { + let fixture = open_managed_run_state_fixture("t12-r2b-collector-error"); + let http = Http::bind(fixture.state.clone()).await; + let _graph1 = run_state_plugin(&http, &fixture.token).await; + let session_id = create_session(&http, &fixture.token).await; + let persistence = fixture.state.persistence().unwrap(); + let store = PluginStateStore::open(Arc::clone(&persistence)); + + let baseline_provider = rustscript_agent::ScriptedProvider::new(); + script_state_turns(&baseline_provider); + fixture + .state + .service() + .inject_provider_host(Arc::new(baseline_provider.clone())); + let baseline_run = + admit_state_run(&http, &fixture.token, &session_id, &baseline_provider).await; + assert_eq!( + wait_run_any_terminal(&fixture.state, &baseline_run).await, + "completed" + ); + let key = store + .session_root_key(&session_id, "org.rustscript.test.staterun") + .expect("session key") + .expect("schema1 root"); + let (graph2, _) = + activate_state_package(&http, &fixture.token, "package-staterun2", "2.0.0").await; + let payload = PluginStatePayload::parse("{}").expect("empty state document"); + + // A real in-transaction SQL failure, surfaced by the typed collector. + persistence.inject_plugin_state_collect_failpoint(); + let first = store.collect_orphans_result_for_test(); + assert!(first.is_err(), "the real collector failure must surface"); + + let mut refused_ids = Vec::new(); + for _ in 0..70 { + persistence.inject_plugin_state_collect_failpoint(); + let (object_id, result) = store.candidate_put_for_test( + "org.rustscript.test.staterun", + &root_artifact(&store, &key), + 2, + &payload, + ); + assert!( + result.is_err(), + "a failed collector must refuse the next candidate write" + ); + refused_ids.push(object_id); + } + for object_id in &refused_ids { + assert!( + store.object_payload(object_id).is_err(), + "backpressured candidate must not be durable" + ); + } + + // Recovered collector re-opens candidate writes. + assert!(store.collect_orphans_result_for_test().is_ok()); + let (recovered_id, recovered) = store.candidate_put_for_test( + "org.rustscript.test.staterun", + &root_artifact(&store, &key), + 2, + &payload, + ); + assert!(recovered.is_ok(), "candidate write resumes after recovery"); + assert!(store.collect_orphans_result_for_test().is_ok()); + assert!( + store.object_payload(&recovered_id).is_ok(), + "a recovered candidate write is bounded by the existing 64-orphan rule" + ); + + let recovered_provider = rustscript_agent::ScriptedProvider::new(); + script_state_turns(&recovered_provider); + fixture + .state + .service() + .inject_provider_host(Arc::new(recovered_provider.clone())); + let (status, recovered) = admit_run_with_identity( + &http, + &fixture.token, + &session_id, + "r2b collector recovered", + Some(&graph2), + "t12-r2b-collector-recovered", + ) + .await; + assert_eq!(status, 202, "{}", explain(status, &recovered)); + let recovered_run = recovered["run_id"].as_str().unwrap().to_string(); + assert_eq!( + wait_run_any_terminal(&fixture.state, &recovered_run).await, + "completed" + ); + assert_eq!(store.root(&key).expect("root").expect("root").schema, 2); +} + +#[cfg(feature = "workspace-io-fault")] +fn root_artifact(store: &PluginStateStore, key: &PluginStateRootKey) -> String { + store + .root(key) + .expect("root") + .expect("root row") + .artifact_id +} + +// --------------------------------------------------------------------------- +// 406 baseline negative controls. These helpers only use the real typed +// producer, managed HTTP/RSS run, storage worker, and collector. No state +// algorithm or protection rule is changed. +// --------------------------------------------------------------------------- + +#[cfg(feature = "workspace-io-fault")] +struct ReleaseFlag(Arc); + +#[cfg(feature = "workspace-io-fault")] +impl Drop for ReleaseFlag { + fn drop(&mut self) { + self.0.store(true, std::sync::atomic::Ordering::SeqCst); + } +} + +#[cfg(feature = "workspace-io-fault")] +struct R2ASetup { + fixture: Fixture, + http: Arc, + session_id: String, + other_session: String, + q_session: String, + graph2: String, + #[allow(dead_code)] + schema2_run: String, + persistence: Arc, + store: PluginStateStore, +} + +#[cfg(feature = "workspace-io-fault")] +async fn r2a_setup(label: &str) -> R2ASetup { + let fixture = open_managed_run_state_fixture(label); + let http = Arc::new(Http::bind(fixture.state.clone()).await); + let _graph1 = run_state_plugin(&http, &fixture.token).await; + let session_id = create_session(&http, &fixture.token).await; + let other_session = create_session(&http, &fixture.token).await; + let q_session = create_session(&http, &fixture.token).await; + for sid in [&session_id, &other_session, &q_session] { + let provider = rustscript_agent::ScriptedProvider::new(); + script_state_turns(&provider); + fixture + .state + .service() + .inject_provider_host(Arc::new(provider.clone())); + let run = admit_state_run(&http, &fixture.token, sid, &provider).await; + assert_eq!( + wait_run_any_terminal(&fixture.state, &run).await, + "completed", + "baseline schema1 run" + ); + } + let (graph2, _) = + activate_state_package(&http, &fixture.token, "package-staterun2", "2.0.0").await; + // Move the controlled session to schema 2 before the target run so its + // admission has no migration candidate of its own. + let provider = rustscript_agent::ScriptedProvider::new(); + script_state_turns(&provider); + fixture + .state + .service() + .inject_provider_host(Arc::new(provider.clone())); + let schema2_run = admit_state_run(&http, &fixture.token, &session_id, &provider).await; + assert_eq!( + wait_run_any_terminal(&fixture.state, &schema2_run).await, + "completed", + "schema2 baseline run" + ); + let persistence = fixture.state.persistence().unwrap(); + let store = PluginStateStore::open(Arc::clone(&persistence)); + R2ASetup { + fixture, + http, + session_id, + other_session, + q_session, + graph2, + schema2_run, + persistence, + store, + } +} + +#[cfg(feature = "workspace-io-fault")] +async fn r2a_checkpoint_object(env: &R2ASetup, run_id: &str) -> (String, String) { + let (status, effects) = env + .http + .request( + "GET", + &format!("/v1/runs/{run_id}/effects?limit=128"), + json!(null), + Some(&env.fixture.token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &effects)); + let entry = effects["entries"] + .as_array() + .and_then(|entries| entries.first()) + .expect("managed state effect") + .clone(); + let workspace_id = entry["workspace_id"].as_str().unwrap().to_string(); + let branch_id = entry["branch_id"].as_str().unwrap().to_string(); + let epoch = entry["epoch"].as_u64().unwrap(); + let (status, checkpoint) = env + .http + .request( + "POST", + &format!("/v1/workspaces/{workspace_id}/checkpoints"), + json!({ + "source_run_id": run_id, + "workspace_id": workspace_id, + "source_branch_id": branch_id, + "expected_epoch": epoch, + "request_id": uuid::Uuid::new_v4().to_string(), + "mode": "at_closed_batch" + }), + Some(&env.fixture.token), + None, + ) + .await; + assert_eq!(status, 201, "{}", explain(status, &checkpoint)); + let checkpoint_id = checkpoint["checkpoint"]["checkpoint_id"] + .as_str() + .unwrap() + .to_string(); + let roots = checkpoint["checkpoint"]["plugin_state"]["roots"] + .as_array() + .expect("stateful roots"); + let object_id = roots + .iter() + .find(|root| root["plugin_id"] == "org.rustscript.test.staterun") + .and_then(|root| root["immutable_root_id"].as_str()) + .expect("state root object") + .to_string(); + (checkpoint_id, object_id) +} + +#[cfg(feature = "workspace-io-fault")] +async fn r2a_reference_objects(env: &R2ASetup) -> (String, String, String) { + let key = env + .store + .session_root_key(&env.session_id, "org.rustscript.test.staterun") + .expect("session key") + .expect("session root"); + let root = env.store.root(&key).expect("root").expect("root row"); + let package_id = root.package_id.clone(); + let artifact_id = root.artifact_id.clone(); + + // Root-only reference: a second branch root with no run binding. + let payload = PluginStatePayload::parse(r#"{"ref":"root-only"}"#).unwrap(); + let root_only = env + .store + .put_object(&package_id, &artifact_id, 2, &payload) + .expect("root-only object"); + let mut root_only_key = key.clone(); + root_only_key.branch_id = "r2a-root-only-branch".into(); + env.store + .cas_root(&root_only_key, 0, &root_only) + .expect("root-only cas"); + + // Binding-only reference: run-a keeps revision 2 while the shared session + // root advances through run-b. + seed_session(&env.persistence, "r2a-bind-session", 7_000); + seed_run(&env.persistence, "r2a-bind-session", "r2a-run-a", 7_001); + seed_run(&env.persistence, "r2a-bind-session", "r2a-run-b", 7_002); + let request_a = bind_request( + "r2a-run-a", + "r2a-bind-session", + "org.rustscript.test.staterun", + PluginStateScope::Session, + 1, + ); + let snapshot_a = env.store.bind_run(&request_a).expect("bind run-a"); + env.store + .commit( + &snapshot_a, + &PluginStatePayload::parse(r#"{"ref":"a1"}"#).unwrap(), + ) + .expect("commit run-a"); + let request_b = bind_request( + "r2a-run-b", + "r2a-bind-session", + "org.rustscript.test.staterun", + PluginStateScope::Session, + 1, + ); + let snapshot_b = env.store.bind_run(&request_b).expect("bind run-b"); + env.store + .commit( + &snapshot_b, + &PluginStatePayload::parse(r#"{"ref":"b1"}"#).unwrap(), + ) + .expect("commit run-b"); + let binding_only = env + .store + .binding("r2a-run-a") + .expect("run-a binding read") + .expect("run-a binding") + .binding + .object_id; + + // Checkpoint-only reference: a fresh single-run session captures a real + // checkpoint root, then the same run binding advances so the checkpoint + // object is referenced by the checkpoint receipt only. + let checkpoint_session = create_session(&env.http, &env.fixture.token).await; + let checkpoint_provider = rustscript_agent::ScriptedProvider::new(); + script_state_turns(&checkpoint_provider); + env.fixture + .state + .service() + .inject_provider_host(Arc::new(checkpoint_provider.clone())); + let checkpoint_run = admit_state_run( + &env.http, + &env.fixture.token, + &checkpoint_session, + &checkpoint_provider, + ) + .await; + assert_eq!( + wait_run_any_terminal(&env.fixture.state, &checkpoint_run).await, + "completed", + "checkpoint source run" + ); + let (_checkpoint_id, checkpoint_object) = r2a_checkpoint_object(env, &checkpoint_run).await; + let checkpoint_snapshot = env + .store + .binding(&checkpoint_run) + .expect("checkpoint binding read") + .expect("checkpoint binding"); + let advanced = env + .store + .commit( + &checkpoint_snapshot, + &PluginStatePayload::parse(r#"{"after":"checkpoint"}"#).unwrap(), + ) + .expect("advance past checkpoint"); + assert_ne!(advanced.binding.object_id, checkpoint_object); + (root_only.object_id, binding_only, checkpoint_object) +} + +#[cfg(feature = "workspace-io-fault")] +async fn r2a_orphans(env: &R2ASetup, label: &str) { + for attempt in 0..65 { + env.persistence.inject_admission_state_fail(); + let (status, body) = admit_run_with_identity( + &env.http, + &env.fixture.token, + &env.other_session, + "r2a later orphan", + Some(&env.graph2), + &format!("{label}-orphan-{attempt}"), + ) + .await; + assert_eq!(status, 503, "{}", explain(status, &body)); + } +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn r2a_guest_staged_state_object_survives_collector() { + use std::sync::atomic::{AtomicBool, Ordering}; + + let env = r2a_setup("r2a-staged").await; + let (root_only, binding_only, checkpoint_only) = r2a_reference_objects(&env).await; + + let (put_tx, put_rx) = std::sync::mpsc::channel::(); + let release = Arc::new(AtomicBool::new(false)); + let release_slot = Arc::clone(&release); + let first = Arc::new(AtomicBool::new(true)); + let first_slot = Arc::clone(&first); + env.persistence + .inject_plugin_state_object_put_observer(Arc::new(move |object_id: &str| { + if first_slot.swap(false, Ordering::SeqCst) { + put_tx + .send(object_id.to_string()) + .expect("guest staged object id"); + while !release_slot.load(Ordering::SeqCst) { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + } + })); + let _release_guard = ReleaseFlag(Arc::clone(&release)); + + let target_provider = rustscript_agent::ScriptedProvider::new(); + script_state_turns(&target_provider); + env.fixture + .state + .service() + .inject_provider_host(Arc::new(target_provider.clone())); + let target_run = admit_state_run( + &env.http, + &env.fixture.token, + &env.session_id, + &target_provider, + ) + .await; + let p_object = tokio::task::spawn_blocking(move || { + put_rx + .recv_timeout(std::time::Duration::from_secs(30)) + .expect("guest state::write reaches staged put") + }) + .await + .unwrap(); + assert!( + env.store.object_payload(&p_object).is_ok(), + "staged guest object must be durable before the pause" + ); + + r2a_orphans(&env, "r2a-staged").await; + env.store + .collect_orphans_result_for_test() + .expect("generic collector after 65 newer orphans"); + + let p_staged_readable = env.store.object_payload(&p_object).is_ok(); + release.store(true, Ordering::SeqCst); + drop(_release_guard); + let status = wait_any_status(&env.fixture.state, &target_run).await; + let key = env + .store + .session_root_key(&env.session_id, "org.rustscript.test.staterun") + .expect("session key") + .expect("session root"); + let root = env.store.root(&key).expect("root").expect("root row"); + let committed = root.object_id == p_object; + eprintln!( + "r2a staged observed: p_readable={p_staged_readable} run={status} root_is_p={committed} root_object={}", + root.object_id + ); + + assert!( + env.store.object_payload(&root_only).is_ok(), + "404-R2-A reference: root-only object was deleted" + ); + assert!( + env.store.object_payload(&binding_only).is_ok(), + "404-R2-A reference: binding-only object was deleted" + ); + assert!( + env.store.object_payload(&checkpoint_only).is_ok(), + "404-R2-A reference: checkpoint-only object was deleted" + ); + assert!( + p_staged_readable, + "404-R2-A staged: generic collector deleted the staged guest state object {p_object}" + ); + assert_eq!(status, "completed", "staged guest run must commit"); + assert!(committed, "staged guest object must commit"); +} + +#[cfg(feature = "workspace-io-fault")] +async fn wait_flag(flag: &std::sync::atomic::AtomicBool, label: &str) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(45); + while !flag.load(std::sync::atomic::Ordering::SeqCst) { + assert!( + std::time::Instant::now() < deadline, + "timed out waiting for {label}" + ); + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } +} + +#[cfg(feature = "workspace-io-fault")] +async fn wait_any_status(state: &AgentGatewayState, run_id: &str) -> String { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(45); + loop { + let persistence = state.persistence().unwrap(); + let run = persistence.run_get(run_id).unwrap_or_default(); + let status = run["rows"][0][3].as_str().unwrap_or("").to_string(); + if matches!(status.as_str(), "completed" | "failed" | "cancelled") { + return status; + } + assert!( + std::time::Instant::now() < deadline, + "run {run_id} did not reach terminal status, last={status}" + ); + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn r2a_guest_prepared_state_object_survives_collector() { + use std::sync::atomic::{AtomicBool, Ordering}; + + let env = r2a_setup("r2a-prepared").await; + let prepare_gate = env + .fixture + .state + .service() + .arm_managed_prepare_gate() + .expect("arm prepare gate"); + + let (put_tx, put_rx) = std::sync::mpsc::channel::(); + let release_put = Arc::new(AtomicBool::new(false)); + let release_slot = Arc::clone(&release_put); + let first = Arc::new(AtomicBool::new(true)); + let first_slot = Arc::clone(&first); + env.persistence + .inject_plugin_state_object_put_observer(Arc::new(move |object_id: &str| { + if first_slot.swap(false, Ordering::SeqCst) { + put_tx + .send(object_id.to_string()) + .expect("prepared staged object id"); + while !release_slot.load(Ordering::SeqCst) { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + } + })); + let _release_guard = ReleaseFlag(Arc::clone(&release_put)); + + let target_provider = rustscript_agent::ScriptedProvider::new(); + script_state_turns(&target_provider); + env.fixture + .state + .service() + .inject_provider_host(Arc::new(target_provider.clone())); + let target_run = admit_state_run( + &env.http, + &env.fixture.token, + &env.session_id, + &target_provider, + ) + .await; + let p_object = tokio::task::spawn_blocking(move || { + put_rx + .recv_timeout(std::time::Duration::from_secs(30)) + .expect("guest state::write reaches staged put") + }) + .await + .unwrap(); + + // Newer typed objects age P past the recent-64 set while its real + // effect.prepare is about to run. + let key = env + .store + .session_root_key(&env.q_session, "org.rustscript.test.staterun") + .expect("q session key") + .expect("q session root"); + let q_root = env.store.root(&key).expect("q root").expect("q root row"); + let mut orphan_ids = Vec::new(); + for attempt in 0..65 { + orphan_ids.push( + env.store + .put_object( + &q_root.package_id, + &q_root.artifact_id, + q_root.schema, + &PluginStatePayload::parse(&format!(r#"{{"orphan":{attempt}}}"#)).unwrap(), + ) + .expect("newer typed orphan object") + .object_id, + ); + } + release_put.store(true, Ordering::SeqCst); + drop(_release_guard); + prepare_gate + .wait_arrived(1) + .expect("real effect.prepare reaches the gate"); + + env.store + .collect_orphans_result_for_test() + .expect("generic collector after 65 newer orphans"); + let p_prepared_readable = env.store.object_payload(&p_object).is_ok(); + // The prepared receipt is durable, so its object ref is part of the typed + // collector reference set: a NULL-filtered stateless receipt must not + // stall the collector, and exactly the oldest true orphan is reclaimed + // while the prepared object survives. + let readable_orphans = orphan_ids + .iter() + .filter(|object_id| env.store.object_payload(object_id).is_ok()) + .count(); + eprintln!("r2a prepared orphans readable={readable_orphans}"); + prepare_gate.release(); + let status = wait_any_status(&env.fixture.state, &target_run).await; + let session_key = env + .store + .session_root_key(&env.session_id, "org.rustscript.test.staterun") + .expect("session key") + .expect("session root"); + let root = env + .store + .root(&session_key) + .expect("root") + .expect("root row"); + let committed = root.object_id == p_object; + eprintln!( + "r2a prepared observed: p_readable={p_prepared_readable} run={status} root_is_p={committed} root_object={}", + root.object_id + ); + assert!( + p_prepared_readable, + "404-R2-A prepared: generic collector deleted the prepared guest state object {p_object}" + ); + assert_eq!( + readable_orphans, 64, + "stateless prepared receipts must not stall collection of true orphans" + ); + assert_eq!(status, "completed", "prepared guest run must commit"); + assert!(committed, "prepared guest object must commit"); +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn r2b_queued_admission_owner_survives_caller_timeout() { + use std::sync::atomic::{AtomicBool, Ordering}; + + let env = r2a_setup("r2b-queued").await; + let candidate_id = Arc::new(std::sync::Mutex::new(None::)); + let candidate_slot = Arc::clone(&candidate_id); + env.fixture + .state + .service() + .observe_state_candidates(Arc::new(move |object_id: &str| { + *candidate_slot.lock().expect("candidate slot") = Some(object_id.to_string()); + })); + + let q_run_id = Arc::new(std::sync::Mutex::new(None::)); + let precommit_entered = Arc::new(AtomicBool::new(false)); + let precommit_release = Arc::new(AtomicBool::new(false)); + let entered_slot = Arc::clone(&precommit_entered); + let release_slot = Arc::clone(&precommit_release); + let run_slot = Arc::clone(&q_run_id); + env.persistence + .inject_admission_precommit_hook(move |payload: &serde_json::Value| { + *run_slot.lock().expect("queued run id") = + Some(payload["run_id"].as_str().unwrap_or_default().to_string()); + entered_slot.store(true, Ordering::SeqCst); + while !release_slot.load(Ordering::SeqCst) { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + }); + + let http = Arc::clone(&env.http); + let token = env.fixture.token.clone(); + let session = env.q_session.clone(); + let graph = env.graph2.clone(); + env.persistence + .inject_control_reply_timeout_for_test(std::time::Duration::from_secs(2)); + let q_task = tokio::spawn(async move { + admit_run_with_identity( + &http, + &token, + &session, + "r2b queued admission", + Some(&graph), + "r2b-queued-q", + ) + .await + }); + wait_flag(&precommit_entered, "queued admission precommit").await; + let p_object = candidate_id + .lock() + .expect("candidate id") + .clone() + .expect("prepared migration candidate"); + assert!( + env.store.object_payload(&p_object).is_ok(), + "queued admission P must be durable before the collector" + ); + + // 65 newer unreferenced objects through the real typed producer. The + // paused admission holds the global commit gate, so the ageing objects use + // the same plugin.state_object_put worker command instead of admissions. + let key = env + .store + .session_root_key(&env.q_session, "org.rustscript.test.staterun") + .expect("q session key") + .expect("q session root"); + let q_root = env.store.root(&key).expect("q root").expect("q root row"); + for attempt in 0..65 { + env.store + .put_object( + &q_root.package_id, + &q_root.artifact_id, + q_root.schema, + &PluginStatePayload::parse(&format!(r#"{{"orphan":{attempt}}}"#)).unwrap(), + ) + .expect("newer typed orphan object"); + } + + let collect_entered = Arc::new(AtomicBool::new(false)); + let gate_release = Arc::new(AtomicBool::new(false)); + let entered_slot = Arc::clone(&collect_entered); + let release_slot = Arc::clone(&gate_release); + env.persistence + .inject_worker_execute_gate(Arc::new(move |op: &str| { + if op == "plugin.state_collect" { + entered_slot.store(true, Ordering::SeqCst); + while !release_slot.load(Ordering::SeqCst) { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + } + })); + let _release_guard = ReleaseFlag(Arc::clone(&gate_release)); + let collector_store = env.store.clone(); + let collector = std::thread::spawn(move || collector_store.collect_orphans_result_for_test()); + wait_flag(&collect_entered, "queued collector at worker boundary").await; + + // Q's admission.create is enqueued strictly after C in the same worker. + precommit_release.store(true, Ordering::SeqCst); + let (q_status, q_body) = tokio::time::timeout(std::time::Duration::from_secs(90), q_task) + .await + .expect("queued admission caller settles") + .expect("queued admission join"); + assert_ne!( + q_status, + 202, + "queued admission must be unknown after the real storage timeout: {}", + explain(q_status, &q_body) + ); + let q_run = q_run_id + .lock() + .expect("queued run id") + .clone() + .expect("run id captured before admission.create"); + // The caller released only its own reference on the unknown result. The + // still-queued admission.create request keeps one worker-held reservation + // sidecar until the worker settles it, so the queued object stays + // protected while the earlier collector runs. + let owner_deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + let held = env.store.candidate_registry_len_for_test(); + if held == 1 { + break; + } + assert!( + std::time::Instant::now() < owner_deadline, + "worker-held admission owner did not survive the caller timeout (held={held})" + ); + std::thread::sleep(std::time::Duration::from_millis(10)); + } + + gate_release.store(true, Ordering::SeqCst); + drop(_release_guard); + let collector_result = collector.join().expect("collector thread"); + // The collector caller itself timed out; the queued request still runs. + let _ = collector_result; + // run_get is enqueued after Q, so it returns only after the worker settled Q. + let run_row = env.persistence.run_get(&q_run).unwrap_or_default(); + let q_rows = run_row["rows"].as_array().cloned().unwrap_or_default(); + eprintln!( + "r2b queued observed: p_readable={} q_run_rows={} registry={} collector_rc={:?}", + env.store.object_payload(&p_object).is_ok(), + q_rows.len(), + env.store.candidate_registry_len_for_test(), + collector_result.as_ref().err().map(|error| error.code) + ); + + assert!( + env.store.object_payload(&p_object).is_ok(), + "404-R2-B queued: earlier collector deleted P required by still-queued admission" + ); + assert!( + !q_rows.is_empty(), + "404-R2-B queued: admission returned no durable formal result after worker resume" + ); + assert_eq!( + env.store.candidate_registry_len_for_test(), + 0, + "both owner references must be clear after the queued request settles" + ); +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn r2b_inflight_candidate_put_owner_covers_unknown_caller() { + use std::sync::atomic::{AtomicBool, Ordering}; + + let env = r2a_setup("r2b-inflight").await; + let reserved_id = Arc::new(std::sync::Mutex::new(None::)); + let reserved_slot = Arc::clone(&reserved_id); + env.persistence + .observe_plugin_state_candidate_reservations(Arc::new(move |object_id: &str| { + let mut slot = reserved_slot.lock().expect("reservation slot"); + if slot.is_none() { + *slot = Some(object_id.to_string()); + } + })); + + let put_entered = Arc::new(AtomicBool::new(false)); + let gate_release = Arc::new(AtomicBool::new(false)); + let entered_slot = Arc::clone(&put_entered); + let release_slot = Arc::clone(&gate_release); + let armed = Arc::new(AtomicBool::new(false)); + let armed_slot = Arc::clone(&armed); + env.persistence + .inject_worker_execute_gate(Arc::new(move |op: &str| { + if op == "plugin.state_object_put" && !armed_slot.swap(true, Ordering::SeqCst) { + entered_slot.store(true, Ordering::SeqCst); + while !release_slot.load(Ordering::SeqCst) { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + } + })); + let _release_guard = ReleaseFlag(Arc::clone(&gate_release)); + + let http = Arc::clone(&env.http); + let token = env.fixture.token.clone(); + let session = env.q_session.clone(); + let graph = env.graph2.clone(); + env.persistence + .inject_control_reply_timeout_for_test(std::time::Duration::from_secs(2)); + let q_task = tokio::spawn(async move { + admit_run_with_identity( + &http, + &token, + &session, + "r2b inflight candidate put", + Some(&graph), + "r2b-inflight-q", + ) + .await + }); + wait_flag(&put_entered, "in-flight candidate put at worker boundary").await; + let p_object = reserved_id + .lock() + .expect("reservation id") + .clone() + .expect("candidate reservation before the put"); + // The caller is still blocked on the real storage response; the put has + // not run yet. + let (q_status, q_body) = tokio::time::timeout(std::time::Duration::from_secs(90), q_task) + .await + .expect("in-flight put caller settles") + .expect("in-flight put join"); + assert_ne!( + q_status, + 202, + "unknown in-flight put caller must surface the storage timeout: {}", + explain(q_status, &q_body) + ); + let held = env.store.candidate_registry_len_for_test(); + + let collector_store = env.store.clone(); + let collector = std::thread::spawn(move || collector_store.collect_orphans_result_for_test()); + gate_release.store(true, Ordering::SeqCst); + drop(_release_guard); + let collector_result = collector.join().expect("collector thread"); + eprintln!( + "r2b inflight observed: held_at_timeout={} p_readable_after_settle={} collector_rc={:?} registry_after={}", + held, + env.store.object_payload(&p_object).is_ok(), + collector_result.as_ref().err().map(|error| error.code), + env.store.candidate_registry_len_for_test() + ); + assert!( + held > 0, + "404-R2-B inflight: caller timeout removed the only owner while plugin.state_object_put is still executing" + ); + assert!( + collector_result.is_ok(), + "collector queued behind the put must settle with the real worker result" + ); + assert!( + env.store.object_payload(&p_object).is_ok(), + "the worker still executes a real put after its caller became unknown" + ); + assert_eq!( + env.store.candidate_registry_len_for_test(), + 0, + "owner registry must clear after the request settles" + ); +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn r2b_collector_health_is_not_cleared_by_delayed_success() { + use std::sync::atomic::{AtomicBool, Ordering}; + + let fixture = open_fixture("r2b-health-order"); + let persistence = fixture.state.persistence().expect("native persistence"); + let store = PluginStateStore::open(Arc::clone(&persistence)); + + let success_entered = Arc::new(AtomicBool::new(false)); + let release_success = Arc::new(AtomicBool::new(false)); + let entered_slot = Arc::clone(&success_entered); + let release_slot = Arc::clone(&release_success); + let first = Arc::new(AtomicBool::new(true)); + let first_slot = Arc::clone(&first); + persistence.inject_plugin_state_collect_result_observer(Arc::new(move |ok: bool| { + if ok && first_slot.swap(false, Ordering::SeqCst) { + entered_slot.store(true, Ordering::SeqCst); + while !release_slot.load(Ordering::SeqCst) { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + } + })); + + let old_store = store.clone(); + let old_success = std::thread::spawn(move || old_store.collect_orphans_result_for_test()); + wait_flag(&success_entered, "older successful collector reply").await; + + // A newer collector fails with a real in-transaction SQL guard fault. + persistence.inject_plugin_state_collect_failpoint(); + let newer = store.collect_orphans_result_for_test(); + assert!( + newer.is_err(), + "the real SQL guard fault must surface from the collector" + ); + assert!( + store.collection_debt_for_test(), + "the newer failure must record collection debt" + ); + + release_success.store(true, Ordering::SeqCst); + old_success + .join() + .expect("old collector thread") + .expect("older collector itself succeeded"); + assert!( + store.collection_debt_for_test(), + "404-R2-B health: delayed older success cleared the newer collector failure debt" + ); +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn r2b_guest_plain_puts_ignore_collector_debt_and_exceed_bound() { + let fixture = open_managed_run_state_fixture("r2b-guest-debt"); + let http = Http::bind(fixture.state.clone()).await; + let _graph = run_state_plugin(&http, &fixture.token).await; + let session_id = create_session(&http, &fixture.token).await; + let baseline_provider = rustscript_agent::ScriptedProvider::new(); + script_state_turns(&baseline_provider); + fixture + .state + .service() + .inject_provider_host(Arc::new(baseline_provider.clone())); + let baseline_run = + admit_state_run(&http, &fixture.token, &session_id, &baseline_provider).await; + assert_eq!( + wait_run_any_terminal(&fixture.state, &baseline_run).await, + "completed" + ); + let persistence = fixture.state.persistence().unwrap(); + let store = PluginStateStore::open(Arc::clone(&persistence)); + let baseline_root = store + .root( + &store + .session_root_key(&session_id, "org.rustscript.test.staterun") + .expect("session key") + .expect("schema1 session root"), + ) + .expect("root") + .expect("root row") + .object_id; + eprintln!("r2b guest debt diag: baseline_root={baseline_root}"); + let collect_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let calls_slot = Arc::clone(&collect_calls); + persistence.inject_plugin_state_collect_observer(Arc::new(move || { + calls_slot.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + })); + + let ids = Arc::new(std::sync::Mutex::new(Vec::::new())); + let ids_slot = Arc::clone(&ids); + persistence.inject_plugin_state_object_put_observer(Arc::new(move |object_id: &str| { + let mut ids = ids_slot.lock().expect("put ids"); + if ids.last().is_none_or(|last| last != object_id) { + ids.push(object_id.to_string()); + } + })); + + // Break the collector with the real SQL guard fault. + persistence.inject_plugin_state_collect_failpoint(); + assert!( + store.collect_orphans_result_for_test().is_err(), + "real collector guard fault must surface" + ); + assert!( + store.collection_debt_for_test(), + "failed collector must record debt" + ); + + // Formal guest producer: each run stages/prepares a plain state object. + // With the collector degraded the unified reservation boundary refuses the + // put before any durable write or managed prepare cut, so no one-shot + // fixture fault is armed here; the typed refusal is asserted from events. + // + // 406 armed AfterOperationBeforePrepare for each of 72 guest runs because a + // put used to become durable before preparation. The unified boundary now + // refuses the put earlier, so that one-shot cut was never consumed and + // leaked into the recovery run (real diagnosis: diag5-diag8). The refusal + // count is preserved as 72 by pairing 56 guest runs (below the managed + // effect retention bound of 64 receipts) with 16 direct typed producer + // attempts that assert the same refusal without consuming managed slots. + let session_key = store + .session_root_key(&session_id, "org.rustscript.test.staterun") + .expect("session key") + .expect("session root"); + let baseline_row = store.root(&session_key).expect("root").expect("root row"); + let mut statuses = std::collections::BTreeMap::new(); + for attempt in 0..56 { + let provider = rustscript_agent::ScriptedProvider::new(); + script_state_turns(&provider); + fixture + .state + .service() + .inject_provider_host(Arc::new(provider.clone())); + let run = admit_state_run(&http, &fixture.token, &session_id, &provider).await; + let status = wait_any_status(&fixture.state, &run).await; + let events = fixture.state.service().run_events(&run); + let failed = events + .iter() + .find(|event| event["event"] == "tool.failed") + .unwrap_or_else(|| { + panic!("debt-refused guest run {attempt} must fail the tool call: {events:?}") + }); + assert_eq!( + failed["data"]["error_code"], "plugin_state_unavailable", + "debt backpressure must surface as the typed state refusal: {events:?}" + ); + *statuses.entry(status.clone()).or_insert(0usize) += 1; + assert!( + matches!(status.as_str(), "completed" | "failed"), + "guest run {attempt} reached {status}" + ); + } + let direct_payload = PluginStatePayload::parse(r#"{"refused":"debt"}"#).unwrap(); + for attempt in 0..16 { + let (attempted_id, result) = store.candidate_put_for_test( + &baseline_row.package_id, + &baseline_row.artifact_id, + baseline_row.schema, + &direct_payload, + ); + let error = match result { + Ok(_) => panic!("debt-refused typed put {attempt} unexpectedly succeeded"), + Err(error) => error, + }; + assert_eq!( + error.code, "plugin_state_unavailable", + "typed producer {attempt} ({attempted_id}) must surface the debt refusal" + ); + } + + let ids_snapshot = ids.lock().expect("put ids").clone(); + let readable_ids: Vec = ids_snapshot + .iter() + .filter(|object_id| store.object_payload(object_id).is_ok()) + .cloned() + .collect(); + eprintln!( + "r2b guest debt observed: put_ids={} readable={} statuses={statuses:?} collect_calls={} debt={}", + ids_snapshot.len(), + readable_ids.len(), + collect_calls.load(std::sync::atomic::Ordering::SeqCst), + store.collection_debt_for_test() + ); + + // A further guest producer must be refused while collection debt exists. + // The correct typed backpressure now refuses at the state reservation + // boundary, before any managed operation/prepare cut is reached. Arming + // the old prepare-time fixture cut here would leave a one-shot fault that + // the refusal never consumes, poisoning the later recovery run; the real + // assertion is the precise debt refusal below. + let puts_before_next = ids.lock().expect("put ids").len(); + let next_provider = rustscript_agent::ScriptedProvider::new(); + script_state_turns(&next_provider); + fixture + .state + .service() + .inject_provider_host(Arc::new(next_provider.clone())); + let (admit_status, body) = admit_run_with_identity( + &http, + &fixture.token, + &session_id, + "r2b debt next", + None, + "r2b-debt-next", + ) + .await; + let mut next_accepted = admit_status == 202; + if admit_status == 202 { + let next_run = body["run_id"].as_str().unwrap_or_default().to_string(); + let _ = wait_any_status(&fixture.state, &next_run).await; + let events = fixture.state.service().run_events(&next_run); + let failed = events + .iter() + .find(|event| event["event"] == "tool.failed") + .unwrap_or_else(|| { + panic!("the debt-refused guest run must fail the tool call: {events:?}") + }); + assert_eq!( + failed["data"]["error_code"], "plugin_state_unavailable", + "debt backpressure must surface as the typed state refusal: {events:?}" + ); + assert_eq!( + ids.lock().expect("put ids").len(), + puts_before_next, + "a debt-refused guest put must not create a durable object" + ); + next_accepted = false; + } + eprintln!( + "r2b guest debt next: admit_status={admit_status} next_accepted={next_accepted} readable_bound={}", + readable_ids.len() + ); + + // Recovery: the real collector succeeds again (debt clears), then a formal + // guest effect/admission must commit and advance the root. + let recovered_collect = store.collect_orphans_result_for_test().is_ok(); + let recovered_debt = store.collection_debt_for_test(); + let recovered_provider = rustscript_agent::ScriptedProvider::new(); + script_state_turns(&recovered_provider); + fixture + .state + .service() + .inject_provider_host(Arc::new(recovered_provider.clone())); + let recovered_run = + admit_state_run(&http, &fixture.token, &session_id, &recovered_provider).await; + let recovered_status = wait_any_status(&fixture.state, &recovered_run).await; + let session_key = store + .session_root_key(&session_id, "org.rustscript.test.staterun") + .expect("session key") + .expect("session root"); + let recovered_root = store + .root(&session_key) + .expect("root") + .expect("root row") + .object_id; + eprintln!( + "r2b guest debt recovery: collect_ok={recovered_collect} debt={recovered_debt} run={recovered_status} root_after={recovered_root}" + ); + let recovered_root_observed = ids + .lock() + .expect("put ids") + .iter() + .any(|object_id| object_id == &recovered_root); + let observed_snapshot = ids.lock().expect("put ids").clone(); + let observed_payload = observed_snapshot + .first() + .and_then(|object_id| store.object_payload(object_id).ok()) + .map(|payload| payload.as_str().to_string()) + .unwrap_or_default(); + let root_payload = store + .object_payload(&recovered_root) + .ok() + .map(|payload| payload.as_str().to_string()) + .unwrap_or_default(); + let recovered_binding = store + .binding(&recovered_run) + .ok() + .flatten() + .map(|snapshot| { + format!( + "rev={} obj={} schema={}", + snapshot.binding.revision, snapshot.binding.object_id, snapshot.binding.schema + ) + }) + .unwrap_or_default(); + eprintln!( + "r2b guest debt diag: observed_puts={} root_after={recovered_root} observed={observed_snapshot:?} observed_payload={observed_payload} root_payload={root_payload} binding={recovered_binding}", + observed_snapshot.len(), + ); + assert!( + recovered_collect, + "the same collector must recover after its real SQL fault clears" + ); + assert!( + !recovered_debt, + "a successful sweep must clear collection debt" + ); + assert_eq!( + recovered_status, "completed", + "formal guest effect/admission must succeed after collector recovery" + ); + assert!( + recovered_root_observed, + "the recovered guest commit must advance the session root to a new object" + ); + + assert!( + readable_ids.len() <= 64, + "404-R2-B gc-health: formal guest plain puts left {} durable unreferenced objects while collector debt was set", + readable_ids.len() + ); + assert!( + !next_accepted, + "404-R2-B gc-health: a new plain guest put was accepted without a proven bounded sweep" + ); +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn r2b_new_persistence_health_does_not_default_to_swept() { + let fixture = open_fixture("r2b-new-persistence"); + let persistence = fixture.state.persistence().expect("native persistence"); + let store = PluginStateStore::open(Arc::clone(&persistence)); + let payload = PluginStatePayload::parse(r#"{"orphan":"older"}"#).unwrap(); + for _ in 0..70 { + store + .put_object("org.rustscript.test.state", "artifact-state", 1, &payload) + .expect("older unreferenced object write"); + } + persistence.shutdown(); + drop(store); + drop(persistence); + + let reopened = Arc::new( + GatewayPersistence::open(fixture.state.service().config(), &fixture.db) + .expect("reopen persistence worker"), + ); + let store = PluginStateStore::open(Arc::clone(&reopened)); + let collect_calls = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let calls_slot = Arc::clone(&collect_calls); + reopened.inject_plugin_state_collect_observer(Arc::new(move || { + calls_slot.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + })); + // A fresh worker starts at NeedsSweep instead of defaulting to swept: the + // first related put must prove one bounded sweep. The armed failpoint + // makes that sweep fail, so the put is refused with a precise degraded + // error before any durable write. + let put_ids = Arc::new(std::sync::Mutex::new(Vec::::new())); + let put_slot = Arc::clone(&put_ids); + reopened.inject_plugin_state_object_put_observer(Arc::new(move |object_id: &str| { + put_slot + .lock() + .expect("put ids") + .push(object_id.to_string()); + })); + reopened.inject_plugin_state_collect_failpoint(); + let executed_before = reopened.executed_storage_commands(); + let refused = store + .put_object( + "org.rustscript.test.state", + "artifact-state", + 1, + &PluginStatePayload::parse(r#"{"orphan":"new"}"#).unwrap(), + ) + .expect_err("first put on a fresh worker must not bypass the proof sweep"); + assert_eq!( + refused.code, "plugin_state_unavailable", + "fresh-worker refusal must be the precise degraded error" + ); + assert!( + collect_calls.load(std::sync::atomic::Ordering::SeqCst) > 0, + "404-R2-B gc-health: a fresh worker treated the default false health as a completed bounded sweep and accepted a plain put without any collector attempt" + ); + assert_eq!( + reopened.executed_storage_commands(), + executed_before + 1, + "only the proof sweep may execute; the refused put writes nothing" + ); + assert!( + put_ids.lock().expect("put ids").is_empty(), + "a backpressured put must never become durable" + ); + // Recovery: the one-shot failpoint is consumed, so the same explicit + // collector succeeds, clears the health, and the next put proceeds. + assert!(store.collect_orphans_result_for_test().is_ok()); + store + .put_object( + "org.rustscript.test.state", + "artifact-state", + 1, + &PluginStatePayload::parse(r#"{"orphan":"new"}"#).unwrap(), + ) + .expect("plain producer write resumes after collector recovery"); + assert_eq!( + put_ids.lock().expect("put ids").len(), + 1, + "exactly the recovered put became durable" + ); +} + +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn r2c_injected_collect_payload_native_budget() { + let fixture = open_fixture("r2c-native-budget"); + let persistence = fixture.state.persistence().expect("native persistence"); + let store = PluginStateStore::open(Arc::clone(&persistence)); + let payload = PluginStatePayload::parse(r#"{"leaf":"kept"}"#).unwrap(); + let kept = store + .put_object("org.rustscript.test.state", "artifact-state", 1, &payload) + .expect("kept object"); + let _kept_lease = store.hold_candidate_reservation_for_test(&kept.object_id); + + let before = persistence.executed_storage_commands(); + store + .collect_orphans_result_for_test() + .expect("in-budget collector"); + assert_eq!( + persistence.executed_storage_commands(), + before + 1, + "an in-budget collector must execute the real SQL once" + ); + assert!(store.object_payload(&kept.object_id).is_ok()); + + // Fill only the real Native reservation registry. The ids are opaque + // reservation strings (no authority grant) and the collector payload is + // assembled by the same worker injection path. + let mut reservation_ids = Vec::new(); + let mut leases = Vec::new(); + for _ in 0..25_800 { + let id = uuid::Uuid::new_v4().to_string(); + reservation_ids.push(id.clone()); + leases.push(store.hold_candidate_reservation_for_test(&id)); + } + reservation_ids.push(kept.object_id.clone()); + reservation_ids.sort(); + let protected_json = serde_json::to_string(&reservation_ids).expect("reservation json"); + let payload_wire = serde_json::to_string(&json!({ + "failpoint": "", + "protected_ids_json": protected_json, + })) + .expect("worker payload json"); + eprintln!( + "r2c native budget input: reservations={} protected_json_bytes={} worker_payload_bytes={} metadata_limit_bytes={}", + reservation_ids.len(), + protected_json.len(), + payload_wire.len(), + 1024 * 1024 + ); + assert!( + protected_json.len() < 1024 * 1024, + "test input must stay below the RSS SQL parameter limit" + ); + assert!( + payload_wire.len() > 1024 * 1024, + "test input must exceed the Native transport budget after injection" + ); + let before = persistence.executed_storage_commands(); + let result = store.collect_orphans_result_for_test(); + let after = persistence.executed_storage_commands(); + eprintln!( + "r2c native budget result: {:?} executed_before={before} executed_after={after}", + result.as_ref().err().map(|error| error.code) + ); + assert!( + result.is_err(), + "404-R2-NATIVE-BUDGET: oversized injected collector payload was accepted" + ); + assert_eq!( + after, before, + "404-R2-NATIVE-BUDGET: SQL executed for a payload that exceeds the Native budget" + ); + assert!( + store.object_payload(&kept.object_id).is_ok(), + "protected P must remain readable after the typed budget rejection" + ); + drop(leases); +} + +// --------------------------------------------------------------------------- +// 411 evidence-only: 410-F1/F2 runtime counterexamples and 410-E2 exact bounds. +// These tests add no production behavior; they exercise the existing producer, +// collector, and trusted-switch paths with the existing fixture seams. +// --------------------------------------------------------------------------- + +/// 410-F1: a producer that passed the caller health check while Healthy is +/// enqueued behind a real failing collector. The worker must apply the current +/// health decision at its own put execution boundary; today it does not. +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn f411_queued_healthy_put_crosses_newer_collector_debt() { + use std::sync::Mutex as StdMutex; + use std::sync::atomic::{AtomicBool, Ordering}; + + let fixture = open_fixture("f411-f1-queue"); + let persistence = fixture.state.persistence().expect("native persistence"); + let store = PluginStateStore::open(Arc::clone(&persistence)); + + let ops = Arc::new(StdMutex::new(Vec::::new())); + let ops_slot = Arc::clone(&ops); + persistence.inject_worker_execute_gate(Arc::new(move |op: &str| { + ops_slot.lock().unwrap().push(op.to_string()); + })); + + // A fresh worker never defaults to swept: prove one real bounded sweep. + store + .collect_orphans_result_for_test() + .expect("baseline sweep"); + assert!( + !store.collection_debt_for_test(), + "baseline sweep must be Healthy" + ); + + let p_entered = Arc::new(AtomicBool::new(false)); + let p_release = Arc::new(AtomicBool::new(false)); + let debt_at_pause = Arc::new(AtomicBool::new(true)); + let entered_slot = Arc::clone(&p_entered); + let release_slot = Arc::clone(&p_release); + let debt_slot = Arc::clone(&debt_at_pause); + let armed = Arc::new(AtomicBool::new(false)); + let armed_slot = Arc::clone(&armed); + let health_probe = store.clone(); + persistence.observe_plugin_state_candidate_reservations(Arc::new(move |_object_id: &str| { + if !armed_slot.swap(true, Ordering::SeqCst) { + // The producer already passed ensure_object_capacity() before it + // registered this reservation; this pause is after that caller + // check and before the put request is enqueued. + debt_slot.store(health_probe.collection_debt_for_test(), Ordering::SeqCst); + entered_slot.store(true, Ordering::SeqCst); + while !release_slot.load(Ordering::SeqCst) { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + } + })); + let _release_guard = ReleaseFlag(Arc::clone(&p_release)); + + let p_store = store.clone(); + let p_thread = std::thread::spawn(move || { + let payload = PluginStatePayload::parse(r#"{"f411":"queued-put"}"#).unwrap(); + p_store.candidate_put_for_test("pkg.f411", "artifact.f411", 1, &payload) + }); + wait_flag(&p_entered, "producer paused after the caller health check").await; + assert!( + !debt_at_pause.load(Ordering::SeqCst), + "the caller must have observed Healthy before its request is queued" + ); + + // C is a real typed collector with the existing in-transaction SQL guard + // fault. It is enqueued and executed while P has not issued its put. + persistence.inject_plugin_state_collect_failpoint(); + let c_error = store + .collect_orphans_result_for_test() + .expect_err("the real SQL guard fault must surface from the collector"); + assert!( + store.collection_debt_for_test(), + "the worker must record the newer collector failure in execution order" + ); + + p_release.store(true, Ordering::SeqCst); + drop(_release_guard); + let (p_id, p_result) = p_thread.join().expect("producer thread"); + let sequence = ops.lock().unwrap().clone(); + let collect_positions: Vec = sequence + .iter() + .enumerate() + .filter(|(_, op)| op.as_str() == "plugin.state_collect") + .map(|(index, _)| index) + .collect(); + let put_positions: Vec = sequence + .iter() + .enumerate() + .filter(|(_, op)| op.as_str() == "plugin.state_object_put") + .map(|(index, _)| index) + .collect(); + let p_durable = store.object_payload(&p_id).is_ok(); + let p_code = p_result.as_ref().err().map(|error| error.code); + eprintln!( + "f411 F1 observed: sequence={sequence:?} collect_positions={collect_positions:?} put_positions={put_positions:?} c_code={} debt_after_c={} p_code={p_code:?} p_durable={p_durable}", + c_error.code, + store.collection_debt_for_test() + ); + + // Recovery and business success are recorded before the finding assertion + // so a RED run still carries the full control evidence. + let recovery = store.collect_orphans_result_for_test(); + let debt_after_recovery = store.collection_debt_for_test(); + let payload = PluginStatePayload::parse(r#"{"f411":"after-recovery"}"#).unwrap(); + let (next_id, next_result) = + store.candidate_put_for_test("pkg.f411", "artifact.f411", 1, &payload); + let next_durable = store.object_payload(&next_id).is_ok(); + eprintln!( + "f411 F1 recovery: recovery_ok={} debt_after_recovery={debt_after_recovery} next_put_ok={} next_durable={next_durable}", + recovery.is_ok(), + next_result.is_ok() + ); + + assert!( + put_positions.first().is_some_and(|put| { + collect_positions + .get(1) + .is_some_and(|collect| put > collect) + }), + "the worker must execute C then P in FIFO order: {sequence:?}" + ); + assert!( + collect_positions + .last() + .is_some_and(|collect| { put_positions.first().is_some_and(|put| collect > put) }), + "the refused put must run its own bounded quiet reclamation after the refusal: {sequence:?}" + ); + assert!( + recovery.is_ok() && !debt_after_recovery && next_result.is_ok() && next_durable, + "an explicit successful sweep must recover and allow the next business put" + ); + assert_eq!( + p_code, + Some("plugin_state_unavailable"), + "410-F1: the queued producer must be refused with the exact typed code" + ); + assert!( + !p_durable, + "410-F1 RED: a durable put executed although the worker had already recorded collector debt" + ); +} + +/// 410-F1 quiet: the backpressure-path collector captures the old epoch, a +/// newer explicit sweep succeeds, then the quiet collector fails for real. The +/// worker records no failure (`report_health=false`) and the caller epoch check +/// ignores it, so a real failed sweep leaves health Healthy. +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn f411_quiet_collector_failure_after_newer_success_is_ignored() { + use std::sync::Mutex as StdMutex; + use std::sync::atomic::{AtomicBool, Ordering}; + + let fixture = open_fixture("f411-f1-quiet"); + let persistence = fixture.state.persistence().expect("native persistence"); + let store = PluginStateStore::open(Arc::clone(&persistence)); + + let results = Arc::new(StdMutex::new(Vec::::new())); + let results_slot = Arc::clone(&results); + persistence.inject_plugin_state_collect_result_observer(Arc::new(move |ok: bool| { + results_slot.lock().unwrap().push(ok); + })); + + store + .collect_orphans_result_for_test() + .expect("baseline sweep"); + + let quiet_entered = Arc::new(AtomicBool::new(false)); + let quiet_release = Arc::new(AtomicBool::new(false)); + let seen_quiet = Arc::new(AtomicBool::new(false)); + let entered_slot = Arc::clone(&quiet_entered); + let release_slot = Arc::clone(&quiet_release); + let seen_slot = Arc::clone(&seen_quiet); + persistence.inject_plugin_state_collect_enqueue_observer(Arc::new( + move |report_health: bool| { + if !report_health && !seen_slot.swap(true, Ordering::SeqCst) { + entered_slot.store(true, Ordering::SeqCst); + while !release_slot.load(Ordering::SeqCst) { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + } + }, + )); + let _release_guard = ReleaseFlag(Arc::clone(&quiet_release)); + + // Production path: the durable put succeeds but its reply is lost, so the + // typed producer abandons its owner and runs the quiet reclamation. The + // in-transaction SQL fault is latched by that quiet caller when it enters + // the collector, before its request is issued. + persistence.inject_plugin_state_collect_failpoint(); + persistence.inject_plugin_state_put_ack_loss(); + let quiet_store = store.clone(); + let quiet_thread = std::thread::spawn(move || { + let payload = PluginStatePayload::parse(r#"{"f411":"quiet"}"#).unwrap(); + quiet_store.candidate_put_for_test("pkg.f411", "artifact.f411", 1, &payload) + }); + wait_flag( + &quiet_entered, + "quiet collector enqueued after the epoch snapshot", + ) + .await; + + // A newer explicit sweep executes and succeeds while the quiet request has + // not been issued yet. + store + .collect_orphans_result_for_test() + .expect("newer explicit sweep"); + assert!(!store.collection_debt_for_test()); + + // The pending quiet collector then executes the latched real SQL fault. + quiet_release.store(true, Ordering::SeqCst); + drop(_release_guard); + let (_quiet_id, quiet_put) = quiet_thread.join().expect("quiet producer thread"); + let observed = results.lock().unwrap().clone(); + let debt = store.collection_debt_for_test(); + let payload = PluginStatePayload::parse(r#"{"f411":"after-quiet"}"#).unwrap(); + let (_probe_id, probe) = store.candidate_put_for_test("pkg.f411", "artifact.f411", 1, &payload); + eprintln!( + "f411 F1 quiet observed: collect_results={observed:?} put_lost_reply_err={} debt={debt} next_put_ok={} next_put_code={:?}", + quiet_put.is_err(), + probe.is_ok(), + probe.as_ref().err().map(|error| error.code) + ); + + assert!( + quiet_put.is_err(), + "the lost put reply must surface as the original typed error" + ); + assert_eq!( + observed, + vec![true, true, false], + "the quiet collector must have failed for real after the newer success" + ); + assert!( + debt, + "410-F1 quiet RED: a real quiet collector failure was not recorded as worker debt" + ); + assert!( + probe.is_err(), + "a failed quiet sweep must still refuse the next producer until an explicit success" + ); +} + +/// 410-F2 corrected: the trusted session schema switch writes a durable +/// object, then the original cancellation is observed before the root CAS. +/// The released owner records one bounded dirty generation, so every later +/// put first runs a bounded worker-ordered sweep. The recent-64 retention +/// contract still holds: a just-abandoned object may stay readable while it +/// is within the recent 64, the in-flight transient peaks at 65 (64 recent + +/// 1 newest), and the final explicit settle sweep reclaims down to 64. +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn f412_trusted_switch_abandon_is_bounded_by_dirty_sweep() { + use std::sync::Mutex as StdMutex; + + let fixture = open_managed_run_state_fixture("f411-f2-switch"); + let http = Http::bind(fixture.state.clone()).await; + let _graph1 = run_state_plugin(&http, &fixture.token).await; + let session_id = create_session(&http, &fixture.token).await; + let provider = rustscript_agent::ScriptedProvider::new(); + fixture + .state + .service() + .inject_provider_host(Arc::new(provider.clone())); + let baseline_run = admit_state_run(&http, &fixture.token, &session_id, &provider).await; + assert_eq!( + wait_run_any_terminal(&fixture.state, &baseline_run).await, + "completed", + "schema 1 baseline run" + ); + // Only after the session holds a schema 1 root does the schema 2 graph + // become active, exactly like the production switch path. + let (graph2, _) = + activate_state_package(&http, &fixture.token, "package-staterun2", "2.0.0").await; + let persistence = fixture.state.persistence().expect("native persistence"); + let store = PluginStateStore::open(Arc::clone(&persistence)); + let key = store + .session_root_key(&session_id, "org.rustscript.test.staterun") + .expect("session key") + .expect("schema 1 root"); + let root_before = store.root(&key).expect("root").expect("root row"); + assert_eq!(root_before.schema, 1); + + let ops = Arc::new(StdMutex::new(Vec::::new())); + let ops_slot = Arc::clone(&ops); + persistence.inject_worker_execute_gate(Arc::new(move |op: &str| { + ops_slot.lock().unwrap().push(op.to_string()); + })); + let current = Arc::new(StdMutex::new(None::)); + let put_ids = Arc::new(StdMutex::new(Vec::::new())); + let current_slot = Arc::clone(¤t); + let ids_slot = Arc::clone(&put_ids); + // Fires after the real durable put and before the switch's next + // ensure_open(); requesting the cancellation there keeps the original + // clock and proves the cancellation follows the successful put. + persistence.inject_plugin_state_object_put_observer(Arc::new(move |object_id: &str| { + ids_slot.lock().unwrap().push(object_id.to_string()); + if let Some(cancel) = current_slot.lock().unwrap().as_ref() { + cancel.request(rustscript_agent::CancellationReason::Requested); + } + })); + + let service = fixture.state.service().clone(); + const ATTEMPTS: usize = 70; + // Bounded dirty bookkeeping before the abandon burst: every attempt must + // advance the dirty generation by exactly one last-owner release. + let (dirty_before, swept_before, sealed_before) = store.candidate_dirty_generations_for_test(); + assert!( + !sealed_before, + "the dirty counter must not be sealed before the burst" + ); + let ops_before = ops.lock().unwrap().len(); + let mut max_readable: usize = 0; + for attempt in 0..ATTEMPTS { + let cancel = rustscript_agent::RunCancellation::with_deadline( + std::time::Instant::now() + std::time::Duration::from_secs(30), + ); + *current.lock().unwrap() = Some(cancel.clone()); + let error = service + .switch_plugin_state_schema(&session_id, &graph2, &cancel) + .expect_err("a cancelled switch after the durable put must not publish the root"); + assert_eq!(error, "cancelled", "attempt {attempt}"); + // The worker sidecar settles just after the attempt returns; poll + // briefly rather than asserting on the racy instant. + { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + if store.candidate_registry_len_for_test() == 0 { + break; + } + assert!( + std::time::Instant::now() < deadline, + "attempt {attempt}: the abandoned owner must be released" + ); + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + } + // Transient bound sampled every attempt: recent 64 plus the newest + // in-flight orphan, never the unbounded 70 of the pre-fix run. + let readable_now = put_ids + .lock() + .unwrap() + .iter() + .filter(|object_id| store.object_payload(object_id).is_ok()) + .count(); + max_readable = max_readable.max(readable_now); + assert!( + readable_now <= 65, + "attempt {attempt}: {readable_now} readable orphans exceed the recent-64 plus in-flight transient bound" + ); + } + *current.lock().unwrap() = None; + let ids = put_ids.lock().unwrap().clone(); + let sequence = ops.lock().unwrap().clone(); + let collects_during = sequence[ops_before..] + .iter() + .filter(|op| op.as_str() == "plugin.state_collect") + .count(); + let readable = ids + .iter() + .filter(|object_id| store.object_payload(object_id).is_ok()) + .count(); + let root_after = store.root(&key).expect("root").expect("root row"); + let debt = store.collection_debt_for_test(); + // The worker-sidecar release races the attempt return by nanoseconds, so + // the dirty count is polled to its exact expected value, not asserted + // on a racy snapshot. + let (dirty_after, swept_covered, sealed_after) = { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + let generations = store.candidate_dirty_generations_for_test(); + if generations.0 == dirty_before + ATTEMPTS as u64 { + break generations; + } + assert!( + std::time::Instant::now() < deadline, + "dirty generation did not advance by one per abandon: {generations:?}" + ); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + }; + eprintln!( + "f412 F2 switch observed: attempts={ATTEMPTS} put_ids={} readable_orphans={readable} max_readable={max_readable} collects_during={collects_during} registry={} root_schema={} root_revision={} debt={debt} dirty={dirty_before}->{dirty_after} swept={swept_before}->{swept_covered} sealed={sealed_after}", + ids.len(), + store.candidate_registry_len_for_test(), + root_after.schema, + root_after.revision + ); + assert_eq!(ids.len(), ATTEMPTS, "each switch attempt writes one object"); + assert_eq!( + root_after.object_id, root_before.object_id, + "the cancelled switch must not publish a new root" + ); + assert_eq!( + root_after.revision, root_before.revision, + "the cancelled switch must not advance the root revision" + ); + assert!( + !debt, + "successful puts must not mark collection debt (dirty is sweep-pending, not Debt)" + ); + assert!( + !sealed_after, + "the bounded dirty counter must not seal during the burst" + ); + assert_eq!( + dirty_after, + dirty_before + ATTEMPTS as u64, + "every abandoned attempt records exactly one dirty generation" + ); + assert!( + collects_during >= ATTEMPTS - 1, + "every put after the first must first run a bounded dirty sweep, saw {collects_during}" + ); + assert!( + max_readable <= 65, + "the cross-request transient must stay within recent-64 plus one in-flight orphan, saw {max_readable}" + ); + + // One final bounded explicit settle sweep reclaims to the recent-64 + // contract and proves the dirty generations were really processed. + store + .collect_orphans_result_for_test() + .expect("bounded sweep"); + let (dirty_settled, swept_settled, sealed_settled) = + store.candidate_dirty_generations_for_test(); + let readable_after = ids + .iter() + .filter(|object_id| store.object_payload(object_id).is_ok()) + .count(); + eprintln!( + "f412 F2 switch after sweep: readable={readable_after} dirty={dirty_settled} swept={swept_settled} sealed={sealed_settled}" + ); + assert!( + !store.collection_debt_for_test(), + "the settle sweep must succeed without Debt" + ); + assert_eq!( + swept_settled, dirty_settled, + "the settle sweep must cover every abandoned generation" + ); + assert!( + !sealed_settled, + "the settle sweep must not seal the dirty counter" + ); + assert_eq!( + readable_after, 64, + "the bounded collector retains exactly the recent 64 true orphans" + ); + + // Business recovery: an ordinary uncancelled switch on the same root + // publishes the new schema and advances the revision. + let ok_cancel = rustscript_agent::RunCancellation::with_deadline( + std::time::Instant::now() + std::time::Duration::from_secs(30), + ); + let switched = service + .switch_plugin_state_schema(&session_id, &graph2, &ok_cancel) + .expect("an uncancelled switch must publish"); + let root_final = store.root(&key).expect("root").expect("root row"); + eprintln!( + "f412 F2 switch recovery: switched={switched:?} schema={} revision={}", + root_final.schema, root_final.revision + ); + assert_eq!(root_final.schema, 2, "recovery must publish schema 2"); + assert!( + root_final.revision > root_after.revision, + "recovery must advance the root revision" + ); + assert!( + !store.collection_debt_for_test(), + "business recovery must leave no collection debt" + ); +} + +/// 410-E2: exact typed budget code plus the real registry count/byte bounds and +/// the full-wire-under/aggregate-over collector boundary. Production caps are +/// the real ones; nothing is enlarged or bypassed. +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn f411_native_budget_exact_codes_and_registry_bounds() { + use std::sync::Mutex as StdMutex; + + let fixture = open_fixture("f411-e2-budget"); + let persistence = fixture.state.persistence().expect("native persistence"); + let store = PluginStateStore::open(Arc::clone(&persistence)); + let payload = PluginStatePayload::parse(r#"{"leaf":"kept"}"#).unwrap(); + let kept = store + .put_object("org.rustscript.test.state", "artifact-state", 1, &payload) + .expect("kept object"); + let _kept_lease = store.hold_candidate_reservation_for_test(&kept.object_id); + let executed_before_puts = persistence.executed_storage_commands(); + + // (a) Real registry count bound: 65,536 unique Native identities (the kept + // object already owns one entry). + let mut leases = Vec::new(); + for index in 0..65_535u32 { + let id = format!("f411-{index:08}-{}", "c".repeat(16)); + leases.push(store.hold_candidate_reservation_for_test(&id)); + } + let count_fill = store.candidate_registry_len_for_test(); + let executed_before = persistence.executed_storage_commands(); + let (count_id, count_result) = + store.candidate_put_for_test("pkg.f411", "artifact.f411", 1, &payload); + let count_executed = persistence.executed_storage_commands(); + let count_code = count_result.as_ref().err().map(|error| error.code); + eprintln!( + "f411 E2 count bound: registry={count_fill} code={count_code:?} sql_executed={} object_present={} kept_readable={}", + count_executed - executed_before, + store.object_payload(&count_id).is_ok(), + store.object_payload(&kept.object_id).is_ok() + ); + assert_eq!(count_fill, 65_536, "the real Native count bound is reached"); + assert_eq!( + count_code, + Some("plugin_state_budget_exceeded"), + "over-count reservations must return the exact typed code" + ); + assert_eq!( + count_executed, executed_before, + "no SQL may execute for an over-count reservation" + ); + assert!(store.object_payload(&count_id).is_err()); + assert!(store.object_payload(&kept.object_id).is_ok()); + drop(leases); + // Settle the mass abandonment while the registry is small again: one + // bounded explicit sweep must cover every released generation without + // Debt, so the byte-bound section below starts from a clean lifecycle + // instead of sweeping 65k drops under a full registry. + store + .collect_orphans_result_for_test() + .expect("settle sweep after the count-bound abandonment"); + let (dirty_settled_a, swept_settled_a, sealed_settled_a) = + store.candidate_dirty_generations_for_test(); + eprintln!( + "f411 E2 count settle: dirty={dirty_settled_a} swept={swept_settled_a} sealed={sealed_settled_a}" + ); + assert!( + !sealed_settled_a, + "the settle sweep must not seal the dirty counter" + ); + assert_eq!( + swept_settled_a, dirty_settled_a, + "the settle sweep must cover every count-bound abandonment" + ); + assert!( + !store.collection_debt_for_test(), + "settling a clean mass abandonment must not leave Debt" + ); + + // (b) Real registry byte bound: 8 MiB of identity bytes. 41,942 x 200 + // bytes plus the kept 36-byte id still leaves room for one 36-byte uuid; + // exactly filling the remaining 172 bytes then refuses the next uuid. + let mut leases = Vec::new(); + let long = "b".repeat(200 - "f411-00000000-".len()); + for index in 0..41_942u32 { + let id = format!("f411-{index:08}-{long}"); + assert_eq!(id.len(), 200); + leases.push(store.hold_candidate_reservation_for_test(&id)); + } + let (inside_id, inside_result) = + store.candidate_put_for_test("pkg.f411", "artifact.f411", 1, &payload); + assert!( + inside_result.is_ok(), + "a put that still fits the real byte bound must be accepted: {:?}", + inside_result.as_ref().err().map(|error| error.code) + ); + let edge_id = format!("f411-edge-{}", "e".repeat(172 - "f411-edge-".len())); + assert_eq!(edge_id.len(), 172); + leases.push(store.hold_candidate_reservation_for_test(&edge_id)); + let executed_before = persistence.executed_storage_commands(); + let (over_id, over_result) = + store.candidate_put_for_test("pkg.f411", "artifact.f411", 1, &payload); + let over_executed = persistence.executed_storage_commands(); + let over_code = over_result.as_ref().err().map(|error| error.code); + eprintln!( + "f411 E2 byte bound: accepted_inside={} code={over_code:?} sql_executed={} object_present={}", + inside_result.is_ok() && store.object_payload(&inside_id).is_ok(), + over_executed - executed_before, + store.object_payload(&over_id).is_ok() + ); + assert_eq!( + over_code, + Some("plugin_state_budget_exceeded"), + "over-byte reservations must return the exact typed code" + ); + assert_eq!( + over_executed, executed_before, + "no SQL may execute for an over-byte reservation" + ); + drop(leases); + + // (c) Collector injection boundary: full wire stays under the Native cap + // while the aggregate (2x repeat + 64-byte element charge + wire) exceeds + // it. The worker must fail closed with the exact code and run no SQL. + let mut leases = Vec::new(); + let mut ids = Vec::new(); + for _ in 0..6_000 { + let id = uuid::Uuid::new_v4().to_string(); + ids.push(id.clone()); + leases.push(store.hold_candidate_reservation_for_test(&id)); + } + ids.push(kept.object_id.clone()); + ids.sort(); + let observed = Arc::new(StdMutex::new(None::<(usize, usize, usize)>)); + let observed_slot = Arc::clone(&observed); + persistence.observe_plugin_state_collect_budget(Arc::new( + move |count: usize, wire: usize, aggregate: usize| { + *observed_slot.lock().unwrap() = Some((count, wire, aggregate)); + }, + )); + let executed_before = persistence.executed_storage_commands(); + let result = store.collect_orphans_result_for_test(); + let executed_after = persistence.executed_storage_commands(); + let inputs = *observed.lock().unwrap(); + eprintln!( + "f411 E2 aggregate boundary: inputs={inputs:?} code={:?} sql_executed={} kept_readable={}", + result.as_ref().err().map(|error| error.code), + executed_after - executed_before, + store.object_payload(&kept.object_id).is_ok() + ); + let (count, wire, aggregate) = inputs.expect("worker budget inputs are observed"); + assert_eq!( + count, 6_001, + "the protected set is read at the worker boundary" + ); + assert!( + wire < 1024 * 1024, + "the full wire payload stays under the Native cap: {wire}" + ); + assert!( + aggregate > 1024 * 1024, + "the aggregate repeat weight exceeds the Native cap: {aggregate}" + ); + // The worker's fail-closed branch answers `plugin_state_budget_exceeded`, + // but the typed collector surface flattens every non-allowlisted storage + // code to `plugin_state_unavailable` (`PluginStateError::storage`). Record + // the observable caller code; the exact registry code is asserted above. + assert_eq!( + result.as_ref().err().map(|error| error.code), + Some("plugin_state_unavailable"), + "the aggregate-over collector must fail closed with the typed collector code" + ); + assert_eq!( + executed_after, executed_before, + "no SQL may execute for an over-aggregate collector payload" + ); + assert!( + store.object_payload(&kept.object_id).is_ok(), + "the protected P must remain readable after the typed budget rejection" + ); + drop(leases); + let _ = executed_before_puts; +} + +/// 410-F2 guest corrected: a guest state write becomes durable, the managed +/// prepare then fails. The released owner records one bounded dirty +/// generation, so the next ordinary request first runs a bounded +/// worker-ordered sweep and processes that generation. The staged object may +/// legitimately stay readable while it sits inside the recent-64 retention +/// window; only after 65 newer orphans age it out does the bounded collector +/// reclaim it. Business recovery (root advance on the next request) is +/// asserted directly. +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn f412_guest_abandon_is_reclaimed_by_dirty_sweep_across_requests() { + use rustscript_agent::effects::ManagedEffectFault; + use std::sync::Mutex as StdMutex; + + let env = r2a_setup("f411-f2-guest-prepare").await; + let ops = Arc::new(StdMutex::new(Vec::::new())); + let ops_slot = Arc::clone(&ops); + env.persistence + .inject_worker_execute_gate(Arc::new(move |op: &str| { + ops_slot.lock().unwrap().push(op.to_string()); + })); + let put_ids = Arc::new(StdMutex::new(Vec::::new())); + let ids_slot = Arc::clone(&put_ids); + env.persistence + .inject_plugin_state_object_put_observer(Arc::new(move |object_id: &str| { + ids_slot.lock().unwrap().push(object_id.to_string()); + })); + let key = env + .store + .session_root_key(&env.session_id, "org.rustscript.test.staterun") + .expect("session key") + .expect("schema 2 root"); + let root_before = env.store.root(&key).expect("root").expect("root row"); + + env.fixture + .state + .service() + .inject_managed_effect_fault(ManagedEffectFault::AfterOperationBeforePrepare) + .expect("arm the one-shot prepare fault"); + // Dirty baseline before the failed run: exactly one last-owner release + // (the abandoned staged object) must land during it. + let dirty_before_failure = env.store.candidate_dirty_generations_for_test().0; + let failed_provider = rustscript_agent::ScriptedProvider::new(); + env.fixture + .state + .service() + .inject_provider_host(Arc::new(failed_provider.clone())); + let failed_run = admit_state_run( + &env.http, + &env.fixture.token, + &env.session_id, + &failed_provider, + ) + .await; + let failed_status = wait_run_any_terminal(&env.fixture.state, &failed_run).await; + let staged_id = put_ids + .lock() + .unwrap() + .first() + .cloned() + .expect("the guest write reached a durable put before the prepare failure"); + let staged_readable_after_failure = env.store.object_payload(&staged_id).is_ok(); + let root_after_failure = env.store.root(&key).expect("root").expect("root row"); + assert_eq!( + root_after_failure.object_id, root_before.object_id, + "the failed prepare must not publish the staged object" + ); + // The worker sidecar settles just after the run returns; poll briefly + // rather than asserting on the racy instant. + { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + if env.store.candidate_registry_len_for_test() == 0 { + break; + } + assert!( + std::time::Instant::now() < deadline, + "the abandoned staged owner must be released" + ); + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + } + let registry_after_failure = env.store.candidate_registry_len_for_test(); + // The released staged owner must record exactly one dirty generation + // (polled: the worker sidecar settles just after the run returns). + // Dirty is sweep-pending reclamation, never failure Debt. + let (dirty_at_abandon, swept_at_abandon, sealed_at_abandon) = { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + let generations = env.store.candidate_dirty_generations_for_test(); + if generations.0 > dirty_before_failure { + break generations; + } + assert!( + std::time::Instant::now() < deadline, + "the abandoned staged owner must record one dirty generation: {generations:?}" + ); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + }; + assert_eq!( + dirty_at_abandon, + dirty_before_failure + 1, + "exactly the abandoned staged object records a dirty generation during the failed run" + ); + assert!( + !sealed_at_abandon, + "the abandoned staged owner must not seal the dirty counter" + ); + assert!( + !env.store.collection_debt_for_test(), + "an abandoned guest write is dirty, not Debt" + ); + + // The next ordinary request must run a real bounded collector for the + // abandoned generation before its own business put, and must recover + // the business root on the same bindings. + let ops_before_recovery = ops.lock().unwrap().len(); + let ok_provider = rustscript_agent::ScriptedProvider::new(); + env.fixture + .state + .service() + .inject_provider_host(Arc::new(ok_provider.clone())); + let ok_run = + admit_state_run(&env.http, &env.fixture.token, &env.session_id, &ok_provider).await; + let ok_status = wait_run_any_terminal(&env.fixture.state, &ok_run).await; + let sequence = ops.lock().unwrap().clone(); + let collects = sequence[ops_before_recovery..] + .iter() + .filter(|op| op.as_str() == "plugin.state_collect") + .count(); + let (dirty_after_next, swept_after_next, _) = env.store.candidate_dirty_generations_for_test(); + let staged_readable_after_next = env.store.object_payload(&staged_id).is_ok(); + let root_after_next = env.store.root(&key).expect("root").expect("root row"); + eprintln!( + "f412 F2 guest observed: failed_status={failed_status} staged_durable={staged_readable_after_failure} root_unchanged={} registry={registry_after_failure} dirty={dirty_at_abandon} swept={swept_at_abandon} next_status={ok_status} collects_during_next={collects} staged_after_next={staged_readable_after_next} swept_after_next={swept_after_next} root_advanced={}", + root_after_failure.object_id == root_before.object_id, + root_after_next.object_id != root_before.object_id + ); + assert!( + staged_readable_after_failure, + "the guest write must be durable before the prepare failure" + ); + assert_eq!(ok_status, "completed"); + // The next successful request must run a real bounded collector for the + // abandoned generation: the pre-fix run had zero collects here. + assert!( + collects >= 1, + "the next request must run a bounded dirty sweep for the abandoned generation, saw {collects}" + ); + assert!( + swept_after_next >= dirty_at_abandon, + "the next request's sweep must cover the abandoned generation: swept={swept_after_next} dirty_at_abandon={dirty_at_abandon}" + ); + assert!( + dirty_after_next >= dirty_at_abandon, + "dirty generations only move forward across the next request: after={dirty_after_next} abandon={dirty_at_abandon}" + ); + assert!( + root_after_next.object_id != root_before.object_id, + "the next request must publish its own business object (business recovery)" + ); + assert!( + !env.store.collection_debt_for_test(), + "dirty reclamation across requests must not leave collection debt" + ); + // The staged object may still be readable inside its recent-64 window; + // that is the retention contract, not a leak. Record, do not forbid. + eprintln!( + "f412 F2 guest retention note: staged_after_next={staged_readable_after_next} (allowed while inside recent 64)" + ); + + // 65 newer typed objects age the abandoned object out of the recent-64 set; + // only then does the bounded collector reclaim it, proving it was a true + // unreferenced orphan all along. Each of these puts first runs its own + // bounded dirty sweep, so the cross-request count stays capped throughout. + let q_key = env + .store + .session_root_key(&env.q_session, "org.rustscript.test.staterun") + .expect("q session key") + .expect("q root"); + let q_root = env.store.root(&q_key).expect("q root").expect("q root row"); + let mut newer_ids = Vec::new(); + for attempt in 0..65 { + newer_ids.push( + env.store + .put_object( + &q_root.package_id, + &q_root.artifact_id, + q_root.schema, + &PluginStatePayload::parse(&format!(r#"{{"f411_orphan":{attempt}}}"#)).unwrap(), + ) + .expect("newer typed orphan object") + .object_id, + ); + } + env.store + .collect_orphans_result_for_test() + .expect("bounded sweep after 65 newer orphans"); + let staged_readable_after_sweep = env.store.object_payload(&staged_id).is_ok(); + let newer_readable = newer_ids + .iter() + .filter(|object_id| env.store.object_payload(object_id).is_ok()) + .count(); + eprintln!( + "f412 F2 guest after sweep: staged_readable={staged_readable_after_sweep} newer_readable={newer_readable}" + ); + assert!( + !staged_readable_after_sweep, + "the abandoned guest object must be a true orphan reclaimed by the bounded sweep once aged out of recent 64" + ); + assert_eq!( + newer_readable, 64, + "the bounded collector retains exactly the recent 64 newer orphans" + ); + assert!( + !env.store.collection_debt_for_test(), + "the aging sweep must succeed without Debt" + ); +} + +/// 410-F2 dirty concurrency: a last-owner release that lands mid-sweep must +/// not be cleared by that sweep's later success, and a late worker success +/// must not shadow newer dirt. The worker parks at its own collect snapshot +/// (already taken, covering generation S); the test releases the last owner +/// (dirty S+1) and only then lets the sweep run. The success covers exactly +/// S, so dirty stays pending and the next put runs its own bounded sweep. +/// The worker-side sidecar release never self-waits: a put whose reply is +/// lost still settles, the registry returns to zero, and ordinary business +/// puts keep succeeding without Debt. +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn f412_dirty_generation_survives_racing_release_and_late_success() { + use std::sync::Mutex as StdMutex; + use std::sync::atomic::{AtomicBool, Ordering}; + + let fixture = open_fixture("f412-dirty-race"); + let persistence = fixture.state.persistence().expect("native persistence"); + let store = PluginStateStore::open(Arc::clone(&persistence)); + + store + .collect_orphans_result_for_test() + .expect("baseline sweep"); + let (dirty_base, swept_base, sealed_base) = store.candidate_dirty_generations_for_test(); + assert!(!sealed_base, "baseline must not seal the dirty counter"); + assert_eq!( + dirty_base, swept_base, + "baseline sweep must cover all prior dirt" + ); + assert!( + !store.collection_debt_for_test(), + "baseline must be Healthy" + ); + + // Hold one real reservation, then run an explicit sweep parked at its + // own worker snapshot; the release lands strictly after the snapshot. + let lease = store.hold_candidate_reservation_for_test("f412-dirty-race-owner"); + let (dirty_held, _, _) = store.candidate_dirty_generations_for_test(); + assert_eq!( + dirty_held, dirty_base, + "holding a reservation is not an abandonment" + ); + let snapshot_entered = Arc::new(AtomicBool::new(false)); + let snapshot_release = Arc::new(AtomicBool::new(false)); + let snapshot_seen = Arc::new(AtomicBool::new(false)); + let snapshot_gen = Arc::new(StdMutex::new(None::)); + let entered_slot = Arc::clone(&snapshot_entered); + let release_slot = Arc::clone(&snapshot_release); + let seen_slot = Arc::clone(&snapshot_seen); + let gen_slot = Arc::clone(&snapshot_gen); + persistence.inject_plugin_state_collect_snapshot_observer(Arc::new(move |generation: u64| { + if !seen_slot.swap(true, Ordering::SeqCst) { + *gen_slot.lock().unwrap() = Some(generation); + entered_slot.store(true, Ordering::SeqCst); + while !release_slot.load(Ordering::SeqCst) { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + } + })); + let sweep_store = store.clone(); + let sweep_thread = std::thread::spawn(move || sweep_store.collect_orphans_result_for_test()); + wait_flag(&snapshot_entered, "worker parked at its collect snapshot").await; + + // The sweep already snapshotted generation S; this last-owner release + // moves dirt to S+1 while the sweep is still in flight. + drop(lease); + let (dirty_racing, swept_racing, _) = store.candidate_dirty_generations_for_test(); + assert_eq!( + dirty_racing, + dirty_base + 1, + "the mid-sweep release must record one dirty generation" + ); + assert_eq!( + swept_racing, swept_base, + "no sweep has covered the mid-sweep release yet" + ); + snapshot_release.store(true, Ordering::SeqCst); + let sweep_result = sweep_thread.join().expect("parked sweep thread"); + let covered = snapshot_gen + .lock() + .unwrap() + .expect("snapshot generation observed"); + let (dirty_covered, swept_covered, sealed_covered) = + store.candidate_dirty_generations_for_test(); + eprintln!( + "f412 dirty race observed: sweep_ok={} snapshot_gen={covered} dirty={dirty_covered} swept={swept_covered} sealed={sealed_covered}", + sweep_result.is_ok() + ); + persistence.clear_plugin_state_collect_snapshot_observer(); + assert!(sweep_result.is_ok(), "the parked sweep must still succeed"); + assert_eq!( + covered, swept_base, + "the sweep snapshot predates the mid-sweep release" + ); + assert_eq!( + swept_covered, swept_base, + "the late success clears exactly the generation it covered, not the newer dirt" + ); + assert_eq!( + dirty_covered, + dirty_base + 1, + "the mid-sweep release stays pending after the older success" + ); + assert!( + !sealed_covered, + "a racing release must not seal the dirty counter" + ); + assert!( + !store.collection_debt_for_test(), + "a covered-older success is not a failure: no Debt" + ); + + // The next business put first runs its own bounded sweep for the pending + // dirt, then succeeds durably; ordinary success is not degraded by + // routine Drops. + let put_ops = Arc::new(StdMutex::new(0usize)); + let put_ops_slot = Arc::clone(&put_ops); + persistence.inject_worker_execute_gate(Arc::new(move |op: &str| { + if op == "plugin.state_collect" { + *put_ops_slot.lock().unwrap() += 1; + } + })); + let payload = PluginStatePayload::parse(r#"{"f412":"after-race"}"#).unwrap(); + let (next_id, next_result) = + store.candidate_put_for_test("pkg.f412", "artifact.f412", 1, &payload); + let next_collects = *put_ops.lock().unwrap(); + persistence.clear_worker_execute_gate(); + assert!( + next_result.is_ok(), + "a business put after a racing release must succeed once its own sweep covers the dirt" + ); + assert!( + store.object_payload(&next_id).is_ok(), + "the post-race business object must be durable" + ); + assert!( + next_collects >= 1, + "the post-race put must first run a bounded sweep for the pending dirt, saw {next_collects}" + ); + // The post-race sweep covered the racing release; only the post-race + // put's own release may still be pending, and no Debt exists. + let (dirty_settled, swept_settled, sealed_settled) = + store.candidate_dirty_generations_for_test(); + eprintln!( + "f412 dirty race settled: dirty={dirty_settled} swept={swept_settled} sealed={sealed_settled} collects_for_put={next_collects}" + ); + assert!( + swept_settled >= dirty_racing, + "the post-race sweep must cover the racing release: swept={swept_settled} racing={dirty_racing}" + ); + assert!( + !sealed_settled, + "routine releases must not seal the counter" + ); + assert!( + !store.collection_debt_for_test(), + "ordinary business success must not leave collection debt" + ); + + // Worker Drop releases its own sidecar without self-wait: a put whose + // reply is lost still settles (registry back to zero, dirty recorded), + // and the next put recovers through the same bounded path. + persistence.inject_plugin_state_put_ack_loss(); + let payload = PluginStatePayload::parse(r#"{"f412":"lost-reply"}"#).unwrap(); + let (_lost_id, lost_result) = + store.candidate_put_for_test("pkg.f412", "artifact.f412", 1, &payload); + assert!( + lost_result.is_err(), + "the lost put reply must surface as the original typed error" + ); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + loop { + if store.candidate_registry_len_for_test() == 0 { + break; + } + assert!( + std::time::Instant::now() < deadline, + "the worker sidecar must settle without self-wait after a lost reply" + ); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + let payload = PluginStatePayload::parse(r#"{"f412":"after-loss"}"#).unwrap(); + let (recovered_id, recovered_result) = + store.candidate_put_for_test("pkg.f412", "artifact.f412", 1, &payload); + assert!( + recovered_result.is_ok() && store.object_payload(&recovered_id).is_ok(), + "business puts must keep succeeding after a lost reply settles" + ); + assert!( + !store.collection_debt_for_test(), + "a lost reply is not a collector failure: no Debt" + ); +} + +/// 410-E1 boundary: the durable prepared receipt alone protects the object when +/// no Native registry owner remains. The existing formal prepare gate is placed +/// after the durable `effect.prepare` but still holds the caller lease, so the +/// owner-zero state is produced here by the existing `AfterPrepared` fixture +/// cut (after the durable receipt, before any commit owner). +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn f411_durable_only_prepared_reference_protects_object() { + use rustscript_agent::effects::ManagedEffectFault; + use std::sync::Mutex as StdMutex; + + let env = r2a_setup("f411-e1-durable-only").await; + let put_ids = Arc::new(StdMutex::new(Vec::::new())); + let ids_slot = Arc::clone(&put_ids); + env.persistence + .inject_plugin_state_object_put_observer(Arc::new(move |object_id: &str| { + ids_slot.lock().unwrap().push(object_id.to_string()); + })); + env.fixture + .state + .service() + .inject_managed_effect_fault(ManagedEffectFault::AfterPrepared) + .expect("arm the one-shot post-prepare cut"); + let provider = rustscript_agent::ScriptedProvider::new(); + env.fixture + .state + .service() + .inject_provider_host(Arc::new(provider.clone())); + let run = admit_state_run(&env.http, &env.fixture.token, &env.session_id, &provider).await; + let status = wait_run_any_terminal(&env.fixture.state, &run).await; + let staged_id = put_ids + .lock() + .unwrap() + .first() + .cloned() + .expect("staged put before the durable prepared receipt"); + let registry_after_cut = env.store.candidate_registry_len_for_test(); + let staged_readable = env.store.object_payload(&staged_id).is_ok(); + + let key = env + .store + .session_root_key(&env.q_session, "org.rustscript.test.staterun") + .expect("q session key") + .expect("q root"); + let q_root = env.store.root(&key).expect("q root").expect("q root row"); + let mut orphan_ids = Vec::new(); + for attempt in 0..65 { + orphan_ids.push( + env.store + .put_object( + &q_root.package_id, + &q_root.artifact_id, + q_root.schema, + &PluginStatePayload::parse(&format!(r#"{{"f411_e1_orphan":{attempt}}}"#)) + .unwrap(), + ) + .expect("newer typed orphan object") + .object_id, + ); + } + env.store + .collect_orphans_result_for_test() + .expect("bounded sweep with a durable-only prepared reference"); + let readable_orphans = orphan_ids + .iter() + .filter(|object_id| env.store.object_payload(object_id).is_ok()) + .count(); + let staged_readable_after = env.store.object_payload(&staged_id).is_ok(); + eprintln!( + "f411 E1 observed: run_status={status} registry_after_durable_prepare={registry_after_cut} staged_readable_before={staged_readable} staged_readable_after={staged_readable_after} readable_orphans={readable_orphans}" + ); + assert_eq!( + registry_after_cut, 0, + "the durable prepared receipt must not need a Native registry owner" + ); + assert!( + staged_readable, + "the durable put must exist before the collector runs" + ); + assert!( + staged_readable_after, + "410-E1: the durable-only prepared reference failed to protect the object" + ); + assert_eq!( + readable_orphans, 64, + "stateless NULL prepared receipts must not stall the bounded collection" + ); +} + +/// 410-E1 boundary measurement: the existing formal prepare gate sits after the +/// durable `effect.prepare` but inside `prepare_result`, so the caller lease is +/// still held while the gate blocks. This records that exact boundary instead +/// of repeating the prepared survival test through the same gate. +#[cfg(feature = "workspace-io-fault")] +#[tokio::test(flavor = "multi_thread")] +async fn f411_prepare_gate_retains_the_caller_lease_boundary() { + use std::sync::Mutex as StdMutex; + + let env = r2a_setup("f411-e1-gate-boundary").await; + let put_ids = Arc::new(StdMutex::new(Vec::::new())); + let ids_slot = Arc::clone(&put_ids); + env.persistence + .inject_plugin_state_object_put_observer(Arc::new(move |object_id: &str| { + ids_slot.lock().unwrap().push(object_id.to_string()); + })); + let gate = env + .fixture + .state + .service() + .arm_managed_prepare_gate() + .expect("arm the formal prepare gate"); + let provider = rustscript_agent::ScriptedProvider::new(); + env.fixture + .state + .service() + .inject_provider_host(Arc::new(provider.clone())); + let run = admit_state_run(&env.http, &env.fixture.token, &env.session_id, &provider).await; + gate.wait_arrived(1) + .expect("the real effect.prepare reaches the formal gate"); + let registry_at_gate = env.store.candidate_registry_len_for_test(); + let staged_id = put_ids + .lock() + .unwrap() + .first() + .cloned() + .expect("staged put before the formal gate"); + eprintln!( + "f411 E1 gate boundary observed: registry_at_durable_prepare_gate={registry_at_gate} staged_readable={}", + env.store.object_payload(&staged_id).is_ok() + ); + gate.release(); + let status = wait_run_any_terminal(&env.fixture.state, &run).await; + eprintln!("f411 E1 gate boundary after release: status={status}"); + assert_eq!( + registry_at_gate, 1, + "the formal prepare gate retains exactly the caller lease after the durable prepare" + ); +} diff --git a/tests/support/plugin_admin_cycle.rs b/tests/support/plugin_admin_cycle.rs index 6a7d955..8f52794 100644 --- a/tests/support/plugin_admin_cycle.rs +++ b/tests/support/plugin_admin_cycle.rs @@ -448,8 +448,9 @@ async fn host_bound_cycle_rejects_caller_authority_and_pins_native_receipts() { Some(token), ) .await; - assert_eq!(status, 422, "{raw}"); - assert_eq!(body["error"]["code"], "plugin_state_unsupported"); + assert_eq!(status, 200, "{raw}"); + assert_eq!(body["graph_id"], state_graph); + assert_eq!(body["executable"], true); let unchanged = PluginControlStore::new(&GatewayPersistence::open(&config, &db).unwrap()) .activation() .unwrap(); @@ -467,7 +468,7 @@ async fn host_bound_cycle_rejects_caller_authority_and_pins_native_receipts() { session_id, &[ (graph_b.as_str(), 422, "plugin_graph_pin_required"), - (state_graph, 422, "plugin_state_unsupported"), + (state_graph, 422, "plugin_graph_pin_required"), ("caller-forged-graph", 422, "plugin_graph_pin_required"), (" ", 422, "plugin_graph_pin_required"), ("", 422, "plugin_graph_pin_required"), From 3aabb9b30b27270511011ca1e2a4a0e52f61bcbc Mon Sep 17 00:00:00 2001 From: fffonion Date: Wed, 4 Nov 2026 01:33:04 +0800 Subject: [PATCH 19/21] feat(plugins): link pure tool libraries with fresh effect authorization Capture trusted stateless tool and pure library graphs, retain original run authority for Native execution and replay, and keep historical receipts separate from executable restoration. Cache only immutable schema and builtin materials, with regression coverage for cancellation and revocation. --- ...2026-10-17_plugin-graphs-effect-sandbox.md | 22 +- src/config.rs | 4 +- src/durable_provider.rs | 40 +- src/effects/broker.rs | 19 + src/effects/journal.rs | 23 + src/gateway/store.rs | 36 +- src/plugins/control_store.rs | 55 +- src/plugins/mod.rs | 3 +- src/plugins/pin.rs | 125 +- src/plugins/prepare.rs | 1205 ++++- src/plugins/schema.rs | 140 +- src/runtime/agent_host.rs | 96 +- src/runtime/native_tools.rs | 71 +- src/service.rs | 270 +- src/service/managed_effects_tests.rs | 4402 +++++++++++++++++ src/service/managed_graph_tests.rs | 1184 ++++- src/service/managed_linearization_tests.rs | 11 +- src/service/managed_publications.rs | 14 +- src/service_preflight_tests.rs | 20 +- tests/plugin_effect_api_e2e_tests.rs | 1102 +++++ tests/plugin_graph_tests.rs | 668 +++ tests/plugin_hot_reload_tests.rs | 114 + tests/tools_pin_invoke_tracer.rs | 1699 ++++++- tests/tools_pin_production_ab.rs | 348 +- tests/tools_pinned_commit_outcome.rs | 12 +- 25 files changed, 11394 insertions(+), 289 deletions(-) diff --git a/plans/2026-10-17_plugin-graphs-effect-sandbox.md b/plans/2026-10-17_plugin-graphs-effect-sandbox.md index d385abf..193ce07 100644 --- a/plans/2026-10-17_plugin-graphs-effect-sandbox.md +++ b/plans/2026-10-17_plugin-graphs-effect-sandbox.md @@ -18,9 +18,15 @@ ## 1. 当前依据与设计边界 -计划所在工作区:`/mnt/TEMP/workspace/rustscript-agent/worktrees/effect-g09-final-regression-232-e70f8c95`。 +本计划的验收绑定到对应提交的源码和执行记录。修改时保全范围外的既有改动;编译、运行和静态检查分别记录,不把旧候选的通过结果转为新源码的资格。 -对照基线 HEAD `77653d574ebd4f0c451e443b5899fb5970f14cb6`,分支 `subagent/effect-g09-final-regression-232-e70f8c95`。工作区含未提交改动。实施时保全与本计划无关的既有修改,不能用该 HEAD 的测试结果代替当前候选验收。 +### 首期 A 本地验收 + +- 已完成一个 `AcceptedPublisher`、`ToolOnly`、stateless tool 链接一个纯 leaf library 的首期接入;真实 `read_file` 授权读取、library 字符串处理与原托管提交保持完整。 +- 已覆盖完整 graph lock 与已捕获代码固定、A/B 并存、真实 Native 首次执行与精确回放、同控制面撤销后的新鲜授权、只读历史保留及纯 leaf 运行失败。全局缓存仅保留不可变 builtin/schema 材料,不缓存执行权限。 +- 绝对 deadline 与取消仍来自原准入 root。四个只读回放探针的初始预算为 10s;独立 2s 真实 child-clock 场景、Native 执行类探针和生产默认配置保持原边界。查询 cut、精确错误、单次完成与历史断言不变。 +- 本地验证:library 611 通过、0 失败、6 忽略;六个相关集成 target 共 103 个唯一测试通过;全项目格式检查、全 target/all-features clippy `-D warnings` 与 schema `--check` 通过。六例聚焦属于 library 子集,不重复累计。独立代码审查已接受本切片;远端 CI 与发布另行核验。 +- 该验收限于首期 A。T07 正式 tool→tool、T14 多库和全量工具接入、stateful child、新拓扑 checkpoint/restore 及对应剩余 T15/T16 链路继续按既定后续范围推进;当前 typed 不支持边界保留。 已核对的复用点: @@ -520,6 +526,8 @@ T02 和 T09 已移出本计划。编号不回收,也不另开替代任务。 **T07 typed 插件调用桥与工具适配** +首期收敛为 T14 的单 tool+纯 library 可执行链接;本任务的 tool→tool 正式调用延期。后续首个 plugin-call 只支持可信 stateless caller→callee、一次同步调用和一个真实 effect,不同时引入 library、嵌套、并行或多个子 effect。独立 VM/主体、Native 重新发行授权、权限交集、有界 typed data 与原 clock/cancel/epoch/撤销必须同期完成。纯库和严格 stateless child 无 T12 功能依赖;共享文件仍由单写者串行修改。 + - 维护 `src/runtime/native_tools.rs`、`src/runtime/agent_host.rs`、`rss/tools/{registry.rss,dispatch.rss}`、`tests/tools_pin_invoke_tracer.rs`。 - RED:A 无法调用未声明的 B。B 不能继承 A 的其他权限。resource 无法借 JSON 跨 VM。同名不同 revision 不能重放旧结果。缺失 effect 的真实 Native 操作在发生前被拒绝。纯入口不能通过库、时钟或插件委托隐藏 effect。迟到批准不执行。 - GREEN:独立调用主体、Native 重新发行子 VM 资源、现有工具 schemas 不变。 @@ -555,6 +563,8 @@ T02 和 T09 已移出本计划。编号不回收,也不另开替代任务。 **T12 有版本的插件状态** +GC 采用最小正确结构:worker 在每次 put 执行前复核健康,所有 collector 失败顺序结算;最后 owner 在 registry 锁内记录固定大小 dirty generation,有限 sweep 只确认覆盖代次。保留现有状态层级、纯迁移和精确 checkpoint,不删除重做。不引入逐 producer 状态机、持久脏表、后台 GC 或重试调度。两项收尾、直接并发回归、既定最终同源门禁与独立审查闭合后停止;E1 剩余组合补证、E2 typed 错误码映射及 sweep 成本优化后移。 + - 维护 `src/plugins/state.rs`、`src/plugins/pin.rs`、`src/plugins/manifest.rs`、`rss/storage/plugins.rss`、`src/effects/journal.rs` 与 `src/service/managed_checkpoints.rs` 中的版本化 state root。测试使用 `tests/plugin_state_migration_tests.rs`;该文件是本任务的原定测试目标,不另起名称。 - 状态对象是有界 canonical JSON,先持久化再以 root revision CAS 发布;run binding 固定该 run 可见的 root revision,session 切换 schema 前取得既有 session maintenance lease 并等待该 session 旧 run terminal。 - 纯迁移是 manifest `state.migration.entry` 指向的同包 RSS entry,仅允许 `json`/`bytes` 与包内模块;生成器与 `canonical` schema 同步。`state::read`/`state::write` 复用 workspace effect 声明与 sealed Native pin,不新增 effect kind。 @@ -573,6 +583,10 @@ T02 和 T09 已移出本计划。编号不回收,也不另开替代任务。 **T14 tool 包的图接入** +首期仅一个 AcceptedPublisher、ToolOnly、stateless tool 链接一个纯 leaf library,以现有 read_file 的真实授权读取→library 字符串处理→原提交为交付行为。library 单源文件、无依赖/状态/capabilities/direct/delegated effect,独立纯编译,string 输入/输出;组合闭包统一计入既有预算。复用 prepare/closed_bundle/Program/pin,保留静态工具路由,仅窄化新拓扑/catalog并同步支持既有 graph_revoke。plugin 调用继续拒绝,不迁移全部工具。 + +新拓扑 checkpoint/restore 首期明确返回既有 typed 不支持错误,原无边图能力保持。后续分别扩多库、单层 stateless child、新拓扑 checkpoint/restore、stateful child;只有 stateful child 依赖已接受 T12。首期真实 prepare/pin/read/library结果/commit、A/B pin/replay和直接安全负控通过即停止,不将后续迭代算作隐藏尾项。首期不表示 T07 正式调用或 T14 全量接入完成。 + 保留图加载 tools 所需的内置包、manifest、exports 和 library 依赖。不迁移无关业务模块。 - 内置 tool/library 包进入插件 store,并由 graph resolve、load、pin。 @@ -595,6 +609,8 @@ T02 和 T09 已移出本计划。编号不回收,也不另开替代任务。 **T16 范围内 HTTP 链路** +既有无边拓扑已接受场景保留。新 tool+library 拓扑的首期验收限真实读取、library结果、固定图与撤销/停止控制;该拓扑 checkpoint/restore 用精确 typed 拒绝证明隔离。后续启用对应能力后再补新拓扑完整恢复链路,不以既有通过记录冒称新能力完成。 + - 测试目标保持 `tests/plugin_effect_api_e2e_tests.rs`。不另起名称。 - 从真实 socket 上的现有 `POST /api/sessions`、`POST /v1/runs` 发起。禁止用内部 SDK 代交后把 SSE 接收等同于 API 入口验证。 - 可以复用 `tests/support/live_http_delivery.rs` 的现有 HTTP 支撑。不改 delivery projection,不改 provider 协议,不把该文件的修改当作 delivery 重写验收。 @@ -623,7 +639,7 @@ T02 和 T09 已移出本计划。编号不回收,也不另开替代任务。 - 文件工具写入后,terminal 立即读取同一 view。terminal 写入后,RSS 文件读取返回同一 view。 - checkpoint 后修改再 restore,受控树与已覆盖插件状态一致。不能恢复已撤销权限。 - 关键 FS 和 SQLite publication 崩溃点。receipt 已存在时不重复 effect。未知状态不报成功。 -- 不改变 global suite concurrency。不使用放宽 deadline、取消断言或 retry-until-pass 使测试通过。 +- 不改变 global suite concurrency;不改变 harness 默认 deadline,执行中不延长 D、不重置时钟、不弱化取消断言,也不使用 retry-until-pass 使测试通过。四个只读 468 轮次沿用已授权的 fixture 初始 10s,独立 2s 时钟不变。 ### API 与外部 effect diff --git a/src/config.rs b/src/config.rs index 5a91d30..0f99557 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2550,8 +2550,9 @@ mod tests { "COALESCE(json_extract(idem.response_json, '$.registry_identity'), '')", "COALESCE(json_extract(idem.response_json, '$.workspace'), '')", "COALESCE(json_extract(idem.response_json, '$.profile'), '')", + "COALESCE(json_extract(idem.response_json, '$.harness_selection'), 'null')", ], - "the operation-owned receipt must retain all eight cells in decoder order" + "the operation-owned receipt must retain all nine cells in decoder order" ); for (index, (field, kind)) in [ ("graph_id", "text"), @@ -2562,6 +2563,7 @@ mod tests { ("registry_identity", "text"), ("workspace", "text"), ("profile", "text"), + ("harness_selection_json", "text"), ] .into_iter() .enumerate() diff --git a/src/durable_provider.rs b/src/durable_provider.rs index 9af5265..254478e 100644 --- a/src/durable_provider.rs +++ b/src/durable_provider.rs @@ -1004,6 +1004,20 @@ mod tests { (state, admitted.run_id, admitted.session_id) } + /// The admitted run's own cancellation root: a real admission deadline, + /// never a deadline-less synthetic. Positive-path provider calls must use + /// this; the strict native guard rejects deadline-less callers before any + /// inner call. Explicit requested/elapsed negative controls keep their own + /// synthetic cancels and are asserted separately. + fn admitted_cancel(state: &AgentGatewayState, run_id: &str) -> RunCancellation { + state + .service() + .handle(run_id) + .expect("admitted run handle") + .cancel + .clone() + } + fn host_for( state: &AgentGatewayState, run_id: &str, @@ -1025,7 +1039,7 @@ mod tests { inner.push_ok(text_ok("review assessment")); inner.push_ok(text_ok("final business")); let host = host_for(&state, &run_id, inner.clone()); - let cancel = RunCancellation::new(); + let cancel = admitted_cancel(&state, &run_id); let mut first = request(); first["_provider_step_key"] = json!("business:0"); let mut review = request(); @@ -1064,7 +1078,7 @@ mod tests { let inner = ScriptedProvider::new(); inner.push_ok(text_ok("recorded")); let host = host_for(&state, &run_id, inner.clone()); - let cancel = RunCancellation::new(); + let cancel = admitted_cancel(&state, &run_id); for invalid in [ json!(""), json!("x".repeat(513)), @@ -1095,7 +1109,7 @@ mod tests { let (state, run_id, _) = admitted_state().await; let inner = ScriptedProvider::new(); inner.push_ok(text_ok("legacy")); - let cancel = RunCancellation::new(); + let cancel = admitted_cancel(&state, &run_id); assert_eq!( host_for(&state, &run_id, inner.clone()).call(&request(), &cancel)["ok"], true @@ -1129,7 +1143,7 @@ mod tests { inner.push_ok(text_ok("first")); inner.push_ok(text_ok("second")); let host = host_for(&state, &admitted.run_id, inner.clone()); - let cancel = RunCancellation::new(); + let cancel = admitted_cancel(&state, &admitted.run_id); let mut first = request(); first["_provider_step_key"] = json!("business:0"); let mut second = request(); @@ -1394,7 +1408,7 @@ mod tests { })); inner.push_ok(text_ok("should not run")); let host = host_for(&state, &run_id, inner.clone()); - let cancellation = RunCancellation::new(); + let cancellation = admitted_cancel(&state, &run_id); let first = host.call(&request(), &cancellation); assert_eq!(first["ok"], json!(false), "{first}"); assert_eq!(first["error"]["retryable"], json!(false), "{first}"); @@ -1514,7 +1528,7 @@ mod tests { })); inner.push_ok(text_ok("should not run")); let host = host_for(&state, &run_id, inner.clone()); - let cancellation = RunCancellation::new(); + let cancellation = admitted_cancel(&state, &run_id); let first = host.call(&request(), &cancellation); assert_eq!(first["ok"], json!(false), "{first}"); assert_eq!(first["error"]["retryable"], json!(false), "{first}"); @@ -1603,7 +1617,7 @@ mod tests { inner.push_error(retryable_error()); inner.push_ok(text_ok("recovered")); let host = host_for(&state, &run_id, inner.clone()); - let cancellation = RunCancellation::new(); + let cancellation = admitted_cancel(&state, &run_id); let first = host.call(&request(), &cancellation); assert_eq!(first["ok"], json!(false), "{first}"); let requested = events_named(&state, &run_id, "model.requested"); @@ -1634,7 +1648,7 @@ mod tests { inner.push_error(retryable_error()); inner.push_error(retryable_error()); let first_host = host_for(&state, &run_id, inner.clone()); - let cancellation = RunCancellation::new(); + let cancellation = admitted_cancel(&state, &run_id); let first = first_host.call(&request(), &cancellation); assert_eq!(first["ok"], json!(false), "{first}"); drop(first_host); @@ -1702,9 +1716,10 @@ mod tests { let inner = ScriptedProvider::new(); inner.push_ok(text_ok("after-redrive")); state.service().inject_crash_after_provider_request(); + let cancel = admitted_cancel(&state, &run_id); let host = host_for(&state, &run_id, inner.clone()); let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - host.call(&request(), &RunCancellation::new()) + host.call(&request(), &cancel) })); assert!( panicked.is_err(), @@ -1715,7 +1730,7 @@ mod tests { assert_eq!(events_named(&state, &run_id, "model.completed").len(), 0); let host = host_for(&state, &run_id, inner.clone()); - let envelope = host.call(&request(), &RunCancellation::new()); + let envelope = host.call(&request(), &cancel); assert_eq!(envelope["ok"], json!(true), "{envelope}"); assert_eq!(inner.call_count(), 1); assert_eq!(events_named(&state, &run_id, "model.requested").len(), 1); @@ -2043,7 +2058,10 @@ mod tests { inner.push_error(retryable_error()); inner.push_ok(text_ok("must not run")); let host = host_for(fixture.state(), &fixture.run_id, inner.clone()); - let first = host.call(&request(), &RunCancellation::new()); + let first = host.call( + &request(), + &admitted_cancel(fixture.state(), &fixture.run_id), + ); assert_eq!(first["ok"], json!(false), "{first}"); assert_eq!(inner.call_count(), 1); let before = fixture.state().service().run_events(&fixture.run_id); diff --git a/src/effects/broker.rs b/src/effects/broker.rs index 1358f6b..a6781dc 100644 --- a/src/effects/broker.rs +++ b/src/effects/broker.rs @@ -2011,6 +2011,15 @@ impl ManagedRunBinding { else { return Ok(None); }; + // Original record identity, epoch, arguments, and durable outcome + // classifications keep their exact order and precedence here. The + // freshness guard runs only on the branch that would actually return + // a final live replay, immediately before it. A missing record stays + // a plain miss for the fresh-execution path (guarded separately at + // the Native entry). Staged/indeterminate/identity failures keep + // their existing codes; only a real final replay can be refused for + // revocation. Query/decode/authority failure refuses closed. + // Positive absence is never cached. let identity = &record.identity; if identity.owner != self.owner || identity.call_id != call_id @@ -2058,6 +2067,16 @@ impl ManagedRunBinding { ) { return Err("managed replay final disposition invalid".into()); } + // Freshness guard: only the branch that would actually return a final + // live replay re-reads the exact sealed graph revocation through this + // binding's own repository owner under the original run cancellation. + let revoked = self + .runtime + .repository + .execution_revocation(&self.code.graph_id, &self.cancel)?; + if revoked.is_some() { + return Err("plugin_graph_revoked".into()); + } self.runtime.verify_tree( outcome .publication diff --git a/src/effects/journal.rs b/src/effects/journal.rs index 3ddbb45..ea926a9 100644 --- a/src/effects/journal.rs +++ b/src/effects/journal.rs @@ -1385,6 +1385,29 @@ impl ManagedEffectRepository { .managed_effect_get_with_cancellation(request, cancellation) .map_err(|e| e.to_string()) } + /// Execution-guard revocation read through this repository's own control + /// store owner under the original run cancellation. Query or decode + /// failures refuse closed at the caller; a positive absence is never + /// cached. + pub(crate) fn execution_revocation( + &self, + graph_id: &str, + cancellation: &crate::RunCancellation, + ) -> Result, String> { + // Same original-cancel precedence as the Native entry/return guard: + // an original requested/expired root keeps its existing + // cancelled/deadline_elapsed classification and takes precedence over + // a revoked or failed query. No new timer is minted. + cancellation + .ensure_open() + .map_err(|code| code.to_string())?; + let query = crate::plugins::PluginControlStore::new(&self.persistence) + .graph_revocation_for_execution(graph_id, cancellation); + cancellation + .ensure_open() + .map_err(|code| code.to_string())?; + query.map_err(|_| "plugin_graph_pin_required".to_string()) + } #[cfg(feature = "workspace-io-fault")] pub(crate) fn inject_reference_transport_failure(&self) { self.persistence.inject_persist_failure(); diff --git a/src/gateway/store.rs b/src/gateway/store.rs index ab31b73..8b9eb9b 100644 --- a/src/gateway/store.rs +++ b/src/gateway/store.rs @@ -1984,7 +1984,15 @@ impl GatewayPersistence { if op == "admission.create" { validate_composed_restore_budget(payload)?; } - let cancellation = if op.starts_with("effect.") || claim.is_some() { + let cancellation = if op.starts_with("effect.") + || claim.is_some() + || (op == "plugin.graph_revocation_get" && parent.is_some()) + { + // The exact execution-guard read carries the original run + // cancellation: it honors the same finite-deadline requirement and + // remaining-deadline/command-timeout bound as effect calls, without + // minting a new run clock. Ordinary history reads pass no parent + // and keep the previous parent-less path. let parent = parent.ok_or("managed storage context missing")?; if parent.deadline_instant().is_none() { return Err("managed storage finite original deadline required".into()); @@ -3059,6 +3067,32 @@ impl GatewayPersistence { self.command_data_for_original_proof(op, payload, budget_until) } + /// Narrow execution-guard read: only the exact graph-revocation op, always + /// under the caller's original run cancellation. Ordinary history reads + /// keep using the parent-less command path. The same original root is + /// re-checked at the return boundary before the read result is accepted: + /// a requested/expired original keeps its existing classification instead + /// of becoming a missing-authority refusal. No new timer is minted. + pub(crate) fn plugin_control_command_for_execution( + &self, + op: &str, + payload: &Value, + cancellation: &crate::RunCancellation, + ) -> Result { + if op != "plugin.graph_revocation_get" { + return Err(StorageError { + code: "plugin_control_unavailable".to_string(), + message: "plugin control operation is not registered".to_string(), + }); + } + let data = self.command_data_with_cancellation(op, payload, cancellation)?; + cancellation.ensure_open().map_err(|code| StorageError { + code: code.to_string(), + message: code.to_string(), + })?; + Ok(data) + } + // ------------------------------------------------------------------ // Typed repository commands (production read/write path). // ------------------------------------------------------------------ diff --git a/src/plugins/control_store.rs b/src/plugins/control_store.rs index c788418..06da5e8 100644 --- a/src/plugins/control_store.rs +++ b/src/plugins/control_store.rs @@ -389,8 +389,12 @@ impl<'a> PluginControlStore<'a> { Ok(()) } - /// Revoke one supported embedding-owned graph; shared package identity - /// does not widen the revocation to any other graph. + /// Revoke one supported graph; shared package identity does not widen the + /// revocation to any other graph. Embedding-owned graphs use the original + /// path; phase-A tool plus pure-leaf graphs revoke through the same + /// verified narrow topology. Revocation tombstones the graph id only and + /// never grants execution: the leaf manifest and sources are re-verified + /// at prepare time before anything runs. pub fn revoke_graph( &self, packages: &super::PackageStore, @@ -408,18 +412,23 @@ impl<'a> PluginControlStore<'a> { .iter() .find(|p| &p.id == tool_id) .ok_or_else(PluginControlError::invalid)?; - if package.id != crate::runtime::native_tools::EMBEDDING_PACKAGE_ID { - return Err(PluginControlError::invalid()); - } let installed = packages .read_installed(&package.artifact_id) .map_err(|_| PluginControlError::invalid())?; - if installed.trust != super::PackageTrust::EmbeddingOwnedBuiltin - || installed.manifest.id != package.id + if installed.manifest.id != package.id || super::SupportedManagedTopology::validate(&loaded.lock, &installed).is_none() { return Err(PluginControlError::invalid()); } + if installed.trust == super::PackageTrust::EmbeddingOwnedBuiltin { + if package.id != crate::runtime::native_tools::EMBEDDING_PACKAGE_ID { + return Err(PluginControlError::invalid()); + } + } else if installed.trust != super::PackageTrust::AcceptedPublisher + || super::prepare::tool_library_topology(&loaded.lock, &installed).is_none() + { + return Err(PluginControlError::invalid()); + } let data = self.command( "plugin.graph_revoke", &json!({"graph_id":graph_id,"now_ms":now_ms(),"failpoint":self.failpoint()}), @@ -452,6 +461,38 @@ impl<'a> PluginControlStore<'a> { .transpose() } + /// Execution-guard typed read of the exact sealed graph revocation. The + /// query runs under the original run cancellation with the existing + /// remaining-deadline bound; any query failure is fail-closed by the + /// caller, and a row for a different graph never authorizes this one. + pub(crate) fn graph_revocation_for_execution( + &self, + graph_id: &str, + cancel: &crate::RunCancellation, + ) -> Result, PluginControlError> { + let data = self + .persistence + .plugin_control_command_for_execution( + "plugin.graph_revocation_get", + &json!({"graph_id":graph_id}), + cancel, + ) + .map_err(map_storage)?; + storage_rows(&data)? + .first() + .map(|row| { + let fact = GraphRevocation { + graph_id: text_at(row, 0)?, + revoked_at_ms: int_at(row, 1)?, + }; + if fact.graph_id != graph_id { + return Err(PluginControlError::unavailable()); + } + Ok(fact) + }) + .transpose() + } + pub fn pin_requirement_for_run( &self, run_id: &str, diff --git a/src/plugins/mod.rs b/src/plugins/mod.rs index 872eb10..d708288 100644 --- a/src/plugins/mod.rs +++ b/src/plugins/mod.rs @@ -31,7 +31,8 @@ pub use pin::{ }; pub(crate) use pin::{ PreparedProductionPin, prepare_checkpoint_pin, prepare_production_pin, - restore_owned_pin_with_cancellation, seal_production_receipt, validate_owned_harness_fact, + restore_owned_pin_with_cancellation, seal_production_receipt, validate_owned_admission_receipt, + validate_owned_harness_fact, }; pub(crate) use prepare::SupportedManagedTopology; pub use resolve::{ diff --git a/src/plugins/pin.rs b/src/plugins/pin.rs index e5531a8..12d1e62 100644 --- a/src/plugins/pin.rs +++ b/src/plugins/pin.rs @@ -15,7 +15,9 @@ use super::control_store::PluginControlStore; use super::resolve::{GraphLock, LockedPackage}; use super::store::PackageStore; use crate::gateway::store::GatewayPersistence; -use crate::runtime::agent_host::{PinnedToolCode, PinnedToolProgram, PinnedToolReceipt}; +use crate::runtime::agent_host::{ + NativeExecutionAuthority, PinnedToolCode, PinnedToolProgram, PinnedToolReceipt, +}; const POSTCOMMIT_UNPUBLISHED: &str = "failpoint_after_commit_before_publish"; @@ -117,7 +119,7 @@ impl PinnedToolAdmission { /// newer graph. A post-commit publication failure is recovered from the /// committed receipt; it is not reported as a rollback. pub fn admit_pinned_tool_run( - persistence: &GatewayPersistence, + persistence: &Arc, store: &PackageStore, caller: &Value, context: &PinnedAdmitContext, @@ -150,7 +152,7 @@ pub fn admit_pinned_tool_run( } fn admit_new( - persistence: &GatewayPersistence, + persistence: &Arc, store: &PackageStore, caller: &Value, context: &PinnedAdmitContext, @@ -294,6 +296,7 @@ fn admit_new( registry_identity: context.registry_identity.clone(), workspace: context.workspace.clone(), }, + persistence, )) } Err(error) if error.code == POSTCOMMIT_UNPUBLISHED => recover_unpublished( @@ -313,7 +316,7 @@ fn admit_new( } fn recover_unpublished( - persistence: &GatewayPersistence, + persistence: &Arc, store: &PackageStore, scope: &str, key: &str, @@ -338,7 +341,7 @@ fn recover_unpublished( } fn recover_pinned( - persistence: &GatewayPersistence, + persistence: &Arc, store: &PackageStore, data: &Value, cancel: Option<&crate::RunCancellation>, @@ -393,6 +396,20 @@ fn recover_pinned( "pinned package artifact is no longer the captured ref", )); } + // A recovered executable binding re-checks the exact current graph + // revocation before it can run; the reader stays attached to the pin. + let revoked = match cancel { + Some(cancel) => control + .graph_revocation_for_execution(&graph_id, cancel) + .map_err(|_| PinnedToolAdmitError::new("plugin_graph_pin_required"))?, + None => control + .graph_revocation(&graph_id) + .map_err(|_| PinnedToolAdmitError::new("plugin_graph_pin_required"))? + .filter(|fact| fact.graph_id == graph_id), + }; + if revoked.is_some() { + return Err(PinnedToolAdmitError::new("plugin_graph_revoked")); + } let prepared = store .prepare(&loaded.lock) .map_err(PinnedToolAdmitError::plugin)?; @@ -409,6 +426,7 @@ fn recover_pinned( registry_identity, workspace, }, + persistence, )) } @@ -467,7 +485,11 @@ fn capture_tool( }) } -fn binding_from_capture(captured: CapturedTool, receipt: PinnedToolReceipt) -> PinnedToolAdmission { +fn binding_from_capture( + captured: CapturedTool, + receipt: PinnedToolReceipt, + persistence: &Arc, +) -> PinnedToolAdmission { let binding = PinnedToolProgram::seal( PinnedToolCode { harness_selection: captured.harness_selection, @@ -484,6 +506,9 @@ fn binding_from_capture(captured: CapturedTool, receipt: PinnedToolReceipt) -> P == crate::runtime::native_tools::EMBEDDING_PACKAGE_ID, }, receipt.clone(), + NativeExecutionAuthority::Persistent { + persistence: Arc::clone(persistence), + }, ); PinnedToolAdmission { binding: Arc::new(binding), @@ -798,6 +823,7 @@ pub(crate) fn seal_production_receipt( registry_identity: String, workspace: PathBuf, state_store: Option, + persistence: &Arc, ) -> Result { let pin = receipt .get("pin") @@ -846,6 +872,7 @@ pub(crate) fn seal_production_receipt( registry_identity, workspace, }, + persistence, ) .program_handle(); if let Some(state) = pin.state.clone() { @@ -932,7 +959,7 @@ fn state_workspace_identity( } pub fn restore_owned_pin( - persistence: &GatewayPersistence, + persistence: &Arc, store: &PackageStore, owner_id: &str, ) -> Result { @@ -987,8 +1014,67 @@ pub(crate) fn validate_owned_harness_fact( Ok(true) } -pub(crate) fn restore_owned_pin_with_cancellation( +/// Read-only admission receipt validation for history replay. This proves the +/// exact captured identity/owner binding without constructing executable code, +/// without checking revocation, and without refreshing execution authority. +/// Revoked history stays replayable here; execution restore still refuses. +pub(crate) fn validate_owned_admission_receipt( persistence: &GatewayPersistence, + owner_id: &str, +) -> Result<(), PinnedToolAdmitError> { + let control = PluginControlStore::new(persistence); + let pin = control + .pin_for_run(owner_id) + .map_err(|_| PinnedToolAdmitError::new("plugin_graph_pin_required"))?; + if pin["pinned"] != true || pin["run_id"] != owner_id { + return Err(PinnedToolAdmitError::new( + "retained tool pin does not match the original run", + )); + } + let graph_id = required_text(&pin, "graph_id")?; + let ref_id = required_text(&pin, "ref_id")?; + if graph_id.is_empty() || ref_id.is_empty() { + return Err(PinnedToolAdmitError::new( + "retained tool pin does not match the original run", + )); + } + let required = control + .pin_requirement_for_run(owner_id) + .map_err(|_| PinnedToolAdmitError::new("plugin_graph_pin_required"))? + .ok_or_else(|| PinnedToolAdmitError::new("plugin_graph_pin_required"))?; + if required.graph_id != graph_id || required.ref_id != ref_id { + return Err(PinnedToolAdmitError::new("plugin_graph_pin_required")); + } + let loaded = control + .load_lock(&graph_id) + .map_err(|error| PinnedToolAdmitError::new(error.message))?; + validate_pin_selection(&pin, &loaded.lock)?; + let run = persistence + .run_get(owner_id) + .map_err(|_| PinnedToolAdmitError::new("plugin_graph_pin_required"))?; + if matches!( + loaded.lock.harness_selection, + super::HarnessSelection::ContextPrompt { .. } + ) { + let outer: Value = serde_json::from_str( + run["rows"][0][4] + .as_str() + .ok_or_else(|| PinnedToolAdmitError::new("harness_policy_selection_invalid"))?, + ) + .map_err(|_| PinnedToolAdmitError::new("harness_policy_selection_invalid"))?; + if outer["run_graph_selection"] + != json!({"graph_id":required.graph_id,"ref_id":required.ref_id,"harness_selection":loaded.lock.harness_selection}) + { + return Err(PinnedToolAdmitError::new( + "harness_policy_selection_invalid", + )); + } + } + Ok(()) +} + +pub(crate) fn restore_owned_pin_with_cancellation( + persistence: &Arc, store: &PackageStore, owner_id: &str, cancel: &crate::RunCancellation, @@ -996,7 +1082,7 @@ pub(crate) fn restore_owned_pin_with_cancellation( restore_owned_pin_inner(persistence, store, owner_id, Some(cancel)) } fn restore_owned_pin_inner( - persistence: &GatewayPersistence, + persistence: &Arc, store: &PackageStore, owner_id: &str, cancel: Option<&crate::RunCancellation>, @@ -1083,6 +1169,26 @@ fn restore_owned_pin_inner( "pinned package artifact is no longer the captured ref", )); } + // Supported executable ToolOnly restores re-check the exact current + // graph revocation before producing a runnable binding. The ContextPrompt + // branch above already enforces its own requirement plus revocation. + if !matches!( + loaded.lock.harness_selection, + super::HarnessSelection::ContextPrompt { .. } + ) { + let revoked = match cancel { + Some(cancel) => control + .graph_revocation_for_execution(&graph_id, cancel) + .map_err(|_| PinnedToolAdmitError::new("plugin_graph_pin_required"))?, + None => control + .graph_revocation(&graph_id) + .map_err(|_| PinnedToolAdmitError::new("plugin_graph_pin_required"))? + .filter(|fact| fact.graph_id == graph_id), + }; + if revoked.is_some() { + return Err(PinnedToolAdmitError::new("plugin_graph_revoked")); + } + } let prepared = match cancel { Some(cancel) => store.prepare_harness_graph(&loaded.lock, cancel), None => store.prepare(&loaded.lock), @@ -1101,6 +1207,7 @@ fn restore_owned_pin_inner( registry_identity, workspace, }, + persistence, ) .program_handle()) } diff --git a/src/plugins/prepare.rs b/src/plugins/prepare.rs index 065d1e2..77c4602 100644 --- a/src/plugins/prepare.rs +++ b/src/plugins/prepare.rs @@ -27,7 +27,13 @@ impl SupportedManagedTopology { pub(crate) fn validate(lock: &GraphLock, tool: &InstalledPackage) -> Option { use super::resolve::HarnessSelection; if !tool.manifest.dependencies.is_empty() || !lock.edges.is_empty() { - return None; + // Phase-A narrow shape: one AcceptedPublisher stateless tool plus + // one pure leaf library. The leaf manifest and sources are + // verified at prepare time; lock plus tool manifest already pin + // the exact shape, including the single library-relation edge. + return tool_library_topology(lock, tool).map(|_| Self { + selection: lock.harness_selection.clone(), + }); } let embedding = tool.manifest.id == crate::runtime::native_tools::EMBEDDING_PACKAGE_ID && tool.trust == PackageTrust::EmbeddingOwnedBuiltin; @@ -94,6 +100,392 @@ impl SupportedManagedTopology { } } +/// Phase-A narrow link metadata visible from the lock plus tool manifest. +/// The leaf manifest and sources need the installed leaf package and are +/// verified separately at prepare time; revocation only needs this much +/// because it tombstones a graph without granting execution. +pub(crate) struct ToolLibraryLinkMeta { + pub alias: String, + pub leaf_id: String, + pub leaf_artifact: String, +} + +/// Phase-A shape gate: exactly one AcceptedPublisher stateless read-only tool +/// plus one leaf library joined by a single `library`-relation edge. Anything +/// else returns `None` so the legacy edgeless or plugin-edge paths keep their +/// previous behavior; `None` here never widens an edge into a call. +pub(crate) fn tool_library_topology( + lock: &GraphLock, + tool: &InstalledPackage, +) -> Option { + use super::resolve::HarnessSelection; + let HarnessSelection::ToolOnly { tool_package } = &lock.harness_selection else { + return None; + }; + if tool_package != &tool.manifest.id { + return None; + } + if lock.packages.len() != 2 || lock.edges.len() != 1 || lock.closure.len() != 2 { + return None; + } + let edge = &lock.edges[0]; + if edge.from != tool.manifest.id + || edge.from_entry != tool.manifest.entry.export + || edge.relation != "library" + || !edge.bindings.is_empty() + { + return None; + } + if !is_tool_role(tool) + || tool.trust != PackageTrust::AcceptedPublisher + || tool.manifest.state.is_some() + { + return None; + } + if tool.manifest.dependencies.len() != 1 { + return None; + } + let dependency = &tool.manifest.dependencies[0]; + if dependency.alias != edge.alias + || dependency.id != edge.to + || dependency.version != edge.version_requirement + { + return None; + } + let leaf = lock.packages.iter().find(|package| package.id == edge.to)?; + if leaf.kind != "library" + || edge.to_version != leaf.version + || edge.to_artifact_id != leaf.artifact_id + { + return None; + } + // The public read-only surface is fixed: one read effect on the tool + // entry, nothing delegated, no state, and the workspace.read capability. + if tool.manifest.capabilities != ["workspace.read".to_string()] { + return None; + } + let entry_effects = tool + .manifest + .effects + .entries + .get(&tool.manifest.entry.export)?; + if entry_effects.direct.len() != 1 || !entry_effects.delegated.is_empty() { + return None; + } + let declared = &entry_effects.direct[0]; + if declared.kind != "workspace.fs.read" + || declared.scope != "bound_workspace" + || declared.recovery != "none" + { + return None; + } + if tool.manifest.effects.entries.iter().any(|(name, effects)| { + name != &tool.manifest.entry.export + && (!effects.direct.is_empty() || !effects.delegated.is_empty()) + }) { + return None; + } + let tool_closure = lock.closure.iter().find(|item| { + item.package == tool.manifest.id && item.entry == tool.manifest.entry.export + })?; + if tool_closure.effects.len() != 1 { + return None; + } + let locked = &tool_closure.effects[0]; + if locked.kind != "workspace.fs.read" + || locked.scope != "bound_workspace" + || locked.recovery != "none" + || locked.via.relation != "direct" + || locked.via.caller_package != tool.manifest.id + || locked.via.caller_entry != tool.manifest.entry.export + || !locked.via.alias.is_empty() + || locked.via.callee_package != tool.manifest.id + || locked.via.callee_entry != tool.manifest.entry.export + { + return None; + } + let leaf_closure = lock + .closure + .iter() + .find(|item| item.package == leaf.id && item.entry == edge.to_entry)?; + if !leaf_closure.effects.is_empty() { + return None; + } + // The alias becomes the single-file module `.rss` in the memory + // compile view. It must be a valid module name and must shadow neither a + // host namespace nor one of the tool's own local modules. + if !crate::runtime::closed_bundle::valid_module_identifier(&dependency.alias) { + return None; + } + if host_roots().contains(&dependency.alias) { + return None; + } + for path in tool.sources.keys() { + let first = path.split('/').next().unwrap_or(""); + let stem = first.strip_suffix(".rss").unwrap_or(first); + if stem == dependency.alias { + return None; + } + } + Some(ToolLibraryLinkMeta { + alias: dependency.alias.clone(), + leaf_id: leaf.id.clone(), + leaf_artifact: leaf.artifact_id.clone(), + }) +} + +/// The verified phase-A link: the tool keeps its Native effects, the leaf +/// only formats strings, and the alias maps to `.rss` in the memory +/// compile view. Captured bytes are never rewritten; the mapping lives only +/// in this compilation. +struct ToolLibraryLink<'a> { + tool: &'a InstalledPackage, + leaf: &'a InstalledPackage, + alias: String, +} + +/// Resolve the full phase-A link from the verified capture, including the +/// leaf manifest and source-file facts that the lock alone cannot carry. +/// `None` falls back to the legacy paths; it never widens an edge. +fn tool_library_link<'a>( + lock: &GraphLock, + selected: &[&'a InstalledPackage], +) -> Option> { + let super::resolve::HarnessSelection::ToolOnly { tool_package } = &lock.harness_selection + else { + return None; + }; + let tool = selected + .iter() + .copied() + .find(|package| package.manifest.id == *tool_package)?; + let meta = tool_library_topology(lock, tool)?; + let leaf = selected + .iter() + .copied() + .find(|package| package.manifest.id == meta.leaf_id)?; + if leaf.artifact_id != meta.leaf_artifact { + return None; + } + if leaf.manifest.kind != "library" + || leaf.manifest.contract != "agent.library/v1" + || leaf.trust != PackageTrust::AcceptedPublisher + || leaf.manifest.state.is_some() + || !leaf.manifest.dependencies.is_empty() + || !leaf.manifest.capabilities.is_empty() + { + return None; + } + if leaf + .manifest + .effects + .entries + .values() + .any(|entry| !entry.direct.is_empty() || !entry.delegated.is_empty()) + { + return None; + } + if !leaf + .manifest + .effects + .entries + .contains_key(&leaf.manifest.entry.export) + { + return None; + } + if leaf.sources.len() != 1 || !leaf.sources.contains_key(&leaf.manifest.entry.path) { + return None; + } + Some(ToolLibraryLink { + tool, + leaf, + alias: meta.alias, + }) +} + +/// The structural skeleton: ToolOnly plus two packages joined by one +/// `library`-relation edge. No manifest judgment here; that is the precise +/// diagnosis below. +struct LibrarySkeleton<'a> { + tool: &'a InstalledPackage, + leaf: &'a InstalledPackage, +} + +fn library_skeleton<'a>( + lock: &GraphLock, + selected: &[&'a InstalledPackage], +) -> Option> { + let super::resolve::HarnessSelection::ToolOnly { tool_package } = &lock.harness_selection + else { + return None; + }; + if lock.packages.len() != 2 || lock.edges.len() != 1 { + return None; + } + let edge = &lock.edges[0]; + if edge.relation != "library" || edge.from != *tool_package { + return None; + } + let tool = selected + .iter() + .copied() + .find(|package| package.manifest.id == *tool_package)?; + let leaf = selected + .iter() + .copied() + .find(|package| package.manifest.id == edge.to)?; + Some(LibrarySkeleton { tool, leaf }) +} + +/// Precise rejection for a library skeleton that misses the narrow shape. +/// Only used when the tool actually imports the alias; disconnected graphs +/// keep their legacy behavior. Forgery and revision checks come first, then +/// the tool surface, the lock closure, the alias mapping, and the leaf +/// manifest. Leaf *source* impurity is diagnosed separately on the linked +/// path, where the leaf manifest is already clean. +fn diagnose_library_link(lock: &GraphLock, skeleton: &LibrarySkeleton<'_>) -> PluginError { + let tool = skeleton.tool; + let leaf = skeleton.leaf; + let shape = |item: String| PluginError::new(PluginErrorKind::ContractIncompatible, item); + let Some(edge) = lock.edges.first() else { + return shape(format!("{} library_shape", tool.manifest.id)); + }; + if edge.from_entry != tool.manifest.entry.export { + return shape(format!("{} library_edge_entry", tool.manifest.id)); + } + if !edge.bindings.is_empty() { + return shape(format!("{} library_edge_bindings", tool.manifest.id)); + } + if !is_tool_role(tool) + || tool.trust != PackageTrust::AcceptedPublisher + || tool.manifest.state.is_some() + { + return shape(format!("{} library_tool_shape", tool.manifest.id)); + } + if tool.manifest.dependencies.len() != 1 { + return shape(format!("{} library_tool_shape", tool.manifest.id)); + } + let dependency = &tool.manifest.dependencies[0]; + if dependency.alias != edge.alias + || dependency.id != edge.to + || dependency.version != edge.version_requirement + { + return shape(format!("{} library_edge_mismatch", tool.manifest.id)); + } + let locked_leaf = lock.packages.iter().find(|package| package.id == edge.to); + let Some(locked_leaf) = locked_leaf else { + return shape(format!("{} library_leaf_revision", tool.manifest.id)); + }; + if locked_leaf.kind != "library" + || edge.to_version != locked_leaf.version + || edge.to_artifact_id != locked_leaf.artifact_id + { + return shape(format!("{} library_leaf_revision", tool.manifest.id)); + } + if tool.manifest.capabilities != ["workspace.read".to_string()] { + return shape(format!("{} library_tool_capabilities", tool.manifest.id)); + } + let entry_effects = tool + .manifest + .effects + .entries + .get(&tool.manifest.entry.export); + let Some(entry_effects) = entry_effects else { + return shape(format!("{} library_tool_effects", tool.manifest.id)); + }; + if entry_effects.direct.len() != 1 || !entry_effects.delegated.is_empty() { + return shape(format!("{} library_tool_effects", tool.manifest.id)); + } + let declared = &entry_effects.direct[0]; + if declared.kind != "workspace.fs.read" + || declared.scope != "bound_workspace" + || declared.recovery != "none" + { + return shape(format!("{} library_tool_effects", tool.manifest.id)); + } + if tool.manifest.effects.entries.iter().any(|(name, effects)| { + name != &tool.manifest.entry.export + && (!effects.direct.is_empty() || !effects.delegated.is_empty()) + }) { + return shape(format!("{} library_tool_effects", tool.manifest.id)); + } + if lock.closure.len() != 2 { + return shape(format!("{} library_closure", tool.manifest.id)); + } + let tool_closure = lock + .closure + .iter() + .find(|item| item.package == tool.manifest.id && item.entry == tool.manifest.entry.export); + let Some(tool_closure) = tool_closure else { + return shape(format!("{} library_closure", tool.manifest.id)); + }; + if tool_closure.effects.len() != 1 { + return shape(format!("{} library_closure", tool.manifest.id)); + } + let locked = &tool_closure.effects[0]; + if locked.kind != "workspace.fs.read" + || locked.scope != "bound_workspace" + || locked.recovery != "none" + || locked.via.relation != "direct" + || locked.via.caller_package != tool.manifest.id + || locked.via.caller_entry != tool.manifest.entry.export + || !locked.via.alias.is_empty() + || locked.via.callee_package != tool.manifest.id + || locked.via.callee_entry != tool.manifest.entry.export + { + return shape(format!("{} library_closure", tool.manifest.id)); + } + let leaf_closure = lock + .closure + .iter() + .find(|item| item.package == locked_leaf.id && item.entry == edge.to_entry); + let Some(leaf_closure) = leaf_closure else { + return shape(format!("{} library_closure", tool.manifest.id)); + }; + if !leaf_closure.effects.is_empty() { + return shape(format!("{} library_leaf_effects", tool.manifest.id)); + } + if !crate::runtime::closed_bundle::valid_module_identifier(&dependency.alias) + || host_roots().contains(&dependency.alias) + { + return shape(format!("{} library_alias", tool.manifest.id)); + } + for path in tool.sources.keys() { + let first = path.split('/').next().unwrap_or(""); + let stem = first.strip_suffix(".rss").unwrap_or(first); + if stem == dependency.alias { + return shape(format!("{} library_alias", tool.manifest.id)); + } + } + if leaf.manifest.kind != "library" + || leaf.manifest.contract != "agent.library/v1" + || leaf.trust != PackageTrust::AcceptedPublisher + || leaf.manifest.state.is_some() + || !leaf.manifest.dependencies.is_empty() + || !leaf.manifest.capabilities.is_empty() + { + return shape(format!("{} library_leaf_manifest", leaf.manifest.id)); + } + if leaf + .manifest + .effects + .entries + .values() + .any(|entry| !entry.direct.is_empty() || !entry.delegated.is_empty()) + || !leaf + .manifest + .effects + .entries + .contains_key(&leaf.manifest.entry.export) + { + return shape(format!("{} library_leaf_effects", leaf.manifest.id)); + } + if leaf.sources.len() != 1 || !leaf.sources.contains_key(&leaf.manifest.entry.path) { + return shape(format!("{} library_leaf_sources", leaf.manifest.id)); + } + shape(format!("{} library_shape", tool.manifest.id)) +} + #[derive(Clone)] pub struct PreparedGraph { sources: BTreeMap<(String, String), Vec>, @@ -267,6 +659,35 @@ impl PackageStore { })?; selected.push(package); } + // Phase-A narrow link, resolved once from the verified capture. `None` + // keeps every legacy path identical; `Some` only admits the exact + // single-tool plus pure-leaf shape. + let link = tool_library_link(lock, &selected); + // A library skeleton that misses the narrow shape keeps the legacy + // disconnected behavior unless the tool actually imports the alias. + // The precise shape error then replaces the generic bypass at the + // import site; leaf source impurity is diagnosed on the linked path. + let skeleton = library_skeleton(lock, &selected); + let link_error = if link.is_none() { + skeleton + .as_ref() + .map(|shape| diagnose_library_link(lock, shape)) + } else { + None + }; + // A host-namespace alias would shadow the host in every combined + // view, and the duplicate import may not even parse for the scan to + // see. Reject it before scanning; disconnected graphs with sane + // aliases keep their legacy behavior. + if link.is_none() + && let Some(shape) = skeleton.as_ref() + && lock + .edges + .first() + .is_some_and(|edge| host_roots().contains(&edge.alias)) + { + return Err(diagnose_library_link(lock, shape)); + } let request = match &lock.harness_selection { super::resolve::HarnessSelection::Unselected => { super::resolve::HarnessSelectionRequest::Unselected @@ -299,7 +720,17 @@ impl PackageStore { } for package in &selected { let pure = matches!(&lock.harness_selection,super::HarnessSelection::ContextPrompt{policy,..} if policy.package_id==package.manifest.id); - scan_package(package, &selected, pure).map_err(|error| { + let allowed_library_alias = link.as_ref().and_then(|linked| { + (linked.tool.manifest.id == package.manifest.id).then_some(linked.alias.as_str()) + }); + scan_package( + package, + &selected, + pure, + allowed_library_alias, + link_error.as_ref(), + ) + .map_err(|error| { if pure { PluginError::new(error.kind, "harness_policy_not_pure") } else { @@ -307,6 +738,10 @@ impl PackageStore { } })?; } + if let Some(linked) = link.as_ref() { + scan_pure_library_source(linked.leaf, &linked.alias)?; + check_library_string_interface(linked.leaf)?; + } let mut prepared = PreparedGraph { sources: BTreeMap::new(), harness_selection: lock.harness_selection.clone(), @@ -330,6 +765,15 @@ impl PackageStore { prepared .managed_policy_catalogs .insert(package.manifest.id.clone(), catalog); + } else if let Some(linked) = link + .as_ref() + .filter(|linked| linked.tool.manifest.id == package.manifest.id) + && let Some(catalog) = + capture_signed_tool_library_catalog(package, linked.leaf, lock, cancel)? + { + prepared + .managed_policy_catalogs + .insert(package.manifest.id.clone(), catalog); } let effects = lock .closure @@ -355,7 +799,15 @@ impl PackageStore { crate::effects::broker::ManagedEntryContract { trust: package.trust, effects, - topology: SupportedManagedTopology::validate(lock, package), + topology: match link.as_ref() { + Some(linked) if linked.tool.manifest.id == package.manifest.id => { + SupportedManagedTopology::validate(lock, package) + } + _ if package.manifest.dependencies.is_empty() && lock.edges.is_empty() => { + SupportedManagedTopology::validate(lock, package) + } + _ => None, + }, }, ); for (path, bytes) in &package.sources { @@ -381,7 +833,24 @@ impl PackageStore { package.manifest.entry.export.clone(), ); check_cancel(cancel)?; - let (callable, program) = compile_package(package)?; + let (callable, program) = match link.as_ref() { + Some(linked) if linked.tool.manifest.id == package.manifest.id => { + compile_tool_with_library(linked.tool, linked.leaf, &linked.alias)? + } + Some(linked) if linked.leaf.manifest.id == package.manifest.id => { + let (callable, program) = compile_leaf_package(linked.leaf)?; + for import in &program.imports { + if !is_pure(&import.name) { + return Err(PluginError::new( + PluginErrorKind::UnauthorizedHostImport, + import.name.clone(), + )); + } + } + (callable, program) + } + _ => compile_package(package)?, + }; check_cancel(cancel)?; let imports: Vec<_> = program .imports @@ -686,20 +1155,126 @@ fn capture_signed_stateless_catalog( .map_err(|_| invalid()) }) .collect::>()?; + let value = eval_tool_catalog_value(&sources, &entry_path, cancel)?; + if let Some(cancel) = cancel { + cancel + .ensure_open() + .map_err(|e| PluginError::new(PluginErrorKind::CompileFailed, e))?; + } + crate::effects::authority::ManagedToolPolicyCatalog::from_captured_value( + &package.manifest.id, + &package.artifact_id, + &package.manifest.entry.export, + &value, + ) + .map(Some) + .map_err(|_| invalid()) +} + +/// Signed catalog capture for the phase-A tool plus pure-leaf graph. The +/// whole two-package lock (packages, edges, closure, harness selection) is +/// rebuilt and compared; the catalog value itself still comes from the tool's +/// own catalog export alone, which must not reach into the leaf. +fn capture_signed_tool_library_catalog( + tool: &InstalledPackage, + leaf: &InstalledPackage, + lock: &GraphLock, + cancel: Option<&crate::RunCancellation>, +) -> Result>, PluginError> { + let invalid = || { + PluginError::new( + PluginErrorKind::ContractIncompatible, + "managed captured catalog", + ) + }; + let meta = tool_library_topology(lock, tool).ok_or_else(invalid)?; + if meta.leaf_id != leaf.manifest.id || meta.leaf_artifact != leaf.artifact_id { + return Err(invalid()); + } + // A verified tool plus leaf link is fully lock-checked even when the tool + // ships no catalog export: the optional catalog lookup must not precede + // the rebuild, or a forged lock would be accepted without a catalog. + check_cancel(cancel)?; + let rebuilt = super::resolve::resolve_installed( + &[tool.clone(), leaf.clone()], + &super::resolve::ResolveRequest { + roots: vec![super::resolve::PackageRequirement { + id: tool.manifest.id.clone(), + version: tool.manifest.version.clone(), + }], + host_runtime: lock.host_runtime.clone(), + harness_selection: super::resolve::HarnessSelectionRequest::ToolOnly { + tool_package: tool.manifest.id.clone(), + }, + }, + )?; + if rebuilt.packages != lock.packages + || rebuilt.edges != lock.edges + || rebuilt.closure != lock.closure + || rebuilt.harness_selection != lock.harness_selection + { + return Err(invalid()); + } + let paths = catalog_export_paths(tool); + let entry_path = match paths.len() { + 0 => return Ok(None), + 1 => paths[0].clone(), + _ => return Err(invalid()), + }; + let sources: Vec<_> = tool + .sources + .iter() + .map(|(key, bytes)| { + std::str::from_utf8(bytes) + .map(|text| (key.as_str(), text)) + .map_err(|_| invalid()) + }) + .collect::>()?; + let value = eval_tool_catalog_value(&sources, &entry_path, cancel)?; + if let Some(cancel) = cancel { + cancel + .ensure_open() + .map_err(|e| PluginError::new(PluginErrorKind::CompileFailed, e))?; + } + crate::effects::authority::ManagedToolPolicyCatalog::from_captured_value( + &tool.manifest.id, + &tool.artifact_id, + &tool.manifest.entry.export, + &value, + ) + .map(Some) + .map_err(|_| invalid()) +} + +/// Evaluate one tool-owned catalog export over that tool's captured sources +/// only. The leaf never participates: a catalog that reaches the alias fails +/// the closure selection instead of inheriting leaf code. +fn eval_tool_catalog_value( + sources: &[(&str, &str)], + entry_path: &str, + cancel: Option<&crate::RunCancellation>, +) -> Result { + let invalid = || { + PluginError::new( + PluginErrorKind::ContractIncompatible, + "managed captured catalog", + ) + }; let host_roots = host_roots(); let selected = closed_bundle::select_catalog_closure( - &sources, + sources, agent_host_catalog().as_ref(), - &entry_path, + entry_path, &host_roots, ) .map_err(|_| invalid())?; let sources: Vec<_> = sources - .into_iter() + .iter() + .copied() .filter(|(path, _)| selected.iter().any(|item| item == path)) .collect(); let compiled = closed_bundle::compile_bundled_closed_bundle( - &entry_path, + entry_path, &sources, ClosedBundleOptions { limits: ClosedBundleLimits { @@ -724,27 +1299,13 @@ fn capture_signed_stateless_catalog( .ensure_open() .map_err(|e| PluginError::new(PluginErrorKind::CompileFailed, e))?; } - let value = - crate::runtime::rss_runner::eval_captured_pure_export(program, &entry).map_err(|e| { - if cancel.is_some() { - PluginError::new(PluginErrorKind::CompileFailed, e) - } else { - invalid() - } - })?; - if let Some(cancel) = cancel { - cancel - .ensure_open() - .map_err(|e| PluginError::new(PluginErrorKind::CompileFailed, e))?; - } - crate::effects::authority::ManagedToolPolicyCatalog::from_captured_value( - &package.manifest.id, - &package.artifact_id, - &package.manifest.entry.export, - &value, - ) - .map(Some) - .map_err(|_| invalid()) + crate::runtime::rss_runner::eval_captured_pure_export(program, &entry).map_err(|e| { + if cancel.is_some() { + PluginError::new(PluginErrorKind::CompileFailed, e) + } else { + invalid() + } + }) } fn capture_state_migration_program( @@ -911,6 +1472,8 @@ fn scan_package( package: &InstalledPackage, selected: &[&InstalledPackage], pure: bool, + allowed_library_alias: Option<&str>, + library_error: Option<&PluginError>, ) -> Result<(), PluginError> { let aliases: BTreeSet<&str> = package .manifest @@ -943,10 +1506,20 @@ fn scan_package( } } if aliases.contains(first.as_str()) { - return Err(PluginError::new( - PluginErrorKind::PluginBoundaryBypass, - format!("{} {first}", package.manifest.id), - )); + // The single verified library alias resolves inside the + // combined memory compile view; every other dependency alias + // stays a cross-package call behind the broker boundary. A + // library skeleton that misses the narrow shape reports its + // precise shape error at the import site instead. + if allowed_library_alias != Some(first.as_str()) { + if let Some(error) = library_error { + return Err(error.clone()); + } + return Err(PluginError::new( + PluginErrorKind::PluginBoundaryBypass, + format!("{} {first}", package.manifest.id), + )); + } } if roots.contains(first) && !is_allowed_host_root(first) { return Err(PluginError::new( @@ -995,26 +1568,123 @@ fn scan_source( Ok(imports) } -fn compile_package( - package: &InstalledPackage, -) -> Result<(String, Arc), PluginError> { - compile_package_export(package, &package.manifest.entry.export) -} -fn compile_package_export( - package: &InstalledPackage, - export: &str, -) -> Result<(String, Arc), PluginError> { - let host_roots = host_roots(); - let catalog = agent_host_catalog(); - let owned: Vec<(String, String)> = package - .sources - .iter() - .filter_map(|(path, bytes)| { - std::str::from_utf8(bytes).ok().map(|text| { - let compiled_text = if path == &package.manifest.entry.path { - // Pure builtin namespaces only. This is compile context, not - // stored package bytes, and it cannot grant an effect. - format!("use bytes;\nuse json;\n{text}") +fn scan_pure_library_source(leaf: &InstalledPackage, alias: &str) -> Result<(), PluginError> { + // The leaf is one file with no caller. Only the pure `json`/`bytes` + // namespaces may appear; a host namespace is an effect grab and any other + // first segment smuggles a local or caller module into the leaf. + let roots = host_roots(); + let prelude = catalog_prelude(); + for (path, bytes) in &leaf.sources { + let source = std::str::from_utf8(bytes) + .map_err(|_| PluginError::new(PluginErrorKind::CompileFailed, path.clone()))?; + let Ok(imports) = scan_source(&format!("{prelude}{source}")) else { + continue; + }; + let prelude_count = scan_source(&prelude).map(|items| items.len()).unwrap_or(0); + for import in imports.into_iter().skip(prelude_count) { + let Some(first) = import.first() else { + continue; + }; + if first == "json" || first == "bytes" { + continue; + } + if first == alias { + return Err(PluginError::new( + PluginErrorKind::ContractIncompatible, + format!("{} {first}", leaf.manifest.id), + )); + } + if roots.contains(first) { + return Err(PluginError::new( + PluginErrorKind::UnauthorizedHostImport, + format!("{} {first}", leaf.manifest.id), + )); + } + return Err(PluginError::new( + PluginErrorKind::PluginBoundaryBypass, + format!("{} {first}", leaf.manifest.id), + )); + } + } + Ok(()) +} + +fn check_library_string_interface(leaf: &InstalledPackage) -> Result<(), PluginError> { + // Phase A moves only strings across the link. Every exported leaf + // function must take strings and return a string; tokens, resources, + // callbacks, maps, and handles have no representation here. + let bytes = leaf.sources.get(&leaf.manifest.entry.path).ok_or_else(|| { + PluginError::new( + PluginErrorKind::ContractIncompatible, + leaf.manifest.id.clone(), + ) + })?; + let source = std::str::from_utf8(bytes).map_err(|_| { + PluginError::new( + PluginErrorKind::CompileFailed, + leaf.manifest.entry.path.clone(), + ) + })?; + let ir = parse_source_with_dialect( + source, + snapshot_parser_dialect(), + SharedParserOptions { + source_id: 0, + allow_implicit_externs: true, + allow_implicit_semicolons: false, + enforce_mutable_bindings: false, + import_scan_mode: false, + }, + ) + .map_err(|_| PluginError::new(PluginErrorKind::CompileFailed, leaf.manifest.id.clone()))?; + let mut saw_export = false; + for function in &ir.functions { + if !function.exported { + continue; + } + let shape_ok = function.name == leaf.manifest.entry.export + && function.return_schema == Some(rustscript_vm::TypeSchema::String) + && function + .arg_schemas + .iter() + .all(|schema| *schema == Some(rustscript_vm::TypeSchema::String)); + if !shape_ok { + return Err(PluginError::new( + PluginErrorKind::ContractIncompatible, + format!("{} {}", leaf.manifest.id, function.name), + )); + } + saw_export = true; + } + if !saw_export { + return Err(PluginError::new( + PluginErrorKind::UncoveredExport, + leaf.manifest.entry.export.clone(), + )); + } + Ok(()) +} + +fn compile_package( + package: &InstalledPackage, +) -> Result<(String, Arc), PluginError> { + compile_package_export(package, &package.manifest.entry.export) +} +fn compile_package_export( + package: &InstalledPackage, + export: &str, +) -> Result<(String, Arc), PluginError> { + let host_roots = host_roots(); + let catalog = agent_host_catalog(); + let owned: Vec<(String, String)> = package + .sources + .iter() + .filter_map(|(path, bytes)| { + std::str::from_utf8(bytes).ok().map(|text| { + let compiled_text = if path == &package.manifest.entry.path { + // Pure builtin namespaces only. This is compile context, not + // stored package bytes, and it cannot grant an effect. + format!("use bytes;\nuse json;\n{text}") } else { text.to_string() }; @@ -1054,6 +1724,135 @@ fn compile_package_export( Ok((entry, Arc::new(compiled.program))) } +/// Compile the tool entry together with the single verified leaf. Both +/// packages share one closed-bundle budget; the leaf is mapped to +/// `.rss` in this memory view only, and the tool keeps its declared +/// Native effects. The combined program is charged against the tool manifest +/// because the leaf contributes no host imports of its own. +fn compile_tool_with_library( + tool: &InstalledPackage, + leaf: &InstalledPackage, + alias: &str, +) -> Result<(String, Arc), PluginError> { + let host_roots = host_roots(); + let catalog = agent_host_catalog(); + let mut owned: Vec<(String, String)> = tool + .sources + .iter() + .filter_map(|(path, bytes)| { + std::str::from_utf8(bytes).ok().map(|text| { + let compiled_text = if path == &tool.manifest.entry.path { + // Pure builtin namespaces only. This is compile context, not + // stored package bytes, and it cannot grant an effect. + format!("use bytes;\nuse json;\n{text}") + } else { + text.to_string() + }; + (path.clone(), compiled_text) + }) + }) + .collect(); + let leaf_path = format!("{alias}.rss"); + let leaf_bytes = leaf.sources.get(&leaf.manifest.entry.path).ok_or_else(|| { + PluginError::new( + PluginErrorKind::ContractIncompatible, + leaf.manifest.id.clone(), + ) + })?; + let leaf_text = std::str::from_utf8(leaf_bytes).map_err(|_| { + PluginError::new( + PluginErrorKind::CompileFailed, + leaf.manifest.entry.path.clone(), + ) + })?; + owned.push((leaf_path, leaf_text.to_string())); + let sources: Vec<(&str, &str)> = owned + .iter() + .map(|(path, text)| (path.as_str(), text.as_str())) + .collect(); + let compiled = closed_bundle::compile_bundled_closed_bundle( + &tool.manifest.entry.path, + &sources, + ClosedBundleOptions { + limits: ClosedBundleLimits { + max_files: 64, + max_depth: 16, + max_file_bytes: 1024 * 1024, + max_total_bytes: 8 * 1024 * 1024, + }, + virtual_root: Path::new(VIRTUAL_ROOT), + host_roots: &host_roots, + catalog: Arc::clone(&catalog), + dialect: snapshot_parser_dialect(), + entry_export: &tool.manifest.entry.export, + }, + ) + .map_err(|_| PluginError::new(PluginErrorKind::CompileFailed, tool.manifest.id.clone()))?; + let entry = compiled.entry_name.ok_or_else(|| { + PluginError::new( + PluginErrorKind::UncoveredExport, + tool.manifest.entry.export.clone(), + ) + })?; + charge_program(tool, &compiled.program)?; + Ok((entry, Arc::new(compiled.program))) +} + +/// Standalone purity proof for the leaf: compiled alone, with no caller +/// modules in the view, so a later combined run cannot gain caller host or +/// module reachability through it. Callers additionally require every program +/// import to be pure. +fn compile_leaf_package( + leaf: &InstalledPackage, +) -> Result<(String, Arc), PluginError> { + let host_roots = host_roots(); + let catalog = agent_host_catalog(); + let owned: Vec<(String, String)> = leaf + .sources + .iter() + .filter_map(|(path, bytes)| { + std::str::from_utf8(bytes) + .ok() + .map(|text| (path.clone(), text.to_string())) + }) + .collect(); + if owned.len() != leaf.sources.len() { + return Err(PluginError::new( + PluginErrorKind::CompileFailed, + leaf.manifest.id.clone(), + )); + } + let sources: Vec<(&str, &str)> = owned + .iter() + .map(|(path, text)| (path.as_str(), text.as_str())) + .collect(); + let compiled = closed_bundle::compile_bundled_closed_bundle( + &leaf.manifest.entry.path, + &sources, + ClosedBundleOptions { + limits: ClosedBundleLimits { + max_files: 64, + max_depth: 16, + max_file_bytes: 1024 * 1024, + max_total_bytes: 8 * 1024 * 1024, + }, + virtual_root: Path::new(VIRTUAL_ROOT), + host_roots: &host_roots, + catalog: Arc::clone(&catalog), + dialect: snapshot_parser_dialect(), + entry_export: &leaf.manifest.entry.export, + }, + ) + .map_err(|_| PluginError::new(PluginErrorKind::CompileFailed, leaf.manifest.id.clone()))?; + let entry = compiled.entry_name.ok_or_else(|| { + PluginError::new( + PluginErrorKind::UncoveredExport, + leaf.manifest.entry.export.clone(), + ) + })?; + Ok((entry, Arc::new(compiled.program))) +} + fn is_tool_role(package: &InstalledPackage) -> bool { package.manifest.kind == "tool" && package.manifest.contract == "agent.tool/v1" } @@ -1473,17 +2272,19 @@ mod policy_tests { root } - fn sign_install( + fn try_sign_install( root: &std::path::Path, - _id: &str, manifest: &str, files: &[(&str, &[u8])], signed: bool, - ) -> ( - PackageStore, - super::super::store::InstallAuthorization, - super::super::store::PackageRevision, - ) { + ) -> Result< + ( + PackageStore, + super::super::store::InstallAuthorization, + super::super::store::PackageRevision, + ), + PluginError, + > { let store = PackageStore::open(root.join("store")).unwrap(); let pkcs8 = ring::signature::Ed25519KeyPair::generate_pkcs8(&ring::rand::SystemRandom::new()) @@ -1513,8 +2314,22 @@ mod policy_tests { unsigned_dev_space: Some(root.to_path_buf()), } }; - let installed = store.install(&package, &auth).unwrap(); - (store, auth, installed) + let installed = store.install(&package, &auth)?; + Ok((store, auth, installed)) + } + + fn sign_install( + root: &std::path::Path, + _id: &str, + manifest: &str, + files: &[(&str, &[u8])], + signed: bool, + ) -> ( + PackageStore, + super::super::store::InstallAuthorization, + super::super::store::PackageRevision, + ) { + try_sign_install(root, manifest, files, signed).unwrap() } fn stateless_manifest(id: &str) -> String { @@ -1523,6 +2338,31 @@ mod policy_tests { ) } + /// State declaration plus an optional pure migration entry. Mirrors the + /// accepted T12 manifest shape (session scope, schema 2, migration entry + /// declared under `state` and under `effects.entries` with no effects). + fn stateful_manifest(id: &str, schema: u32, migration_effects: Option<&str>) -> String { + let migration_block = migration_effects + .map(|_| " migration:\n entry: migrate\n") + .unwrap_or_default(); + let migration_entry = migration_effects + .map(|effects| format!(" migrate:\n direct: {effects}\n delegated: []\n")) + .unwrap_or_default(); + format!( + "format: rustscript.plugin/v1\nid: {id}\nversion: 1.0.0\nkind: tool\nruntime: \">=0.1.0, <0.2.0\"\ncontract: agent.tool/v1\nhost_abi: agent.cap/v1\nentry:\n path: run.rss\n export: run\ndependencies: []\ncapabilities:\n - workspace.read\n - workspace.write\nstate:\n scope: session\n schema: {schema}\n{migration_block}effects:\n contract: agent.effects/v1\n entries:\n run:\n direct:\n - kind: workspace.fs.read\n scope: bound_workspace\n recovery: none\n - kind: workspace.fs.write\n scope: bound_workspace\n recovery: none\n delegated: []\n{migration_entry}" + ) + } + + const PURE_MIGRATION_SOURCE: &str = r#" +use json; +pub fn migrate(input: map) -> map { + let document: string = input["document"]; + let mut decoded: map = json::decode(document); + decoded.version = 2; + { schema: 2, document: json::encode(decoded) } +} +"#; + fn resolve_tool(store: &PackageStore, id: &str) -> super::super::resolve::GraphLock { store .resolve(&super::super::resolve::ResolveRequest { @@ -1606,7 +2446,7 @@ mod policy_tests { } #[test] - fn unsigned_stateful_library_and_edges_do_not_capture_catalog() { + fn unsigned_library_and_unsupported_edges_do_not_capture_catalog() { let catalog = include_bytes!("../../rss/tools/types.rss"); let run = b"pub fn run(context: map) -> map { context }\n"; let root = open_root("signed-ineligible"); @@ -1626,28 +2466,6 @@ mod policy_tests { Some(PackageTrust::EmbeddingOwnedBuiltin) ); - let stateful_id = "org.rustscript.tools.statefulcatalog"; - let stateful_root = open_root("signed-stateful"); - let (store, _, _) = sign_install( - &stateful_root, - stateful_id, - &format!( - "{}state:\n scope: run\n schema: 1\n", - stateless_manifest(stateful_id) - ), - &[("run.rss", run), ("catalog.rss", catalog)], - true, - ); - let prepared = store.prepare(&resolve_tool(&store, stateful_id)).unwrap(); - assert!(prepared.managed_policy_catalog(stateful_id).is_none()); - assert!( - prepared - .managed_contract(stateful_id) - .unwrap() - .topology - .is_none() - ); - let library_id = "org.rustscript.lib.cataloglib"; let library_root = open_root("signed-library"); let library_manifest = format!( @@ -1732,11 +2550,98 @@ mod policy_tests { Some(PackageTrust::AcceptedPublisher) ); let _ = std::fs::remove_dir_all(&root); - let _ = std::fs::remove_dir_all(&stateful_root); let _ = std::fs::remove_dir_all(&library_root); let _ = std::fs::remove_dir_all(&edge_root); } + #[test] + fn stateful_tool_only_captures_catalog_and_topology() { + let root = open_root("signed-stateful-catalog"); + let id = "org.rustscript.tools.statefulcatalog"; + let (store, _, installed) = sign_install( + &root, + id, + &stateful_manifest(id, 1, None), + &[ + ("run.rss", b"pub fn run(context: map) -> map { context }\n"), + ("catalog.rss", include_bytes!("../../rss/tools/types.rss")), + ], + true, + ); + assert_eq!(installed.trust, PackageTrust::AcceptedPublisher); + let prepared = store.prepare(&resolve_tool(&store, id)).unwrap(); + assert!( + prepared.managed_policy_catalog(id).is_some(), + "stateful single-package ToolOnly must capture its same-package catalog" + ); + assert!( + prepared.managed_contract(id).unwrap().topology.is_some(), + "stateful single-package ToolOnly must keep the supported topology" + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn stateful_pure_migration_entry_captures_catalog_and_topology() { + let root = open_root("signed-stateful-migration"); + let id = "org.rustscript.tools.statefulmigration"; + let (store, _, _) = sign_install( + &root, + id, + &stateful_manifest(id, 2, Some("[]")), + &[ + ("run.rss", b"pub fn run(context: map) -> map { context }\n"), + ("migrate.rss", PURE_MIGRATION_SOURCE.as_bytes()), + ("catalog.rss", include_bytes!("../../rss/tools/types.rss")), + ], + true, + ); + let prepared = store.prepare(&resolve_tool(&store, id)).unwrap(); + assert!( + prepared.managed_policy_catalog(id).is_some(), + "pure same-package migration must not suppress the tool catalog" + ); + assert!( + prepared.managed_contract(id).unwrap().topology.is_some(), + "pure same-package migration must keep the supported ToolOnly topology" + ); + assert_eq!( + prepared.state_migration(id).unwrap().target_schema, + 2, + "the pure migration entry must be compiled, not dropped" + ); + let _ = std::fs::remove_dir_all(&root); + } + + #[test] + fn effectful_migration_entry_is_rejected_exactly_before_install() { + let root = open_root("signed-stateful-effect"); + let id = "org.rustscript.tools.statefuleffect"; + let error = match try_sign_install( + &root, + &stateful_manifest( + id, + 2, + Some("[{kind: workspace.fs.write, scope: bound_workspace, recovery: none}]"), + ), + &[ + ("run.rss", b"pub fn run(context: map) -> map { context }\n"), + ("migrate.rss", PURE_MIGRATION_SOURCE.as_bytes()), + ], + true, + ) { + Ok(_) => panic!("an effectful migration entry must not install"), + Err(error) => error, + }; + assert_eq!(error.kind, PluginErrorKind::UncoveredExport); + assert_eq!( + error.to_string(), + "UncoveredExport: state migration must be pure", + "the manifest contract rejects the effectful migration entry explicitly" + ); + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn signed_context_prompt_does_not_validate_as_managed_topology() { let root = open_root("signed-context"); @@ -1842,4 +2747,132 @@ mod policy_tests { assert!(store.prepare_harness_graph(&lock, &expired).is_err()); let _ = std::fs::remove_dir_all(&root); } + + fn narrow_pair_manifests( + tool_id: &str, + leaf_id: &str, + ) -> (String, String, &'static str, &'static str) { + let tool = format!( + "format: rustscript.plugin/v1\nid: {tool_id}\nversion: 1.0.0\nkind: tool\nruntime: \">=0.1.0, <0.2.0\"\ncontract: agent.tool/v1\nhost_abi: agent.cap/v1\nentry:\n path: run.rss\n export: run\ndependencies:\n - alias: text\n id: {leaf_id}\n version: \"1.0.0\"\ncapabilities:\n - workspace.read\neffects:\n contract: agent.effects/v1\n entries:\n run:\n direct:\n - kind: workspace.fs.read\n scope: bound_workspace\n recovery: none\n delegated: []\n" + ); + let leaf = format!( + "format: rustscript.plugin/v1\nid: {leaf_id}\nversion: 1.0.0\nkind: library\nruntime: \">=0.1.0, <0.2.0\"\ncontract: agent.library/v1\nhost_abi: agent.cap/v1\nentry:\n path: format.rss\n export: format\ndependencies: []\ncapabilities: []\neffects:\n contract: agent.effects/v1\n entries:\n format:\n direct: []\n delegated: []\n" + ); + ( + tool, + leaf, + "pub fn format(value: string) -> string { \"libA:\" + value }\n", + "use bytes;\nuse text::{format};\n\npub fn run(token: string, path: string) -> map {\n let read: map = cap::fs_read_range(token, path, 0, 8);\n let payload: bytes = read.bytes;\n { ok: true, content: format(bytes::to_utf8(payload)) }\n}\n", + ) + } + + fn install_narrow_pair( + root: &std::path::Path, + tool_id: &str, + leaf_id: &str, + ) -> super::super::store::PackageStore { + let (tool_manifest, leaf_manifest, leaf_source, tool_source) = + narrow_pair_manifests(tool_id, leaf_id); + let store = super::super::store::PackageStore::open(root.join("store")).unwrap(); + let pkcs8 = + ring::signature::Ed25519KeyPair::generate_pkcs8(&ring::rand::SystemRandom::new()) + .unwrap(); + let pair = ring::signature::Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).unwrap(); + let auth = super::super::store::InstallAuthorization { + admin: true, + accepted_publishers: vec![super::super::store::PublisherKey { + publisher: "org.rustscript".into(), + verifying_key: pair.public_key().as_ref().to_vec(), + }], + unsigned_dev_space: None, + }; + for (dir, manifest, files) in [ + ( + "leaf", + leaf_manifest.as_str(), + vec![("format.rss", leaf_source)], + ), + ( + "tool", + tool_manifest.as_str(), + vec![("run.rss", tool_source)], + ), + ] { + let package = root.join(dir); + std::fs::create_dir_all(&package).unwrap(); + std::fs::write(package.join("manifest.yaml"), manifest).unwrap(); + for (name, body) in files { + std::fs::write(package.join(name), body).unwrap(); + } + std::fs::write( + package.join("signature.ed25519"), + pair.sign(&store.signature_payload(&package).unwrap()) + .as_ref(), + ) + .unwrap(); + store.install(&package, &auth).unwrap(); + } + store + } + + #[test] + fn tool_library_topology_accepts_only_the_narrow_shape() { + let root = open_root("narrow-topology"); + let tool_id = "org.rustscript.tools.narrowread"; + let leaf_id = "org.rustscript.lib.narrowtext"; + let store = install_narrow_pair(&root, tool_id, leaf_id); + let lock = resolve_tool(&store, tool_id); + assert_eq!(lock.packages.len(), 2); + assert_eq!(lock.edges.len(), 1); + let installed = store + .verified_capture(&[( + tool_id, + lock.packages + .iter() + .find(|package| package.id == tool_id) + .unwrap() + .artifact_id + .as_str(), + )]) + .unwrap(); + let tool = installed + .iter() + .find(|package| package.manifest.id == tool_id) + .unwrap(); + assert!(SupportedManagedTopology::validate(&lock, tool).is_some()); + assert!(store.prepare(&lock).unwrap().program(tool_id).is_some()); + + let mut plugin_edge = lock.clone(); + plugin_edge.edges[0].relation = "plugin".into(); + assert!(SupportedManagedTopology::validate(&plugin_edge, tool).is_none()); + + let mut unselected = lock.clone(); + unselected.harness_selection = super::super::resolve::HarnessSelection::Unselected; + assert!(SupportedManagedTopology::validate(&unselected, tool).is_none()); + + let mut write_cap = tool.clone(); + write_cap + .manifest + .capabilities + .push("workspace.write".into()); + assert!(SupportedManagedTopology::validate(&lock, &write_cap).is_none()); + + let leaf = store + .verified_capture(&[( + leaf_id, + lock.packages + .iter() + .find(|package| package.id == leaf_id) + .unwrap() + .artifact_id + .as_str(), + )]) + .unwrap(); + let leaf = leaf + .iter() + .find(|package| package.manifest.id == leaf_id) + .unwrap(); + assert!(SupportedManagedTopology::validate(&lock, leaf).is_none()); + let _ = std::fs::remove_dir_all(&root); + } } diff --git a/src/plugins/schema.rs b/src/plugins/schema.rs index 37aed25..fe42618 100644 --- a/src/plugins/schema.rs +++ b/src/plugins/schema.rs @@ -7,6 +7,7 @@ use std::collections::BTreeMap; use std::fs; use std::path::Path; +use std::sync::OnceLock; use serde_json::{Map, Value}; @@ -112,8 +113,8 @@ pub(crate) fn validate_managed_maintenance( definition: &str, value: &Value, ) -> Result<(), PluginError> { - let schemas = bundled_values()?; - let mut selected = schemas.get(name).cloned().ok_or_else(|| { + let prepared = prepared_schemas()?; + let mut selected = prepared.values.get(name).cloned().ok_or_else(|| { PluginError::new( PluginErrorKind::ContractIncompatible, "managed schema unavailable", @@ -130,9 +131,8 @@ pub(crate) fn validate_managed_maintenance( )); } selected["$ref"] = Value::String(format!("#/$defs/{definition}")); - let registry = registry(&schemas)?; let validator = jsonschema::options() - .with_registry(®istry) + .with_registry(&prepared.registry) .build(&selected) .map_err(|_| { PluginError::new( @@ -149,9 +149,9 @@ pub(crate) fn validate_managed_maintenance( } fn validate_named(name: &str, value: &Value) -> Result<(), PluginError> { - let schemas = bundled_values()?; - let registry = registry(&schemas)?; - let selected = schemas + let prepared = prepared_schemas()?; + let selected = prepared + .values .get(name) .ok_or_else(|| PluginError::new(PluginErrorKind::ContractIncompatible, name))?; let mut selected = selected.clone(); @@ -170,7 +170,7 @@ fn validate_named(name: &str, value: &Value) -> Result<(), PluginError> { ); } let validator = jsonschema::options() - .with_registry(®istry) + .with_registry(&prepared.registry) .build(&selected) .map_err(|err| PluginError::new(PluginErrorKind::ContractIncompatible, err.to_string()))?; let errors: Vec<_> = validator.iter_errors(value).collect(); @@ -187,6 +187,28 @@ fn validate_named(name: &str, value: &Value) -> Result<(), PluginError> { )) } +/// Parsed bundled schema documents and their prepared registry. Both are a pure +/// function of `include_str!` inputs compiled into this binary, so the pair is +/// prepared once and shared read-only; every validation still parses the actual +/// incoming document and runs its own selected schema. +struct PreparedSchemas { + values: BTreeMap<&'static str, Value>, + registry: jsonschema::Registry<'static>, +} + +static PREPARED_SCHEMAS: OnceLock> = OnceLock::new(); + +fn prepared_schemas() -> Result<&'static PreparedSchemas, PluginError> { + PREPARED_SCHEMAS + .get_or_init(|| { + let values = bundled_values()?; + let registry = registry(&values)?; + Ok(PreparedSchemas { values, registry }) + }) + .as_ref() + .map_err(|error| error.clone()) +} + fn bundled_values() -> Result, PluginError> { let mut values = BTreeMap::new(); for (name, text) in SCHEMA_SOURCES { @@ -308,8 +330,10 @@ fn sort_value(value: &Value) -> Value { #[cfg(test)] mod tests { - use super::super::error::{EFFECT_KINDS, capability_for, recovery_allowed, scope_for}; - use super::validate_manifest; + use super::super::error::{ + EFFECT_KINDS, PluginErrorKind, capability_for, recovery_allowed, scope_for, + }; + use super::{prepared_schemas, validate_lock, validate_managed_maintenance, validate_manifest}; use serde_json::json; fn sample(kind: &str, scope: &str, recovery: &str) -> serde_json::Value { @@ -360,4 +384,100 @@ mod tests { ); } } + + #[test] + fn bundled_schema_material_is_prepared_once_and_isolated_from_managed_selection() { + let first = prepared_schemas().expect("bundled schema values and registry"); + let second = prepared_schemas().expect("bundled schema values and registry"); + assert!( + std::ptr::eq(first, second), + "immutable bundled schema material must be shared, not rebuilt" + ); + let effect_before = first.values["managed-effect.schema.yaml"].clone(); + let manifest_before = first.values["plugin-manifest.schema.yaml"].clone(); + + // The managed path removes `oneOf` and rewrites `$ref` on a selected + // copy; the shared documents must stay untouched across interleavings. + let unknown_name = + validate_managed_maintenance("missing.schema.yaml", "Record", &json!({})) + .expect_err("unknown managed schema name"); + assert_eq!(unknown_name.kind, PluginErrorKind::ContractIncompatible); + assert_eq!( + unknown_name.chain, + vec!["managed schema unavailable".to_string()] + ); + let unknown_body = validate_managed_maintenance( + "managed-checkpoint.schema.yaml", + "UnknownDefinition", + &json!({}), + ) + .expect_err("unknown named body"); + assert_eq!(unknown_body.kind, PluginErrorKind::ContractIncompatible); + assert_eq!( + unknown_body.chain, + vec!["managed named body unavailable".to_string()] + ); + let rejected = validate_managed_maintenance( + "managed-checkpoint.schema.yaml", + "ManagedCheckpointCreateRequest", + &json!({"unexpected_authority": true}), + ) + .expect_err("invalid managed body"); + assert_eq!(rejected.kind, PluginErrorKind::ContractIncompatible); + assert_eq!(rejected.chain, vec!["managed schema rejected".to_string()]); + + assert_eq!(first.values["managed-effect.schema.yaml"], effect_before); + assert_eq!(first.values["plugin-manifest.schema.yaml"], manifest_before); + let effect_now = prepared_schemas().expect("bundled schema values and registry"); + assert!(std::ptr::eq(first, effect_now)); + assert!( + effect_now.values["managed-effect.schema.yaml"] + .get("oneOf") + .is_some(), + "managed selection must not remove oneOf from the shared document" + ); + + // Ordinary validation still runs its own selected schema on the shared + // registry after the managed interleaving. + validate_manifest(&sample("workspace.fs.read", "bound_workspace", "none")) + .expect("valid manifest after managed selection"); + + let mut escape = sample("workspace.fs.read", "bound_workspace", "none"); + escape["entry"]["path"] = json!("../escape.rss"); + assert_eq!( + validate_manifest(&escape).expect_err("path escape").kind, + PluginErrorKind::PathEscape + ); + let mut forged_recovery = sample("workspace.fs.read", "bound_workspace", "none"); + forged_recovery["effects"]["entries"]["invoke"]["direct"][0]["recovery"] = json!("replay"); + assert_eq!( + validate_manifest(&forged_recovery) + .expect_err("forged recovery") + .kind, + PluginErrorKind::ForgedRecovery + ); + let mut unknown_effect = sample("workspace.fs.read", "bound_workspace", "none"); + unknown_effect["effects"]["entries"]["invoke"]["direct"][0]["kind"] = + json!("workspace.exec"); + assert_eq!( + validate_manifest(&unknown_effect) + .expect_err("unknown effect") + .kind, + PluginErrorKind::UnknownEffect + ); + let mut missing_effects = sample("workspace.fs.read", "bound_workspace", "none"); + missing_effects + .as_object_mut() + .expect("manifest object") + .remove("effects"); + assert_eq!( + validate_manifest(&missing_effects) + .expect_err("missing effects") + .kind, + PluginErrorKind::MissingEffects + ); + let lock_error = validate_lock(&json!({})).expect_err("empty lock rejected"); + assert_eq!(lock_error.kind, PluginErrorKind::UnknownField); + assert!(lock_error.chain[0].starts_with("plugin-graph-lock.schema.yaml ")); + } } diff --git a/src/runtime/agent_host.rs b/src/runtime/agent_host.rs index 0822e37..6f07d66 100644 --- a/src/runtime/agent_host.rs +++ b/src/runtime/agent_host.rs @@ -598,6 +598,7 @@ pub type AuthHostFactory = pub struct PinnedToolProgram { pub(crate) harness_selection: crate::plugins::HarnessSelection, pub(crate) managed: Option>, + execution_authority: NativeExecutionAuthority, managed_contract: Option, managed_policy_catalog: Option>, pub(crate) state: Option, @@ -648,9 +649,36 @@ pub(crate) struct PinnedToolReceipt { pub(crate) workspace: std::path::PathBuf, } +/// Sealed Native execution authority. A persistent pin retains the original +/// control-store owner object; it is never rebuilt from caller JSON, a fresh +/// open of the same path, or a compatibility bypass. The explicit stateless +/// shape exists only for the closed embedded dispatcher factory. +#[derive(Clone)] +pub(crate) enum NativeExecutionAuthority { + Persistent { + persistence: Arc, + }, + ExplicitStateless, +} + +impl NativeExecutionAuthority { + fn same_owner(&self, other: &Self) -> bool { + match (self, other) { + (Self::Persistent { persistence: left }, Self::Persistent { persistence: right }) => { + Arc::ptr_eq(left, right) + } + (Self::ExplicitStateless, Self::ExplicitStateless) => true, + _ => false, + } + } +} + impl PinnedToolProgram { pub(crate) fn same_native_binding(&self, other: &Self) -> bool { Arc::ptr_eq(&self.program, &other.program) + && self + .execution_authority + .same_owner(&other.execution_authority) && self.entry == other.entry && self.graph_id == other.graph_id && self.ref_id == other.ref_id @@ -721,9 +749,14 @@ impl PinnedToolProgram { Ok(()) } - pub(crate) fn seal(code: PinnedToolCode, receipt: PinnedToolReceipt) -> Self { + pub(crate) fn seal( + code: PinnedToolCode, + receipt: PinnedToolReceipt, + execution_authority: NativeExecutionAuthority, + ) -> Self { Self { managed: None, + execution_authority, harness_selection: code.harness_selection, managed_contract: code.managed_contract, managed_policy_catalog: code.managed_policy_catalog, @@ -748,6 +781,36 @@ impl PinnedToolProgram { } } + /// Fresh execution authorization against the original control-store owner. + /// Every persistent execution re-reads the exact sealed graph revocation + /// under the run cancellation; a tombstone refuses with the revocation + /// code, while a missing authority, a cancelled/expired wait, or any typed + /// query/decode failure refuses closed. Positive results are never cached. + /// The explicit stateless shape needs no control-store owner. + /// The original cancellation reason is checked at entry and again at the + /// read return boundary, before the revocation verdict is accepted: an + /// original requested/expired root keeps its existing classification and + /// takes precedence over a revoked or failed query. + pub(crate) fn check_execution_authority( + &self, + cancel: Option<&RunCancellation>, + ) -> Result<(), &'static str> { + let persistence = match &self.execution_authority { + NativeExecutionAuthority::Persistent { persistence } => persistence, + NativeExecutionAuthority::ExplicitStateless => return Ok(()), + }; + let cancel = cancel.ok_or("plugin_graph_pin_required")?; + cancel.ensure_open()?; + let query = crate::plugins::PluginControlStore::new(persistence) + .graph_revocation_for_execution(&self.graph_id, cancel); + cancel.ensure_open()?; + let revoked = query.map_err(|_| "plugin_graph_pin_required")?; + if revoked.is_some() { + return Err("plugin_graph_revoked"); + } + Ok(()) + } + #[cfg(test)] pub(crate) fn with_native_code_identity_for_test( &self, @@ -1559,6 +1622,15 @@ impl AgentHostState { if expected.as_ref() != Some(&arguments) { return pinned_call_mismatch(); } + // Fresh execution authorization from the original control-store owner, + // after the sealed owner/run/registry/parent checks and before any + // replay or child dispatch. The envelope carries the exact typed code + // in both fields so outer layers that only propagate messages keep + // the exact refusal observable. Historical receipts stay queryable; + // only execution is refused here. + if let Err(code) = pinned.check_execution_authority(Some(&self.cancellation)) { + return pinned_outcome(false, "", "", code, code, ""); + } if let Some(binding) = &pinned.managed { match binding.replay_live(&call_id, &tool_name, Some(&arguments), Some(&canonical)) { Ok(Some(replay)) => { @@ -1572,7 +1644,27 @@ impl AgentHostState { } Ok(None) => {} Err(message) => { - return pinned_outcome(false, "", "", "managed_effect_denied", &message, ""); + // The execution guard speaks the exact typed codes; every + // other replay failure keeps the existing managed envelope. + // The code rides in both fields so message-only outer + // layers keep the exact refusal observable. + let code = match message.as_str() { + "plugin_graph_revoked" => "plugin_graph_revoked", + "plugin_graph_pin_required" => "plugin_graph_pin_required", + "cancelled" => "cancelled", + "deadline_elapsed" => "deadline_elapsed", + _ => { + return pinned_outcome( + false, + "", + "", + "managed_effect_denied", + &message, + "", + ); + } + }; + return pinned_outcome(false, "", "", code, code, ""); } } } diff --git a/src/runtime/native_tools.rs b/src/runtime/native_tools.rs index cbaf944..7a5bd18 100644 --- a/src/runtime/native_tools.rs +++ b/src/runtime/native_tools.rs @@ -96,6 +96,7 @@ impl NativeToolRuntime { registry_identity: registry_identity.to_string(), workspace, }, + super::agent_host::NativeExecutionAuthority::ExplicitStateless, ) } } @@ -114,6 +115,23 @@ const EMBEDDING_ENTRY: &str = "rss/tools/dispatch.rss"; pub(crate) fn embedding_package_files() -> Result>, String> { + shared_embedding_package_files().cloned() +} + +/// The embedded catalog is a pure function of `bundled_rss_sources`, the fixed +/// host catalog and the fixed entry, so it is built once and shared. Callers +/// receive an owned copy and may mutate it freely. +fn shared_embedding_package_files() +-> Result<&'static std::collections::BTreeMap>, String> { + static FILES: OnceLock>, String>> = + OnceLock::new(); + FILES + .get_or_init(build_embedding_package_files) + .as_ref() + .map_err(String::clone) +} + +fn build_embedding_package_files() -> Result>, String> { let catalog = super::agent_host::agent_host_catalog(); let mut host_roots = BTreeSet::new(); host_roots.insert("json".to_string()); @@ -148,9 +166,9 @@ pub(crate) fn embedding_package_files() } pub(crate) fn embedding_payload_matches(payload: &[u8]) -> bool { - embedding_package_files() + shared_embedding_package_files() .ok() - .is_some_and(|files| crate::plugins::embedding_payload_bytes(&files) == payload) + .is_some_and(|files| crate::plugins::embedding_payload_bytes(files) == payload) } fn embedding_manifest() -> String { @@ -192,3 +210,52 @@ effects: "# ) } + +#[cfg(test)] +mod tests { + use super::{ + EMBEDDING_ENTRY, embedding_package_files, embedding_payload_matches, + shared_embedding_package_files, + }; + + #[test] + fn embedded_catalog_material_is_built_once_and_shared() { + let first = shared_embedding_package_files().expect("embedded catalog"); + let second = shared_embedding_package_files().expect("embedded catalog"); + assert!( + std::ptr::eq(first, second), + "immutable embedded catalog must be shared, not rebuilt" + ); + assert!(first.contains_key("manifest.yaml")); + assert!(first.contains_key(EMBEDDING_ENTRY)); + assert_eq!(first.len(), second.len()); + } + + #[test] + fn embedded_catalog_copies_isolate_mutation_and_match_real_payloads() { + let original = embedding_package_files().expect("embedded catalog"); + let expected = crate::plugins::embedding_payload_bytes(&original); + assert!(embedding_payload_matches(&expected)); + + // The returned map is an owned copy: mutating it must not touch the + // shared immutable material or a later payload comparison. + let mut mutated = embedding_package_files().expect("embedded catalog"); + mutated.insert("manifest.yaml".into(), b"tampered".to_vec()); + mutated.insert("rss/tools/extra.rss".into(), b"extra".to_vec()); + let mutated_payload = crate::plugins::embedding_payload_bytes(&mutated); + assert_ne!(mutated_payload, expected); + assert!(!embedding_payload_matches(&mutated_payload)); + + let again = embedding_package_files().expect("embedded catalog"); + assert_eq!(again, original); + assert_eq!( + again.get("manifest.yaml").map(Vec::as_slice), + original.get("manifest.yaml").map(Vec::as_slice) + ); + assert!(embedding_payload_matches(&expected)); + + let mut shortened = expected.clone(); + shortened.truncate(shortened.len() - 1); + assert!(!embedding_payload_matches(&shortened)); + } +} diff --git a/src/service.rs b/src/service.rs index 93cf011..87691c7 100644 --- a/src/service.rs +++ b/src/service.rs @@ -2107,11 +2107,53 @@ impl AgentService { AdmitError::PluginGraphPinRequired } - fn restore_pinned_tool( - &self, - run_id: &str, - ) -> Result, AdmitError> { - self.restore_pinned_tool_inner(run_id, None) + /// Receipt-only history replay gate: strict read-only owned identity + /// validation WITHOUT constructing an executable pin, WITHOUT checking + /// revocation, and WITHOUT refreshing execution authority. Revoked + /// history stays replayable here; execution restore still refuses. + fn validate_owned_admission_receipt(&self, run_id: &str) -> Result<(), AdmitError> { + if let Some(persistence) = self.inner.persistence.as_ref() { + return crate::plugins::validate_owned_admission_receipt(persistence, run_id) + .map_err(|error| AdmitError::Persistence(error.to_string())); + } + // Explicit-stateless history: no durable owner exists, so the proof is + // the ORIGINAL retained handle with its positively sealed + // ExplicitStateless disposition. No persistence None=>Ok, no global + // flag, no new executable pin, and no store lock here (a caller may + // already hold it). Missing handle/proof fails closed. + let handle = self + .handle(run_id) + .ok_or(AdmitError::PluginGraphPinRequired)?; + let actual = handle + .pinned_tool + .as_ref() + .ok_or(AdmitError::PluginGraphPinRequired)?; + let disposition = handle + .native_disposition + .as_ref() + .ok_or(AdmitError::PluginGraphPinRequired)?; + if !matches!(disposition, NativeRuntimeDisposition::ExplicitStateless(_)) { + return Err(AdmitError::PluginGraphPinRequired); + } + let expected = disposition.pin(); + if !actual.same_native_binding(expected) { + return Err(AdmitError::PluginGraphPinRequired); + } + if actual.harness_selection != expected.harness_selection { + return Err(AdmitError::PluginGraphPinRequired); + } + if actual.managed.is_some() + || !matches!( + actual.harness_selection, + crate::plugins::HarnessSelection::ToolOnly { .. } + ) + { + return Err(AdmitError::PluginGraphPinRequired); + } + if actual.run_id != run_id { + return Err(AdmitError::PluginGraphPinRequired); + } + Ok(()) } fn restore_pinned_tool_inner( &self, @@ -6588,7 +6630,7 @@ impl AgentService { }) }; if let Some((sid, status, replayed_run_id)) = published { - self.restore_pinned_tool(&replayed_run_id)?; + self.validate_owned_admission_receipt(&replayed_run_id)?; return Ok(AdmittedRun { run_id: replayed_run_id, session_id: sid, @@ -6603,7 +6645,7 @@ impl AgentService { if cur_run_exists { let store = self.inner.store.read(); if let Some(existing) = store.runs.get(&run_id) { - self.restore_pinned_tool(&run_id)?; + self.validate_owned_admission_receipt(&run_id)?; return Ok(AdmittedRun { run_id: run_id.clone(), session_id: existing.session_id.clone(), @@ -6865,15 +6907,21 @@ impl AgentService { let prepared = prepared_pin.take().ok_or_else(|| { AdmitError::Persistence("prepared tool pin was dropped".to_string()) })?; + let persistence = service.inner.persistence.as_ref().ok_or_else(|| { + AdmitError::Persistence( + "original execution authority is missing".to_string(), + ) + })?; admitted_pin = Some( crate::plugins::seal_production_receipt( prepared, &data, snapshot.registry.identity().to_string(), pinned_workspace.clone(), - service.inner.persistence.as_ref().map(|persistence| { - crate::plugins::PluginStateStore::open(Arc::clone(persistence)) - }), + Some(crate::plugins::PluginStateStore::open(Arc::clone( + persistence, + ))), + persistence, ) .map_err(|error| AdmitError::Persistence(error.to_string()))?, ); @@ -6896,7 +6944,7 @@ impl AgentService { if store.runs.contains_key(&run_id) && let Some(existing) = store.runs.get(&run_id) { - service.restore_pinned_tool(&run_id)?; + service.validate_owned_admission_receipt(&run_id)?; return Ok(AdmittedRun { run_id: run_id.clone(), session_id: existing.session_id.clone(), @@ -6929,7 +6977,7 @@ impl AgentService { ) }); if let Some((sid, status, replayed_run_id)) = published { - service.restore_pinned_tool(&replayed_run_id)?; + service.validate_owned_admission_receipt(&replayed_run_id)?; return Ok(AdmittedRun { run_id: replayed_run_id, session_id: sid, @@ -7184,7 +7232,7 @@ impl AgentService { .admission_rejected(AdmitRejectReason::IdempotencyConflict); return Err(AdmitError::IdempotencyConflict); } - self.restore_pinned_tool(&existing.run_id)?; + self.validate_owned_admission_receipt(&existing.run_id)?; let store = self.inner.store.write(); let Some(current) = store.idempotency.get(key) else { return Ok(None); @@ -7467,7 +7515,7 @@ impl AgentService { .admission_rejected(AdmitRejectReason::IdempotencyConflict); return Err(AdmitError::IdempotencyConflict); } - self.restore_pinned_tool(&existing.run_id)?; + self.validate_owned_admission_receipt(&existing.run_id)?; let store = self.inner.store.read(); let run = store.runs.get(&existing.run_id); Ok(AdmittedRun { @@ -7733,7 +7781,7 @@ impl AgentService { let replayed_status = admission_run_str(&run_row, ADMISSION_RUN_COL_STATUS) .unwrap_or("unknown") .to_string(); - self.restore_pinned_tool(&replayed_run_id)?; + self.validate_owned_admission_receipt(&replayed_run_id)?; let published = { let store = self.inner.store.write(); store.runs.get(&replayed_run_id).map(|run| AdmittedRun { @@ -9997,7 +10045,19 @@ impl DurableToolLifecycle for ServiceDurableLifecycle { return binding .replay_live(call_id, tool_name, arguments.as_ref(), None) .map(|replay| replay.map(|replay| replay.result)) - .map_err(LifecycleError::ResultCommitFailed); + .map_err(|message: String| { + if message == "plugin_graph_revoked" { + LifecycleError::PluginGraphRevoked + } else if message == "plugin_graph_pin_required" { + LifecycleError::PluginGraphPinRequired + } else if message == "cancelled" { + LifecycleError::Cancelled + } else if message == "deadline_elapsed" { + LifecycleError::DeadlineElapsed + } else { + LifecycleError::ResultCommitFailed(message) + } + }); } match self.events.replay_durable_tool_result(call_id, tool_name) { Ok(Some(result)) => Ok(Some( @@ -12780,43 +12840,45 @@ mod f3_session_maintenance_tests { #[tokio::test(flavor = "multi_thread")] async fn persisted_context_refreshes_reuse_one_compaction_runner() { const SESSION_ID: &str = "compaction-cache-session"; - const RUN_ID: &str = "compaction-cache-run"; let root = private_root(); let db_path = root.join("gateway.db"); - let state = AgentGatewayState::with_sqlite_path(AgentGatewayConfig::default(), &db_path) + let setup = AgentGatewayState::with_sqlite_path(AgentGatewayConfig::default(), &db_path) .expect("open private persisted compaction fixture"); - let persistence = state.persistence().expect("private persistence"); + let persistence = setup.persistence().expect("private persistence"); seed_session(&persistence, SESSION_ID, 10); + // Reload so the seeded session is visible to in-memory admission; + // direct persistence seeds bypass the live store. + drop(persistence); + drop(setup); + let state = AgentGatewayState::with_sqlite_path(AgentGatewayConfig::default(), &db_path) + .expect("reload private persisted compaction fixture"); + let persistence = state.persistence().expect("private persistence"); let service = state.service(); - service - .inner - .contexts - .lock() - .expect("contexts lock") - .insert( - RUN_ID.to_string(), - crate::domain::RunContext { - run_id: RUN_ID.to_string(), - session_id: SESSION_ID.to_string(), - parent_run_id: None, - platform: "compaction-cache-test".to_string(), - input: json!({}), - messages: json!([]), - system_prompt: None, - model: "test-model".to_string(), - provider: Some("test-provider".to_string()), - provider_options: json!({}), - tool_schemas: json!([]), - limits: json!({}), - metadata: json!({}), - coding_system_prompt: None, - }, - ); + // Real admission: the refresh gate requires a live run handle with + // the original admission deadline. A hand-seeded session/context + // alone is rejected with plugin_graph_pin_required before any + // provider work, so the positive path must admit first. + let admitted = service + .admit(AdmitRunRequest { + input: json!({"prompt": "compaction cache refresh"}), + session_id: Some(SESSION_ID.to_string()), + platform: "compaction-cache-test".to_string(), + ..AdmitRunRequest::default() + }) + .await + .expect("admit real compaction refresh run"); + let admitted_cancel = service + .handle(&admitted.run_id) + .expect("admitted run handle") + .cancel + .clone(); let context = json!({ "session_id": SESSION_ID, "messages": [] }); - let first = - service.refresh_provider_context(RUN_ID, &context, &super::RunCancellation::new()); + // The admission input is a real durable session fact, so the first + // refresh observes 40 seeded facts plus the admission message; the + // appended durable fact then moves the second refresh by exactly one. + let first = service.refresh_provider_context(&admitted.run_id, &context, &admitted_cancel); assert_eq!( first["ok"], json!(true), @@ -12824,7 +12886,7 @@ mod f3_session_maintenance_tests { ); assert_eq!( first["messages"].as_array().expect("first messages").len(), - 40 + 41 ); persistence @@ -12843,8 +12905,7 @@ mod f3_session_maintenance_tests { "now_ms": 51, })) .expect("append current durable history through persistence API"); - let second = - service.refresh_provider_context(RUN_ID, &context, &super::RunCancellation::new()); + let second = service.refresh_provider_context(&admitted.run_id, &context, &admitted_cancel); assert_eq!( second["ok"], json!(true), @@ -12855,7 +12916,7 @@ mod f3_session_maintenance_tests { .as_array() .expect("second messages") .len(), - 41, + 42, "refresh must reload current persisted history" ); let constructions = service @@ -13203,6 +13264,16 @@ mod f3_session_maintenance_tests { }) .await .expect("admit real failure context"); + // Positive paths drive the admitted run's own cancellation root: the + // strict native guard rejects deadline-less callers before any + // provider work. Explicit requested/elapsed negative controls below + // keep their own synthetic cancels. + let admitted_cancel = f + .service + .handle(&admitted.run_id) + .expect("admitted run handle") + .cancel + .clone(); let raw = crate::runtime::agent_host::ScriptedProvider::new(); let host = crate::durable_provider::DurableProviderHost::new( super::AgentService::clone(f.service.as_ref()), @@ -13212,26 +13283,88 @@ mod f3_session_maintenance_tests { ); let context = json!({"session_id":"cache-coverage-a", "messages":[]}); assert_eq!( - host.refresh_context(&context, &crate::RunCancellation::new()) - .unwrap()["ok"], + host.refresh_context(&context, &admitted_cancel).unwrap()["ok"], true ); coverage_count(&f.service); f.service .inject_compaction_policy_failure("CACHE_COVERAGE_POLICY_FAILURE"); - let policy = host - .refresh_context(&context, &crate::RunCancellation::new()) - .unwrap(); + let policy = host.refresh_context(&context, &admitted_cancel).unwrap(); assert_eq!(policy["ok"], false); assert_eq!(policy["error"]["code"], "context_refresh_failed"); + // Strict-guard precedence: the native admission guard checks the + // caller deadline before the requested flag, so these deadline-less + // synthetic cancels (requested or elapsed) refuse with + // deadline_elapsed rather than the legacy cancelled mapping. The + // refusal itself is the negative control. let cancelled = crate::RunCancellation::new(); cancelled.request(super::CancellationReason::Requested); let expired = crate::RunCancellation::with_deadline(Instant::now()); for cancellation in [&cancelled, &expired] { let failure = host.refresh_context(&context, cancellation).unwrap(); assert_eq!(failure["ok"], false); - assert_eq!(failure["error"]["code"], "cancelled"); + assert_eq!(failure["error"]["code"], "deadline_elapsed"); } + // Independent finite, non-elapsed admitted cancel: the requested + // branch must return `cancelled` from the native admission guard. + // A separate admitted run leaves the reload positive control on its + // own untouched cancellation root. + let requested_run = f + .service + .admit(AdmitRunRequest { + input: json!({"prompt":"requested cancellation"}), + session_id: Some("cache-coverage-b".to_string()), + platform: "cache-coverage".to_string(), + ..AdmitRunRequest::default() + }) + .await + .expect("admit finite requested-cancel context"); + let requested_cancel = f + .service + .handle(&requested_run.run_id) + .expect("requested run handle") + .cancel + .clone(); + assert!( + requested_cancel.deadline_instant().is_some(), + "admission must supply a finite original deadline" + ); + assert!( + !requested_cancel.deadline_passed(), + "requested control must start before its original deadline" + ); + requested_cancel.request(super::CancellationReason::Requested); + assert!(matches!( + requested_cancel.requested(), + Some(super::CancellationReason::Requested) + )); + assert!( + !requested_cancel.deadline_passed(), + "requested control must still be unexpired at the entry assertion" + ); + let requested_host = crate::durable_provider::DurableProviderHost::new( + super::AgentService::clone(f.service.as_ref()), + requested_run.run_id.clone(), + Arc::new(raw.clone()), + Arc::clone(&f.service.inner.metrics), + ); + let requested_context = json!({"session_id":"cache-coverage-b", "messages":[]}); + let requested_failure = requested_host + .refresh_context(&requested_context, &requested_cancel) + .unwrap(); + assert_eq!(requested_failure["ok"], false); + assert_eq!(requested_failure["error"]["code"], "cancelled"); + assert_eq!(raw.call_count(), 0); + assert_eq!( + coverage_event_count(&f.persistence, &requested_run.run_id, "model.requested"), + 0 + ); + f.service + .finish_failed( + &requested_run.run_id, + json!({"error":"requested cancellation fixture completed"}), + ) + .await; assert_eq!(raw.call_count(), 0); assert_eq!( coverage_event_count(&f.persistence, &admitted.run_id, "model.requested"), @@ -13262,9 +13395,7 @@ mod f3_session_maintenance_tests { 2, "alpha after failure generation three", ); - let current = host - .refresh_context(&context, &crate::RunCancellation::new()) - .unwrap(); + let current = host.refresh_context(&context, &admitted_cancel).unwrap(); assert_eq!(current["ok"], true); assert_eq!( current["messages"][0], @@ -13386,6 +13517,14 @@ mod f3_session_maintenance_tests { }) .await .expect("admit actual durable provider run"); + // Positive paths drive the admitted run's own cancellation root; the + // failpoint/crash seams below keep their own synthetic cancels. + let admitted_cancel = f + .service + .handle(&admitted.run_id) + .expect("admitted run handle") + .cancel + .clone(); let raw = crate::runtime::agent_host::ScriptedProvider::new(); raw.push_ok(json!({"text":"coverage answer", "tool_calls":[], "usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}, "reasoning":"", "stop_reason":"stop"})); let committed_entries = Arc::new(std::sync::atomic::AtomicUsize::new(0)); @@ -13406,8 +13545,7 @@ mod f3_session_maintenance_tests { let host = make_host(); let context = json!({"session_id":"cache-coverage-a", "messages":[]}); assert_eq!( - host.refresh_context(&context, &crate::RunCancellation::new()) - .unwrap()["ok"], + host.refresh_context(&context, &admitted_cancel).unwrap()["ok"], true ); coverage_count(&f.service); @@ -13433,23 +13571,19 @@ mod f3_session_maintenance_tests { ); let redrive = make_host(); assert_eq!( - redrive - .refresh_context(&context, &crate::RunCancellation::new()) - .unwrap()["ok"], + redrive.refresh_context(&context, &admitted_cancel).unwrap()["ok"], true ); - let success = redrive.call(&request, &crate::RunCancellation::new()); + let success = redrive.call(&request, &admitted_cancel); assert_eq!(success["ok"], true); assert_eq!(raw.call_count(), 1); assert_eq!(committed_entries.load(Ordering::SeqCst), 1); let replay = make_host(); assert_eq!( - replay - .refresh_context(&context, &crate::RunCancellation::new()) - .unwrap()["ok"], + replay.refresh_context(&context, &admitted_cancel).unwrap()["ok"], true ); - let replayed = replay.call(&request, &crate::RunCancellation::new()); + let replayed = replay.call(&request, &admitted_cancel); assert_eq!(replayed, success); assert_eq!(raw.call_count(), 1); assert_eq!(committed_entries.load(Ordering::SeqCst), 1); diff --git a/src/service/managed_effects_tests.rs b/src/service/managed_effects_tests.rs index 7160642..2a809c9 100644 --- a/src/service/managed_effects_tests.rs +++ b/src/service/managed_effects_tests.rs @@ -81,6 +81,71 @@ fn native_json(value: &rustscript_vm::Value) -> Value { _ => panic!("unexpected non-JSON Native result"), } } +const NATIVE_PROBE_SOURCE: &str = r#"pub fn run(context:map)->map { + let tool_name:string=context["tool_name"];let call_id:string=context["call_id"];let canonical_arguments:string=context["canonical_arguments"];let canonical_input:string=context["canonical_input"];let run_id:string=context["run_id"];let registry_identity:string=context["registry_identity"];let argument_digest:string=context["argument_digest"];let risk_class:string=context["risk_class"];let summary:string=context["summary"];let untrusted_graph_id:string=context["untrusted_graph_id"]; + let attest:map=agent_runtime::approval_attest({run_id:run_id,call_id:call_id,tool_name:tool_name,argument_digest:argument_digest,registry_identity:registry_identity,risk_class:risk_class,summary:summary}); + let invoked:map=agent_runtime::invoke_pinned_tool({tool_name:tool_name,call_id:call_id,canonical_arguments:canonical_arguments,canonical_input:canonical_input,run_id:run_id,registry_identity:registry_identity,argument_digest:argument_digest,risk_class:risk_class,summary:summary,untrusted_graph_id:untrusted_graph_id}); + {attest:attest, invoke:invoked} + }"#; +const NATIVE_PROBE_REPLAY_SOURCE: &str = r#"pub fn run(context:map)->map { + let tool_name:string=context["tool_name"];let call_id:string=context["call_id"];let canonical_arguments:string=context["canonical_arguments"];let canonical_input:string=context["canonical_input"];let run_id:string=context["run_id"];let registry_identity:string=context["registry_identity"];let argument_digest:string=context["argument_digest"];let risk_class:string=context["risk_class"];let summary:string=context["summary"];let untrusted_graph_id:string=context["untrusted_graph_id"]; + agent_runtime::invoke_pinned_tool({tool_name:tool_name,call_id:call_id,canonical_arguments:canonical_arguments,canonical_input:canonical_input,run_id:run_id,registry_identity:registry_identity,argument_digest:argument_digest,risk_class:risk_class,summary:summary,untrusted_graph_id:untrusted_graph_id}) + }"#; +/// The original private Native probe: build the exact pinned invocation wire +/// from an already-authorized host, then enter `agent_runtime::invoke_pinned_tool` +/// through the real RSS host bridge. Fixtures own the parent/identity facts. +fn native_probe_input( + service: &crate::AgentService, + host: &crate::AgentHostBridges, + run_id: &str, + call: &Value, + canonical: &str, + risk_class: &str, +) -> rustscript_vm::Value { + let wire = json!({"tool_name":call["name"],"call_id":call["id"],"canonical_arguments":canonical,"canonical_input":json!({"call":call,"config":service.run_context(run_id).unwrap().limits}).to_string(),"run_id":run_id,"registry_identity":host.lifecycle.as_ref().unwrap().registry_identity(),"argument_digest":canonical,"risk_class":risk_class,"summary":call["name"],"untrusted_graph_id":""}); + rustscript_vm::Value::map( + wire.as_object() + .unwrap() + .iter() + .map(|(key, value)| { + ( + rustscript_vm::Value::string(key), + rustscript_vm::Value::string(value.as_str().unwrap()), + ) + }) + .collect(), + ) +} +fn native_probe( + service: &crate::AgentService, + mut host: crate::AgentHostBridges, + run_id: &str, + call: &Value, + canonical: &str, + risk_class: &str, + attest: bool, +) -> Value { + host.pinned_tool = service.handle(run_id).unwrap().pinned_tool.clone(); + let input = native_probe_input(service, &host, run_id, call, canonical, risk_class); + let source = if attest { + NATIVE_PROBE_SOURCE + } else { + NATIVE_PROBE_REPLAY_SOURCE + }; + let runner = crate::AgentRunner::from_source(source, crate::AgentConfig::default()) + .unwrap() + .with_host(host); + let result = native_json(&runner.run_with_context(input).unwrap()); + if attest { + assert_eq!( + result["attest"]["ok"], true, + "the fixture must obtain the real approval attestation before Native begin: {result}" + ); + result["invoke"].clone() + } else { + result + } +} impl crate::AgentProviderHost for ExactReplayProvider { fn call(&self, _request: &Value, cancellation: &crate::RunCancellation) -> Value { if self.step.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 { @@ -486,3 +551,4340 @@ async fn failed_multiartifact_native_observation_replays_every_field_after_pruni ); reopened.service().shutdown().await.unwrap(); } + +/// 461: after a real Service managed call has durably committed, a real +/// same-control-store graph revocation must stop the *execution replay* of that +/// call. The provider hook revokes the admitted graph and then replays the same +/// call id through the actual host bridge / lifecycle / journal path. +struct RevokingReplayProvider { + persistence: Arc, + service: std::sync::Weak, + run_id: std::sync::Mutex, + call_id: String, + revoke: bool, + step: std::sync::atomic::AtomicUsize, + replay: std::sync::Mutex>, + revoked: std::sync::Mutex>, +} + +impl crate::AgentProviderHost for RevokingReplayProvider { + fn call(&self, _request: &Value, _cancellation: &crate::RunCancellation) -> Value { + if self.step.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 { + return json!({"ok":true,"response":response(Some(json!({"id":self.call_id,"name":"read_file","arguments":{"path":"seed.txt"}}))),"error":{}}); + } + let service = self.service.upgrade().unwrap(); + let run_id = self.run_id.lock().unwrap().clone(); + let pin = service + .handle(&run_id) + .unwrap() + .pinned_tool + .clone() + .unwrap(); + let binding = pin.managed.as_ref().unwrap(); + let graph = binding.code.graph_id.clone(); + let owner = binding.owner.clone(); + if self.revoke { + // The same durable control store that admitted the pin now records + // the tombstone, exactly as the accepted service revoke fixture does. + self.persistence + .plugin_control_command( + "plugin.graph_revoke", + &json!({"graph_id":graph,"now_ms":201,"failpoint":""}), + ) + .unwrap(); + *self.revoked.lock().unwrap() = Some(graph.clone()); + assert!( + crate::plugins::PluginControlStore::new(&self.persistence) + .graph_revocation(&graph) + .unwrap() + .is_some(), + "the revocation must be durably visible before the replay" + ); + } + let request = crate::effects::ManagedEffectGetRequest { + owner, + call_id: self.call_id.clone(), + }; + let record = self + .persistence + .managed_effect_get(&request) + .unwrap() + .unwrap(); + let crate::effects::ManagedArgumentIdentity::Inline { + canonical_arguments, + } = &record.identity.argument_identity + else { + panic!("small fixture arguments should remain inline"); + }; + let canonical = canonical_arguments.clone(); + let mut host = service.capability_host_bridges(&run_id).unwrap().unwrap(); + host.pinned_tool = Some(pin); + let wire = json!({ + "tool_name":"read_file", + "call_id":self.call_id, + "canonical_arguments":canonical, + "canonical_input":"", + "run_id":run_id, + "registry_identity":host.lifecycle.as_ref().unwrap().registry_identity(), + "argument_digest":canonical, + "risk_class":"read", + "summary":"exact replay of the committed call", + "untrusted_graph_id":"" + }); + let input = rustscript_vm::Value::map( + wire.as_object() + .unwrap() + .iter() + .map(|(key, value)| { + ( + rustscript_vm::Value::string(key), + rustscript_vm::Value::string(value.as_str().unwrap()), + ) + }) + .collect(), + ); + let source = r#"pub fn run(context:map)->map { + agent_runtime::invoke_pinned_tool({tool_name:context["tool_name"],call_id:context["call_id"],canonical_arguments:context["canonical_arguments"],canonical_input:context["canonical_input"],run_id:context["run_id"],registry_identity:context["registry_identity"],argument_digest:context["argument_digest"],risk_class:context["risk_class"],summary:context["summary"],untrusted_graph_id:context["untrusted_graph_id"]}) + }"#; + let runner = crate::AgentRunner::from_source(source, crate::AgentConfig::default()) + .unwrap() + .with_host(host); + let result = runner.run_with_context(input).unwrap(); + *self.replay.lock().unwrap() = Some(native_json(&result)); + json!({"ok":true,"response":response(None),"error":{}}) + } +} + +/// F4 evidence: a revoked graph must refuse the execution replay of an already +/// durably committed call, instead of returning the retained success. +#[tokio::test(flavor = "multi_thread")] +async fn phase_a_service_replay_denies_revoke_before_exact_execution_replay() { + let (root, state) = fixture(); + let service = state.service(); + fs::write(root.join("workspace/seed.txt"), b"seeded").unwrap(); + let provider = Arc::new(RevokingReplayProvider { + persistence: state.persistence().unwrap(), + service: Arc::downgrade(&service), + run_id: std::sync::Mutex::new(String::new()), + call_id: "svc-replay-1".into(), + revoke: true, + step: std::sync::atomic::AtomicUsize::new(0), + replay: std::sync::Mutex::new(None), + revoked: std::sync::Mutex::new(None), + }); + service.inject_provider_host(provider.clone()); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"commit once, then replay after revocation"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + *provider.run_id.lock().unwrap() = run.run_id.clone(); + service + .clone() + .run_worker(run.run_id.clone(), "ignored".into()) + .await; + + let events = service.run_events(&run.run_id); + let completed = events + .iter() + .filter(|event| { + event["event"] == "tool.completed" && event["data"]["tool_call_id"] == "svc-replay-1" + }) + .count(); + assert_eq!(completed, 1, "the original call must commit exactly once"); + let graph = provider + .revoked + .lock() + .unwrap() + .clone() + .expect("the fixture must revoke the admitted graph before the replay"); + let replay = provider + .replay + .lock() + .unwrap() + .clone() + .expect("the real execution replay probe must produce an envelope"); + println!("phase_a_service replay evidence: graph={graph} envelope={replay}"); + assert_eq!( + replay["ok"], false, + "a revoked graph must refuse the execution replay: {replay}" + ); + assert_eq!( + replay["code"], "plugin_graph_revoked", + "the replay refusal must be the exact typed graph revocation: {replay}" + ); + assert!( + replay["canonical_result"].as_str().unwrap_or("").is_empty(), + "the refused replay must not leak the original canonical success: {replay}" + ); + assert_eq!( + service + .run_events(&run.run_id) + .iter() + .filter(|event| event["event"] == "tool.completed") + .count(), + 1, + "the refused replay must not commit a second result" + ); + let request = crate::effects::ManagedEffectGetRequest { + owner: crate::effects::ManagedOwnerScope { + profile: "gateway".into(), + session_id: run.session_id.clone(), + run_id: run.run_id.clone(), + }, + call_id: "svc-replay-1".into(), + }; + let record = state + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(); + assert!( + record.outcome.is_some(), + "the original receipt must stay durable after the refusal" + ); + let crate::effects::ManagedCallReplay::Final { + canonical_result, .. + } = service.replay_managed_call(request.clone()).unwrap() + else { + panic!("the committed canonical receipt must stay queryable after the refusal"); + }; + assert_eq!( + serde_json::from_str::(canonical_result.as_str()).unwrap()["content"], + json!("seeded"), + "history must still return the original canonical content" + ); + service.shutdown().await.unwrap(); +} + +/// Unrevoked same-identity execution replay stays a live replay of the +/// original Native outcome: the guard only denies after a revocation. +#[tokio::test(flavor = "multi_thread")] +async fn phase_a_service_replay_without_revoke_returns_original_success() { + let (root, state) = fixture(); + let service = state.service(); + fs::write(root.join("workspace/seed.txt"), b"seeded").unwrap(); + let provider = Arc::new(RevokingReplayProvider { + persistence: state.persistence().unwrap(), + service: Arc::downgrade(&service), + run_id: std::sync::Mutex::new(String::new()), + call_id: "svc-replay-1".into(), + revoke: false, + step: std::sync::atomic::AtomicUsize::new(0), + replay: std::sync::Mutex::new(None), + revoked: std::sync::Mutex::new(None), + }); + service.inject_provider_host(provider.clone()); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"commit once, then replay without revocation"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + *provider.run_id.lock().unwrap() = run.run_id.clone(); + service + .clone() + .run_worker(run.run_id.clone(), "ignored".into()) + .await; + + let replay = provider + .replay + .lock() + .unwrap() + .clone() + .expect("the live execution replay probe must produce an envelope"); + assert_eq!( + replay["ok"], true, + "an unrevoked graph must replay the original success: {replay}" + ); + assert_eq!(replayed_kind(&replay), "replay"); + let request = crate::effects::ManagedEffectGetRequest { + owner: crate::effects::ManagedOwnerScope { + profile: "gateway".into(), + session_id: run.session_id.clone(), + run_id: run.run_id.clone(), + }, + call_id: "svc-replay-1".into(), + }; + let crate::effects::ManagedCallReplay::Final { + canonical_result, .. + } = service.replay_managed_call(request).unwrap() + else { + panic!("expected committed receipt"); + }; + assert_eq!( + replay["canonical_result"].as_str().unwrap(), + canonical_result.as_str() + ); + assert_eq!( + service + .run_events(&run.run_id) + .iter() + .filter(|event| { + event["event"] == "tool.completed" + && event["data"]["tool_call_id"] == "svc-replay-1" + }) + .count(), + 1, + "the live replay must not commit a second result" + ); + service.shutdown().await.unwrap(); +} + +fn replayed_kind(replay: &Value) -> &str { + replay["kind"].as_str().unwrap_or("") +} + +// ===== guard_contract_465_:当前合同边界证据(只证据,不修生产) ===== +// 覆盖 R464-1 冷历史 receipt 回放、R464-3 lifecycle 图错误码、R464-4 原 +// 取消分类/返回边界、R464-7 final replay 顺序。RED 为缺陷证据,GREEN 为正控。 + +/// R464-1:正式 ToolOnly producer 带 key 完成 run,撤销确切图后,先正向 +/// 关闭 owners 并丢弃 Arc pins,再经正式构造器重开同一物理根。同 key / +/// hash / selector 必须返回原 run/status 且 replayed=true,不构造可执行 +/// pin,不新增事件/effect/ref;公开可执行 ToolOnly restore 仍精确拒绝。 +#[tokio::test(flavor = "multi_thread")] +async fn guard_contract_465_r464_1_cold_toolonly_history_replay_after_revoke() { + let (root, state) = fixture(); + let service = state.service(); + fs::write(root.join("workspace/seed.txt"), b"seeded").unwrap(); + let provider = Arc::new(crate::ScriptedProvider::new()); + provider.push_ok(response(Some( + json!({"id":"cold-1","name":"read_file","arguments":{"path":"seed.txt"}}), + ))); + provider.push_ok(response(None)); + service.inject_provider_host(provider); + let request = AdmitRunRequest { + input: json!({"message":"commit once, then cold-reopen history after revocation"}), + idempotency_key: Some("guard-465-r464-1".to_string()), + idempotency_hash: Some("fnv64:0000000000000465".to_string()), + ..Default::default() + }; + let run = service + .admit_external_with_workspace_selector(request.clone(), None) + .await + .unwrap(); + let persistence = state.persistence().unwrap(); + let control = crate::plugins::PluginControlStore::new(&persistence); + let receipt = control.pin_for_run(&run.run_id).unwrap(); + assert_eq!(receipt["pinned"], true); + assert_eq!( + receipt["harness_selection"]["mode"], "tool_only", + "R464-1 只覆盖 ToolOnly 准入,当前夹具必须 pin 住 ToolOnly 程序" + ); + let graph = receipt["graph_id"].as_str().unwrap().to_string(); + service + .clone() + .run_worker(run.run_id.clone(), "ignored".into()) + .await; + assert_eq!( + service + .run_events(&run.run_id) + .iter() + .filter(|event| { + event["event"] == "tool.completed" && event["data"]["tool_call_id"] == "cold-1" + }) + .count(), + 1, + "原 ToolOnly 调用必须实际提交一次" + ); + // 撤销确切图:经正式 typed producer 记录 tombstone。 + persistence + .plugin_control_command( + "plugin.graph_revoke", + &json!({"graph_id":graph,"now_ms":201,"failpoint":""}), + ) + .unwrap(); + assert!( + control.graph_revocation(&graph).unwrap().is_some(), + "撤销必须在同一控制存储内持久可见" + ); + // 撤销后 canonical 历史仍可读(只读 receipt,不执行)。 + let owner = crate::effects::ManagedOwnerScope { + profile: "gateway".into(), + session_id: run.session_id.clone(), + run_id: run.run_id.clone(), + }; + let get = crate::effects::ManagedEffectGetRequest { + owner, + call_id: "cold-1".into(), + }; + let crate::effects::ManagedCallReplay::Final { + canonical_result: before_canonical, + .. + } = service.replay_managed_call(get.clone()).unwrap() + else { + panic!("撤销后原 canonical receipt 必须仍可查询"); + }; + let before_events = service.run_events(&run.run_id).len(); + // 公开可执行 ToolOnly restore 仍精确拒绝(正控,通过)。 + let store = service.tool_store().unwrap(); + assert!( + crate::plugins::restore_owned_pin(&persistence, &store, &run.run_id) + .is_err_and(|error| error.code == "plugin_graph_revoked"), + "撤销后可执行 ToolOnly restore 必须精确拒绝" + ); + // 热路径(retained handle)返回历史。 + let warm = service + .admit_external_with_workspace_selector(request.clone(), None) + .await + .unwrap(); + assert!(warm.replayed, "热路径必须返回原 run"); + assert_eq!(warm.run_id, run.run_id); + // 正向关闭 owners 并丢弃 Arc pins。 + service.evict_run_handle(&run.run_id); + service.shutdown().await.unwrap(); + let config = (*service.inner.config).clone(); + let db = root.join("gateway.sqlite"); + drop(service); + drop(store); + drop(persistence); + drop(state); + // 正式重开同一物理根(无复制、无手写 SQL)。重开服务自带嵌入 + // tool store,与原 admission 的 embedding 图一致。 + let reopened = AgentGatewayState::with_sqlite_path(config, &db) + .unwrap() + .with_workspace_home(root.join("home")); + let cold = reopened + .service() + .admit_external_with_workspace_selector(request.clone(), None) + .await + .expect("冷 ToolOnly 历史回放必须返回原 run/status,不构造可执行 pin"); + assert!(cold.replayed, "冷重开必须标记 replayed"); + assert_eq!(cold.run_id, run.run_id); + assert_eq!(cold.status, warm.status); + assert_eq!( + reopened.service().run_events(&run.run_id).len(), + before_events, + "冷历史返回不得新增事件" + ); + let reopened_persistence = reopened.persistence().unwrap(); + let control2 = crate::plugins::PluginControlStore::new(&reopened_persistence); + assert_eq!( + control2.pin_for_run(&run.run_id).unwrap(), + receipt, + "冷历史返回不得替换 receipt/ref" + ); + let crate::effects::ManagedCallReplay::Final { + canonical_result: after_canonical, + .. + } = reopened.service().replay_managed_call(get.clone()).unwrap() + else { + panic!("冷重开后原 canonical receipt 必须仍可查询"); + }; + assert_eq!(after_canonical, before_canonical); + reopened.service().shutdown().await.unwrap(); +} + +/// R464-3 探针:在同一正式活 binding 上,撤销前后分别经官方 tool_replay +/// lifecycle 入口与官方 Native invoke 查询已完成调用。撤销前两者都返回原 +/// 成功(正控);撤销后两者都必须精确 plugin_graph_revoked,不得返回原 +/// canonical 成功,不得新增 started/completion。 +struct Guard465ReplayCodeProvider { + persistence: Arc, + service: std::sync::Weak, + run_id: std::sync::Mutex, + call_id: String, + step: std::sync::atomic::AtomicUsize, + tool_replay_before: std::sync::Mutex>, + native_before: std::sync::Mutex>, + tool_replay_after: std::sync::Mutex>, + native_after: std::sync::Mutex>, +} + +fn guard465_tool_replay_input( + run_id: &str, + call_id: &str, + canonical: &str, + registry: &str, +) -> Value { + json!({ + "run_id": run_id, + "call_id": call_id, + "tool_name": "read_file", + "argument_digest": canonical, + "registry_identity": registry, + "risk_class": "read", + "summary": "guard465 replay code probe", + }) +} + +fn guard465_vm_map(input: &Value) -> rustscript_vm::Value { + rustscript_vm::Value::map( + input + .as_object() + .unwrap() + .iter() + .map(|(key, value)| { + ( + rustscript_vm::Value::string(key), + rustscript_vm::Value::string(value.as_str().unwrap()), + ) + }) + .collect(), + ) +} + +impl crate::AgentProviderHost for Guard465ReplayCodeProvider { + fn call(&self, _request: &Value, _cancellation: &crate::RunCancellation) -> Value { + if self.step.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 { + return json!({"ok":true,"response":response(Some(json!({"id":self.call_id,"name":"read_file","arguments":{"path":"seed.txt"}}))),"error":{}}); + } + let service = self.service.upgrade().unwrap(); + let run_id = self.run_id.lock().unwrap().clone(); + let pin = service + .handle(&run_id) + .unwrap() + .pinned_tool + .clone() + .unwrap(); + let binding = pin.managed.as_ref().unwrap(); + let graph = binding.code.graph_id.clone(); + let record = self + .persistence + .managed_effect_get(&crate::effects::ManagedEffectGetRequest { + owner: binding.owner.clone(), + call_id: self.call_id.clone(), + }) + .unwrap() + .unwrap(); + let crate::effects::ManagedArgumentIdentity::Inline { + canonical_arguments, + } = &record.identity.argument_identity + else { + panic!("small fixture arguments should remain inline"); + }; + let canonical = canonical_arguments.clone(); + let mut host = service.capability_host_bridges(&run_id).unwrap().unwrap(); + host.pinned_tool = Some(pin); + let registry = host.lifecycle.as_ref().unwrap().registry_identity(); + let replay_source = "pub fn run(context:map)->map { agent_runtime::tool_replay(context) }"; + // 撤销前:官方 tool_replay 入口必须返回原成功(正控)。 + let before_input = guard465_tool_replay_input(&run_id, &self.call_id, &canonical, registry); + let before_runner = + crate::AgentRunner::from_source(replay_source, crate::AgentConfig::default()) + .unwrap() + .with_host(host.clone()); + let before_replay = native_json( + &before_runner + .run_with_context(guard465_vm_map(&before_input)) + .unwrap(), + ); + // 撤销前:官方 Native invoke 必须返回原成功(正控)。 + let wire = json!({"tool_name":"read_file","call_id":self.call_id,"canonical_arguments":canonical,"canonical_input":"","run_id":run_id,"registry_identity":registry,"argument_digest":canonical,"risk_class":"read","summary":"guard465 replay code probe","untrusted_graph_id":""}); + let invoke_source = r#"pub fn run(context:map)->map { + agent_runtime::invoke_pinned_tool({tool_name:context["tool_name"],call_id:context["call_id"],canonical_arguments:context["canonical_arguments"],canonical_input:context["canonical_input"],run_id:context["run_id"],registry_identity:context["registry_identity"],argument_digest:context["argument_digest"],risk_class:context["risk_class"],summary:context["summary"],untrusted_graph_id:context["untrusted_graph_id"]}) + }"#; + let invoke_runner = + crate::AgentRunner::from_source(invoke_source, crate::AgentConfig::default()) + .unwrap() + .with_host(host.clone()); + let before_native = native_json( + &invoke_runner + .run_with_context(guard465_vm_map(&wire)) + .unwrap(), + ); + *self.tool_replay_before.lock().unwrap() = Some(before_replay); + *self.native_before.lock().unwrap() = Some(before_native); + // 撤销确切图。 + self.persistence + .plugin_control_command( + "plugin.graph_revoke", + &json!({"graph_id":graph,"now_ms":202,"failpoint":""}), + ) + .unwrap(); + assert!( + crate::plugins::PluginControlStore::new(&self.persistence) + .graph_revocation(&graph) + .unwrap() + .is_some(), + "撤销必须在回放探针前持久可见" + ); + // 撤销后:官方 Native invoke 必须精确拒绝。 + let after_native_runner = + crate::AgentRunner::from_source(invoke_source, crate::AgentConfig::default()) + .unwrap() + .with_host(host.clone()); + let after_native = native_json( + &after_native_runner + .run_with_context(guard465_vm_map(&wire)) + .unwrap(), + ); + *self.native_after.lock().unwrap() = Some(after_native); + // 撤销后:官方 tool_replay 入口必须精确拒绝(当前 contracts 缺陷 RED)。 + let after_input = guard465_tool_replay_input(&run_id, &self.call_id, &canonical, registry); + let after_replay_runner = + crate::AgentRunner::from_source(replay_source, crate::AgentConfig::default()) + .unwrap() + .with_host(host); + let after_replay = native_json( + &after_replay_runner + .run_with_context(guard465_vm_map(&after_input)) + .unwrap(), + ); + *self.tool_replay_after.lock().unwrap() = Some(after_replay); + json!({"ok":true,"response":response(None),"error":{}}) + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn guard_contract_465_r464_3_tool_replay_keeps_exact_revocation_code() { + let (root, state) = fixture(); + let service = state.service(); + fs::write(root.join("workspace/seed.txt"), b"seeded").unwrap(); + let provider = Arc::new(Guard465ReplayCodeProvider { + persistence: state.persistence().unwrap(), + service: Arc::downgrade(&service), + run_id: std::sync::Mutex::new(String::new()), + call_id: "code-1".into(), + step: std::sync::atomic::AtomicUsize::new(0), + tool_replay_before: std::sync::Mutex::new(None), + native_before: std::sync::Mutex::new(None), + tool_replay_after: std::sync::Mutex::new(None), + native_after: std::sync::Mutex::new(None), + }); + service.inject_provider_host(provider.clone()); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"commit once, probe replay codes around revocation"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + *provider.run_id.lock().unwrap() = run.run_id.clone(); + service + .clone() + .run_worker(run.run_id.clone(), "ignored".into()) + .await; + let events = service.run_events(&run.run_id); + assert_eq!( + events + .iter() + .filter(|event| { + event["event"] == "tool.completed" && event["data"]["tool_call_id"] == "code-1" + }) + .count(), + 1, + "原调用必须精确提交一次" + ); + let before_replay = provider.tool_replay_before.lock().unwrap().clone().unwrap(); + assert_eq!( + before_replay["ok"], true, + "撤销前官方 tool_replay 必须返回原成功: {before_replay}" + ); + let before_native = provider.native_before.lock().unwrap().clone().unwrap(); + assert_eq!( + before_native["ok"], true, + "撤销前官方 Native invoke 必须返回原成功: {before_native}" + ); + assert_eq!(replayed_kind(&before_native), "replay"); + let after_native = provider.native_after.lock().unwrap().clone().unwrap(); + assert_eq!( + after_native["ok"], false, + "撤销后 Native 执行回放必须拒绝: {after_native}" + ); + assert_eq!( + after_native["code"], "plugin_graph_revoked", + "撤销后 Native 回放必须精确分类: {after_native}" + ); + assert!( + after_native["canonical_result"] + .as_str() + .unwrap_or("") + .is_empty(), + "被拒绝的回放不得泄露原 canonical 成功" + ); + let after_replay = provider.tool_replay_after.lock().unwrap().clone().unwrap(); + assert_eq!( + after_replay["ok"], false, + "撤销后官方 tool_replay 必须拒绝: {after_replay}" + ); + assert_eq!( + after_replay["error"]["code"], "plugin_graph_revoked", + "撤销后官方 tool_replay 必须精确分类,不得吞为 result_commit_failed: {after_replay}" + ); + assert_eq!( + service + .run_events(&run.run_id) + .iter() + .filter(|event| event["event"] == "tool.completed") + .count(), + 1, + "被拒绝的回放不得提交第二个结果" + ); + service.shutdown().await.unwrap(); +} + +/// R464-4 正控与 RED:原 admission 根(构造时配置短且受支持的 run_timeout, +/// 不重建时钟)在 guard 已有入口处保持原 deadline/root。requested 必须保留 +/// 既有 cancelled 分类,不得转成 pin_required。 +#[tokio::test(flavor = "multi_thread")] +async fn guard_contract_465_r464_4_requested_keeps_cancelled() { + let (_root, state) = + fixture_config(|config| config.run_timeout = std::time::Duration::from_secs(2)); + let service = state.service(); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"guard465 requested root"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + let handle = service.handle(&run.run_id).unwrap(); + let original = handle.cancel.clone(); + let deadline = original + .deadline_instant() + .expect("原 admission 根必须携带有限 deadline"); + assert!( + deadline > std::time::Instant::now(), + "夹具 deadline 必须是未来时刻" + ); + let pinned = handle.pinned_tool.clone().expect("准入必须 pin 住工具程序"); + let persistence = state.persistence().unwrap(); + let observed = Arc::new(std::sync::Mutex::new(Vec::new())); + let capture = Arc::clone(&observed); + let root_clock = original.clone(); + *persistence.managed_request_hook.lock().unwrap() = + Some(Arc::new(move |op, cancel, enqueued| { + if op == "plugin.graph_revocation_get" { + capture + .lock() + .unwrap() + .push((enqueued, cancel.shares_original_clock(&root_clock))); + } + })); + pinned + .check_execution_authority(Some(&original)) + .expect("开放的原根必须通过 guard 精确读"); + original.request(crate::CancellationReason::Requested); + let err = pinned + .check_execution_authority(Some(&original)) + .expect_err("已 requested 的原根必须拒绝"); + assert_eq!( + err, "cancelled", + "原 requested 必须保留既有 cancelled 分类,不得转成 pin_required" + ); + *persistence.managed_request_hook.lock().unwrap() = None; + let rows = observed.lock().unwrap().clone(); + assert!(!rows.is_empty(), "guard 必须经过队列边界可观测读"); + assert!( + rows.iter().all(|(_, shared)| *shared), + "guard 读必须共享原 admission 时钟: {rows:?}" + ); + service.shutdown().await.unwrap(); +} + +/// R464-4 RED:原 admission 根自然到期(按记录的 deadline 做一次有界等待, +/// 不猜阶段、不重建时钟)必须保留既有 deadline_elapsed 分类。 +#[tokio::test(flavor = "multi_thread")] +async fn guard_contract_465_r464_4_elapsed_keeps_deadline_elapsed() { + let (_root, state) = + fixture_config(|config| config.run_timeout = std::time::Duration::from_secs(2)); + let service = state.service(); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"guard465 elapsed root"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + let handle = service.handle(&run.run_id).unwrap(); + let original = handle.cancel.clone(); + let deadline = original + .deadline_instant() + .expect("原 admission 根必须携带有限 deadline"); + assert!( + deadline > std::time::Instant::now(), + "夹具 deadline 必须是未来时刻" + ); + let pinned = handle.pinned_tool.clone().expect("准入必须 pin 住工具程序"); + let persistence = state.persistence().unwrap(); + let observed = Arc::new(std::sync::Mutex::new(Vec::new())); + let capture = Arc::clone(&observed); + let root_clock = original.clone(); + *persistence.managed_request_hook.lock().unwrap() = + Some(Arc::new(move |op, cancel, enqueued| { + if op == "plugin.graph_revocation_get" { + capture + .lock() + .unwrap() + .push((enqueued, cancel.shares_original_clock(&root_clock))); + } + })); + pinned + .check_execution_authority(Some(&original)) + .expect("开放的原根必须通过 guard 精确读"); + std::thread::sleep( + original.remaining_deadline().unwrap_or_default() + std::time::Duration::from_millis(250), + ); + assert!(original.deadline_passed(), "原 deadline 必须已过"); + let err = pinned + .check_execution_authority(Some(&original)) + .expect_err("已到期的原根必须拒绝"); + assert_eq!( + err, "deadline_elapsed", + "原到期必须保留既有 deadline_elapsed 分类,不得转成 pin_required" + ); + *persistence.managed_request_hook.lock().unwrap() = None; + let rows = observed.lock().unwrap().clone(); + assert!(!rows.is_empty(), "guard 必须经过队列边界可观测读"); + assert!( + rows.iter().all(|(_, shared)| *shared), + "guard 读必须共享原 admission 时钟: {rows:?}" + ); + service.shutdown().await.unwrap(); +} + +/// R464-4 正控:开放的原根下发生真实查询故障,guard 仍精确 pin_required。 +#[tokio::test(flavor = "multi_thread")] +async fn guard_contract_465_r464_4_query_failure_stays_pin_required() { + let (_root, state) = fixture(); + let service = state.service(); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"guard465 query failure root"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + let handle = service.handle(&run.run_id).unwrap(); + let original = handle.cancel.clone(); + assert!( + original.deadline_instant().is_some(), + "原 admission 根必须携带有限 deadline" + ); + let pinned = handle.pinned_tool.clone().expect("准入必须 pin 住工具程序"); + pinned + .check_execution_authority(Some(&original)) + .expect("开放的原根必须通过 guard 精确读"); + state.persistence().unwrap().inject_persist_failure(); + let err = pinned + .check_execution_authority(Some(&original)) + .expect_err("真实查询故障必须拒绝"); + assert_eq!( + err, "plugin_graph_pin_required", + "真实查询故障必须精确 pin_required: {err}" + ); + service.shutdown().await.unwrap(); +} + +/// R464-7 探针:正式 journal/broker typed API 建立的状态(miss / final), +/// 在撤销图下经同一正式活 binding 做执行回放。只有真正的 final 执行回放才 +/// 走新拒绝;miss 保持原有缺席语义,final 的身份/参数不匹配保持原有分类。 +#[derive(Clone, Copy)] +enum Guard465JournalProbe { + Miss, + FinalExact, + WrongCanonical, + WrongTool, +} + +struct Guard465JournalProvider { + persistence: Arc, + service: std::sync::Weak, + run_id: std::sync::Mutex, + call_id: String, + probe: Guard465JournalProbe, + step: std::sync::atomic::AtomicUsize, + outcome: std::sync::Mutex>>, +} + +impl crate::AgentProviderHost for Guard465JournalProvider { + fn call(&self, _request: &Value, _cancellation: &crate::RunCancellation) -> Value { + if self.step.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 { + return json!({"ok":true,"response":response(Some(json!({"id":self.call_id,"name":"read_file","arguments":{"path":"seed.txt"}}))),"error":{}}); + } + let service = self.service.upgrade().unwrap(); + let run_id = self.run_id.lock().unwrap().clone(); + let pin = service + .handle(&run_id) + .unwrap() + .pinned_tool + .clone() + .unwrap(); + let binding = pin.managed.as_ref().unwrap(); + let graph = binding.code.graph_id.clone(); + let record = self + .persistence + .managed_effect_get(&crate::effects::ManagedEffectGetRequest { + owner: binding.owner.clone(), + call_id: self.call_id.clone(), + }) + .unwrap() + .unwrap(); + let crate::effects::ManagedArgumentIdentity::Inline { + canonical_arguments, + } = &record.identity.argument_identity + else { + panic!("small fixture arguments should remain inline"); + }; + let canonical = canonical_arguments.clone(); + let args: Value = serde_json::from_str(&canonical).unwrap(); + self.persistence + .plugin_control_command( + "plugin.graph_revoke", + &json!({"graph_id":graph,"now_ms":203,"failpoint":""}), + ) + .unwrap(); + assert!( + crate::plugins::PluginControlStore::new(&self.persistence) + .graph_revocation(&graph) + .unwrap() + .is_some(), + "撤销必须在回放探针前持久可见" + ); + let probed = match self.probe { + Guard465JournalProbe::Miss => { + binding.replay_live("never-registered-465", "read_file", None, None) + } + Guard465JournalProbe::FinalExact => { + binding.replay_live(&self.call_id, "read_file", Some(&args), Some(&canonical)) + } + Guard465JournalProbe::WrongCanonical => binding.replay_live( + &self.call_id, + "read_file", + Some(&args), + Some("{\"path\":\"wrong.txt\"}"), + ), + Guard465JournalProbe::WrongTool => { + binding.replay_live(&self.call_id, "write_file", Some(&args), Some(&canonical)) + } + }; + *self.outcome.lock().unwrap() = Some(match probed { + Ok(None) => Ok("miss".to_string()), + Ok(Some(_)) => Ok("final".to_string()), + Err(message) => Err(message), + }); + json!({"ok":true,"response":response(None),"error":{}}) + } +} + +async fn guard465_journal_probe_round( + probe: Guard465JournalProbe, +) -> ( + Arc, + String, + Option>, +) { + let (root, state) = fixture(); + let service = state.service(); + fs::write(root.join("workspace/seed.txt"), b"seeded").unwrap(); + let provider = Arc::new(Guard465JournalProvider { + persistence: state.persistence().unwrap(), + service: Arc::downgrade(&service), + run_id: std::sync::Mutex::new(String::new()), + call_id: "journal-1".into(), + probe, + step: std::sync::atomic::AtomicUsize::new(0), + outcome: std::sync::Mutex::new(None), + }); + service.inject_provider_host(provider.clone()); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"commit once, probe journal replay ordering under revoke"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + *provider.run_id.lock().unwrap() = run.run_id.clone(); + service + .clone() + .run_worker(run.run_id.clone(), "ignored".into()) + .await; + assert_eq!( + service + .run_events(&run.run_id) + .iter() + .filter(|event| { + event["event"] == "tool.completed" && event["data"]["tool_call_id"] == "journal-1" + }) + .count(), + 1, + "原调用必须精确提交一次" + ); + let outcome = provider.outcome.lock().unwrap().clone(); + (service, run.run_id.clone(), outcome) +} + +/// R464-7 正控:miss 在撤销图下仍是普通缺席,不走新拒绝。 +#[tokio::test(flavor = "multi_thread")] +async fn guard_contract_465_r464_7_miss_stays_absent_under_revoke() { + let (service, run_id, outcome) = guard465_journal_probe_round(Guard465JournalProbe::Miss).await; + assert_eq!( + outcome.unwrap().unwrap(), + "miss", + "撤销图下未登记调用必须仍是普通缺席" + ); + assert_eq!( + service + .run_events(&run_id) + .iter() + .filter(|event| event["event"] == "tool.completed") + .count(), + 1 + ); + service.shutdown().await.unwrap(); +} + +/// R464-7 正控:真正的 final 执行回放在撤销图下走新拒绝。 +#[tokio::test(flavor = "multi_thread")] +async fn guard_contract_465_r464_7_final_revoked_refuses_execution() { + let (service, run_id, outcome) = + guard465_journal_probe_round(Guard465JournalProbe::FinalExact).await; + assert_eq!( + outcome.unwrap().unwrap_err(), + "plugin_graph_revoked", + "真正的 final 执行回放必须走新拒绝" + ); + assert_eq!( + service + .run_events(&run_id) + .iter() + .filter(|event| event["event"] == "tool.completed") + .count(), + 1, + "被拒绝的回放不得提交第二个结果" + ); + service.shutdown().await.unwrap(); +} + +/// R464-7 RED:final 记录配错 canonical 时,撤销不得覆盖原参数身份分类。 +#[tokio::test(flavor = "multi_thread")] +async fn guard_contract_465_r464_7_final_argument_mismatch_keeps_identity_error() { + let (service, _run_id, outcome) = + guard465_journal_probe_round(Guard465JournalProbe::WrongCanonical).await; + assert_eq!( + outcome.unwrap().unwrap_err(), + "managed replay argument identity mismatch", + "参数身份不匹配必须保留原有分类,不得被撤销覆盖" + ); + service.shutdown().await.unwrap(); +} + +/// R464-7 RED:final 记录配错 tool 时,撤销不得覆盖原身份分类。 +#[tokio::test(flavor = "multi_thread")] +async fn guard_contract_465_r464_7_final_tool_mismatch_keeps_identity_error() { + let (service, _run_id, outcome) = + guard465_journal_probe_round(Guard465JournalProbe::WrongTool).await; + assert_eq!( + outcome.unwrap().unwrap_err(), + "managed replay identity mismatch", + "调用身份不匹配必须保留原有分类,不得被撤销覆盖" + ); + service.shutdown().await.unwrap(); +} + +// ===== guard_contract_468_:R467-1/R467-2 当前合同缺陷证据(只证据,不修生产) ===== +// R467-1:公开 memory 构造器(AgentGatewayState::new,即 closed embedded +// sealed runtime)下,同 canonical 请求/key 两次准入必须返回原 run。 +// 当前 service.rs:7198 处 receipt-only 门在 persistence=None 时直接拒绝, +// 按静态分析第二次请求失败为 PluginGraphPinRequired(本用例取 RED)。 +// R467-2:direct managed final replay 在原根开放时进入 freshness 查询 +// (plugin.graph_revocation_get,enqueue 边界),查询中原根 requested 或 +// 有限 deadline 到期。当前 journal.rs:1392-1399 把查询失败一律改写成 +// pin_required,正确分类应为 cancelled / deadline_elapsed(本组取 RED)。 +// 所有探针只用已有 fixture/producer/hook,不新增生产逻辑与通用框架。 + +/// R467-1:memory 显式无状态准入自带 ExplicitStateless 证明与 retained +/// handle;同 key/hash/selector 第二次准入必须返回原 run/session/status +/// 且 replayed=true,不新增事件与 handle。 +#[tokio::test(flavor = "multi_thread")] +async fn guard_contract_468_r467_1_stateless_memory_replay_returns_original() { + let state = AgentGatewayState::new(AgentGatewayConfig::default()) + .expect("memory 构造器必须装好当前受支持的无状态 sealed runtime"); + let service = state.service(); + let request = AdmitRunRequest { + input: json!({"message":"stateless memory idempotent replay"}), + idempotency_key: Some("guard-468-r467-1".to_string()), + idempotency_hash: Some("fnv64:0000000000004681".to_string()), + ..Default::default() + }; + let first = service + .admit_external_with_workspace_selector(request.clone(), None) + .await + .expect("首次显式无状态准入必须成功,否则为 setup 失败而非 qualified RED"); + assert!(!first.replayed, "首次准入不得标记 replayed: {first:?}"); + // 正向原准入证明:retained handle 真实存在,且为 ExplicitStateless。 + let handle = service + .handle(&first.run_id) + .expect("原 run 必须保留 retained handle,不得凭 persistence=None 放行"); + let pin = handle + .pinned_tool + .clone() + .expect("无状态准入必须 seal 住原 pin"); + assert!( + pin.managed.is_none(), + "显式无状态 pin 不得携带 managed binding" + ); + assert!( + matches!( + handle.native_disposition.clone(), + Some(super::super::NativeRuntimeDisposition::ExplicitStateless(_)) + ), + "原准入证明必须是 ExplicitStateless retained handle" + ); + let before_events = service.run_events(&first.run_id).len(); + let before_handles = service.handle_count(); + // 同 canonical 请求/key 第二次准入必须返回原 run。 + let second = service + .admit_external_with_workspace_selector(request.clone(), None) + .await + .expect("显式无状态 memory 历史必须返回原 run/status,不得构造可执行 pin"); + assert!(second.replayed, "历史返回必须标记 replayed"); + assert_eq!(second.run_id, first.run_id, "幂等历史必须返回原 run"); + assert_eq!( + second.session_id, first.session_id, + "幂等历史不得新建 session" + ); + assert_eq!(second.status, first.status, "幂等历史必须返回原 status"); + assert_eq!( + service.run_events(&first.run_id).len(), + before_events, + "历史返回不得新增事件" + ); + assert_eq!( + service.handle_count(), + before_handles, + "历史返回不得新增 handle" + ); + service.shutdown().await.unwrap(); +} + +/// R467-2 关根方式:requested 直接 request 原根;elapsed 在 enqueue 边界 +/// 做一次有界等待,让原已配置有限 deadline 在该精确缝隙到期。不建新根、 +/// 不延预算、无重试轮询。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Guard468FinalClose { + Requested, + Elapsed, +} + +/// 468 lifecycle 探针原有内联源码,提为测试常量,使 admission 前编译的 +/// 程序与 provider 实际执行的是同一份源码。 +const GUARD468_LIFECYCLE_REPLAY_SOURCE: &str = + "pub fn run(context:map)->map { agent_runtime::tool_replay(context) }"; + +/// 491:把探针的不可变编译成本移出原 2s admission 窗口。 +/// +/// 只保留由既有 fixture 源码编译出的 immutable 代码句柄(program + registry, +/// 入口固定为 `run`);不含 run/owner/receipt/authority/grant/token。每次实际 +/// 调用仍从 admitted handle 现取 fresh host bridges 再绑定,原 provider 顺序、 +/// 原根时钟、原 cut 与全部语义断言不变。 +struct GuardRetainedProbeCode { + runner: std::sync::Mutex>, +} + +impl GuardRetainedProbeCode { + /// admission 前编译:只准备代码,不建 run、不建 authority。 + fn compile(source: &str) -> Result { + crate::AgentRunner::from_source(source, crate::AgentConfig::default()) + .map(|runner| Self { + runner: std::sync::Mutex::new(Some(runner)), + }) + .map_err(|error| error.to_string()) + } + + /// 取走 immutable 代码,并安装本次实际调用刚取得的 host bridges。 + fn bind(&self, host: crate::AgentHostBridges) -> crate::AgentRunner { + self.runner + .lock() + .unwrap() + .take() + .expect("retained probe code must bind exactly once") + .with_host(host) + } +} + +/// 共享的 admission 前编译 Native 探针执行器:调用方只提供已冻结的 +/// `GuardRetainedProbeCode`;这里在 original pinned handle 上现取 pin 并绑定 +/// fresh host bridges,绝不重新编译(编译成本已在原 2s 窗口之外)。 +fn run_retained_native_probe_result( + probe: &GuardRetainedProbeCode, + service: &crate::AgentService, + host: crate::AgentHostBridges, + run_id: &str, + call: &Value, + canonical: &str, + risk_class: &str, +) -> Result { + let mut host = host; + let pinned_tool = service + .handle(run_id) + .ok_or("the admitted run handle must stay live for the Native probe")? + .pinned_tool + .clone(); + host.pinned_tool = pinned_tool; + let input = native_probe_input(service, &host, run_id, call, canonical, risk_class); + let runner = probe.bind(host); + runner + .run_with_context(input) + .map(|value| native_json(&value)) + .map_err(|error| format!("retained Native probe execution failed: {error:?}")) +} + +fn run_retained_native_probe( + probe: &GuardRetainedProbeCode, + service: &crate::AgentService, + host: crate::AgentHostBridges, + run_id: &str, + call: &Value, + canonical: &str, + risk_class: &str, +) -> Value { + run_retained_native_probe_result(probe, service, host, run_id, call, canonical, risk_class) + .unwrap() +} + +/// step 2 的 original bridge 事实:同一原时钟、Some(D)、owner run/profile/ +/// session、durable parent canonical arguments 与 sealed registry。 +/// 495:返回普通 Result,供首个 final 之前的失败统一走清理路径。 +/// 508:run_id/session_id 由既有 `AdmittedRun` 一并携带,参数回到 7 个。 +fn original_bridge_facts( + host: &crate::AgentHostBridges, + run: &crate::service::AdmittedRun, + original: &crate::RunCancellation, + binding: &crate::effects::broker::ManagedRunBinding, + call_id: &str, + tool_name: &str, + call_arguments: &Value, +) -> Result<(), String> { + let bridge = host + .cancellation + .as_ref() + .ok_or("admission bridge must carry a cancellation root")?; + if !bridge.shares_original_clock(original) { + return Err("bridge cancellation must share the original admission clock".into()); + } + if bridge.deadline_instant() != original.deadline_instant() { + return Err("bridge deadline must be the original admission deadline".into()); + } + if bridge.deadline_instant().is_none() { + return Err("the original admission root must carry Some(D)".into()); + } + let owner = host + .capability_owner + .as_ref() + .ok_or("admission bridge must carry the capability owner")?; + if owner.run() != run.run_id.as_str() { + return Err("bridge owner run must be original".into()); + } + if owner.session() != run.session_id.as_str() { + return Err("bridge owner session must be original".into()); + } + if owner.profile() != binding.owner.profile.as_str() { + return Err("bridge owner profile must be original".into()); + } + let lifecycle = host + .lifecycle + .as_ref() + .ok_or("admission bridge must carry the lifecycle")?; + let parent = lifecycle + .parent_arguments(owner, call_id, tool_name) + .map_err(|_| "durable parent arguments must stay readable".to_string())? + .ok_or("the durable provider parent tool call must be sealed")?; + if &parent != call_arguments { + return Err( + "durable parent canonical arguments must equal the exact first call arguments".into(), + ); + } + if lifecycle.registry_identity() != binding.code.registry_identity { + return Err("bridge must seal the original registry identity".into()); + } + Ok(()) +} + +fn assert_original_bridge( + host: &crate::AgentHostBridges, + run: &crate::service::AdmittedRun, + original: &crate::RunCancellation, + binding: &crate::effects::broker::ManagedRunBinding, + call_id: &str, + tool_name: &str, + call_arguments: &Value, +) { + if let Err(failure) = original_bridge_facts( + host, + run, + original, + binding, + call_id, + tool_name, + call_arguments, + ) { + panic!("{failure}"); + } +} + +/// step 3:首个真实 Native managed final 的 typed 事实。 +/// 495:返回普通 Result;调用方可先清理再报告首个 final 的失败。 +fn first_native_final_facts( + persistence: &crate::GatewayPersistence, + binding: &crate::effects::broker::ManagedRunBinding, + call_id: &str, + tool_name: &str, + canonical: &str, +) -> Result { + let record = persistence + .managed_effect_get(&crate::effects::ManagedEffectGetRequest { + owner: binding.owner.clone(), + call_id: call_id.into(), + }) + .map_err(|_| "managed record query must succeed".to_string())? + .ok_or("the first Native call must have a typed managed effect record")?; + let outcome = record + .outcome + .as_ref() + .ok_or("the first Native call must commit a durable final outcome")?; + if !matches!( + record.state, + crate::effects::ManagedPublicationStatus::ReadOnly + | crate::effects::ManagedPublicationStatus::Committed + ) { + return Err(format!( + "legitimate first final must be read_only or committed: {record:?}" + )); + } + if outcome.disposition != record.state { + return Err("final disposition must equal the committed record state".into()); + } + if outcome.replayed { + return Err("the first final must be a real commit, not a replay".into()); + } + if record.effect_id != outcome.publication.effect_id { + return Err("the typed record must keep the original effect id".into()); + } + if record.identity.owner != binding.owner { + return Err("owner must be the original binding owner".into()); + } + if record.identity.call_id.as_str() != call_id { + return Err("call id must be the exact first call".into()); + } + if record.identity.tool_name.as_str() != tool_name { + return Err("tool name must be the exact first tool".into()); + } + if record.identity.code != binding.code { + return Err( + "graph/generation/registry/package identity must be the admitted sealed code".into(), + ); + } + let crate::effects::ManagedArgumentIdentity::Inline { + canonical_arguments, + } = &record.identity.argument_identity + else { + return Err("small fixture arguments must remain inline".into()); + }; + if canonical_arguments.as_str() != canonical { + return Err("the first final must seal the exact first call canonical arguments".into()); + } + Ok(record) +} + +fn assert_first_native_final( + persistence: &crate::GatewayPersistence, + binding: &crate::effects::broker::ManagedRunBinding, + call_id: &str, + tool_name: &str, + canonical: &str, +) -> crate::effects::ManagedEffectRecord { + match first_native_final_facts(persistence, binding, call_id, tool_name, canonical) { + Ok(record) => record, + Err(failure) => panic!("{failure}"), + } +} + +fn count_tool_events( + service: &crate::AgentService, + run_id: &str, + event: &str, + call_id: &str, +) -> usize { + service + .run_events(run_id) + .iter() + .filter(|row| row["event"] == event && row["data"]["tool_call_id"] == call_id) + .count() +} + +/// 468 首个只读 final 的稳定 admission/call 事实。 +/// +/// `run` 是唯一一次 admission 的 typed 结果(run/session 身份唯一来源); +/// `handle` 是同一 run 已保留的 owned handle,original 取消根只从它派生, +/// 不另存可被替换的副本;`binding` 是同一 handle 上 pinned 的 managed +/// binding。`first_probe`/`call`/`canonical`/`args` 是 admission 前编译的 +/// invoke-only 探针与首个调用的原始字节,`call_id` 是它 sealed 的调用 id。 +struct Guard468FirstFinal<'a> { + run: &'a crate::service::AdmittedRun, + handle: &'a crate::service::RunHandle, + binding: &'a crate::effects::broker::ManagedRunBinding, + first_probe: &'a GuardRetainedProbeCode, + call: &'a Value, + canonical: &'a str, + args: &'a Value, + call_id: &'a str, +} + +impl Guard468FirstFinal<'_> { + /// 原取消/时钟根直接取自保留 handle 的 cancel(同一 Arc 根)。 + fn original(&self) -> &crate::RunCancellation { + &self.handle.cancel + } +} + +/// 495:468 首个只读 Native final 的全部验证收集为普通 Result。 +/// +/// 只读动作按真实审批模型使用 admission 前编译的 invoke-only 源码,不申请 +/// 写审批;parent/bridge/首个 final/事件仍是同一原 10s 根上的真实事实。 +/// 任一事实不成立时返回 Err,由调用方先撤 hook、shutdown,再报告失败。 +fn guard468_first_final( + service: &crate::AgentService, + persistence: &crate::GatewayPersistence, + provider: &Arc, + facts: &Guard468FirstFinal<'_>, +) -> Result { + use crate::AgentProviderHost; + let run_id = facts.run.run_id.as_str(); + let call_id = facts.call_id; + let original = facts.original(); + if original.deadline_instant().is_none() { + return Err("the original admission root must carry Some(D)".into()); + } + // step 2:正式 DurableProviderHost parent,恰好一次真实 provider 请求。 + let durable = crate::durable_provider::DurableProviderHost::new( + (*service).clone(), + run_id.to_string(), + provider.clone(), + service.inner.metrics.clone(), + ); + let request = json!({"model":"synthetic","messages":[]}); + let parent = durable.call(&request, original); + if parent["ok"] != true { + return Err(format!( + "the formal durable provider parent must succeed: {parent}" + )); + } + if provider.requests().len() != 1 { + return Err(format!( + "the narrow fixture must issue exactly one real provider request, got {}", + provider.requests().len() + )); + } + let host = match service.capability_host_bridges(run_id) { + Ok(Some(host)) => host, + Ok(None) => { + return Err("the admitted run must expose its original capability bridges".into()); + } + Err(error) => return Err(format!("capability host bridge query failed: {error}")), + }; + original_bridge_facts( + &host, + facts.run, + original, + facts.binding, + call_id, + "read_file", + facts.args, + )?; + // step 3:真实 invoke_pinned_tool 执行首个只读 final,无写审批申请。 + let first = run_retained_native_probe_result( + facts.first_probe, + service, + host, + run_id, + facts.call, + facts.canonical, + "read", + )?; + if first["ok"] != true { + return Err(format!( + "the first Native invoke must commit the read-only final: {first}" + )); + } + let invoke_canonical: Value = match first["canonical_result"].as_str() { + Some(result) => serde_json::from_str(result) + .map_err(|error| format!("first Native canonical_result is not JSON: {error}"))?, + None => { + return Err(format!( + "the first Native invoke must return canonical_result: {first}" + )); + } + }; + // The plugin tool's final content is the audited pure leaf applied to the + // known `path` call argument, not a builtin read_file echo and not a value + // copied from the final managed record. + if invoke_canonical["content"] != json!(signed_leaf_478::GUARD468_EXPECTED_CONTENT) { + return Err(format!( + "the first Native read must return the exact pure-leaf content: {invoke_canonical}" + )); + } + let first_record = first_native_final_facts( + persistence, + facts.binding, + call_id, + "read_file", + facts.canonical, + )?; + if first_record.state != crate::effects::ManagedPublicationStatus::ReadOnly { + return Err(format!( + "the read-only 468 first final must be read_only: {first_record:?}" + )); + } + let started = count_tool_events(service, run_id, "tool.started", call_id); + if started != 1 { + return Err(format!( + "the first Native call must start exactly once, got {started}" + )); + } + let completed = count_tool_events(service, run_id, "tool.completed", call_id); + if completed != 1 { + return Err(format!( + "the first Native call must complete exactly once, got {completed}" + )); + } + Ok(first_record) +} + +struct Guard468CloseEvidence { + before: Result, + binding_after: Option>, + lifecycle_after: Option, + hook_rows: Vec<(String, bool, bool)>, + root_requested: bool, + root_deadline_passed: bool, + tool_completed: usize, +} + +/// 一次正式 fixture:真实提交一次终态→正控→有限 hook 关原根→单次精确回放。 +/// 返回前清理 hook 并 shutdown,再由调用方断言。 +async fn guard468_close_round( + close: Guard468FinalClose, + lifecycle: bool, + call_id: &str, +) -> Guard468CloseEvidence { + // The four rounds reuse the audited signed phase-A ToolOnly + pure-leaf + // fixture through a dedicated test body (real prepare -> pure leaf over the + // known `path` argument -> canonical result -> real commit). No file is + // opened; the original 10 s root is still created only by the single target + // admission below. + let label = format!("468-{call_id}"); + let (state, signed_graph) = signed_leaf_478::open_guard468_signed_read_chain(&label).await; + let service = state.service(); + let persistence = state.persistence().unwrap(); + let call = json!({"id":call_id,"name":"read_file","arguments":{"path":"seed.txt"}}); + let canonical = call["arguments"].to_string(); + let args = call["arguments"].clone(); + // 491:原 10s 根只在下面唯一一次正式 admission 创建。先把 service 自身的 + // agent/template 与两条不可变 Native 探针编译完,窗口内不再 from_source。 + if let Err(error) = service.materialize_cached_runner() { + let cleanup = service.shutdown().await; + panic!("guard468 fixture runner materialization failed: {error} (shutdown: {cleanup:?})"); + } + // 495:只读 468 使用 admission 前编译的 invoke-only 源码,不申请写审批。 + let first_probe = match GuardRetainedProbeCode::compile(NATIVE_PROBE_REPLAY_SOURCE) { + Ok(code) => code, + Err(error) => { + let cleanup = service.shutdown().await; + panic!( + "guard468 Native first-final probe compile failed: {error} (shutdown: {cleanup:?})" + ); + } + }; + let lifecycle_probe = if lifecycle { + match GuardRetainedProbeCode::compile(GUARD468_LIFECYCLE_REPLAY_SOURCE) { + Ok(code) => Some(code), + Err(error) => { + let cleanup = service.shutdown().await; + panic!("guard468 lifecycle probe compile failed: {error} (shutdown: {cleanup:?})"); + } + } + } else { + None + }; + let provider = Arc::new(crate::ScriptedProvider::new()); + provider.push_ok(response(Some(call.clone()))); + service.inject_provider_host(provider.clone()); + // 唯一一次真实 admission:它创建 original 10s 根、owned handle 与 pinned binding。 + let run = match service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"commit once, probe final freshness close classification"}), + ..Default::default() + }, + None, + ) + .await + { + Ok(run) => run, + Err(error) => { + let cleanup = service.shutdown().await; + panic!("guard468 single admission failed: {error} (shutdown: {cleanup:?})"); + } + }; + let run_id = run.run_id.clone(); + let handle = service.handle(&run_id).unwrap(); + let original = handle.cancel.clone(); + let pin = handle + .pinned_tool + .clone() + .expect("the single admission must pin an owned managed tool"); + let binding = pin + .managed + .as_ref() + .expect("the admitted pin must own a managed binding") + .clone(); + // The activated signed lock and the admitted pin/managed code must be the + // same ToolOnly phase-A graph/generation/package/catalog. A mismatch is + // still before any cut and uses the existing first-failure cleanup protocol. + if let Err(primary) = signed_leaf_478::guard468_signed_pin_facts(&pin, &binding, &signed_graph) + { + *persistence.managed_request_hook.lock().unwrap() = None; + let cleanup = service.shutdown().await; + panic!( + "guard468 signed pin validation failed before any cut: {primary} (shutdown: {cleanup:?})" + ); + } + // step 2/3:parent、original bridge 与首个只读 final 全部收集为 Result; + // 任一失败都先清 hook、shutdown,再连同清理结果报告。 + let first_final_facts = Guard468FirstFinal { + run: &run, + handle: &handle, + binding: &binding, + first_probe: &first_probe, + call: &call, + canonical: &canonical, + args: &args, + call_id, + }; + let first_record = match guard468_first_final( + &service, + &persistence, + &provider, + &first_final_facts, + ) { + Ok(record) => record, + Err(primary) => { + *persistence.managed_request_hook.lock().unwrap() = None; + let cleanup = service.shutdown().await; + panic!( + "guard468 first-final validation failed before any cut: {primary} (shutdown: {cleanup:?})" + ); + } + }; + // step 4:同一 binding 上的未撤销正控。 + let before = match binding.replay_live(call_id, "read_file", Some(&args), Some(&canonical)) { + Ok(None) => Ok("miss".to_string()), + Ok(Some(_)) => Ok("final".to_string()), + Err(message) => Err(message), + }; + // 有限 hook:只匹配 exact fresh graph 查询的 enqueue 边界,恰触发一次。 + let hook_rows = Arc::new(std::sync::Mutex::new(Vec::new())); + let hook_rows_in = Arc::clone(&hook_rows); + let fired = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let fired_in = Arc::clone(&fired); + *persistence.managed_request_hook.lock().unwrap() = + Some(Arc::new(move |op, cancel, enqueued| { + if op != "plugin.graph_revocation_get" || enqueued { + return; + } + hook_rows_in.lock().unwrap().push(( + op.to_string(), + enqueued, + cancel.shares_original_clock(&original), + )); + if fired_in + .compare_exchange( + false, + true, + std::sync::atomic::Ordering::SeqCst, + std::sync::atomic::Ordering::SeqCst, + ) + .is_err() + { + return; + } + match close { + Guard468FinalClose::Requested => { + original.request(crate::CancellationReason::Requested) + } + Guard468FinalClose::Elapsed => std::thread::sleep( + original.remaining_deadline().unwrap_or_default() + + std::time::Duration::from_millis(100), + ), + } + })); + let (binding_after, lifecycle_after) = if before == Ok("final".to_string()) { + if lifecycle { + let mut host = service.capability_host_bridges(&run_id).unwrap().unwrap(); + host.pinned_tool = Some(pin.clone()); + let registry = host.lifecycle.as_ref().unwrap().registry_identity(); + let input = guard465_tool_replay_input(&run_id, call_id, &canonical, registry); + let runner = lifecycle_probe + .as_ref() + .expect("lifecycle probe code must be compiled before admission") + .bind(host); + let replayed = native_json(&runner.run_with_context(guard465_vm_map(&input)).unwrap()); + (None, Some(replayed)) + } else { + let probed = binding.replay_live(call_id, "read_file", Some(&args), Some(&canonical)); + ( + Some(match probed { + Ok(None) => Ok("miss".to_string()), + Ok(Some(_)) => Ok("final".to_string()), + Err(message) => Err(message), + }), + None, + ) + } + } else { + (None, None) + }; + // 保留原始 Option/Result,撤 hook 与 shutdown 之后才判定缺失。 + let handle = service.handle(&run_id); + let root_requested = handle + .as_ref() + .is_some_and(|handle| handle.cancel.requested().is_some()); + let root_deadline_passed = handle + .as_ref() + .is_some_and(|handle| handle.cancel.deadline_passed()); + let tool_completed = count_tool_events(&service, &run_id, "tool.completed", call_id); + // 495:终态记录也先收成 Result;撤 hook、shutdown 之后才判定缺失或比较。 + let after_record: Result = persistence + .managed_effect_get(&crate::effects::ManagedEffectGetRequest { + owner: binding.owner.clone(), + call_id: call_id.into(), + }) + .map_err(|error| format!("managed record query failed: {error}")) + .and_then(|record| { + record.ok_or_else(|| "the first committed final must stay queryable".to_string()) + }); + let rows = hook_rows.lock().unwrap().clone(); + drop(handle); + *persistence.managed_request_hook.lock().unwrap() = None; + let shutdown_result = service.shutdown().await; + let after_record = match after_record { + Ok(record) => record, + Err(failure) => { + panic!("guard468 post-cut record invalid: {failure} (shutdown: {shutdown_result:?})") + } + }; + shutdown_result.unwrap(); + assert_eq!( + after_record.effect_id, first_record.effect_id, + "the refusal must not replace the original effect" + ); + assert_eq!( + after_record.outcome, first_record.outcome, + "the whole canonical history must stay exactly the first committed final" + ); + Guard468CloseEvidence { + before, + binding_after, + lifecycle_after, + hook_rows: rows, + root_requested, + root_deadline_passed, + tool_completed, + } +} + +fn guard468_assert_freshness_stage(rows: &[(String, bool, bool)]) { + assert!( + !rows.is_empty(), + "必须实际经过 exact fresh graph 查询的 enqueue 边界:{rows:?}" + ); + assert!( + rows.iter().all(|(op, enqueued, shared)| { + op == "plugin.graph_revocation_get" && !enqueued && *shared + }), + "查询必须是原根时钟下的 plugin.graph_revocation_get enqueue 边界:{rows:?}" + ); +} + +/// R467-2 requested:原根在新增 freshness 查询中被 request,直接 binding +/// 回放必须保留 cancelled 分类,不得改成 pin_required。 +#[tokio::test(flavor = "multi_thread")] +async fn guard_contract_468_r467_2_requested_binding_keeps_cancelled() { + let evidence = guard468_close_round(Guard468FinalClose::Requested, false, "close-1").await; + assert_eq!( + evidence.before, + Ok("final".to_string()), + "正控必须先成功,否则为 setup 失败而非 qualified RED" + ); + guard468_assert_freshness_stage(&evidence.hook_rows); + assert!(evidence.root_requested, "hook 必须关闭原根,不得替换新根"); + assert_eq!( + evidence.binding_after.unwrap(), + Err("cancelled".to_string()), + "原 requested 在新增 freshness 查询中必须保留 cancelled 分类" + ); + assert_eq!(evidence.tool_completed, 1, "被拒绝的回放不得提交第二个结果"); +} + +/// R467-2 requested:同一条件下官方 tool_replay lifecycle 入口必须精确 +/// cancelled,不得改成 pin_required。 +#[tokio::test(flavor = "multi_thread")] +async fn guard_contract_468_r467_2_requested_lifecycle_keeps_cancelled() { + let evidence = guard468_close_round(Guard468FinalClose::Requested, true, "close-2").await; + assert_eq!( + evidence.before, + Ok("final".to_string()), + "正控必须先成功,否则为 setup 失败而非 qualified RED" + ); + guard468_assert_freshness_stage(&evidence.hook_rows); + assert!(evidence.root_requested, "hook 必须关闭原根,不得替换新根"); + let replayed = evidence.lifecycle_after.unwrap(); + assert_eq!( + replayed["ok"], false, + "关闭原根后 tool_replay 必须拒绝:{replayed}" + ); + assert_eq!( + replayed["error"]["code"], "cancelled", + "官方 tool_replay 必须精确 cancelled,不得吞成 pin_required:{replayed}" + ); + assert_eq!(evidence.tool_completed, 1, "被拒绝的回放不得提交第二个结果"); +} + +/// R467-2 elapsed:原有限 deadline 在新增 freshness 查询的受控缝隙到期, +/// 直接 binding 回放必须保留 deadline_elapsed 分类,不得改成 pin_required。 +#[tokio::test(flavor = "multi_thread")] +async fn guard_contract_468_r467_2_elapsed_binding_keeps_deadline_elapsed() { + let evidence = guard468_close_round(Guard468FinalClose::Elapsed, false, "close-3").await; + assert_eq!( + evidence.before, + Ok("final".to_string()), + "正控必须先成功,否则为 setup 失败而非 qualified RED" + ); + guard468_assert_freshness_stage(&evidence.hook_rows); + assert!( + evidence.root_deadline_passed, + "原有限 deadline 必须已在查询缝隙到期" + ); + assert_eq!( + evidence.binding_after.unwrap(), + Err("deadline_elapsed".to_string()), + "原到期在新增 freshness 查询中必须保留 deadline_elapsed 分类" + ); + assert_eq!(evidence.tool_completed, 1, "被拒绝的回放不得提交第二个结果"); +} + +/// R467-2 elapsed:同一条件下官方 tool_replay lifecycle 入口必须精确 +/// deadline_elapsed,不得改成 pin_required。 +#[tokio::test(flavor = "multi_thread")] +async fn guard_contract_468_r467_2_elapsed_lifecycle_keeps_deadline_elapsed() { + let evidence = guard468_close_round(Guard468FinalClose::Elapsed, true, "close-4").await; + assert_eq!( + evidence.before, + Ok("final".to_string()), + "正控必须先成功,否则为 setup 失败而非 qualified RED" + ); + guard468_assert_freshness_stage(&evidence.hook_rows); + assert!( + evidence.root_deadline_passed, + "原有限 deadline 必须已在查询缝隙到期" + ); + let replayed = evidence.lifecycle_after.unwrap(); + assert_eq!( + replayed["ok"], false, + "原到期后 tool_replay 必须拒绝:{replayed}" + ); + assert_eq!( + replayed["error"]["code"], "deadline_elapsed", + "官方 tool_replay 必须精确 deadline_elapsed,不得吞成 pin_required:{replayed}" + ); + assert_eq!(evidence.tool_completed, 1, "被拒绝的回放不得提交第二个结果"); +} + +// ===== guard471:retained stateless authority 负控(仅测试,不改生产) ===== +// memory 无持久化路径下,缺 retained handle 的历史复用必须闭门失败, +// 不能凭 persistence=None 放行,更不能返回 Ok。 + +/// R467-1 负控:未知 id 与被驱逐的 retained handle 都必须闭门失败。 +#[tokio::test(flavor = "multi_thread")] +async fn guard_contract_471_r467_1_stateless_absent_handle_refuses_replay() { + let state = AgentGatewayState::new(AgentGatewayConfig::default()) + .expect("memory 构造器必须装好当前受支持的无状态 sealed runtime"); + let service = state.service(); + assert!( + matches!( + service.validate_owned_admission_receipt("no-such-run"), + Err(crate::AdmitError::PluginGraphPinRequired) + ), + "未知 run 必须闭门失败,不能凭缺席放行" + ); + let request = AdmitRunRequest { + input: json!({"message":"stateless memory idempotent replay"}), + idempotency_key: Some("guard-471-evicted".to_string()), + idempotency_hash: Some("fnv64:0000000000004711".to_string()), + ..Default::default() + }; + let first = service + .admit_external_with_workspace_selector(request.clone(), None) + .await + .expect("首次显式无状态准入必须成功,否则为 setup 失败而非 qualified RED"); + let handles_before = service.handle_count(); + service.evict_run_handle(&first.run_id); + assert!(service.handle(&first.run_id).is_none()); + let replayed = service + .admit_external_with_workspace_selector(request.clone(), None) + .await; + assert!( + matches!(replayed, Err(crate::AdmitError::PluginGraphPinRequired)), + "被驱逐的 retained handle 必须闭门失败,不得返回 Ok:{replayed:?}" + ); + assert_eq!( + service.handle_count(), + handles_before - 1, + "失败的复用不得重建 handle" + ); + service.shutdown().await.unwrap(); +} + +/// R467-1 负控:retained handle 缺 pin、缺 disposition 或证明错配时, +/// 历史收据校验必须闭门失败。只用已 retained 的真实 pin/证明交叉装配, +/// 不伪造 guest 证明,不新增生产 API。 +#[tokio::test(flavor = "multi_thread")] +async fn guard_contract_471_r467_1_stateless_absent_or_mismatched_proof_refuses_receipt() { + let state = AgentGatewayState::new(AgentGatewayConfig::default()) + .expect("memory 构造器必须装好当前受支持的无状态 sealed runtime"); + let service = state.service(); + let first = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"stateless proof zero"}), + ..Default::default() + }, + None, + ) + .await + .expect("首次显式无状态准入必须成功,否则为 setup 失败而非 qualified RED"); + let second = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"stateless proof one"}), + ..Default::default() + }, + None, + ) + .await + .expect("第二次显式无状态准入必须成功,否则为 setup 失败而非 qualified RED"); + let live_zero = service + .handle(&first.run_id) + .expect("原 run 必须保留 retained handle"); + let pin_zero = live_zero + .pinned_tool + .clone() + .expect("无状态准入必须 seal 住原 pin"); + let proof_zero = live_zero + .native_disposition + .clone() + .expect("原准入证明必须是 ExplicitStateless"); + let live_one = service + .handle(&second.run_id) + .expect("第二个 run 必须保留 retained handle"); + let proof_one = live_one + .native_disposition + .clone() + .expect("第二个准入证明必须是 ExplicitStateless"); + drop(live_zero); + drop(live_one); + let craft = |pinned: Option, + disposition: Option| { + super::super::RunHandle { + cancel: crate::RunCancellation::with_timeout(std::time::Duration::from_secs(5)), + terminal_at: std::sync::Mutex::new(None), + permit: std::sync::Mutex::new(None), + started_at: std::time::Instant::now(), + terminal: std::sync::atomic::AtomicBool::new(false), + cancel_reason: std::sync::Mutex::new(None), + subscribers: std::sync::Mutex::new(super::super::SubscriberState { + count: 0, + notified: false, + }), + disconnect_policy: crate::config::ClientDisconnectPolicy::KeepRunning, + tool_cancel: crate::runtime::cancellation::CancellationToken::new(), + capability_host: std::sync::Mutex::new(super::super::CapabilityHostPhase::Empty), + capability_host_cv: std::sync::Condvar::new(), + workspace_authority: None, + legacy_workspace_root: None, + coding_system_prompt: std::sync::Arc::from(""), + occupancy: std::sync::atomic::AtomicBool::new(false), + restore_worker_started: None, + cleanup_outcome: std::sync::Mutex::new(None), + pinned_tool: pinned, + native_disposition: disposition, + } + }; + service.inner.runs.lock().expect("runs lock").insert( + "guard-471-missing-pin".to_string(), + Arc::new(craft(None, Some(proof_zero.clone()))), + ); + service.inner.runs.lock().expect("runs lock").insert( + "guard-471-missing-disposition".to_string(), + Arc::new(craft(Some(pin_zero.clone()), None)), + ); + service.inner.runs.lock().expect("runs lock").insert( + "guard-471-wrong-binding".to_string(), + Arc::new(craft(Some(pin_zero), Some(proof_one))), + ); + for id in [ + "guard-471-missing-pin", + "guard-471-missing-disposition", + "guard-471-wrong-binding", + ] { + assert!( + matches!( + service.validate_owned_admission_receipt(id), + Err(crate::AdmitError::PluginGraphPinRequired) + ), + "缺 pin、缺 disposition 或证明错配必须闭门失败:{id}" + ); + } + service.shutdown().await.unwrap(); +} + +// ===== guard471:Native invoke_pinned_tool managed Err 分支直接证据 ===== +// 图不撤销、entry 放行之后,在 repository fresh 查询缝隙关闭原根, +// 字面 invoke_pinned_tool 的 cancelled/deadline_elapsed 映射必须精确落盘。 + +/// 471 探针原有内联源码,提为测试常量,使 admission 前编译的程序与 provider +/// 实际执行的是同一份源码。 +const GUARD471_NATIVE_INVOKE_SOURCE: &str = r#"pub fn run(context:map)->map { + let tool_name:string=context["tool_name"];let call_id:string=context["call_id"];let canonical_arguments:string=context["canonical_arguments"];let canonical_input:string=context["canonical_input"];let run_id:string=context["run_id"];let registry_identity:string=context["registry_identity"];let argument_digest:string=context["argument_digest"];let risk_class:string=context["risk_class"];let summary:string=context["summary"];let untrusted_graph_id:string=context["untrusted_graph_id"]; + agent_runtime::invoke_pinned_tool({tool_name:tool_name,call_id:call_id,canonical_arguments:canonical_arguments,canonical_input:canonical_input,run_id:run_id,registry_identity:registry_identity,argument_digest:argument_digest,risk_class:risk_class,summary:summary,untrusted_graph_id:untrusted_graph_id}) + }"#; + +struct Guard471NativeManagedEvidence { + positive: Value, + denied: Value, + hook_rows: Vec<(String, bool, bool)>, + root_requested: bool, + root_deadline_passed: bool, + tool_completed: usize, + history_canonical: String, + close_query_index: usize, +} + +/// 一次正式 fixture:真实终态→未撤销正控→有限 hook 关原根→同源 Native +/// invoke 拒绝。hook 在 worker 返回后清理,shutdown 后再断言。 +async fn guard471_native_managed_round(close: Guard468FinalClose) -> Guard471NativeManagedEvidence { + use crate::AgentProviderHost; + let (root, state) = + fixture_config(|config| config.run_timeout = std::time::Duration::from_secs(2)); + let service = state.service(); + let persistence = state.persistence().unwrap(); + let sentinel = root.join("native-managed-sentinel"); + let call = json!({"id":"once","name":"terminal","arguments":{"argv":["/bin/sh","-c",format!("printf x >> '{}'; printf observed",sentinel.display())],"timeout_ms":1000}}); + let canonical = call["arguments"].to_string(); + let args = call["arguments"].clone(); + // 491:原 2s 根只在下面唯一一次正式 admission 创建。先把 service 自身的 + // agent/template 与三份不可变 Native 探针编译完,窗口内不再 from_source。 + if let Err(error) = service.materialize_cached_runner() { + let cleanup = service.shutdown().await; + panic!("guard471 fixture runner materialization failed: {error} (shutdown: {cleanup:?})"); + } + let first_probe = match GuardRetainedProbeCode::compile(NATIVE_PROBE_SOURCE) { + Ok(code) => code, + Err(error) => { + let cleanup = service.shutdown().await; + panic!( + "guard471 Native first-final probe compile failed: {error} (shutdown: {cleanup:?})" + ); + } + }; + let positive_probe = match GuardRetainedProbeCode::compile(GUARD471_NATIVE_INVOKE_SOURCE) { + Ok(code) => code, + Err(error) => { + let cleanup = service.shutdown().await; + panic!( + "guard471 positive Native probe compile failed: {error} (shutdown: {cleanup:?})" + ); + } + }; + let denied_probe = match GuardRetainedProbeCode::compile(GUARD471_NATIVE_INVOKE_SOURCE) { + Ok(code) => code, + Err(error) => { + let cleanup = service.shutdown().await; + panic!("guard471 denied Native probe compile failed: {error} (shutdown: {cleanup:?})"); + } + }; + let provider = Arc::new(crate::ScriptedProvider::new()); + provider.push_ok(response(Some(call.clone()))); + service.inject_provider_host(provider.clone()); + // 唯一一次真实 admission:它创建 original 2s 根、owned handle 与 pinned binding。 + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"probe Native managed mapping after entry"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + let run_id = run.run_id.clone(); + let handle = service.handle(&run_id).unwrap(); + let original = handle.cancel.clone(); + let pin = handle + .pinned_tool + .clone() + .expect("the single admission must pin an owned managed tool"); + let binding = pin + .managed + .as_ref() + .expect("the admitted pin must own a managed binding") + .clone(); + assert!( + original.deadline_instant().is_some(), + "the original admission root must carry Some(D)" + ); + // step 2:正式 DurableProviderHost parent,恰好一次真实 provider 请求。 + let durable = crate::durable_provider::DurableProviderHost::new( + (*service).clone(), + run_id.clone(), + provider.clone(), + service.inner.metrics.clone(), + ); + let request = json!({"model":"synthetic","messages":[]}); + let parent = durable.call(&request, &original); + assert_eq!( + parent["ok"], true, + "the formal durable provider parent must succeed: {parent}" + ); + assert_eq!( + provider.requests().len(), + 1, + "the narrow fixture must issue exactly one real provider request" + ); + let host = service + .capability_host_bridges(&run_id) + .unwrap() + .expect("the admitted run must expose its original capability bridges"); + assert_original_bridge(&host, &run, &original, &binding, "once", "terminal", &args); + // step 3:真实 approval_attest + invoke_pinned_tool 执行首个 Native final。 + let first = run_retained_native_probe( + &first_probe, + &service, + host.clone(), + &run_id, + &call, + &canonical, + "execute", + ); + assert_eq!( + first["attest"]["ok"], true, + "the first Native call must obtain the real approval attestation: {first}" + ); + assert_eq!( + first["invoke"]["ok"], true, + "the first Native invoke must commit the execute final: {first}" + ); + let invoke_canonical: Value = + serde_json::from_str(first["invoke"]["canonical_result"].as_str().unwrap()).unwrap(); + assert_eq!( + invoke_canonical["content"], "observed", + "the first Native terminal must observe its once-executed output: {invoke_canonical}" + ); + assert_eq!( + fs::read(&sentinel).unwrap(), + b"x", + "the first Native terminal must execute exactly once" + ); + let first_record = + assert_first_native_final(&persistence, &binding, "once", "terminal", &canonical); + assert_eq!( + count_tool_events(&service, &run_id, "tool.started", "once"), + 1, + "the first Native call must start exactly once" + ); + assert_eq!( + count_tool_events(&service, &run_id, "tool.completed", "once"), + 1, + "the first Native call must complete exactly once" + ); + // step 4:未撤销 Native invoke 精确同 calling 必须回放历史 canonical。 + let positive = run_retained_native_probe( + &positive_probe, + &service, + host.clone(), + &run_id, + &call, + &canonical, + "execute", + ); + let history = service + .replay_managed_call(crate::effects::ManagedEffectGetRequest { + owner: binding.owner.clone(), + call_id: "once".into(), + }) + .unwrap(); + let crate::effects::ManagedCallReplay::Final { + canonical_result, .. + } = history + else { + panic!("the committed final must stay queryable as history"); + }; + let history_canonical = canonical_result.as_str().to_string(); + assert_eq!( + positive["ok"], true, + "the unrevoked positive Native invoke must succeed: {positive}" + ); + assert_eq!( + replayed_kind(&positive), + "replay", + "the unrevoked positive Native invoke must be the original replay: {positive}" + ); + assert_eq!( + positive["canonical_result"].as_str().unwrap(), + history_canonical, + "the positive replay must equal the durable history canonical" + ); + // 有限 hook:只匹配 exact fresh 查询的 enqueue 边界。第一次是 Native + // entry guard 的读;第二次才是 entry 放行后 repository 的 final + // freshness 读,只在该次(恰一次)关闭原根。 + let hook_rows = Arc::new(std::sync::Mutex::new(Vec::new())); + let hook_rows_in = Arc::clone(&hook_rows); + let seen = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen_in = Arc::clone(&seen); + let close_query = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let close_query_out = Arc::clone(&close_query); + *persistence.managed_request_hook.lock().unwrap() = + Some(Arc::new(move |op, cancel, enqueued| { + if op != "plugin.graph_revocation_get" || enqueued { + return; + } + hook_rows_in.lock().unwrap().push(( + op.to_string(), + enqueued, + cancel.shares_original_clock(&original), + )); + if seen_in.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1 != 2 { + return; + } + close_query_out.store(2, std::sync::atomic::Ordering::SeqCst); + match close { + Guard468FinalClose::Requested => { + original.request(crate::CancellationReason::Requested) + } + Guard468FinalClose::Elapsed => std::thread::sleep( + original.remaining_deadline().unwrap_or_default() + + std::time::Duration::from_millis(100), + ), + } + })); + let denied = run_retained_native_probe( + &denied_probe, + &service, + host, + &run_id, + &call, + &canonical, + "execute", + ); + // 保留原始 Option/Result,撤 hook 与 shutdown 之后才判定缺失。 + let handle = service.handle(&run_id); + let root_requested = handle + .as_ref() + .is_some_and(|handle| handle.cancel.requested().is_some()); + let root_deadline_passed = handle + .as_ref() + .is_some_and(|handle| handle.cancel.deadline_passed()); + let tool_completed = count_tool_events(&service, &run_id, "tool.completed", "once"); + let after_record = persistence + .managed_effect_get(&crate::effects::ManagedEffectGetRequest { + owner: binding.owner.clone(), + call_id: "once".into(), + }) + .unwrap() + .unwrap(); + assert_eq!( + after_record.effect_id, first_record.effect_id, + "the refusal must not replace the original effect" + ); + assert_eq!( + after_record.outcome, first_record.outcome, + "the whole canonical history must stay exactly the first committed final" + ); + let rows = hook_rows.lock().unwrap().clone(); + let close_query_index = close_query.load(std::sync::atomic::Ordering::SeqCst); + drop(handle); + *persistence.managed_request_hook.lock().unwrap() = None; + let shutdown_result = service.shutdown().await; + shutdown_result.unwrap(); + assert_eq!( + fs::read(&sentinel).unwrap(), + b"x", + "the refused Native invoke must not append a second sentinel" + ); + Guard471NativeManagedEvidence { + positive, + denied, + hook_rows: rows, + root_requested, + root_deadline_passed, + tool_completed, + history_canonical, + close_query_index, + } +} + +/// R467-2 requested:Native entry 放行后关原根,字面 invoke 分支必须 +/// 精确 cancelled,无 canonical、无第二结果,正控先成功。 +#[tokio::test(flavor = "multi_thread")] +async fn guard_contract_471_r467_2_native_invoke_requested_keeps_cancelled() { + let evidence = guard471_native_managed_round(Guard468FinalClose::Requested).await; + assert_eq!( + evidence.positive["ok"], + true, + "正控必须先成功,否则为 setup 失败而非 qualified RED:{positive}", + positive = evidence.positive + ); + assert_eq!( + replayed_kind(&evidence.positive), + "replay", + "未撤销正控必须是原 Native 结果回放:{positive}", + positive = evidence.positive + ); + assert_eq!( + evidence.positive["canonical_result"].as_str().unwrap(), + evidence.history_canonical, + "正控回放必须等于历史 canonical" + ); + assert_eq!( + evidence.close_query_index, 2, + "原根必须在 entry guard 之后的第二次 repository freshness 查询关闭" + ); + guard468_assert_freshness_stage(&evidence.hook_rows); + assert!(evidence.root_requested, "hook 必须关闭原根,不得替换新根"); + assert_eq!( + evidence.denied["ok"], + false, + "关闭原根后 Native invoke 必须拒绝:{denied}", + denied = evidence.denied + ); + assert_eq!( + evidence.denied["code"], + "cancelled", + "字面 invoke 分支必须精确 cancelled:{denied}", + denied = evidence.denied + ); + assert_eq!( + evidence.denied["message"], + "cancelled", + "拒绝 message 必须携带精确分类:{denied}", + denied = evidence.denied + ); + assert!( + evidence + .denied + .get("canonical_result") + .is_none_or(|value| value == ""), + "被拒绝的 invoke 不得泄露 canonical 结果:{denied}", + denied = evidence.denied + ); + assert_eq!(evidence.tool_completed, 1, "被拒绝的回放不得提交第二个结果"); +} + +/// R467-2 elapsed:原有限 deadline 在 fresh 查询缝隙到期,字面 invoke +/// 分支必须精确 deadline_elapsed,无 canonical、无第二结果。 +#[tokio::test(flavor = "multi_thread")] +async fn guard_contract_471_r467_2_native_invoke_elapsed_keeps_deadline_elapsed() { + let evidence = guard471_native_managed_round(Guard468FinalClose::Elapsed).await; + assert_eq!( + evidence.positive["ok"], + true, + "正控必须先成功,否则为 setup 失败而非 qualified RED:{positive}", + positive = evidence.positive + ); + assert_eq!( + replayed_kind(&evidence.positive), + "replay", + "未撤销正控必须是原 Native 结果回放:{positive}", + positive = evidence.positive + ); + assert_eq!( + evidence.positive["canonical_result"].as_str().unwrap(), + evidence.history_canonical, + "正控回放必须等于历史 canonical" + ); + assert_eq!( + evidence.close_query_index, 2, + "原根必须在 entry guard 之后的第二次 repository freshness 查询关闭" + ); + guard468_assert_freshness_stage(&evidence.hook_rows); + assert!( + evidence.root_deadline_passed, + "原有限 deadline 必须已在查询缝隙到期" + ); + assert_eq!( + evidence.denied["ok"], + false, + "原到期后 Native invoke 必须拒绝:{denied}", + denied = evidence.denied + ); + assert_eq!( + evidence.denied["code"], + "deadline_elapsed", + "字面 invoke 分支必须精确 deadline_elapsed:{denied}", + denied = evidence.denied + ); + assert_eq!( + evidence.denied["message"], + "deadline_elapsed", + "拒绝 message 必须携带精确分类:{denied}", + denied = evidence.denied + ); + assert!( + evidence + .denied + .get("canonical_result") + .is_none_or(|value| value == ""), + "被拒绝的 invoke 不得泄露 canonical 结果:{denied}", + denied = evidence.denied + ); + assert_eq!(evidence.tool_completed, 1, "被拒绝的回放不得提交第二个结果"); +} + +// ============================================================================ +// 478: first-party signedA — a formally installed signed ToolOnly tool that +// links the approved pure `string -> string` leaf library. +// +// Evidence 1 drives the real Service run to its durable read-only final, then +// replays the exact same call through the actual Native `invoke_pinned_tool` +// bridge while the original run/capability are live, then revokes the exact +// graph through the same control store and re-invokes through the original +// binding bridge. +// Evidence 2 keeps the same signed tool/read pipeline but makes the approved +// pure export fail deterministically at VM runtime on the real provided value. +// ============================================================================ +mod signed_leaf_478 { + use super::{native_json, response}; + use crate::effects::{ + ManagedArgumentIdentity, ManagedCallReplay, ManagedEffectGetRequest, ManagedOwnerScope, + ManagedPublicationStatus, + }; + use crate::plugins::{ + InstallAuthorization, PackageStore, PluginControlStore, PublisherKey, + trusted_tool_package_root, + }; + use crate::{AdmitRunRequest, AgentGatewayConfig, AgentGatewayState}; + use serde_json::{Value, json}; + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + + const LEAF_ID: &str = "org.rustscript.lib.phasetext"; + const TOOL_A_ID: &str = "org.rustscript.tools.phasea"; + const TOOL_C_ID: &str = "org.rustscript.tools.phasec"; + + // The same approved pure library export used by the existing 421/456 + // signed tool + leaf fixture. + const VALID_LEAF: &[u8] = b"pub fn format(value: string) -> string { \"libA:\" + value }\n"; + // Same manifest/export/host-import contract as VALID_LEAF, but a real + // runtime failure for the value whose length is 5: `size - 5 == 0` and the + // VM's checked integer division raises DivisionByZero. + // + // The 478 body used an arrow-form `if ... => { assignment } else => { + // assignment }` and the production parser rejected it with + // `ParseError { line: 8, message: "if expression branch must end with an + // expression" }`: an arrow-form branch is an expression position, so an + // assignment statement cannot terminate it. This body therefore uses the + // ordinary statement `while` form already used by the bundled RSS tools, + // and keeps the divisor/scale dependency observable: the loop bound is the + // quotient itself, so a value of length 2 (2 / -3 == 0) yields `libF:hi`. + const FAILING_LEAF: &[u8] = b"pub fn format(value: string) -> string {\n let size: int = value.length;\n let divisor: int = size - 5;\n let scale: int = size / divisor;\n let mut cursor: int = 0;\n let mut out: string = \"libF:\";\n while cursor < scale {\n out = out + \"x\";\n cursor = cursor + 1;\n }\n out + value\n}\n"; + + const PHASE_A_RUN_SOURCE: &str = r#"use bytes; +use text::{format}; +fn map_string(value: map, key: string, fallback: string) -> string { + let mut result: string = fallback; + if value.has(key) { + if type(value[key]) == "string" { + let coerced: string = value[key]; + result = coerced; + } + } + result +} + +fn map_map(value: map, key: string) -> map { + let mut result: map = {}; + if value.has(key) { + if type(value[key]) == "map" { + let coerced: map = value[key]; + result = coerced; + } + } + result +} + +fn map_int(value: map, key: string, fallback: int) -> int { + let mut result: int = fallback; + if value.has(key) { + if type(value[key]) == "int" { + let coerced: int = value[key]; + result = coerced; + } + } + result +} + +fn map_bool(value: map, key: string, fallback: bool) -> bool { + let mut result: bool = fallback; + if value.has(key) { + if type(value[key]) == "bool" { + let coerced: bool = value[key]; + result = coerced; + } + } + result +} + +pub fn run(context: map) -> map { + let prepared: map = agent_runtime::tool_prepare(map_map(context, "prepare")); + if map_string(prepared, "kind", "") == "replay" => { + map_map(prepared, "result") + } else if map_bool(prepared, "ok", false) == false => { + prepared + } else => { + let token: string = map_string(prepared, "execution_token", ""); + let arguments: map = map_map(context, "arguments"); + let path: string = map_string(arguments, "path", ""); + let meta: map = cap::fs_metadata(token, path); + if map_bool(meta, "ok", false) == false => { + meta + } else => { + let file_len: int = map_int(meta, "len", 0); + let read: map = cap::fs_read_range(token, path, 0, file_len); + if map_bool(read, "ok", false) == false => { + read + } else => { + let payload: bytes = read.bytes; + let labeled: string = format(bytes::to_utf8(payload)); + let result: map = { ok: true, content: labeled, data: {}, error: null, truncated: false, artifacts: [] }; + let committed: map = agent_runtime::tool_commit(token, result); + if map_string(committed, "kind", "") == "committed" => { + map_map(committed, "result") + } else => { + committed + } + } + } + } +} +"#; + + struct SignedLeafChain { + workspace: std::path::PathBuf, + db: std::path::PathBuf, + state: AgentGatewayState, + store: Arc, + graph_a: String, + generation_a: u64, + } + + fn effect_request(run: &crate::service::AdmittedRun, call_id: &str) -> ManagedEffectGetRequest { + ManagedEffectGetRequest { + owner: ManagedOwnerScope { + profile: "gateway".into(), + session_id: run.session_id.clone(), + run_id: run.run_id.clone(), + }, + call_id: call_id.into(), + } + } + + /// The fallible formal half of the fixture: resolve → prepare → activate + /// through the same production entry points the HTTP handlers use. Returns + /// the original error text instead of panicking so every caller can shut + /// the already-created Service down before reporting the first cause. + async fn activate_tool_result( + state: &AgentGatewayState, + tool_id: &str, + ) -> Result<(String, u64), String> { + let service = state.service(); + let resolved = service + .resolve_controlled_graph(&json!({ + "roots": [{"id": tool_id, "version": "1.0.0"}], + "tool_package": tool_id, + "deployment_space": "owner-space" + })) + .map_err(|error| format!("resolve_controlled_graph({tool_id}): {error:?}"))?; + let graph = resolved["graph_id"] + .as_str() + .ok_or_else(|| format!("resolve_controlled_graph({tool_id}) returned no graph_id"))? + .to_string(); + service + .prepare_controlled_graph(&graph) + .map_err(|error| format!("prepare_controlled_graph({tool_id}): {error:?}"))?; + let persistence = state + .persistence() + .ok_or_else(|| format!("persistence({tool_id}) unavailable"))?; + let expected = PluginControlStore::new(&persistence) + .activation() + .map_err(|error| format!("activation({tool_id}): {error:?}"))? + .generation; + let activated = service + .activate_controlled_graph(&json!({ + "graph_id": graph, + "deployment_space": "owner-space", + "expected_generation": expected + })) + .map_err(|error| format!("activate_controlled_graph({tool_id}): {error:?}"))?; + Ok(( + activated["graph_id"] + .as_str() + .unwrap_or_default() + .to_string(), + activated["generation"].as_u64().unwrap_or_default(), + )) + } + + /// Formally installs the signed phase-A tool (plus the shared-leaf tool C) + /// and activates tool A through the production resolve/prepare/activate + /// path. The fixture root/db/source are per-test, so the same package ids + /// can carry different approved library bodies in separate stores. + async fn open_signed_leaf_chain_with( + label: &str, + leaf_body: &[u8], + tool_source: &str, + run_timeout: std::time::Duration, + ) -> SignedLeafChain { + use crate::config::{ + ManagedMaintenancePolicy, ManagedRetentionLimits, ManagedWorkspaceConfig, + }; + use crate::effects::ResourceQuota; + use ring::rand::SystemRandom; + use ring::signature::{Ed25519KeyPair, KeyPair}; + + let leaf_manifest = format!( + "format: rustscript.plugin/v1\nid: {LEAF_ID}\nversion: 1.0.0\nkind: library\nruntime: \">=0.1.0, <0.2.0\"\ncontract: agent.library/v1\nhost_abi: agent.cap/v1\nentry:\n path: format.rss\n export: format\ndependencies: []\ncapabilities: []\neffects:\n contract: agent.effects/v1\n entries:\n format:\n direct: []\n delegated: []\n" + ); + let tool_manifest = |id: &str| { + format!( + "format: rustscript.plugin/v1\nid: {id}\nversion: 1.0.0\nkind: tool\nruntime: \">=0.1.0, <0.2.0\"\ncontract: agent.tool/v1\nhost_abi: agent.cap/v1\nentry:\n path: run.rss\n export: run\ndependencies:\n - alias: text\n id: {LEAF_ID}\n version: \"1.0.0\"\ncapabilities:\n - workspace.read\neffects:\n contract: agent.effects/v1\n entries:\n run:\n direct:\n - kind: workspace.fs.read\n scope: bound_workspace\n recovery: none\n delegated: []\n" + ) + }; + + let root = std::path::PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()) + .join(format!("t478-{label}-{}", uuid::Uuid::new_v4().simple())); + let workspace = root.join("workspace"); + let home = root.join("home"); + let source = root.join("controlled-source"); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&source).unwrap(); + std::fs::write(workspace.join("seed.txt"), b"seed").unwrap(); + std::fs::write( + home.join("config.yaml"), + format!( + "version: 1\nmodel:\n provider: local-test\n model: synthetic\nproviders:\n local-test:\n protocol: codex-responses\n base_url: https://127.0.0.1:1\nworkspaces:\n allowed_roots:\n - '{}'\n default: '{}'\n", + workspace.display(), + workspace.display() + ), + ) + .unwrap(); + { + use std::os::unix::fs::PermissionsExt; + for path in [&root, &workspace, &home, &source] { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + std::fs::set_permissions( + home.join("config.yaml"), + std::fs::Permissions::from_mode(0o600), + ) + .unwrap(); + } + let key = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap(); + let pair = Ed25519KeyPair::from_pkcs8(key.as_ref()).unwrap(); + let auth = InstallAuthorization { + admin: true, + accepted_publishers: vec![PublisherKey { + publisher: "org.rustscript".into(), + verifying_key: pair.public_key().as_ref().to_vec(), + }], + unsigned_dev_space: None, + }; + let mut config = AgentGatewayConfig { + managed_workspace: Some(ManagedWorkspaceConfig { + source_root: workspace.clone(), + quota: ResourceQuota::new(1000, 1_048_576, 16).unwrap(), + retention: ManagedRetentionLimits::default(), + maintenance_policy: ManagedMaintenancePolicy { + create_checkpoint: true, + restore: true, + branch: true, + publish: false, + }, + }), + ..AgentGatewayConfig::default() + }; + config.approvals.write = "allow".into(); + config.approvals.process = "allow".into(); + config.run_timeout = run_timeout; + let db = root.join("gateway.sqlite"); + let state = AgentGatewayState::with_sqlite_path(config, &db) + .unwrap() + .with_workspace_home(&home) + .with_plugin_control(auth.clone(), source.clone(), "owner-space") + .unwrap(); + let store = Arc::new(PackageStore::open(trusted_tool_package_root(&db)).unwrap()); + let stage = |dir: &str, manifest: &str, files: &[(&str, &[u8])]| { + let package = source.join(dir); + std::fs::create_dir_all(&package).unwrap(); + std::fs::write(package.join("manifest.yaml"), manifest).unwrap(); + for (name, body) in files { + std::fs::write(package.join(name), body).unwrap(); + } + let payload = store.signature_payload(&package).unwrap(); + std::fs::write( + package.join("signature.ed25519"), + pair.sign(&payload).as_ref(), + ) + .unwrap(); + store.install(&package, &auth).unwrap(); + }; + stage( + "package-phaselib", + &leaf_manifest, + &[("format.rss", leaf_body)], + ); + for (dir, id) in [("package-phasea", TOOL_A_ID), ("package-phasec", TOOL_C_ID)] { + stage( + dir, + &tool_manifest(id), + &[ + ("run.rss", tool_source.as_bytes()), + ( + "catalog.rss", + include_bytes!("../../rss/tools/types.rss").as_slice(), + ), + ], + ); + } + // A prepare/activation rejection must not leak the already-created + // Service: shut it down first, then report the original cause. + let (graph_a, generation_a) = match activate_tool_result(&state, TOOL_A_ID).await { + Ok(activated) => activated, + Err(error) => { + let cleanup = state.service().shutdown().await; + panic!("signed leaf fixture activation failed: {error} (shutdown: {cleanup:?})"); + } + }; + SignedLeafChain { + workspace, + db, + state, + store, + graph_a, + generation_a, + } + } + + /// Existing 478/481 callers keep the phase-A tool source and the 900 s budget. + async fn open_signed_leaf_chain(label: &str, leaf_body: &[u8]) -> SignedLeafChain { + open_signed_leaf_chain_with( + label, + leaf_body, + PHASE_A_RUN_SOURCE, + std::time::Duration::from_secs(900), + ) + .await + } + + /// Dedicated 468 read-only tool body. It keeps the real managed + /// prepare/commit contract but performs no filesystem IO: the audited pure + /// leaf (`VALID_LEAF`, `"libA:" + value`) is applied to the known `path` + /// call argument (`seed.txt`), and the resulting canonical read result is + /// really committed. A bare pure return would stay + /// `pinned_result_uncommitted`. + const GUARD468_PURE_TOOL_SOURCE: &str = r#"use text::{format}; +fn map_string(value: map, key: string, fallback: string) -> string { + let mut result: string = fallback; + if value.has(key) { + if type(value[key]) == "string" { + let coerced: string = value[key]; + result = coerced; + } + } + result +} + +fn map_map(value: map, key: string) -> map { + let mut result: map = {}; + if value.has(key) { + if type(value[key]) == "map" { + let coerced: map = value[key]; + result = coerced; + } + } + result +} + +fn map_bool(value: map, key: string, fallback: bool) -> bool { + let mut result: bool = fallback; + if value.has(key) { + if type(value[key]) == "bool" { + let coerced: bool = value[key]; + result = coerced; + } + } + result +} + +pub fn run(context: map) -> map { + let prepared: map = agent_runtime::tool_prepare(map_map(context, "prepare")); + if map_string(prepared, "kind", "") == "replay" => { + map_map(prepared, "result") + } else if map_bool(prepared, "ok", false) == false => { + prepared + } else => { + let token: string = map_string(prepared, "execution_token", ""); + let arguments: map = map_map(context, "arguments"); + let value: string = map_string(arguments, "path", ""); + let labeled: string = format(value); + let result: map = { ok: true, content: labeled, data: {}, error: null, truncated: false, artifacts: [] }; + let committed: map = agent_runtime::tool_commit(token, result); + if map_string(committed, "kind", "") == "committed" => { + map_map(committed, "result") + } else => { + committed + } + } +} +"#; + + /// Activated signed-lock facts returned beside the state so the caller + /// can independently compare them with the admitted pin before any Native + /// final. + pub(super) struct Guard468SignedReadGraph { + pub(super) graph: String, + pub(super) generation: u64, + pub(super) lock: crate::plugins::GraphLock, + pub(super) trust_ok: bool, + pub(super) catalog_present: bool, + pub(super) contract_present: bool, + } + + /// Narrow reuse entry for the four read-only 468 rounds. It reuses this + /// module's already-audited signed phase-A ToolOnly + pure-leaf fixture. + /// Signing, install, resolve/prepare/activate all finish before the single + /// admission; only the original 10 s budget is handed to the same helper, so + /// no re-admission, clock reset or write approval is introduced. + pub(super) async fn open_guard468_signed_read_chain( + label: &str, + ) -> (AgentGatewayState, Guard468SignedReadGraph) { + let chain = open_signed_leaf_chain_with( + label, + VALID_LEAF, + GUARD468_PURE_TOOL_SOURCE, + std::time::Duration::from_secs(10), + ) + .await; + let persistence = chain + .state + .persistence() + .expect("signed phase-A fixture persistence"); + let lock = crate::plugins::PluginControlStore::new(&persistence) + .load_lock(&chain.graph_a) + .expect("activated signed phase-A lock") + .lock; + let prepared = chain + .store + .prepare(&lock) + .expect("prepared signed phase-A graph"); + let trust_ok = + prepared.trust(TOOL_A_ID) == Some(crate::plugins::PackageTrust::AcceptedPublisher); + let catalog_present = prepared.managed_policy_catalog(TOOL_A_ID).is_some(); + let contract_present = prepared.managed_contract(TOOL_A_ID).is_some(); + ( + chain.state, + Guard468SignedReadGraph { + graph: chain.graph_a, + generation: chain.generation_a, + lock, + trust_ok, + catalog_present, + contract_present, + }, + ) + } + + /// Independently compare the activated signed lock and the + /// admitted pin/managed code before any Native final. The four test bodies + /// stay unchanged; a mismatch is reported through the caller's first-failure + /// cleanup path. + pub(super) fn guard468_signed_pin_facts( + pin: &crate::runtime::agent_host::PinnedToolProgram, + binding: &crate::effects::broker::ManagedRunBinding, + expected: &Guard468SignedReadGraph, + ) -> Result<(), String> { + use crate::plugins::HarnessSelection; + let tool = expected + .lock + .packages + .iter() + .find(|package| package.id == TOOL_A_ID) + .ok_or("activated signed graph must contain the phase-A tool package")?; + if expected.lock.graph_id != expected.graph { + return Err("activated signed lock must be the sealed graph".into()); + } + if expected.lock.harness_selection + != (HarnessSelection::ToolOnly { + tool_package: TOOL_A_ID.into(), + }) + { + return Err("activated signed graph must select ToolOnly phase A".into()); + } + if expected.lock.packages.len() != 2 + || tool.kind != "tool" + || !expected + .lock + .packages + .iter() + .any(|package| package.id == LEAF_ID && package.kind == "library") + { + return Err("activated signed graph must be exactly phase-A tool plus one leaf".into()); + } + if !expected.trust_ok || !expected.catalog_present || !expected.contract_present { + return Err( + "activated phase-A graph must be AcceptedPublisher with managed catalog/contract" + .into(), + ); + } + if !expected.lock.closure.iter().any(|row| { + row.package == TOOL_A_ID + && row.entry == "run" + && row.effects.iter().any(|effect| { + effect.kind == "workspace.fs.read" && effect.scope == "bound_workspace" + }) + }) { + return Err( + "activated phase-A catalog must declare workspace.fs.read/bound_workspace".into(), + ); + } + if !matches!( + &pin.harness_selection, + HarnessSelection::ToolOnly { tool_package } + if tool_package.as_str() == TOOL_A_ID + ) { + return Err("admitted pin must select ToolOnly phase A".into()); + } + if pin.graph_id != expected.graph + || pin.package_id != TOOL_A_ID + || pin.artifact_id != tool.artifact_id + || pin.state.is_some() + { + return Err("admitted pin must be the stateless activated phase-A tool package".into()); + } + if binding.code.graph_id != expected.graph + || binding.code.activation_generation != expected.generation + || binding.code.package_id != TOOL_A_ID + || binding.code.artifact_id != tool.artifact_id + || binding.code.export != "run" + { + return Err( + "admitted managed code must match the activated phase-A graph/generation/package/artifact/export" + .into(), + ); + } + Ok(()) + } + + /// The known 468 `path` argument (`seed.txt`) formatted by this + /// module's audited `VALID_LEAF` (`pub fn format(value) -> "libA:" + value`). + /// The expectation is derived from these two known inputs only, never copied + /// from the final managed record. + pub(super) const GUARD468_EXPECTED_CONTENT: &str = "libA:seed.txt"; + + struct SignedLeafReplayProvider { + persistence: Arc, + store: Arc, + service: std::sync::Weak, + run_id: std::sync::Mutex, + graph: String, + call_id: String, + path: String, + step: AtomicUsize, + replayed: std::sync::Mutex>, + refused: std::sync::Mutex>, + revoked: std::sync::Mutex>, + receipt_before: std::sync::Mutex>, + receipt_after: std::sync::Mutex>, + live: AtomicBool, + shared_clock: AtomicBool, + } + + impl crate::AgentProviderHost for SignedLeafReplayProvider { + fn call(&self, _request: &Value, cancellation: &crate::RunCancellation) -> Value { + if self.step.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 { + return json!({"ok":true,"response":response(Some(json!({"id":self.call_id,"name":"read_file","arguments":{"path":self.path}}))),"error":{}}); + } + let service = self.service.upgrade().unwrap(); + let run_id = self.run_id.lock().unwrap().clone(); + // The replay must run against the original live run/capability, + // not a reconstructed run. + self.live.store( + !service.handle(&run_id).unwrap().is_terminal(), + Ordering::SeqCst, + ); + let pin = service + .handle(&run_id) + .unwrap() + .pinned_tool + .clone() + .expect("the admitted run must carry the sealed signed tool pin"); + let binding = pin.managed.as_ref().unwrap(); + assert_eq!( + binding.code.graph_id, self.graph, + "the live pin must be the exact activated signed graph A" + ); + let owner = binding.owner.clone(); + let request = ManagedEffectGetRequest { + owner, + call_id: self.call_id.clone(), + }; + let before = self + .persistence + .managed_effect_get(&request) + .unwrap() + .expect("the first actual call must have a durable receipt"); + *self.receipt_before.lock().unwrap() = Some(serde_json::to_value(&before).unwrap()); + let ManagedArgumentIdentity::Inline { + canonical_arguments, + } = &before.identity.argument_identity + else { + panic!("small fixture arguments should remain inline"); + }; + let canonical = canonical_arguments.clone(); + let mut host = service.capability_host_bridges(&run_id).unwrap().unwrap(); + // The bridge must reference the run's original absolute deadline + // instead of creating another clock root. + self.shared_clock.store( + host.cancellation.as_ref().unwrap().deadline_instant() + == cancellation.deadline_instant(), + Ordering::SeqCst, + ); + host.pinned_tool = Some(pin); + let wire = json!({ + "tool_name":"read_file", + "call_id":self.call_id, + "canonical_arguments":canonical, + "canonical_input":"", + "run_id":run_id, + "registry_identity":host.lifecycle.as_ref().unwrap().registry_identity(), + "argument_digest":canonical, + "risk_class":"read", + "summary":"exact signed leaf replay of the committed call", + "untrusted_graph_id":"" + }); + let input = rustscript_vm::Value::map( + wire.as_object() + .unwrap() + .iter() + .map(|(key, value)| { + ( + rustscript_vm::Value::string(key), + rustscript_vm::Value::string(value.as_str().unwrap()), + ) + }) + .collect(), + ); + let source = r#"pub fn run(context:map)->map { + agent_runtime::invoke_pinned_tool({tool_name:context["tool_name"],call_id:context["call_id"],canonical_arguments:context["canonical_arguments"],canonical_input:context["canonical_input"],run_id:context["run_id"],registry_identity:context["registry_identity"],argument_digest:context["argument_digest"],risk_class:context["risk_class"],summary:context["summary"],untrusted_graph_id:context["untrusted_graph_id"]}) + }"#; + let replay = { + let runner = crate::AgentRunner::from_source(source, crate::AgentConfig::default()) + .unwrap() + .with_host(host.clone()); + native_json(&runner.run_with_context(input.clone()).unwrap()) + }; + *self.replayed.lock().unwrap() = Some(replay); + let after = self + .persistence + .managed_effect_get(&request) + .unwrap() + .expect("the original receipt must stay durable after the replay"); + *self.receipt_after.lock().unwrap() = Some(serde_json::to_value(&after).unwrap()); + // Same durable control store that admitted the pin: revoke the + // exact graph A, then re-invoke through the original binding + // bridge of the still-live run. + PluginControlStore::new(&self.persistence) + .revoke_graph(&self.store, &self.graph) + .expect("the signed tool + pure leaf topology must revoke"); + assert!( + PluginControlStore::new(&self.persistence) + .graph_revocation(&self.graph) + .unwrap() + .is_some(), + "the revocation must be durably visible before the second invoke" + ); + *self.revoked.lock().unwrap() = Some(self.graph.clone()); + let refused = { + let runner = crate::AgentRunner::from_source(source, crate::AgentConfig::default()) + .unwrap() + .with_host(host); + native_json(&runner.run_with_context(input).unwrap()) + }; + *self.refused.lock().unwrap() = Some(refused); + json!({"ok":true,"response":response(None),"error":{}}) + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn signed_leaf_a_native_replay_then_same_store_revoke_refuses_and_history_holds() { + let chain = open_signed_leaf_chain("replay", VALID_LEAF).await; + let service = chain.state.service(); + std::fs::write(chain.workspace.join("note.txt"), b"hello").unwrap(); + let call_id = "read-lib-a"; + let provider = Arc::new(SignedLeafReplayProvider { + persistence: chain.state.persistence().unwrap(), + store: Arc::clone(&chain.store), + service: Arc::downgrade(&service), + run_id: std::sync::Mutex::new(String::new()), + graph: chain.graph_a.clone(), + call_id: call_id.into(), + path: "note.txt".into(), + step: AtomicUsize::new(0), + replayed: std::sync::Mutex::new(None), + refused: std::sync::Mutex::new(None), + revoked: std::sync::Mutex::new(None), + receipt_before: std::sync::Mutex::new(None), + receipt_after: std::sync::Mutex::new(None), + live: AtomicBool::new(false), + shared_clock: AtomicBool::new(false), + }); + service.inject_provider_host(provider.clone()); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"read once through the signed leaf, then replay the exact call"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + { + let pinned = service + .handle(&run.run_id) + .unwrap() + .pinned_tool + .clone() + .unwrap(); + let binding = pinned.managed.as_ref().expect("the run must be managed"); + assert_eq!(binding.code.graph_id, chain.graph_a); + assert_eq!(binding.code.activation_generation, chain.generation_a); + } + *provider.run_id.lock().unwrap() = run.run_id.clone(); + service + .clone() + .run_worker(run.run_id.clone(), "ignored".into()) + .await; + + let events = service.run_events(&run.run_id); + println!( + "478 signed leaf replay evidence: graph={} generation={} events={events:?}", + chain.graph_a, chain.generation_a + ); + assert!( + provider.live.load(Ordering::SeqCst), + "the Native replay must run while the original run is live" + ); + assert!( + provider.shared_clock.load(Ordering::SeqCst), + "the Native bridge must reference the run's original deadline" + ); + assert_eq!( + provider.revoked.lock().unwrap().clone().as_deref(), + Some(chain.graph_a.as_str()), + "the fixture must revoke the exact admitted graph A" + ); + + // First actual call: one committed read-only receipt through the leaf. + assert_eq!( + events + .iter() + .filter(|event| { + event["event"] == "tool.completed" && event["data"]["tool_call_id"] == call_id + }) + .count(), + 1, + "the first actual call must commit exactly one tool result: {events:?}" + ); + + let before = provider + .receipt_before + .lock() + .unwrap() + .clone() + .expect("durable receipt before the replay"); + let after = provider + .receipt_after + .lock() + .unwrap() + .clone() + .expect("durable receipt after the replay"); + // The durable final receipt carries the original graph A, run and + // effect identity. + assert_eq!(before["identity"]["code"]["graph_id"], json!(chain.graph_a)); + assert_eq!( + before["identity"]["code"]["activation_generation"], + json!(chain.generation_a) + ); + assert_eq!( + before["identity"]["owner"]["run_id"], + json!(run.run_id.clone()) + ); + assert_eq!(before["identity"]["call_id"], json!(call_id)); + assert_eq!(before["state"], json!("read_only")); + assert!( + before["effect_id"] + .as_str() + .is_some_and(|id| !id.is_empty()), + "the receipt must carry the original effect id: {before}" + ); + + // Positive unrevoked replay: exact original canonical, no second + // effect or completion. + let replay = provider + .replayed + .lock() + .unwrap() + .clone() + .expect("the real Native replay must produce an envelope"); + println!("478 signed leaf native replay envelope: {replay}"); + assert_eq!(replay["ok"], json!(true), "replay must succeed: {replay}"); + assert_eq!(replay["kind"], json!("replay"), "{replay}"); + assert!( + replay["content"] + .as_str() + .unwrap_or("") + .contains("libA:hello"), + "the replay must return the original leaf-labeled canonical bytes: {replay}" + ); + assert_eq!( + before["effect_id"], after["effect_id"], + "the replay must not create a second effect record" + ); + assert_eq!( + before["outcome"], after["outcome"], + "the replay must not commit a second outcome" + ); + + let request = effect_request(&run, call_id); + let ManagedCallReplay::Final { + canonical_result, .. + } = service.replay_managed_call(request.clone()).unwrap() + else { + panic!("the original read-only receipt must stay final"); + }; + assert_eq!( + replay["canonical_result"].as_str().unwrap(), + canonical_result.as_str(), + "the Native replay must return the exact durable canonical result" + ); + let canonical: Value = serde_json::from_str(canonical_result.as_str()).unwrap(); + assert_eq!(canonical["ok"], json!(true)); + assert_eq!(canonical["content"], json!("libA:hello")); + + // Exact same-control-store revocation: the original binding bridge + // must refuse the execution replay with the typed code only. + let refused = provider + .refused + .lock() + .unwrap() + .clone() + .expect("the revoked Native invoke must produce an envelope"); + println!("478 signed leaf revoked native invoke envelope: {refused}"); + assert_eq!(refused["ok"], json!(false), "{refused}"); + assert_eq!( + refused["code"], + json!("plugin_graph_revoked"), + "the refused invoke must carry the exact typed revocation: {refused}" + ); + assert!( + refused["canonical_result"] + .as_str() + .unwrap_or("") + .is_empty(), + "the refused invoke must not leak the canonical success: {refused}" + ); + assert_eq!( + service + .run_events(&run.run_id) + .iter() + .filter(|event| { + event["event"] == "tool.completed" && event["data"]["tool_call_id"] == call_id + }) + .count(), + 1, + "the refused replay must not commit a second result" + ); + + // History stays the complete original read-only final. + let ManagedCallReplay::Final { + canonical_result, .. + } = service.replay_managed_call(request.clone()).unwrap() + else { + panic!("history must keep the original final after the revocation"); + }; + let history: Value = serde_json::from_str(canonical_result.as_str()).unwrap(); + assert_eq!(history["ok"], json!(true)); + assert_eq!(history["content"], json!("libA:hello")); + let record = chain + .state + .persistence() + .unwrap() + .managed_effect_get(&request) + .unwrap() + .unwrap(); + assert_eq!(record.state, ManagedPublicationStatus::ReadOnly); + assert_eq!( + record.outcome.as_ref().unwrap().disposition, + ManagedPublicationStatus::ReadOnly + ); + + // The shared pure leaf stays valid for another signed tool after A is + // revoked: activate tool C on the same leaf package. Every remaining + // fallible fixture step is collected so the owned Service is shut down + // before these assertions and the first cause survives. + // 共享 pure leaf 一次真实读取回归的 6 项证据;顺序与下面的解构一致: + // run_id、generation_c、graph_c、pinned_graph、canonical final、 + // workspace bytes。别名只命名既有事实,不改变任何取值或校验。 + type SharedLeafEvidence = (String, u64, String, String, Value, Vec); + let shared_leaf: Result = async { + let (graph_c, generation_c) = activate_tool_result(&chain.state, TOOL_C_ID).await?; + let read_c = Arc::new(crate::ScriptedProvider::new()); + read_c.push_ok(response(Some(json!({"id":"read-lib-c","name":"read_file","arguments":{"path":"note.txt"}})))); + read_c.push_ok(response(None)); + service.inject_provider_host(read_c); + let run_c = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"read through the shared pure leaf after A revoked"}), + ..Default::default() + }, + None, + ) + .await + .map_err(|error| format!("shared leaf admit: {error:?}"))?; + let pinned_graph = service + .handle(&run_c.run_id) + .and_then(|handle| handle.pinned_tool.clone()) + .and_then(|pin| pin.managed.as_ref().map(|binding| binding.code.graph_id.clone())) + .ok_or_else(|| "the shared leaf run has no sealed managed pin".to_string())?; + service + .clone() + .run_worker(run_c.run_id.clone(), "ignored".into()) + .await; + let shared = match service + .replay_managed_call(effect_request(&run_c, "read-lib-c")) + .map_err(|error| format!("shared leaf replay: {error}"))? + { + ManagedCallReplay::Final { + canonical_result, .. + } => serde_json::from_str::(canonical_result.as_str()) + .map_err(|error| format!("shared leaf canonical json: {error}"))?, + other => { + return Err(format!( + "the shared leaf read must commit a final receipt: {other:?}" + )); + } + }; + let bytes = std::fs::read(chain.workspace.join("note.txt")) + .map_err(|error| format!("workspace read after the shared leaf run: {error}"))?; + Ok(( + run_c.run_id.clone(), + generation_c, + graph_c, + pinned_graph, + shared, + bytes, + )) + } + .await; + + // Cleanup always runs; then the first cause, then cleanup facts. + let cleanup = service.shutdown().await; + let (shared_run_id, generation_c, graph_c, pinned_graph, shared, workspace_bytes) = + match shared_leaf { + Ok(evidence) => evidence, + Err(error) => { + panic!("shared leaf regression failed: {error} (shutdown: {cleanup:?})") + } + }; + assert!(generation_c > chain.generation_a); + assert_eq!( + pinned_graph, graph_c, + "the shared leaf run must pin the newly activated graph C" + ); + assert_eq!(shared["content"], json!("libA:hello")); + assert_eq!( + workspace_bytes, b"hello", + "the signed read must not mutate the served workspace bytes" + ); + assert!( + cleanup.is_ok(), + "the owned service must shut down: {cleanup:?}" + ); + for run_id in [&run.run_id, &shared_run_id] { + assert_eq!( + service + .handle(run_id) + .and_then(|handle| { *handle.cleanup_outcome.lock().unwrap() }), + Some(crate::CleanupOutcome::Clean), + "run {run_id} must retain a Clean capability-host cleanup outcome" + ); + assert!( + service.capability_host_closed(run_id), + "run {run_id} must leave a closed capability host" + ); + assert!( + !service.capability_host_retained(run_id), + "run {run_id} must not retain capability host state" + ); + assert_eq!( + service.process_owner_count(run_id), + 0, + "run {run_id} must leave no live process owner" + ); + assert!( + service + .capability_host_bridges(run_id) + .ok() + .flatten() + .is_none(), + "run {run_id} must not expose a usable capability host bridge/token" + ); + } + let _ = &chain.db; + } + + /// Defensive bounded poll budget for one pure invocation: the leaf performs + /// no host operation, so a Pending poll must never be the steady state. + const PURE_INVOCATION_POLLS: usize = 1_000; + + #[derive(Debug)] + enum LeafInvocation { + Complete(rustscript_vm::Value), + Typed(rustscript_vm::InvocationError), + } + + /// Test-only probe of an already compiled pure leaf program: it evaluates + /// the capture-time program on the real VM with the capture-time registry + /// and keeps the VM's own typed invocation error instead of collapsing it + /// into a string. No production seam is added for it. + fn invoke_compiled_pure_leaf( + program: &Arc, + registry: &Arc, + entry: &str, + input: rustscript_vm::Value, + ) -> Result { + let mut vm = rustscript_vm::Vm::try_new(program.as_ref().clone()) + .map_err(|error| format!("leaf vm init: {error}"))?; + registry + .bind_vm_cached(&mut vm) + .map_err(|error| format!("leaf registry bind: {error}"))?; + vm.set_fuel(10_000_000); + match vm.run() { + Ok(rustscript_vm::VmStatus::Halted) => {} + other => return Err(format!("leaf root frame did not halt: {other:?}")), + } + let callable = vm + .resolve_exported_callable(entry) + .map_err(|error| format!("leaf export `{entry}`: {error}"))?; + let mut invocation = vm + .start_invocation(callable, vec![input]) + .map_err(|error| format!("leaf invocation start: {error}"))?; + for _ in 0..PURE_INVOCATION_POLLS { + match invocation + .poll_next() + .map_err(|error| format!("leaf invocation poll: {error}"))? + { + rustscript_vm::InvocationPoll::Pending => continue, + rustscript_vm::InvocationPoll::Ready(Some(Ok( + rustscript_vm::InvocationItem::Complete(value), + ))) => return Ok(LeafInvocation::Complete(value)), + rustscript_vm::InvocationPoll::Ready(Some(Ok( + rustscript_vm::InvocationItem::Event(_), + ))) => continue, + rustscript_vm::InvocationPoll::Ready(Some(Err(error))) => { + return Ok(LeafInvocation::Typed(error)); + } + rustscript_vm::InvocationPoll::Ready(None) => { + return Err("leaf invocation ended without a result".to_string()); + } + } + } + Err("leaf invocation did not settle within the bounded poll budget".to_string()) + } + + struct PreparedLeafProbe { + entry: String, + control: Result, + failing: Result, + } + + /// Provenance: the exact `PreparedGraph` artifact the formal + /// resolve → prepare path produced for this signed store. The probe never + /// recompiles a re-typed copy of the fixture body, so the typed observation + /// is about the same pure leaf program the Service later invokes. + fn prepared_leaf_probe(chain: &SignedLeafChain) -> Result { + let lock = chain + .store + .resolve(&crate::plugins::ResolveRequest { + harness_selection: crate::plugins::HarnessSelectionRequest::ToolOnly { + tool_package: TOOL_A_ID.into(), + }, + roots: vec![crate::plugins::PackageRequirement { + id: TOOL_A_ID.into(), + version: "1.0.0".into(), + }], + host_runtime: crate::plugins::HOST_RUNTIME.into(), + }) + .map_err(|error| format!("typed probe resolve: {error:?}"))?; + let prepared = chain + .store + .prepare(&lock) + .map_err(|error| format!("typed probe prepare: {error:?}"))?; + let program = prepared + .program(LEAF_ID) + .ok_or_else(|| "typed probe: prepared leaf program missing".to_string())?; + let registry = prepared + .registry(LEAF_ID) + .ok_or_else(|| "typed probe: prepared leaf registry missing".to_string())? + .map_err(|error| format!("typed probe: prepared leaf registry unusable: {error}"))?; + let entry = prepared + .entry_callable(LEAF_ID) + .ok_or_else(|| "typed probe: prepared leaf entry callable missing".to_string())? + .to_string(); + let control = invoke_compiled_pure_leaf( + &program, + ®istry, + &entry, + rustscript_vm::Value::string("hi"), + ); + let failing = invoke_compiled_pure_leaf( + &program, + ®istry, + &entry, + rustscript_vm::Value::string("hello"), + ); + Ok(PreparedLeafProbe { + entry, + control, + failing, + }) + } + + /// Sealed managed pin of an admitted run: (graph id, activation generation). + fn sealed_pin(service: &crate::AgentService, run_id: &str) -> Option<(String, u64)> { + service + .handle(run_id) + .and_then(|handle| handle.pinned_tool.clone()) + .and_then(|pin| { + pin.managed.as_ref().map(|binding| { + ( + binding.code.graph_id.clone(), + binding.code.activation_generation, + ) + }) + }) + } + + struct LeafFailureObservations { + run_id: String, + control_canonical: Value, + control_graph: String, + failing_graph: String, + failing_generation: u64, + events: Vec, + record: Option, + first_replay: ManagedCallReplay, + second_replay: ManagedCallReplay, + } + + struct LeafCleanupFacts { + cleanup_outcome: Option, + host_closed: bool, + host_retained: bool, + process_owners: usize, + bridges_gone: bool, + } + + #[tokio::test(flavor = "multi_thread")] + async fn signed_pure_leaf_runtime_failure_is_bounded_and_leaves_no_success_receipt() { + let chain = open_signed_leaf_chain("leaf-fail", FAILING_LEAF).await; + let service = chain.state.service(); + std::fs::write(chain.workspace.join("note.txt"), b"hello").unwrap(); + std::fs::write(chain.workspace.join("short.txt"), b"hi").unwrap(); + + // Typed program proof. It executes before the first Service call; its + // assertion is deferred so the owned Service is always cleaned up. + let typed = prepared_leaf_probe(&chain); + let mut failing_run_id: Option = None; + let observed: Result = { + let service = service.clone(); + let persistence = chain.state.persistence(); + let failing_run_id = &mut failing_run_id; + async move { + // Control: the same signed tool/leaf graph with a real + // non-failing value (length 2), so the fixture itself is sound. + let control = Arc::new(crate::ScriptedProvider::new()); + control.push_ok(response(Some(json!({"id":"read-short","name":"read_file","arguments":{"path":"short.txt"}})))); + control.push_ok(response(None)); + service.inject_provider_host(control); + let run_ok = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"read the short file through the approved pure leaf"}), + ..Default::default() + }, + None, + ) + .await + .map_err(|error| format!("control admit: {error:?}"))?; + let (control_graph, _) = sealed_pin(&service, &run_ok.run_id) + .ok_or_else(|| "the control run has no sealed managed pin".to_string())?; + service + .clone() + .run_worker(run_ok.run_id.clone(), "ignored".into()) + .await; + let control_canonical = match service + .replay_managed_call(effect_request(&run_ok, "read-short")) + .map_err(|error| format!("control replay: {error}"))? + { + ManagedCallReplay::Final { + canonical_result, .. + } => serde_json::from_str::(canonical_result.as_str()) + .map_err(|error| format!("control canonical json: {error}"))?, + other => { + return Err(format!( + "the control call must commit a final receipt: {other:?}" + )); + } + }; + + // The same graph/tool/read path with the real value whose + // length makes the approved pure export hit checked division. + let failing = Arc::new(crate::ScriptedProvider::new()); + failing.push_ok(response(Some(json!({"id":"read-fail","name":"read_file","arguments":{"path":"note.txt"}})))); + failing.push_ok(response(None)); + service.inject_provider_host(failing); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"read the failing value through the approved pure leaf"}), + ..Default::default() + }, + None, + ) + .await + .map_err(|error| format!("failing admit: {error:?}"))?; + *failing_run_id = Some(run.run_id.clone()); + let (failing_graph, failing_generation) = sealed_pin(&service, &run.run_id) + .ok_or_else(|| "the failing run has no sealed managed pin".to_string())?; + service + .clone() + .run_worker(run.run_id.clone(), "ignored".into()) + .await; + let events = service.run_events(&run.run_id); + let request = effect_request(&run, "read-fail"); + let record = persistence + .as_ref() + .ok_or_else(|| "fixture persistence unavailable".to_string())? + .managed_effect_get(&request) + .map_err(|error| format!("failing record read: {error}"))?; + let first_replay = service + .replay_managed_call(request.clone()) + .map_err(|error| format!("failing replay 1: {error}"))?; + let second_replay = service + .replay_managed_call(request) + .map_err(|error| format!("failing replay 2: {error}"))?; + Ok(LeafFailureObservations { + run_id: run.run_id.clone(), + control_canonical, + control_graph, + failing_graph, + failing_generation, + events, + record, + first_replay, + second_replay, + }) + } + } + .await; + + // Cleanup always runs: both worker futures returned, this test installs + // no observer hook of its own, and shutdown closes every capability + // host and joins the janitor/storage owners. + let cleanup = service.shutdown().await; + let cleanup_facts = failing_run_id.as_ref().map(|run_id| LeafCleanupFacts { + cleanup_outcome: service + .handle(run_id) + .and_then(|handle| *handle.cleanup_outcome.lock().unwrap()), + host_closed: service.capability_host_closed(run_id), + host_retained: service.capability_host_retained(run_id), + process_owners: service.process_owner_count(run_id), + bridges_gone: service + .capability_host_bridges(run_id) + .ok() + .flatten() + .is_none(), + }); + + // First cause, then the collected evidence, then the cleanup facts. + let probe = match typed { + Ok(probe) => probe, + Err(error) => { + panic!("prepared pure leaf typed probe failed: {error} (shutdown: {cleanup:?})") + } + }; + let observed = match observed { + Ok(observed) => observed, + Err(error) => { + panic!("pure leaf service evidence failed: {error} (shutdown: {cleanup:?})") + } + }; + + // 1. Typed proof on the same prepared pure leaf program. + assert_eq!( + probe.entry, "format", + "the prepared leaf entry callable must be the declared export" + ); + match probe.control { + Ok(LeafInvocation::Complete(value)) => assert_eq!( + value, + rustscript_vm::Value::string("libF:hi"), + "the same prepared program must return libF:hi for the short control" + ), + other => panic!( + "the non-failing control must complete on the prepared leaf program: {other:?}" + ), + } + match probe.failing { + Ok(LeafInvocation::Typed(rustscript_vm::InvocationError::Vm( + rustscript_vm::VmError::DivisionByZero, + ))) => {} + other => panic!( + "the prepared leaf must fail with the typed \ + InvocationError::Vm(VmError::DivisionByZero): {other:?}" + ), + } + + // 2. The real Service path on the same signed graph A. + let call_id = "read-fail"; + assert_eq!( + observed.control_graph, chain.graph_a, + "the short control must run the same signed graph A" + ); + assert_eq!( + observed.failing_graph, chain.graph_a, + "the failing call must run the same signed graph A" + ); + assert_eq!( + observed.control_canonical["ok"], + json!(true), + "the control call must succeed: {}", + observed.control_canonical + ); + assert_eq!( + observed.control_canonical["content"], + json!("libF:hi"), + "the control call must return the leaf-labeled short bytes: {}", + observed.control_canonical + ); + + let started: Vec<&Value> = observed + .events + .iter() + .filter(|event| event["event"] == "tool.started") + .collect(); + assert_eq!( + started.len(), + 1, + "the failing call must produce exactly one real tool.started: {:?}", + observed.events + ); + assert_eq!( + started[0]["run_id"], + json!(observed.run_id), + "tool.started must belong to the observed run: {:?}", + started[0] + ); + assert_eq!( + started[0]["data"]["tool_call_id"], + json!(call_id), + "tool.started must carry the exact failing call id: {:?}", + started[0] + ); + assert_eq!( + started[0]["data"]["generation"], + json!(observed.failing_generation), + "tool.started must carry the sealed activation generation of graph A: {:?}", + started[0] + ); + assert_eq!( + observed + .events + .iter() + .filter(|event| event["event"] == "tool.completed" + && event["data"]["tool_call_id"] == json!(call_id)) + .count(), + 0, + "a failed pure export must not report a completed tool call: {:?}", + observed.events + ); + let terminals: Vec<&Value> = observed + .events + .iter() + .filter(|event| { + matches!( + event["event"].as_str(), + Some("run.completed") | Some("run.failed") | Some("run.cancelled") + ) + }) + .collect(); + assert_eq!( + terminals.len(), + 1, + "the failed run must surface exactly one terminal run event: {:?}", + observed.events + ); + let terminal = terminals[0]; + assert_eq!( + terminal["event"], "run.failed", + "a bounded Native runtime failure must fail the run: {terminal}" + ); + assert_eq!( + terminal["run_id"], + json!(observed.run_id), + "the terminal event must belong to the observed run: {terminal}" + ); + assert_eq!( + terminal["data"]["error_code"], + json!("agent_failed"), + "the bounded failure must keep the existing run failure classification: {terminal}" + ); + assert!( + terminal["data"]["error_message"] + .as_str() + .unwrap_or_default() + .contains("division by zero"), + "the failure must carry the actual bounded VM message: {terminal}" + ); + assert!( + !serde_json::to_string(&observed.events) + .unwrap_or_default() + .contains("libF:hello"), + "no success canonical may appear in the failed run events: {:?}", + observed.events + ); + + // 3. Tight record oracle: the begun (never prepared) managed call. + let record = observed + .record + .as_ref() + .expect("the begun managed call must keep a durable receipt"); + assert_eq!( + record.state, + ManagedPublicationStatus::Indeterminate, + "an interrupted managed call must be recorded as Indeterminate" + ); + assert!( + record.prepared.is_none(), + "the failing call never reached a prepared observation" + ); + assert!( + record.outcome.is_none(), + "the failing call must not carry a committed outcome" + ); + assert_eq!( + record.identity.code.graph_id, observed.failing_graph, + "the receipt must carry the exact signed graph A" + ); + assert_eq!( + record.identity.owner.run_id, observed.run_id, + "the receipt must carry the exact failing run" + ); + assert_eq!( + record.identity.call_id, call_id, + "the receipt must carry the exact failing call" + ); + match (&observed.first_replay, &observed.second_replay) { + ( + ManagedCallReplay::Indeterminate { record: first }, + ManagedCallReplay::Indeterminate { record: second }, + ) => { + assert_eq!( + first.as_ref(), + record, + "the replay must return the exact durable record" + ); + assert_eq!( + first.as_ref(), + second.as_ref(), + "the exact Indeterminate replay must be repeatable" + ); + } + (first, second) => panic!( + "the interrupted call must stay exactly Indeterminate, got {first:?} / {second:?}" + ), + } + + // 4. The served workspace bytes are unchanged by both reads. + assert_eq!( + std::fs::read(chain.workspace.join("note.txt")).unwrap(), + b"hello", + "the failed read must not mutate the served workspace bytes" + ); + assert_eq!( + std::fs::read(chain.workspace.join("short.txt")).unwrap(), + b"hi", + "the control read must not mutate the served workspace bytes" + ); + + // 5. Bounded cleanup facts of the already-shut-down owned Service. + assert!( + cleanup.is_ok(), + "the owned service must shut down: {cleanup:?}" + ); + let facts = cleanup_facts.expect("the failing run must stay queryable for cleanup facts"); + assert_eq!( + facts.cleanup_outcome, + Some(crate::CleanupOutcome::Clean), + "the failed run must record a Clean capability-host cleanup outcome" + ); + assert!( + facts.host_closed, + "the failed run must leave a closed capability host" + ); + assert!( + !facts.host_retained, + "the failed run must not retain capability host state" + ); + assert_eq!( + facts.process_owners, 0, + "the failed run must leave no live process owner" + ); + assert!( + facts.bridges_gone, + "the failed run must expose no usable capability host bridge/token" + ); + let _ = &chain.db; + } + + // ============================================================================ + // 485: the original admission deadline driven into a retained child VM after + // a delayed entry. Layer proof only: it reuses the signed ToolOnly fixture + // and the production `invoke_retained_tool`; it claims no full Service run, + // no durable effect receipt and no run terminal. + // ============================================================================ + + /// Clock-only library body. The short control (`length <= 2`) returns at + /// once; a longer input enters an ordinary `while true { }` pure CPU loop + /// that cannot finish inside the child window. The constant loop is chosen + /// before the first execution: it performs no arithmetic on the input + /// length, so the long leg does not rest on an unwitnessed counted-loop + /// bound or on an unused counter being kept alive. The RSS compiler already + /// accepts `while true { }` in the captured-pure fixture. + const CLOCK_LEAF: &[u8] = b"pub fn format(value: string) -> string {\n let mut out: string = \"libC:\" + value;\n if value.length > 2 {\n while true { }\n }\n out\n}\n"; + + /// Clock-only tool body: the same manifest/permissions as phase A, but the + /// only host interaction is the pure `agent::control_check()` executed + /// immediately before the pure leaf call. No prepare/commit, no I/O, no + /// capability token, and nothing after the marker but the leaf call. + const CLOCK_TOOL_SOURCE: &str = r#"use agent; +use text::{format}; +fn map_string(value: map, key: string, fallback: string) -> string { + let mut result: string = fallback; + if value.has(key) { + if type(value[key]) == "string" { + let coerced: string = value[key]; + result = coerced; + } + } + result +} + +fn map_map(value: map, key: string) -> map { + let mut result: map = {}; + if value.has(key) { + if type(value[key]) == "map" { + let coerced: map = value[key]; + result = coerced; + } + } + result +} + +pub fn run(context: map) -> map { + let arguments: map = map_map(context, "arguments"); + let value: string = map_string(arguments, "value", ""); + let control: map = agent::control_check(); + let labeled: string = format(value); + { ok: true, content: labeled, control: control } +} +"#; + + struct ClockRetainedTool { + program: Arc, + registry: Result, String>, + entry: String, + } + + /// Compiled linked tool program/registry/entry from the same signed store + /// the Service activated. Called before admission so no compilation work + /// shares the timed window. + fn prepared_clock_tool(chain: &SignedLeafChain) -> Result { + let lock = chain + .store + .resolve(&crate::plugins::ResolveRequest { + harness_selection: crate::plugins::HarnessSelectionRequest::ToolOnly { + tool_package: TOOL_A_ID.into(), + }, + roots: vec![crate::plugins::PackageRequirement { + id: TOOL_A_ID.into(), + version: "1.0.0".into(), + }], + host_runtime: crate::plugins::HOST_RUNTIME.into(), + }) + .map_err(|error| format!("clock retained tool resolve: {error:?}"))?; + let prepared = chain + .store + .prepare(&lock) + .map_err(|error| format!("clock retained tool prepare: {error:?}"))?; + let program = prepared + .program(TOOL_A_ID) + .ok_or_else(|| "clock retained tool program missing".to_string())?; + let registry = prepared + .registry(TOOL_A_ID) + .ok_or_else(|| "clock retained tool registry missing".to_string())?; + let entry = prepared + .entry_callable(TOOL_A_ID) + .ok_or_else(|| "clock retained tool entry callable missing".to_string())? + .to_string(); + Ok(ClockRetainedTool { + program, + registry, + entry, + }) + } + + /// One observation of the child clock at the `agent::control_check()` marker. + #[derive(Clone)] + struct ClockRow { + tag: &'static str, + at: std::time::Instant, + deadline: Option, + shares: bool, + same_root: bool, + armed: bool, + child: crate::runtime::rss_runner::RunCancellation, + } + + /// Recording-only control hook. It never sleeps, never requests cancellation + /// and never fabricates a clock: it only snapshots the live child. + fn clock_bridges( + original: &crate::runtime::rss_runner::RunCancellation, + tag: &'static str, + rows: Arc>>, + ) -> crate::runtime::agent_host::AgentHostBridges { + let original = original.clone(); + let mut bridges = crate::runtime::agent_host::AgentHostBridges::default(); + let hook: crate::runtime::agent_host::ControlCheckHook = + Arc::new(move |child: &crate::runtime::rss_runner::RunCancellation| { + rows.lock().unwrap().push(ClockRow { + tag, + at: std::time::Instant::now(), + deadline: child.deadline_instant(), + shares: child.shares_original_clock(&original), + same_root: child.same_root(&original), + armed: child.watcher_is_armed(), + child: child.clone(), + }); + }); + bridges.control_hook = Some(hook); + bridges + } + + #[tokio::test(flavor = "multi_thread")] + async fn child_clock_485_delayed_entry_keeps_original_deadline_before_child_budget() { + use crate::runtime::rss_runner::invoke_retained_tool; + use std::sync::Mutex; + use std::time::{Duration, Instant}; + + const T: Duration = Duration::from_secs(2); + const RESERVE: Duration = Duration::from_millis(700); + const ALLOWANCE: Duration = Duration::from_millis(250); + const LONG_LEN: usize = 64 * 1024; + + // Compile/sign/install/activate and prepare the retained program before + // the original clock exists. + let chain = + open_signed_leaf_chain_with("child-clock", CLOCK_LEAF, CLOCK_TOOL_SOURCE, T).await; + let service = chain.state.service(); + let tool = match prepared_clock_tool(&chain) { + Ok(tool) => tool, + Err(error) => { + let cleanup = service.shutdown().await; + panic!("clock retained tool preparation failed: {error} (shutdown: {cleanup:?})"); + } + }; + assert_eq!( + tool.entry, "run", + "the retained tool entry must be the declared export" + ); + // Compile the Service's own agent runner before the clock exists so + // admission cannot spend the short T on lazy compilation. + if let Err(error) = service.materialize_cached_runner() { + let cleanup = service.shutdown().await; + panic!("clock fixture runner materialization failed: {error} (shutdown: {cleanup:?})"); + } + + let run = match service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message":"485 child clock delayed entry"}), + ..Default::default() + }, + None, + ) + .await + { + Ok(run) => run, + Err(error) => { + let cleanup = service.shutdown().await; + panic!("clock admission failed: {error:?} (shutdown: {cleanup:?})"); + } + }; + let handle = match service.handle(&run.run_id) { + Some(handle) => handle, + None => { + let cleanup = service.shutdown().await; + panic!("admitted clock run has no handle (shutdown: {cleanup:?})"); + } + }; + // T was configured on the fixture before admission; this is the + // admission's own root, never a fresh child `with_timeout`. + let admitted_at = Instant::now(); + let original = handle.cancel.clone(); + let deadline = original + .deadline_instant() + .expect("the original admission must carry Some(D)"); + assert!( + deadline > admitted_at, + "the original deadline must still be in the future" + ); + assert!( + deadline <= admitted_at + T, + "T must be configured before admission, not at child entry" + ); + assert!( + !original.has_deadline_overflow(), + "the original clock must be a real Instant deadline" + ); + assert_eq!( + sealed_pin(&service, &run.run_id).map(|pin| pin.0), + Some(chain.graph_a.clone()), + "the admitted run must pin the signed phase-A graph" + ); + + let rows = Arc::new(Mutex::new(Vec::new())); + let short = invoke_retained_tool( + Arc::clone(&tool.program), + &tool.entry, + clock_bridges(&original, "short", Arc::clone(&rows)), + crate::domain::json_to_vm_value(&json!({"arguments": {"value": "hi"}})), + &original, + &tool.registry, + None, + ); + + // One delayed entry: wait until RESERVE before the original D, then run + // the long pure input on the real child VM. + let mut long_entry = None; + let mut long_ended = Instant::now(); + let long = match &short { + Ok(_) => { + std::thread::sleep( + deadline + .saturating_duration_since(Instant::now()) + .saturating_sub(RESERVE), + ); + long_entry = Some(Instant::now()); + let result = invoke_retained_tool( + Arc::clone(&tool.program), + &tool.entry, + clock_bridges(&original, "long", Arc::clone(&rows)), + crate::domain::json_to_vm_value(&json!({ + "arguments": {"value": "x".repeat(LONG_LEN)} + })), + &original, + &tool.registry, + None, + ); + long_ended = Instant::now(); + result + } + Err(_) => Err("short control failed; the long leg was not run".to_string()), + }; + let observed = rows.lock().unwrap().clone(); + let cleanup = service.shutdown().await; + let cleanup_facts = ( + service + .handle(&run.run_id) + .and_then(|handle| *handle.cleanup_outcome.lock().unwrap()), + service.capability_host_closed(&run.run_id), + service.capability_host_retained(&run.run_id), + service.process_owner_count(&run.run_id), + service + .capability_host_bridges(&run.run_id) + .ok() + .flatten() + .is_none(), + ); + let _ = &chain.db; + + // 1. Short positive control on the very same original clock. + let short = match short { + Ok(value) => value, + Err(error) => { + panic!("short clock control failed: {error} (shutdown: {cleanup:?})") + } + }; + let short_json = native_json(&short); + assert_eq!(short_json["ok"], json!(true)); + assert_eq!(short_json["content"], json!("libC:hi")); + assert_eq!( + short_json["control"]["ok"], + json!(true), + "the marker must read an open clock: {}", + short_json["control"] + ); + + // 2. Both markers observed the same live child clock. + assert_eq!( + observed.len(), + 2, + "one marker per retained invocation, got {}", + observed.len() + ); + let short_row = &observed[0]; + let long_row = &observed[1]; + assert_eq!((short_row.tag, long_row.tag), ("short", "long")); + for row in [short_row, long_row] { + assert_eq!( + row.deadline, + Some(deadline), + "the child must carry the original Some(D)" + ); + assert!( + row.shares, + "the child must share the original request/deadline flags" + ); + assert!( + !row.same_root, + "a child owns a distinct inner root, not the original Arc" + ); + assert!( + row.armed, + "the child epoch watcher must already be armed at the marker" + ); + assert!( + row.at < deadline, + "the marker must be reached before the original D" + ); + } + + // 3. The long leg is a delayed entry: the delay from admission exceeds + // the existing settle allowance, so D + allowance is strictly earlier + // than a hypothetical child-entry + full T. + let entry = long_entry.expect("the long leg must enter after the control"); + assert!( + entry.duration_since(admitted_at) > ALLOWANCE, + "the long child must enter after a distinguishable delay" + ); + assert!( + deadline + ALLOWANCE < entry + T, + "D + allowance must be strictly earlier than child entry + T" + ); + assert!(entry < deadline, "the long child must enter before D"); + + // 4. The long leg terminates only from the original deadline. + let error = match long { + Ok(value) => panic!( + "the long pure child must not complete: {:?}", + native_json(&value) + ), + Err(error) => error, + }; + assert!( + error == "run cancelled (deadline)" || error.starts_with("run exceeded its deadline ("), + "the long child must terminate on the original deadline, got {error}" + ); + assert!( + long_ended <= deadline + ALLOWANCE, + "the cancel settle exceeded the existing allowance: {error}" + ); + assert!( + long_ended < entry + T, + "the child must not receive a fresh full T from its entry" + ); + assert!( + original.requested().is_none(), + "the case must not synthesize an explicit cancellation request" + ); + assert!(original.deadline_passed()); + assert!( + !long_row.child.watcher_is_armed(), + "the long child watcher must be disarmed and joined" + ); + assert!( + !short_row.child.watcher_is_armed(), + "the control child watcher must be disarmed and joined" + ); + + // 5. Owned service cleanup. This layer case starts no worker, so the + // run handle releases an Empty capability host to Closed(None)/Clean. + assert!( + cleanup.is_ok(), + "the owned service must shut down: {cleanup:?}" + ); + assert_eq!(cleanup_facts.0, Some(crate::CleanupOutcome::Clean)); + assert!( + cleanup_facts.1, + "the run must leave a closed capability host" + ); + assert!( + !cleanup_facts.2, + "the run must not retain capability host state" + ); + assert_eq!(cleanup_facts.3, 0, "the run must leave no process owner"); + assert!(cleanup_facts.4, "the run must expose no usable bridge"); + } +} diff --git a/src/service/managed_graph_tests.rs b/src/service/managed_graph_tests.rs index f8e4556..859dbe1 100644 --- a/src/service/managed_graph_tests.rs +++ b/src/service/managed_graph_tests.rs @@ -163,6 +163,64 @@ async fn current_http_routes_preserve_missing_package_and_revoked_graph_codes() service.shutdown().await.unwrap(); } +#[tokio::test(flavor = "multi_thread")] +async fn begun_call_finishes_and_exact_replay_does_not_execute_again() { + let (root, state) = fixture(); + let service = state.service(); + let p = state.persistence().unwrap(); + let sentinel = root.join("begun-entered-sentinel"); + let provider = Arc::new(ExactReplayProvider { + persistence: p.clone(), + service: Arc::downgrade(&service), + run_id: std::sync::Mutex::new(String::new()), + call: json!({"id":"once","name":"terminal","arguments":{"argv":["/bin/sh","-c",format!("printf x >> '{}'; printf observed",sentinel.display())],"timeout_ms":1000}}), + step: std::sync::atomic::AtomicUsize::new(0), + replayed: std::sync::Mutex::new(None), + }); + service.inject_provider_host(provider.clone()); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"text":"begin then exact replay"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + *provider.run_id.lock().unwrap() = run.run_id.clone(); + // No revocation: the begun Native call must finish and the same-identity + // exact replay must verify without a second native execution. + service + .clone() + .run_worker(run.run_id.clone(), String::new()) + .await; + assert_eq!(fs::read(&sentinel).unwrap(), b"x"); + assert_eq!( + provider.replayed.lock().unwrap().as_ref().unwrap()["ok"], + true + ); + let record = p + .managed_effect_get(&crate::effects::ManagedEffectGetRequest { + owner: service + .handle(&run.run_id) + .unwrap() + .pinned_tool + .clone() + .unwrap() + .managed + .as_ref() + .unwrap() + .owner + .clone(), + call_id: "once".into(), + }) + .unwrap() + .unwrap(); + assert!(record.outcome.is_some()); + service.shutdown().await.unwrap(); +} + #[tokio::test(flavor = "multi_thread")] async fn already_begun_call_finishes_after_revoke_and_exact_replay_does_not_execute_again() { let (root, state) = fixture(); @@ -212,27 +270,352 @@ async fn already_begun_call_finishes_after_revoke_and_exact_replay_does_not_exec .clone() .run_worker(run.run_id.clone(), String::new()) .await; + // R470-2: clear the prepare hook the moment the worker returns, before + // any fallible summary read, so a failed read cannot unwind with the + // p→hook→p cycle still installed. Summaries stay Results until the + // shutdown completes; only then are they asserted. *p.managed_request_hook.lock().unwrap() = None; + let request = crate::effects::ManagedEffectGetRequest { + owner: pin.managed.as_ref().unwrap().owner.clone(), + call_id: "once".into(), + }; + let events = service.run_events(&run.run_id); + let record_result = p.managed_effect_get(&request); + let replay_result = service.replay_managed_call(request.clone()); + let facade_result = service.replay_durable_tool_result(&run.run_id, "once", "terminal"); + let step = provider.step.load(std::sync::atomic::Ordering::SeqCst); + let replayed_none = provider.replayed.lock().unwrap().is_none(); + let tombstone_result = crate::plugins::PluginControlStore::new(&p).graph_revocation(&graph); + let shutdown_result = service.shutdown().await; + shutdown_result.unwrap(); + let record = record_result.unwrap().unwrap(); + let replay = replay_result.unwrap(); + let facade = facade_result.unwrap().unwrap(); + let tombstone = tombstone_result.unwrap().is_some(); + // Already-begun first result finishes exactly once under the early + // Service revoke; the second provider never runs. This is Service + // scheduling coverage only, never a Native PASS from a missing probe. assert_eq!(fs::read(&sentinel).unwrap(), b"x"); assert_eq!( - provider.replayed.lock().unwrap().as_ref().unwrap()["ok"], - true + step, 1, + "second provider must never run after the early revoke" ); - let record = p - .managed_effect_get(&crate::effects::ManagedEffectGetRequest { - owner: pin.managed.as_ref().unwrap().owner.clone(), + assert!( + replayed_none, + "Native replay probe must stay None when the Service guard stops the second provider" + ); + let completed = events + .iter() + .filter(|event| event["event"] == "tool.completed") + .count(); + let failed = events + .iter() + .filter(|event| event["event"] == "tool.failed") + .count(); + assert_eq!( + completed, 1, + "begun first result must complete exactly once" + ); + assert_eq!(failed, 0, "begun first result must not fail"); + assert!( + events.iter().any(|event| event["event"] == "run.failed" + && event.to_string().contains("plugin_graph_revoked")), + "run must end revoked after the early Service denial: {events:?}" + ); + assert!( + record.outcome.is_some(), + "original outcome must stay durable" + ); + let crate::effects::ManagedCallReplay::Final { + outcome, + canonical_result, + } = replay + else { + panic!("original canonical receipt must stay queryable after revocation"); + }; + // R470-1: exact first-result contract, not a substring probe. The live + // command prints exactly `observed` with a clean exit, so the sealed + // canonical bytes must decode to that complete success value, and the + // history receipt must carry the original durable outcome. + assert_eq!( + *outcome, + record + .outcome + .clone() + .expect("original outcome must stay durable"), + "history canonical must belong to the original durable outcome" + ); + let full: Value = serde_json::from_str(canonical_result.as_str()).unwrap(); + let tool_result = crate::service::canonical_tool_result(&full) + .expect("history canonical must decode through the existing ToolResult contract"); + assert!( + tool_result.ok, + "first result must be an exact success: {full:?}" + ); + assert_eq!( + tool_result.content, "observed", + "first result content must equal the actual command output: {full:?}" + ); + assert!( + tool_result.error.is_none(), + "first result must carry no error: {full:?}" + ); + assert!( + !tool_result.truncated, + "first result must not be truncated: {full:?}" + ); + assert!( + tool_result.artifacts.is_empty(), + "first result must carry no artifacts: {full:?}" + ); + assert_eq!( + serde_json::to_value(&facade).unwrap(), + full, + "the durable facade must return the full original result, not a projection" + ); + assert!(tombstone, "exact graph tombstone must exist"); + assert_eq!( + completed, 1, + "revoked scheduling must not commit a second result" + ); +} + +struct TerminalSameCallRevokeProvider { + persistence: Arc, + service: std::sync::Weak, + run_id: std::sync::Mutex, + call: Value, + step: std::sync::atomic::AtomicUsize, + denied: std::sync::Mutex>, + pre_revoke_outcome: std::sync::Mutex>, +} + +impl crate::AgentProviderHost for TerminalSameCallRevokeProvider { + fn call(&self, _request: &Value, cancellation: &crate::RunCancellation) -> Value { + if self.step.fetch_add(1, std::sync::atomic::Ordering::SeqCst) == 0 { + return json!({"ok":true,"response":response(Some(self.call.clone())),"error":{}}); + } + let service = self.service.upgrade().unwrap(); + let run_id = self.run_id.lock().unwrap().clone(); + let pin = service + .handle(&run_id) + .unwrap() + .pinned_tool + .clone() + .unwrap(); + let binding = pin.managed.as_ref().unwrap().clone(); + let graph = binding.code.graph_id.clone(); + // Legal live entry: bridge, original pin, and final receipt are all + // obtained before the revoke, proving reachability. + let host = service.capability_host_bridges(&run_id).unwrap().unwrap(); + assert_eq!( + host.cancellation.as_ref().unwrap().deadline_instant(), + cancellation.deadline_instant() + ); + let request = crate::effects::ManagedEffectGetRequest { + owner: binding.owner.clone(), call_id: "once".into(), - }) + }; + let record = self + .persistence + .managed_effect_get(&request) + .unwrap() + .unwrap(); + assert!( + record.outcome.is_some(), + "terminal same-call revoke needs a committed final before revoking" + ); + // R470-1: snapshot the full pre-revoke final outcome; the outer + // scope compares it verbatim after the revoke and the refused call. + *self.pre_revoke_outcome.lock().unwrap() = + Some(serde_json::to_value(record.outcome.as_ref().unwrap()).unwrap()); + let crate::effects::ManagedArgumentIdentity::Inline { + canonical_arguments, + } = &record.identity.argument_identity + else { + panic!("small fixture arguments should remain inline"); + }; + let canonical = canonical_arguments.clone(); + self.persistence + .plugin_control_command( + "plugin.graph_revoke", + &json!({"graph_id":graph,"now_ms":100,"failpoint":""}), + ) + .unwrap(); + assert!( + crate::plugins::PluginControlStore::new(&self.persistence) + .graph_revocation(&graph) + .unwrap() + .is_some(), + "revocation must be durably visible before the same-call invoke" + ); + let mut host = host; + host.pinned_tool = Some(pin); + let wire = json!({"tool_name":"terminal","call_id":"once","canonical_arguments":canonical,"canonical_input":json!({"call":self.call,"config":service.run_context(&run_id).unwrap().limits}).to_string(),"run_id":run_id,"registry_identity":host.lifecycle.as_ref().unwrap().registry_identity(),"argument_digest":canonical,"risk_class":"execute","summary":"terminal","untrusted_graph_id":""}); + let input = rustscript_vm::Value::map( + wire.as_object() + .unwrap() + .iter() + .map(|(key, value)| { + ( + rustscript_vm::Value::string(key), + rustscript_vm::Value::string(value.as_str().unwrap()), + ) + }) + .collect(), + ); + let source = r#"pub fn run(context:map)->map { + let tool_name:string=context["tool_name"];let call_id:string=context["call_id"];let canonical_arguments:string=context["canonical_arguments"];let canonical_input:string=context["canonical_input"];let run_id:string=context["run_id"];let registry_identity:string=context["registry_identity"];let argument_digest:string=context["argument_digest"];let risk_class:string=context["risk_class"];let summary:string=context["summary"];let untrusted_graph_id:string=context["untrusted_graph_id"]; + agent_runtime::invoke_pinned_tool({tool_name:tool_name,call_id:call_id,canonical_arguments:canonical_arguments,canonical_input:canonical_input,run_id:run_id,registry_identity:registry_identity,argument_digest:argument_digest,risk_class:risk_class,summary:summary,untrusted_graph_id:untrusted_graph_id}) + }"#; + let runner = crate::AgentRunner::from_source(source, crate::AgentConfig::default()) + .unwrap() + .with_host(host); + let denied = native_json(&runner.run_with_context(input).unwrap()); + *self.denied.lock().unwrap() = Some(denied); + json!({"ok":true,"response":response(None),"error":{}}) + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn terminal_same_call_revoke_refuses_native_execution_after_live_entry() { + let (root, state) = fixture(); + let service = state.service(); + let p = state.persistence().unwrap(); + let sentinel = root.join("revoked-entered-sentinel"); + let provider = Arc::new(TerminalSameCallRevokeProvider { + persistence: p.clone(), + service: Arc::downgrade(&service), + run_id: std::sync::Mutex::new(String::new()), + call: json!({"id":"once","name":"terminal","arguments":{"argv":["/bin/sh","-c",format!("printf x >> '{}'; printf observed",sentinel.display())],"timeout_ms":1000}}), + step: std::sync::atomic::AtomicUsize::new(0), + denied: std::sync::Mutex::new(None), + pre_revoke_outcome: std::sync::Mutex::new(None), + }); + service.inject_provider_host(provider.clone()); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"text":"terminal same-call revoke after live entry"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + *provider.run_id.lock().unwrap() = run.run_id.clone(); + let pin = service + .handle(&run.run_id) .unwrap() + .pinned_tool + .clone() .unwrap(); - assert!(record.outcome.is_some()); + let graph = pin.managed.as_ref().unwrap().code.graph_id.clone(); + service + .clone() + .run_worker(run.run_id.clone(), String::new()) + .await; + let request = crate::effects::ManagedEffectGetRequest { + owner: pin.managed.as_ref().unwrap().owner.clone(), + call_id: "once".into(), + }; + let events = service.run_events(&run.run_id); + let record_result = p.managed_effect_get(&request); + let replay_result = service.replay_managed_call(request.clone()); + let facade_result = service.replay_durable_tool_result(&run.run_id, "once", "terminal"); + let denied_option = provider.denied.lock().unwrap().clone(); + let baseline_option = provider.pre_revoke_outcome.lock().unwrap().clone(); + let tombstone_result = crate::plugins::PluginControlStore::new(&p).graph_revocation(&graph); + service.shutdown().await.unwrap(); + let record = record_result.unwrap().unwrap(); + let replay = replay_result.unwrap(); + let facade = facade_result.unwrap().unwrap(); + let tombstone = tombstone_result.unwrap().is_some(); + assert_eq!(fs::read(&sentinel).unwrap(), b"x"); + let denied = denied_option.expect("same-call Native denial envelope must be stored"); + assert_eq!( + denied["ok"], false, + "revoked same-call invoke must be refused: {denied}" + ); + assert_eq!( + denied["code"], "plugin_graph_revoked", + "same-call invoke must keep the exact revocation code: {denied}" + ); assert!( - crate::plugins::PluginControlStore::new(&p) - .graph_revocation(&graph) - .unwrap() - .is_some() + denied + .get("canonical_result") + .is_none_or(|value| value == ""), + "refused same-call invoke must not leak a canonical result: {denied}" ); - service.shutdown().await.unwrap(); + assert_eq!( + events + .iter() + .filter(|event| event["event"] == "tool.completed") + .count(), + 1, + "refused same-call invoke must not commit a second result" + ); + assert!( + record.outcome.is_some(), + "original outcome must stay durable" + ); + let crate::effects::ManagedCallReplay::Final { + outcome, + canonical_result, + } = replay + else { + panic!("original canonical receipt must stay queryable after revocation"); + }; + // R470-1: the post-revoke outcome and the history receipt must equal + // the full pre-revoke final baseline snapshotted in the provider, and + // the sealed canonical bytes must decode to the complete first result. + let baseline = baseline_option + .expect("provider must snapshot the pre-revoke final outcome before revoking"); + assert_eq!( + serde_json::to_value( + record + .outcome + .as_ref() + .expect("original outcome must stay durable") + ) + .unwrap(), + baseline, + "post-revoke outcome must equal the pre-revoke final baseline" + ); + assert_eq!( + serde_json::to_value(outcome.as_ref()).unwrap(), + baseline, + "history canonical must belong to the pre-revoke final baseline" + ); + let full: Value = serde_json::from_str(canonical_result.as_str()).unwrap(); + let tool_result = crate::service::canonical_tool_result(&full) + .expect("history canonical must decode through the existing ToolResult contract"); + assert!( + tool_result.ok, + "first result must be an exact success: {full:?}" + ); + assert_eq!( + tool_result.content, "observed", + "first result content must equal the actual command output: {full:?}" + ); + assert!( + tool_result.error.is_none(), + "first result must carry no error: {full:?}" + ); + assert!( + !tool_result.truncated, + "first result must not be truncated: {full:?}" + ); + assert!( + tool_result.artifacts.is_empty(), + "first result must carry no artifacts: {full:?}" + ); + assert_eq!( + serde_json::to_value(&facade).unwrap(), + full, + "the durable facade must return the full original result, not a projection" + ); + assert!(tombstone, "exact graph tombstone must exist"); } #[tokio::test(flavor = "multi_thread")] @@ -267,10 +650,37 @@ async fn missing_requirement_binding_or_exact_ref_cannot_exempt_managed_begin() .run_worker(run.run_id.clone(), String::new()) .await; let events = service.run_events(&run.run_id); + // Early run-level refusal: the refresh guard rejects before any + // provider dispatch, so the run terminates with run.failed carrying + // the generic terminal code plus the precise graph reason in the + // message. tool.failed never fires; the per-mutation typed refusal + // at the native-begin boundary is covered by + // native_begin_boundary_refuses_mutated_pin_with_typed_code. + let failed: Vec<_> = events + .iter() + .filter(|e| e["event"] == "run.failed") + .collect(); + assert_eq!( + failed.len(), + 1, + "{mutation} must terminate the run exactly once: {events:?}" + ); + assert_eq!(failed[0]["data"]["error_code"], "agent_failed"); + // The precise graph reason survives in the message in one of two + // closed forms: bare from the refresh guard, or wrapped with the run + // identity when capability-host construction rejects first. Both pin + // the exact code; nothing else is accepted. + let message = failed[0]["data"]["error_message"] + .as_str() + .expect("run.failed carries a text message"); assert!( - events.iter().any(|e| e["event"] == "tool.failed" - && e["data"]["error_code"] == "plugin_graph_pin_required"), - "{mutation} must fail typed: {events:?}" + message == "plugin_graph_pin_required" + || message + == format!( + "run {} context metadata is invalid: plugin_graph_pin_required", + run.run_id + ), + "{mutation} must retain the precise graph reason: {events:?}" ); assert!( !events @@ -661,17 +1071,26 @@ async fn revoked_retained_graph_refuses_new_native_call_after_successful_diagnos .run_worker(run.run_id.clone(), "ignored".into()) .await; let events = service.run_events(&run.run_id); + // The not-begun call never executes: no tool lifecycle events at all. + // The refusal surfaces at run level with the exact revocation code. assert!( - events + !events .iter() - .any(|e| e["event"] == "tool.failed" - && e["data"]["error_code"] == "plugin_graph_revoked"), - "revoked retained graph must fail with safe typed code: {events:?}" + .any(|e| e["event"].as_str().is_some_and(|name| { + matches!( + name, + "tool.requested" | "tool.started" | "tool.completed" | "tool.failed" + ) + })), + "refused call must not emit tool events: {events:?}" ); assert!( - !events - .iter() - .any(|e| matches!(e["event"].as_str(), Some("tool.requested" | "tool.started"))) + events.iter().any(|e| e["event"] == "run.failed" + && e["data"]["error_code"] == "agent_failed" + && e["data"]["error_message"] + .as_str() + .is_some_and(|message| message.contains("plugin_graph_revoked"))), + "revoked retained graph must fail the run with the exact revocation refusal: {events:?}" ); assert!( p.managed_effect_get(&crate::effects::ManagedEffectGetRequest { @@ -748,9 +1167,73 @@ async fn revoked_graph_fresh_admission_returns_distinct_code_without_publication .count(), 1 ); + let payload = captured.lock().unwrap().clone(); + assert!( + payload.is_none(), + "early revocation rejection must skip the admission transaction entirely" + ); + service.shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn revoke_between_prepare_and_commit_rolls_back_without_publication() { + let (_root, state) = fixture(); + let service = state.service(); + let p = state.persistence().unwrap(); + let first = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"text":"first"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + let graph = service + .handle(&first.run_id) + .unwrap() + .pinned_tool + .clone() + .unwrap() + .managed + .as_ref() + .unwrap() + .code + .graph_id + .clone(); + // Revoke after preparation but before the admission transaction: the + // precommit hook runs inside admission_create, after prepare_production_pin. + let revoker = p.clone(); + let graph_copy = graph.clone(); + let captured = Arc::new(std::sync::Mutex::new(None)); + let sink = captured.clone(); + p.inject_admission_precommit_hook(move |payload| { + *sink.lock().unwrap() = Some(payload.clone()); + revoker + .plugin_control_command( + "plugin.graph_revoke", + &json!({"graph_id":graph_copy,"now_ms":100,"failpoint":""}), + ) + .unwrap(); + }); + let result = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"text":"must not admit"}), + ..Default::default() + }, + None, + ) + .await; let payload = captured.lock().unwrap().clone().unwrap(); let run_id = payload["run_id"].as_str().unwrap(); let session_id = payload["session_id"].as_str().unwrap(); + assert_eq!( + result.as_ref().err().map(|e| format!("{e:?}")), + Some("PluginGraphRevoked".into()), + "post-prepare revocation must refuse with the same typed code: {result:?}" + ); assert!( p.run_get(run_id).unwrap()["rows"] .as_array() @@ -826,13 +1309,21 @@ async fn released_required_managed_pin_refuses_native_begin_without_intent_or_ex let events = service.run_events(&run.run_id); let failed: Vec<_> = events .iter() - .filter(|e| e["event"] == "tool.failed") + .filter(|e| e["event"] == "run.failed") .collect(); - assert!( - failed - .iter() - .any(|e| e["data"]["error_code"] == "plugin_graph_pin_required"), - "typed pin rejection must reach real tool.failed: {events:?}" + // Early run-level refusal: releasing the required pin rejects before any + // provider dispatch. The terminal carries the generic agent_failed code + // with the precise pin reason; the typed gate refusal is covered by + // native_begin_boundary_refuses_mutated_pin_with_typed_code. + assert_eq!( + failed.len(), + 1, + "released pin must terminate the run exactly once: {events:?}" + ); + assert_eq!(failed[0]["data"]["error_code"], "agent_failed"); + assert_eq!( + failed[0]["data"]["error_message"], "plugin_graph_pin_required", + "typed pin rejection must reach the real run terminal: {events:?}" ); assert!( !events @@ -855,3 +1346,640 @@ async fn released_required_managed_pin_refuses_native_begin_without_intent_or_ex assert!(!root.join("workspace/denied.txt").exists()); service.shutdown().await.unwrap(); } + +#[tokio::test(flavor = "multi_thread")] +async fn native_begin_boundary_refuses_mutated_pin_with_typed_code() { + use crate::AgentProviderHost; + for mutation in ["drop_requirement", "drop_binding", "replace_ref"] { + let (_root, state) = fixture(); + let service = state.service(); + let p = state.persistence().unwrap(); + let provider = Arc::new(crate::ScriptedProvider::new()); + provider.push_ok(response(Some(json!({"id":"native-begin","name":"write_file","arguments":{"path":"never.txt","content":"deny"}})))); + provider.push_ok(response(None)); + service.inject_provider_host(provider.clone()); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"text":"mutated pin at native begin"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + let handle = service.handle(&run.run_id).unwrap(); + let durable = crate::durable_provider::DurableProviderHost::new( + (*service).clone(), + run.run_id.clone(), + provider.clone(), + service.inner.metrics.clone(), + ); + // Legal tool parent: one real provider turn passes the admitted gate. + let request = json!({"model":"synthetic","messages":[]}); + assert_eq!(durable.call(&request, &handle.cancel)["ok"], true); + // Mutate only after the legal parent exists, at the native-begin + // boundary the real worker would cross next. + p.mutate_graph_pin_for_test(&run.run_id, mutation).unwrap(); + assert_eq!( + service.check_harness_run(&run.run_id, &handle.cancel), + Err("plugin_graph_pin_required"), + "{mutation}" + ); + assert_eq!( + durable.call(&request, &handle.cancel)["error"]["code"], + "plugin_graph_pin_required", + "{mutation}" + ); + let context = serde_json::to_value(service.run_context(&run.run_id).unwrap()).unwrap(); + assert_eq!( + service.refresh_provider_context(&run.run_id, &context, &handle.cancel)["error"]["code"], + "plugin_graph_pin_required", + "{mutation}" + ); + assert_eq!( + provider.requests().len(), + 1, + "{mutation} must not reach the transport again" + ); + service.shutdown().await.unwrap(); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn native_probe_positive_control_executes_legal_parent_through_pinned_begin() { + use crate::AgentProviderHost; + let (_root, state) = fixture(); + let service = state.service(); + let p = state.persistence().unwrap(); + let call = json!({"id":"native-begin","name":"write_file","arguments":{"path":"control.txt","content":"control"}}); + let provider = Arc::new(crate::ScriptedProvider::new()); + provider.push_ok(response(Some(call.clone()))); + provider.push_ok(response(None)); + service.inject_provider_host(provider.clone()); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"text":"legal parent before native begin"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + let handle = service.handle(&run.run_id).unwrap(); + let durable = crate::durable_provider::DurableProviderHost::new( + (*service).clone(), + run.run_id.clone(), + provider.clone(), + service.inner.metrics.clone(), + ); + let request = json!({"model":"synthetic","messages":[]}); + assert_eq!(durable.call(&request, &handle.cancel)["ok"], true); + let host = service + .capability_host_bridges(&run.run_id) + .unwrap() + .unwrap(); + let canonical = call["arguments"].to_string(); + let result = native_probe( + &service, + host, + &run.run_id, + &call, + &canonical, + "write", + true, + ); + assert_eq!(result["ok"], true, "{result}"); + let pin = handle.pinned_tool.clone().unwrap(); + let binding = pin.managed.as_ref().unwrap(); + let record = p + .managed_effect_get(&crate::effects::ManagedEffectGetRequest { + owner: binding.owner.clone(), + call_id: "native-begin".into(), + }) + .unwrap() + .unwrap(); + assert!(record.outcome.is_some()); + assert!(record.prepared.as_ref().unwrap().candidate_root.is_some()); + assert_eq!(provider.requests().len(), 1); + service.shutdown().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread")] +async fn native_begin_after_pin_mutation_reaches_boundary_and_refuses_without_intent() { + use crate::AgentProviderHost; + for mutation in ["drop_requirement", "drop_binding", "replace_ref"] { + let (root, state) = fixture(); + let service = state.service(); + let p = state.persistence().unwrap(); + let call = json!({"id":"native-begin","name":"write_file","arguments":{"path":"never.txt","content":"deny"}}); + let provider = Arc::new(crate::ScriptedProvider::new()); + provider.push_ok(response(Some(call.clone()))); + provider.push_ok(response(None)); + service.inject_provider_host(provider.clone()); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"text":"mutated pin at real native begin"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + let handle = service.handle(&run.run_id).unwrap(); + let durable = crate::durable_provider::DurableProviderHost::new( + (*service).clone(), + run.run_id.clone(), + provider.clone(), + service.inner.metrics.clone(), + ); + // Legal tool parent: one real provider turn passes the admitted gate. + let request = json!({"model":"synthetic","messages":[]}); + assert_eq!(durable.call(&request, &handle.cancel)["ok"], true); + let pin = handle.pinned_tool.clone().unwrap(); + let binding = pin.managed.as_ref().unwrap(); + let workspace = binding.admitted_branch.clone(); + let head_before = p.managed_workspace_head(&workspace).unwrap().head_version; + // Obtain the original host while the admission is still valid; the + // mutation is applied only after the parent turn and host capture. + let host = service + .capability_host_bridges(&run.run_id) + .unwrap() + .unwrap(); + p.mutate_graph_pin_for_test(&run.run_id, mutation).unwrap(); + let canonical = call["arguments"].to_string(); + let result = native_probe( + &service, + host, + &run.run_id, + &call, + &canonical, + "write", + true, + ); + // All three static pin mutations reach the later child `tool_prepare` + // cut: the exact typed LifecycleError is serialized as the complete + // three-field canonical error envelope, and the outer human message is + // the LifecycleError message, not the code. + assert_eq!(result["ok"], false, "{mutation}: {result}"); + assert_eq!(result["kind"], "error", "{mutation}: {result}"); + assert_eq!( + result["code"], "plugin_graph_pin_required", + "{mutation}: {result}" + ); + assert_eq!( + result["message"], "tool graph pin is required", + "{mutation}: {result}" + ); + assert_eq!(result["content"], "", "{mutation}: {result}"); + assert_eq!(result["text"], "", "{mutation}: {result}"); + let canonical_result = result["canonical_result"].as_str().unwrap_or_else(|| { + panic!("{mutation} must return the pinned Native outcome: {result}") + }); + assert_eq!( + serde_json::from_str::(canonical_result).unwrap(), + json!({ + "ok": false, + "kind": "error", + "error": { + "code": "plugin_graph_pin_required", + "message": "tool graph pin is required", + }, + }), + "{mutation} canonical must be the exact typed refusal: {result}" + ); + assert!( + result.get("managed_publication").is_none(), + "{mutation} must not return a managed success publication: {result}" + ); + assert!( + p.managed_effect_get(&crate::effects::ManagedEffectGetRequest { + owner: binding.owner.clone(), + call_id: "native-begin".into(), + }) + .unwrap() + .is_none() + ); + assert!( + !service + .run_events(&run.run_id) + .iter() + .any(|e| matches!(e["event"].as_str(), Some("tool.requested" | "tool.started"))) + ); + assert_eq!( + p.managed_workspace_head(&workspace).unwrap().head_version, + head_before, + "{mutation} must not publish a branch effect" + ); + assert!(!root.join("workspace/never.txt").exists()); + assert_eq!(provider.requests().len(), 1, "{mutation}"); + service.shutdown().await.unwrap(); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn native_begin_after_release_or_revoke_reaches_boundary_and_refuses_without_effect() { + use crate::AgentProviderHost; + for case in ["release", "revoke"] { + let (root, state) = fixture(); + let service = state.service(); + let p = state.persistence().unwrap(); + let call = json!({"id":"native-begin","name":"write_file","arguments":{"path":"never.txt","content":"deny"}}); + let provider = Arc::new(crate::ScriptedProvider::new()); + provider.push_ok(response(Some(call.clone()))); + provider.push_ok(response(None)); + service.inject_provider_host(provider.clone()); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"text":"released or revoked at real native begin"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + let handle = service.handle(&run.run_id).unwrap(); + let pin = handle.pinned_tool.clone().unwrap(); + let binding = pin.managed.as_ref().unwrap(); + let graph = binding.code.graph_id.clone(); + let workspace = binding.admitted_branch.clone(); + let durable = crate::durable_provider::DurableProviderHost::new( + (*service).clone(), + run.run_id.clone(), + provider.clone(), + service.inner.metrics.clone(), + ); + let request = json!({"model":"synthetic","messages":[]}); + assert_eq!(durable.call(&request, &handle.cancel)["ok"], true); + let head_before = p.managed_workspace_head(&workspace).unwrap().head_version; + let host = service + .capability_host_bridges(&run.run_id) + .unwrap() + .unwrap(); + if case == "release" { + crate::plugins::PluginControlStore::new(&p) + .release(&crate::plugins::GraphRetention { + graph_id: graph.clone(), + owner_kind: "run".into(), + owner_id: run.run_id.clone(), + ref_id: binding.code.graph_ref_id.clone(), + }) + .unwrap(); + } else { + p.plugin_control_command( + "plugin.graph_revoke", + &json!({"graph_id":graph,"now_ms":100,"failpoint":""}), + ) + .unwrap(); + } + let canonical = call["arguments"].to_string(); + let result = native_probe( + &service, + host, + &run.run_id, + &call, + &canonical, + "write", + true, + ); + // Two legally distinct static cuts. The formal release reaches the + // later child `tool_prepare` LifecycleError and serializes the complete + // three-field canonical error envelope; the revoke reaches the early + // fresh-authority guard with an empty kind and an empty canonical + // envelope. Each case keeps its own exact shape. + assert_eq!(result["ok"], false, "{case}: {result}"); + if case == "release" { + assert_eq!(result["kind"], "error", "{case}: {result}"); + assert_eq!( + result["code"], "plugin_graph_pin_required", + "{case}: {result}" + ); + assert_eq!( + result["message"], "tool graph pin is required", + "{case}: {result}" + ); + assert_eq!(result["content"], "", "{case}: {result}"); + assert_eq!(result["text"], "", "{case}: {result}"); + let canonical_result = result["canonical_result"].as_str().unwrap_or_else(|| { + panic!("{case} must return the pinned Native outcome: {result}") + }); + assert_eq!( + serde_json::from_str::(canonical_result).unwrap(), + json!({ + "ok": false, + "kind": "error", + "error": { + "code": "plugin_graph_pin_required", + "message": "tool graph pin is required", + }, + }), + "{case} canonical must be the exact typed refusal: {result}" + ); + } else { + assert_eq!(result["kind"], "", "{case}: {result}"); + assert_eq!(result["code"], "plugin_graph_revoked", "{case}: {result}"); + assert_eq!( + result["message"], "plugin_graph_revoked", + "{case}: {result}" + ); + assert_eq!(result["content"], "", "{case}: {result}"); + assert_eq!(result["text"], "", "{case}: {result}"); + assert_eq!(result["canonical_result"], "", "{case}: {result}"); + } + assert!( + result.get("managed_publication").is_none(), + "{case} must not return a managed success publication: {result}" + ); + assert!( + p.managed_effect_get(&crate::effects::ManagedEffectGetRequest { + owner: binding.owner.clone(), + call_id: "native-begin".into(), + }) + .unwrap() + .is_none() + ); + assert!( + !service + .run_events(&run.run_id) + .iter() + .any(|e| matches!(e["event"].as_str(), Some("tool.requested" | "tool.started"))) + ); + assert_eq!( + p.managed_workspace_head(&workspace).unwrap().head_version, + head_before, + "{case} must not publish a branch effect" + ); + assert!(!root.join("workspace/never.txt").exists()); + assert_eq!(provider.requests().len(), 1, "{case}"); + service.shutdown().await.unwrap(); + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn after_revoke_completed_identity_native_replay_refuses_execution_and_preserves_history() { + use crate::AgentProviderHost; + let (root, state) = fixture(); + let service = state.service(); + let p = state.persistence().unwrap(); + let sentinel = root.join("after-revoke-replay-sentinel"); + let call = json!({"id":"once","name":"terminal","arguments":{"argv":["/bin/sh","-c",format!("printf x >> '{}'; printf observed",sentinel.display())],"timeout_ms":1000}}); + let provider = Arc::new(crate::ScriptedProvider::new()); + provider.push_ok(response(Some(call.clone()))); + provider.push_ok(response(None)); + service.inject_provider_host(provider.clone()); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"text":"completed identity after revoke"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + let handle = service.handle(&run.run_id).unwrap(); + let durable = crate::durable_provider::DurableProviderHost::new( + (*service).clone(), + run.run_id.clone(), + provider.clone(), + service.inner.metrics.clone(), + ); + // Real parent provider turn: the tool call and its arguments become the + // durable parent fact consumed by the pinned Native entry. + assert_eq!( + durable.call(&json!({"model":"synthetic","messages":[]}), &handle.cancel)["ok"], + true + ); + let pin = handle.pinned_tool.clone().unwrap(); + let binding = pin.managed.as_ref().unwrap(); + let graph = binding.code.graph_id.clone(); + let owner = binding.owner.clone(); + let workspace = binding.admitted_branch.clone(); + // The original host/cancel pair, captured before any graph revocation. + let host = service + .capability_host_bridges(&run.run_id) + .unwrap() + .unwrap(); + // Revoke exactly while the first legal Native call is enqueueing its + // effect.prepare. The hook holds only a Weak owner, so no p->hook->p + // strong cycle survives a panic, and it is cleared immediately after the + // first call returns, before any assert or fallible summary read. + let weak = Arc::downgrade(&p); + let graph_copy = graph.clone(); + *p.managed_request_hook.lock().unwrap() = Some(Arc::new(move |op, _, enqueue| { + if op == "effect.prepare" + && enqueue + && let Some(revoker) = weak.upgrade() + { + revoker + .plugin_control_command( + "plugin.graph_revoke", + &json!({"graph_id":graph_copy,"now_ms":100,"failpoint":""}), + ) + .unwrap(); + } + })); + let canonical = call["arguments"].to_string(); + let first = native_probe( + &service, + host.clone(), + &run.run_id, + &call, + &canonical, + "execute", + true, + ); + *p.managed_request_hook.lock().unwrap() = None; + let request = crate::effects::ManagedEffectGetRequest { + owner: owner.clone(), + call_id: "once".into(), + }; + let record_result = p.managed_effect_get(&request); + let tombstone_result = crate::plugins::PluginControlStore::new(&p).graph_revocation(&graph); + let terminal_before_refusal = handle.is_terminal(); + let replay_result = service.replay_managed_call(request.clone()); + let head_before_result = p.managed_workspace_head(&workspace); + let messages_before_result = p.message_list(&run.session_id, 0); + let events_before = service.run_events(&run.run_id); + // Same original host/cancel and complete identity, directly at the pinned + // Native entry after the revoke: fresh execution must be refused. + let refused = native_probe( + &service, + host, + &run.run_id, + &call, + &canonical, + "execute", + false, + ); + let record_after_result = p.managed_effect_get(&request); + let events_after = service.run_events(&run.run_id); + let head_after_result = p.managed_workspace_head(&workspace); + let messages_after_result = p.message_list(&run.session_id, 0); + let shutdown_result = service.shutdown().await; + shutdown_result.unwrap(); + // First legal call: one complete success and exactly one sentinel append. + assert_eq!(first["ok"], true, "{first}"); + assert_eq!(fs::read(&sentinel).unwrap(), b"x"); + assert!( + !terminal_before_refusal, + "the refusal probe must run before the run terminal" + ); + let record = record_result.unwrap().unwrap(); + let original_outcome = record.outcome.clone().expect("first call must commit"); + assert!( + tombstone_result.unwrap().is_some(), + "exact graph tombstone must exist" + ); + // Read-only service history keeps the original full canonical result and + // the original durable outcome; only fresh execution is refused. + let crate::effects::ManagedCallReplay::Final { + outcome: history_outcome, + canonical_result: history_canonical, + } = replay_result.unwrap() + else { + panic!("original canonical receipt must stay queryable after revocation"); + }; + assert_eq!(*history_outcome, original_outcome); + let first_canonical = first["canonical_result"] + .as_str() + .expect("first call must return a complete canonical result"); + assert_eq!(history_canonical.as_str(), first_canonical); + let first_full: Value = serde_json::from_str(first_canonical).unwrap(); + let first_tool = crate::service::canonical_tool_result(&first_full) + .expect("first canonical must decode through the existing ToolResult contract"); + assert!(first_tool.ok); + assert_eq!(first_tool.content, "observed"); + // Fresh Native execution after revoke: exact typed refusal, empty + // canonical, and no newly returned managed success publication. + assert_eq!(refused["ok"], false, "{refused}"); + assert_eq!(refused["code"], "plugin_graph_revoked", "{refused}"); + assert_eq!(refused["message"], "plugin_graph_revoked", "{refused}"); + assert!( + refused.get("canonical_result").is_some(), + "the refusal must still carry the pinned Native envelope: {refused}" + ); + assert_eq!(refused["canonical_result"], "", "{refused}"); + assert!( + refused.get("managed_publication").is_none(), + "the refusal must not return a managed success publication: {refused}" + ); + // The durable record is untouched: exact identity and outcome. + let record_after = record_after_result.unwrap().unwrap(); + assert_eq!(record_after.identity, record.identity); + assert_eq!(record_after.outcome, record.outcome); + let head_before = head_before_result.unwrap().head_version; + let head_after = head_after_result.unwrap().head_version; + assert_eq!(head_after, head_before); + let messages_before = messages_before_result.unwrap()["rows"] + .as_array() + .unwrap() + .len(); + let messages_after = messages_after_result.unwrap()["rows"] + .as_array() + .unwrap() + .len(); + assert_eq!(messages_after, messages_before); + for event in [ + "tool.requested", + "tool.started", + "tool.output", + "tool.completed", + ] { + assert_eq!( + events_before + .iter() + .filter(|value| value["event"] == event) + .count(), + 1, + "{event} must remain a single durable fact" + ); + assert_eq!( + events_after + .iter() + .filter(|value| value["event"] == event) + .count(), + 1, + "{event} must not be duplicated by the refused execution" + ); + } + assert_eq!(fs::read(&sentinel).unwrap(), b"x"); + assert_eq!(provider.requests().len(), 1); +} + +#[tokio::test(flavor = "multi_thread")] +async fn released_and_revoked_pins_refuse_at_native_begin_boundary() { + use crate::AgentProviderHost; + for case in ["release", "revoke"] { + let (_root, state) = fixture(); + let service = state.service(); + let p = state.persistence().unwrap(); + let provider = Arc::new(crate::ScriptedProvider::new()); + provider.push_ok(response(Some(json!({"id":"native-begin","name":"write_file","arguments":{"path":"never.txt","content":"deny"}})))); + provider.push_ok(response(None)); + service.inject_provider_host(provider.clone()); + let run = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"text":"released or revoked pin at native begin"}), + ..Default::default() + }, + None, + ) + .await + .unwrap(); + let handle = service.handle(&run.run_id).unwrap(); + let pin = handle.pinned_tool.clone().unwrap(); + let binding = pin.managed.as_ref().unwrap(); + let graph = binding.code.graph_id.clone(); + let durable = crate::durable_provider::DurableProviderHost::new( + (*service).clone(), + run.run_id.clone(), + provider.clone(), + service.inner.metrics.clone(), + ); + // Legal tool parent: one real provider turn passes the admitted gate. + let request = json!({"model":"synthetic","messages":[]}); + assert_eq!(durable.call(&request, &handle.cancel)["ok"], true); + if case == "release" { + crate::plugins::PluginControlStore::new(&p) + .release(&crate::plugins::GraphRetention { + graph_id: graph.clone(), + owner_kind: "run".into(), + owner_id: run.run_id.clone(), + ref_id: binding.code.graph_ref_id.clone(), + }) + .unwrap(); + } else { + p.plugin_control_command( + "plugin.graph_revoke", + &json!({"graph_id":graph,"now_ms":100,"failpoint":""}), + ) + .unwrap(); + } + let expected = if case == "revoke" { + "plugin_graph_revoked" + } else { + "plugin_graph_pin_required" + }; + assert_eq!( + service.check_harness_run(&run.run_id, &handle.cancel), + Err(expected), + "{case}" + ); + assert_eq!( + durable.call(&request, &handle.cancel)["error"]["code"], + expected, + "{case}" + ); + assert_eq!( + provider.requests().len(), + 1, + "{case} must not reach the transport again" + ); + service.shutdown().await.unwrap(); + } +} diff --git a/src/service/managed_linearization_tests.rs b/src/service/managed_linearization_tests.rs index 9136091..cbf35db 100644 --- a/src/service/managed_linearization_tests.rs +++ b/src/service/managed_linearization_tests.rs @@ -697,10 +697,9 @@ async fn restore_loses_to_actual_source_commit_at_preparation_boundary() { observer.arrive(); } })); - let s = service.clone(); - let req = request.clone(); - let restore = tokio::spawn(async move { s.restore_managed_checkpoint_and_admit(req).await }); - gate.wait(1); + // The writer fixture is prepared before the restore is spawned so its + // admission no longer burns the restore's own watchdog window; the real + // worker still starts only after the restore is observed at its arrival. let provider = Arc::new(crate::ScriptedProvider::new()); provider.push_ok(response(Some( json!({"id":"cas-b","name":"write_file","arguments":{"path":"value.txt","content":"B"}}), @@ -709,6 +708,10 @@ async fn restore_loses_to_actual_source_commit_at_preparation_boundary() { service.inject_provider_host(provider); let writer = admitted(&service).await; let writer_binding = binding(&service, &writer); + let s = service.clone(); + let req = request.clone(); + let restore = tokio::spawn(async move { s.restore_managed_checkpoint_and_admit(req).await }); + gate.wait(1); let result = tokio::spawn( service .clone() diff --git a/src/service/managed_publications.rs b/src/service/managed_publications.rs index 3f65e89..4b1c4b9 100644 --- a/src/service/managed_publications.rs +++ b/src/service/managed_publications.rs @@ -410,14 +410,24 @@ mod tests { .unwrap_err(); assert_eq!(denied.to_string(), "managed_management_denied"); let expired = RunCancellation::with_timeout(Duration::ZERO); - let deadline = open(true) + let expired_state = open(true); + let deadline = expired_state .service() .publish_managed_workspace_blocking(request(), &expired) .unwrap_err(); assert_eq!(deadline.to_string(), "managed_deadline_elapsed"); + // Fixture timing: service initialization is the expensive step + // (multi-second even in isolation). The finite requested cancel is + // created only after the service under test exists, so initialization + // cannot consume its 5s budget and change the expected closed code. + let busy_state = open(true); let cancelled = RunCancellation::with_timeout(Duration::from_secs(5)); cancelled.request(CancellationReason::Requested); - let busy = open(true) + assert!( + !cancelled.deadline_passed(), + "requested fixture must enter publish before its original deadline" + ); + let busy = busy_state .service() .publish_managed_workspace_blocking(request(), &cancelled) .unwrap_err(); diff --git a/src/service_preflight_tests.rs b/src/service_preflight_tests.rs index 1fe6e89..7958791 100644 --- a/src/service_preflight_tests.rs +++ b/src/service_preflight_tests.rs @@ -175,19 +175,33 @@ async fn preflight_failure_persist_failure_stops_context_refresh_and_cancellatio .persistence .as_ref() .unwrap() - .inject_persist_failure(); + .inject_fail_after_partial_write(); let mut ctx = context("persist", "search_files", true); ctx["session_id"] = json!(run.session_id); - let refreshed = s.refresh_provider_context(&run.run_id, &ctx, &RunCancellation::new()); + // Write-scoped failpoint: the failure must surface at the tool.failed + // record write. The broad next-command failpoint would fail the guard's + // own control reads first (fail-closed pin_required on unreadable + // control state), before the record seam under test is reached. + // Positive path drives the admitted run's own cancellation root: the + // strict native guard rejects deadline-less callers before the + // persist-failure seam is reached. + let admitted_cancel = s + .handle(&run.run_id) + .expect("admitted run handle") + .cancel + .clone(); + let refreshed = s.refresh_provider_context(&run.run_id, &ctx, &admitted_cancel); assert_eq!(refreshed["ok"], false); assert_eq!(refreshed["error"]["code"], "tool_failure_persist_failed"); + // Strict-guard precedence: an elapsed caller deadline refuses with + // deadline_elapsed (checked first), not the legacy cancelled mapping. let expired = s.refresh_provider_context( &run.run_id, &ctx, &RunCancellation::with_timeout(Duration::ZERO), ); assert_eq!(expired["ok"], false); - assert_eq!(expired["error"]["code"], "cancelled"); + assert_eq!(expired["error"]["code"], "deadline_elapsed"); assert!( !s.run_events(&run.run_id) .iter() diff --git a/tests/plugin_effect_api_e2e_tests.rs b/tests/plugin_effect_api_e2e_tests.rs index e42717a..3dd78dc 100644 --- a/tests/plugin_effect_api_e2e_tests.rs +++ b/tests/plugin_effect_api_e2e_tests.rs @@ -1656,3 +1656,1105 @@ async fn fault_cut_does_not_publish_and_active_writer_blocks_checkpoint() { http.close().await; shutdown_when_idle(&cycle.state).await; } + +// 421 phase A service cut: one AcceptedPublisher stateless read tool plus one +// pure leaf library. The service prepare/activate path must keep the combined +// program, the managed run must return the leaf-labeled bytes through the +// original commit path, revoking A must block the next run, the shared-leaf +// graph C must keep reading, and the new topology must refuse checkpoints +// with the typed unsupported error. +#[tokio::test(flavor = "multi_thread")] +async fn phase_a_library_graph_reads_labels_revoke_blocks_and_shared_leaf_still_reads() { + use ring::{ + rand::SystemRandom, + signature::{Ed25519KeyPair, KeyPair}, + }; + use rustscript_agent::plugins::{ + InstallAuthorization, PackageStore, PublisherKey, trusted_tool_package_root, + }; + + const LEAF_ID: &str = "org.rustscript.lib.phasetext"; + const TOOL_A_ID: &str = "org.rustscript.tools.phasea"; + const TOOL_C_ID: &str = "org.rustscript.tools.phasec"; + let leaf_manifest = format!( + "format: rustscript.plugin/v1\nid: {LEAF_ID}\nversion: 1.0.0\nkind: library\nruntime: \">=0.1.0, <0.2.0\"\ncontract: agent.library/v1\nhost_abi: agent.cap/v1\nentry:\n path: format.rss\n export: format\ndependencies: []\ncapabilities: []\neffects:\n contract: agent.effects/v1\n entries:\n format:\n direct: []\n delegated: []\n" + ); + let tool_manifest = |id: &str| { + format!( + "format: rustscript.plugin/v1\nid: {id}\nversion: 1.0.0\nkind: tool\nruntime: \">=0.1.0, <0.2.0\"\ncontract: agent.tool/v1\nhost_abi: agent.cap/v1\nentry:\n path: run.rss\n export: run\ndependencies:\n - alias: text\n id: {LEAF_ID}\n version: \"1.0.0\"\ncapabilities:\n - workspace.read\neffects:\n contract: agent.effects/v1\n entries:\n run:\n direct:\n - kind: workspace.fs.read\n scope: bound_workspace\n recovery: none\n delegated: []\n" + ) + }; + let run_source = r#"use bytes; +use text::{format}; +fn map_string(value: map, key: string, fallback: string) -> string { + let mut result: string = fallback; + if value.has(key) { + if type(value[key]) == "string" { + let coerced: string = value[key]; + result = coerced; + } + } + result +} + +fn map_map(value: map, key: string) -> map { + let mut result: map = {}; + if value.has(key) { + if type(value[key]) == "map" { + let coerced: map = value[key]; + result = coerced; + } + } + result +} + +fn map_int(value: map, key: string, fallback: int) -> int { + let mut result: int = fallback; + if value.has(key) { + if type(value[key]) == "int" { + let coerced: int = value[key]; + result = coerced; + } + } + result +} + +fn map_bool(value: map, key: string, fallback: bool) -> bool { + let mut result: bool = fallback; + if value.has(key) { + if type(value[key]) == "bool" { + let coerced: bool = value[key]; + result = coerced; + } + } + result +} + +pub fn run(context: map) -> map { + let prepared: map = agent_runtime::tool_prepare(map_map(context, "prepare")); + if map_string(prepared, "kind", "") == "replay" => { + map_map(prepared, "result") + } else if map_bool(prepared, "ok", false) == false => { + prepared + } else => { + let token: string = map_string(prepared, "execution_token", ""); + let arguments: map = map_map(context, "arguments"); + let path: string = map_string(arguments, "path", ""); + let meta: map = cap::fs_metadata(token, path); + if map_bool(meta, "ok", false) == false => { + meta + } else => { + let file_len: int = map_int(meta, "len", 0); + let limit: int = file_len; + let read: map = cap::fs_read_range(token, path, 0, limit); + if map_bool(read, "ok", false) == false => { + read + } else => { + let payload: bytes = read.bytes; + let labeled: string = format(bytes::to_utf8(payload)); + let result: map = { ok: true, content: labeled, data: {}, error: null, truncated: false, artifacts: [] }; + let committed: map = agent_runtime::tool_commit(token, result); + if map_string(committed, "kind", "") == "committed" => { + map_map(committed, "result") + } else => { + committed + } + } + } + } +} +"#; + + // The fixtures are signed with this test's own accepted key, so the + // cycle is built here with that key: the service revalidates every + // capture against its own bound policy on read. + use rustscript_agent::{ + AgentGatewayConfig, AgentGatewayState, + config::{ManagedMaintenancePolicy, ManagedRetentionLimits, ManagedWorkspaceConfig}, + effects::ResourceQuota, + }; + use std::path::PathBuf; + struct PhaseACycle { + workspace: PathBuf, + source: PathBuf, + db: PathBuf, + token: String, + config: AgentGatewayConfig, + auth: InstallAuthorization, + state: AgentGatewayState, + } + let label = format!("t421-liblink-{}", uuid::Uuid::new_v4().simple()); + let root = PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()).join(label); + let _ = std::fs::remove_dir_all(&root); + let workspace = root.join("workspace"); + let home = root.join("workspace-home"); + let source = root.join("controlled-source"); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&source).unwrap(); + std::fs::write(workspace.join("seed.txt"), b"seed").unwrap(); + let yaml = format!( + "version: 1\nmodel:\n provider: local-test\n model: synthetic\nproviders:\n local-test:\n protocol: codex-responses\n base_url: https://127.0.0.1:1\nworkspaces:\n allowed_roots:\n - '{}'\n default: '{}'\n named:\n lifecycle: '{}'\n", + workspace.display(), + workspace.display(), + workspace.display() + ); + std::fs::write(home.join("config.yaml"), yaml).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for path in [&root, &workspace, &home, &source] { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + std::fs::set_permissions( + home.join("config.yaml"), + std::fs::Permissions::from_mode(0o600), + ) + .unwrap(); + } + let token = uuid::Uuid::new_v4().to_string(); + let key = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap(); + let pair = Ed25519KeyPair::from_pkcs8(key.as_ref()).unwrap(); + let auth = InstallAuthorization { + admin: true, + accepted_publishers: vec![PublisherKey { + publisher: "org.rustscript".into(), + verifying_key: pair.public_key().as_ref().to_vec(), + }], + unsigned_dev_space: None, + }; + let mut config = AgentGatewayConfig { + bearer_token: Some(token.clone()), + managed_workspace: Some(ManagedWorkspaceConfig { + source_root: workspace.clone(), + quota: ResourceQuota::new(1000, 1_048_576, 16).unwrap(), + retention: ManagedRetentionLimits::default(), + maintenance_policy: ManagedMaintenancePolicy { + create_checkpoint: true, + restore: true, + branch: true, + publish: false, + }, + }), + ..AgentGatewayConfig::default() + }; + config.approvals.write = "allow".into(); + config.run_timeout = Duration::from_secs(900); + let db = root.join("gateway.sqlite"); + let cycle = PhaseACycle { + workspace: workspace.clone(), + source: source.clone(), + db: db.clone(), + token: token.clone(), + config: config.clone(), + auth: auth.clone(), + state: AgentGatewayState::with_sqlite_path(config, &db) + .unwrap() + .with_workspace_home(&home) + .with_plugin_control(auth, source, "owner-space") + .unwrap(), + }; + let store = PackageStore::open(trusted_tool_package_root(&cycle.db)).unwrap(); + let stage = |dir: &str, manifest: &str, files: &[(&str, &[u8])]| { + let package = cycle.source.join(dir); + std::fs::create_dir_all(&package).unwrap(); + std::fs::write(package.join("manifest.yaml"), manifest).unwrap(); + for (name, body) in files { + std::fs::write(package.join(name), body).unwrap(); + } + let payload = store.signature_payload(&package).unwrap(); + std::fs::write( + package.join("signature.ed25519"), + pair.sign(&payload).as_ref(), + ) + .unwrap(); + store.install(&package, &cycle.auth).unwrap(); + }; + stage( + "package-phaselib", + &leaf_manifest, + &[( + "format.rss", + b"pub fn format(value: string) -> string { \"libA:\" + value }\n".as_slice(), + )], + ); + for (dir, id) in [("package-phasea", TOOL_A_ID), ("package-phasec", TOOL_C_ID)] { + stage( + dir, + &tool_manifest(id), + &[ + ("run.rss", run_source.as_bytes()), + ( + "catalog.rss", + include_bytes!("../rss/tools/types.rss").as_slice(), + ), + ], + ); + } + + let http = Http::bind(cycle.state.clone()).await; + let hidden = [cycle.workspace.as_path(), cycle.source.as_path()]; + let (graph_a, generation_a) = + activate(&http, &cycle.token, &cycle.config, &cycle.db, TOOL_A_ID).await; + let (status, session) = http + .request("POST", "/api/sessions", json!({}), Some(&cycle.token), None) + .await; + assert_eq!(status, 201, "{}", explain(status, &session)); + let session_id = session["session"]["id"].as_str().unwrap().to_string(); + std::fs::write(cycle.workspace.join("note.txt"), b"hello").unwrap(); + + let provider = Arc::new(ScriptedProvider::new()); + provider.push_ok(tool_call("read-lib-a", "read_file", "note.txt")); + provider.push_ok(stop_turn("done-a")); + cycle.state.service().inject_provider_host(provider); + let (status, admitted) = http + .request( + "POST", + "/v1/runs", + json!({"session_id": session_id, "input": "read note through the leaf library"}), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 202, "{}", explain(status, &admitted)); + assert_eq!(admitted["graph_id"], graph_a); + assert_eq!(admitted["activation_generation"], generation_a); + let run_a = admitted["run_id"].as_str().unwrap().to_string(); + wait_until(|| { + event_names(&cycle.state, &run_a) + .iter() + .any(|name| name == "run.completed") + }) + .await; + let projection = projected_run(&http, &cycle.token, &session_id, &run_a).await; + assert!(projection.contains("libA:hello"), "{projection}"); + let (status, effects) = http + .request( + "GET", + &format!("/v1/runs/{run_a}/effects?limit=128"), + json!(null), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &effects)); + let read = effects["entries"] + .as_array() + .unwrap() + .iter() + .find(|entry| entry["call_id"] == "read-lib-a") + .expect("native read receipt through the library link") + .clone(); + assert_eq!(read["state"], "read_only"); + let workspace_id = read["workspace_id"].as_str().unwrap().to_string(); + let branch_id = read["branch_id"].as_str().unwrap().to_string(); + let epoch = read["epoch"].as_u64().unwrap(); + assert_closed(&effects, &cycle.token, &hidden); + + let (status, checkpoint) = http + .request( + "POST", + &format!("/v1/workspaces/{workspace_id}/checkpoints"), + json!({ + "source_run_id": run_a, + "workspace_id": workspace_id, + "source_branch_id": branch_id, + "expected_epoch": epoch, + "request_id": uuid::Uuid::new_v4().to_string(), + "mode": "at_closed_batch" + }), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 422, "{}", explain(status, &checkpoint)); + assert_eq!(checkpoint["error"]["code"], "managed_state_unsupported"); + + revoke_graph( + &cycle.config, + &cycle.db, + &bound_store(&cycle.db, &cycle.auth), + &graph_a, + ) + .unwrap(); + let (status, prepared) = http + .request( + "POST", + &format!("/v1/plugin-graphs/{graph_a}/prepare"), + json!({}), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 409, "{}", explain(status, &prepared)); + assert_eq!(prepared["error"]["code"], "plugin_graph_revoked"); + let denied_provider = Arc::new(ScriptedProvider::new()); + denied_provider.push_ok(tool_call("write-revoked", "write_file", "revoked.txt")); + denied_provider.push_ok(stop_turn("should-not-run")); + cycle.state.service().inject_provider_host(denied_provider); + let (status, denied) = http + .request( + "POST", + "/v1/runs", + json!({"session_id": session_id, "input": "after revoke"}), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 409, "{}", explain(status, &denied)); + assert_eq!(denied["error"]["code"], "plugin_graph_revoked"); + assert!(!cycle.workspace.join("revoked.txt").exists()); + + let (graph_c, generation_c) = + activate(&http, &cycle.token, &cycle.config, &cycle.db, TOOL_C_ID).await; + assert!(generation_c > generation_a); + let provider_c = Arc::new(ScriptedProvider::new()); + provider_c.push_ok(tool_call("read-lib-c", "read_file", "note.txt")); + provider_c.push_ok(stop_turn("done-c")); + cycle.state.service().inject_provider_host(provider_c); + let (status, run_c) = http + .request( + "POST", + "/v1/runs", + json!({"session_id": session_id, "input": "read through the shared leaf"}), + Some(&cycle.token), + None, + ) + .await; + assert_eq!(status, 202, "{}", explain(status, &run_c)); + assert_eq!(run_c["graph_id"], graph_c); + let run_c_id = run_c["run_id"].as_str().unwrap().to_string(); + wait_until(|| { + event_names(&cycle.state, &run_c_id) + .iter() + .any(|name| name == "run.completed") + }) + .await; + let projection_c = projected_run(&http, &cycle.token, &session_id, &run_c_id).await; + assert!(projection_c.contains("libA:hello"), "{projection_c}"); + http.close().await; + shutdown_when_idle(&cycle.state).await; +} + +// --------------------------------------------------------------------------- +// 456 F4: the managed production cut of the phase-A tool + pure leaf. A real +// signed tool carries the captured managed catalog, a real run is admitted and +// pinned, and a write request is handed to the actual Native boundary. +// --------------------------------------------------------------------------- +struct PhaseALink { + workspace: std::path::PathBuf, + db: std::path::PathBuf, + store: rustscript_agent::plugins::PackageStore, + token: String, + state: rustscript_agent::AgentGatewayState, + http: Http, + graph: String, + generation: u64, + session_id: String, +} + +async fn open_phase_a_managed_link(label: &str) -> PhaseALink { + use ring::{ + rand::SystemRandom, + signature::{Ed25519KeyPair, KeyPair}, + }; + use rustscript_agent::plugins::{ + InstallAuthorization, PackageStore, PublisherKey, trusted_tool_package_root, + }; + use rustscript_agent::{ + AgentGatewayConfig, AgentGatewayState, + config::{ManagedMaintenancePolicy, ManagedRetentionLimits, ManagedWorkspaceConfig}, + effects::ResourceQuota, + }; + + const LEAF_ID: &str = "org.rustscript.lib.phasetext"; + const TOOL_A_ID: &str = "org.rustscript.tools.phasea"; + let leaf_manifest = format!( + "format: rustscript.plugin/v1\nid: {LEAF_ID}\nversion: 1.0.0\nkind: library\nruntime: \">=0.1.0, <0.2.0\"\ncontract: agent.library/v1\nhost_abi: agent.cap/v1\nentry:\n path: format.rss\n export: format\ndependencies: []\ncapabilities: []\neffects:\n contract: agent.effects/v1\n entries:\n format:\n direct: []\n delegated: []\n" + ); + let tool_manifest = format!( + "format: rustscript.plugin/v1\nid: {TOOL_A_ID}\nversion: 1.0.0\nkind: tool\nruntime: \">=0.1.0, <0.2.0\"\ncontract: agent.tool/v1\nhost_abi: agent.cap/v1\nentry:\n path: run.rss\n export: run\ndependencies:\n - alias: text\n id: {LEAF_ID}\n version: \"1.0.0\"\ncapabilities:\n - workspace.read\neffects:\n contract: agent.effects/v1\n entries:\n run:\n direct:\n - kind: workspace.fs.read\n scope: bound_workspace\n recovery: none\n delegated: []\n" + ); + let run_source = r#"use bytes; +use text::{format}; +fn map_string(value: map, key: string, fallback: string) -> string { + let mut result: string = fallback; + if value.has(key) { + if type(value[key]) == "string" { + let coerced: string = value[key]; + result = coerced; + } + } + result +} + +fn map_map(value: map, key: string) -> map { + let mut result: map = {}; + if value.has(key) { + if type(value[key]) == "map" { + let coerced: map = value[key]; + result = coerced; + } + } + result +} + +fn map_int(value: map, key: string, fallback: int) -> int { + let mut result: int = fallback; + if value.has(key) { + if type(value[key]) == "int" { + let coerced: int = value[key]; + result = coerced; + } + } + result +} + +fn map_bool(value: map, key: string, fallback: bool) -> bool { + let mut result: bool = fallback; + if value.has(key) { + if type(value[key]) == "bool" { + let coerced: bool = value[key]; + result = coerced; + } + } + result +} + +pub fn run(context: map) -> map { + let prepared: map = agent_runtime::tool_prepare(map_map(context, "prepare")); + if map_string(prepared, "kind", "") == "replay" => { + map_map(prepared, "result") + } else if map_bool(prepared, "ok", false) == false => { + prepared + } else => { + let token: string = map_string(prepared, "execution_token", ""); + let arguments: map = map_map(context, "arguments"); + let path: string = map_string(arguments, "path", ""); + let meta: map = cap::fs_metadata(token, path); + if map_bool(meta, "ok", false) == false => { + meta + } else => { + let file_len: int = map_int(meta, "len", 0); + let read: map = cap::fs_read_range(token, path, 0, file_len); + if map_bool(read, "ok", false) == false => { + read + } else => { + let payload: bytes = read.bytes; + let labeled: string = format(bytes::to_utf8(payload)); + let result: map = { ok: true, content: labeled, data: {}, error: null, truncated: false, artifacts: [] }; + let committed: map = agent_runtime::tool_commit(token, result); + if map_string(committed, "kind", "") == "committed" => { + map_map(committed, "result") + } else => { + committed + } + } + } + } +} +"#; + + let root = std::path::PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()) + .join(format!("t456-{label}-{}", uuid::Uuid::new_v4().simple())); + let _ = std::fs::remove_dir_all(&root); + let workspace = root.join("workspace"); + let home = root.join("workspace-home"); + let source = root.join("controlled-source"); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::create_dir_all(&home).unwrap(); + std::fs::create_dir_all(&source).unwrap(); + std::fs::write(workspace.join("seed.txt"), b"seed").unwrap(); + let yaml = format!( + "version: 1\nmodel:\n provider: local-test\n model: synthetic\nproviders:\n local-test:\n protocol: codex-responses\n base_url: https://127.0.0.1:1\nworkspaces:\n allowed_roots:\n - '{}'\n default: '{}'\n named:\n lifecycle: '{}'\n", + workspace.display(), + workspace.display(), + workspace.display() + ); + std::fs::write(home.join("config.yaml"), yaml).unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for path in [&root, &workspace, &home, &source] { + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + std::fs::set_permissions( + home.join("config.yaml"), + std::fs::Permissions::from_mode(0o600), + ) + .unwrap(); + } + let token = uuid::Uuid::new_v4().to_string(); + let key = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new()).unwrap(); + let pair = Ed25519KeyPair::from_pkcs8(key.as_ref()).unwrap(); + let auth = InstallAuthorization { + admin: true, + accepted_publishers: vec![PublisherKey { + publisher: "org.rustscript".into(), + verifying_key: pair.public_key().as_ref().to_vec(), + }], + unsigned_dev_space: None, + }; + let mut config = AgentGatewayConfig { + bearer_token: Some(token.clone()), + managed_workspace: Some(ManagedWorkspaceConfig { + source_root: workspace.clone(), + quota: ResourceQuota::new(1000, 1_048_576, 16).unwrap(), + retention: ManagedRetentionLimits::default(), + maintenance_policy: ManagedMaintenancePolicy { + create_checkpoint: true, + restore: true, + branch: true, + publish: false, + }, + }), + ..AgentGatewayConfig::default() + }; + config.approvals.write = "allow".into(); + // 461: the undeclared-process case must be refused by the managed effect + // fence, not by the risk approval gate, so the execute class is permitted + // here exactly like the in-crate managed fixtures. + config.approvals.process = "allow".into(); + config.run_timeout = Duration::from_secs(900); + let db = root.join("gateway.sqlite"); + let state = AgentGatewayState::with_sqlite_path(config.clone(), &db) + .unwrap() + .with_workspace_home(&home) + .with_plugin_control(auth.clone(), source.clone(), "owner-space") + .unwrap(); + let store = PackageStore::open(trusted_tool_package_root(&db)).unwrap(); + let stage = |dir: &str, manifest: &str, files: &[(&str, &[u8])]| { + let package = source.join(dir); + std::fs::create_dir_all(&package).unwrap(); + std::fs::write(package.join("manifest.yaml"), manifest).unwrap(); + for (name, body) in files { + std::fs::write(package.join(name), body).unwrap(); + } + let payload = store.signature_payload(&package).unwrap(); + std::fs::write( + package.join("signature.ed25519"), + pair.sign(&payload).as_ref(), + ) + .unwrap(); + store.install(&package, &auth).unwrap(); + }; + stage( + "package-phaselib", + &leaf_manifest, + &[( + "format.rss", + b"pub fn format(value: string) -> string { \"libA:\" + value }\n".as_slice(), + )], + ); + stage( + "package-phasea", + &tool_manifest, + &[ + ("run.rss", run_source.as_bytes()), + ( + "catalog.rss", + include_bytes!("../rss/tools/types.rss").as_slice(), + ), + ], + ); + let http = Http::bind(state.clone()).await; + let (graph, generation) = activate(&http, &token, &config, &db, TOOL_A_ID).await; + let (status, session) = http + .request("POST", "/api/sessions", json!({}), Some(&token), None) + .await; + assert_eq!(status, 201, "{}", explain(status, &session)); + let session_id = session["session"]["id"].as_str().unwrap().to_string(); + std::fs::write(workspace.join("note.txt"), b"hello").unwrap(); + PhaseALink { + workspace, + db, + store, + token, + state, + http, + graph, + generation, + session_id, + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn phase_a_native_boundary_denies_undeclared_write_after_real_pin() { + let link = open_phase_a_managed_link("write-denied").await; + // Positive control: the same real pin returns the leaf-labeled read. + let reader = Arc::new(ScriptedProvider::new()); + reader.push_ok(tool_call("read-lib-a", "read_file", "note.txt")); + reader.push_ok(stop_turn("done-read")); + link.state.service().inject_provider_host(reader); + let (status, admitted) = link + .http + .request( + "POST", + "/v1/runs", + json!({"session_id": link.session_id, "input": "read through the leaf"}), + Some(&link.token), + None, + ) + .await; + assert_eq!(status, 202, "{}", explain(status, &admitted)); + assert_eq!(admitted["graph_id"], link.graph); + assert_eq!(admitted["activation_generation"], link.generation); + let run_a = admitted["run_id"].as_str().unwrap().to_string(); + wait_until(|| { + event_names(&link.state, &run_a).iter().any(|name| { + matches!( + name.as_str(), + "run.completed" | "run.failed" | "run.cancelled" + ) + }) + }) + .await; + let read_events = link.state.service().run_events(&run_a); + assert!( + read_events + .iter() + .any(|event| event["event"] == "run.completed"), + "positive read run must complete: {read_events:?}" + ); + let projection = projected_run(&link.http, &link.token, &link.session_id, &run_a).await; + assert!(projection.contains("libA:hello"), "{projection}"); + + // The same pinned read-only program receives an undeclared write request. + let writer = Arc::new(ScriptedProvider::new()); + writer.push_ok(tool_call("write-denied", "write_file", "denied-write.txt")); + writer.push_ok(stop_turn("done-write")); + link.state.service().inject_provider_host(writer); + let (status, admitted) = link + .http + .request( + "POST", + "/v1/runs", + json!({"session_id": link.session_id, "input": "write through the read-only pin"}), + Some(&link.token), + None, + ) + .await; + assert_eq!(status, 202, "{}", explain(status, &admitted)); + assert_eq!(admitted["graph_id"], link.graph); + let run_w = admitted["run_id"].as_str().unwrap().to_string(); + wait_until(|| { + event_names(&link.state, &run_w).iter().any(|name| { + matches!( + name.as_str(), + "run.completed" | "run.failed" | "run.cancelled" + ) + }) + }) + .await; + let events = link.state.service().run_events(&run_w); + // The pinned read-only capture declares only workspace.fs.read, so the + // Native managed-authority check refuses the write before any effect. With + // the production "allow" write policy the harness requests the Native + // attestation first; `checked_managed_policy` -> `seal_call_policy` returns + // "managed undeclared effect", which the harness surfaces as this exact + // terminal policy rejection (never as a successful tool call). + let refusal = events.iter().find(|event| { + (event["event"] == "run.failed" + && event["data"]["error_message"] == "native policy facts rejected this action") + || (event["event"] == "tool.failed" + && event["data"]["tool_call_id"] == "write-denied" + && event["data"]["error_code"] == "managed_effect_denied") + }); + assert!( + refusal.is_some(), + "the undeclared write must be refused at the Native boundary: {events:?}" + ); + assert!( + !events.iter().any(|event| event["event"] == "run.completed"), + "a refused write must not complete the run: {events:?}" + ); + assert!( + !events.iter().any(|event| { + event["event"] == "tool.completed" && event["data"]["tool_call_id"] == "write-denied" + }), + "a refused write must not report a completed tool call: {events:?}" + ); + assert!( + !link.workspace.join("denied-write.txt").exists(), + "no write may reach the workspace" + ); + let (status, effects) = link + .http + .request( + "GET", + &format!("/v1/runs/{run_w}/effects?limit=128"), + json!(null), + Some(&link.token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &effects)); + assert!( + !effects["entries"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry["call_id"] == "write-denied"), + "a refused write must not register a managed effect: {effects}" + ); + link.http.close().await; + shutdown_when_idle(&link.state).await; + let _ = std::fs::remove_dir_all(link.db.parent().unwrap()); +} + +// --------------------------------------------------------------------------- +// 461: real Service F4 stages on the same signed captured pinned graph A. +// The provider hook pauses the actual run worker after admission sealed the +// pin and built the live host bridge, but before the Native boundary begins. +// --------------------------------------------------------------------------- +struct PausingToolProvider { + entered: std::sync::Mutex>>, + release: std::sync::Mutex>>, + call_id: String, + tool_name: String, + arguments: Value, + step: std::sync::atomic::AtomicUsize, +} + +fn pausing_tool_provider( + call_id: &str, + tool_name: &str, + arguments: Value, +) -> ( + Arc, + std::sync::mpsc::Receiver<()>, + std::sync::mpsc::Sender<()>, +) { + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + ( + Arc::new(PausingToolProvider { + entered: std::sync::Mutex::new(Some(entered_tx)), + release: std::sync::Mutex::new(Some(release_rx)), + call_id: call_id.to_string(), + tool_name: tool_name.to_string(), + arguments, + step: std::sync::atomic::AtomicUsize::new(0), + }), + entered_rx, + release_tx, + ) +} + +impl rustscript_agent::AgentProviderHost for PausingToolProvider { + fn call(&self, _request: &Value, _cancellation: &rustscript_agent::RunCancellation) -> Value { + if self.step.fetch_add(1, std::sync::atomic::Ordering::SeqCst) > 0 { + return json!({"ok": true, "response": stop_turn("after-tool"), "error": {}}); + } + if let Some(entered) = self + .entered + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take() + { + let _ = entered.send(()); + } + if let Some(release) = self + .release + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take() + { + release + .recv_timeout(Duration::from_secs(60)) + .expect("fixture must release the paused provider turn"); + } + json!({ + "ok": true, + "response": json!({ + "text": "", + "tool_calls": [{ + "id": self.call_id, + "name": self.tool_name, + "arguments": self.arguments, + }], + "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + "reasoning": "", + "stop_reason": "tool_calls" + }), + "error": {} + }) + } +} + +fn terminal_event(events: &[Value]) -> Option { + events + .iter() + .find(|event| { + matches!( + event["event"].as_str(), + Some("run.completed") | Some("run.failed") | Some("run.cancelled") + ) + }) + .cloned() +} + +#[tokio::test(flavor = "multi_thread")] +async fn phase_a_service_begin_denies_revoke_between_pin_and_native_begin() { + let link = open_phase_a_managed_link("service-revoke-begin").await; + let (provider, entered, release) = + pausing_tool_provider("service-read-1", "read_file", json!({"path": "note.txt"})); + link.state.service().inject_provider_host(provider); + let (status, admitted) = link + .http + .request( + "POST", + "/v1/runs", + json!({"session_id": link.session_id, "input": "read pinned A after its graph is revoked"}), + Some(&link.token), + None, + ) + .await; + assert_eq!(status, 202, "{}", explain(status, &admitted)); + assert_eq!( + admitted["graph_id"], link.graph, + "the run must be admitted on the same captured graph A: {admitted}" + ); + assert_eq!(admitted["activation_generation"], link.generation); + let run = admitted["run_id"].as_str().unwrap().to_string(); + entered + .recv_timeout(Duration::from_secs(60)) + .expect("the worker must reach the provider after sealing the pin"); + + // Real same-control-store revocation of the exact admitted graph, while the + // live run holds its sealed pin and host bridge and has not begun the tool. + let config = (**link.state.service().config()).clone(); + revoke_graph(&config, &link.db, &link.store, &link.graph) + .expect("graph A must revoke through the narrow topology"); + release.send(()).expect("release the paused provider turn"); + + wait_until(|| terminal_event(&link.state.service().run_events(&run)).is_some()).await; + let events = link.state.service().run_events(&run); + println!("phase_a_service begin evidence: {events:?}"); + assert!( + !events.iter().any(|event| event["event"] == "tool.started"), + "a revoked graph must not start the pinned call: {events:?}" + ); + assert!( + !events + .iter() + .any(|event| event["event"] == "tool.completed"), + "a revoked graph must not complete the pinned call: {events:?}" + ); + let terminal = terminal_event(&events).expect("terminal event"); + assert_ne!( + terminal["event"], "run.completed", + "a revoked pinned run must not complete: {terminal}" + ); + assert!( + events + .iter() + .any(|event| event.to_string().contains("plugin_graph_revoked")), + "the refusal must be the exact typed graph revocation: {events:?}" + ); + assert!( + !projected_run(&link.http, &link.token, &link.session_id, &run) + .await + .contains("libA:hello"), + "the revoked leaf must never deliver its labeled bytes" + ); + let (status, effects) = link + .http + .request( + "GET", + &format!("/v1/runs/{run}/effects?limit=128"), + json!(null), + Some(&link.token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &effects)); + assert!( + !effects["entries"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry["call_id"] == "service-read-1"), + "a refused begin must not register a managed effect: {effects}" + ); + link.http.close().await; + shutdown_when_idle(&link.state).await; + let _ = std::fs::remove_dir_all(link.db.parent().unwrap()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn phase_a_service_cancel_root_denies_before_native_begin_after_pin() { + let link = open_phase_a_managed_link("service-cancel").await; + let (provider, entered, release) = + pausing_tool_provider("service-read-2", "read_file", json!({"path": "note.txt"})); + link.state.service().inject_provider_host(provider); + let (status, admitted) = link + .http + .request( + "POST", + "/v1/runs", + json!({"session_id": link.session_id, "input": "cancel the pinned run before its tool begins"}), + Some(&link.token), + None, + ) + .await; + assert_eq!(status, 202, "{}", explain(status, &admitted)); + assert_eq!(admitted["graph_id"], link.graph); + let run = admitted["run_id"].as_str().unwrap().to_string(); + entered + .recv_timeout(Duration::from_secs(60)) + .expect("the worker must reach the provider after sealing the pin"); + + // The run's own admitted cancellation root, not a fixture-created clock. + let (status, stopped) = link + .http + .request( + "POST", + &format!("/v1/runs/{run}/stop"), + json!({}), + Some(&link.token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &stopped)); + release.send(()).expect("release the paused provider turn"); + + wait_until(|| terminal_event(&link.state.service().run_events(&run)).is_some()).await; + let events = link.state.service().run_events(&run); + println!("phase_a_service cancel evidence: {events:?}"); + let terminal = terminal_event(&events).expect("terminal event"); + assert_eq!( + terminal["event"], "run.cancelled", + "the admitted cancellation root must cancel the pinned run: {terminal}" + ); + assert!( + !events.iter().any(|event| event["event"] == "tool.started"), + "a cancelled run must not start the pinned call: {events:?}" + ); + assert!( + !events + .iter() + .any(|event| event["event"] == "tool.completed"), + "a cancelled run must not complete the pinned call: {events:?}" + ); + assert!( + !events.iter().any(|event| event["event"] == "run.completed"), + "a cancelled run must not report success: {events:?}" + ); + let (status, effects) = link + .http + .request( + "GET", + &format!("/v1/runs/{run}/effects?limit=128"), + json!(null), + Some(&link.token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &effects)); + assert!( + !effects["entries"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry["call_id"] == "service-read-2"), + "a cancelled begin must not register a managed effect: {effects}" + ); + assert!( + !projected_run(&link.http, &link.token, &link.session_id, &run) + .await + .contains("libA:hello"), + "the cancelled leaf must never deliver its labeled bytes" + ); + link.http.close().await; + shutdown_when_idle(&link.state).await; + let _ = std::fs::remove_dir_all(link.db.parent().unwrap()); +} + +#[tokio::test(flavor = "multi_thread")] +async fn phase_a_service_terminal_argv_denies_undeclared_process_effect() { + let link = open_phase_a_managed_link("service-process").await; + let sentinel = link + .workspace + .parent() + .unwrap() + .join("process-spawn-sentinel"); + let provider = Arc::new(ScriptedProvider::new()); + provider.push_ok(json!({ + "text": "", + "tool_calls": [{ + "id": "service-process-1", + "name": "terminal", + "arguments": { + "argv": ["/bin/sh", "-c", format!("printf x > '{}'", sentinel.display())], + "timeout_ms": 1000 + } + }], + "usage": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2}, + "reasoning": "", + "stop_reason": "tool_calls" + })); + provider.push_ok(stop_turn("after-process")); + link.state.service().inject_provider_host(provider); + let (status, admitted) = link + .http + .request( + "POST", + "/v1/runs", + json!({"session_id": link.session_id, "input": "spawn under the read-only pinned graph"}), + Some(&link.token), + None, + ) + .await; + assert_eq!(status, 202, "{}", explain(status, &admitted)); + assert_eq!(admitted["graph_id"], link.graph); + let run = admitted["run_id"].as_str().unwrap().to_string(); + wait_until(|| terminal_event(&link.state.service().run_events(&run)).is_some()).await; + let events = link.state.service().run_events(&run); + println!("phase_a_service process evidence: {events:?}"); + let terminal = terminal_event(&events).expect("terminal event"); + assert_ne!( + terminal["event"], "run.completed", + "an undeclared process effect must not complete: {terminal}" + ); + assert!( + !events.iter().any(|event| { + event["event"] == "tool.completed" + && event["data"]["tool_call_id"] == "service-process-1" + }), + "the undeclared process call must not complete: {events:?}" + ); + assert!( + events.iter().any(|event| { + event.to_string().contains("managed_effect_denied") + || event["data"]["error_message"] == "native policy facts rejected this action" + }), + "the refusal must be the typed managed-effect fence: {events:?}" + ); + assert!( + !sentinel.exists(), + "the process must be refused before any OS spawn" + ); + let (status, effects) = link + .http + .request( + "GET", + &format!("/v1/runs/{run}/effects?limit=128"), + json!(null), + Some(&link.token), + None, + ) + .await; + assert_eq!(status, 200, "{}", explain(status, &effects)); + assert!( + !effects["entries"] + .as_array() + .unwrap() + .iter() + .any(|entry| entry["call_id"] == "service-process-1"), + "a refused process must not register a managed effect: {effects}" + ); + link.http.close().await; + shutdown_when_idle(&link.state).await; + let _ = std::fs::remove_dir_all(link.db.parent().unwrap()); +} diff --git a/tests/plugin_graph_tests.rs b/tests/plugin_graph_tests.rs index 64e8788..019d337 100644 --- a/tests/plugin_graph_tests.rs +++ b/tests/plugin_graph_tests.rs @@ -1364,3 +1364,671 @@ fn resolver_budget_is_a_typed_error() { "{err:?}" ); } + +// 421 phase A: one AcceptedPublisher stateless read-only tool plus one pure +// leaf library. The tool does the Native read; the leaf only formats strings. +fn phase_a_tool_manifest(id: &str, lib: &str) -> String { + format!( + r#"format: rustscript.plugin/v1 +id: {id} +version: 1.0.0 +kind: tool +runtime: ">=0.1.0, <0.2.0" +contract: agent.tool/v1 +host_abi: agent.cap/v1 +entry: + path: run.rss + export: run +dependencies: + - alias: text + id: {lib} + version: "1.0.0" +capabilities: + - workspace.read +effects: + contract: agent.effects/v1 + entries: + run: + direct: + - kind: workspace.fs.read + scope: bound_workspace + recovery: none + delegated: [] +"# + ) +} + +fn phase_a_leaf_manifest(id: &str) -> String { + library_manifest(id, "1.0.0", "format") +} + +fn phase_a_run_source() -> &'static str { + r#"use bytes; +use text::{format}; + +pub fn run(token: string, path: string) -> map { + let read: map = cap::fs_read_range(token, path, 0, 8); + let payload: bytes = read.bytes; + { ok: true, content: format(bytes::to_utf8(payload)) } +} +"# +} + +fn phase_a_format_source() -> &'static str { + "pub fn format(value: string) -> string { \"libA:\" + value }\n" +} + +fn phase_a_resolve(store: &PackageStore, id: &str) -> rustscript_agent::plugins::GraphLock { + store + .resolve(&ResolveRequest { + harness_selection: rustscript_agent::plugins::HarnessSelectionRequest::ToolOnly { + tool_package: id.to_string(), + }, + roots: vec![PackageRequirement { + id: id.to_string(), + version: "1.0.0".to_string(), + }], + host_runtime: "0.1.0".to_string(), + }) + .unwrap() +} + +#[test] +fn phase_a_single_tool_plus_pure_leaf_prepares_combined_program() { + let root = temp_dir("phase-a-ok"); + let store = PackageStore::open(root.join("store")).unwrap(); + let signer = signer(); + write_signed( + &store, + &signer, + &root.join("leaf"), + &phase_a_leaf_manifest("org.rustscript.lib.texta"), + &[("format.rss", phase_a_format_source())], + ); + write_signed( + &store, + &signer, + &root.join("tool"), + &phase_a_tool_manifest("org.rustscript.tools.readliba", "org.rustscript.lib.texta"), + &[("run.rss", phase_a_run_source())], + ); + let lock = phase_a_resolve(&store, "org.rustscript.tools.readliba"); + assert_eq!(lock.packages.len(), 2); + assert_eq!(lock.edges.len(), 1); + assert_eq!(lock.edges[0].relation, "library"); + let prepared = store.prepare(&lock).unwrap(); + assert_eq!( + prepared.trust("org.rustscript.tools.readliba"), + Some(PackageTrust::AcceptedPublisher) + ); + assert_eq!( + prepared.trust("org.rustscript.lib.texta"), + Some(PackageTrust::AcceptedPublisher) + ); + assert!( + prepared.program("org.rustscript.tools.readliba").is_some(), + "tool keeps the combined program" + ); + assert!( + prepared.program("org.rustscript.lib.texta").is_some(), + "leaf keeps its standalone purity proof program" + ); + assert!( + prepared + .captured_source("org.rustscript.tools.readliba", "run.rss") + .is_some() + ); +} + +#[test] +fn phase_a_same_alias_plugin_edge_stays_behind_the_boundary() { + let root = temp_dir("phase-a-plugin-edge"); + let store = PackageStore::open(root.join("store")).unwrap(); + let signer = signer(); + write_signed( + &store, + &signer, + &root.join("callee"), + &tool_manifest( + "org.rustscript.tools.callee", + "1.0.0", + "", + " direct: []\n delegated: []\n", + "", + ), + &[("invoke.rss", "pub fn invoke() -> int { 1 }\n")], + ); + write_signed( + &store, + &signer, + &root.join("caller"), + &phase_a_tool_manifest("org.rustscript.tools.caller", "org.rustscript.tools.callee"), + &[( + "run.rss", + "use text::{invoke};\n\npub fn run() -> int { invoke() }\n", + )], + ); + let lock = phase_a_resolve(&store, "org.rustscript.tools.caller"); + assert_eq!(lock.edges.len(), 1); + assert_eq!(lock.edges[0].relation, "plugin"); + let error = store.prepare(&lock).unwrap_err(); + assert_eq!(error.kind, PluginErrorKind::PluginBoundaryBypass); + assert!( + error.chain.iter().any(|item| item.contains("text")), + "{error:?}" + ); +} + +#[test] +fn phase_a_impure_leaf_sources_are_rejected_with_typed_errors() { + let cases: &[(&str, &str, PluginErrorKind)] = &[ + ( + "cap", + "use cap;\n\npub fn format(value: string) -> string {\n let meta: map = cap::fs_metadata(\"t\", \"p\");\n value\n}\n", + PluginErrorKind::UnauthorizedHostImport, + ), + ( + "undeclared-host-call", + "pub fn format(value: string) -> string {\n let meta: map = cap::fs_metadata(\"t\", \"p\");\n value\n}\n", + // Host calls require an explicit `use` declaration: without one + // the leaf does not even parse, so prepare fails at the compile + // gate before any purity check could run. + PluginErrorKind::CompileFailed, + ), + ( + "clock", + "use agent;\n\npub fn format(value: string) -> string {\n let slept: int = agent::sleep_ms(0);\n value\n}\n", + PluginErrorKind::UnauthorizedHostImport, + ), + ( + "stream", + "use stream;\n\npub fn format(value: string) -> string { value }\n", + PluginErrorKind::UnauthorizedHostImport, + ), + ( + "state", + "use state;\n\npub fn format(value: string) -> string { value }\n", + PluginErrorKind::UnauthorizedHostImport, + ), + ( + "caller-module", + "use run::{run};\n\npub fn format(value: string) -> string { value }\n", + PluginErrorKind::PluginBoundaryBypass, + ), + ( + "map-interface", + "pub fn format(value: map) -> map { value }\n", + PluginErrorKind::ContractIncompatible, + ), + ( + "int-return", + "pub fn format(value: string) -> int { 1 }\n", + PluginErrorKind::ContractIncompatible, + ), + ( + "wrong-export", + "pub fn other(value: string) -> string { value }\n", + PluginErrorKind::ContractIncompatible, + ), + ]; + for (label, leaf_source, expected) in cases { + let root = temp_dir(&format!("phase-a-leaf-{label}")); + let store = PackageStore::open(root.join("store")).unwrap(); + let signer = signer(); + write_signed( + &store, + &signer, + &root.join("leaf"), + &phase_a_leaf_manifest("org.rustscript.lib.texta"), + &[("format.rss", leaf_source)], + ); + write_signed( + &store, + &signer, + &root.join("tool"), + &phase_a_tool_manifest("org.rustscript.tools.readliba", "org.rustscript.lib.texta"), + &[("run.rss", phase_a_run_source())], + ); + let lock = phase_a_resolve(&store, "org.rustscript.tools.readliba"); + let error = store.prepare(&lock).unwrap_err(); + assert_eq!(error.kind, *expected, "{label}: {error:?}"); + assert!( + !error.chain.is_empty(), + "{label}: typed error must carry a chain" + ); + } +} + +#[test] +fn phase_a_leaf_without_export_cannot_serve_the_link() { + let root = temp_dir("phase-a-no-export"); + let store = PackageStore::open(root.join("store")).unwrap(); + let signer = signer(); + write_signed( + &store, + &signer, + &root.join("leaf"), + &phase_a_leaf_manifest("org.rustscript.lib.texta"), + &[( + "format.rss", + "fn helper(value: string) -> string { value }\n", + )], + ); + write_signed( + &store, + &signer, + &root.join("tool"), + &phase_a_tool_manifest("org.rustscript.tools.readliba", "org.rustscript.lib.texta"), + &[("run.rss", phase_a_run_source())], + ); + let lock = phase_a_resolve(&store, "org.rustscript.tools.readliba"); + let error = store.prepare(&lock).unwrap_err(); + assert_eq!(error.kind, PluginErrorKind::UncoveredExport); +} + +#[test] +fn phase_a_impure_leaf_manifests_are_rejected_as_shape() { + let stateful_leaf = format!( + "{}state:\n scope: run\n schema: 1\n", + phase_a_leaf_manifest("org.rustscript.lib.texta") + ); + // Declared effects must be covered by capabilities, so the effectful leaf + // carries the read capability and prepare rejects the effect surface. + let effectful_leaf = phase_a_leaf_manifest("org.rustscript.lib.texta") + .replace( + "capabilities: []\n", + "capabilities:\n - workspace.read\n", + ) + .replace( + " format:\n direct: []\n delegated: []\n", + " format:\n direct:\n - kind: workspace.fs.read\n scope: bound_workspace\n recovery: none\n delegated: []\n", + ); + for (label, leaf_manifest) in [("state", stateful_leaf), ("effect", effectful_leaf)] { + let root = temp_dir(&format!("phase-a-leaf-manifest-{label}")); + let store = PackageStore::open(root.join("store")).unwrap(); + let signer = signer(); + write_signed( + &store, + &signer, + &root.join("leaf"), + &leaf_manifest, + &[("format.rss", phase_a_format_source())], + ); + write_signed( + &store, + &signer, + &root.join("tool"), + &phase_a_tool_manifest("org.rustscript.tools.readliba", "org.rustscript.lib.texta"), + &[("run.rss", phase_a_run_source())], + ); + let lock = phase_a_resolve(&store, "org.rustscript.tools.readliba"); + let error = store.prepare(&lock).unwrap_err(); + assert_eq!( + error.kind, + PluginErrorKind::ContractIncompatible, + "{label}: {error:?}" + ); + assert!( + error.chain.iter().any(|item| item.contains("library_leaf")), + "{label}: {error:?}" + ); + } +} + +#[test] +fn phase_a_capability_without_effects_is_rejected_at_install() { + let root = temp_dir("phase-a-leaf-cap-install"); + let store = PackageStore::open(root.join("store")).unwrap(); + let signer = signer(); + let dir = root.join("leaf"); + fs::create_dir_all(&dir).unwrap(); + fs::write( + dir.join("manifest.yaml"), + phase_a_leaf_manifest("org.rustscript.lib.texta") + .replace("capabilities: []\n", "capabilities:\n - workspace.read\n"), + ) + .unwrap(); + fs::write(dir.join("format.rss"), phase_a_format_source()).unwrap(); + let payload = store.signature_payload(&dir).unwrap(); + fs::write( + dir.join("signature.ed25519"), + signer.pair.sign(&payload).as_ref(), + ) + .unwrap(); + let error = store.install(&dir, &auth(&signer, None)).unwrap_err(); + assert_eq!(error.kind, PluginErrorKind::ContractIncompatible); + assert!( + error.chain.iter().any(|item| item.contains("capabilities")), + "{error:?}" + ); +} + +#[test] +fn phase_a_tool_surface_mismatch_is_rejected_as_shape() { + // Capabilities must cover the declared effects, so the widened tool + // carries both read and write and prepare rejects the capability surface. + let write_tool = phase_a_tool_manifest( + "org.rustscript.tools.readliba", + "org.rustscript.lib.texta", + ) + .replace( + " - workspace.read\n", + " - workspace.read\n - workspace.write\n", + ) + .replace( + " - kind: workspace.fs.read\n scope: bound_workspace\n recovery: none\n", + " - kind: workspace.fs.read\n scope: bound_workspace\n recovery: none\n - kind: workspace.fs.write\n scope: bound_workspace\n recovery: none\n", + ); + // An empty delegated allow list keeps capabilities covered while the + // tool entry gains a delegated surface prepare must reject. + let delegated_tool = phase_a_tool_manifest( + "org.rustscript.tools.readliba", + "org.rustscript.lib.texta", + ) + .replace( + " delegated: []\n", + " delegated:\n - dependency: text\n entry: format\n allow: []\n", + ); + for (label, tool_manifest) in [("capability", write_tool), ("delegated", delegated_tool)] { + let root = temp_dir(&format!("phase-a-tool-surface-{label}")); + let store = PackageStore::open(root.join("store")).unwrap(); + let signer = signer(); + write_signed( + &store, + &signer, + &root.join("leaf"), + &phase_a_leaf_manifest("org.rustscript.lib.texta"), + &[("format.rss", phase_a_format_source())], + ); + write_signed( + &store, + &signer, + &root.join("tool"), + &tool_manifest, + &[("run.rss", phase_a_run_source())], + ); + let lock = phase_a_resolve(&store, "org.rustscript.tools.readliba"); + let error = store.prepare(&lock).unwrap_err(); + assert_eq!( + error.kind, + PluginErrorKind::ContractIncompatible, + "{label}: {error:?}" + ); + assert!( + error.chain.iter().any(|item| item.contains("library_tool")), + "{label}: {error:?}" + ); + } +} + +#[test] +fn phase_a_alias_collisions_are_rejected_as_shape() { + // Host-namespace alias: resolving `use json` to the leaf would shadow + // the host namespace. + let root = temp_dir("phase-a-alias-host"); + let store = PackageStore::open(root.join("store")).unwrap(); + let signer = signer(); + write_signed( + &store, + &signer, + &root.join("leaf"), + &phase_a_leaf_manifest("org.rustscript.lib.texta"), + &[("format.rss", phase_a_format_source())], + ); + let host_alias_manifest = + phase_a_tool_manifest("org.rustscript.tools.readliba", "org.rustscript.lib.texta") + .replace(" - alias: text\n", " - alias: json\n"); + write_signed( + &store, + &signer, + &root.join("tool"), + &host_alias_manifest, + &[( + "run.rss", + "use json::{format};\n\npub fn run(token: string, path: string) -> map {\n cap::fs_read_range(token, path, 0, 8)\n}\n", + )], + ); + let lock = phase_a_resolve(&store, "org.rustscript.tools.readliba"); + let error = store.prepare(&lock).unwrap_err(); + assert_eq!( + error.kind, + PluginErrorKind::ContractIncompatible, + "{error:?}" + ); + assert!( + error + .chain + .iter() + .any(|item| item.contains("library_alias")), + "{error:?}" + ); + + // Local-module alias collision: the tool already ships `text.rss`. + let root = temp_dir("phase-a-alias-local"); + let store = PackageStore::open(root.join("store")).unwrap(); + write_signed( + &store, + &signer, + &root.join("leaf"), + &phase_a_leaf_manifest("org.rustscript.lib.texta"), + &[("format.rss", phase_a_format_source())], + ); + write_signed( + &store, + &signer, + &root.join("tool"), + &phase_a_tool_manifest("org.rustscript.tools.readliba", "org.rustscript.lib.texta"), + &[ + ("run.rss", phase_a_run_source()), + ("text.rss", "pub fn shadow() -> int { 1 }\n"), + ], + ); + let lock = phase_a_resolve(&store, "org.rustscript.tools.readliba"); + let error = store.prepare(&lock).unwrap_err(); + assert_eq!( + error.kind, + PluginErrorKind::ContractIncompatible, + "{error:?}" + ); + assert!( + error + .chain + .iter() + .any(|item| item.contains("library_alias")), + "{error:?}" + ); +} + +#[test] +fn phase_a_forged_edge_alias_is_rejected_as_shape() { + let root = temp_dir("phase-a-forged"); + let store = PackageStore::open(root.join("store")).unwrap(); + let signer = signer(); + write_signed( + &store, + &signer, + &root.join("leaf"), + &phase_a_leaf_manifest("org.rustscript.lib.texta"), + &[("format.rss", phase_a_format_source())], + ); + write_signed( + &store, + &signer, + &root.join("tool"), + &phase_a_tool_manifest("org.rustscript.tools.readliba", "org.rustscript.lib.texta"), + &[("run.rss", phase_a_run_source())], + ); + let mut lock = phase_a_resolve(&store, "org.rustscript.tools.readliba"); + lock.edges[0].alias = "other".to_string(); + let error = store.prepare(&lock).unwrap_err(); + assert_eq!( + error.kind, + PluginErrorKind::ContractIncompatible, + "{error:?}" + ); + assert!( + error + .chain + .iter() + .any(|item| item.contains("library_edge_mismatch")), + "{error:?}" + ); +} + +// 433-F1 evidence: full-lock forgery on the phase-A tool+leaf link. Each +// fixture proves the unmodified lock prepares before the forgery, so a +// semantic rejection failure cannot come from setup or compilation. The lock +// is mutated in memory only; captured package/manifest/source bytes stay real. +fn phase_a_install_linked_pair( + root: &Path, + with_catalog: bool, +) -> (PackageStore, rustscript_agent::plugins::GraphLock) { + let store = PackageStore::open(root.join("store")).unwrap(); + let signer = signer(); + write_signed( + &store, + &signer, + &root.join("leaf"), + &phase_a_leaf_manifest("org.rustscript.lib.texta"), + &[("format.rss", phase_a_format_source())], + ); + let mut files: Vec<(&str, &str)> = vec![("run.rss", phase_a_run_source())]; + if with_catalog { + files.push(("catalog.rss", include_str!("../rss/tools/types.rss"))); + } + write_signed( + &store, + &signer, + &root.join("tool"), + &phase_a_tool_manifest("org.rustscript.tools.readliba", "org.rustscript.lib.texta"), + &files, + ); + let lock = phase_a_resolve(&store, "org.rustscript.tools.readliba"); + (store, lock) +} + +fn phase_a_assert_unmodified_lock_prepares( + store: &PackageStore, + lock: &rustscript_agent::plugins::GraphLock, +) { + let prepared = store + .prepare(lock) + .expect("unmodified phase-A lock must prepare before the forgery control"); + assert!( + prepared.program("org.rustscript.tools.readliba").is_some(), + "positive control must compile the tool program" + ); + assert!( + prepared.program("org.rustscript.lib.texta").is_some(), + "positive control must compile the leaf program" + ); +} + +fn phase_a_forge_entry_identity(lock: &mut rustscript_agent::plugins::GraphLock) { + assert_eq!(lock.edges.len(), 1); + assert_eq!(lock.edges[0].to_entry, "format"); + let leaf_id = lock.edges[0].to.clone(); + lock.edges[0].to_entry = "other".to_string(); + let leaf_closure = lock + .closure + .iter_mut() + .find(|entry| entry.package == leaf_id) + .expect("leaf closure entry"); + assert!( + leaf_closure.effects.is_empty(), + "leaf closure under test must stay empty" + ); + assert_eq!(leaf_closure.entry, "format"); + leaf_closure.entry = "other".to_string(); +} + +fn phase_a_forge_direct_callee_version(lock: &mut rustscript_agent::plugins::GraphLock) { + let tool_closure = lock + .closure + .iter_mut() + .find(|entry| entry.package == "org.rustscript.tools.readliba") + .expect("tool closure entry"); + assert_eq!(tool_closure.effects.len(), 1); + let effect = &mut tool_closure.effects[0]; + assert_eq!(effect.via.relation, "direct"); + assert_eq!(effect.via.callee_version, "1.0.0"); + effect.via.callee_version = "1.0.1".to_string(); +} + +fn phase_a_assert_contract_incompatible( + error: &rustscript_agent::plugins::PluginError, + label: &str, +) { + assert_eq!( + error.kind, + PluginErrorKind::ContractIncompatible, + "{label}: {error:?}" + ); + assert!(!error.chain.is_empty(), "{label}: {error:?}"); +} + +#[test] +fn phase_a_forged_lock_entry_without_catalog_is_rejected_as_shape() { + let root = temp_dir("phase-a-forged-lock-entry-no-catalog"); + let (store, lock) = phase_a_install_linked_pair(&root, false); + phase_a_assert_unmodified_lock_prepares(&store, &lock); + let mut forged = lock.clone(); + phase_a_forge_entry_identity(&mut forged); + let error = store + .prepare(&forged) + .expect_err("forged edge/leaf closure entry must be rejected"); + phase_a_assert_contract_incompatible(&error, "no-catalog entry forgery"); +} + +#[test] +fn phase_a_forged_lock_direct_origin_without_catalog_is_rejected_as_shape() { + let root = temp_dir("phase-a-forged-lock-origin-no-catalog"); + let (store, lock) = phase_a_install_linked_pair(&root, false); + phase_a_assert_unmodified_lock_prepares(&store, &lock); + let mut forged = lock.clone(); + phase_a_forge_direct_callee_version(&mut forged); + let error = store + .prepare(&forged) + .expect_err("forged direct origin callee_version must be rejected"); + phase_a_assert_contract_incompatible(&error, "no-catalog origin forgery"); +} + +#[test] +fn phase_a_forged_lock_entry_with_catalog_is_rejected_as_shape() { + let root = temp_dir("phase-a-forged-lock-entry-with-catalog"); + let (store, lock) = phase_a_install_linked_pair(&root, true); + phase_a_assert_unmodified_lock_prepares(&store, &lock); + let mut forged = lock.clone(); + phase_a_forge_entry_identity(&mut forged); + let error = store + .prepare(&forged) + .expect_err("forged edge/leaf closure entry must be rejected"); + phase_a_assert_contract_incompatible(&error, "with-catalog entry forgery"); + assert!( + error + .chain + .iter() + .any(|item| item.contains("managed captured catalog")), + "with-catalog entry forgery must be rejected by the full lock rebuild: {error:?}" + ); +} + +#[test] +fn phase_a_forged_lock_direct_origin_with_catalog_is_rejected_as_shape() { + let root = temp_dir("phase-a-forged-lock-origin-with-catalog"); + let (store, lock) = phase_a_install_linked_pair(&root, true); + phase_a_assert_unmodified_lock_prepares(&store, &lock); + let mut forged = lock.clone(); + phase_a_forge_direct_callee_version(&mut forged); + let error = store + .prepare(&forged) + .expect_err("forged direct origin callee_version must be rejected"); + phase_a_assert_contract_incompatible(&error, "with-catalog origin forgery"); + assert!( + error + .chain + .iter() + .any(|item| item.contains("managed captured catalog")), + "with-catalog origin forgery must be rejected by the full lock rebuild: {error:?}" + ); +} diff --git a/tests/plugin_hot_reload_tests.rs b/tests/plugin_hot_reload_tests.rs index 8fee002..3742776 100644 --- a/tests/plugin_hot_reload_tests.rs +++ b/tests/plugin_hot_reload_tests.rs @@ -92,6 +92,120 @@ fn open() -> GatewayPersistence { .expect("production typed persistence") } +// 421 phase A: synthetic narrow lock for one read-only tool plus one pure +// leaf library. Mirrors resolve output: ToolOnly selection, one +// library-relation edge, a single direct read on the tool entry, and an empty +// leaf entry. +fn phase_a_lock(graph_id: &str, leaf_artifact: &str) -> GraphLock { + let direct = EffectOrigin { + relation: "direct".into(), + caller_package: "org.rustscript.tools.readliba".into(), + caller_entry: "run".into(), + alias: String::new(), + callee_package: "org.rustscript.tools.readliba".into(), + callee_version: "1.0.0".into(), + callee_entry: "run".into(), + version_requirement: String::new(), + }; + GraphLock { + graph_id: graph_id.into(), + harness_selection: rustscript_agent::plugins::HarnessSelection::ToolOnly { + tool_package: "org.rustscript.tools.readliba".into(), + }, + host_runtime: HOST_RUNTIME.into(), + compile_environment: COMPILE_ENVIRONMENT.into(), + packages: vec![ + LockedPackage { + id: "org.rustscript.lib.texta".into(), + version: "1.0.0".into(), + kind: "library".into(), + artifact_id: leaf_artifact.into(), + }, + LockedPackage { + id: "org.rustscript.tools.readliba".into(), + version: "1.0.0".into(), + kind: "tool".into(), + artifact_id: "artifact-tool-exact".into(), + }, + ], + edges: vec![LockedEdge { + from: "org.rustscript.tools.readliba".into(), + from_entry: "run".into(), + alias: "text".into(), + to: "org.rustscript.lib.texta".into(), + to_version: "1.0.0".into(), + to_artifact_id: leaf_artifact.into(), + to_entry: "format".into(), + relation: "library".into(), + version_requirement: "1.0.0".into(), + bindings: vec![], + }], + closure: vec![ + LockedEffect { + package: "org.rustscript.lib.texta".into(), + entry: "format".into(), + effects: vec![], + }, + LockedEffect { + package: "org.rustscript.tools.readliba".into(), + entry: "run".into(), + effects: vec![LockedEffectItem { + kind: "workspace.fs.read".into(), + scope: "bound_workspace".into(), + recovery: "none".into(), + via: direct, + }], + }, + ], + } +} + +#[test] +fn phase_a_leaf_swap_needs_no_reapproval_and_hot_cut_advances() { + use rustscript_agent::plugins::PackageArtifactRef; + let persistence = open(); + let store = PluginControlStore::new(&persistence); + let before = phase_a_lock("graph-leaf-a", "artifact-leaf-a"); + let after = phase_a_lock("graph-leaf-b", "artifact-leaf-b"); + for lock in [&before, &after] { + store.store_lock(lock).expect("legal phase-A graph"); + } + let diff = readonly_diff(&store, &before.graph_id, &after.graph_id).expect("diff"); + assert!( + !diff.requires_reapproval, + "identical read-only permission with a new leaf revision: {diff:?}" + ); + assert!( + diff.metadata + .package_revisions + .iter() + .any(|revision| revision.package == "org.rustscript.lib.texta"), + "leaf revision change is still tracked: {diff:?}" + ); + let refs = |lock: &GraphLock| { + lock.packages + .iter() + .map(|package| PackageArtifactRef { + package_id: package.id.clone(), + version: package.version.clone(), + kind: package.kind.clone(), + artifact_id: package.artifact_id.clone(), + }) + .collect::>() + }; + let pointer = store + .activate(&before.graph_id, 0, &refs(&before)) + .expect("activate A"); + assert_eq!(pointer.graph_id, before.graph_id); + let pointer = store + .activate(&after.graph_id, pointer.generation, &refs(&after)) + .expect("hot cut to B"); + assert_eq!(pointer.graph_id, after.graph_id); + assert_eq!(pointer.generation, 2); + assert_eq!(store.activation_history().expect("history").len(), 2); + persistence.shutdown(); +} + #[test] fn patch_direct_effect_addition_requires_reapproval_without_writes() { let persistence = open(); diff --git a/tests/tools_pin_invoke_tracer.rs b/tests/tools_pin_invoke_tracer.rs index 8dc9dbf..fdcddb7 100644 --- a/tests/tools_pin_invoke_tracer.rs +++ b/tests/tools_pin_invoke_tracer.rs @@ -3,18 +3,20 @@ //! bytes come from that program. Activating another graph does not retarget //! the already pinned run. +use std::collections::HashMap; use std::fs; use std::path::{Path, PathBuf}; -use std::sync::Arc; -use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use ring::rand::SystemRandom; use ring::signature::{Ed25519KeyPair, KeyPair}; use rustscript_agent::capabilities::{ ApprovalGate, CancellationFlag, CapabilityLifecycle, CapabilityOwner, CapabilityRisk, - DurableStarted, DurableToolLifecycle, FilesystemCapability, FilesystemLimits, LifecycleError, - LifecycleLimits, PrepareMetadata, SystemClock, UuidIssuer, + DurableStarted, DurableToolLifecycle, FilesystemCapability, FilesystemLimits, + GuardianApprovalGate, LifecycleError, LifecycleLimits, PrepareMetadata, SystemClock, + UuidIssuer, }; use rustscript_agent::plugins::{ InstallAuthorization, PackageArtifactRef, PackageRequirement, PackageStore, PluginControlStore, @@ -186,7 +188,9 @@ fn install_tool(store: &PackageStore, signer: &Signer, dir: &Path, id: &str, off store.install(dir, &auth(signer)).unwrap(); } -struct MemoryDurable; +struct MemoryDurable { + run: &'static str, +} impl DurableToolLifecycle for MemoryDurable { fn parent_arguments( @@ -195,7 +199,7 @@ impl DurableToolLifecycle for MemoryDurable { call_id: &str, tool_name: &str, ) -> Result, LifecycleError> { - assert_eq!(run_id, "run-pin-a"); + assert_eq!(run_id, self.run); assert_eq!(tool_name, "read_file"); assert!(matches!( call_id, @@ -318,8 +322,9 @@ fn pinned_read_file_bytes_stay_on_program_a_after_activation_of_b() { ); let db = root.join("state.db"); - let persistence = - GatewayPersistence::open(&rustscript_agent::AgentGatewayConfig::default(), &db).unwrap(); + let persistence = Arc::new( + GatewayPersistence::open(&rustscript_agent::AgentGatewayConfig::default(), &db).unwrap(), + ); persistence.load().unwrap(); let control = PluginControlStore::new(&persistence); let refs_a: Vec = lock_a @@ -406,7 +411,7 @@ fn pinned_read_file_bytes_stay_on_program_a_after_activation_of_b() { .deadline_ms(deadline_ms) .clock(Arc::new(SystemClock)) .tokens(Arc::new(UuidIssuer)) - .durable(Arc::new(MemoryDurable)) + .durable(Arc::new(MemoryDurable { run: "run-pin-a" })) .approval(Arc::new(AllowRead)) .cancellation(Arc::new(NoCancel)) .generation(1) @@ -583,8 +588,9 @@ fn pinned_handle_rejects_other_owner_without_started_effect() { }) .unwrap(); let db = root.join("state.db"); - let persistence = - GatewayPersistence::open(&rustscript_agent::AgentGatewayConfig::default(), &db).unwrap(); + let persistence = Arc::new( + GatewayPersistence::open(&rustscript_agent::AgentGatewayConfig::default(), &db).unwrap(), + ); persistence.load().unwrap(); let control = PluginControlStore::new(&persistence); let refs_a: Vec = lock_a @@ -670,7 +676,7 @@ fn pinned_handle_rejects_other_owner_without_started_effect() { .deadline_ms(9_000_000_000_000) .clock(Arc::new(SystemClock)) .tokens(Arc::new(UuidIssuer)) - .durable(Arc::new(MemoryDurable)) + .durable(Arc::new(MemoryDurable { run: "run-pin-a" })) .approval(Arc::new(AllowRead)) .cancellation(Arc::new(NoCancel)) .generation(1) @@ -812,8 +818,9 @@ fn pinned_replay_keeps_program_a_after_b_activation() { }) .unwrap(); let db = root.join("state.db"); - let persistence = - GatewayPersistence::open(&rustscript_agent::AgentGatewayConfig::default(), &db).unwrap(); + let persistence = Arc::new( + GatewayPersistence::open(&rustscript_agent::AgentGatewayConfig::default(), &db).unwrap(), + ); persistence.load().unwrap(); let control = PluginControlStore::new(&persistence); activate(&control, &lock_a, 0); @@ -900,8 +907,9 @@ effects: }) .unwrap(); let db = root.join("state.db"); - let persistence = - GatewayPersistence::open(&rustscript_agent::AgentGatewayConfig::default(), &db).unwrap(); + let persistence = Arc::new( + GatewayPersistence::open(&rustscript_agent::AgentGatewayConfig::default(), &db).unwrap(), + ); persistence.load().unwrap(); let control = PluginControlStore::new(&persistence); activate(&control, &lock, 0); @@ -926,3 +934,1662 @@ effects: ); let _ = fs::remove_dir_all(&root); } + +// 421 phase A: one AcceptedPublisher stateless tool linked with one pure leaf +// library. The tool does the Native read; the leaf only formats the string. +const LIB_A_ID: &str = "org.rustscript.lib.texta"; +const LIB_B_ID: &str = "org.rustscript.lib.textb"; +const TOOL_LIB_A_ID: &str = "org.rustscript.tools.readliba"; +const TOOL_LIB_B_ID: &str = "org.rustscript.tools.readlibb"; + +fn lib_manifest(id: &str) -> String { + format!( + r#"format: rustscript.plugin/v1 +id: {id} +version: 1.0.0 +kind: library +runtime: ">=0.1.0, <0.2.0" +contract: agent.library/v1 +host_abi: agent.cap/v1 +entry: + path: format.rss + export: format +dependencies: [] +capabilities: [] +effects: + contract: agent.effects/v1 + entries: + format: + direct: [] + delegated: [] +"# + ) +} + +fn tool_lib_manifest(id: &str, lib_id: &str) -> String { + manifest(id).replace( + "dependencies: []\n", + &format!("dependencies:\n - alias: text\n id: {lib_id}\n version: \"1.0.0\"\n"), + ) +} + +fn tool_lib_source(offset: i64) -> String { + format!( + r#" +use bytes; +use text::{{format}}; +fn map_string(value: map, key: string, fallback: string) -> string {{ + let mut result: string = fallback; + if value.has(key) {{ + if type(value[key]) == "string" {{ + let coerced: string = value[key]; + result = coerced; + }} + }} + result +}} + +fn map_map(value: map, key: string) -> map {{ + let mut result: map = {{}}; + if value.has(key) {{ + if type(value[key]) == "map" {{ + let coerced: map = value[key]; + result = coerced; + }} + }} + result +}} + +fn map_int(value: map, key: string, fallback: int) -> int {{ + let mut result: int = fallback; + if value.has(key) {{ + if type(value[key]) == "int" {{ + let coerced: int = value[key]; + result = coerced; + }} + }} + result +}} + +fn map_bool(value: map, key: string, fallback: bool) -> bool {{ + let mut result: bool = fallback; + if value.has(key) {{ + if type(value[key]) == "bool" {{ + let coerced: bool = value[key]; + result = coerced; + }} + }} + result +}} + +pub fn run(context: map) -> map {{ + let prepared: map = agent_runtime::tool_prepare(map_map(context, "prepare")); + if map_bool(prepared, "ok", false) == false => {{ + prepared + }} else => {{ + let token: string = map_string(prepared, "execution_token", ""); + let arguments: map = map_map(context, "arguments"); + let path: string = map_string(arguments, "path", ""); + let meta: map = cap::fs_metadata(token, path); + if map_bool(meta, "ok", false) == false => {{ + meta + }} else => {{ + let file_len: int = map_int(meta, "len", 0); + let offset: int = {offset}; + let limit: int = file_len - offset; + let read: map = cap::fs_read_range(token, path, offset, limit); + if map_bool(read, "ok", false) == false => {{ + read + }} else => {{ + let payload: bytes = read.bytes; + let body: string = bytes::to_utf8(payload); + let labeled: string = format(body); + let result: map = {{ ok: true, content: labeled, data: {{}}, error: null, truncated: false, artifacts: [] }}; + let committed: map = agent_runtime::tool_commit(token, result); + if map_string(committed, "kind", "") == "committed" => {{ + map_map(committed, "result") + }} else => {{ + committed + }} + }} + }} + }} +}} +"# + ) +} + +fn install_library(store: &PackageStore, signer: &Signer, dir: &Path, id: &str, body: &str) { + fs::create_dir_all(dir).unwrap(); + fs::write(dir.join("manifest.yaml"), lib_manifest(id)).unwrap(); + fs::write(dir.join("format.rss"), body).unwrap(); + let payload = store.signature_payload(dir).unwrap(); + let signature = signer.pair.sign(&payload); + fs::write(dir.join("signature.ed25519"), signature.as_ref()).unwrap(); + store.install(dir, &auth(signer)).unwrap(); +} + +fn install_lib_tool(store: &PackageStore, signer: &Signer, dir: &Path, id: &str, lib: &str) { + fs::create_dir_all(dir).unwrap(); + fs::write(dir.join("manifest.yaml"), tool_lib_manifest(id, lib)).unwrap(); + fs::write(dir.join("run.rss"), tool_lib_source(0)).unwrap(); + let payload = store.signature_payload(dir).unwrap(); + let signature = signer.pair.sign(&payload); + fs::write(dir.join("signature.ed25519"), signature.as_ref()).unwrap(); + store.install(dir, &auth(signer)).unwrap(); +} + +fn resolve_tool_only(store: &PackageStore, id: &str) -> rustscript_agent::plugins::GraphLock { + store + .resolve(&ResolveRequest { + harness_selection: rustscript_agent::plugins::HarnessSelectionRequest::ToolOnly { + tool_package: id.to_string(), + }, + roots: vec![PackageRequirement { + id: id.to_string(), + version: "1.0.0".to_string(), + }], + host_runtime: "0.1.0".to_string(), + }) + .unwrap() +} + +fn lib_refs(lock: &rustscript_agent::plugins::GraphLock) -> Vec { + lock.packages + .iter() + .map(|package| PackageArtifactRef { + package_id: package.id.clone(), + version: package.version.clone(), + kind: package.kind.clone(), + artifact_id: package.artifact_id.clone(), + }) + .collect() +} + +fn invoke_pinned_text( + workspace: &Path, + pinned: &rustscript_agent::plugins::PinnedToolAdmission, + call_id: &str, + run_id: &'static str, +) -> String { + let owner = CapabilityOwner::new("gateway", "session-pin", run_id).unwrap(); + let deadline_ms = 9_000_000_000_000u64; + let lifecycle = CapabilityLifecycle::builder() + .owner(owner.clone()) + .registry_identity("pin-tracer-registry".to_string()) + .workspace(workspace.to_path_buf()) + .limits(LifecycleLimits { + max_tool_calls: 8, + max_output_bytes: 4096, + max_summary_bytes: 256, + }) + .deadline_ms(deadline_ms) + .clock(Arc::new(SystemClock)) + .tokens(Arc::new(UuidIssuer)) + .durable(Arc::new(MemoryDurable { run: run_id })) + .approval(Arc::new(AllowRead)) + .cancellation(Arc::new(NoCancel)) + .generation(1) + .build() + .unwrap(); + let filesystem = Arc::new( + FilesystemCapability::new( + lifecycle.clone(), + owner.clone(), + FilesystemLimits { + max_read_bytes: 4096, + max_write_bytes: 4096, + max_list_entries: 16, + }, + ) + .unwrap(), + ); + let cancellation = rustscript_agent::RunCancellation::with_timeout(Duration::from_secs(30)); + let host = AgentHostBridges { + provider: None, + auth_host_factory: None, + cancellation: Some(cancellation), + sleeps: Default::default(), + skip_sleep: true, + metrics: None, + lifecycle: Some(Arc::new(lifecycle)), + capability_owner: Some(owner), + filesystem: Some(filesystem), + processes: None, + artifacts: None, + control_hook: None, + pinned_tool: Some(pinned.program_handle()), + allow_stateless_pin: false, + }; + let harness = AgentRunner::from_source( + &format!( + r#" +pub fn run(context: map) -> map {{ + agent_runtime::invoke_pinned_tool({{ + tool_name: "read_file", + call_id: "{call_id}", + canonical_arguments: "{{\"path\":\"note.txt\"}}", + canonical_input: "", + run_id: "{run_id}", + registry_identity: "pin-tracer-registry", + argument_digest: "{{\"path\":\"note.txt\"}}", + risk_class: "read", + summary: "read note", + untrusted_graph_id: "forged-graph-b" + }}) +}} +"# + ), + AgentConfig::default(), + ) + .expect("harness must compile against the typed bridge"); + let output = harness + .with_host(host) + .run_with_context(VmValue::map(vec![])) + .expect("pinned read through the library link"); + vm_text(&output, "text") +} + +#[test] +fn linked_pure_library_labels_authorized_read_and_pins_program_a() { + let root = temp_root("liblink"); + let store = PackageStore::open(root.join("packages")).unwrap(); + let signer = signer(); + install_library( + &store, + &signer, + &root.join("lib-a"), + LIB_A_ID, + "pub fn format(value: string) -> string { \"libA:\" + value }\n", + ); + install_lib_tool( + &store, + &signer, + &root.join("pkg-a"), + TOOL_LIB_A_ID, + LIB_A_ID, + ); + let lock_a = resolve_tool_only(&store, TOOL_LIB_A_ID); + assert_eq!(lock_a.packages.len(), 2, "tool plus one leaf library"); + assert_eq!(lock_a.edges.len(), 1, "exactly the library edge"); + assert_eq!(lock_a.edges[0].relation, "library"); + let prepared_a = store.prepare(&lock_a).unwrap(); + let program_a = prepared_a + .program(TOOL_LIB_A_ID) + .expect("prepare must retain combined tool program A"); + assert_eq!( + prepared_a.trust(TOOL_LIB_A_ID), + Some(rustscript_agent::plugins::PackageTrust::AcceptedPublisher) + ); + + let db = root.join("state.db"); + let persistence = Arc::new( + GatewayPersistence::open(&rustscript_agent::AgentGatewayConfig::default(), &db).unwrap(), + ); + persistence.load().unwrap(); + let control = PluginControlStore::new(&persistence); + control.store_lock(&lock_a).unwrap(); + control + .activate(&lock_a.graph_id, 0, &lib_refs(&lock_a)) + .unwrap(); + + let workspace = root.join("workspace"); + fs::create_dir(&workspace).unwrap(); + fs::write(workspace.join("note.txt"), b"hello").unwrap(); + let pinned = rustscript_agent::plugins::admit_pinned_tool_run( + &persistence, + &store, + &caller_json("run-pin-a", "", ""), + &rustscript_agent::plugins::PinnedAdmitContext { + original_cancellation: None, + registry_identity: "pin-tracer-registry".to_string(), + workspace: workspace.clone(), + }, + ) + .expect("native admission must pin the combined program"); + assert_eq!(pinned.graph_id, lock_a.graph_id); + assert!(pinned.shares_program(&program_a)); + let text = invoke_pinned_text(&workspace, &pinned, "call-read-1", "run-pin-a"); + assert_eq!(text, "libA:hello"); + let _ = fs::remove_dir_all(&root); +} + +#[test] +fn linked_graph_b_relabels_replay_keeps_a_and_missing_package_is_unavailable() { + let root = temp_root("libab"); + let store = PackageStore::open(root.join("packages")).unwrap(); + let signer = signer(); + install_library( + &store, + &signer, + &root.join("lib-a"), + LIB_A_ID, + "pub fn format(value: string) -> string { \"libA:\" + value }\n", + ); + install_library( + &store, + &signer, + &root.join("lib-b"), + LIB_B_ID, + "pub fn format(value: string) -> string { \"libB:\" + value }\n", + ); + install_lib_tool( + &store, + &signer, + &root.join("pkg-a"), + TOOL_LIB_A_ID, + LIB_A_ID, + ); + install_lib_tool( + &store, + &signer, + &root.join("pkg-b"), + TOOL_LIB_B_ID, + LIB_B_ID, + ); + let lock_a = resolve_tool_only(&store, TOOL_LIB_A_ID); + let lock_b = resolve_tool_only(&store, TOOL_LIB_B_ID); + assert_ne!(lock_a.graph_id, lock_b.graph_id); + let program_a = store + .prepare(&lock_a) + .unwrap() + .program(TOOL_LIB_A_ID) + .unwrap(); + let program_b = store + .prepare(&lock_b) + .unwrap() + .program(TOOL_LIB_B_ID) + .unwrap(); + assert!(!Arc::ptr_eq(&program_a, &program_b)); + + let db = root.join("state.db"); + let persistence = Arc::new( + GatewayPersistence::open(&rustscript_agent::AgentGatewayConfig::default(), &db).unwrap(), + ); + persistence.load().unwrap(); + let control = PluginControlStore::new(&persistence); + control.store_lock(&lock_a).unwrap(); + control.store_lock(&lock_b).unwrap(); + control + .activate(&lock_a.graph_id, 0, &lib_refs(&lock_a)) + .unwrap(); + + let workspace = root.join("workspace"); + fs::create_dir(&workspace).unwrap(); + fs::write(workspace.join("note.txt"), b"hello").unwrap(); + let context = rustscript_agent::plugins::PinnedAdmitContext { + original_cancellation: None, + registry_identity: "pin-tracer-registry".to_string(), + workspace: workspace.clone(), + }; + let first = rustscript_agent::plugins::admit_pinned_tool_run( + &persistence, + &store, + &caller_json("run-pin-a", "lib-key", "hash-a"), + &context, + ) + .expect("first admit pins graph A"); + assert_eq!(first.graph_id, lock_a.graph_id); + assert_eq!( + invoke_pinned_text(&workspace, &first, "call-read-1", "run-pin-a"), + "libA:hello" + ); + + control + .activate(&lock_b.graph_id, 1, &lib_refs(&lock_b)) + .unwrap(); + let replayed = rustscript_agent::plugins::admit_pinned_tool_run( + &persistence, + &store, + &caller_json("run-pin-a", "lib-key", "hash-a"), + &context, + ) + .expect("same idempotency key must replay the original A pin"); + assert_eq!(replayed.graph_id, first.graph_id); + assert_eq!(replayed.ref_id, first.ref_id); + assert_eq!(replayed.generation, first.generation); + assert_eq!( + replayed.program_handle().entry(), + first.program_handle().entry() + ); + assert_eq!( + invoke_pinned_text(&workspace, &replayed, "call-read-1", "run-pin-a"), + "libA:hello" + ); + + let second = rustscript_agent::plugins::admit_pinned_tool_run( + &persistence, + &store, + &caller_json("run-pin-b", "lib-key-b", "hash-b"), + &context, + ) + .expect("fresh run on the active graph pins B"); + assert_eq!(second.graph_id, lock_b.graph_id); + assert_eq!(second.program_handle().entry(), "run"); + assert_eq!( + invoke_pinned_text(&workspace, &second, "call-read-2", "run-pin-b"), + "libB:hello" + ); + + let tool_artifact = lock_a + .packages + .iter() + .find(|package| package.id == TOOL_LIB_A_ID) + .map(|package| package.artifact_id.clone()) + .expect("tool A artifact"); + fs::remove_dir_all(root.join("packages").join("artifacts").join(&tool_artifact)).unwrap(); + control + .activate(&lock_a.graph_id, 2, &lib_refs(&lock_a)) + .expect("activation records the lock even when captures are gone"); + let before = control.activation().unwrap(); + assert_eq!(before.graph_id, lock_a.graph_id); + assert_eq!(before.generation, 3); + let cancel = rustscript_agent::RunCancellation::with_timeout(Duration::from_secs(30)); + let error = match rustscript_agent::plugins::admit_pinned_tool_run( + &persistence, + &store, + &caller_json("run-pin-a", "lib-key-gone", "hash-gone"), + &rustscript_agent::plugins::PinnedAdmitContext { + original_cancellation: Some(cancel), + registry_identity: "pin-tracer-registry".to_string(), + workspace: workspace.clone(), + }, + ) { + Err(error) => error, + Ok(_) => panic!("missing tool A capture must fail closed"), + }; + assert_eq!(error.code, "plugin_artifact_unavailable"); + assert_eq!( + control.activation().unwrap(), + before, + "a missing old package must not switch the active graph away" + ); + let _ = fs::remove_dir_all(&root); +} + +const TOOL_LIB_C_ID: &str = "org.rustscript.tools.readlibc"; + +#[test] +fn revoking_library_graph_a_leaves_shared_leaf_graph_c_readable() { + let root = temp_root("librevoke"); + let store = PackageStore::open(root.join("packages")).unwrap(); + let signer = signer(); + install_library( + &store, + &signer, + &root.join("lib-a"), + LIB_A_ID, + "pub fn format(value: string) -> string { \"libA:\" + value }\n", + ); + install_lib_tool( + &store, + &signer, + &root.join("pkg-a"), + TOOL_LIB_A_ID, + LIB_A_ID, + ); + install_lib_tool( + &store, + &signer, + &root.join("pkg-c"), + TOOL_LIB_C_ID, + LIB_A_ID, + ); + let lock_a = resolve_tool_only(&store, TOOL_LIB_A_ID); + let lock_c = resolve_tool_only(&store, TOOL_LIB_C_ID); + store.prepare(&lock_a).unwrap(); + store.prepare(&lock_c).unwrap(); + + let db = root.join("state.db"); + let persistence = Arc::new( + GatewayPersistence::open(&rustscript_agent::AgentGatewayConfig::default(), &db).unwrap(), + ); + persistence.load().unwrap(); + let control = PluginControlStore::new(&persistence); + control.store_lock(&lock_a).unwrap(); + control.store_lock(&lock_c).unwrap(); + control + .activate(&lock_a.graph_id, 0, &lib_refs(&lock_a)) + .unwrap(); + + let workspace = root.join("workspace"); + fs::create_dir(&workspace).unwrap(); + fs::write(workspace.join("note.txt"), b"hello").unwrap(); + let context = rustscript_agent::plugins::PinnedAdmitContext { + original_cancellation: None, + registry_identity: "pin-tracer-registry".to_string(), + workspace: workspace.clone(), + }; + let pinned_a = rustscript_agent::plugins::admit_pinned_tool_run( + &persistence, + &store, + &caller_json("run-pin-a", "revoke-key-a", "hash-a"), + &context, + ) + .expect("admit graph A before revocation"); + assert_eq!( + invoke_pinned_text(&workspace, &pinned_a, "call-read-1", "run-pin-a"), + "libA:hello" + ); + + control + .revoke_graph(&store, &lock_a.graph_id) + .expect("phase-A tool plus pure-leaf graph must support graph_revoke"); + let revoked = control.graph_revocation(&lock_a.graph_id).unwrap(); + assert!(revoked.is_some()); + assert_eq!(revoked.unwrap().graph_id, lock_a.graph_id); + assert!( + control + .graph_revocation(&lock_c.graph_id) + .unwrap() + .is_none(), + "revoking A must not tombstone the shared-leaf graph C" + ); + + control + .activate(&lock_c.graph_id, 1, &lib_refs(&lock_c)) + .unwrap(); + let pinned_c = rustscript_agent::plugins::admit_pinned_tool_run( + &persistence, + &store, + &caller_json("run-pin-c", "revoke-key-c", "hash-c"), + &context, + ) + .expect("shared-leaf graph C must still admit after A is revoked"); + assert_eq!(pinned_c.graph_id, lock_c.graph_id); + assert_eq!( + invoke_pinned_text(&workspace, &pinned_c, "call-read-2", "run-pin-c"), + "libA:hello" + ); + + let plain = temp_root("libplain"); + let plain_store = PackageStore::open(plain.join("packages")).unwrap(); + install_tool( + &plain_store, + &signer, + &plain.join("pkg"), + "org.rustscript.tools.plain", + 0, + ); + let plain_lock = plain_store + .resolve(&ResolveRequest { + harness_selection: rustscript_agent::plugins::HarnessSelectionRequest::ToolOnly { + tool_package: "org.rustscript.tools.plain".to_string(), + }, + roots: vec![PackageRequirement { + id: "org.rustscript.tools.plain".to_string(), + version: "1.0.0".to_string(), + }], + host_runtime: "0.1.0".to_string(), + }) + .unwrap(); + let plain_db = plain.join("state.db"); + let plain_persistence = + GatewayPersistence::open(&rustscript_agent::AgentGatewayConfig::default(), &plain_db) + .unwrap(); + plain_persistence.load().unwrap(); + let plain_control = PluginControlStore::new(&plain_persistence); + plain_control.store_lock(&plain_lock).unwrap(); + let error = plain_control + .revoke_graph(&plain_store, &plain_lock.graph_id) + .expect_err("edgeless single-tool graphs keep the previous revoke rejection"); + assert_eq!(error.code, "plugin_lock_invalid"); + let _ = fs::remove_dir_all(&plain); + let _ = fs::remove_dir_all(&root); +} + +// --------------------------------------------------------------------------- +// 456 F4: Native-boundary safety on the phase-A stateless tool + pure leaf. +// +// Every case below binds a real signed tool/leaf pair, a real captured lock and +// a real sealed pinned program, then runs the sealed program through the same +// `agent_runtime::invoke_pinned_tool` boundary used for a run. Admission is +// already complete before each negative, so an admission-only rejection cannot +// be mistaken for Native coverage. +// --------------------------------------------------------------------------- + +struct NativeDurable { + run: String, + arguments: Value, + started: AtomicUsize, + commits: AtomicUsize, + results: Mutex>, +} + +impl NativeDurable { + fn new(run: &str, arguments: Value) -> Arc { + Arc::new(Self { + run: run.to_string(), + arguments, + started: AtomicUsize::new(0), + commits: AtomicUsize::new(0), + results: Mutex::new(HashMap::new()), + }) + } + + fn started(&self) -> usize { + self.started.load(Ordering::SeqCst) + } + + fn commits(&self) -> usize { + self.commits.load(Ordering::SeqCst) + } +} + +impl DurableToolLifecycle for NativeDurable { + fn assert_active_run(&self, run_id: &str) -> Result<(), LifecycleError> { + assert_eq!(run_id, self.run.as_str(), "unexpected run identity"); + Ok(()) + } + + fn prepare_parent( + &self, + run_id: &str, + _call_id: &str, + _tool_name: &str, + ) -> Result<(), LifecycleError> { + assert_eq!(run_id, self.run.as_str(), "unexpected run identity"); + Ok(()) + } + + fn parent_arguments( + &self, + run_id: &str, + _call_id: &str, + _tool_name: &str, + ) -> Result, LifecycleError> { + assert_eq!(run_id, self.run.as_str(), "unexpected run identity"); + Ok(Some(self.arguments.clone())) + } + + fn replay_result( + &self, + run_id: &str, + call_id: &str, + _tool_name: &str, + ) -> Result, LifecycleError> { + assert_eq!(run_id, self.run.as_str(), "unexpected run identity"); + Ok(self + .results + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .get(call_id) + .cloned()) + } + + fn commit_started(&self, _record: &DurableStarted) -> Result<(), LifecycleError> { + self.started.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + + fn commit_result(&self, call_id: &str, result: &Value) -> Result { + self.commits.fetch_add(1, Ordering::SeqCst); + self.results + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .insert(call_id.to_string(), result.clone()); + Ok(result.clone()) + } + + fn interrupt(&self, _call_id: &str) -> Result<(), LifecycleError> { + Ok(()) + } +} + +struct ToggleCancellation(Arc); + +impl CancellationFlag for ToggleCancellation { + fn is_cancelled(&self) -> bool { + self.0.load(Ordering::SeqCst) + } +} + +fn vm_json(value: &VmValue) -> Value { + match value { + VmValue::Null => Value::Null, + VmValue::Int(number) => json!(number), + VmValue::Float(number) => json!(number), + VmValue::Bool(flag) => json!(flag), + VmValue::String(text) => json!(text.to_string()), + VmValue::Bytes(bytes) => json!(String::from_utf8_lossy(bytes).into_owned()), + VmValue::Array(items) => Value::Array(items.iter().map(vm_json).collect()), + VmValue::Map(entries) => Value::Object( + entries + .iter() + .map(|(key, value)| { + let key = match key { + VmValue::String(text) => text.to_string(), + VmValue::Int(number) => number.to_string(), + VmValue::Bool(flag) => flag.to_string(), + _ => String::new(), + }; + (key, vm_json(value)) + }) + .collect(), + ), + VmValue::Callable(_) => Value::Null, + } +} + +fn rss_literal(raw: &str) -> String { + raw.replace('\\', "\\\\").replace('"', "\\\"") +} + +struct NativeCase { + workspace: PathBuf, + pinned: rustscript_agent::plugins::PinnedToolAdmission, + run_id: String, + call_id: String, + tool_name: String, + arguments: String, + risk: &'static str, + deadline_ms: u64, + max_output_bytes: usize, + cancelled: Arc, + approval: Arc, + durable: Arc, +} + +impl NativeCase { + fn invoke(&self) -> Value { + let owner = CapabilityOwner::new("gateway", "session-pin", &self.run_id).unwrap(); + let lifecycle = CapabilityLifecycle::builder() + .owner(owner.clone()) + .registry_identity("pin-tracer-registry".to_string()) + .workspace(self.workspace.clone()) + .limits(LifecycleLimits { + max_tool_calls: 8, + max_output_bytes: self.max_output_bytes, + max_summary_bytes: 256, + }) + .deadline_ms(self.deadline_ms) + .clock(Arc::new(SystemClock)) + .tokens(Arc::new(UuidIssuer)) + .durable(Arc::clone(&self.durable) as Arc) + .approval(Arc::clone(&self.approval)) + .cancellation(Arc::new(ToggleCancellation(Arc::clone(&self.cancelled)))) + .generation(1) + .build() + .unwrap(); + let filesystem = Arc::new( + FilesystemCapability::new( + lifecycle.clone(), + owner.clone(), + FilesystemLimits { + max_read_bytes: 4096, + max_write_bytes: 4096, + max_list_entries: 16, + }, + ) + .unwrap(), + ); + let host = AgentHostBridges { + provider: None, + auth_host_factory: None, + cancellation: Some(rustscript_agent::RunCancellation::with_timeout( + Duration::from_secs(30), + )), + sleeps: Default::default(), + skip_sleep: true, + metrics: None, + lifecycle: Some(Arc::new(lifecycle)), + capability_owner: Some(owner), + filesystem: Some(filesystem), + processes: None, + artifacts: None, + control_hook: None, + pinned_tool: Some(self.pinned.program_handle()), + allow_stateless_pin: false, + }; + let arguments = rss_literal(&self.arguments); + let source = [ + "pub fn run(context: map) -> map {".to_string(), + " agent_runtime::invoke_pinned_tool({".to_string(), + format!(" tool_name: \"{}\",", self.tool_name), + format!(" call_id: \"{}\",", self.call_id), + format!(" canonical_arguments: \"{arguments}\","), + " canonical_input: \"\",".to_string(), + format!(" run_id: \"{}\",", self.run_id), + " registry_identity: \"pin-tracer-registry\",".to_string(), + format!(" argument_digest: \"{arguments}\","), + format!(" risk_class: \"{}\",", self.risk), + " summary: \"phase a native case\",".to_string(), + " untrusted_graph_id: \"forged-graph-b\"".to_string(), + " })".to_string(), + "}".to_string(), + ] + .join("\n"); + let harness = AgentRunner::from_source(&source, AgentConfig::default()) + .expect("native case harness must compile against the typed boundary"); + let output = harness + .with_host(host) + .run_with_context(VmValue::map(vec![])) + .expect("the sealed tool boundary must return a typed envelope"); + vm_json(&output) + } +} + +impl NativeCase { + /// Runs the sealed tool to a committed result, then presents a late + /// approval attestation for the same call identity through the actual + /// `agent_runtime::approval_attest` host boundary. + fn invoke_then_late_approval(&self) -> Value { + let owner = CapabilityOwner::new("gateway", "session-pin", &self.run_id).unwrap(); + let lifecycle = CapabilityLifecycle::builder() + .owner(owner.clone()) + .registry_identity("pin-tracer-registry".to_string()) + .workspace(self.workspace.clone()) + .limits(LifecycleLimits { + max_tool_calls: 8, + max_output_bytes: self.max_output_bytes, + max_summary_bytes: 256, + }) + .deadline_ms(self.deadline_ms) + .clock(Arc::new(SystemClock)) + .tokens(Arc::new(UuidIssuer)) + .durable(Arc::clone(&self.durable) as Arc) + .approval(Arc::clone(&self.approval)) + .cancellation(Arc::new(ToggleCancellation(Arc::clone(&self.cancelled)))) + .generation(1) + .build() + .unwrap(); + let filesystem = Arc::new( + FilesystemCapability::new( + lifecycle.clone(), + owner.clone(), + FilesystemLimits { + max_read_bytes: 4096, + max_write_bytes: 4096, + max_list_entries: 16, + }, + ) + .unwrap(), + ); + let host = AgentHostBridges { + provider: None, + auth_host_factory: None, + cancellation: Some(rustscript_agent::RunCancellation::with_timeout( + Duration::from_secs(30), + )), + sleeps: Default::default(), + skip_sleep: true, + metrics: None, + lifecycle: Some(Arc::new(lifecycle)), + capability_owner: Some(owner), + filesystem: Some(filesystem), + processes: None, + artifacts: None, + control_hook: None, + pinned_tool: Some(self.pinned.program_handle()), + allow_stateless_pin: false, + }; + let arguments = rss_literal(&self.arguments); + let source = [ + "pub fn run(context: map) -> map {".to_string(), + " let first: map = agent_runtime::invoke_pinned_tool({".to_string(), + format!(" tool_name: \"{}\",", self.tool_name), + format!(" call_id: \"{}\",", self.call_id), + format!(" canonical_arguments: \"{arguments}\","), + " canonical_input: \"\",".to_string(), + format!(" run_id: \"{}\",", self.run_id), + " registry_identity: \"pin-tracer-registry\",".to_string(), + format!(" argument_digest: \"{arguments}\","), + format!(" risk_class: \"{}\",", self.risk), + " summary: \"phase a native case\",".to_string(), + " untrusted_graph_id: \"forged-graph-b\"".to_string(), + " });".to_string(), + " let late: map = agent_runtime::approval_attest({".to_string(), + format!(" run_id: \"{}\",", self.run_id), + format!(" call_id: \"{}\",", self.call_id), + format!(" tool_name: \"{}\",", self.tool_name), + format!(" argument_digest: \"{arguments}\","), + " registry_identity: \"pin-tracer-registry\",".to_string(), + format!(" risk_class: \"{}\",", self.risk), + " summary: \"phase a native case\"".to_string(), + " });".to_string(), + " { first: first, late: late }".to_string(), + "}".to_string(), + ] + .join("\n"); + let harness = AgentRunner::from_source(&source, AgentConfig::default()) + .expect("late approval harness must compile against the typed boundary"); + let output = harness + .with_host(host) + .run_with_context(VmValue::map(vec![])) + .expect("the sealed boundary must return typed envelopes"); + vm_json(&output) + } +} + +#[test] +fn phase_a_native_late_approval_is_denied_after_completed_commit() { + let root = temp_root("f4-late-approval"); + let (store, _signer, lock_a) = install_phase_a_pair(&root, phase_a_leaf_body()); + let (_persistence, workspace, pinned) = phase_a_open(&root, &store, &lock_a); + let durable = NativeDurable::new("run-pin-a", json!({"path": "note.txt"})); + let outcome = NativeCase { + workspace, + pinned, + run_id: "run-pin-a".to_string(), + call_id: "call-read-1".to_string(), + tool_name: "read_file".to_string(), + arguments: "{\"path\":\"note.txt\"}".to_string(), + risk: "read", + deadline_ms: 9_000_000_000_000, + max_output_bytes: 4096, + cancelled: Arc::new(AtomicBool::new(false)), + approval: Arc::new(AllowRead), + durable: Arc::clone(&durable), + } + .invoke_then_late_approval(); + assert_eq!(outcome["first"]["text"], "libA:hello", "{outcome}"); + assert_eq!(durable.commits(), 1, "the first call really committed"); + assert_eq!( + outcome["late"]["ok"], false, + "a late approval for a completed call must be denied: {outcome}" + ); + assert_eq!( + outcome["late"]["error"]["code"], "approval_denied", + "{outcome}" + ); + assert_eq!( + outcome["late"]["error"]["message"], "completed tool call cannot receive a new approval", + "{outcome}" + ); + assert_eq!(durable.commits(), 1, "late approval must not commit again"); + let _ = fs::remove_dir_all(&root); +} + +fn phase_a_leaf_body() -> &'static str { + "pub fn format(value: string) -> string { \"libA:\" + value }\n" +} + +fn install_phase_a_pair( + root: &Path, + leaf_body: &str, +) -> (PackageStore, Signer, rustscript_agent::plugins::GraphLock) { + let store = PackageStore::open(root.join("packages")).unwrap(); + let signer = signer(); + install_library(&store, &signer, &root.join("lib-a"), LIB_A_ID, leaf_body); + install_lib_tool( + &store, + &signer, + &root.join("pkg-a"), + TOOL_LIB_A_ID, + LIB_A_ID, + ); + let lock = resolve_tool_only(&store, TOOL_LIB_A_ID); + (store, signer, lock) +} + +fn phase_a_open( + root: &Path, + store: &PackageStore, + lock: &rustscript_agent::plugins::GraphLock, +) -> ( + Arc, + PathBuf, + rustscript_agent::plugins::PinnedToolAdmission, +) { + use rustscript_agent::{plugins::PinnedAdmitContext, plugins::admit_pinned_tool_run}; + let store_prepared = store.prepare(lock).unwrap(); + assert!(store_prepared.program(TOOL_LIB_A_ID).is_some()); + let db = root.join("state.db"); + let persistence = Arc::new( + GatewayPersistence::open(&rustscript_agent::AgentGatewayConfig::default(), &db).unwrap(), + ); + persistence.load().unwrap(); + { + let control = PluginControlStore::new(&persistence); + control.store_lock(lock).unwrap(); + control + .activate(&lock.graph_id, 0, &lib_refs(lock)) + .unwrap(); + } + let workspace = root.join("workspace"); + fs::create_dir(&workspace).unwrap(); + fs::write(workspace.join("note.txt"), b"hello").unwrap(); + fs::write(root.join("outside.txt"), b"outside").unwrap(); + let pinned = admit_pinned_tool_run( + &persistence, + store, + &caller_json("run-pin-a", "", ""), + &PinnedAdmitContext { + original_cancellation: None, + registry_identity: "pin-tracer-registry".to_string(), + workspace: workspace.clone(), + }, + ) + .expect("phase-A graph must admit a real sealed pin"); + (persistence, workspace, pinned) +} + +#[test] +fn phase_a_native_begin_denies_revoked_graph_and_keeps_shared_leaf() { + let root = temp_root("f4-revoke-begin"); + let (store, signer, lock_a) = install_phase_a_pair(&root, phase_a_leaf_body()); + install_lib_tool( + &store, + &signer, + &root.join("pkg-c"), + TOOL_LIB_C_ID, + LIB_A_ID, + ); + let lock_c = resolve_tool_only(&store, TOOL_LIB_C_ID); + store.prepare(&lock_c).unwrap(); + let (persistence, workspace, pinned) = phase_a_open(&root, &store, &lock_a); + let control = PluginControlStore::new(&persistence); + control + .store_lock(&lock_c) + .expect("shared-leaf graph C lock must be stored before activation"); + let durable = NativeDurable::new("run-pin-a", json!({"path": "note.txt"})); + let positive = NativeCase { + workspace: workspace.clone(), + pinned: pinned.clone(), + run_id: "run-pin-a".to_string(), + call_id: "call-read-1".to_string(), + tool_name: "read_file".to_string(), + arguments: "{\"path\":\"note.txt\"}".to_string(), + risk: "read", + deadline_ms: 9_000_000_000_000, + max_output_bytes: 4096, + cancelled: Arc::new(AtomicBool::new(false)), + approval: Arc::new(AllowRead), + durable: Arc::clone(&durable), + } + .invoke(); + assert_eq!( + positive["text"], "libA:hello", + "positive control: {positive}" + ); + assert_eq!(durable.commits(), 1); + + control + .revoke_graph(&store, &lock_a.graph_id) + .expect("phase-A graph revokes through the narrow topology"); + let revoked = NativeCase { + workspace: workspace.clone(), + pinned, + run_id: "run-pin-a".to_string(), + call_id: "call-read-2".to_string(), + tool_name: "read_file".to_string(), + arguments: "{\"path\":\"note.txt\"}".to_string(), + risk: "read", + deadline_ms: 9_000_000_000_000, + max_output_bytes: 4096, + cancelled: Arc::new(AtomicBool::new(false)), + approval: Arc::new(AllowRead), + durable: Arc::clone(&durable), + } + .invoke(); + assert_eq!( + revoked["ok"], false, + "an already pinned run must be denied at Native begin after revocation: {revoked}" + ); + assert_eq!(revoked["code"], "plugin_graph_revoked", "{revoked}"); + assert_eq!( + durable.commits(), + 1, + "revocation must not add a successful commit" + ); + + control + .activate(&lock_c.graph_id, 1, &lib_refs(&lock_c)) + .unwrap(); + let pinned_c = rustscript_agent::plugins::admit_pinned_tool_run( + &persistence, + &store, + &caller_json("run-pin-c", "", ""), + &rustscript_agent::plugins::PinnedAdmitContext { + original_cancellation: None, + registry_identity: "pin-tracer-registry".to_string(), + workspace: workspace.clone(), + }, + ) + .expect("shared-leaf graph C still admits after A is revoked"); + let durable_c = NativeDurable::new("run-pin-c", json!({"path": "note.txt"})); + let shared = NativeCase { + workspace, + pinned: pinned_c, + run_id: "run-pin-c".to_string(), + call_id: "call-read-3".to_string(), + tool_name: "read_file".to_string(), + arguments: "{\"path\":\"note.txt\"}".to_string(), + risk: "read", + deadline_ms: 9_000_000_000_000, + max_output_bytes: 4096, + cancelled: Arc::new(AtomicBool::new(false)), + approval: Arc::new(AllowRead), + durable: Arc::clone(&durable_c), + } + .invoke(); + assert_eq!( + shared["text"], "libA:hello", + "shared leaf C must keep a positive Native read: {shared}" + ); + assert_eq!(durable_c.commits(), 1); + let _ = fs::remove_dir_all(&root); +} + +#[test] +fn phase_a_native_replay_denies_revoked_graph_without_new_effect() { + let root = temp_root("f4-revoke-replay"); + let (store, _signer, lock_a) = install_phase_a_pair(&root, phase_a_leaf_body()); + let (persistence, workspace, pinned) = phase_a_open(&root, &store, &lock_a); + let control = PluginControlStore::new(&persistence); + let durable = NativeDurable::new("run-pin-a", json!({"path": "note.txt"})); + let first = NativeCase { + workspace: workspace.clone(), + pinned: pinned.clone(), + run_id: "run-pin-a".to_string(), + call_id: "call-read-1".to_string(), + tool_name: "read_file".to_string(), + arguments: "{\"path\":\"note.txt\"}".to_string(), + risk: "read", + deadline_ms: 9_000_000_000_000, + max_output_bytes: 4096, + cancelled: Arc::new(AtomicBool::new(false)), + approval: Arc::new(AllowRead), + durable: Arc::clone(&durable), + } + .invoke(); + assert_eq!(first["text"], "libA:hello"); + assert_eq!(durable.commits(), 1); + + control + .revoke_graph(&store, &lock_a.graph_id) + .expect("phase-A graph revokes"); + let replayed = NativeCase { + workspace, + pinned, + run_id: "run-pin-a".to_string(), + call_id: "call-read-1".to_string(), + tool_name: "read_file".to_string(), + arguments: "{\"path\":\"note.txt\"}".to_string(), + risk: "read", + deadline_ms: 9_000_000_000_000, + max_output_bytes: 4096, + cancelled: Arc::new(AtomicBool::new(false)), + approval: Arc::new(AllowRead), + durable: Arc::clone(&durable), + } + .invoke(); + assert_eq!( + replayed["ok"], false, + "an already pinned call replay must be denied at Native begin after revocation: {replayed}" + ); + assert_eq!(replayed["code"], "plugin_graph_revoked", "{replayed}"); + assert_eq!( + durable.commits(), + 1, + "revocation must not allow a second successful commit" + ); + let _ = fs::remove_dir_all(&root); +} + +/// Independent shared-leaf control: after tool graph A is revoked, graph C +/// sharing the same leaf still admits and executes. This stands on its own +/// fixture and never runs after an earlier assertion that can fail. +#[test] +fn phase_a_native_shared_leaf_c_succeeds_after_a_revoke() { + let root = temp_root("f4-shared-leaf"); + let (store, signer, lock_a) = install_phase_a_pair(&root, phase_a_leaf_body()); + install_lib_tool( + &store, + &signer, + &root.join("pkg-c"), + TOOL_LIB_C_ID, + LIB_A_ID, + ); + let lock_c = resolve_tool_only(&store, TOOL_LIB_C_ID); + store.prepare(&lock_c).unwrap(); + let (persistence, workspace, _pinned_a) = phase_a_open(&root, &store, &lock_a); + let control = PluginControlStore::new(&persistence); + control + .store_lock(&lock_c) + .expect("shared-leaf graph C lock must be stored before activation"); + control + .revoke_graph(&store, &lock_a.graph_id) + .expect("tool graph A revokes"); + control + .activate(&lock_c.graph_id, 1, &lib_refs(&lock_c)) + .unwrap(); + let pinned_c = rustscript_agent::plugins::admit_pinned_tool_run( + &persistence, + &store, + &caller_json("run-pin-c", "", ""), + &rustscript_agent::plugins::PinnedAdmitContext { + original_cancellation: None, + registry_identity: "pin-tracer-registry".to_string(), + workspace: workspace.clone(), + }, + ) + .expect("shared-leaf graph C still admits after A is revoked"); + let durable_c = NativeDurable::new("run-pin-c", json!({"path": "note.txt"})); + let shared = NativeCase { + workspace, + pinned: pinned_c, + run_id: "run-pin-c".to_string(), + call_id: "call-read-3".to_string(), + tool_name: "read_file".to_string(), + arguments: "{\"path\":\"note.txt\"}".to_string(), + risk: "read", + deadline_ms: 9_000_000_000_000, + max_output_bytes: 4096, + cancelled: Arc::new(AtomicBool::new(false)), + approval: Arc::new(AllowRead), + durable: Arc::clone(&durable_c), + } + .invoke(); + assert_eq!( + shared["text"], "libA:hello", + "shared leaf C must keep a positive Native read: {shared}" + ); + assert_eq!(durable_c.commits(), 1); + let _ = fs::remove_dir_all(&root); +} + +/// Same-owner guard query failure refuses closed: a real injected storage +/// failure on the exact revocation read denies with the typed pin code, +/// without a replay, child dispatch, or managed effect. +#[test] +fn phase_a_native_guard_query_failure_denies_closed_without_effect() { + let root = temp_root("f4-guard-fault"); + let (store, _signer, lock_a) = install_phase_a_pair(&root, phase_a_leaf_body()); + let (persistence, workspace, pinned) = phase_a_open(&root, &store, &lock_a); + let durable = NativeDurable::new("run-pin-a", json!({"path": "note.txt"})); + let case = NativeCase { + workspace: workspace.clone(), + pinned, + run_id: "run-pin-a".to_string(), + call_id: "call-fault-1".to_string(), + tool_name: "read_file".to_string(), + arguments: "{\"path\":\"note.txt\"}".to_string(), + risk: "read", + deadline_ms: 9_000_000_000_000, + max_output_bytes: 4096, + cancelled: Arc::new(AtomicBool::new(false)), + approval: Arc::new(AllowRead), + durable: Arc::clone(&durable), + }; + // The guard read is the next storage command on this fixture: failing it + // must refuse closed instead of executing or replaying. + persistence.inject_persist_failure(); + let denied = case.invoke(); + assert_eq!( + denied["ok"], false, + "a failed guard read must refuse execution: {denied}" + ); + assert_eq!( + denied["code"], "plugin_graph_pin_required", + "a failed guard read must refuse closed: {denied}" + ); + assert!( + denied + .get("canonical_result") + .is_none_or(|value| value == ""), + "a refused guard read must not leak a canonical result: {denied}" + ); + assert_eq!( + durable.commits(), + 0, + "a refused guard read must not commit a managed effect" + ); + assert_eq!( + durable.started(), + 0, + "a refused guard read must not start the sealed child" + ); + let _ = fs::remove_dir_all(&root); +} + +/// The guard wait honors the elapsed original deadline: with no remaining +/// run budget the exact read keeps deadline_elapsed on a bounded wait instead +/// of minting a fresh clock, starting the child, or committing an effect. +#[test] +fn phase_a_native_guard_honors_elapsed_original_deadline() { + let root = temp_root("f4-guard-deadline"); + let (store, _signer, lock_a) = install_phase_a_pair(&root, phase_a_leaf_body()); + let (_persistence, workspace, pinned) = phase_a_open(&root, &store, &lock_a); + let durable = NativeDurable::new("run-pin-a", json!({"path": "note.txt"})); + let owner = CapabilityOwner::new("gateway", "session-pin", "run-pin-a").unwrap(); + let lifecycle = CapabilityLifecycle::builder() + .owner(owner.clone()) + .registry_identity("pin-tracer-registry".to_string()) + .workspace(workspace.clone()) + .limits(LifecycleLimits { + max_tool_calls: 8, + max_output_bytes: 4096, + max_summary_bytes: 256, + }) + .deadline_ms(9_000_000_000_000) + .clock(Arc::new(SystemClock)) + .tokens(Arc::new(UuidIssuer)) + .durable(Arc::clone(&durable) as Arc) + .approval(Arc::new(AllowRead)) + .cancellation(Arc::new(ToggleCancellation(Arc::new(AtomicBool::new( + false, + ))))) + .generation(1) + .build() + .unwrap(); + let filesystem = Arc::new( + FilesystemCapability::new( + lifecycle.clone(), + owner.clone(), + FilesystemLimits { + max_read_bytes: 4096, + max_write_bytes: 4096, + max_list_entries: 16, + }, + ) + .unwrap(), + ); + let elapsed = rustscript_agent::RunCancellation::with_timeout(Duration::from_millis(0)); + assert!( + elapsed.deadline_passed(), + "the fixture original deadline must already be elapsed" + ); + let host = AgentHostBridges { + provider: None, + auth_host_factory: None, + cancellation: Some(elapsed), + sleeps: Default::default(), + skip_sleep: true, + metrics: None, + lifecycle: Some(Arc::new(lifecycle)), + capability_owner: Some(owner), + filesystem: Some(filesystem), + processes: None, + artifacts: None, + control_hook: None, + pinned_tool: Some(pinned.program_handle()), + allow_stateless_pin: false, + }; + let denied = + AgentRunner::from_source(&harness_source("call-deadline-1"), AgentConfig::default()) + .unwrap() + .with_host(host) + .run_with_context(VmValue::map(vec![])) + .expect("an elapsed original deadline is a tool result, not a runner failure"); + let denied = vm_json(&denied); + assert_eq!( + denied["ok"], false, + "an elapsed original deadline must refuse execution: {denied}" + ); + assert_eq!( + denied["code"], "deadline_elapsed", + "an elapsed original deadline must keep its exact classification: {denied}" + ); + assert_eq!( + durable.commits(), + 0, + "an elapsed original deadline must not commit a managed effect" + ); + assert_eq!( + durable.started(), + 0, + "an elapsed original deadline must not start the sealed child" + ); + let _ = fs::remove_dir_all(&root); +} + +#[test] +fn phase_a_native_begin_denies_cancelled_and_expired_original_clock() { + let root = temp_root("f4-clock"); + let (store, _signer, lock_a) = install_phase_a_pair(&root, phase_a_leaf_body()); + let (_persistence, workspace, pinned) = phase_a_open(&root, &store, &lock_a); + let cancelled = Arc::new(AtomicBool::new(true)); + let durable = NativeDurable::new("run-pin-a", json!({"path": "note.txt"})); + let cancelled_result = NativeCase { + workspace: workspace.clone(), + pinned: pinned.clone(), + run_id: "run-pin-a".to_string(), + call_id: "call-cancel-1".to_string(), + tool_name: "read_file".to_string(), + arguments: "{\"path\":\"note.txt\"}".to_string(), + risk: "read", + deadline_ms: 9_000_000_000_000, + max_output_bytes: 4096, + cancelled: Arc::clone(&cancelled), + approval: Arc::new(AllowRead), + durable: Arc::clone(&durable), + } + .invoke(); + assert_eq!(cancelled_result["ok"], false, "{cancelled_result}"); + assert_eq!(cancelled_result["code"], "cancelled", "{cancelled_result}"); + assert_eq!(durable.started(), 0, "cancelled call must not start"); + assert_eq!(durable.commits(), 0, "cancelled call must not commit"); + + let expired = NativeCase { + workspace: workspace.clone(), + pinned: pinned.clone(), + run_id: "run-pin-a".to_string(), + call_id: "call-deadline-1".to_string(), + tool_name: "read_file".to_string(), + arguments: "{\"path\":\"note.txt\"}".to_string(), + risk: "read", + deadline_ms: 0, + max_output_bytes: 4096, + cancelled: Arc::new(AtomicBool::new(false)), + approval: Arc::new(AllowRead), + durable: Arc::clone(&durable), + } + .invoke(); + assert_eq!(expired["ok"], false, "{expired}"); + assert_eq!(expired["code"], "deadline_elapsed", "{expired}"); + assert_eq!(durable.started(), 0, "expired call must not start"); + assert_eq!(durable.commits(), 0, "expired call must not commit"); + + let fresh = NativeCase { + workspace, + pinned, + run_id: "run-pin-a".to_string(), + call_id: "call-read-1".to_string(), + tool_name: "read_file".to_string(), + arguments: "{\"path\":\"note.txt\"}".to_string(), + risk: "read", + deadline_ms: 9_000_000_000_000, + max_output_bytes: 4096, + cancelled: Arc::new(AtomicBool::new(false)), + approval: Arc::new(AllowRead), + durable: Arc::clone(&durable), + } + .invoke(); + assert_eq!(fresh["text"], "libA:hello", "positive control: {fresh}"); + assert_eq!(durable.started(), 1); + assert_eq!(durable.commits(), 1); + let _ = fs::remove_dir_all(&root); +} + +#[test] +fn phase_a_native_boundary_keeps_original_workspace_root_and_owner() { + let root = temp_root("f4-root"); + let (store, _signer, lock_a) = install_phase_a_pair(&root, phase_a_leaf_body()); + let (_persistence, workspace, pinned) = phase_a_open(&root, &store, &lock_a); + let durable = NativeDurable::new("run-pin-a", json!({"path": "../outside.txt"})); + let escaped = NativeCase { + workspace: workspace.clone(), + pinned: pinned.clone(), + run_id: "run-pin-a".to_string(), + call_id: "call-escape-1".to_string(), + tool_name: "read_file".to_string(), + arguments: "{\"path\":\"../outside.txt\"}".to_string(), + risk: "read", + deadline_ms: 9_000_000_000_000, + max_output_bytes: 4096, + cancelled: Arc::new(AtomicBool::new(false)), + approval: Arc::new(AllowRead), + durable: Arc::clone(&durable), + } + .invoke(); + assert_eq!(escaped["ok"], false, "{escaped}"); + assert_eq!(escaped["code"], "path_denied", "{escaped}"); + assert_eq!(durable.commits(), 0); + + let inside_durable = NativeDurable::new("run-pin-a", json!({"path": "note.txt"})); + let inside = NativeCase { + workspace: workspace.clone(), + pinned: pinned.clone(), + run_id: "run-pin-a".to_string(), + call_id: "call-read-1".to_string(), + tool_name: "read_file".to_string(), + arguments: "{\"path\":\"note.txt\"}".to_string(), + risk: "read", + deadline_ms: 9_000_000_000_000, + max_output_bytes: 4096, + cancelled: Arc::new(AtomicBool::new(false)), + approval: Arc::new(AllowRead), + durable: Arc::clone(&inside_durable), + } + .invoke(); + assert_eq!(inside["text"], "libA:hello", "{inside}"); + assert_eq!(inside_durable.commits(), 1); + + // The sealed receipt binds one owner/run; a sibling run cannot reuse it. + let other_durable = NativeDurable::new("run-other", json!({"path": "note.txt"})); + let other = NativeCase { + workspace, + pinned, + run_id: "run-other".to_string(), + call_id: "call-other-1".to_string(), + tool_name: "read_file".to_string(), + arguments: "{\"path\":\"note.txt\"}".to_string(), + risk: "read", + deadline_ms: 9_000_000_000_000, + max_output_bytes: 4096, + cancelled: Arc::new(AtomicBool::new(false)), + approval: Arc::new(AllowRead), + durable: Arc::clone(&other_durable), + } + .invoke(); + assert_eq!(other["ok"], false, "{other}"); + assert_eq!(other["code"], "pinned_binding_mismatch", "{other}"); + assert_eq!(other_durable.started(), 0); + assert_eq!(other_durable.commits(), 0); + let _ = fs::remove_dir_all(&root); +} + +#[test] +fn phase_a_native_commit_rejects_amplified_leaf_output_under_original_limit() { + let root = temp_root("f4-amplified"); + let amplifying = "pub fn format(value: string) -> string {\n let mut out: string = \"libA:\";\n let mut i: int = 0;\n while i < 400 {\n out = out + value;\n i = i + 1;\n }\n out\n}\n"; + let (store, _signer, lock_a) = install_phase_a_pair(&root, amplifying); + let (_persistence, workspace, pinned) = phase_a_open(&root, &store, &lock_a); + let durable = NativeDurable::new("run-pin-a", json!({"path": "note.txt"})); + let amplified = NativeCase { + workspace: workspace.clone(), + pinned: pinned.clone(), + run_id: "run-pin-a".to_string(), + call_id: "call-read-1".to_string(), + tool_name: "read_file".to_string(), + arguments: "{\"path\":\"note.txt\"}".to_string(), + risk: "read", + deadline_ms: 9_000_000_000_000, + max_output_bytes: 256, + cancelled: Arc::new(AtomicBool::new(false)), + approval: Arc::new(AllowRead), + durable: Arc::clone(&durable), + } + .invoke(); + assert_eq!(amplified["ok"], false, "{amplified}"); + assert_eq!(amplified["code"], "result_too_large", "{amplified}"); + assert_eq!(durable.started(), 1, "the call was prepared before commit"); + assert_eq!(durable.commits(), 0, "amplified output must not commit"); + + let permitted = NativeCase { + workspace, + pinned, + run_id: "run-pin-a".to_string(), + call_id: "call-read-2".to_string(), + tool_name: "read_file".to_string(), + arguments: "{\"path\":\"note.txt\"}".to_string(), + risk: "read", + deadline_ms: 9_000_000_000_000, + max_output_bytes: 4096, + cancelled: Arc::new(AtomicBool::new(false)), + approval: Arc::new(AllowRead), + durable: Arc::clone(&durable), + } + .invoke(); + assert_eq!(permitted["ok"], true, "{permitted}"); + assert!( + permitted["text"].as_str().unwrap().len() > 256, + "the leaf really amplified the output: {permitted}" + ); + assert_eq!(durable.commits(), 1); + let _ = fs::remove_dir_all(&root); +} + +#[test] +fn phase_a_native_read_only_grant_denies_undeclared_write_and_process() { + let root = temp_root("f4-undeclared"); + let (store, _signer, lock_a) = install_phase_a_pair(&root, phase_a_leaf_body()); + let (_persistence, workspace, pinned) = phase_a_open(&root, &store, &lock_a); + let gate: Arc = Arc::new(GuardianApprovalGate::new(Arc::new(SystemClock), 8)); + let write_durable = NativeDurable::new("run-pin-a", json!({"path": "denied-write.txt"})); + let write = NativeCase { + workspace: workspace.clone(), + pinned: pinned.clone(), + run_id: "run-pin-a".to_string(), + call_id: "call-write-1".to_string(), + tool_name: "write_file".to_string(), + arguments: "{\"path\":\"denied-write.txt\"}".to_string(), + risk: "write", + deadline_ms: 9_000_000_000_000, + max_output_bytes: 4096, + cancelled: Arc::new(AtomicBool::new(false)), + approval: Arc::clone(&gate), + durable: Arc::clone(&write_durable), + } + .invoke(); + assert_eq!(write["ok"], false, "{write}"); + assert_eq!(write["code"], "approval_denied", "{write}"); + assert_eq!(write_durable.started(), 0); + assert_eq!(write_durable.commits(), 0); + assert!( + !workspace.join("denied-write.txt").exists(), + "no write may reach the workspace" + ); + + let process_durable = NativeDurable::new("run-pin-a", json!({"command": "true"})); + let process = NativeCase { + workspace: workspace.clone(), + pinned: pinned.clone(), + run_id: "run-pin-a".to_string(), + call_id: "call-process-1".to_string(), + tool_name: "process".to_string(), + arguments: "{\"command\":\"true\"}".to_string(), + risk: "execute", + deadline_ms: 9_000_000_000_000, + max_output_bytes: 4096, + cancelled: Arc::new(AtomicBool::new(false)), + approval: Arc::clone(&gate), + durable: Arc::clone(&process_durable), + } + .invoke(); + assert_eq!(process["ok"], false, "{process}"); + assert_eq!(process["code"], "approval_denied", "{process}"); + assert_eq!(process_durable.started(), 0); + assert_eq!(process_durable.commits(), 0); + + let read_durable = NativeDurable::new("run-pin-a", json!({"path": "note.txt"})); + let read = NativeCase { + workspace, + pinned, + run_id: "run-pin-a".to_string(), + call_id: "call-read-1".to_string(), + tool_name: "read_file".to_string(), + arguments: "{\"path\":\"note.txt\"}".to_string(), + risk: "read", + deadline_ms: 9_000_000_000_000, + max_output_bytes: 4096, + cancelled: Arc::new(AtomicBool::new(false)), + approval: gate, + durable: Arc::clone(&read_durable), + } + .invoke(); + assert_eq!(read["text"], "libA:hello", "read stays allowed: {read}"); + assert_eq!(read_durable.commits(), 1); + let _ = fs::remove_dir_all(&root); +} diff --git a/tests/tools_pin_production_ab.rs b/tests/tools_pin_production_ab.rs index a5c1eff..1068d46 100644 --- a/tests/tools_pin_production_ab.rs +++ b/tests/tools_pin_production_ab.rs @@ -178,6 +178,79 @@ effects: ) } +fn lib_manifest(id: &str) -> String { + format!( + r#"format: rustscript.plugin/v1 +id: {id} +version: 1.0.0 +kind: library +runtime: ">=0.1.0, <0.2.0" +contract: agent.library/v1 +host_abi: agent.cap/v1 +entry: + path: format.rss + export: format +dependencies: [] +capabilities: [] +effects: + contract: agent.effects/v1 + entries: + format: + direct: [] + delegated: [] +"# + ) +} + +fn lib_tool_manifest(id: &str, lib_id: &str) -> String { + manifest(id).replace( + "dependencies: []\n", + &format!("dependencies:\n - alias: text\n id: {lib_id}\n version: \"1.0.0\"\n"), + ) +} + +/// Same file-read tool as `tool_source`, but linked against the fixture +/// pure leaf through the `text` alias so the narrow revoke topology +/// applies. The leaf is the identity, so delivered bytes are unchanged. +fn lib_tool_source(offset: i64) -> String { + tool_source(offset) + .replace( + "\nfn map_string(", + "\nuse text::{format};\nfn map_string(", + ) + .replace( + "let result: map = { ok: true, content: text,", + "let labeled: string = format(text);\n let result: map = { ok: true, content: labeled,", + ) +} + +fn install_library(store: &PackageStore, signer: &Signer, dir: &Path, id: &str, body: &str) { + fs::create_dir_all(dir).unwrap(); + fs::write(dir.join("manifest.yaml"), lib_manifest(id)).unwrap(); + fs::write(dir.join("format.rss"), body).unwrap(); + let payload = store.signature_payload(dir).unwrap(); + let signature = signer.pair.sign(&payload); + fs::write(dir.join("signature.ed25519"), signature.as_ref()).unwrap(); + store.install(dir, &auth(signer)).unwrap(); +} + +fn install_lib_tool( + store: &PackageStore, + signer: &Signer, + dir: &Path, + id: &str, + lib: &str, + offset: i64, +) { + fs::create_dir_all(dir).unwrap(); + fs::write(dir.join("manifest.yaml"), lib_tool_manifest(id, lib)).unwrap(); + fs::write(dir.join("run.rss"), lib_tool_source(offset)).unwrap(); + let payload = store.signature_payload(dir).unwrap(); + let signature = signer.pair.sign(&payload); + fs::write(dir.join("signature.ed25519"), signature.as_ref()).unwrap(); + store.install(dir, &auth(signer)).unwrap(); +} + fn install_tool(store: &PackageStore, signer: &Signer, dir: &Path, id: &str, offset: i64) { fs::create_dir_all(dir).unwrap(); fs::write(dir.join("manifest.yaml"), manifest(id)).unwrap(); @@ -215,6 +288,136 @@ fn artifact_refs(lock: &GraphLock) -> Vec { .collect() } +/// Signed tool graph A at the Service layer: revoking the exact admitted +/// graph after admission refuses the worker execution with the exact typed +/// code and never executes the recovered program. The unrevoked positive +/// path stays covered by the production cold-restore test above. +#[tokio::test(flavor = "multi_thread")] +async fn production_signed_a_revoke_denies_worker_execution_before_begin() { + let root = temp_root("ab-revoke"); + let signer = signer(); + let store = Arc::new(PackageStore::open(root.join("packages")).unwrap()); + install_library( + &store, + &signer, + &root.join("lib-a"), + "org.rustscript.lib.abtext", + "pub fn format(value: string) -> string { value }\n", + ); + install_lib_tool( + &store, + &signer, + &root.join("pkg-a"), + "org.rustscript.tools.prodab", + "org.rustscript.lib.abtext", + 0, + ); + let lock_a = resolve_tool(&store, "org.rustscript.tools.prodab"); + assert_eq!( + lock_a.packages.len(), + 2, + "the fixture graph must be the narrow tool-plus-leaf shape" + ); + let (home, _workspace) = workspace_home(&root); + let db = root.join("state.db"); + let state = open_gateway(&db, &home, Arc::clone(&store)); + let persistence = state.persistence().expect("sqlite persistence"); + let control = PluginControlStore::new(persistence.as_ref()); + control.store_lock(&lock_a).unwrap(); + let activated = control + .activate(&lock_a.graph_id, 0, &artifact_refs(&lock_a)) + .unwrap(); + assert_ne!( + activated.generation, 0, + "bootstrap CAS must not leave generation 0" + ); + + let provider = ScriptedProvider::new(); + queue_read(&provider, "call-a"); + state + .service() + .inject_provider_host(Arc::new(provider.clone())); + let admitted = state + .service() + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({ + "message": "read note", + "graph_id": lock_a.graph_id, + "tool_graph_id": lock_a.graph_id + }), + idempotency_key: Some("signed-ab-key".to_string()), + idempotency_hash: Some("fnv64:0000000000000001".to_string()), + graph_id: None, + workspace_branch: None, + platform: "tools_pin_production_ab".to_string(), + ..AdmitRunRequest::default() + }, + Some("name:lifecycle".to_string()), + ) + .await + .expect("admit signed A"); + assert_eq!( + control.pin_for_run(&admitted.run_id).unwrap()["graph_id"], + lock_a.graph_id, + "the run must be admitted on the signed graph A" + ); + + // Same-control-store revocation of the exact admitted graph through the + // narrow public topology, before the worker begins the sealed tool. + PluginControlStore::new(persistence.as_ref()) + .revoke_graph(store.as_ref(), &lock_a.graph_id) + .expect("signed graph A revokes through the narrow topology"); + assert!( + PluginControlStore::new(persistence.as_ref()) + .graph_revocation(&lock_a.graph_id) + .unwrap() + .is_some(), + "the revocation must be durably visible before the worker begins" + ); + + state + .service() + .clone() + .run_worker(admitted.run_id.clone(), "ignored".to_string()) + .await; + let events = state.service().run_events(&admitted.run_id); + assert!( + !events + .iter() + .any(|event| event["event"] == "tool.completed"), + "a revoked signed graph must not complete the sealed call: {events:?}" + ); + assert!( + !events.iter().any(|event| event["event"] == "run.completed"), + "a revoked signed run must not complete: {events:?}" + ); + assert!( + events + .iter() + .any(|event| event.to_string().contains("plugin_graph_revoked")), + "the refusal must be the exact typed graph revocation: {events:?}" + ); + assert!( + !observed_text( + state.service().as_ref(), + &admitted.run_id, + &admitted.session_id, + ) + .contains("AB-marker-bytes"), + "the revoked leaf must never deliver its labeled bytes" + ); + // The supported executable ToolOnly restore path refuses the revoked + // graph as well instead of producing a runnable binding. + assert!( + rustscript_agent::plugins::restore_owned_pin(&persistence, &store, &admitted.run_id) + .is_err_and(|error| error.code == "plugin_graph_revoked"), + "restoring a revoked signed pin must refuse with the revocation code" + ); + state.service().shutdown().await.unwrap(); + let _ = fs::remove_dir_all(&root); +} + fn workspace_home(root: &Path) -> (PathBuf, PathBuf) { let home = root.join("workspace-home"); let workspace = root.join("workspace"); @@ -490,12 +693,9 @@ async fn production_signed_a_survives_activation_of_b_and_reopen() { ); // Execute the recovered actual A Program in a fresh Native adapter with // fixture-owned original parent facts, rather than asserting old history. - let binding = rustscript_agent::plugins::restore_owned_pin( - persistence.as_ref(), - &fresh, - &admitted.run_id, - ) - .unwrap(); + let binding = + rustscript_agent::plugins::restore_owned_pin(&persistence, &fresh, &admitted.run_id) + .unwrap(); let durable = Arc::new(ParentFacts { arguments: json!({"path":"note.txt"}), started: 0.into(), @@ -519,7 +719,7 @@ async fn production_signed_a_survives_activation_of_b_and_reopen() { 1 ); let mut displayed = rustscript_agent::plugins::admit_pinned_tool_run( - persistence.as_ref(), &fresh, + &persistence, &fresh, &json!({"idempotency_scope":"api:chat", "idempotency_key":"signed-a-key", "request_hash":"fnv64:0000000000000001", "workspace_identity_json":json!({"request_selector_present":true,"request_selector":"name:lifecycle"}).to_string()}), &rustscript_agent::plugins::PinnedAdmitContext { original_cancellation:None, registry_identity:"ignored-replay-context".to_string(), workspace:workspace.clone() }, @@ -567,36 +767,122 @@ async fn production_signed_a_survives_activation_of_b_and_reopen() { .join(&lock_a.packages[0].artifact_id) .join("sources/run.rss"); let saved_source = fs::read(&source_a).unwrap(); - for replacement in [ - None, - Some("pub fn run(context: map) -> map { { ok: true } }"), + // Snapshot the original receipt-only history through the existing typed + // accessors before damaging the on-disk capture. The receipt and the event + // history survive revocation/damage; neither is execution permission. + let original_events = reopened.service().run_events(&admitted.run_id); + assert_eq!(original_events.len(), event_count); + let original_handles = reopened.service().handle_count(); + let original_pending = reopened.service().pending_terminal_count(); + let original_refs = control.retained(&lock_a.graph_id).unwrap(); + let original_started = durable.started.load(std::sync::atomic::Ordering::SeqCst); + let original_committed = durable.committed.load(std::sync::atomic::Ordering::SeqCst); + let counting_started = counts.started.load(std::sync::atomic::Ordering::SeqCst); + let counting_committed = counts.committed.load(std::sync::atomic::Ordering::SeqCst); + for (damage, replacement) in [ + ("missing", None), + ( + "substituted", + Some("pub fn run(context: map) -> map { { ok: true } }"), + ), ] { match replacement { None => fs::remove_file(&source_a).unwrap(), Some(source) => fs::write(&source_a, source).unwrap(), } + // Every damage shape gets its own cold store. The service installs a + // clone so the official restore below runs against this exact cold + // snapshot, never the previous warm `fresh` store or loaded Program. let cold = Arc::new(PackageStore::open(root.join("packages")).unwrap()); cold.bind_policy(&auth(&signer)); reopened .service() - .install_validated_tool_store(cold) + .install_validated_tool_store(Arc::clone(&cold)) .unwrap(); + let history = reopened + .service() + .admit_external_with_workspace_selector( + replay_request.clone(), + Some("name:lifecycle".to_string()), + ) + .await; + let refusal = rustscript_agent::plugins::restore_owned_pin( + &persistence, + cold.as_ref(), + &admitted.run_id, + ); + let receipt_after = control.pin_for_run(&admitted.run_id).unwrap(); + let events_after = reopened.service().run_events(&admitted.run_id); + let handles_after = reopened.service().handle_count(); + let pending_after = reopened.service().pending_terminal_count(); + let refs_after = control.retained(&lock_a.graph_id).unwrap(); + let started_after = durable.started.load(std::sync::atomic::Ordering::SeqCst); + let committed_after = durable.committed.load(std::sync::atomic::Ordering::SeqCst); + let counting_started_after = counts.started.load(std::sync::atomic::Ordering::SeqCst); + let counting_committed_after = counts.committed.load(std::sync::atomic::Ordering::SeqCst); + // Restore the fixture-owned capture before any fallible assertion so a + // failed round cannot poison the next one or the final cleanup. + fs::write(&source_a, &saved_source).unwrap(); + println!( + "PRODUCTION_AB_COLD_REFUSAL {}", + json!({ + "damage": damage, + "restore_code": refusal.as_ref().err().map(|error| error.code), + "history_replayed": history.as_ref().map(|run| run.replayed).unwrap_or(false), + }) + ); assert!( - reopened - .service() - .admit_external_with_workspace_selector( - replay_request.clone(), - Some("name:lifecycle".to_string()) - ) - .await - .is_err(), - "missing/substituted A must fail closed" + refusal.is_err(), + "{damage}: cold executable restore must refuse without a runnable binding" ); + let history = match history { + Ok(history) => history, + Err(error) => { + panic!("{damage}: original history replay must stay readable: {error:?}") + } + }; + assert!(history.replayed, "{damage}: history query must be a replay"); + assert_eq!(history.run_id, replayed.run_id, "{damage}: run identity"); assert_eq!( - reopened.service().run_events(&admitted.run_id).len(), - event_count + history.session_id, replayed.session_id, + "{damage}: session identity" + ); + assert_eq!(history.status, replayed.status, "{damage}: terminal status"); + assert_eq!( + receipt_after, original_receipt, + "{damage}: pin receipt changed" + ); + assert_eq!( + events_after, original_events, + "{damage}: history events changed" + ); + assert_eq!( + events_after.len(), + event_count, + "{damage}: event count changed" + ); + assert_eq!( + handles_after, original_handles, + "{damage}: extra run handle" + ); + assert_eq!( + pending_after, original_pending, + "{damage}: extra pending terminal" + ); + assert_eq!(refs_after, original_refs, "{damage}: extra graph ref"); + assert_eq!(started_after, original_started, "{damage}: extra started"); + assert_eq!( + committed_after, original_committed, + "{damage}: extra committed" + ); + assert_eq!( + counting_started_after, counting_started, + "{damage}: extra counted start" + ); + assert_eq!( + counting_committed_after, counting_committed, + "{damage}: extra counted commit" ); - fs::write(&source_a, &saved_source).unwrap(); } let restored_store = Arc::new(PackageStore::open(root.join("packages")).unwrap()); restored_store.bind_policy(&auth(&signer)); @@ -764,7 +1050,7 @@ async fn session_delete_releases_native_run_refs_without_collecting_other_owners assert_eq!(missing["pinned"], false, "deleted pin receipt must be gone"); assert_eq!(missing["ref_id"], ""); assert!( - rustscript_agent::plugins::restore_owned_pin(persistence.as_ref(), &store, &run.run_id) + rustscript_agent::plugins::restore_owned_pin(&persistence, &store, &run.run_id) .is_err() ); } @@ -1677,6 +1963,11 @@ fn invoke_original( host.capability_owner = Some(owner); host.filesystem = Some(fs); host.allow_stateless_pin = false; + // The Narrow execution guard reads the exact sealed graph revocation + // under a bounded run cancellation; the fixture supplies its own. + host.cancellation = Some(rustscript_agent::RunCancellation::with_timeout( + std::time::Duration::from_secs(30), + )); let result = rustscript_agent::AgentRunner::from_source( &source, rustscript_agent::AgentConfig::default(), @@ -1736,12 +2027,9 @@ async fn synchronized_argument_forgery_rejected_before_started() { .await .unwrap(); let receipt = control.pin_for_run(&admitted.run_id).unwrap(); - let binding = rustscript_agent::plugins::restore_owned_pin( - persistence.as_ref(), - &store, - &admitted.run_id, - ) - .unwrap(); + let binding = + rustscript_agent::plugins::restore_owned_pin(&persistence, &store, &admitted.run_id) + .unwrap(); let durable = Arc::new(ParentFacts { arguments: json!({"path":"note.txt"}), started: 0.into(), diff --git a/tests/tools_pinned_commit_outcome.rs b/tests/tools_pinned_commit_outcome.rs index 43cc48d..589a7dd 100644 --- a/tests/tools_pinned_commit_outcome.rs +++ b/tests/tools_pinned_commit_outcome.rs @@ -253,11 +253,13 @@ impl Fixture { host_runtime: "0.1.0".to_string(), }) .unwrap(); - let persistence = GatewayPersistence::open( - &rustscript_agent::AgentGatewayConfig::default(), - &root.join("gateway.sqlite"), - ) - .unwrap(); + let persistence = Arc::new( + GatewayPersistence::open( + &rustscript_agent::AgentGatewayConfig::default(), + &root.join("gateway.sqlite"), + ) + .unwrap(), + ); persistence.load().unwrap(); activate(&PluginControlStore::new(&persistence), &lock, 0); let workspace = root.join("workspace"); From 8d3f30e1333ef97b89a6e464c135bb875b07e743 Mon Sep 17 00:00:00 2001 From: fffonion Date: Wed, 4 Nov 2026 02:52:02 +0800 Subject: [PATCH 20/21] docs(config): document host plugin control environment --- docs/configuration.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/configuration.md b/docs/configuration.md index f6a2466..ca37527 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -103,6 +103,30 @@ covers managed synchronous local filesystem effects, not OS permission isolation or rollback of external effects. Tree quotas and retained-object limits are described with the native field below. +### Host plugin control (primary-only) + +The gateway reads these four primary-only variables directly; they have no +`PD_EDGE_AGENT_*` aliases. They configure the host plugin-control binding, +not ordinary run-token authority. + +- `RUSTSCRIPT_AGENT_PLUGIN_SOURCE_ROOT`: filesystem path for the controlled + plugin source root passed to the host binding. Default: unset. If it is + unset, this binding is skipped and the other three settings below are not + read by that binding. +- `RUSTSCRIPT_AGENT_PLUGIN_DEPLOYMENT_SPACE`: deployment-space string passed + to the host plugin control. There is no default; it is required when the + source root is set. A missing or non-UTF-8 value fails startup. +- `RUSTSCRIPT_AGENT_PLUGIN_PUBLISHER_DIR`: optional directory of accepted + publisher verification keys. Default: unset, so no keys are loaded from a + directory. Each entry's filename is the publisher ID and its file contents + must be a raw 32-byte verification key, not a hexadecimal or PEM encoding. + An unavailable directory, unreadable entry, invalid filename, or wrong key + length fails startup. No filename extension is stripped. +- `RUSTSCRIPT_AGENT_PLUGIN_UNSIGNED_DEV`: optional filesystem path forwarded + as `InstallAuthorization::unsigned_dev_space`. Default: unset (`None`). + This is a development-space path, not a boolean flag; setting it does not + bypass the install, trust, or effect-policy checks. + ### Gateway variables and deprecated aliases Every gateway `RUSTSCRIPT_AGENT_*` variable in the table has a deprecated From 41f0ba655633fc38a357ea5b373644b40fcba881 Mon Sep 17 00:00:00 2001 From: fffonion Date: Wed, 4 Nov 2026 21:04:05 +0800 Subject: [PATCH 21/21] fix(gateway): retain sealed replay proofs across storage outages Bind proof publication and reclamation to the same live run handle, clear sealed history proofs on shutdown, and preserve original execution authority. Use real gateway worker rendezvous and bounded phase-aware fixture cleanup. Retain strict cancellation and deadline cuts with the authorized two-fixture initial budget and bounded failure-safe diagnostics. --- src/service.rs | 214 +++- src/service/managed_effects_tests.rs | 1187 +++++++++++++++++++- src/service/managed_linearization_tests.rs | 382 ++++++- tests/gateway_tests.rs | 549 ++++++++- 4 files changed, 2292 insertions(+), 40 deletions(-) diff --git a/src/service.rs b/src/service.rs index 87691c7..d824f9c 100644 --- a/src/service.rs +++ b/src/service.rs @@ -284,6 +284,18 @@ enum NativeRuntimeDisposition { struct NativeStatelessAdmissionProof { embedded: crate::runtime::agent_host::PinnedToolProgram, } + +/// Sealed in-memory admission receipt proof. Cloned ONLY from the genuinely +/// admitted pin/disposition at in-memory install (after the actual durable +/// commit) or after a successful genuine durable receipt validation. Never +/// derived from caller JSON, request hashes, booleans, or deserialized +/// untrusted rows. Keyed by run id and removed with the run's live handle. +#[derive(Clone)] +struct SealedAdmissionReceiptProof { + run_id: String, + pin: crate::runtime::agent_host::PinnedToolProgram, + disposition: NativeRuntimeDisposition, +} impl NativeRuntimeDisposition { fn persistent( pin: &crate::runtime::agent_host::PinnedToolProgram, @@ -976,6 +988,10 @@ struct AgentServiceInner { unresolved_admissions: Mutex>, original_proof_inflight: Mutex>, original_proof_cv: Condvar, + /// Sealed memory-hit receipt proofs, one per live admitted run. Read-only + /// history replay consults these before any durable read; a present but + /// mismatched proof rejects without a durable fallback. + admission_receipt_proofs: Mutex>, halting: AtomicBool, janitor_stop: Notify, janitor_task: Mutex>>, @@ -1000,6 +1016,12 @@ struct AgentServiceInner { /// cfg(test) rendezvous inside commit_gate, after unproven readback, before install. #[cfg(test)] unpublished_install_window: Mutex>>, + /// cfg(test) deterministic interleave window inside + /// seal_admission_receipt_proof, after the handle clone and before + /// atomic publication. Invoked with no coordination locks held, so the + /// observer may evict/expire the handle exactly in that window. + #[cfg(test)] + seal_publish_window: Mutex>>, #[cfg(test)] shutdown_entered_hook: Mutex>>, #[cfg(test)] @@ -1227,6 +1249,7 @@ impl AgentService { unresolved_admissions: Mutex::new(Vec::new()), original_proof_inflight: Mutex::new(HashSet::new()), original_proof_cv: Condvar::new(), + admission_receipt_proofs: Mutex::new(HashMap::new()), halting: AtomicBool::new(false), janitor_stop: Notify::new(), janitor_task: Mutex::new(None), @@ -1246,6 +1269,8 @@ impl AgentService { #[cfg(test)] unpublished_install_window: Mutex::new(None), #[cfg(test)] + seal_publish_window: Mutex::new(None), + #[cfg(test)] shutdown_entered_hook: Mutex::new(None), #[cfg(test)] shutdown_waiting_for_gate: AtomicBool::new(false), @@ -2113,8 +2138,28 @@ impl AgentService { /// history stays replayable here; execution restore still refuses. fn validate_owned_admission_receipt(&self, run_id: &str) -> Result<(), AdmitError> { if let Some(persistence) = self.inner.persistence.as_ref() { - return crate::plugins::validate_owned_admission_receipt(persistence, run_id) + // Sealed in-memory history proof first: the ORIGINAL actually + // committed/validated identity, with no durable reads/writes. A + // present proof must match the live retained handle exactly; + // mismatch (or a missing handle) rejects fail-closed without a + // durable fallback. Only a proof-cache MISS falls through to the + // genuine durable validation below. + if let Some(proof) = self + .inner + .admission_receipt_proofs + .lock() + .expect("receipt proof lock") + .get(run_id) + .cloned() + { + return self.validate_sealed_memory_proof(run_id, &proof); + } + let outcome = crate::plugins::validate_owned_admission_receipt(persistence, run_id) .map_err(|error| AdmitError::Persistence(error.to_string())); + if outcome.is_ok() { + self.seal_admission_receipt_proof(run_id); + } + return outcome; } // Explicit-stateless history: no durable owner exists, so the proof is // the ORIGINAL retained handle with its positively sealed @@ -2155,6 +2200,120 @@ impl AgentService { } Ok(()) } + + /// Seals the live retained handle's pin/disposition as the run's memory + /// proof. Called only at in-memory install after the actual durable + /// commit, or after a successful genuine durable receipt validation. + /// Missing handles/pins seal nothing and never fail the caller. + fn seal_admission_receipt_proof(&self, run_id: &str) { + if self.inner.halting.load(Ordering::Acquire) { + return; + } + let handle = match self.handle(run_id) { + Some(handle) => handle, + None => return, + }; + let proof = match ( + handle.pinned_tool.clone(), + handle.native_disposition.clone(), + ) { + (Some(pin), Some(disposition)) => Some(SealedAdmissionReceiptProof { + run_id: run_id.to_string(), + pin, + disposition, + }), + _ => return, + }; + // cfg(test) deterministic interleave window: no coordination locks + // are held here, so the observer may evict/expire the cloned handle + // exactly before publication. + #[cfg(test)] + if let Some(window) = self.inner.seal_publish_window.lock().unwrap().clone() { + window(); + } + // Atomic publication: the proof is installed only while the SAME + // Arc is still registered, under the shared runs -> + // proofs lock order (eviction, janitor expiry, and shutdown use the + // same order; no durable IO or callback runs under these locks). A + // handle that left in between seals nothing: later replay falls + // back to genuine durable validation instead of an orphan proof. + // The halting recheck under the proofs lock closes the + // shutdown-clear versus late-reseal race. + let runs = self.inner.runs.lock().expect("runs lock"); + if !matches!(runs.get(run_id), Some(registered) if Arc::ptr_eq(registered, &handle)) { + return; + } + if self.inner.halting.load(Ordering::Acquire) { + return; + } + let mut proofs = self + .inner + .admission_receipt_proofs + .lock() + .expect("receipt proof lock"); + if self.inner.halting.load(Ordering::Acquire) { + return; + } + if let Some(proof) = proof { + proofs.insert(run_id.to_string(), proof); + } + } + + /// Drops the run's sealed memory proof with its live handle lifecycle. + fn forget_admission_receipt_proof(&self, run_id: &str) { + self.inner + .admission_receipt_proofs + .lock() + .expect("receipt proof lock") + .remove(run_id); + } + + /// Read-only sealed-proof check: the proof and the live retained handle + /// must attest the exact same originally admitted identity. No durable + /// IO, no executable pin, no authority refresh; mismatch rejects. + fn validate_sealed_memory_proof( + &self, + run_id: &str, + proof: &SealedAdmissionReceiptProof, + ) -> Result<(), AdmitError> { + if proof.run_id != run_id { + return Err(AdmitError::PluginGraphPinRequired); + } + let handle = self + .handle(run_id) + .ok_or(AdmitError::PluginGraphPinRequired)?; + let actual_pin = handle + .pinned_tool + .as_ref() + .ok_or(AdmitError::PluginGraphPinRequired)?; + let actual_disposition = handle + .native_disposition + .as_ref() + .ok_or(AdmitError::PluginGraphPinRequired)?; + if !proof.pin.same_native_binding(actual_pin) + || proof.pin.harness_selection != actual_pin.harness_selection + { + return Err(AdmitError::PluginGraphPinRequired); + } + let disposition_matches = match (&proof.disposition, actual_disposition) { + ( + NativeRuntimeDisposition::PinnedToolOnly(expected), + NativeRuntimeDisposition::PinnedToolOnly(actual), + ) => expected.same_native_binding(actual) && actual.same_native_binding(&proof.pin), + ( + NativeRuntimeDisposition::ExplicitStateless(expected), + NativeRuntimeDisposition::ExplicitStateless(actual), + ) => { + expected.embedded.same_native_binding(&actual.embedded) + && actual.embedded.same_native_binding(&proof.pin) + } + _ => false, + }; + if !disposition_matches { + return Err(AdmitError::PluginGraphPinRequired); + } + Ok(()) + } fn restore_pinned_tool_inner( &self, run_id: &str, @@ -4512,7 +4671,15 @@ impl AgentService { /// Drops the live handle so `run_worker` must restore cancellation from /// frozen context metadata (restart seam). pub fn evict_run_handle(&self, run_id: &str) { - self.inner.runs.lock().expect("runs lock").remove(run_id); + // Shared runs -> proofs lock order with publication: the live + // handle and its sealed proof leave atomically, so a concurrent + // seal either completes first (then this removes both) or loses + // after (then it skips). No durable IO or callback under these locks. + let mut runs = self.inner.runs.lock().expect("runs lock"); + runs.remove(run_id); + // The live handle lifecycle ends: its sealed memory proof ends with + // it, so later replay falls back to genuine durable validation. + self.forget_admission_receipt_proof(run_id); } /// Test seam: overwrite frozen context deadline for overflow restore tests. @@ -7104,6 +7271,11 @@ impl AgentService { .lock() .expect("runs lock") .insert(run_id.clone(), handle); + // The durable receipt is proven above; seal the installed + // ORIGINAL identity so same-process memory-hit replay + // needs no durable read. Sealing reads back the installed + // handle, never request JSON or untrusted rows. + service.seal_admission_receipt_proof(&run_id); service.cache_context(context, Some(snapshot.registry)); service.inner.metrics.admission_accepted(); service.inner.metrics.active_runs_inc(); @@ -7996,6 +8168,19 @@ impl AgentService { .await .map_err(|e| format!("join lifecycle janitor: {e}"))?; } + // The barrier above excluded admissions/workers and the janitor is + // joined: drop the sealed memory proofs even if the caller retains + // the service Arc. Proofs have no post-halting replay use, and + // publication is already closed by the halting gate under the same + // runs -> proofs order, so nothing can reinsert after this clear. + // Run records/handles keep their existing retention policy. + let runs = self.inner.runs.lock().expect("runs lock"); + self.inner + .admission_receipt_proofs + .lock() + .expect("receipt proof lock") + .clear(); + drop(runs); if let Some(persistence) = &self.inner.persistence { persistence.shutdown(); } @@ -11590,10 +11775,10 @@ fn spawn_lifecycle_janitor(inner: Arc) -> tokio::task::JoinHa } let ttl = inner.config.terminal_run_ttl; let now = Instant::now(); - let mut expired_handles = Vec::new(); - let expired_run_ids: HashSet = { + let (expired_handles, expired_run_ids) = { let mut runs = inner.runs.lock().expect("runs lock"); let mut expired = HashSet::new(); + let mut handles = Vec::new(); runs.retain(|run_id, handle| { let keep = handle .terminal_at @@ -11601,12 +11786,29 @@ fn spawn_lifecycle_janitor(inner: Arc) -> tokio::task::JoinHa .expect("terminal lock") .is_none_or(|terminal_at| terminal_at + ttl > now); if !keep { - expired_handles.push(Arc::clone(handle)); + handles.push(Arc::clone(handle)); expired.insert(run_id.clone()); } keep }); - expired + if !expired.is_empty() { + // Same runs -> proofs order as publication, still under + // the runs guard: each expired handle and its proof leave + // atomically, so a concurrent seal either wins first + // (then its handle is live and the id is not expired) or + // loses after (then it skips). A live re-registration + // under the same id keeps its fresh proof. + let mut proofs = inner + .admission_receipt_proofs + .lock() + .expect("receipt proof lock"); + for run_id in &expired { + if !runs.contains_key(run_id) { + proofs.remove(run_id); + } + } + } + (handles, expired) }; for handle in expired_handles { handle.release_capability_host(); diff --git a/src/service/managed_effects_tests.rs b/src/service/managed_effects_tests.rs index 2a809c9..1f30563 100644 --- a/src/service/managed_effects_tests.rs +++ b/src/service/managed_effects_tests.rs @@ -2577,6 +2577,377 @@ const GUARD471_NATIVE_INVOKE_SOURCE: &str = r#"pub fn run(context:map)->map { agent_runtime::invoke_pinned_tool({tool_name:tool_name,call_id:call_id,canonical_arguments:canonical_arguments,canonical_input:canonical_input,run_id:run_id,registry_identity:registry_identity,argument_digest:argument_digest,risk_class:risk_class,summary:summary,untrusted_graph_id:untrusted_graph_id}) }"#; +// ===== 471 有界阶段观测(test-only,纯观测,不改语义)===== +// 每次 helper 调用最多 20 行 typed 阶段记录,溢出位置位;只用固定枚举/布尔/计数, +// 无 run/graph/call id、canonical 正文、请求响应、SSE、DB 值、环境或墙钟。 +// 观察不增加查询、不换时钟、不参与任何原断言判定。 + +const GUARD471_TRACE_CAP: usize = 20; + +#[derive(Default)] +struct Guard471Row { + stage: &'static str, + remaining_us: Option, + deadline_passed: Option, + requested: Option, + shares_original_clock: Option, + query_ordinal: Option, + op: Option<&'static str>, + phase: Option<&'static str>, + provider_ok: Option, + provider_requests: Option, + positive_ok: Option, + positive_replay: Option, + canonical_equal: Option, + denied_ok: Option, + denied_code: Option<&'static str>, + denied_message: Option<&'static str>, + denied_kind: Option<&'static str>, + canonical_absent: Option, + history_unchanged: Option, + tool_completed: Option, + hook_rows_count: Option, + close_query_index: Option, + hook_detached: Option, + shutdown_success: Option, +} + +fn guard471_put( + map: &mut serde_json::Map, + key: &str, + value: Option, +) { + if let Some(value) = value.and_then(|value| serde_json::to_value(value).ok()) { + map.insert(key.to_string(), value); + } +} + +impl Guard471Row { + fn to_value(&self, sequence: usize, elapsed_us: u64) -> Value { + let mut map = serde_json::Map::new(); + map.insert("seq".to_string(), json!(sequence)); + map.insert("stage".to_string(), json!(self.stage)); + map.insert("elapsed_us".to_string(), json!(elapsed_us)); + guard471_put(&mut map, "remaining_us", self.remaining_us); + guard471_put(&mut map, "deadline_passed", self.deadline_passed); + guard471_put(&mut map, "requested", self.requested); + guard471_put( + &mut map, + "shares_original_clock", + self.shares_original_clock, + ); + guard471_put(&mut map, "query_ordinal", self.query_ordinal); + guard471_put(&mut map, "op", self.op); + guard471_put(&mut map, "phase", self.phase); + guard471_put(&mut map, "provider_ok", self.provider_ok); + guard471_put(&mut map, "provider_requests", self.provider_requests); + guard471_put(&mut map, "positive_ok", self.positive_ok); + guard471_put(&mut map, "positive_replay", self.positive_replay); + guard471_put(&mut map, "canonical_equal", self.canonical_equal); + guard471_put(&mut map, "denied_ok", self.denied_ok); + guard471_put(&mut map, "denied_code", self.denied_code); + guard471_put(&mut map, "denied_message", self.denied_message); + guard471_put(&mut map, "denied_kind", self.denied_kind); + guard471_put(&mut map, "canonical_absent", self.canonical_absent); + guard471_put(&mut map, "history_unchanged", self.history_unchanged); + guard471_put(&mut map, "tool_completed", self.tool_completed); + guard471_put(&mut map, "hook_rows_count", self.hook_rows_count); + guard471_put(&mut map, "close_query_index", self.close_query_index); + guard471_put(&mut map, "hook_detached", self.hook_detached); + guard471_put(&mut map, "shutdown_success", self.shutdown_success); + Value::Object(map) + } +} + +fn guard471_remaining_us(root: &crate::RunCancellation) -> Option { + root.remaining_deadline() + .map(|left| u64::try_from(left.as_micros()).unwrap_or(u64::MAX)) +} + +/// 只接受有界的公开错误码/类型 token;其他一律折叠成 `other`,不落原文。 +fn guard471_typed_token(value: Option<&Value>) -> Option<&'static str> { + match value.and_then(Value::as_str) { + Some("cancelled") => Some("cancelled"), + Some("deadline_elapsed") => Some("deadline_elapsed"), + Some("pin_required") => Some("pin_required"), + Some(_) => Some("other"), + None => None, + } +} + +fn guard471_kind_token(kind: &str) -> Option<&'static str> { + match kind { + "" => None, + "replay" => Some("replay"), + _ => Some("other"), + } +} + +/// 观测落盘失败的固定阶段 token:只用于审计/断言,不携带 OS 原文或路径。 +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Guard471TraceFailure { + Serialize, + Write, + Readback, + Parse, + Mismatch, + Rename, + TempCleanup, + Reentrant, +} + +impl Guard471TraceFailure { + fn token(self) -> &'static str { + match self { + Self::Serialize => "serialize", + Self::Write => "write", + Self::Readback => "readback", + Self::Parse => "parse", + Self::Mismatch => "mismatch", + Self::Rename => "rename", + Self::TempCleanup => "temp_cleanup", + Self::Reentrant => "reentrant", + } + } +} + +/// 次级失败报告的唯一边界:固定 label + 固定阶段 token,尽力写一次到给定 sink。 +/// `eprintln!` 在 stderr 写失败时会 panic,Drop / 主 panic 展开路径因此必须走这里; +/// 任何 IO 失败都被丢弃,既不 unwrap 也不 expect,绝不产生二次 panic。 +fn guard471_report_secondary( + mut sink: W, + context: &'static str, + failure: Guard471TraceFailure, +) { + let line = format!("{context}: {}", failure.token()); + let _ = writeln!(sink, "{line}"); +} + +/// finalize 的显式状态:attempted 与最终结果分开,成功结果只在 rename 之后写入。 +#[derive(Default)] +struct Guard471FinalizeState { + attempted: bool, + result: Option>, +} + +/// 只清理本 writer 自己创建的 `.json.tmp` 的 RAII 守卫。 +struct Guard471TempItem { + path: PathBuf, + armed: bool, +} + +impl Guard471TempItem { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + fn cleanup(&mut self) -> Result<(), Guard471TraceFailure> { + if !self.armed { + return Ok(()); + } + self.armed = false; + match fs::remove_file(&self.path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(_) => Err(Guard471TraceFailure::TempCleanup), + } + } + + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for Guard471TempItem { + fn drop(&mut self) { + if !self.armed { + return; + } + self.armed = false; + match fs::remove_file(&self.path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(_) => guard471_report_secondary( + std::io::stderr().lock(), + "guard471-trace-temp-cleanup-failed", + Guard471TraceFailure::TempCleanup, + ), + } + } +} + +struct Guard471Trace { + case: &'static str, + path: PathBuf, + origin: std::time::Instant, + rows: std::sync::Mutex<(usize, Vec, bool)>, + state: std::sync::Mutex, +} + +impl Guard471Trace { + fn new(case: &'static str, dir: &std::path::Path, origin: std::time::Instant) -> Self { + Self { + case, + path: dir.join(format!("guard471-native-{case}-observation.json")), + origin, + rows: std::sync::Mutex::new((0, Vec::new(), false)), + state: std::sync::Mutex::new(Guard471FinalizeState::default()), + } + } + + fn elapsed_us(&self) -> u64 { + u64::try_from(self.origin.elapsed().as_micros()).unwrap_or(u64::MAX) + } + + fn record(&self, row: Guard471Row) { + let elapsed_us = self.elapsed_us(); + let mut rows = self + .rows + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + let (sequence, collected, overflow) = &mut *rows; + if collected.len() >= GUARD471_TRACE_CAP { + *overflow = true; + return; + } + *sequence += 1; + collected.push(row.to_value(*sequence, elapsed_us)); + } + + /// cleanup_complete 只能由真实 shutdown 结果导出:Some(true) 才是完整, + /// false/None(提前 panic/返回)一律 incomplete,避免两个布尔互相矛盾。 + fn document(&self, shutdown: Option) -> Value { + let rows = self + .rows + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + let (_, collected, overflow) = &*rows; + json!({ + "artifact": "guard471-native-stage-observation", + "case": self.case, + "cap": GUARD471_TRACE_CAP, + "row_count": collected.len(), + "overflow": *overflow, + "cleanup_complete": shutdown == Some(true), + "shutdown_success": shutdown, + "rows": collected, + }) + } + + /// 单文件临时写 + 读回校验 + 原子 rename;任何失败都转换为固定阶段 token, + /// RAII 守卫保证序列化/写/读回/解析/不匹配/rename 失败时删除自己的 .tmp。 + fn write(&self, document: &Value) -> Result<(), Guard471TraceFailure> { + let text = + serde_json::to_string_pretty(document).map_err(|_| Guard471TraceFailure::Serialize)?; + let tmp_path = self.path.with_extension("json.tmp"); + let mut tmp = Guard471TempItem::new(tmp_path.clone()); + fs::write(&tmp_path, text.as_bytes()).map_err(|_| Guard471TraceFailure::Write)?; + let readback = fs::read(&tmp_path).map_err(|_| Guard471TraceFailure::Readback)?; + let parsed = std::str::from_utf8(&readback) + .ok() + .and_then(|text| serde_json::from_str::(text).ok()) + .ok_or(Guard471TraceFailure::Parse)?; + if readback != text.as_bytes() || parsed != *document { + tmp.cleanup()?; + return Err(Guard471TraceFailure::Mismatch); + } + if fs::rename(&tmp_path, &self.path).is_err() { + tmp.cleanup()?; + return Err(Guard471TraceFailure::Rename); + } + tmp.disarm(); + Ok(()) + } + + /// 只落一次盘:成功结果仅在 readback/rename 全部完成后记录;重复调用返回 + /// 已存结果(失败不会被洗成 Ok),并发的重入调用返回固定 Reentrant token。 + fn finalize(&self, shutdown: Option) -> Result<(), Guard471TraceFailure> { + let mut state = self + .state + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + if let Some(result) = state.result { + return result; + } + if state.attempted { + return Err(Guard471TraceFailure::Reentrant); + } + state.attempted = true; + let result = self.write(&self.document(shutdown)); + state.result = Some(result); + result + } +} + +/// 早 panic / 提前返回路径:同步清 hook、清临时文件,并落下 +/// `cleanup_complete=false`、`shutdown_success` 缺失的诚实记录。 +struct Guard471Fallback { + trace: Arc, + persistence: Arc, +} + +impl Drop for Guard471Fallback { + fn drop(&mut self) { + *self + .persistence + .managed_request_hook + .lock() + .unwrap_or_else(|poison| poison.into_inner()) = None; + // 正常路径已成功落盘时 finalize 返回已存 Ok;若落盘失败则重复返回同一 + // 固定 token,并且绝不在此 panic(不能覆盖正在展开的原始失败)。 + if let Err(failure) = self.trace.finalize(None) { + guard471_report_secondary( + std::io::stderr().lock(), + "guard471-trace-finalize-failed", + failure, + ); + } + } +} + +/// 既有 per-persistence hook 的纯观测包装:只按固定 op 白名单计数/记录, +/// 不改原过滤、计数、close_query.store 与 sleep。 +struct Guard471HookTrace { + trace: Arc, + root: crate::RunCancellation, + graph_pre_send: std::sync::atomic::AtomicUsize, + effect_get_pre_send: std::sync::atomic::AtomicUsize, +} + +impl Guard471HookTrace { + fn observe(&self, op: &str, cancel: &crate::RunCancellation, enqueued: bool) { + use std::sync::atomic::Ordering; + let (tag, ordinal) = if op == "plugin.graph_revocation_get" { + let ordinal = if enqueued { + self.graph_pre_send.load(Ordering::SeqCst) + } else { + self.graph_pre_send.fetch_add(1, Ordering::SeqCst) + 1 + }; + ("graph_revocation_get", Some(ordinal)) + } else if op == "effect.get" { + let ordinal = if enqueued { + self.effect_get_pre_send.load(Ordering::SeqCst) + } else { + self.effect_get_pre_send.fetch_add(1, Ordering::SeqCst) + 1 + }; + ("effect_get", Some(ordinal)) + } else { + ("other", None) + }; + self.trace.record(Guard471Row { + stage: "hook", + remaining_us: guard471_remaining_us(&self.root), + deadline_passed: Some(self.root.deadline_passed()), + requested: Some(self.root.requested().is_some()), + shares_original_clock: Some(cancel.shares_original_clock(&self.root)), + query_ordinal: ordinal, + op: Some(tag), + phase: Some(if enqueued { "enqueued" } else { "pre_send" }), + ..Default::default() + }); + } +} + struct Guard471NativeManagedEvidence { positive: Value, denied: Value, @@ -2593,14 +2964,14 @@ struct Guard471NativeManagedEvidence { async fn guard471_native_managed_round(close: Guard468FinalClose) -> Guard471NativeManagedEvidence { use crate::AgentProviderHost; let (root, state) = - fixture_config(|config| config.run_timeout = std::time::Duration::from_secs(2)); + fixture_config(|config| config.run_timeout = std::time::Duration::from_secs(10)); let service = state.service(); let persistence = state.persistence().unwrap(); let sentinel = root.join("native-managed-sentinel"); let call = json!({"id":"once","name":"terminal","arguments":{"argv":["/bin/sh","-c",format!("printf x >> '{}'; printf observed",sentinel.display())],"timeout_ms":1000}}); let canonical = call["arguments"].to_string(); let args = call["arguments"].clone(); - // 491:原 2s 根只在下面唯一一次正式 admission 创建。先把 service 自身的 + // 491:原 10s 根只在下面唯一一次正式 admission 创建。先把 service 自身的 // agent/template 与三份不可变 Native 探针编译完,窗口内不再 from_source。 if let Err(error) = service.materialize_cached_runner() { let cleanup = service.shutdown().await; @@ -2634,7 +3005,7 @@ async fn guard471_native_managed_round(close: Guard468FinalClose) -> Guard471Nat let provider = Arc::new(crate::ScriptedProvider::new()); provider.push_ok(response(Some(call.clone()))); service.inject_provider_host(provider.clone()); - // 唯一一次真实 admission:它创建 original 2s 根、owned handle 与 pinned binding。 + // 唯一一次真实 admission:它创建 original 10s 根、owned handle 与 pinned binding。 let run = service .admit_external_with_workspace_selector( AdmitRunRequest { @@ -2661,6 +3032,27 @@ async fn guard471_native_managed_round(close: Guard468FinalClose) -> Guard471Nat original.deadline_instant().is_some(), "the original admission root must carry Some(D)" ); + // 观测:在原根捕获之后开单调时钟并只在同一 original root 上取快照, + // 不刷新/替换时钟,不参与任何判定。artifact 落在本 case 的既有 test TMP。 + let trace = Arc::new(Guard471Trace::new( + match close { + Guard468FinalClose::Requested => "requested", + Guard468FinalClose::Elapsed => "elapsed", + }, + &root, + std::time::Instant::now(), + )); + let _trace_fallback = Guard471Fallback { + trace: Arc::clone(&trace), + persistence: Arc::clone(&persistence), + }; + trace.record(Guard471Row { + stage: "root_capture", + remaining_us: guard471_remaining_us(&original), + deadline_passed: Some(original.deadline_passed()), + requested: Some(original.requested().is_some()), + ..Default::default() + }); // step 2:正式 DurableProviderHost parent,恰好一次真实 provider 请求。 let durable = crate::durable_provider::DurableProviderHost::new( (*service).clone(), @@ -2679,6 +3071,15 @@ async fn guard471_native_managed_round(close: Guard468FinalClose) -> Guard471Nat 1, "the narrow fixture must issue exactly one real provider request" ); + trace.record(Guard471Row { + stage: "provider_parent_return", + provider_ok: parent["ok"].as_bool(), + provider_requests: Some(provider.requests().len()), + remaining_us: guard471_remaining_us(&original), + deadline_passed: Some(original.deadline_passed()), + requested: Some(original.requested().is_some()), + ..Default::default() + }); let host = service .capability_host_bridges(&run_id) .unwrap() @@ -2725,6 +3126,20 @@ async fn guard471_native_managed_round(close: Guard468FinalClose) -> Guard471Nat 1, "the first Native call must complete exactly once" ); + trace.record(Guard471Row { + stage: "first_final", + canonical_equal: Some(invoke_canonical["content"] == "observed"), + tool_completed: Some(count_tool_events( + &service, + &run_id, + "tool.completed", + "once", + )), + remaining_us: guard471_remaining_us(&original), + deadline_passed: Some(original.deadline_passed()), + requested: Some(original.requested().is_some()), + ..Default::default() + }); // step 4:未撤销 Native invoke 精确同 calling 必须回放历史 canonical。 let positive = run_retained_native_probe( &positive_probe, @@ -2762,9 +3177,43 @@ async fn guard471_native_managed_round(close: Guard468FinalClose) -> Guard471Nat history_canonical, "the positive replay must equal the durable history canonical" ); + trace.record(Guard471Row { + stage: "positive_history", + positive_ok: positive["ok"].as_bool(), + positive_replay: Some(replayed_kind(&positive) == "replay"), + canonical_equal: Some( + positive["canonical_result"].as_str() == Some(history_canonical.as_str()), + ), + remaining_us: guard471_remaining_us(&original), + deadline_passed: Some(original.deadline_passed()), + requested: Some(original.requested().is_some()), + ..Default::default() + }); // 有限 hook:只匹配 exact fresh 查询的 enqueue 边界。第一次是 Native // entry guard 的读;第二次才是 entry 放行后 repository 的 final // freshness 读,只在该次(恰一次)关闭原根。 + // 观测:denied 调用前快照。克隆只共享原时钟(同一 deadline/requested Arc), + // 不刷新 deadline、不构成新根。 + let trace_root = original.clone(); + let hook_trace = Guard471HookTrace { + trace: Arc::clone(&trace), + root: trace_root.clone(), + graph_pre_send: std::sync::atomic::AtomicUsize::new(0), + effect_get_pre_send: std::sync::atomic::AtomicUsize::new(0), + }; + trace.record(Guard471Row { + stage: "denied_start", + remaining_us: guard471_remaining_us(&trace_root), + deadline_passed: Some(trace_root.deadline_passed()), + requested: Some(trace_root.requested().is_some()), + tool_completed: Some(count_tool_events( + &service, + &run_id, + "tool.completed", + "once", + )), + ..Default::default() + }); let hook_rows = Arc::new(std::sync::Mutex::new(Vec::new())); let hook_rows_in = Arc::clone(&hook_rows); let seen = Arc::new(std::sync::atomic::AtomicUsize::new(0)); @@ -2773,6 +3222,7 @@ async fn guard471_native_managed_round(close: Guard468FinalClose) -> Guard471Nat let close_query_out = Arc::clone(&close_query); *persistence.managed_request_hook.lock().unwrap() = Some(Arc::new(move |op, cancel, enqueued| { + hook_trace.observe(op, cancel, enqueued); if op != "plugin.graph_revocation_get" || enqueued { return; } @@ -2804,6 +3254,22 @@ async fn guard471_native_managed_round(close: Guard468FinalClose) -> Guard471Nat &canonical, "execute", ); + trace.record(Guard471Row { + stage: "denied_return", + denied_ok: denied["ok"].as_bool(), + denied_code: guard471_typed_token(denied.get("code")), + denied_message: guard471_typed_token(denied.get("message")), + denied_kind: guard471_kind_token(replayed_kind(&denied)), + canonical_absent: Some( + denied + .get("canonical_result") + .is_none_or(|value| value == ""), + ), + remaining_us: guard471_remaining_us(&trace_root), + deadline_passed: Some(trace_root.deadline_passed()), + requested: Some(trace_root.requested().is_some()), + ..Default::default() + }); // 保留原始 Option/Result,撤 hook 与 shutdown 之后才判定缺失。 let handle = service.handle(&run_id); let root_requested = handle @@ -2828,11 +3294,58 @@ async fn guard471_native_managed_round(close: Guard468FinalClose) -> Guard471Nat after_record.outcome, first_record.outcome, "the whole canonical history must stay exactly the first committed final" ); + trace.record(Guard471Row { + stage: "history_unchanged", + history_unchanged: Some( + after_record.effect_id == first_record.effect_id + && after_record.outcome == first_record.outcome, + ), + tool_completed: Some(tool_completed), + close_query_index: Some(close_query.load(std::sync::atomic::Ordering::SeqCst)), + remaining_us: guard471_remaining_us(&trace_root), + deadline_passed: Some(trace_root.deadline_passed()), + requested: Some(trace_root.requested().is_some()), + ..Default::default() + }); let rows = hook_rows.lock().unwrap().clone(); let close_query_index = close_query.load(std::sync::atomic::Ordering::SeqCst); drop(handle); *persistence.managed_request_hook.lock().unwrap() = None; + trace.record(Guard471Row { + stage: "hook_detach", + hook_detached: Some(true), + hook_rows_count: Some(rows.len()), + close_query_index: Some(close_query_index), + remaining_us: guard471_remaining_us(&trace_root), + deadline_passed: Some(trace_root.deadline_passed()), + requested: Some(trace_root.requested().is_some()), + ..Default::default() + }); let shutdown_result = service.shutdown().await; + // 观测落盘在 shutdown 之后、任何调用方断言之前:cleanup_complete 只由真实 + // shutdown 成功导出;shutdown 失败时保留原始失败传播,写入失败只以固定 token + // 作为次要审计,绝不用写 panic 覆盖主因。 + let shutdown_ok = shutdown_result.is_ok(); + trace.record(Guard471Row { + stage: "shutdown_result", + shutdown_success: Some(shutdown_ok), + hook_detached: Some(true), + ..Default::default() + }); + let trace_result = trace.finalize(Some(shutdown_ok)); + if let Err(failure) = trace_result { + if shutdown_ok { + panic!( + "guard471 trace artifact was not written: {}", + failure.token() + ); + } + guard471_report_secondary( + std::io::stderr().lock(), + "guard471-trace-secondary-write-failed", + failure, + ); + } shutdown_result.unwrap(); assert_eq!( fs::read(&sentinel).unwrap(), @@ -2968,6 +3481,242 @@ async fn guard_contract_471_r467_2_native_invoke_elapsed_keeps_deadline_elapsed( assert_eq!(evidence.tool_completed, 1, "被拒绝的回放不得提交第二个结果"); } +// ===== 471 观测落盘真伪与原子写清理回归(529 P2-01/P2-02)===== +// 只使用分配的 test TMP 目录:用“目标同名目录”伪造 rename 失败、用缺失父目录 +// 伪造写失败,不触碰 DB/配置/密钥,也不引入新故障注入框架。 +fn guard471_trace_scratch_root() -> PathBuf { + let root = PathBuf::from(std::env::var("RUSTSCRIPT_AGENT_TEST_TMP").unwrap()) + .join(format!("guard471-trace-{}", uuid::Uuid::new_v4().simple())); + fs::create_dir_all(&root).unwrap(); + root +} + +#[test] +fn guard_contract_471_trace_shutdown_false_records_incomplete() { + let root = guard471_trace_scratch_root(); + let trace = Guard471Trace::new("shutdown-false", &root, std::time::Instant::now()); + assert_eq!(trace.finalize(Some(false)), Ok(())); + let document: Value = serde_json::from_slice(&fs::read(&trace.path).unwrap()).unwrap(); + assert_eq!( + document["cleanup_complete"], false, + "shutdown 失败不得伪报 cleanup 完整:{document}" + ); + assert_eq!( + document["shutdown_success"], false, + "必须保留真实 shutdown 结果:{document}" + ); +} + +#[test] +fn guard_contract_471_trace_shutdown_none_records_incomplete() { + let root = guard471_trace_scratch_root(); + let trace = Guard471Trace::new("shutdown-none", &root, std::time::Instant::now()); + assert_eq!(trace.finalize(None), Ok(())); + let document: Value = serde_json::from_slice(&fs::read(&trace.path).unwrap()).unwrap(); + assert_eq!( + document["cleanup_complete"], false, + "未观察到 shutdown 时不得伪报 cleanup 完整:{document}" + ); + assert!( + document["shutdown_success"].is_null(), + "未观察到 shutdown 时必须写 null:{document}" + ); +} + +#[test] +fn guard_contract_471_trace_rename_failure_removes_tmp() { + let root = guard471_trace_scratch_root(); + let trace = Guard471Trace::new("rename-failure", &root, std::time::Instant::now()); + fs::create_dir(&trace.path).unwrap(); + assert_eq!( + trace.finalize(Some(true)), + Err(Guard471TraceFailure::Rename), + "rename 失败必须返回固定 rename token" + ); + let tmp = trace.path.with_extension("json.tmp"); + assert!( + !tmp.exists(), + "rename 失败必须删除自己已知的 .tmp,不能遗留在测试目录" + ); + assert!( + trace.path.is_dir(), + "rename 失败不得覆盖同名目录或伪造 JSON 产物" + ); + assert_eq!( + trace.finalize(Some(true)), + Err(Guard471TraceFailure::Rename), + "重复 finalize 必须返回已存失败,不能洗成 Ok" + ); + assert!(!tmp.exists(), "重复失败不得留下新的 .tmp"); +} + +#[test] +fn guard_contract_471_trace_write_failure_is_typed_and_writes_nothing() { + let root = guard471_trace_scratch_root(); + let missing_parent = root.join("missing-parent"); + let trace = Guard471Trace::new("write-failure", &missing_parent, std::time::Instant::now()); + assert_eq!( + trace.finalize(Some(true)), + Err(Guard471TraceFailure::Write), + "缺失父目录必须返回固定 write token,而非静默成功" + ); + assert!(!trace.path.exists(), "写失败不得留下目标产物"); + assert!( + !trace.path.with_extension("json.tmp").exists(), + "写失败不得留下 .tmp" + ); + assert_eq!( + trace.finalize(Some(true)), + Err(Guard471TraceFailure::Write), + "重复 finalize 必须保留首次写入失败" + ); +} + +#[test] +fn guard_contract_471_trace_success_persists_valid_json_without_tmp() { + let root = guard471_trace_scratch_root(); + let trace = Guard471Trace::new("complete", &root, std::time::Instant::now()); + trace.record(Guard471Row { + stage: "root_capture", + ..Default::default() + }); + assert_eq!(trace.finalize(Some(true)), Ok(())); + assert_eq!( + trace.finalize(Some(true)), + Ok(()), + "重复 finalize 返回已存成功,不重写" + ); + let bytes = fs::read(&trace.path).unwrap(); + let document: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(document["cleanup_complete"], true); + assert_eq!(document["shutdown_success"], true); + assert_eq!(document["cap"], GUARD471_TRACE_CAP); + assert_eq!(document["row_count"], 1); + assert_eq!(document["overflow"], false); + assert_eq!(document["rows"].as_array().unwrap().len(), 1); + assert!( + !trace.path.with_extension("json.tmp").exists(), + "成功 rename 后不得留下 .tmp" + ); + assert_eq!( + fs::read(&trace.path).unwrap(), + bytes, + "重复 finalize 不得改写已成功产物" + ); +} + +#[test] +fn guard_contract_471_trace_temp_guard_removes_tmp_on_unwind() { + let root = guard471_trace_scratch_root(); + let tmp = root.join("guard471-native-unwind-observation.json.tmp"); + fs::write(&tmp, b"armed").unwrap(); + let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _guard = Guard471TempItem::new(tmp.clone()); + panic!("guard471 synthetic unwind"); + })); + assert!(unwound.is_err(), "测试自身的 panic 必须只展开一次"); + assert!(!tmp.exists(), "RAII 守卫必须在展开时删除自己的 .tmp"); +} + +/// 确定性失败 sink:记录每次真实尝试写入的字节,然后返回真实的 BrokenPipe。 +#[derive(Clone, Default)] +struct Guard471BrokenPipeSink { + attempted: Arc>>, +} + +impl Guard471BrokenPipeSink { + fn attempted(&self) -> Vec { + self.attempted + .lock() + .unwrap_or_else(|poison| poison.into_inner()) + .clone() + } +} + +impl std::io::Write for Guard471BrokenPipeSink { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.attempted + .lock() + .unwrap_or_else(|poison| poison.into_inner()) + .extend_from_slice(buf); + Err(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "guard471 synthetic broken pipe", + )) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +#[test] +fn guard_contract_471_secondary_report_ignores_io_error() { + let sink = Guard471BrokenPipeSink::default(); + // 报告必须真的尝试写一次固定 token,并吞掉 BrokenPipe 而非 panic/传播。 + guard471_report_secondary( + sink.clone(), + "guard471-trace-temp-cleanup-failed", + Guard471TraceFailure::TempCleanup, + ); + assert_eq!( + String::from_utf8(sink.attempted()).unwrap(), + "guard471-trace-temp-cleanup-failed: temp_cleanup", + "次级报告必须真的尝试写出固定 label + 固定 token,而不是只返回" + ); + let mut probe = Guard471BrokenPipeSink::default(); + let error = std::io::Write::write_all(&mut probe, b"x").expect_err("sink 必须真实失败"); + assert_eq!( + error.kind(), + std::io::ErrorKind::BrokenPipe, + "sink 返回的必须是真实 BrokenPipe,而不是形状断言" + ); + assert_eq!( + probe.attempted(), + b"x".to_vec(), + "sink 必须记录到那次真实写尝试" + ); +} + +#[test] +fn guard_contract_471_secondary_report_preserves_primary_unwind_payload() { + struct Guard471UnwindDrop { + sink: Guard471BrokenPipeSink, + } + + impl Drop for Guard471UnwindDrop { + fn drop(&mut self) { + // 与生产 Drop 分支同一个私有 helper:写失败只能被忽略。 + guard471_report_secondary( + self.sink.clone(), + "guard471-trace-finalize-failed", + Guard471TraceFailure::Write, + ); + } + } + + let sink = Guard471BrokenPipeSink::default(); + let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let _guard = Guard471UnwindDrop { sink: sink.clone() }; + panic!("guard471 primary unwind payload"); + })); + let payload = unwound.expect_err("主 panic 必须是唯一展开,不得被次级写失败吞掉"); + let message = payload + .downcast_ref::<&str>() + .copied() + .or_else(|| payload.downcast_ref::().map(String::as_str)); + assert_eq!( + message, + Some("guard471 primary unwind payload"), + "原始 unwind payload 必须原样保留,不得被替换或二次 panic" + ); + assert_eq!( + String::from_utf8(sink.attempted()).unwrap(), + "guard471-trace-finalize-failed: write", + "Drop 展开路径必须真的尝试写固定 token" + ); +} + // ============================================================================ // 478: first-party signedA — a formally installed signed ToolOnly tool that // links the approved pure `string -> string` leaf library. @@ -4888,3 +5637,435 @@ pub fn run(context: map) -> map { assert!(cleanup_facts.4, "the run must expose no usable bridge"); } } + +// ===== guard519:sealed memory-hit 历史回放(仅测试,不改生产语义) ===== +// 同进程 memory-hit 回放必须用首次 durable commit 后封存的原身份做只读 +// 校验,不读存储、不刷新执行权限;错配/缺席必须闭门失败。 + +/// 519 正控:durable 打断期间,同进程 sealed memory-hit 回放必须返回原 +/// run(202 等价:Ok + replayed),恢复后仍回放原 run,不建新 session。 +#[tokio::test(flavor = "multi_thread")] +async fn guard_contract_519_sealed_memory_proof_replays_while_durable_is_down() { + let (root, state) = fixture(); + let service = state.service(); + let request = AdmitRunRequest { + input: json!({"message": "sealed replay while down"}), + idempotency_key: Some("guard-519-down".to_string()), + idempotency_hash: Some("fnv64:0000000000051901".to_string()), + ..Default::default() + }; + let first = service + .admit_external_with_workspace_selector(request.clone(), None) + .await + .expect("managed admission must commit while storage is healthy"); + assert!( + !first.replayed, + "first admission must not be marked replayed" + ); + let db = root.join("gateway.sqlite"); + let backup = root.join("gateway.sqlite.bak519"); + std::fs::rename(&db, &backup).expect("move the db aside"); + std::fs::create_dir(&db).expect("break storage with a directory"); + let down = service + .admit_external_with_workspace_selector(request.clone(), None) + .await + .expect("sealed memory-hit replay must succeed while storage is down"); + assert!(down.replayed, "outage replay must be marked replayed"); + assert_eq!( + down.run_id, first.run_id, + "outage replay must return the original run" + ); + assert_eq!( + down.session_id, first.session_id, + "outage replay must not create a session" + ); + assert_eq!( + down.status, first.status, + "outage replay must preserve the original status, not refresh authority" + ); + std::fs::remove_dir(&db).expect("remove storage directory"); + std::fs::rename(&backup, &db).expect("restore storage"); + let restored = service + .admit_external_with_workspace_selector(request.clone(), None) + .await + .expect("replay after restore must succeed"); + assert!( + restored.replayed, + "post-restore replay must be marked replayed" + ); + assert_eq!( + restored.run_id, first.run_id, + "post-restore replay must still return the original run" + ); + service.shutdown().await.unwrap(); +} + +/// 519 负控:retained handle 与封存证明错配(跨 run 装配)时,收据校验 +/// 必须闭门失败,不得回退到 durable 读取。 +#[tokio::test(flavor = "multi_thread")] +async fn guard_contract_519_mismatched_memory_proof_refuses_receipt() { + let (_root, state) = fixture(); + let service = state.service(); + let first = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message": "sealed proof zero"}), + idempotency_key: Some("guard-519-zero".to_string()), + idempotency_hash: Some("fnv64:0000000000051902".to_string()), + ..Default::default() + }, + None, + ) + .await + .expect("first managed admission must commit"); + let second = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message": "sealed proof one"}), + idempotency_key: Some("guard-519-one".to_string()), + idempotency_hash: Some("fnv64:0000000000051903".to_string()), + ..Default::default() + }, + None, + ) + .await + .expect("second managed admission must commit"); + let live_zero = service + .handle(&first.run_id) + .expect("original run must retain its handle"); + let pin_zero = live_zero + .pinned_tool + .clone() + .expect("admission must seal the original pin"); + let live_one = service + .handle(&second.run_id) + .expect("second run must retain its handle"); + let proof_one = live_one + .native_disposition + .clone() + .expect("second admission must seal its disposition"); + drop(live_zero); + drop(live_one); + // 只用已 retained 的真实 pin/证明交叉装配,不伪造 guest 证明。 + let craft = super::super::RunHandle { + cancel: crate::RunCancellation::with_timeout(std::time::Duration::from_secs(5)), + terminal_at: std::sync::Mutex::new(None), + permit: std::sync::Mutex::new(None), + started_at: std::time::Instant::now(), + terminal: std::sync::atomic::AtomicBool::new(false), + cancel_reason: std::sync::Mutex::new(None), + subscribers: std::sync::Mutex::new(super::super::SubscriberState { + count: 0, + notified: false, + }), + disconnect_policy: crate::config::ClientDisconnectPolicy::KeepRunning, + tool_cancel: crate::runtime::cancellation::CancellationToken::new(), + capability_host: std::sync::Mutex::new(super::super::CapabilityHostPhase::Empty), + capability_host_cv: std::sync::Condvar::new(), + workspace_authority: None, + legacy_workspace_root: None, + coding_system_prompt: std::sync::Arc::from(""), + occupancy: std::sync::atomic::AtomicBool::new(false), + restore_worker_started: None, + cleanup_outcome: std::sync::Mutex::new(None), + pinned_tool: Some(pin_zero), + native_disposition: Some(proof_one), + }; + service + .inner + .runs + .lock() + .expect("runs lock") + .insert(first.run_id.clone(), std::sync::Arc::new(craft)); + assert!( + matches!( + service.validate_owned_admission_receipt(&first.run_id), + Err(crate::AdmitError::PluginGraphPinRequired) + ), + "mismatched sealed proof must refuse the receipt without a durable fallback" + ); + service.shutdown().await.unwrap(); +} + +/// 519 负控:handle 被驱逐后封存证明同步消失;durable 打断下的回放必须 +/// 闭门失败(无 memory 回退),durable 健康时仍走原 durable 校验。 +#[tokio::test(flavor = "multi_thread")] +async fn guard_contract_519_evicted_proof_falls_back_to_durable_closed() { + let (root, state) = fixture(); + let service = state.service(); + let request = AdmitRunRequest { + input: json!({"message": "evicted proof fallback"}), + idempotency_key: Some("guard-519-evict".to_string()), + idempotency_hash: Some("fnv64:0000000000051904".to_string()), + ..Default::default() + }; + let first = service + .admit_external_with_workspace_selector(request.clone(), None) + .await + .expect("managed admission must commit while storage is healthy"); + service.evict_run_handle(&first.run_id); + assert!(service.handle(&first.run_id).is_none()); + assert!( + service + .inner + .admission_receipt_proofs + .lock() + .expect("receipt proof lock") + .get(&first.run_id) + .is_none(), + "evicting the live handle must drop its sealed memory proof" + ); + // durable 健康:miss 回退到原 durable 校验,仍回放原 run。 + let healthy = service + .admit_external_with_workspace_selector(request.clone(), None) + .await + .expect("evicted replay with healthy storage must use durable validation"); + assert!(healthy.replayed); + assert_eq!(healthy.run_id, first.run_id); + // durable 打断:无 memory 回退,必须闭门失败。 + let db = root.join("gateway.sqlite"); + let backup = root.join("gateway.sqlite.bak519evict"); + std::fs::rename(&db, &backup).expect("move the db aside"); + std::fs::create_dir(&db).expect("break storage with a directory"); + let down = service + .admit_external_with_workspace_selector(request.clone(), None) + .await; + assert!( + matches!(down, Err(crate::AdmitError::Persistence(_))), + "evicted replay while storage is down must fail closed: {down:?}" + ); + std::fs::remove_dir(&db).expect("remove storage directory"); + std::fs::rename(&backup, &db).expect("restore storage"); + service.shutdown().await.unwrap(); +} + +/// 521 F-01:durable 校验成功后的封存必须与“同一 Arc 仍在 runs 中”原子 +/// 确认;封存中途 handle 被驱逐/过期时不得留下无主 proof,且健康回放仍 +/// 走原严格 durable 路径(无 proof 时 durable 打断必须闭门失败)。 +#[tokio::test(flavor = "multi_thread")] +async fn guard_contract_521_seal_after_eviction_leaves_no_orphan_proof() { + let (root, state) = fixture(); + let service = state.service(); + let request = AdmitRunRequest { + input: json!({"message": "521 seal race"}), + idempotency_key: Some("guard-521-seal-race".to_string()), + idempotency_hash: Some("fnv64:0000000000052101".to_string()), + ..Default::default() + }; + let first = service + .admit_external_with_workspace_selector(request.clone(), None) + .await + .expect("managed admission must commit while storage is healthy"); + assert!(!first.replayed); + assert!( + service + .inner + .admission_receipt_proofs + .lock() + .expect("receipt proof lock") + .contains_key(&first.run_id), + "admission install must seal the original receipt proof" + ); + // 留下真实 pin/disposition 克隆:只用已 retained 的原身份装配, + // 不伪造 guest 证明。 + let live = service + .handle(&first.run_id) + .expect("original run must retain its handle"); + let real_pin = live + .pinned_tool + .clone() + .expect("admission must seal the original pin"); + let real_disposition = live + .native_disposition + .clone() + .expect("admission must seal its disposition"); + drop(live); + // 驱逐:handle 与 proof 同步消失(519 行为)。 + service.evict_run_handle(&first.run_id); + assert!(service.handle(&first.run_id).is_none()); + assert!( + service + .inner + .admission_receipt_proofs + .lock() + .expect("receipt proof lock") + .get(&first.run_id) + .is_none() + ); + // 装入一只“复活但尚无 proof”的 handle:与 + // restore_handle_from_frozen_context 的安装形状一致(真实 + // pin/disposition,无缓存 proof),durable miss 即走封存路径。 + let craft = super::super::RunHandle { + cancel: crate::RunCancellation::with_timeout(std::time::Duration::from_secs(5)), + terminal_at: std::sync::Mutex::new(None), + permit: std::sync::Mutex::new(None), + started_at: std::time::Instant::now(), + terminal: std::sync::atomic::AtomicBool::new(false), + cancel_reason: std::sync::Mutex::new(None), + subscribers: std::sync::Mutex::new(super::super::SubscriberState { + count: 0, + notified: false, + }), + disconnect_policy: crate::config::ClientDisconnectPolicy::KeepRunning, + tool_cancel: crate::runtime::cancellation::CancellationToken::new(), + capability_host: std::sync::Mutex::new(super::super::CapabilityHostPhase::Empty), + capability_host_cv: std::sync::Condvar::new(), + workspace_authority: None, + legacy_workspace_root: None, + coding_system_prompt: std::sync::Arc::from(""), + occupancy: std::sync::atomic::AtomicBool::new(false), + restore_worker_started: None, + cleanup_outcome: std::sync::Mutex::new(None), + pinned_tool: Some(real_pin.clone()), + native_disposition: Some(real_disposition.clone()), + }; + service + .inner + .runs + .lock() + .expect("runs lock") + .insert(first.run_id.clone(), std::sync::Arc::new(craft)); + // 确定性交错:封存克隆 handle 后、发布 proof 前精确驱逐,模拟 + // janitor/TTL 过期赢得竞态。seam 在无协调锁持有时触发。 + let raced = service.clone(); + let raced_run = first.run_id.clone(); + service + .inner + .seal_publish_window + .lock() + .unwrap() + .replace(Arc::new(move || raced.evict_run_handle(&raced_run))); + // 健康回放:miss -> 原严格 durable 校验成功(Ok),真理仍是 durable。 + service + .validate_owned_admission_receipt(&first.run_id) + .expect("healthy durable validation must succeed despite the mid-seal eviction"); + // 中途失去 handle 的封存不得发布无主 proof。 + assert!( + service + .inner + .admission_receipt_proofs + .lock() + .expect("receipt proof lock") + .get(&first.run_id) + .is_none(), + "a seal that loses its handle mid-flight must not publish an orphan proof" + ); + assert!(service.handle(&first.run_id).is_none()); + // 关窗:新代 handle 重新安装后封存新 proof,旧驱逐不得抹掉新代。 + service.inner.seal_publish_window.lock().unwrap().take(); + let craft_fresh = super::super::RunHandle { + cancel: crate::RunCancellation::with_timeout(std::time::Duration::from_secs(5)), + terminal_at: std::sync::Mutex::new(None), + permit: std::sync::Mutex::new(None), + started_at: std::time::Instant::now(), + terminal: std::sync::atomic::AtomicBool::new(false), + cancel_reason: std::sync::Mutex::new(None), + subscribers: std::sync::Mutex::new(super::super::SubscriberState { + count: 0, + notified: false, + }), + disconnect_policy: crate::config::ClientDisconnectPolicy::KeepRunning, + tool_cancel: crate::runtime::cancellation::CancellationToken::new(), + capability_host: std::sync::Mutex::new(super::super::CapabilityHostPhase::Empty), + capability_host_cv: std::sync::Condvar::new(), + workspace_authority: None, + legacy_workspace_root: None, + coding_system_prompt: std::sync::Arc::from(""), + occupancy: std::sync::atomic::AtomicBool::new(false), + restore_worker_started: None, + cleanup_outcome: std::sync::Mutex::new(None), + pinned_tool: Some(real_pin), + native_disposition: Some(real_disposition), + }; + service + .inner + .runs + .lock() + .expect("runs lock") + .insert(first.run_id.clone(), std::sync::Arc::new(craft_fresh)); + service + .validate_owned_admission_receipt(&first.run_id) + .expect("healthy durable validation must succeed for the fresh handle"); + assert!( + service + .inner + .admission_receipt_proofs + .lock() + .expect("receipt proof lock") + .contains_key(&first.run_id), + "a fresh handle must seal a fresh proof after the race" + ); + // 无 proof 时 durable 打断必须闭门失败。 + service.evict_run_handle(&first.run_id); + let db = root.join("gateway.sqlite"); + let backup = root.join("gateway.sqlite.bak521seal"); + std::fs::rename(&db, &backup).expect("move the db aside"); + std::fs::create_dir(&db).expect("break storage with a directory"); + let down = service.validate_owned_admission_receipt(&first.run_id); + assert!( + matches!(down, Err(crate::AdmitError::Persistence(_))), + "replay without a proof while storage is down must fail closed: {down:?}" + ); + std::fs::remove_dir(&db).expect("remove storage directory"); + std::fs::rename(&backup, &db).expect("restore storage"); + service.shutdown().await.unwrap(); +} + +/// 521 F-03:shutdown 在 barrier 之后必须清空新增 proof 缓存(service +/// Arc 仍被持有也不得保留 pin/disposition 副本),且关停后不得迟到重封; +/// 原 run-record/handle 保留策略不变。 +#[tokio::test(flavor = "multi_thread")] +async fn guard_contract_521_shutdown_clears_proof_cache_without_reseal() { + let (_root, state) = fixture(); + let service = state.service(); + let first = service + .admit_external_with_workspace_selector( + AdmitRunRequest { + input: json!({"message": "521 shutdown clears proof"}), + idempotency_key: Some("guard-521-shutdown".to_string()), + idempotency_hash: Some("fnv64:0000000000052103".to_string()), + ..Default::default() + }, + None, + ) + .await + .expect("managed admission must commit"); + assert!( + service + .inner + .admission_receipt_proofs + .lock() + .expect("receipt proof lock") + .contains_key(&first.run_id), + "admission install must seal the receipt proof before shutdown" + ); + service.shutdown().await.expect("shutdown must succeed"); + // service Arc 仍活着:proof 不得再被持有。 + assert!( + service + .inner + .admission_receipt_proofs + .lock() + .expect("receipt proof lock") + .is_empty(), + "shutdown must clear the sealed proof cache while the service is retained" + ); + // 原保留策略:handle/run 记录不动。 + assert!( + service.handle(&first.run_id).is_some(), + "shutdown must keep the existing run-record retention policy" + ); + // 迟到重封被关停门挡住:走真实校验路径后 map 仍空。 + let _ = service.validate_owned_admission_receipt(&first.run_id); + assert!( + service + .inner + .admission_receipt_proofs + .lock() + .expect("receipt proof lock") + .is_empty(), + "no proof may be resealed after shutdown cleared the cache" + ); + drop(service); + drop(state); +} diff --git a/src/service/managed_linearization_tests.rs b/src/service/managed_linearization_tests.rs index cbf35db..8dab20c 100644 --- a/src/service/managed_linearization_tests.rs +++ b/src/service/managed_linearization_tests.rs @@ -1024,12 +1024,315 @@ async fn paused(runtime: &crate::effects::broker::ManagedEffectRuntime, branch: .expect("actual checkpoint pause publication"); } +/// 536: bounded test-local observation for the whole-batch timeout. Rows hold +/// only fixed typed phases, counters and enums (no ids, payloads, paths, SQL, +/// SSE text or raw errors). They stay in memory during the 3s window; the +/// artifact is serialized only after the original cleanup. +const WHOLE_BATCH_OBS_CAP: usize = 24; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum WholeBatchStage { + BeforeWindow, + ProbeInstalled, + RssEntered, + RssExited, + WorkerFutureReturned, + SampleBegin, + ProviderCalls, + EventModelRequested, + EventModelCompleted, + EventToolCompleted, + EventRunCompleted, + EventRunFailed, + EventRunCancelled, + HandleTerminal, + ClockRoot, + EffectState, + SampleEnd, +} + +impl WholeBatchStage { + fn token(self) -> &'static str { + match self { + Self::BeforeWindow => "before_window", + Self::ProbeInstalled => "probe_installed", + Self::RssEntered => "rss_entered", + Self::RssExited => "rss_exited", + Self::WorkerFutureReturned => "worker_future_returned", + Self::SampleBegin => "sample_begin", + Self::ProviderCalls => "provider_calls", + Self::EventModelRequested => "event_model_requested", + Self::EventModelCompleted => "event_model_completed", + Self::EventToolCompleted => "event_tool_completed", + Self::EventRunCompleted => "event_run_completed", + Self::EventRunFailed => "event_run_failed", + Self::EventRunCancelled => "event_run_cancelled", + Self::HandleTerminal => "handle_terminal", + Self::ClockRoot => "clock_root", + Self::EffectState => "effect_state", + Self::SampleEnd => "sample_end", + } + } +} + +/// Rows that must exist for a complete post-window sample. `RssExited` is +/// deliberately absent: a detached blocking job may still be running. +const WHOLE_BATCH_EXPECTED: &[WholeBatchStage] = &[ + WholeBatchStage::BeforeWindow, + WholeBatchStage::ProbeInstalled, + WholeBatchStage::RssEntered, + WholeBatchStage::WorkerFutureReturned, + WholeBatchStage::SampleBegin, + WholeBatchStage::ProviderCalls, + WholeBatchStage::EventModelRequested, + WholeBatchStage::EventModelCompleted, + WholeBatchStage::EventToolCompleted, + WholeBatchStage::EventRunCompleted, + WholeBatchStage::EventRunFailed, + WholeBatchStage::EventRunCancelled, + WholeBatchStage::HandleTerminal, + WholeBatchStage::ClockRoot, + WholeBatchStage::EffectState, + WholeBatchStage::SampleEnd, +]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum WholeBatchEffectState { + Absent, + PreparedNoOutcome, + Committed, + OtherTypedOutcome, + ReadError, +} + +impl WholeBatchEffectState { + fn token(self) -> &'static str { + match self { + Self::Absent => "absent", + Self::PreparedNoOutcome => "prepared_no_outcome", + Self::Committed => "committed", + Self::OtherTypedOutcome => "other_typed_outcome", + Self::ReadError => "read_error", + } + } +} + +#[derive(Clone, Copy)] +enum WholeBatchEvent { + Stage(WholeBatchStage), + WorkerFuture { + ok: bool, + }, + Count { + stage: WholeBatchStage, + count: u64, + }, + Terminal { + terminal: Option, + }, + Clock { + requested: bool, + has_deadline: bool, + remaining_ms: Option, + shares_original: bool, + }, + Effect { + state: WholeBatchEffectState, + }, +} + +impl WholeBatchEvent { + fn stage(self) -> WholeBatchStage { + match self { + Self::Stage(stage) => stage, + Self::WorkerFuture { .. } => WholeBatchStage::WorkerFutureReturned, + Self::Count { stage, .. } => stage, + Self::Terminal { .. } => WholeBatchStage::HandleTerminal, + Self::Clock { .. } => WholeBatchStage::ClockRoot, + Self::Effect { .. } => WholeBatchStage::EffectState, + } + } +} + +struct WholeBatchRow { + event: WholeBatchEvent, + elapsed_us: u64, +} + +impl WholeBatchRow { + fn to_value(&self, sequence: usize) -> Value { + let elapsed_us = self.elapsed_us; + match self.event { + WholeBatchEvent::Stage(stage) => { + json!({"sequence": sequence, "stage": stage.token(), "elapsed_us": elapsed_us}) + } + WholeBatchEvent::WorkerFuture { ok } => json!({ + "sequence": sequence, + "stage": WholeBatchStage::WorkerFutureReturned.token(), + "elapsed_us": elapsed_us, + "ok": ok, + }), + WholeBatchEvent::Count { stage, count } => json!({ + "sequence": sequence, + "stage": stage.token(), + "elapsed_us": elapsed_us, + "count": count, + }), + WholeBatchEvent::Terminal { terminal } => json!({ + "sequence": sequence, + "stage": WholeBatchStage::HandleTerminal.token(), + "elapsed_us": elapsed_us, + "terminal": terminal, + }), + WholeBatchEvent::Clock { + requested, + has_deadline, + remaining_ms, + shares_original, + } => json!({ + "sequence": sequence, + "stage": WholeBatchStage::ClockRoot.token(), + "elapsed_us": elapsed_us, + "requested": requested, + "has_deadline": has_deadline, + "remaining_ms": remaining_ms, + "shares_original": shares_original, + }), + WholeBatchEvent::Effect { state } => json!({ + "sequence": sequence, + "stage": WholeBatchStage::EffectState.token(), + "elapsed_us": elapsed_us, + "effect": state.token(), + }), + } + } +} + +#[derive(Default)] +struct WholeBatchRows { + rows: Vec, + overflow: bool, +} + +struct WholeBatchObservation { + origin: std::time::Instant, + rows: std::sync::Mutex, +} + +impl WholeBatchObservation { + fn new() -> Self { + Self { + origin: std::time::Instant::now(), + rows: std::sync::Mutex::new(WholeBatchRows { + rows: Vec::with_capacity(WHOLE_BATCH_OBS_CAP), + overflow: false, + }), + } + } + + fn elapsed_us(&self) -> u64 { + u64::try_from(self.origin.elapsed().as_micros()).unwrap_or(u64::MAX) + } + + fn record(&self, event: WholeBatchEvent) { + let elapsed_us = self.elapsed_us(); + let mut rows = self + .rows + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + if rows.rows.len() >= WHOLE_BATCH_OBS_CAP { + rows.overflow = true; + return; + } + rows.rows.push(WholeBatchRow { event, elapsed_us }); + } + + fn record_stage(&self, stage: WholeBatchStage) { + self.record(WholeBatchEvent::Stage(stage)); + } + + fn document(&self) -> Value { + let rows = self + .rows + .lock() + .unwrap_or_else(|poison| poison.into_inner()); + let stages: Vec = rows.rows.iter().map(|row| row.event.stage()).collect(); + let missing: Vec<&'static str> = WHOLE_BATCH_EXPECTED + .iter() + .filter(|expected| !stages.iter().any(|stage| stage == *expected)) + .map(|expected| expected.token()) + .collect(); + json!({ + "artifact": "checkpoint-whole-batch-observation", + "case": "536", + "cap": WHOLE_BATCH_OBS_CAP, + "row_count": rows.rows.len(), + "overflow": rows.overflow, + "missing_stages": missing, + "rss_exit_observed": stages.contains(&WholeBatchStage::RssExited), + "sampling": "after_original_3s_window", + "probe_exit_is_whole_worker_return": false, + "commit_after_timeout_does_not_date_commit_before_timeout": true, + "rows": rows.rows.iter().enumerate().map(|(index, row)| row.to_value(index + 1)).collect::>(), + }) + } +} + +/// Reuses the accepted one-shot per-instance worker probe. The guard's clause +/// runs when the blocking runner returns; it is not the whole-worker future +/// returning, and a timeout-dropped future can leave that job running. +struct WholeBatchWorkerProbe { + observation: Arc, +} + +impl crate::service::WorkerProbeInstall for WholeBatchWorkerProbe { + fn install(self: Box) -> crate::service::WorkerProbeGuard { + self.observation.record_stage(WholeBatchStage::RssEntered); + let observation = self.observation.clone(); + crate::service::WorkerProbeGuard::new(move || { + observation.record_stage(WholeBatchStage::RssExited); + }) + } +} + +fn whole_batch_effect_state( + persistence: &crate::GatewayPersistence, + request: &ManagedEffectGetRequest, +) -> WholeBatchEffectState { + match persistence.managed_effect_get(request) { + Err(_) => WholeBatchEffectState::ReadError, + Ok(None) => WholeBatchEffectState::Absent, + Ok(Some(record)) => match record.outcome { + None if record.prepared.is_none() => WholeBatchEffectState::Absent, + None => WholeBatchEffectState::PreparedNoOutcome, + Some(outcome) if outcome.disposition == ManagedPublicationStatus::Committed => { + WholeBatchEffectState::Committed + } + Some(_) => WholeBatchEffectState::OtherTypedOutcome, + }, + } +} + +/// Only serialization path: the accepted 471 writer (own `.tmp` guard, readback, +/// atomic rename). The private path field is retargeted at the fixture root so +/// the document never travels through the 471 document/finalize labels. +fn whole_batch_write_observation( + observation: &WholeBatchObservation, + root: &std::path::Path, +) -> Result<(), super::Guard471TraceFailure> { + let mut writer = super::Guard471Trace::new("whole-batch-536", root, observation.origin); + writer.path = root.join("checkpoint-whole-batch-observation.json"); + writer.write(&observation.document()) +} + #[tokio::test(flavor = "multi_thread")] async fn checkpoint_waits_for_whole_tool_batch_complete() { - let (_root, state) = fixture_config(management_config); + let (root, state) = fixture_config(management_config); let (_other_root, other_state) = fixture_config(management_config); let other = other_state.service(); let service = state.service(); + let observation = Arc::new(WholeBatchObservation::new()); + observation.record_stage(WholeBatchStage::BeforeWindow); let provider = Arc::new(crate::ScriptedProvider::new()); provider.push_ok(json!({"text":"","tool_calls":[ {"id":"whole-first","name":"write_file","arguments":{"path":"first.txt","content":"one"}}, @@ -1069,12 +1372,13 @@ async fn checkpoint_waits_for_whole_tool_batch_complete() { let other_provider = Arc::new(crate::ScriptedProvider::new()); other_provider.push_ok(response(Some(json!({"id":"other-write","name":"write_file","arguments":{"path":"other.txt","content":"independent"}})))); other_provider.push_ok(response(None)); - other.inject_provider_host(other_provider); + other.inject_provider_host(other_provider.clone()); let observed = service.clone(); let independent = other.clone(); let branch = bound.admitted_branch.branch_id.clone(); let request_probe = request.clone(); let runtime = rt.clone(); + let observation_probe = observation.clone(); let check = tokio::spawn(async move { paused(&runtime, &branch).await; assert!( @@ -1116,14 +1420,73 @@ async fn checkpoint_waits_for_whole_tool_batch_complete() { other_binding.admitted_branch.workspace_id, bound.admitted_branch.workspace_id ); - tokio::time::timeout( + *independent + .inner + .worker_probe + .lock() + .unwrap_or_else(|poison| poison.into_inner()) = Some(Box::new(WholeBatchWorkerProbe { + observation: observation_probe.clone(), + })); + observation_probe.record_stage(WholeBatchStage::ProbeInstalled); + let other_worker_result = tokio::time::timeout( Duration::from_secs(3), independent .clone() .run_worker(other_run.run_id.clone(), String::new()), ) - .await - .expect("other workspace actual commit cannot wait on batch IO"); + .await; + observation_probe.record(WholeBatchEvent::WorkerFuture { + ok: other_worker_result.is_ok(), + }); + observation_probe.record_stage(WholeBatchStage::SampleBegin); + observation_probe.record(WholeBatchEvent::Count { + stage: WholeBatchStage::ProviderCalls, + count: other_provider.call_count(), + }); + let other_events = independent.run_events(&other_run.run_id); + for (stage, name) in [ + (WholeBatchStage::EventModelRequested, "model.requested"), + (WholeBatchStage::EventModelCompleted, "model.completed"), + (WholeBatchStage::EventToolCompleted, "tool.completed"), + (WholeBatchStage::EventRunCompleted, "run.completed"), + (WholeBatchStage::EventRunFailed, "run.failed"), + (WholeBatchStage::EventRunCancelled, "run.cancelled"), + ] { + let count = other_events + .iter() + .filter(|event| event.get("event").and_then(Value::as_str) == Some(name)) + .count(); + observation_probe.record(WholeBatchEvent::Count { + stage, + count: u64::try_from(count).unwrap_or(u64::MAX), + }); + } + observation_probe.record(WholeBatchEvent::Terminal { + terminal: independent + .handle(&other_run.run_id) + .as_ref() + .map(|handle| handle.is_terminal()), + }); + observation_probe.record(WholeBatchEvent::Clock { + requested: other_binding.cancel.requested().is_some(), + has_deadline: other_binding.cancel.deadline_instant().is_some(), + remaining_ms: other_binding + .cancel + .remaining_deadline() + .map(|remaining| u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX)), + shares_original: other_binding.cancel.shares_original_clock(&original_clock), + }); + observation_probe.record(WholeBatchEvent::Effect { + state: whole_batch_effect_state( + independent.inner.persistence.as_ref().unwrap(), + &ManagedEffectGetRequest { + owner: other_binding.owner.clone(), + call_id: "other-write".into(), + }, + ), + }); + observation_probe.record_stage(WholeBatchStage::SampleEnd); + other_worker_result.expect("other workspace actual commit cannot wait on batch IO"); let result = independent .inner .persistence @@ -1172,6 +1535,14 @@ async fn checkpoint_waits_for_whole_tool_batch_complete() { service.shutdown().await.unwrap(); other.shutdown().await.unwrap(); other_state.service().shutdown().await.unwrap(); + let observation_write = whole_batch_write_observation(&observation, &root); + if let Err(failure) = &observation_write { + super::guard471_report_secondary( + std::io::stderr().lock(), + "checkpoint-whole-batch-observation-write-failed", + *failure, + ); + } if let Err(error) = check { std::panic::resume_unwind(error.into_panic()); } @@ -1205,4 +1576,5 @@ async fn checkpoint_waits_for_whole_tool_batch_complete() { assert_eq!(output.receipt_id, record.prepared.unwrap().effect_id); assert_eq!(output.result, outcome.canonical_result_ref); } + observation_write.expect("checkpoint whole batch observation write"); } diff --git a/tests/gateway_tests.rs b/tests/gateway_tests.rs index 0a22114..0fa8193 100644 --- a/tests/gateway_tests.rs +++ b/tests/gateway_tests.rs @@ -91,6 +91,38 @@ fn spawn_holding_fixture() -> ( (port, arrived_rx, release_tx, handle) } +/// Owns a held HTTP fixture's release sender and worker thread so the gate +/// is released and the fixture joined on success and on unwind. The join +/// expectation only fires outside an active panic. +struct HoldingFixtureGuard { + release: Option>, + worker: Option>, +} + +impl HoldingFixtureGuard { + fn release(&mut self) { + if let Some(release) = self.release.take() { + let _ = release.send(()); + } + } + + fn join(&mut self) { + if let Some(worker) = self.worker.take() { + let joined = worker.join(); + if !std::thread::panicking() { + joined.expect("held HTTP fixture must finish"); + } + } + } +} + +impl Drop for HoldingFixtureGuard { + fn drop(&mut self) { + self.release(); + self.join(); + } +} + #[test] fn holding_http_fixture_can_be_released_before_connection() { let (_, _, release_tx, fixture) = spawn_holding_fixture(); @@ -1777,21 +1809,44 @@ async fn wait_until(timeout: std::time::Duration, mut condition: impl FnMut() -> /// recovers (durable commit first, publish only after). #[tokio::test] async fn terminal_commit_is_retried_after_storage_recovers_exactly_once() { + // The storage cut must land after the worker passes context/pin + // validation: the script first performs a real held HTTP call, and the + // fixture arrival proves RSS execution began (post-validation), not + // merely admission. The trailing infinite loop keeps the worker + // never-normally-completing so the stop below commits a cancelled + // terminal, exactly as before. + let (port, arrived_rx, release_tx, fixture) = spawn_holding_fixture(); + let mut holder = HoldingFixtureGuard { + release: Some(release_tx), + worker: Some(fixture), + }; let path = gateway_db_path("terminal-retry"); let state = AgentGatewayState::with_agent_source_and_sqlite( AgentGatewayConfig { janitor_interval: std::time::Duration::from_millis(100), terminal_commit_retry_window: std::time::Duration::from_secs(10), + http: rustscript_vm::HttpConfig { + allowed_schemes: vec!["http".to_string()], + allowed_hosts: vec!["127.0.0.1".to_string()], + allowed_ports: vec![port], + allow_private_ips: true, + ..rustscript_vm::HttpConfig::default() + }, ..AgentGatewayConfig::default() }, - r#" - pub fn run(input: map) -> string { - while true { + format!( + r#" + use http; + pub fn run(input: map) -> string {{ + let request = http::request::new("GET", "http://127.0.0.1:{port}/"); + let response = http::client::request(request); + while true {{ 1; - } + }} "unreachable"; - } - "#, + }} + "# + ), &path, ) .expect("SQLite state should open"); @@ -1808,6 +1863,15 @@ async fn terminal_commit_is_retried_after_storage_recovers_exactly_once() { assert_eq!(status, StatusCode::ACCEPTED); let run_id = run["run_id"].as_str().expect("run id").to_string(); + // Post-validation rendezvous: the real worker reached the held HTTP + // call, so context and pin validation already succeeded while storage + // was healthy. Awaiting (instead of blocking) keeps the current-thread + // runtime polling the worker task. + tokio::time::timeout(std::time::Duration::from_secs(15), arrived_rx) + .await + .expect("the worker must pass validation and reach the held HTTP call") + .expect("fixture arrival signal"); + // Storage goes down: replace the SQLite file with a directory so every // storage command fails (the worker opens the DB per command). let broken = path.with_extension("db.broken"); @@ -1826,6 +1890,11 @@ async fn terminal_commit_is_retried_after_storage_recovers_exactly_once() { .await; assert_eq!(stop_status, StatusCode::OK); + // Release the held call: the worker (already stop-requested, parked past + // the HTTP response in its never-ending loop) observes cancellation and + // commits a cancelled terminal, which cannot persist while storage is + // down. + holder.release(); let pending = wait_until(std::time::Duration::from_secs(10), || { service.pending_terminal_count() == 1 }) @@ -1883,6 +1952,7 @@ async fn terminal_commit_is_retried_after_storage_recovers_exactly_once() { 1, "restart must not duplicate or recover the terminal, got: {text}" ); + holder.join(); let _ = std::fs::remove_file(&path); } @@ -2006,37 +2076,217 @@ async fn terminal_commit_window_expiry_releases_capacity_and_restart_recovery_re let _ = std::fs::remove_file(&path); } +/// Test-only stage observer for the gated fixture: three at-most-once, +/// non-blocking stage signals over three bounded (capacity 1) channels. +/// Only the two F02 stage tests construct it through the staged +/// constructor below; every other caller keeps the plain no-observer +/// constructor, so this is single-fixture test instrumentation, not a +/// second parser, a global hook, or a new API. +struct GatedFixtureStages { + /// Fires after accept/setup, immediately before the first header read. + read_ready: std::sync::mpsc::Receiver<()>, + /// Fires after incomplete header bytes are incorporated into `header`. + partial_consumed: std::sync::mpsc::Receiver<()>, + /// Fires after a real read timeout while partial bytes are present. + idle_timeout: std::sync::mpsc::Receiver<()>, +} + /// Gated HTTP fixture: accepts one connection, reads the request headers, /// then blocks until released so the test can break storage while the -/// worker is mid-flight. +/// worker is mid-flight. Accept and the incomplete-header read honor +/// release and one absolute deadline (the same existing 30s arrival budget +/// as the holding fixture), and the release wait is bounded too, so guard +/// Drop (release + join) provably finishes within that budget on success +/// and on failure cleanup. A complete request header still sends the real +/// worker-arrival oneshot before storage is cut. fn spawn_gated_fixture() -> ( u16, + tokio::sync::oneshot::Receiver<()>, std::sync::mpsc::Sender<()>, std::thread::JoinHandle<()>, ) { - use std::io::{BufRead as _, Write as _}; + let (port, arrived_rx, release_tx, handle, _stages) = spawn_gated_fixture_inner(false); + (port, arrived_rx, release_tx, handle) +} + +/// Stage-observing constructor: only the two F02 stage tests use the +/// returned receivers; all other callers use the plain constructor above. +fn spawn_gated_fixture_with_stages() -> ( + u16, + tokio::sync::oneshot::Receiver<()>, + std::sync::mpsc::Sender<()>, + std::thread::JoinHandle<()>, + GatedFixtureStages, +) { + let (port, arrived_rx, release_tx, handle, stages) = spawn_gated_fixture_inner(true); + ( + port, + arrived_rx, + release_tx, + handle, + stages.expect("a staged fixture always returns its stage observer"), + ) +} + +/// Shared gated-fixture body. `observe == false` for every existing caller +/// (no stage channels exist, zero behavior change); `true` only for the two +/// F02 stage tests, which receive the bounded stage observer. +fn spawn_gated_fixture_inner( + observe: bool, +) -> ( + u16, + tokio::sync::oneshot::Receiver<()>, + std::sync::mpsc::Sender<()>, + std::thread::JoinHandle<()>, + Option, +) { + use std::io::{Read as _, Write as _}; let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind fixture"); let port = listener.local_addr().expect("fixture address").port(); + let (arrived_tx, arrived_rx) = tokio::sync::oneshot::channel(); let (release_tx, release_rx) = std::sync::mpsc::channel::<()>(); + // Per-fixture opt-in stage channels: three capacity-1 senders, each + // fired at most once with a non-blocking try_send. `None` (the default + // for all pre-existing callers) leaves the loop below untouched. + let mut read_ready_tx: Option> = None; + let mut partial_consumed_tx: Option> = None; + let mut idle_timeout_tx: Option> = None; + let stages = if observe { + let (read_ready_sender, read_ready) = std::sync::mpsc::sync_channel::<()>(1); + let (partial_sender, partial_consumed) = std::sync::mpsc::sync_channel::<()>(1); + let (idle_sender, idle_timeout) = std::sync::mpsc::sync_channel::<()>(1); + read_ready_tx = Some(read_ready_sender); + partial_consumed_tx = Some(partial_sender); + idle_timeout_tx = Some(idle_sender); + Some(GatedFixtureStages { + read_ready, + partial_consumed, + idle_timeout, + }) + } else { + None + }; + listener.set_nonblocking(true).expect("nonblocking fixture"); let handle = std::thread::spawn(move || { - let (stream, _) = listener.accept().expect("fixture accept"); - let mut reader = std::io::BufReader::new(stream.try_clone().expect("clone stream")); - let mut line = String::new(); + let accept_deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + let mut stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + match release_rx.recv_timeout(std::time::Duration::from_millis(5)) { + Ok(()) | Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} + } + assert!( + std::time::Instant::now() < accept_deadline, + "fixture request did not arrive" + ); + } + Err(error) => panic!("accept fixture request: {error}"), + } + }; + stream + .set_nonblocking(false) + .expect("blocking fixture stream"); + // F02: bounded incremental header read. One `read` returns at most one + // fixed-size chunk, so continuous non-newline input cannot pin a + // single call past release or the absolute deadline: every chunk and + // every read timeout re-checks release and the same absolute + // deadline. Partial bytes persist in `header` across timeouts, so a + // header fragmented across an idle timeout still completes. + // Accumulation is capped by an explicit header bound; only a real + // complete header sends the entered oneshot. Each blocking wait is + // bounded by min(existing 200ms check interval, remaining absolute). + stream + .set_read_timeout(Some(std::time::Duration::from_millis(200))) + .expect("fixture read timeout"); + stream + .set_write_timeout(Some(std::time::Duration::from_secs(5))) + .expect("fixture write timeout"); + const HEADER_CHUNK: usize = 512; + const HEADER_BOUND: usize = 16 * 1024; + let mut header: Vec = Vec::new(); + let mut chunk = [0_u8; HEADER_CHUNK]; + let mut partial_present = false; loop { - line.clear(); - let read = reader.read_line(&mut line).expect("read request head"); - if read == 0 || line == "\r\n" { - break; + let remaining = accept_deadline.saturating_duration_since(std::time::Instant::now()); + assert!( + !remaining.is_zero(), + "fixture request headers did not complete" + ); + stream + .set_read_timeout(Some(std::time::Duration::from_millis(200).min(remaining))) + .expect("fixture read timeout"); + // Stage A (test-only, at most once): the last release/deadline + // check precedes this point and the next operation is the first + // header read, so this ack proves the worker will enter the + // read path instead of exiting through the accept loop. + if let Some(tx) = read_ready_tx.take() { + let _ = tx.try_send(()); + } + match stream.read(&mut chunk) { + Ok(0) => return, + Ok(n) => { + if header.len() + n > HEADER_BOUND { + return; + } + header.extend_from_slice(&chunk[..n]); + if header.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + // Stage B (test-only, at most once): these incomplete + // bytes are now incorporated in `header` parser state, + // not merely sent by the client. + partial_present = true; + if let Some(tx) = partial_consumed_tx.take() { + let _ = tx.try_send(()); + } + } + Err(error) + if error.kind() == std::io::ErrorKind::WouldBlock + || error.kind() == std::io::ErrorKind::TimedOut => + { + // Idle timeout: partial bytes stay in `header` for the + // next chunk; release and deadline are checked below. + // Stage C (test-only, at most once): a real read timeout + // fired while partial bytes from stage B are present. + if partial_present && let Some(tx) = idle_timeout_tx.take() { + let _ = tx.try_send(()); + } + } + // Reset/aborted connection during cleanup: exit quietly so + // guard join stays a clean finish, not a panic. + Err(_) => return, + } + match release_rx.try_recv() { + Ok(()) | Err(std::sync::mpsc::TryRecvError::Disconnected) => return, + Err(std::sync::mpsc::TryRecvError::Empty) => {} } + assert!( + std::time::Instant::now() < accept_deadline, + "fixture request headers did not complete" + ); } - drop(reader); - release_rx.recv().expect("fixture release"); - let mut stream = stream; - stream - .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") - .expect("write fixture response"); + let _ = arrived_tx.send(()); + // Bounded release wait on the same absolute deadline: the guard + // always releases, so this returns promptly; the deadline only + // guards a truly lost releaser. + loop { + match release_rx.recv_timeout(std::time::Duration::from_millis(5)) { + Ok(()) => break, + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} + } + assert!( + std::time::Instant::now() < accept_deadline, + "fixture release did not arrive" + ); + } + // Best-effort response after release: a vanished client must not + // turn cleanup into a panic. + let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok"); }); - (port, release_tx, handle) + (port, arrived_rx, release_tx, handle, stages) } /// P2: the completed terminal (assistant message + delta + completed @@ -2045,7 +2295,14 @@ fn spawn_gated_fixture() -> ( /// message. #[tokio::test] async fn completed_terminal_with_message_is_retried_after_storage_recovers() { - let (port, release, fixture) = spawn_gated_fixture(); + // Entered rendezvous: the gated fixture signals once the real worker's + // HTTP request arrives, proving context/pin validation already passed + // while storage was healthy. The guard releases and joins on all paths. + let (port, arrived_rx, release_tx, fixture) = spawn_gated_fixture(); + let mut holder = HoldingFixtureGuard { + release: Some(release_tx), + worker: Some(fixture), + }; let http = rustscript_vm::HttpConfig { allowed_schemes: vec!["http".to_string()], allowed_hosts: vec!["127.0.0.1".to_string()], @@ -2101,12 +2358,20 @@ async fn completed_terminal_with_message_is_retried_after_storage_recovers() { .expect("admission session id") .to_string(); + // The worker already passed validation once its HTTP request reaches + // the gated fixture; only then may the outage begin. Awaiting (instead + // of blocking) keeps the current-thread runtime polling the worker. + tokio::time::timeout(std::time::Duration::from_secs(15), arrived_rx) + .await + .expect("the worker must pass validation and reach the gated HTTP call") + .expect("fixture entered signal"); + // The worker is blocked inside the HTTP call: break storage now, then // release the fixture so the completed terminal commit fails durably. let broken = path.with_extension("db.broken"); std::fs::rename(&path, &broken).expect("move the db aside"); std::fs::create_dir(&path).expect("break storage with a directory"); - release.send(()).expect("release the fixture"); + holder.release(); let pending = wait_until(std::time::Duration::from_secs(10), || { service.pending_terminal_count() == 1 @@ -2189,7 +2454,7 @@ async fn completed_terminal_with_message_is_retried_after_storage_recovers() { Some(2), "the durable reload must keep both messages" ); - fixture.join().expect("fixture thread"); + holder.join(); let _ = std::fs::remove_file(&path); } @@ -3242,7 +3507,16 @@ async fn failed_replay_persist_keeps_the_original_idempotency_record() { // is down and leaves the durable idempotency record untouched. let (down_status, down_replay) = json_request_with_key(&app, json!({"input": "replay-durable"}), "durable-key").await; - assert_eq!(down_status, StatusCode::ACCEPTED); + assert_eq!( + down_status, + StatusCode::ACCEPTED, + "a read-only replay while persistence is down must stay 202, sanitized error code: {}", + down_replay + .get("error") + .and_then(|error| error.get("code")) + .and_then(Value::as_str) + .unwrap_or("missing_typed_code") + ); assert_eq!( down_replay["run_id"], run_id, "a read-only replay must return the original run while persistence is down" @@ -5919,3 +6193,226 @@ async fn session_messages_api_serializes_canonical_tool_call_blocks() { drop(state); let _ = std::fs::remove_file(&path); } + +/// 521 F-02 no-request cleanup: no connection ever arrives, so guard Drop +/// (release + join) must finish within the existing cleanup budget instead +/// of hanging in a blocking accept(). The watchdog only unblocks a legacy +/// blocking listener on the planned-RED path; it never intervenes on GREEN. +#[test] +fn gated_fixture_cleanup_without_request_is_bounded() { + let (port, mut arrived_rx, release_tx, fixture) = spawn_gated_fixture(); + let holder = HoldingFixtureGuard { + release: Some(release_tx), + worker: Some(fixture), + }; + let (done_tx, done_rx) = std::sync::mpsc::channel::<()>(); + std::thread::spawn(move || { + drop(holder); + let _ = done_tx.send(()); + }); + if done_rx + .recv_timeout(std::time::Duration::from_secs(10)) + .is_err() + { + // Planned-RED safety: unblock a legacy blocking accept so this run + // cannot hang or leak the fixture thread, then report the RED. + let _ = std::net::TcpStream::connect(("127.0.0.1", port)); + let _ = done_rx.recv_timeout(std::time::Duration::from_secs(10)); + panic!("gated fixture cleanup without a request must finish within the cleanup budget"); + } + assert!( + !matches!(arrived_rx.try_recv(), Ok(())), + "no request arrived, so the fixture must not signal entry" + ); +} + +/// 521 F-02 partial-header cleanup: a client that never completes its +/// headers must not pin the fixture in read_line(); release still ends the +/// guard within budget and no entry is signaled. +#[test] +fn gated_fixture_cleanup_after_partial_header_is_bounded() { + let (port, mut arrived_rx, release_tx, fixture) = spawn_gated_fixture(); + let mut client = + Some(std::net::TcpStream::connect(("127.0.0.1", port)).expect("fixture client")); + client + .as_mut() + .expect("client") + .write_all(b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n") + .expect("partial headers"); + let holder = HoldingFixtureGuard { + release: Some(release_tx), + worker: Some(fixture), + }; + let (done_tx, done_rx) = std::sync::mpsc::channel::<()>(); + std::thread::spawn(move || { + drop(holder); + let _ = done_tx.send(()); + }); + if done_rx + .recv_timeout(std::time::Duration::from_secs(10)) + .is_err() + { + // Planned-RED safety: EOF-unblock a legacy blocking header read + // (plus a dummy connect for a legacy blocking accept) so this run + // cannot hang or leak the fixture thread, then report the RED. + drop(client.take()); + let _ = std::net::TcpStream::connect(("127.0.0.1", port)); + let _ = done_rx.recv_timeout(std::time::Duration::from_secs(10)); + panic!( + "gated fixture cleanup after a partial header must finish within the cleanup budget" + ); + } + drop(client.take()); + assert!( + !matches!(arrived_rx.try_recv(), Ok(())), + "incomplete headers must not count as worker entry" + ); +} + +/// 524 F-02 continuous-input cleanup: a peer streaming bytes with no newline +/// must not pin one header-read call past release or the absolute deadline. +/// Guard Drop (release + join) still finishes within budget and no entry is +/// signaled. Readiness is proven by the fixture's real ReadReady stage ack +/// (accept done, first header read next), never by a settle sleep. +#[test] +fn gated_fixture_cleanup_during_continuous_no_newline_input_is_bounded() { + // One absolute watchdog for every wait below: the stage ack and the + // cleanup join share it instead of stacking per-phase timeouts. + let watchdog = std::time::Instant::now() + std::time::Duration::from_secs(10); + let remaining = || watchdog.saturating_duration_since(std::time::Instant::now()); + let (port, mut arrived_rx, release_tx, fixture, stages) = spawn_gated_fixture_with_stages(); + let client = std::net::TcpStream::connect(("127.0.0.1", port)).expect("fixture client"); + let peer = client.try_clone().expect("clone fixture client"); + peer.set_write_timeout(Some(std::time::Duration::from_secs(2))) + .expect("flood write timeout"); + let flooding = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)); + let keep_flooding = std::sync::Arc::clone(&flooding); + let mut flooder = Some(std::thread::spawn(move || { + let mut peer = peer; + let chunk = [b'x'; 1024]; + while keep_flooding.load(std::sync::atomic::Ordering::Relaxed) { + if peer.write_all(&chunk).is_err() { + break; + } + std::thread::sleep(std::time::Duration::from_millis(1)); + } + })); + // Stage A: the fixture has accepted this connection and its next + // operation is the first header read. Releasing before this ack could + // let the worker exit through the accept loop without ever exercising + // the header read (vacuous pass); a fixed settle sleep cannot prove + // otherwise, so the release waits for the real ack. + if stages.read_ready.recv_timeout(remaining()).is_err() { + flooding.store(false, std::sync::atomic::Ordering::Relaxed); + if let Some(flooder) = flooder.take() { + let _ = flooder.join(); + } + drop(client); + let _ = release_tx.send(()); + let _ = fixture.join(); + panic!("gated fixture must reach the header read during continuous input"); + } + let holder = HoldingFixtureGuard { + release: Some(release_tx), + worker: Some(fixture), + }; + let (done_tx, done_rx) = std::sync::mpsc::channel::<()>(); + std::thread::spawn(move || { + drop(holder); + let _ = done_tx.send(()); + }); + if done_rx.recv_timeout(remaining()).is_err() { + // Cleanup failure: stop the flood and EOF-unblock the header read + // (plus a dummy connect for a blocking accept) so this run cannot + // hang or leak the fixture thread, then report. + flooding.store(false, std::sync::atomic::Ordering::Relaxed); + if let Some(flooder) = flooder.take() { + let _ = flooder.join(); + } + drop(client); + let _ = std::net::TcpStream::connect(("127.0.0.1", port)); + let _ = done_rx.recv_timeout(remaining()); + panic!( + "gated fixture cleanup during continuous input must finish within the cleanup budget" + ); + } + flooding.store(false, std::sync::atomic::Ordering::Relaxed); + if let Some(flooder) = flooder.take() { + let _ = flooder.join(); + } + drop(client); + assert!( + !matches!(arrived_rx.try_recv(), Ok(())), + "continuous non-newline input must not count as worker entry" + ); +} + +/// 524 F-02 fragmented-header arrival: a valid request header split across an +/// idle gap longer than the fixture's 200ms read timeout must still complete +/// (partial bytes persist across timeouts) and signal real worker entry. The +/// gap is proven by real stage acks: PartialConsumed (first bytes actually +/// read into header state) then IdleTimeout (a real read timeout while those +/// bytes are present); only then is the terminating LF sent. +#[test] +fn gated_fixture_completes_header_fragmented_across_read_timeout() { + // One absolute watchdog for every wait below: both stage acks and the + // final arrival share it instead of stacking per-phase timeouts. + let watchdog = std::time::Instant::now() + std::time::Duration::from_secs(10); + let remaining = || watchdog.saturating_duration_since(std::time::Instant::now()); + let (port, mut arrived_rx, release_tx, fixture, stages) = spawn_gated_fixture_with_stages(); + let mut holder = HoldingFixtureGuard { + release: Some(release_tx), + worker: Some(fixture), + }; + let mut client = std::net::TcpStream::connect(("127.0.0.1", port)).expect("fixture client"); + // The terminator is split across the gap: the trailing "\r" alone can + // never complete a header line, so arrival proves the pre-gap bytes were + // retained across the read timeout instead of discarded. + client + .write_all(b"GET / HTTP/1.1\r\nHost: 127.0.0.1\r\n\r") + .expect("first fragment"); + // Stage B then stage C: the first fragment was actually consumed into + // header state, and a real read timeout elapsed while it was retained. + // Sending the final LF before both acks would let both fragments land + // in one read and pass without exercising the timeout path. + if stages.partial_consumed.recv_timeout(remaining()).is_err() { + drop(client); + let _ = std::net::TcpStream::connect(("127.0.0.1", port)); + holder.release(); + holder.join(); + panic!("the first header fragment must be consumed into header state"); + } + if stages.idle_timeout.recv_timeout(remaining()).is_err() { + drop(client); + let _ = std::net::TcpStream::connect(("127.0.0.1", port)); + holder.release(); + holder.join(); + panic!("a read timeout must elapse while the first fragment is retained"); + } + client.write_all(b"\n").expect("second fragment"); + let deadline = watchdog; + let mut arrived = false; + while std::time::Instant::now() < deadline { + match arrived_rx.try_recv() { + Ok(()) => { + arrived = true; + break; + } + Err(tokio::sync::oneshot::error::TryRecvError::Closed) => break, + Err(tokio::sync::oneshot::error::TryRecvError::Empty) => { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + } + } + if !arrived { + // Cleanup failure: EOF-unblock the header read (plus a dummy connect + // for a blocking accept), join before reporting so nothing leaks. + drop(client); + let _ = std::net::TcpStream::connect(("127.0.0.1", port)); + holder.release(); + holder.join(); + panic!("a header fragmented across a read timeout must still complete"); + } + drop(holder); + drop(client); +}