From c5a4da31e9a41e96568277a5b2d7d2228587ac72 Mon Sep 17 00:00:00 2001 From: fffonion Date: Sun, 18 Oct 2026 17:40:15 +0800 Subject: [PATCH 01/16] 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 4b28ded4a17a957ea00f403aeeea589896b26814 Mon Sep 17 00:00:00 2001 From: fffonion Date: Sun, 18 Oct 2026 21:14:44 +0800 Subject: [PATCH 02/16] 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 5c0fbe78c2dd4833cff9b2e040c085bdcb613923 Mon Sep 17 00:00:00 2001 From: fffonion Date: Sun, 18 Oct 2026 21:32:05 +0800 Subject: [PATCH 03/16] 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 2b39448c67bbbb7edb1cc3f0f8e67d32eae5f782 Mon Sep 17 00:00:00 2001 From: fffonion Date: Sun, 18 Oct 2026 23:45:50 +0800 Subject: [PATCH 04/16] 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 01dd56049921567af6ddd21537c73dc7ac9ca990 Mon Sep 17 00:00:00 2001 From: fffonion Date: Mon, 19 Oct 2026 05:41:06 +0800 Subject: [PATCH 05/16] 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 2b3d80be53b538e76115751aa3cd21f2d7a061f0 Mon Sep 17 00:00:00 2001 From: fffonion Date: Mon, 19 Oct 2026 07:18:31 +0800 Subject: [PATCH 06/16] 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 eb92d8cfb59d3a937381413655102941b80dde55 Mon Sep 17 00:00:00 2001 From: fffonion Date: Mon, 19 Oct 2026 08:42:35 +0800 Subject: [PATCH 07/16] 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 ce9ad25c95aa02092243f9959b51e7cea7345575 Mon Sep 17 00:00:00 2001 From: fffonion Date: Mon, 19 Oct 2026 09:17:24 +0800 Subject: [PATCH 08/16] 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 3250ef5928a571b6ae1687923e3d7aa9af691b4b Mon Sep 17 00:00:00 2001 From: fffonion Date: Mon, 19 Oct 2026 09:45:14 +0800 Subject: [PATCH 09/16] 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 07e8f22a6233788d82e96d84be6a833cf58c3cc4 Mon Sep 17 00:00:00 2001 From: fffonion Date: Mon, 19 Oct 2026 13:43:31 +0800 Subject: [PATCH 10/16] 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 2461971324d312fc5824758b718c9f3afb7993a0 Mon Sep 17 00:00:00 2001 From: fffonion Date: Mon, 19 Oct 2026 17:29:18 +0800 Subject: [PATCH 11/16] 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 7be6454d41c72003297fe15a742c759e1ca5ad33 Mon Sep 17 00:00:00 2001 From: fffonion Date: Mon, 19 Oct 2026 18:26:48 +0800 Subject: [PATCH 12/16] 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 299d60f1c25bdff71efceb03863842ffab021bf2 Mon Sep 17 00:00:00 2001 From: fffonion Date: Mon, 19 Oct 2026 19:03:33 +0800 Subject: [PATCH 13/16] 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 d37c8e0e0cf940793f1af83399d0dfabff71008c Mon Sep 17 00:00:00 2001 From: fffonion Date: Mon, 19 Oct 2026 19:56:57 +0800 Subject: [PATCH 14/16] 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 836a415420e1e341ac1c57bd1c3e4b443610b5e4 Mon Sep 17 00:00:00 2001 From: fffonion Date: Mon, 19 Oct 2026 20:16:46 +0800 Subject: [PATCH 15/16] 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 77653d574ebd4f0c451e443b5899fb5970f14cb6 Mon Sep 17 00:00:00 2001 From: fffonion Date: Mon, 19 Oct 2026 22:03:27 +0800 Subject: [PATCH 16/16] 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()); + } }