diff --git a/Cargo.lock b/Cargo.lock index 5bace4f..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", @@ -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..a0c25e6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,11 +25,12 @@ 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" +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/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 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 ``` 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..582592c 100644 --- a/rss/agent/main.rss +++ b/rss/agent/main.rss @@ -554,6 +554,60 @@ 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); + 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 +} + 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 +655,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 +666,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 +675,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 +703,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..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 { @@ -40,6 +41,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 +73,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 +165,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 +234,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 +257,40 @@ 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() != "" { + 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 { + 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 +313,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 +350,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 +395,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 +448,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 +504,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/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 3318dd2..5b18585 100644 --- a/rss/storage/main.rss +++ b/rss/storage/main.rss @@ -33,6 +33,8 @@ 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::effects as effects; use self::admission as admission; use self::load as load; @@ -515,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, 1) == 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" { @@ -643,6 +649,62 @@ 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" { + 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..458e53e 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)" @@ -106,14 +144,18 @@ fn index_statement(statement_index: int) -> string { } } +use self::effects as effects; + pub fn schema_migration_count() -> int { - 4 + 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 => { 4 } + if migration_index == 2 => { 3 } else => { + if migration_index == 3 => { 4 } else => { if migration_index == 4 => { 5 } else => { 6 } } + } } } } @@ -128,7 +170,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 => { + if migration_index == 4 => { "agent-storage-v5-plugin-control" } else => { "agent-storage-v6-managed-effects" } + } } } } @@ -144,7 +190,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 => { + if migration_index == 4 => { "rss-storage-v5-plugin-control" } else => { "rss-storage-v6-managed-effects" } + } } } } @@ -153,14 +203,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 => { if migration_index == 4 => { 6 } else => { effects::migration_count() } } + } } } } /// The schema version a fully migrated database reaches. pub fn schema_current_version() -> int { - 4 + 6 } fn table_migration_statement(statement_index: int) -> string { @@ -221,7 +273,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 => { + 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 fed0ed8..3e1b3ae 100644 --- a/rss/storage/sessions.rss +++ b/rss/storage/sessions.rss @@ -172,15 +172,31 @@ 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_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())], + 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/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/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/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/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/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/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/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() ); 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..449c367 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,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, + 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()), @@ -224,6 +247,10 @@ impl FilesystemCapability { limit: usize, ) -> Result { let _claims = self.authorize(token, CapabilityRisk::Read)?; + let _operation = self.enter_managed_operation( + &_claims, + crate::effects::authority::ManagedNativeOperation::FsRead, + )?; if limit == 0 { return Err(CapabilityError::new( "invalid_request", @@ -268,6 +295,10 @@ impl FilesystemCapability { limit: usize, ) -> Result { let _claims = self.authorize(token, CapabilityRisk::Read)?; + let _operation = self.enter_managed_operation( + &_claims, + crate::effects::authority::ManagedNativeOperation::FsRead, + )?; if limit == 0 { return Err(CapabilityError::new( "invalid_request", @@ -319,6 +350,10 @@ impl FilesystemCapability { bytes: &[u8], ) -> Result { let _claims = self.authorize(token, 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", @@ -338,7 +373,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 +413,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 +475,44 @@ impl FilesystemCapability { .clone() } + fn enter_managed_operation( + &self, + claims: &TokenClaims, + operation: crate::effects::authority::ManagedNativeOperation, + ) -> Result, CapabilityError> { + self.managed + .as_ref() + .map(|managed| { + managed + .upgrade() + .ok_or_else(|| { + CapabilityError::new("managed_call_unavailable", "managed call unavailable") + })? + .enter_operation(claims, operation) + .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 a06e0ad..567d4a9 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. @@ -704,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, @@ -991,7 +1030,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/capabilities/process.rs b/src/capabilities/process.rs index 69be16f..43e3cef 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,19 @@ impl ProcessCapability { stdin: Option<&[u8]>, ) -> Result { let claims = self.authorize(token, CapabilityRisk::Execute)?; + 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() && !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 +502,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 +602,10 @@ impl ProcessCapability { cursor: u64, limit: usize, ) -> Result { + let _operation = self.enter_managed_operation( + token, + crate::effects::authority::ManagedNativeOperation::OwnedProcessUse, + )?; if limit == 0 { return Err(CapabilityError::new( "invalid_request", @@ -471,6 +644,10 @@ impl ProcessCapability { handle: &str, timeout_ms: Option, ) -> Result { + 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 { @@ -575,6 +752,10 @@ impl ProcessCapability { cursor: u64, limit: usize, ) -> Result { + let _operation = self.enter_managed_operation( + token, + crate::effects::authority::ManagedNativeOperation::OwnedProcessUse, + )?; if limit == 0 { return Err(CapabilityError::new( "invalid_request", @@ -593,6 +774,10 @@ impl ProcessCapability { bytes: &[u8], timeout_ms: Option, ) -> Result { + 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( @@ -613,6 +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, + crate::effects::authority::ManagedNativeOperation::OwnedProcessUse, + )?; let owned = self.lookup(token, handle)?; match owned.handle.close_stdin() { Ok(()) @@ -624,6 +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, + crate::effects::authority::ManagedNativeOperation::OwnedProcessUse, + )?; let owned = self.lookup(token, handle)?; terminate_owned(&owned); Ok(()) @@ -676,6 +869,30 @@ impl ProcessCapability { self.inner.closing.load(Ordering::SeqCst) } + fn enter_managed_operation( + &self, + token: &str, + operation: crate::effects::authority::ManagedNativeOperation, + ) -> Result, CapabilityError> { + self.inner + .managed + .as_ref() + .map(|managed| { + let claims = self + .inner + .lifecycle + .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, operation) + .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 +904,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 e421ff5..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, } } } @@ -2441,7 +2508,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/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()); + } } 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 new file mode 100644 index 0000000..aec4800 --- /dev/null +++ b/src/effects/broker.rs @@ -0,0 +1,2050 @@ +//! 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, +}; +use super::{ + journal::*, + tree_store, + types::{EffectCancellation, EffectDeadline}, +}; +use crate::registry::ToolRegistrySnapshot; +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, + pub(crate) tree_sync: tree_store::DirectorySync, + 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, + &super::retention::StartupCensus::new(), + )?; + 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, + tree_sync: tree_store::DirectorySync::default(), + 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_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); + #[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, + cancellation: &RunCancellation, + ) -> 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, cancellation)? + .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, + 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 + || contract.trust != crate::plugins::PackageTrust::EmbeddingOwnedBuiltin + { + 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()); + } + 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, + &cancel, + )?; + 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, &cancel)? + .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, + policy_catalog, + registry_snapshot, + 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, + policy_catalog: Arc, + registry_snapshot: Arc, + 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 { + #[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(), + policy_catalog: Arc::clone(&self.policy_catalog), + registry_snapshot: Arc::clone(&self.registry_snapshot), + 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 + && 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 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, + 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(), + }, + &self.cancel, + )? + 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, &self.cancel)?; + 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, + 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()); + } + 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 + .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(), + }, + &self.cancel, + )? + .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 = policy.class().writable(); + 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), + policy, + 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) policy: Arc, + 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, &binding.cancel) + .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, + operation: ManagedNativeOperation, + ) -> Result { + if !self.policy.allows_operation(operation) { + return Err("managed primitive denied by captured class".into()); + } + self.authorize(claims, operation.required_risk())?; + let mut active = self.native_operations.lock(); + if self.closing.load(Ordering::Acquire) || *self.phase.lock() != CallPhase::Executing { + return Err("managed call closing".into()); + } + if *active >= 32 { + return Err("managed operation capacity exceeded".into()); + } + *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 != self.policy.expected_risk() + || 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()); + } + 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, + ); + 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)] + let observer = binding.runtime.native_ready_observer.lock().clone(); + #[cfg(test)] + if let Some(observer) = observer { + 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> { + 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.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 + || 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()); + } + 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 = self.policy.class().writable(); + 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: self.policy.class().external_effects(), + }; + 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/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/journal.rs b/src/effects/journal.rs new file mode 100644 index 0000000..2a69607 --- /dev/null +++ b/src/effects/journal.rs @@ -0,0 +1,761 @@ +//! 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_with_cancellation( + &self, + op: &str, + payload: &Value, + cancellation: &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()) + { + let validation = managed_document(text); + #[cfg(feature = "workspace-io-fault")] + let validation = validation.inspect_err(|error| { + eprintln!("managed document validation rejected {field}: {error}"); + }); + validation?; + } + } + self.persistence + .managed_effect_command_with_cancellation(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_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_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> { + 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) { + self.persistence.inject_persist_failure(); + } + pub(crate) fn object_references( + &self, + request: &ManagedObjectReferencesRequest, + cancellation: &crate::RunCancellation, + ) -> Result, String> { + self.persistence + .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_with_cancellation(request, cancellation) + .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()) +} + +#[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(); + } +} diff --git a/src/effects/managed_authority_tests.rs b/src/effects/managed_authority_tests.rs new file mode 100644 index 0000000..be9b1d2 --- /dev/null +++ b/src/effects/managed_authority_tests.rs @@ -0,0 +1,809 @@ +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, + ( + Arc::clone(&binding.policy_catalog), + Arc::clone(&binding.registry_snapshot) + ), + 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, + ( + Arc::clone(&binding.policy_catalog), + Arc::clone(&binding.registry_snapshot) + ), + 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, + ( + Arc::clone(&binding.policy_catalog), + Arc::clone(&binding.registry_snapshot), + ), + binding.cancel.clone(), + ) + .unwrap(); + pure.bind_admitted_limits( + FilesystemLimits::default(), + ProcessLimits::default(), + original.output_budget, + ) + .unwrap(); + assert!( + pure.seal_call_policy("write_file", CapabilityRisk::Write, "{}") + .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 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(); + 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() + }, + &call.binding().unwrap().cancel + ) + .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 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, + &[ + "/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()); + assert_eq!(spawns.load(Ordering::SeqCst), 0); + 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 new file mode 100644 index 0000000..435add3 --- /dev/null +++ b/src/effects/mod.rs @@ -0,0 +1,46 @@ +//! Native filesystem effect: private views, immutable trees, and checkpoints. +//! +//! 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; +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, + 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/retention.rs b/src/effects/retention.rs new file mode 100644 index 0000000..525bd68 --- /dev/null +++ b/src/effects/retention.rs @@ -0,0 +1,417 @@ +//! Native reservations and leases. No lease lock spans filesystem or SDK IO. +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}; + +#[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, + 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(all(test, feature = "workspace-io-fault"))] + 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, + census: &StartupCensus, + ) -> Result, String> { + let mut entries: BTreeMap = BTreeMap::new(); + let mut physical = 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"] { + 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 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() && prefix == "tree" { + let directory = root + .subroot(&format!("{namespace}/{id}"), root.limits()) + .map_err(|e| e.to_string())?; + let usage = tree_store::startup_census_usage_from_fd( + &directory + .duplicate_descriptor() + .map_err(|e| e.to_string())?, + &config.quota, + census, + ) + .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 { + 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) { + 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; + } 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/retention_tests.rs b/src/effects/retention_tests.rs new file mode 100644 index 0000000..eff6178 --- /dev/null +++ b/src/effects/retention_tests.rs @@ -0,0 +1,164 @@ +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 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(), + 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 new file mode 100644 index 0000000..523622f --- /dev/null +++ b/src/effects/tree_store.rs @@ -0,0 +1,1451 @@ +//! 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, +} + +#[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, + 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 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)?; + 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)?; + copy_tree_from_fd(&source_dir, dest, quota, cancellation, deadline, seal) +} + +/// 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 { + copy_tree_from_fd_with_sync( + source_dir, + dest, + quota, + cancellation, + deadline, + seal, + &DirectorySync::default(), + ) +} + +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()?; + 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, + directory_sync, + )?; + 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); + } + } +} + +// 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(), + inodes: std::collections::HashMap::new(), + entries_count: 0, + bytes: 0, + }; + 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")); + } + if *seen > *nlink { + return Err(inconsistent("hardlink count changed during scan")); + } + } + Ok(plan) +} + +fn scan_dir( + dir: &OwnedFd, + prefix: &str, + depth: u32, + quota: &ResourceQuota, + policy: &ScanPolicy<'_>, + plan: &mut Plan, +) -> Result<(), EffectError> { + 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"))?; + 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, policy, 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, + directory_sync: &DirectorySync, +) -> 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, directory_sync)?; + } + 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, + 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; + } + 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)?; + directory_sync.sync(&entry.rel, &created)?; + } + 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> { + 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())); + } + 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; + } + 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"))?; + 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") +} + +#[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/effects/types.rs b/src/effects/types.rs new file mode 100644 index 0000000..47e4a65 --- /dev/null +++ b/src/effects/types.rs @@ -0,0 +1,460 @@ +//! 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 { + native_check: Option bool + Send + Sync>>, + 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::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), + 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) + || self + .inner + .native_check + .as_ref() + .is_some_and(|check| check()) + { + 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/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..abbb877 --- /dev/null +++ b/src/gateway/managed_storage_budget_tests.rs @@ -0,0 +1,237 @@ +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}; + 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, + 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 3511a1c..a2ca591 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, @@ -368,29 +374,21 @@ 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), 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 +460,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 +502,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())?; @@ -531,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 ded2408..f638cd4 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,13 +94,27 @@ impl std::fmt::Display for StorageError { impl std::error::Error for StorageError {} +type AdmissionPrecommitHook = Box; + #[cfg(feature = "config-fixture")] struct ApprovalExpireHook { before: Box, 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, worker: StorageWorker, max_events: i64, broadcast_capacity: usize, @@ -100,7 +123,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)] @@ -118,6 +150,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 { @@ -125,6 +213,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 @@ -135,8 +225,186 @@ 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(); + } +} + +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(); + } +} + +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); } } @@ -145,6 +413,8 @@ struct StorageRequest { op: String, payload: Value, respond: Sender>, + cancellation: Option, + _managed_permit: Option, } /// The dedicated storage worker: owns the compiled RSS program and executes @@ -191,7 +461,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![ @@ -219,10 +499,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" @@ -232,17 +516,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) => { @@ -330,6 +640,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, @@ -375,11 +748,18 @@ 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)), + #[cfg(test)] + managed_request_hook: Mutex::new(None), worker: StorageWorker { sender, shutdown: shutdown_sender, @@ -395,7 +775,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)] @@ -417,25 +802,69 @@ 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 { + // 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, + op: &str, + payload: &Value, + parent: Option<&crate::RunCancellation>, + ) -> Result { + 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()) + { + 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() .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 @@ -445,6 +874,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 @@ -453,14 +888,33 @@ 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(), respond, + cancellation, + _managed_permit: managed_permit, }); 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 => { @@ -499,6 +953,423 @@ 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> { + 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)?; + 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_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| { + 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> { + 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)?; + 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_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)) + { + 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> { + 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_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)?; + 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_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_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)) + { + 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> { + 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)?; + 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 { + 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)? + .ok_or_else(|| crate::effects::journal::storage_error("managed head missing")) + } + + 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 +1379,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 +1450,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 +1567,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. @@ -689,11 +1589,191 @@ 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 } } + /// 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 +1951,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!(6)) { return Err(format!( "gateway schema migrated to an unexpected version: {migrated}" )); @@ -898,6 +1978,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 { @@ -1312,6 +2394,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" { @@ -1471,6 +2557,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::*; 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..86b352d --- /dev/null +++ b/src/plugins/mod.rs @@ -0,0 +1,43 @@ +//! 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 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 new file mode 100644 index 0000000..350f7bb --- /dev/null +++ b/src/plugins/pin.rs @@ -0,0 +1,634 @@ +//! 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 { + managed_policy_catalog: Option>, + managed_contract: Option, + 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 { + 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"))?, + 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 { + 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(), + 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, +} + +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() + } + 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, + 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..cd8861e --- /dev/null +++ b/src/plugins/prepare.rs @@ -0,0 +1,741 @@ +//! 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, + managed_contracts: BTreeMap, + managed_policy_catalogs: + 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() + } + + pub(crate) fn managed_contract( + &self, + package_id: &str, + ) -> Option { + 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, + 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(), + 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() + .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 + .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 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], +) -> 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 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); + 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..a75ce56 --- /dev/null +++ b/src/plugins/resolve.rs @@ -0,0 +1,885 @@ +//! 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(()) +} + +#[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, +} + +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( + 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, + }; + 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")); + } + 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) +} + +// 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, + }); + } + 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( + if host_mismatch { + PluginErrorKind::ContractIncompatible + } else { + PluginErrorKind::MissingDependency + }, + &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")); + } + 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(first_failure.expect("nonempty candidate list")); + } + 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(), + }); + } + 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..a7ca19e --- /dev/null +++ b/src/plugins/schema.rs @@ -0,0 +1,305 @@ +//! 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 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), + ("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), + ("managed-effect.schema.yaml", MANAGED_EFFECT_SCHEMA), + ("managed-workspace.schema.yaml", MANAGED_WORKSPACE_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), + "managed-effect.schema.json" => { + documents.insert("managed-effect.schema.json", canonical) + } + "managed-workspace.schema.json" => { + documents.insert("managed-workspace.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) +} + +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) + .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), + if name.starts_with("managed-") { + format!("{name} rejected at {}", errors[0].instance_path()) + } else { + 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", + "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", + } +} + +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..e865c78 --- /dev/null +++ b/src/plugins/store.rs @@ -0,0 +1,1280 @@ +//! 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, + } + } +} + +enum CapturedRevision { + Executable(InstalledPackage), + HistoricalBuiltin(PluginManifest), +} + +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); + // 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 Ok(revision); + } + self.write_artifact( + &artifact_id, + &files, + PackageTrust::EmbeddingOwnedBuiltin, + &manifest.publisher, + &payload, + )?; + Ok(PackageRevision { + artifact_id, + trust: PackageTrust::EmbeddingOwnedBuiltin, + manifest, + }) + } + + #[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"))?; + let mut packages = Vec::new(); + for name in list_dir(&self.artifacts)? { + if name.ends_with(".tmp") { + continue; + } + 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()); + 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 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) + } + + 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 { + 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 { + 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()?; + 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, + 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(CapturedRevision::Executable(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(()) +} + +#[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/plugins/yaml.rs b/src/plugins/yaml.rs new file mode 100644 index 0000000..b3dd72a --- /dev/null +++ b/src/plugins/yaml.rs @@ -0,0 +1,262 @@ +//! 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 { + 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, + 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", + )); + } + if !allow_nested_null { + 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..0b9aed8 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,223 @@ 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 { + pub(crate) managed: Option>, + managed_contract: Option, + managed_policy_catalog: Option>, + 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) managed_contract: Option, + pub(crate) managed_policy_catalog: + Option>, + 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 { + 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, + 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, + } + } + + #[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 + } + + 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 { + managed_call: Option>, + managed_policy: Option>, + 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(Clone, Default)] +enum PinnedCallOutcome { + #[default] + Pending, + Preparing, + Closing, + Executing { + token: String, + }, + Complete { + result: Arc, + }, + Failed { + error: JsonValue, + }, +} + +impl PinnedCallOutcome { + fn resolve( + &self, + child: &JsonValue, + publication: Option, + ) -> JsonValue { + match self { + Self::Complete { result } => { + match crate::capabilities::lifecycle::validate_canonical_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)) + } + } + } + Self::Failed { error } if error.get("ok") == Some(&JsonValue::Bool(false)) => { + pinned_tool_result(error) + } + Self::Pending | Self::Preparing | Self::Closing | Self::Executing { .. } + if child.get("ok") == Some(&JsonValue::Bool(false)) => + { + pinned_tool_result(child) + } + Self::Pending + | Self::Preparing + | Self::Closing + | 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 + && 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)) + }) + } +} + /// Injectable host bridges for one compiled runner. #[derive(Clone, Default)] pub struct AgentHostBridges { @@ -534,6 +798,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 +821,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 +850,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 { @@ -602,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( @@ -613,10 +959,57 @@ 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(); + } + 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, 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()) + }); + 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) @@ -626,10 +1019,332 @@ 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) = complete_result.as_ref() + { + PinnedCallOutcome::Complete { + result: Arc::clone(result), + } + } 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(); + } + 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 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) { + 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 { + managed_call: match pinned.managed.as_ref() { + Some(binding) => match binding.register( + &call_id, + &tool_name, + &canonical, + 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()), + ) { + Ok(call) => Some(call), + Err(message) => { + return pinned_outcome( + false, + "", + "", + "managed_effect_denied", + &message, + "", + ); + } + }, + None => None, + }, + managed_policy, + 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, + }; + let managed_call = grant.managed_call.clone(); + 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) => { + 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, ""), + } + } + + 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 { + 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 + || 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()); + }; + 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( @@ -652,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( @@ -663,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), @@ -682,7 +1454,63 @@ impl AgentHostState { "capability owner is not installed".to_string(), )); }; - let envelope = tool_commit(lifecycle, owner, token, result.clone()); + 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(); + } + 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") + && envelope.get("call_id").and_then(JsonValue::as_str) + == self + .expected_call + .as_ref() + .map(|grant| grant.call_id.as_str()) + && let Some(result) = complete_result.as_ref() + { + PinnedCallOutcome::Complete { + result: Arc::clone(result), + } + } else { + PinnedCallOutcome::Failed { + error: envelope.clone(), + } + }; + } let Some(mut lease) = self .leases .lock() @@ -697,6 +1525,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( @@ -711,7 +1567,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) { @@ -726,7 +1582,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) { @@ -736,7 +1592,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) { @@ -763,7 +1619,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) { @@ -788,7 +1644,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() { @@ -808,7 +1664,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) { @@ -818,7 +1674,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) { @@ -828,7 +1684,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) { @@ -844,7 +1700,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) { @@ -856,7 +1712,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) { @@ -866,7 +1722,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) { @@ -1275,6 +2131,268 @@ 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 { + 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") + .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() <= 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 +3175,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..471fe97 100644 --- a/src/runtime/host_types.rs +++ b/src/runtime/host_types.rs @@ -221,6 +221,59 @@ 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!( + 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", + [ + ("ok", HostTypeSchema::Bool), + ("text", HostTypeSchema::String), + ("content", HostTypeSchema::String), + ("kind", HostTypeSchema::String), + ("code", HostTypeSchema::String), + ("message", HostTypeSchema::String), + ("canonical_result", HostTypeSchema::String), + ( + "managed_publication", + optional(named::()) + ) + ] +); + 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..2318348 --- /dev/null +++ b/src/runtime/native_tools.rs @@ -0,0 +1,190 @@ +//! 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 { + managed_contract: None, + managed_policy_catalog: None, + 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..dee7be2 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; @@ -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())) @@ -933,6 +939,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 +1153,7 @@ impl AgentRunner { prepare_fault: RunnerPrepareFault::None, snapshot_digest, bundled_origin, + expected_call: None, }) } @@ -1456,6 +1464,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 +1661,103 @@ 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) }; +} + +/// 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( + 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 +1926,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 +2389,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 +2466,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 +2495,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| { @@ -2675,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 47f53bd..a0e90f6 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::{ @@ -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; @@ -261,6 +262,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 +393,8 @@ impl CapabilityHostState { processes: self.processes.clone(), artifacts: Some(Arc::clone(&self.artifacts)), control_hook: None, + pinned_tool: None, + allow_stateless_pin: false, } } } @@ -867,6 +872,7 @@ struct AgentServiceInner { pending: Mutex>, halting: AtomicBool, janitor_stop: Notify, + janitor_task: Mutex>>, store_generation: AtomicU64, metrics: Arc, file_search_entered: Mutex>>, @@ -919,6 +925,9 @@ struct AgentServiceInner { crash_after_provider_request: AtomicBool, crash_after_tool_commit: AtomicBool, provider_commit_crashed: AtomicBool, + tool_packages: Mutex>>, + managed_effects: RwLock>>, + stateless_tools: AtomicBool, } impl Drop for AgentServiceInner { @@ -958,6 +967,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, @@ -979,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), @@ -1014,8 +1025,12 @@ 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), + 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 } } @@ -1310,9 +1325,47 @@ 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())?; + // 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(()) + } + /// 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 +1448,334 @@ 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> { + 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( + 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(()) + } + + 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_with_cancellation(&request, &cancellation) + .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( + &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); + } + let current = store.install_embedding_owned().map_err(|error| { + AdmitError::Persistence(format!("embedding bootstrap failed: {error}")) + })?; + 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}"))) + } + + 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()?; @@ -1409,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 @@ -2164,6 +2553,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); @@ -2989,10 +3383,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,7 +3403,12 @@ impl AgentService { .clock(Arc::new(SystemClock)) .tokens(Arc::new(UuidIssuer)) .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), @@ -3014,7 +3420,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(), @@ -3034,7 +3458,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(), @@ -3392,21 +3816,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 +3886,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 +3899,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. @@ -3602,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 @@ -4637,7 +5083,81 @@ impl AgentService { AdmitError::Invalid(error.to_string()) })?; - let payload = json!({ + 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 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(), + ) + })?, + ( + 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)?, + ) + } else { + None + }; + let mut payload = json!({ "session_id": session_id, "session_new": if session_new { 1 } else { 0 }, "profile": ADMISSION_SESSION_PROFILE, @@ -4660,7 +5180,29 @@ 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(), }); + 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 @@ -4853,6 +5395,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 +5406,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 +5425,77 @@ 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 mut admitted_pin = admitted_pin.take().ok_or_else(|| { + AdmitError::Persistence( + "tool pin was not sealed from the admission receipt".to_string(), + ) + })?; + admitted_pin.managed = managed_binding.clone(); let mut store = self.inner.store.write(); if store.runs.contains_key(&run_id) && let Some(existing) = store.runs.get(&run_id) { + self.restore_pinned_tool(&run_id)?; return Ok(AdmittedRun { run_id: run_id.clone(), session_id: existing.session_id.clone(), @@ -4930,6 +5524,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 +5632,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 +5685,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 +5813,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 { @@ -5369,6 +5967,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 @@ -5492,11 +6119,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 +6276,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,7 +7791,9 @@ impl CancellationFlag for HandleCancelFlag { } struct ServiceDurableLifecycle { + store: Arc>, events: Arc, + managed: Option>, } impl DurableToolLifecycle for ServiceDurableLifecycle { @@ -7176,12 +7817,61 @@ 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, 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!({})), @@ -7192,6 +7882,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, @@ -7215,6 +7919,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" @@ -7252,6 +7971,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 @@ -7278,7 +8006,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, _ => { @@ -7360,6 +8088,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() @@ -7404,6 +8144,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) } @@ -8016,7 +8765,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| { @@ -8629,7 +9378,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); @@ -8678,7 +9427,7 @@ fn spawn_lifecycle_janitor(inner: Arc) { .retain(|run_id, _| !expired_run_ids.contains(run_id)); } } - }); + }) } #[cfg(test)] @@ -10974,6 +11723,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/src/service/managed_effects.rs b/src/service/managed_effects.rs new file mode 100644 index 0000000..ecd9dfb --- /dev/null +++ b/src/service/managed_effects.rs @@ -0,0 +1,643 @@ +//! 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, + RunCancellation, + ), + 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")?, + cancellation, + )) +} +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, 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_with_cancellation(&request, &cancellation) + .map_err(|e| e.to_string())?; + runtime.authorize_workspace_query( + &authority, + ADMISSION_SESSION_PROFILE, + &request.workspace_id, + &cancellation, + )?; + 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, cancellation) = query_context(service)?; + let record = service + .inner + .persistence + .as_ref() + .ok_or("managed query unavailable")? + .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 { + 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 { + 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); + }; + 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(), + }, + &binding.cancel, + ) + .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..2e3bfc8 --- /dev/null +++ b/src/service/managed_effects_tests.rs @@ -0,0 +1,480 @@ +//! 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}; +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..a5aaaa2 --- /dev/null +++ b/src/service/managed_protocol_tests.rs @@ -0,0 +1,635 @@ +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"}"#, + binding + .seal_call_policy( + "read_file", + crate::capabilities::CapabilityRisk::Read, + r#"{"path":"never-executed.txt"}"#, + ) + .unwrap(), + 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/src/service/managed_review_tests.rs b/src/service/managed_review_tests.rs new file mode 100644 index 0000000..9be030f --- /dev/null +++ b/src/service/managed_review_tests.rs @@ -0,0 +1,644 @@ +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 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(( + 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); + } +} + +#[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, + 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" + ); + } + } +} 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/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" + ); } } 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_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..21ef2dc --- /dev/null +++ b/tests/effect_commit_recovery/startup_matrix.rs @@ -0,0 +1,274 @@ +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() +{ + 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()); + 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(); + 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); + configure_gateway_child_scratch(&mut command, |key| std::env::var_os(key)); + 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(); +} 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/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 +} diff --git a/tests/plugin_graph_tests.rs b/tests/plugin_graph_tests.rs new file mode 100644 index 0000000..f30a5f9 --- /dev/null +++ b/tests/plugin_graph_tests.rs @@ -0,0 +1,1350 @@ +//! 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 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( + "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, + ); + } +} + +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(); + 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/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()); +} 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(); +}