From 498d7abb88d408ebca2c4b298d7c5a4ffc4b2e1f Mon Sep 17 00:00:00 2001 From: mizuki0x <197570892+mizuki0x@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:49:29 +0200 Subject: [PATCH 1/4] feat(wurk): agent-to-human x402 connector (create/view/choose-winners) --- agent-os/Cargo.lock | 17 + agent-os/Cargo.toml | 1 + agent-os/crates/covenant-wurk/Cargo.toml | 23 + .../covenant-wurk/examples/live_create.rs | 118 ++++ agent-os/crates/covenant-wurk/src/config.rs | 93 ++++ agent-os/crates/covenant-wurk/src/lib.rs | 41 ++ agent-os/crates/covenant-wurk/src/tools.rs | 510 ++++++++++++++++++ agent-os/crates/covenant-wurk/src/view.rs | 110 ++++ agent-os/crates/covenant-wurk/src/x402.rs | 509 +++++++++++++++++ 9 files changed, 1422 insertions(+) create mode 100644 agent-os/crates/covenant-wurk/Cargo.toml create mode 100644 agent-os/crates/covenant-wurk/examples/live_create.rs create mode 100644 agent-os/crates/covenant-wurk/src/config.rs create mode 100644 agent-os/crates/covenant-wurk/src/lib.rs create mode 100644 agent-os/crates/covenant-wurk/src/tools.rs create mode 100644 agent-os/crates/covenant-wurk/src/view.rs create mode 100644 agent-os/crates/covenant-wurk/src/x402.rs diff --git a/agent-os/Cargo.lock b/agent-os/Cargo.lock index c9e94e4c1..77029e3ca 100644 --- a/agent-os/Cargo.lock +++ b/agent-os/Cargo.lock @@ -1502,6 +1502,23 @@ dependencies = [ "uuid", ] +[[package]] +name = "covenant-wurk" +version = "0.0.0" +dependencies = [ + "async-trait", + "base64 0.22.1", + "covenant-mcp", + "covenant-x402", + "reqwest 0.12.28", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tracing", + "wiremock", +] + [[package]] name = "covenant-x402" version = "0.0.0" diff --git a/agent-os/Cargo.toml b/agent-os/Cargo.toml index e3be58a1a..58c953f44 100644 --- a/agent-os/Cargo.toml +++ b/agent-os/Cargo.toml @@ -19,6 +19,7 @@ members = [ "crates/covenant-budget", "crates/covenant-x402", "crates/covenant-hyre", + "crates/covenant-wurk", "crates/covenant-zauth", "crates/covenant-metaplex", "crates/covenant-sap-bridge", diff --git a/agent-os/crates/covenant-wurk/Cargo.toml b/agent-os/crates/covenant-wurk/Cargo.toml new file mode 100644 index 000000000..4580163ce --- /dev/null +++ b/agent-os/crates/covenant-wurk/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "covenant-wurk" +version = "0.0.0" +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +description = "WURK agent-to-human x402 provider profile for Covenant — paid microjob creation, free submission view, and creator-mode winner selection. Agent-to-human family only." + +[dependencies] +covenant-x402 = { path = "../covenant-x402" } +covenant-mcp = { path = "../covenant-mcp" } +async-trait = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tracing = { workspace = true } +reqwest = { workspace = true } +base64 = "0.22" + +[dev-dependencies] +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +wiremock = "0.6" diff --git a/agent-os/crates/covenant-wurk/examples/live_create.rs b/agent-os/crates/covenant-wurk/examples/live_create.rs new file mode 100644 index 000000000..77fa4d6e8 --- /dev/null +++ b/agent-os/crates/covenant-wurk/examples/live_create.rs @@ -0,0 +1,118 @@ +//! Live WURK agent-to-human create, end to end through covenant-wurk. +//! +//! Drives the real `execute_paid` 402-then-pay loop against WURK's +//! production endpoint, signing with the funding key via the +//! covenant-x402-signer sidecar (so no Solana deps enter this crate). +//! +//! Usage: +//! WURK_SIGNER_BIN=.../target/release/covenant-x402-signer \ +//! WURK_KEYPAIR=~/.config/solana/solana-id.json \ +//! WURK_RPC=https://api.mainnet-beta.solana.com \ +//! cargo run -p covenant-wurk --example live_create -- "" + +use std::io::Write; +use std::process::{Command, Stdio}; + +use async_trait::async_trait; +use covenant_wurk::{config, execute_paid, PaidRequest}; +use covenant_x402::{PaymentRequirements, Signer}; + +/// Signs by piping the requirements to the standalone signer sidecar. +struct SubprocessSigner { + bin: String, + keypair: String, + rpc: String, +} + +#[async_trait] +impl Signer for SubprocessSigner { + async fn build_payment(&self, requirements: &PaymentRequirements) -> covenant_x402::Result { + let json = serde_json::to_string(requirements) + .map_err(|e| covenant_x402::X402Error::Sign(e.to_string()))?; + let mut child = Command::new(&self.bin) + .env("COVENANT_X402_FUNDING_KEYPAIR", &self.keypair) + .env("COVENANT_X402_RPC_URL", &self.rpc) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .map_err(|e| covenant_x402::X402Error::Sign(format!("spawn signer: {e}")))?; + { + let mut stdin = child.stdin.take().expect("piped stdin"); + stdin + .write_all(json.as_bytes()) + .map_err(|e| covenant_x402::X402Error::Sign(format!("write signer stdin: {e}")))?; + } + let out = child + .wait_with_output() + .map_err(|e| covenant_x402::X402Error::Sign(format!("wait signer: {e}")))?; + if !out.status.success() { + return Err(covenant_x402::X402Error::Sign(format!( + "signer exited {:?}", + out.status.code() + ))); + } + Ok(String::from_utf8_lossy(&out.stdout).trim().to_string()) + } +} + +fn encode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char), + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} + +#[tokio::main] +async fn main() { + let bin = std::env::var("WURK_SIGNER_BIN").expect("set WURK_SIGNER_BIN"); + let keypair = std::env::var("WURK_KEYPAIR").expect("set WURK_KEYPAIR"); + let rpc = std::env::var("WURK_RPC").unwrap_or_else(|_| "https://api.mainnet-beta.solana.com".into()); + + let args: Vec = std::env::args().collect(); + let description = args + .get(1) + .cloned() + .unwrap_or_else(|| "Covenant x402 connectivity test. Please reply: ok".into()); + let winners: u64 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(1); + let per_user: f64 = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(0.01); + let cap = (winners as f64 * per_user * 1_000_000.0).round() as u128; + + let url = format!( + "{}/solana/agenttohuman?description={}&winners={}&perUser={}", + config::BASE_URL, + encode(&description), + winners, + per_user + ); + eprintln!("create: {url}\ncap atomic: {cap}"); + + let plan = PaidRequest { + provider: "wurk".into(), + slug: "agenttohuman".into(), + url, + method: "GET".into(), + body: None, + network: config::SOLANA_NETWORK.into(), + asset: config::USDC_MINT.into(), + per_call_cap: cap, + }; + + let signer = SubprocessSigner { bin, keypair, rpc }; + let http = reqwest::Client::new(); + match execute_paid(&http, &signer, &plan).await { + Ok(r) => { + println!("status: {}", r.status); + println!("paid_amount: {:?}", r.paid_amount); + println!("body: {}", r.body); + } + Err(e) => { + eprintln!("ERROR: {e}"); + std::process::exit(1); + } + } +} diff --git a/agent-os/crates/covenant-wurk/src/config.rs b/agent-os/crates/covenant-wurk/src/config.rs new file mode 100644 index 000000000..873ac82e7 --- /dev/null +++ b/agent-os/crates/covenant-wurk/src/config.rs @@ -0,0 +1,93 @@ +//! WURK provider configuration. Agent-to-human family only. +//! +//! Constants are pinned from WURK's live 402 challenge and agent-card so +//! a manipulated challenge can't steer the funding key to another payee +//! or sponsor (enforced in [`crate::x402::to_requirements`]). + +use serde::{Deserialize, Serialize}; + +/// CAIP-2 id for WURK's Solana settlement (mainnet), from the live 402. +pub const SOLANA_NETWORK: &str = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp"; +/// USDC mint payments are denominated in. +pub const USDC_MINT: &str = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; +/// WURK's Solana payee (holds job funds until payout), from the live 402. +pub const PAY_TO: &str = "SAT8g2xU7AFy7eUmNJ9SNrM6yYo7LDCi13GXJ8Ez9kC"; +/// PayAI sponsor pubkey co-signing as `feePayer` (same facilitator Hyre uses). +pub const FEE_PAYER: &str = "2wKupLR9q6wXYppw8Gr2NvWxKBUqm4PPJKkQfoxHDBg4"; +/// API host. +pub const BASE_URL: &str = "https://wurkapi.fun"; + +/// The ONLY endpoint families this crate may touch. The engagement, +/// raid, and vote endpoints are deliberately excluded — covenant-wurk +/// cannot construct a request against them. +pub const ALLOWED_PATHS: &[&str] = &["agenttohuman", "agenttohumanadvanced"]; + +/// Whether `path_segment` is in the agent-to-human allowlist. +pub fn is_allowed(path_segment: &str) -> bool { + ALLOWED_PATHS.contains(&path_segment) +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct WurkConfig { + /// Master switch. `false` means no WURK tools are registered. + #[serde(default)] + pub enabled: bool, + /// API host. Endpoint URLs are built against this. + #[serde(default = "default_base_url")] + pub base_url: String, + /// CAIP-2 settlement network. + #[serde(default = "default_network")] + pub network: String, + /// Payment asset (USDC mint). + #[serde(default = "default_asset")] + pub asset: String, + /// Per-call ceiling in atomic USDC. A 402 amount above this is + /// rejected before the signer runs. `0` defers to the caller-supplied + /// cap (winners * perUser). + #[serde(default)] + pub per_call_cap: u128, +} + +fn default_base_url() -> String { + BASE_URL.to_string() +} +fn default_network() -> String { + SOLANA_NETWORK.to_string() +} +fn default_asset() -> String { + USDC_MINT.to_string() +} + +impl Default for WurkConfig { + fn default() -> Self { + Self { + enabled: false, + base_url: default_base_url(), + network: default_network(), + asset: default_asset(), + per_call_cap: 0, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_is_disabled_with_solana_rail() { + let c = WurkConfig::default(); + assert!(!c.enabled); + assert_eq!(c.network, SOLANA_NETWORK); + assert_eq!(c.asset, USDC_MINT); + } + + #[test] + fn only_agent_to_human_paths_are_allowed() { + assert!(is_allowed("agenttohuman")); + assert!(is_allowed("agenttohumanadvanced")); + for blocked in ["xraid", "xlikes", "xfollowers", "dex", "cmcvote", "cgvote", "tgmembers"] { + assert!(!is_allowed(blocked), "{blocked} must be blocked"); + } + } +} diff --git a/agent-os/crates/covenant-wurk/src/lib.rs b/agent-os/crates/covenant-wurk/src/lib.rs new file mode 100644 index 000000000..590319124 --- /dev/null +++ b/agent-os/crates/covenant-wurk/src/lib.rs @@ -0,0 +1,41 @@ +//! WURK agent-to-human provider profile for Covenant. +//! +//! WURK (wurk.fun) is an x402 marketplace. This crate wires ONLY its +//! agent-to-human microjob family: create a human-review job, read the +//! submissions, and pick winners. The social-engagement/vote endpoints +//! are never reachable through here (see [`config::ALLOWED_PATHS`]). +//! +//! Payment reuses covenant-x402's [`covenant_x402::Signer`] (the +//! funding-key sidecar) and [`covenant_x402::PaymentRequirements`]. +//! WURK's 402 is the object/`accepts` shape with `amount` + +//! `extra.feePayer`, but the payment header is **`PAYMENT-SIGNATURE`** +//! (not x402's `x-payment`), so this crate runs its own 402-then-pay +//! loop in [`x402`]. View and winner-selection are free (secret-authed) +//! and need no signer. + +pub mod config; +pub mod tools; +pub mod view; +pub mod x402; + +pub use config::WurkConfig; +pub use tools::{wurk_specs, wurk_tools, PaidRequest, PaidResponse, WurkExecutor}; +pub use view::{parse_view, JobView, Submission}; +pub use x402::{execute_paid, parse_challenge, PaidHttp}; + +/// Errors raised across the WURK profile. +#[derive(Debug, thiserror::Error)] +pub enum WurkError { + #[error("challenge: {0}")] + Challenge(String), + #[error("not allowed: {0}")] + NotAllowed(String), + #[error("execute: {0}")] + Execute(String), + #[error("view: {0}")] + View(String), + #[error(transparent)] + Http(#[from] reqwest::Error), +} + +pub type Result = std::result::Result; diff --git a/agent-os/crates/covenant-wurk/src/tools.rs b/agent-os/crates/covenant-wurk/src/tools.rs new file mode 100644 index 000000000..60d041522 --- /dev/null +++ b/agent-os/crates/covenant-wurk/src/tools.rs @@ -0,0 +1,510 @@ +//! MCP tools for the WURK agent-to-human family. +//! +//! Three tools: `wurk.hire_humans` (paid create via the x402 +//! PAYMENT-SIGNATURE loop), `wurk.job_status` (free view), and +//! `wurk.choose_winners` (free creator-mode selection). A tool never +//! signs or holds keys: it marshals args and calls a [`WurkExecutor`] +//! the daemon supplies (x402 client + signer sidecar + accounting). +//! +//! Only the agent-to-human endpoint families are constructible here. + +use std::sync::Arc; + +use async_trait::async_trait; +use covenant_mcp::{Content, Tool, ToolCallResult, ToolError, ToolSpec}; +use serde_json::{Map, Value}; + +use crate::config::WurkConfig; + +/// Provider tag for audit. +pub const PROVIDER: &str = "wurk"; + +/// A resolved paid create call the daemon executes and accounts for. +#[derive(Debug, Clone, PartialEq)] +pub struct PaidRequest { + pub provider: String, + /// Endpoint slug for audit (`agenttohuman` / `agenttohumanadvanced`). + pub slug: String, + /// Fully resolved URL with the query string appended. + pub url: String, + pub method: String, + pub body: Option, + /// CAIP-2 settlement network. + pub network: String, + /// Payment asset (USDC mint). + pub asset: String, + /// Per-call ceiling in atomic USDC (winners * perUser). + pub per_call_cap: u128, +} + +/// Outcome of a paid create call. +#[derive(Debug, Clone, PartialEq)] +pub struct PaidResponse { + pub status: u16, + pub body: Value, + pub receipt_id: Option, +} + +/// The daemon's seam: paid create + free reads. Implemented over the +/// x402 loop + signer sidecar; mocked in this crate's tests. +#[async_trait] +pub trait WurkExecutor: Send + Sync { + /// Create a paid agent-to-human job (runs the PAYMENT-SIGNATURE loop). + async fn create(&self, req: PaidRequest) -> std::result::Result; + /// View submissions for a job (free, secret-authed). + async fn view(&self, secret: &str, page: u32) -> std::result::Result; + /// Pick winners by submission id (free, creator mode, secret-authed). + async fn choose_winners( + &self, + secret: &str, + submission_ids: Vec, + ) -> std::result::Result; +} + +struct HireHumansTool { + base_url: String, + network: String, + asset: String, + cap_override: u128, + executor: Arc, +} + +#[async_trait] +impl Tool for HireHumansTool { + fn name(&self) -> &str { + "wurk.hire_humans" + } + fn description(&self) -> &str { + "Hire humans to review an agent's work via WURK (agent-to-human microjob, paid per winner in USDC over x402). Best-effort, custodial (WURK holds funds and pays workers). Use for non-code human judgment: content, instructions, faithfulness." + } + fn input_schema(&self) -> Value { + serde_json::json!({ + "type": "object", + "properties": { + "description": { "type": "string", "description": "The task shown to human workers." }, + "winners": { "type": "integer", "description": "Number of paid replies sought (default 10)." }, + "per_user_usdc": { "type": "number", "description": "USDC paid per winner (default 0.05, min 0.01)." }, + "advanced": { "type": "boolean", "description": "Use agenttohumanadvanced (enables the fields below)." }, + "selection_type": { "type": "string", "enum": ["creator", "random"], "description": "Winner selection mode (advanced)." }, + "selection_time_minutes": { "type": "integer", "description": "Submission window, 60-4320 (advanced)." }, + "human_verified": { "type": "boolean", "description": "Require VeryAI palm verification (advanced)." }, + "community": { "type": "string", "description": "Restrict to a WURK community (e.g. the Covenant pool)." } + }, + "required": ["description"], + "additionalProperties": false + }) + } + + async fn call(&self, arguments: Value) -> Result { + let args = as_object(&arguments)?; + let description = req_str(args, "description")?; + let winners = args.get("winners").and_then(Value::as_u64).unwrap_or(10).max(1); + let per_user = args + .get("per_user_usdc") + .and_then(Value::as_f64) + .unwrap_or(0.05) + .max(0.01); + let advanced = args.get("advanced").and_then(Value::as_bool).unwrap_or(false) + || args.contains_key("selection_type") + || args.contains_key("selection_time_minutes") + || args.contains_key("human_verified"); + + let slug = if advanced { + "agenttohumanadvanced" + } else { + "agenttohuman" + }; + let mut query: Vec<(String, String)> = vec![ + ("description".into(), description), + ("winners".into(), winners.to_string()), + ("perUser".into(), trim_num(per_user)), + ]; + if advanced { + if let Some(v) = args.get("selection_type").and_then(Value::as_str) { + query.push(("selectionType".into(), v.to_string())); + } + if let Some(v) = args.get("selection_time_minutes").and_then(Value::as_u64) { + query.push(("selectionTimeMinutes".into(), v.to_string())); + } + if args.get("human_verified").and_then(Value::as_bool) == Some(true) { + query.push(("human_verified".into(), "yes".into())); + } + } + if let Some(v) = args.get("community").and_then(Value::as_str) { + query.push(("community".into(), v.to_string())); + } + + let url = format!( + "{}/solana/{slug}?{}", + self.base_url.trim_end_matches('/'), + query + .iter() + .map(|(k, v)| format!("{}={}", encode(k), encode(v))) + .collect::>() + .join("&") + ); + + let computed_cap = (winners as f64 * per_user * 1_000_000.0).round() as u128; + let per_call_cap = if self.cap_override > 0 { + self.cap_override.min(computed_cap.max(1)) + } else { + computed_cap.max(1) + }; + + let req = PaidRequest { + provider: PROVIDER.into(), + slug: slug.into(), + url, + method: "GET".into(), + body: None, + network: self.network.clone(), + asset: self.asset.clone(), + per_call_cap, + }; + + match self.executor.create(req).await { + Ok(resp) if (200..300).contains(&resp.status) => { + let mut content = vec![Content::json(resp.body)]; + if let Some(rid) = resp.receipt_id { + content.push(Content::text(format!("x402 settled; receipt {rid}"))); + } + Ok(ToolCallResult::ok(content)) + } + Ok(resp) => Ok(ToolCallResult::error(format!( + "wurk hire_humans returned status {}", + resp.status + ))), + Err(e) => Ok(ToolCallResult::error(format!("wurk create failed: {e}"))), + } + } +} + +struct JobStatusTool { + executor: Arc, +} + +#[async_trait] +impl Tool for JobStatusTool { + fn name(&self) -> &str { + "wurk.job_status" + } + fn description(&self) -> &str { + "Read submissions for a WURK job (free, by job secret). Returns the submissions array for grading." + } + fn input_schema(&self) -> Value { + serde_json::json!({ + "type": "object", + "properties": { + "secret": { "type": "string", "description": "The job secret returned at creation." }, + "page": { "type": "integer", "description": "Page number (default 1)." } + }, + "required": ["secret"], + "additionalProperties": false + }) + } + + async fn call(&self, arguments: Value) -> Result { + let args = as_object(&arguments)?; + let secret = req_str(args, "secret")?; + let page = args.get("page").and_then(Value::as_u64).unwrap_or(1) as u32; + match self.executor.view(&secret, page).await { + Ok(body) => Ok(ToolCallResult::ok(vec![Content::json(body)])), + Err(e) => Ok(ToolCallResult::error(format!("wurk view failed: {e}"))), + } + } +} + +struct ChooseWinnersTool { + executor: Arc, +} + +#[async_trait] +impl Tool for ChooseWinnersTool { + fn name(&self) -> &str { + "wurk.choose_winners" + } + fn description(&self) -> &str { + "Select winners for a WURK job by submission id (free, creator mode, by job secret)." + } + fn input_schema(&self) -> Value { + serde_json::json!({ + "type": "object", + "properties": { + "secret": { "type": "string", "description": "The job secret." }, + "submission_ids": { + "type": "array", + "items": { "type": "string" }, + "description": "Submission short ids to mark as winners." + } + }, + "required": ["secret", "submission_ids"], + "additionalProperties": false + }) + } + + async fn call(&self, arguments: Value) -> Result { + let args = as_object(&arguments)?; + let secret = req_str(args, "secret")?; + let ids: Vec = args + .get("submission_ids") + .and_then(Value::as_array) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + if ids.is_empty() { + return Err(ToolError::InvalidArguments( + "submission_ids must be a non-empty array of strings".into(), + )); + } + match self.executor.choose_winners(&secret, ids).await { + Ok(body) => Ok(ToolCallResult::ok(vec![Content::json(body)])), + Err(e) => Ok(ToolCallResult::error(format!( + "wurk choose_winners failed: {e}" + ))), + } + } +} + +/// Build the WURK tool set wired to `executor`. +pub fn wurk_tools(config: &WurkConfig, executor: Arc) -> Vec> { + if !config.enabled { + return Vec::new(); + } + vec![ + Arc::new(HireHumansTool { + base_url: config.base_url.clone(), + network: config.network.clone(), + asset: config.asset.clone(), + cap_override: config.per_call_cap, + executor: executor.clone(), + }), + Arc::new(JobStatusTool { + executor: executor.clone(), + }), + Arc::new(ChooseWinnersTool { executor }), + ] +} + +/// Tool specs for discovery without binding a payer. +pub fn wurk_specs(config: &WurkConfig) -> Vec { + let mut cfg = config.clone(); + cfg.enabled = true; + wurk_tools(&cfg, Arc::new(SpecOnly)) + .iter() + .map(|t| t.spec()) + .collect() +} + +struct SpecOnly; + +#[async_trait] +impl WurkExecutor for SpecOnly { + async fn create(&self, _req: PaidRequest) -> std::result::Result { + Err("spec-only executor cannot make calls".into()) + } + async fn view(&self, _secret: &str, _page: u32) -> std::result::Result { + Err("spec-only executor cannot make calls".into()) + } + async fn choose_winners( + &self, + _secret: &str, + _ids: Vec, + ) -> std::result::Result { + Err("spec-only executor cannot make calls".into()) + } +} + +fn as_object(arguments: &Value) -> Result<&Map, ToolError> { + match arguments { + Value::Object(m) => Ok(m), + Value::Null => { + Err(ToolError::InvalidArguments("arguments must be a JSON object".into())) + } + _ => Err(ToolError::InvalidArguments( + "arguments must be a JSON object".into(), + )), + } +} + +fn req_str(args: &Map, key: &str) -> Result { + args.get(key) + .and_then(Value::as_str) + .filter(|s| !s.is_empty()) + .map(String::from) + .ok_or_else(|| ToolError::InvalidArguments(format!("missing string {key:?}"))) +} + +/// Format a USDC amount without a trailing `.0` so `0.05` stays `0.05`. +fn trim_num(n: f64) -> String { + let s = format!("{n}"); + s +} + +/// Percent-encode everything outside the unreserved set. +fn encode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Mutex; + + #[derive(Default)] + struct MockExecutor { + last_create: Mutex>, + last_view: Mutex>, + last_choose: Mutex)>>, + } + + #[async_trait] + impl WurkExecutor for MockExecutor { + async fn create(&self, req: PaidRequest) -> Result { + *self.last_create.lock().unwrap() = Some(req); + Ok(PaidResponse { + status: 200, + body: serde_json::json!({ "ok": true, "jobId": "j1", "secret": "s1" }), + receipt_id: Some("rcpt-1".into()), + }) + } + async fn view(&self, secret: &str, page: u32) -> Result { + *self.last_view.lock().unwrap() = Some((secret.into(), page)); + Ok(serde_json::json!({ "ok": true, "submissions": [] })) + } + async fn choose_winners(&self, secret: &str, ids: Vec) -> Result { + *self.last_choose.lock().unwrap() = Some((secret.into(), ids)); + Ok(serde_json::json!({ "ok": true })) + } + } + + fn enabled_config() -> WurkConfig { + WurkConfig { + enabled: true, + ..Default::default() + } + } + + fn find<'a>(tools: &'a [Arc], name: &str) -> &'a Arc { + tools.iter().find(|t| t.name() == name).expect("tool present") + } + + #[test] + fn disabled_config_registers_no_tools() { + assert!(wurk_tools(&WurkConfig::default(), Arc::new(MockExecutor::default())).is_empty()); + } + + #[test] + fn enabled_config_registers_three_tools() { + let tools = wurk_tools(&enabled_config(), Arc::new(MockExecutor::default())); + assert_eq!(tools.len(), 3); + for n in ["wurk.hire_humans", "wurk.job_status", "wurk.choose_winners"] { + assert!(tools.iter().any(|t| t.name() == n), "missing {n}"); + } + } + + #[tokio::test] + async fn hire_builds_url_and_cap() { + let exec = Arc::new(MockExecutor::default()); + let tools = wurk_tools(&enabled_config(), exec.clone()); + let res = find(&tools, "wurk.hire_humans") + .call(serde_json::json!({ "description": "judge this", "winners": 10, "per_user_usdc": 0.05 })) + .await + .unwrap(); + assert!(!res.is_error); + let req = exec.last_create.lock().unwrap().clone().unwrap(); + assert_eq!(req.method, "GET"); + assert_eq!(req.slug, "agenttohuman"); + assert!(req.url.starts_with("https://wurkapi.fun/solana/agenttohuman?")); + assert!(req.url.contains("description=judge%20this")); + assert!(req.url.contains("winners=10")); + assert!(req.url.contains("perUser=0.05")); + // 10 * 0.05 USDC = $0.50 = 500000 atomic. + assert_eq!(req.per_call_cap, 500_000); + } + + #[tokio::test] + async fn advanced_flags_route_to_advanced_endpoint() { + let exec = Arc::new(MockExecutor::default()); + let tools = wurk_tools(&enabled_config(), exec.clone()); + find(&tools, "wurk.hire_humans") + .call(serde_json::json!({ + "description": "x", "selection_type": "creator", + "selection_time_minutes": 1440, "human_verified": true, + "community": "covenant" + })) + .await + .unwrap(); + let req = exec.last_create.lock().unwrap().clone().unwrap(); + assert_eq!(req.slug, "agenttohumanadvanced"); + assert!(req.url.contains("/solana/agenttohumanadvanced?")); + assert!(req.url.contains("selectionType=creator")); + assert!(req.url.contains("selectionTimeMinutes=1440")); + assert!(req.url.contains("human_verified=yes")); + assert!(req.url.contains("community=covenant")); + } + + #[tokio::test] + async fn hire_requires_description() { + let tools = wurk_tools(&enabled_config(), Arc::new(MockExecutor::default())); + let err = find(&tools, "wurk.hire_humans") + .call(serde_json::json!({})) + .await + .unwrap_err(); + assert!(matches!(err, ToolError::InvalidArguments(_))); + } + + #[tokio::test] + async fn job_status_passes_secret_and_page() { + let exec = Arc::new(MockExecutor::default()); + let tools = wurk_tools(&enabled_config(), exec.clone()); + find(&tools, "wurk.job_status") + .call(serde_json::json!({ "secret": "abc", "page": 2 })) + .await + .unwrap(); + assert_eq!( + exec.last_view.lock().unwrap().clone().unwrap(), + ("abc".to_string(), 2) + ); + } + + #[tokio::test] + async fn choose_winners_requires_non_empty_ids() { + let tools = wurk_tools(&enabled_config(), Arc::new(MockExecutor::default())); + let err = find(&tools, "wurk.choose_winners") + .call(serde_json::json!({ "secret": "s", "submission_ids": [] })) + .await + .unwrap_err(); + assert!(matches!(err, ToolError::InvalidArguments(_))); + } + + #[tokio::test] + async fn choose_winners_forwards_ids() { + let exec = Arc::new(MockExecutor::default()); + let tools = wurk_tools(&enabled_config(), exec.clone()); + find(&tools, "wurk.choose_winners") + .call(serde_json::json!({ "secret": "s", "submission_ids": ["ba5db22d", "abc12345"] })) + .await + .unwrap(); + let (secret, ids) = exec.last_choose.lock().unwrap().clone().unwrap(); + assert_eq!(secret, "s"); + assert_eq!(ids, vec!["ba5db22d", "abc12345"]); + } + + #[test] + fn specs_list_three_tools_without_executor() { + let specs = wurk_specs(&WurkConfig::default()); + assert_eq!(specs.len(), 3); + } +} diff --git a/agent-os/crates/covenant-wurk/src/view.rs b/agent-os/crates/covenant-wurk/src/view.rs new file mode 100644 index 000000000..00ab44065 --- /dev/null +++ b/agent-os/crates/covenant-wurk/src/view.rs @@ -0,0 +1,110 @@ +//! WURK submission view (free, secret-authed) and winner selection. +//! +//! `GET /{network}/agenttohuman[advanced]?action=view&secret=…&page=N` +//! returns the job's submissions. Selection is `POST +//! /api/agenttohumanadvanced/choose-winners {secret, submissionIds}`. +//! Neither needs a payment. + +use serde::Deserialize; + +/// One worker submission. `winner` is `0`/`1` in the wire form. +#[derive(Debug, Clone, Deserialize, PartialEq)] +pub struct Submission { + pub id: String, + #[serde(default)] + pub content_text: String, + #[serde(default)] + pub attachment_urls: Option>, + #[serde(default)] + pub winner: i64, +} + +impl Submission { + pub fn is_winner(&self) -> bool { + self.winner != 0 + } +} + +/// A page of a WURK job view (only the fields we consume). +#[derive(Debug, Clone, Deserialize)] +pub struct JobView { + #[serde(default)] + pub ok: bool, + #[serde(rename = "jobId", default)] + pub job_id: String, + #[serde(default)] + pub submissions: Vec, + #[serde(default)] + pub total: u64, + #[serde(rename = "totalPages", default)] + pub total_pages: u64, + #[serde(rename = "nextPageUrl", default)] + pub next_page_url: Option, +} + +/// Parse a WURK view response body. +pub fn parse_view(body: &str) -> crate::Result { + serde_json::from_str(body).map_err(|e| crate::WurkError::View(format!("decode view: {e}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Trimmed but faithful copy of the live view response for the + /// Covenant selection job (job c27eb389), captured from production. + const LIVE_VIEW: &str = r#"{ + "ok": true, + "jobId": "c27eb389", + "network": "solana", + "submissions": [ + { + "id": "ba5db22d", + "content_text": "1: no - included the price and missed the BPA-free requirement\n2: no - inaccurately claimed it is open every day instead of just weekdays\n3: yes - accurately summarized the statement in one sentence under 20 words", + "attachment_urls": ["https://ik.imagekit.io/wurk/IMG_5784_KozBmOmsT.png"], + "winner": 0 + }, + { "id": "7f207879", "content_text": "Wurk Done!", "attachment_urls": null, "winner": 0 }, + { "id": "963098fd", "content_text": "Yes", "attachment_urls": null, "winner": 0 } + ], + "page": 1, + "pageSize": 50, + "total": 3, + "totalPages": 1, + "nextPageUrl": null + }"#; + + #[test] + fn parses_live_view_with_submissions() { + let view = parse_view(LIVE_VIEW).expect("parse view"); + assert!(view.ok); + assert_eq!(view.job_id, "c27eb389"); + assert_eq!(view.total, 3); + assert_eq!(view.submissions.len(), 3); + let first = &view.submissions[0]; + assert_eq!(first.id, "ba5db22d"); + assert!(first.content_text.contains("1: no")); + assert_eq!( + first.attachment_urls.as_deref().map(|a| a.len()), + Some(1) + ); + assert!(!first.is_winner()); + } + + #[test] + fn tolerates_missing_optional_fields() { + let view = parse_view(r#"{"ok":true,"submissions":[{"id":"x"}]}"#).expect("parse"); + assert_eq!(view.submissions[0].id, "x"); + assert_eq!(view.submissions[0].content_text, ""); + assert!(view.submissions[0].attachment_urls.is_none()); + assert!(view.next_page_url.is_none()); + } + + #[test] + fn rejects_malformed_view() { + assert!(matches!( + parse_view("{not json"), + Err(crate::WurkError::View(m)) if m.contains("decode view") + )); + } +} diff --git a/agent-os/crates/covenant-wurk/src/x402.rs b/agent-os/crates/covenant-wurk/src/x402.rs new file mode 100644 index 000000000..5c599889e --- /dev/null +++ b/agent-os/crates/covenant-wurk/src/x402.rs @@ -0,0 +1,509 @@ +//! WURK's x402 challenge format and the 402-then-pay loop. +//! +//! WURK serves the object x402 v2 shape: a `402` body of +//! `{"x402Version": 2, "accepts": [ … ], "resource": {…}}` whose options +//! use `amount`, a CAIP-2 `network`, and `extra.feePayer` (the PayAI +//! sponsor). The job-specific pay URL is in `resource.url`. +//! +//! Two WURK-specific quirks vs the generic x402 client: +//! 1. The retry goes to `resource.url` (a fresh job is minted per create +//! GET), not the create URL. +//! 2. The payment header is `PAYMENT-SIGNATURE`, and the payload is the +//! x402 **v2** shape `{x402Version:2, accepted:, +//! payload:{transaction}}`. The official x402 matcher does a +//! `deepEqual` of the requirement against `accepted`, so we echo the +//! selected accept verbatim. +//! +//! The Solana signing is reused from `covenant-x402`'s sidecar signer +//! (it returns a v1 envelope); we extract the signed `transaction` from +//! it and re-wrap it in WURK's v2 payload here. + +use base64::Engine as _; +use covenant_x402::{PaymentRequirements, Signer}; +use serde::Deserialize; +use serde_json::Value; +use tracing::debug; + +use crate::tools::PaidRequest; +use crate::{Result, WurkError}; + +const PAYMENT_HEADER: &str = "PAYMENT-SIGNATURE"; +const B64: base64::engine::general_purpose::GeneralPurpose = base64::engine::general_purpose::STANDARD; + +/// One payment option from a WURK 402 challenge. +#[derive(Debug, Clone, Deserialize)] +pub struct Accept { + #[serde(default)] + pub scheme: String, + #[serde(default)] + pub network: String, + #[serde(default)] + pub asset: String, + #[serde(rename = "payTo", default)] + pub pay_to: String, + #[serde(default)] + pub amount: Option, + #[serde(rename = "maxAmountRequired", default)] + pub max_amount_required: Option, + #[serde(default)] + pub extra: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct Extra { + #[serde(rename = "feePayer", default)] + pub fee_payer: Option, +} + +impl Accept { + pub fn atomic_amount(&self) -> Option<&str> { + self.amount + .as_deref() + .or(self.max_amount_required.as_deref()) + } +} + +/// Outcome of the loop. `paid_amount` is the atomic amount settled; +/// `None` means a 2xx without a 402 (a free call). +#[derive(Debug, Clone, PartialEq)] +pub struct PaidHttp { + pub status: u16, + pub body: String, + pub paid_amount: Option, +} + +/// Parse a WURK 402 body into its typed payment options. +pub fn parse_challenge(body: &str) -> Result> { + accepts_value(body)? + .into_iter() + .map(|v| { + serde_json::from_value(v).map_err(|e| WurkError::Challenge(format!("decode accept: {e}"))) + }) + .collect() +} + +/// The raw `accepts` JSON objects (kept verbatim so the selected one can +/// be echoed back into the v2 `accepted` field for matching). +fn accepts_value(body: &str) -> Result> { + let value: Value = serde_json::from_str(body) + .map_err(|e| WurkError::Challenge(format!("decode 402 body: {e}")))?; + let accepts = if value.is_array() { + value + } else { + value + .get("accepts") + .cloned() + .ok_or_else(|| WurkError::Challenge("402 body has no accepts array".into()))? + }; + serde_json::from_value(accepts) + .map_err(|e| WurkError::Challenge(format!("decode accepts: {e}"))) +} + +/// The job-specific pay URL WURK returns in the 402 `resource.url`. +pub fn resource_url(body: &str) -> Option { + serde_json::from_str::(body) + .ok()? + .get("resource")? + .get("url")? + .as_str() + .map(String::from) +} + +fn accept_matches(a: &Accept, network: &str, asset: &str, per_call_cap: u128) -> bool { + a.scheme == "exact" + && a.asset == asset + && network_matches(&a.network, network) + && a.atomic_amount() + .and_then(|s| s.parse::().ok()) + .is_some_and(|n| n <= per_call_cap) +} + +/// First `exact` option on the caller's `(network, asset)` within cap. +pub fn select<'a>( + accepts: &'a [Accept], + network: &str, + asset: &str, + per_call_cap: u128, +) -> Option<&'a Accept> { + accepts + .iter() + .find(|a| accept_matches(a, network, asset, per_call_cap)) +} + +fn network_matches(accept: &str, want: &str) -> bool { + accept == want + || want.starts_with(&format!("{accept}:")) + || accept.starts_with(&format!("{want}:")) +} + +/// Normalise a selected option into the signer's input, pinning the +/// payee and sponsor to WURK's published wallets. +fn to_requirements(accept: &Accept, caip2_network: &str) -> Result { + if accept.pay_to != crate::config::PAY_TO { + return Err(WurkError::NotAllowed(format!( + "challenge pay_to {} does not match pinned WURK payee {}", + accept.pay_to, + crate::config::PAY_TO + ))); + } + let fee_payer = accept + .extra + .as_ref() + .and_then(|e| e.fee_payer.as_deref()) + .ok_or_else(|| { + WurkError::NotAllowed( + "challenge missing extra.feePayer — WURK sponsors the fee, it is required".into(), + ) + })?; + if fee_payer != crate::config::FEE_PAYER { + return Err(WurkError::NotAllowed(format!( + "challenge feePayer {fee_payer} does not match pinned sponsor {}", + crate::config::FEE_PAYER + ))); + } + let amount = accept + .atomic_amount() + .ok_or_else(|| WurkError::Challenge("accept missing amount".into()))? + .to_string(); + let amount_usdc = amount.parse::().unwrap_or(0) as f64 / 1_000_000.0; + Ok(PaymentRequirements { + network: caip2_network.to_string(), + asset: accept.asset.clone(), + amount, + amount_usdc, + pay_to: accept.pay_to.clone(), + scheme: accept.scheme.clone(), + extra: Some(covenant_x402::PaymentExtra { + fee_payer: Some(fee_payer.to_string()), + }), + }) +} + +/// Extract the base64 signed transaction from the sidecar signer's +/// (v1) envelope so we can re-wrap it in WURK's v2 payload. +fn extract_transaction(envelope_b64: &str) -> Result { + let bytes = B64 + .decode(envelope_b64.trim()) + .map_err(|e| WurkError::Execute(format!("decode signer envelope: {e}")))?; + let v: Value = serde_json::from_slice(&bytes) + .map_err(|e| WurkError::Execute(format!("parse signer envelope: {e}")))?; + v.get("payload") + .and_then(|p| p.get("transaction")) + .and_then(|t| t.as_str()) + .map(String::from) + .ok_or_else(|| WurkError::Execute("signer envelope missing payload.transaction".into())) +} + +/// Build WURK's x402 v2 payment header: the chosen requirement echoed +/// into `accepted` (so the server's deepEqual match succeeds) plus the +/// signed transaction. +fn wrap_v2(accepted: &Value, transaction: &str) -> String { + let envelope = serde_json::json!({ + "x402Version": 2, + "accepted": accepted, + "payload": { "transaction": transaction }, + }); + B64.encode(envelope.to_string().as_bytes()) +} + +/// Run the 402-then-pay loop for one resolved WURK call. +pub async fn execute_paid( + http: &reqwest::Client, + signer: &dyn Signer, + plan: &PaidRequest, +) -> Result { + let method = reqwest::Method::from_bytes(plan.method.as_bytes()) + .map_err(|_| WurkError::Execute(format!("invalid HTTP method: {:?}", plan.method)))?; + + let first = send(http, &method, &plan.url, plan.body.as_ref(), None).await?; + let status = first.status(); + if status.is_success() { + let body = first.text().await?; + return Ok(PaidHttp { + status: status.as_u16(), + body, + paid_amount: None, + }); + } + if status.as_u16() != 402 { + let body = first.text().await.unwrap_or_default(); + return Err(WurkError::Execute(format!( + "{} returned {} (not 402): {}", + plan.url, + status.as_u16(), + truncate(&body) + ))); + } + + let challenge = first.text().await?; + // WURK mints a fresh job per create GET and returns a job-specific + // pay URL in `resource.url`. Retry the payment against THAT url. + let retry_url = resource_url(&challenge).unwrap_or_else(|| plan.url.clone()); + + let raws = accepts_value(&challenge)?; + let mut selected: Option<(Accept, Value)> = None; + for raw in raws { + if let Ok(a) = serde_json::from_value::(raw.clone()) { + if accept_matches(&a, &plan.network, &plan.asset, plan.per_call_cap) { + selected = Some((a, raw)); + break; + } + } + } + let (accept, raw_accept) = selected.ok_or_else(|| { + WurkError::NotAllowed(format!( + "no x402 option on {} / {} within cap {} atomic", + plan.network, plan.asset, plan.per_call_cap + )) + })?; + if let Some(fee_payer) = accept.extra.as_ref().and_then(|e| e.fee_payer.as_deref()) { + debug!(%fee_payer, url = %retry_url, "WURK feePayer sponsors the tx"); + } + + let requirements = to_requirements(&accept, &plan.network)?; + let inner = signer + .build_payment(&requirements) + .await + .map_err(|e| WurkError::Execute(e.to_string()))?; + let transaction = extract_transaction(&inner)?; + let header = wrap_v2(&raw_accept, &transaction); + + let paid = send(http, &method, &retry_url, plan.body.as_ref(), Some(&header)).await?; + let status = paid.status(); + let body = paid.text().await?; + let paid_amount = status.is_success().then(|| requirements.amount.clone()); + Ok(PaidHttp { + status: status.as_u16(), + body, + paid_amount, + }) +} + +async fn send( + http: &reqwest::Client, + method: &reqwest::Method, + url: &str, + body: Option<&Value>, + payment_header: Option<&str>, +) -> Result { + let mut req = http.request(method.clone(), url); + if let Some(b) = body { + req = req.json(b); + } + if let Some(h) = payment_header { + req = req.header(PAYMENT_HEADER, h); + } + Ok(req.send().await?) +} + +fn truncate(s: &str) -> String { + let cut: String = s.chars().take(200).collect(); + if cut.len() < s.len() { + format!("{cut}…") + } else { + cut + } +} + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use wiremock::matchers::{header_exists, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + /// The exact advanced-create 402 body WURK's live API returns. + const LIVE_WURK_402: &str = r#"{ + "x402Version": 2, + "accepts": [{ + "scheme": "exact", + "network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "amount": "1000000", + "asset": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "payTo": "SAT8g2xU7AFy7eUmNJ9SNrM6yYo7LDCi13GXJ8Ez9kC", + "maxTimeoutSeconds": 60, + "extra": { "feePayer": "2wKupLR9q6wXYppw8Gr2NvWxKBUqm4PPJKkQfoxHDBg4" } + }], + "resource": { "url": "https://wurkapi.fun/api/x402/jobs/6a5f09dc/pay", "description": "Pay to unlock job execution", "mimeType": "application/json" } + }"#; + + const NETWORK: &str = crate::config::SOLANA_NETWORK; + const ASSET: &str = crate::config::USDC_MINT; + + /// Returns a v1 envelope (what the real sidecar emits) carrying a + /// known transaction, so the v2 re-wrap can be exercised offline. + struct FakeEnvelopeSigner; + #[async_trait] + impl Signer for FakeEnvelopeSigner { + async fn build_payment( + &self, + _r: &PaymentRequirements, + ) -> covenant_x402::Result { + let env = serde_json::json!({ + "x402Version": 1, "scheme": "exact", "network": "solana", + "payload": { "transaction": "FAKETX==" } + }); + Ok(B64.encode(env.to_string().as_bytes())) + } + } + + fn plan(url: &str, cap: u128) -> PaidRequest { + PaidRequest { + provider: "wurk".into(), + slug: "agenttohuman".into(), + url: url.into(), + method: "GET".into(), + body: None, + network: NETWORK.into(), + asset: ASSET.into(), + per_call_cap: cap, + } + } + + #[test] + fn parses_live_wurk_challenge() { + let accepts = parse_challenge(LIVE_WURK_402).expect("parse"); + assert_eq!(accepts.len(), 1); + assert_eq!(accepts[0].atomic_amount(), Some("1000000")); + assert_eq!(accepts[0].pay_to, crate::config::PAY_TO); + assert_eq!( + accepts[0].extra.as_ref().unwrap().fee_payer.as_deref(), + Some(crate::config::FEE_PAYER) + ); + } + + #[test] + fn resource_url_extracted() { + assert_eq!( + resource_url(LIVE_WURK_402).as_deref(), + Some("https://wurkapi.fun/api/x402/jobs/6a5f09dc/pay") + ); + } + + #[test] + fn select_matches_caip2_and_respects_cap() { + let accepts = parse_challenge(LIVE_WURK_402).unwrap(); + assert!(select(&accepts, NETWORK, ASSET, 1_000_000).is_some()); + assert!(select(&accepts, NETWORK, ASSET, 999_999).is_none()); + assert!(select(&accepts, NETWORK, "OTHER_MINT", 1_000_000).is_none()); + } + + #[test] + fn to_requirements_pins_payee_and_sponsor() { + let base = parse_challenge(LIVE_WURK_402).expect("parse")[0].clone(); + to_requirements(&base, NETWORK).expect("pinned live option normalises"); + + let mut wrong_payee = base.clone(); + wrong_payee.pay_to = "So11111111111111111111111111111111111111112".into(); + assert!(matches!( + to_requirements(&wrong_payee, NETWORK), + Err(WurkError::NotAllowed(m)) if m.contains("does not match pinned WURK payee") + )); + + let mut wrong_fee_payer = base.clone(); + wrong_fee_payer.extra = Some(Extra { + fee_payer: Some("11111111111111111111111111111111".into()), + }); + assert!(matches!( + to_requirements(&wrong_fee_payer, NETWORK), + Err(WurkError::NotAllowed(m)) if m.contains("does not match pinned sponsor") + )); + } + + #[test] + fn extract_transaction_reads_payload() { + let env = serde_json::json!({ "payload": { "transaction": "ABC123" } }); + let b64 = B64.encode(env.to_string().as_bytes()); + assert_eq!(extract_transaction(&b64).unwrap(), "ABC123"); + assert!(extract_transaction("not base64!!!").is_err()); + } + + #[test] + fn wrap_v2_echoes_accepted_and_carries_tx() { + let accepted = serde_json::json!({ + "scheme": "exact", "network": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "amount": "10000", "asset": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + "payTo": "SAT8g2xU7AFy7eUmNJ9SNrM6yYo7LDCi13GXJ8Ez9kC", + "maxTimeoutSeconds": 60, "extra": { "feePayer": "2wKupLR9q6wXYppw8Gr2NvWxKBUqm4PPJKkQfoxHDBg4" } + }); + let header = wrap_v2(&accepted, "TXDATA"); + let decoded: Value = + serde_json::from_slice(&B64.decode(header).unwrap()).unwrap(); + assert_eq!(decoded["x402Version"], 2); + assert_eq!(decoded["payload"]["transaction"], "TXDATA"); + // The accepted block must be echoed verbatim for the server's + // deepEqual match to succeed. + assert_eq!(decoded["accepted"], accepted); + } + + #[tokio::test] + async fn full_loop_pays_v2_against_resource_url() { + let server = MockServer::start().await; + // Challenge whose resource.url points at the mock's pay path. + let challenge = serde_json::json!({ + "x402Version": 2, + "accepts": [{ + "scheme": "exact", "network": NETWORK, "amount": "10000", + "asset": ASSET, "payTo": crate::config::PAY_TO, "maxTimeoutSeconds": 60, + "extra": { "feePayer": crate::config::FEE_PAYER } + }], + "resource": { "url": format!("{}/api/x402/jobs/t/pay", server.uri()) } + }) + .to_string(); + + Mock::given(method("GET")) + .and(path("/solana/agenttohuman")) + .respond_with(ResponseTemplate::new(402).set_body_string(challenge)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/api/x402/jobs/t/pay")) + .and(header_exists(PAYMENT_HEADER)) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "ok": true, "paid": true, "jobId": "abc123", "secret": "s3cr3t" + }))) + .mount(&server) + .await; + + let url = format!("{}/solana/agenttohuman?description=ping", server.uri()); + let out = execute_paid(&reqwest::Client::new(), &FakeEnvelopeSigner, &plan(&url, 1_000_000)) + .await + .expect("paid"); + assert_eq!(out.status, 200); + assert_eq!(out.paid_amount.as_deref(), Some("10000")); + let body: Value = serde_json::from_str(&out.body).unwrap(); + assert_eq!(body["jobId"], "abc123"); + assert_eq!(body["secret"], "s3cr3t"); + } + + #[tokio::test] + async fn over_cap_rejected_before_payment() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/solana/agenttohuman")) + .respond_with(ResponseTemplate::new(402).set_body_string(LIVE_WURK_402)) + .mount(&server) + .await; + let url = format!("{}/solana/agenttohuman?description=ping", server.uri()); + let err = execute_paid(&reqwest::Client::new(), &FakeEnvelopeSigner, &plan(&url, 999_999)) + .await + .expect_err("over cap"); + assert!(matches!(err, WurkError::NotAllowed(_)), "got {err:?}"); + } + + #[tokio::test] + async fn free_2xx_needs_no_payment() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/solana/agenttohuman")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "ok": true }))) + .mount(&server) + .await; + let url = format!("{}/solana/agenttohuman?action=view&secret=x", server.uri()); + let out = execute_paid(&reqwest::Client::new(), &FakeEnvelopeSigner, &plan(&url, 1_000_000)) + .await + .expect("free"); + assert_eq!(out.status, 200); + assert!(out.paid_amount.is_none()); + } +} From 444627b3c85b84ad73f46c5b498705ad24809b30 Mon Sep 17 00:00:00 2001 From: mizuki0x <197570892+mizuki0x@users.noreply.github.com> Date: Wed, 17 Jun 2026 17:59:33 +0200 Subject: [PATCH 2/4] feat(wurk): wire DaemonWurkExecutor into covenantd (tools + config) --- agent-os/Cargo.lock | 1 + agent-os/crates/covenant-wurk/src/lib.rs | 2 +- agent-os/crates/covenant-wurk/src/tools.rs | 14 ++ agent-os/crates/covenantd/Cargo.toml | 1 + agent-os/crates/covenantd/src/lib.rs | 66 ++++++ agent-os/crates/covenantd/src/main.rs | 39 ++++ agent-os/crates/covenantd/src/wurk.rs | 249 +++++++++++++++++++++ 7 files changed, 371 insertions(+), 1 deletion(-) create mode 100644 agent-os/crates/covenantd/src/wurk.rs diff --git a/agent-os/Cargo.lock b/agent-os/Cargo.lock index 77029e3ca..7b16e39f7 100644 --- a/agent-os/Cargo.lock +++ b/agent-os/Cargo.lock @@ -1583,6 +1583,7 @@ dependencies = [ "covenant-sap-bridge", "covenant-settlement", "covenant-types", + "covenant-wurk", "covenant-x402", "dotenvy", "futures", diff --git a/agent-os/crates/covenant-wurk/src/lib.rs b/agent-os/crates/covenant-wurk/src/lib.rs index 590319124..fb92ee118 100644 --- a/agent-os/crates/covenant-wurk/src/lib.rs +++ b/agent-os/crates/covenant-wurk/src/lib.rs @@ -19,7 +19,7 @@ pub mod view; pub mod x402; pub use config::WurkConfig; -pub use tools::{wurk_specs, wurk_tools, PaidRequest, PaidResponse, WurkExecutor}; +pub use tools::{wurk_specs, wurk_tool, wurk_tools, PaidRequest, PaidResponse, WurkExecutor}; pub use view::{parse_view, JobView, Submission}; pub use x402::{execute_paid, parse_challenge, PaidHttp}; diff --git a/agent-os/crates/covenant-wurk/src/tools.rs b/agent-os/crates/covenant-wurk/src/tools.rs index 60d041522..31c6fb6bc 100644 --- a/agent-os/crates/covenant-wurk/src/tools.rs +++ b/agent-os/crates/covenant-wurk/src/tools.rs @@ -288,6 +288,20 @@ pub fn wurk_tools(config: &WurkConfig, executor: Arc) -> Vec, +) -> Option> { + let mut cfg = config.clone(); + cfg.enabled = true; + wurk_tools(&cfg, executor) + .into_iter() + .find(|t| t.name() == name) +} + /// Tool specs for discovery without binding a payer. pub fn wurk_specs(config: &WurkConfig) -> Vec { let mut cfg = config.clone(); diff --git a/agent-os/crates/covenantd/Cargo.toml b/agent-os/crates/covenantd/Cargo.toml index 5c5b11a76..faeaaa610 100644 --- a/agent-os/crates/covenantd/Cargo.toml +++ b/agent-os/crates/covenantd/Cargo.toml @@ -33,6 +33,7 @@ covenant-settlement = { path = "../covenant-settlement" } # feature in a separate change. covenant-x402 = { path = "../covenant-x402" } covenant-hyre = { path = "../covenant-hyre" } +covenant-wurk = { path = "../covenant-wurk" } covenant-metaplex = { path = "../covenant-metaplex" } anyhow = { workspace = true } async-trait = { workspace = true } diff --git a/agent-os/crates/covenantd/src/lib.rs b/agent-os/crates/covenantd/src/lib.rs index 60a718b5c..b678602bd 100644 --- a/agent-os/crates/covenantd/src/lib.rs +++ b/agent-os/crates/covenantd/src/lib.rs @@ -13,6 +13,7 @@ pub mod http; pub mod hyre; pub mod metaplex; pub mod reputation; +pub mod wurk; pub mod spend_authz; pub mod sse; pub mod stream_dispatch; @@ -1219,6 +1220,10 @@ pub struct Server { /// the operator has not enabled Metaplex; in that state no /// `metaplex.*` tool is advertised or callable. metaplex: Option>, + /// Opt-in WURK agent-to-human profile (config only). None when the + /// operator has not enabled WURK; in that state no `wurk.*` tool is + /// advertised or callable. + wurk: Option>, /// Opt-in Synapse Agent Protocol bridge. `None` when no operator /// has wired it in (the default); a built [`SapBridge`] when /// `Server::with_sap_bridge` was called at boot. Handlers that @@ -1273,6 +1278,7 @@ impl Server { escrow: None, hyre: None, metaplex: None, + wurk: None, sap_bridge: None, intent_outcomes: Arc::new(std::sync::Mutex::new(OutcomeStore::default())), } @@ -1390,6 +1396,13 @@ impl Server { self } + /// Enable the WURK agent-to-human profile (config only). When unset, + /// no `wurk.*` tool is advertised or callable. + pub fn with_wurk(mut self, state: wurk::WurkState) -> Self { + self.wurk = Some(Arc::new(state)); + self + } + pub fn with_budget_checkpoints(mut self, store: Arc) -> Self { self.budget_checkpoints = Some(store); self @@ -4034,6 +4047,9 @@ impl Server { if let Some(state) = &self.metaplex { tools.extend(covenant_metaplex::metaplex_specs(&state.config)); } + if let Some(state) = &self.wurk { + tools.extend(covenant_wurk::wurk_specs(&state.config)); + } Response::ToolList { tools } } @@ -4102,6 +4118,9 @@ impl Server { if name.starts_with("metaplex.") { return self.metaplex_tool_call(name, arguments).await; } + if name.starts_with("wurk.") { + return self.wurk_tool_call(name, arguments, peer).await; + } match self.tools.call(&name, arguments).await { Ok(r) => Response::ToolResult { @@ -4200,6 +4219,53 @@ impl Server { } } + /// Execute a WURK tool on the caller's behalf. `wurk.hire_humans` + /// runs the paid x402 v2 loop with the caller as payer; + /// `wurk.job_status` and `wurk.choose_winners` are free reads. The + /// funding key stays in the `covenant-x402-signer` sidecar. + async fn wurk_tool_call( + &self, + name: String, + arguments: serde_json::Value, + peer: &AgentId, + ) -> Response { + let Some(state) = self.wurk.clone() else { + return Response::Error { + message: "wurk provider is not enabled on this daemon.".into(), + }; + }; + let Some(x402) = self.x402_dispatch.clone() else { + return Response::Error { + message: "wurk requires the x402 funding-key sidecar. \ + Wire it via Server::with_x402_dispatch and restart." + .into(), + }; + }; + let executor = Arc::new(wurk::DaemonWurkExecutor::new( + self.settlement.clone(), + self.audit.clone(), + self.budget.clone(), + x402, + self.identity.agent_id(), + peer.clone(), + state.config.base_url.clone(), + )); + let Some(tool) = covenant_wurk::wurk_tool(&state.config, &name, executor) else { + return Response::Error { + message: format!("unknown wurk tool: {name}"), + }; + }; + match tool.call(arguments).await { + Ok(r) => Response::ToolResult { + content: r.content, + is_error: r.is_error, + }, + Err(e) => Response::Error { + message: format!("tool: {e}"), + }, + } + } + async fn tool_call_scope_allows( &self, action: &str, diff --git a/agent-os/crates/covenantd/src/main.rs b/agent-os/crates/covenantd/src/main.rs index 088f45cf9..0bb64a34f 100644 --- a/agent-os/crates/covenantd/src/main.rs +++ b/agent-os/crates/covenantd/src/main.rs @@ -371,6 +371,14 @@ async fn main() -> Result<()> { None => server, }; + let server = match wurk_config_from_env() { + Some(cfg) => { + info!(base_url = %cfg.base_url, "wurk provider enabled"); + server.with_wurk(covenantd::wurk::WurkState::new(cfg)) + } + None => server, + }; + let server = { let cfg = covenant_metaplex::MetaplexConfig::from_env(); if !cfg.enabled { @@ -837,6 +845,37 @@ fn hyre_config_from_env() -> Option { Some(cfg) } +/// Build the WURK profile from env. Returns `None` unless +/// `COVENANT_WURK_ENABLED` is truthy. +/// +/// - `COVENANT_WURK_ENABLED` — `1`/`true`/`yes` to enable +/// - `COVENANT_WURK_BASE_URL` — API host (optional) +/// - `COVENANT_WURK_PER_CALL_CAP` — atomic-USDC per-call ceiling (optional) +fn wurk_config_from_env() -> Option { + let enabled = std::env::var("COVENANT_WURK_ENABLED") + .map(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes")) + .unwrap_or(false); + if !enabled { + return None; + } + let mut cfg = covenant_wurk::WurkConfig { + enabled: true, + ..Default::default() + }; + if let Ok(url) = std::env::var("COVENANT_WURK_BASE_URL") { + cfg.base_url = url; + } + if let Ok(cap) = std::env::var("COVENANT_WURK_PER_CALL_CAP") { + match cap.trim().parse() { + Ok(n) => cfg.per_call_cap = n, + Err(_) => { + tracing::warn!(value = %cap, "ignoring non-numeric COVENANT_WURK_PER_CALL_CAP") + } + } + } + Some(cfg) +} + /// Mask secret query params (api keys, tokens) in a URL before logging it, /// so a keyed RPC endpoint in `sap.env` never lands in stdout/journald. fn redact_url(url: &str) -> String { diff --git a/agent-os/crates/covenantd/src/wurk.rs b/agent-os/crates/covenantd/src/wurk.rs new file mode 100644 index 000000000..f22862a73 --- /dev/null +++ b/agent-os/crates/covenantd/src/wurk.rs @@ -0,0 +1,249 @@ +//! Daemon glue for the WURK agent-to-human x402 provider profile. +//! +//! Mirrors [`crate::hyre`]: `covenant-wurk` owns the tool surface and the +//! x402 v2 pay loop; this binds one caller (the payer) to the daemon's +//! accounting and signs via the `covenant-x402-signer` sidecar. Only the +//! paid `hire_humans` create goes through settlement/audit/budget; the +//! `job_status` view and `choose_winners` selection are free reads. + +use std::sync::Arc; + +use async_trait::async_trait; +use covenant_audit::AuditLog; +use covenant_budget::{BudgetError, BudgetLedger}; +use covenant_settlement::Settlement; +use covenant_types::AgentId; +use covenant_wurk::{PaidRequest, PaidResponse, WurkConfig, WurkExecutor}; +use covenant_x402::Capability; +use serde_json::Value; +use tracing::warn; + +use crate::x402::{record_paid_call, PaidCall, SettlementContext, X402Config}; + +/// One budget credit per hire call. The authoritative on-chain amount is +/// recorded separately from the live 402 settlement. +const HIRE_CREDITS: u64 = 1; + +/// WURK config, built at daemon startup and shared behind an `Arc`. +pub struct WurkState { + pub config: WurkConfig, +} + +impl WurkState { + pub fn new(config: WurkConfig) -> Self { + Self { config } + } +} + +/// A [`WurkExecutor`] bound to one payer and the daemon's accounting. +/// Constructed per tool call so the budget debit and settlement receipt +/// land against the agent that invoked the tool. +pub struct DaemonWurkExecutor { + settlement: Arc, + audit: Arc, + budget: Arc, + x402: Arc, + issuer: AgentId, + payer: AgentId, + base_url: String, + http: reqwest::Client, +} + +impl DaemonWurkExecutor { + #[allow(clippy::too_many_arguments)] + pub fn new( + settlement: Arc, + audit: Arc, + budget: Arc, + x402: Arc, + issuer: AgentId, + payer: AgentId, + base_url: String, + ) -> Self { + Self { + settlement, + audit, + budget, + x402, + issuer, + payer, + base_url, + http: reqwest::Client::new(), + } + } +} + +#[async_trait] +impl WurkExecutor for DaemonWurkExecutor { + async fn create(&self, req: PaidRequest) -> Result { + if !self.x402.enabled { + return Err("x402 outbound surface is disabled".into()); + } + let method = reqwest::Method::from_bytes(req.method.as_bytes()) + .map_err(|_| format!("invalid HTTP method: {:?}", req.method))?; + + // Read-only pre-check so the daemon never spends USDC for an agent + // that can't afford the call. The authoritative debit happens in + // record_paid_call after a successful settlement. + match self.budget.would_exceed(&self.payer, HIRE_CREDITS).await { + Ok(false) => {} + Ok(true) => return Err("payer budget would be exceeded by this call".into()), + Err(BudgetError::NoCapacity(_)) => { + return Err("payer has no budget capacity; refusing to spend".into()) + } + Err(e) => return Err(format!("budget: {e}")), + } + + let mut signer = crate::x402::SubprocessSigner::new(&self.x402.signer_binary); + for (k, v) in &self.x402.signer_env { + signer = signer.env(k.clone(), v.clone()); + } + + let out = covenant_wurk::execute_paid(&self.http, &signer, &req) + .await + .map_err(|e| e.to_string())?; + + let receipt_id = match &out.paid_amount { + Some(amount) if (200..300).contains(&out.status) => { + let capability = Capability { + provider: req.provider.clone(), + network: req.network.clone(), + asset: req.asset.clone(), + per_call_cap: req.per_call_cap, + }; + let call = PaidCall { + provider: &req.provider, + endpoint: &req.url, + method, + capability, + body: req.body.as_ref(), + amount: amount.clone(), + network: req.network.clone(), + asset: req.asset.clone(), + credits: HIRE_CREDITS, + }; + let ctx = SettlementContext { + settlement: self.settlement.as_ref(), + audit: self.audit.as_ref(), + budget: self.budget.as_ref(), + issuer: &self.issuer, + }; + match record_paid_call(&ctx, &self.payer, &call).await { + Ok(id) => Some(id), + Err(e) => { + warn!(error = %e, endpoint = %req.url, "wurk paid call succeeded but accounting failed"); + return Err(e.to_string()); + } + } + } + _ => None, + }; + + let body = serde_json::from_str(&out.body).unwrap_or(Value::String(out.body)); + Ok(PaidResponse { + status: out.status, + body, + receipt_id: receipt_id.map(|id| id.to_string()), + }) + } + + async fn view(&self, secret: &str, page: u32) -> Result { + let url = format!( + "{}/solana/agenttohumanadvanced?action=view&secret={}&page={}", + self.base_url.trim_end_matches('/'), + secret, + page + ); + let resp = self.http.get(&url).send().await.map_err(|e| e.to_string())?; + let status = resp.status(); + let body = resp.text().await.map_err(|e| e.to_string())?; + if !status.is_success() { + return Err(format!("view returned {}: {}", status.as_u16(), body)); + } + serde_json::from_str(&body).map_err(|e| format!("decode view: {e}")) + } + + async fn choose_winners( + &self, + secret: &str, + submission_ids: Vec, + ) -> Result { + let url = format!( + "{}/api/agenttohumanadvanced/choose-winners", + self.base_url.trim_end_matches('/') + ); + let resp = self + .http + .post(&url) + .json(&serde_json::json!({ "secret": secret, "submissionIds": submission_ids })) + .send() + .await + .map_err(|e| e.to_string())?; + let status = resp.status(); + let body = resp.text().await.map_err(|e| e.to_string())?; + if !status.is_success() { + return Err(format!( + "choose-winners returned {}: {}", + status.as_u16(), + body + )); + } + serde_json::from_str(&body).map_err(|e| format!("decode choose-winners: {e}")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use covenant_audit::InMemoryAuditLog; + use covenant_budget::InMemoryLedger; + use covenant_settlement::InMemorySettlement; + + fn agent(tag: u8) -> AgentId { + AgentId::new("agent@local", [tag; 32]) + } + + fn req() -> PaidRequest { + PaidRequest { + provider: "wurk".into(), + slug: "agenttohuman".into(), + url: "https://wurkapi.fun/solana/agenttohuman?description=x".into(), + method: "GET".into(), + body: None, + network: covenant_wurk::config::SOLANA_NETWORK.into(), + asset: covenant_wurk::config::USDC_MINT.into(), + per_call_cap: 10_000, + } + } + + fn exec(x402: X402Config, payer: AgentId, budget: Arc) -> DaemonWurkExecutor { + DaemonWurkExecutor::new( + Arc::new(InMemorySettlement::new()), + Arc::new(InMemoryAuditLog::new()), + budget, + Arc::new(x402), + agent(9), + payer, + covenant_wurk::config::BASE_URL.into(), + ) + } + + #[tokio::test] + async fn create_refuses_when_x402_disabled() { + let e = exec(X402Config::default(), agent(1), Arc::new(InMemoryLedger::new())); + let err = e.create(req()).await.expect_err("disabled"); + assert!(err.contains("disabled"), "got: {err}"); + } + + #[tokio::test] + async fn create_refuses_payer_without_budget() { + let x402 = X402Config { + enabled: true, + signer_binary: "/nonexistent-signer".into(), + signer_env: vec![], + }; + let e = exec(x402, agent(1), Arc::new(InMemoryLedger::new())); + let err = e.create(req()).await.expect_err("no capacity"); + assert!(err.contains("capacity"), "got: {err}"); + } +} From 60757546b78973ffacf6f8694ca8eabc03eafc5b Mon Sep 17 00:00:00 2001 From: mizuki0x <197570892+mizuki0x@users.noreply.github.com> Date: Wed, 17 Jun 2026 18:05:25 +0200 Subject: [PATCH 3/4] style: rustfmt wurk crate + daemon module --- .../covenant-wurk/examples/live_create.rs | 12 +++-- agent-os/crates/covenant-wurk/src/config.rs | 10 +++- agent-os/crates/covenant-wurk/src/tools.rs | 26 +++++++--- agent-os/crates/covenant-wurk/src/view.rs | 5 +- agent-os/crates/covenant-wurk/src/x402.rs | 48 ++++++++++++------- agent-os/crates/covenantd/src/wurk.rs | 13 ++++- 6 files changed, 79 insertions(+), 35 deletions(-) diff --git a/agent-os/crates/covenant-wurk/examples/live_create.rs b/agent-os/crates/covenant-wurk/examples/live_create.rs index 77fa4d6e8..1b17db364 100644 --- a/agent-os/crates/covenant-wurk/examples/live_create.rs +++ b/agent-os/crates/covenant-wurk/examples/live_create.rs @@ -26,7 +26,10 @@ struct SubprocessSigner { #[async_trait] impl Signer for SubprocessSigner { - async fn build_payment(&self, requirements: &PaymentRequirements) -> covenant_x402::Result { + async fn build_payment( + &self, + requirements: &PaymentRequirements, + ) -> covenant_x402::Result { let json = serde_json::to_string(requirements) .map_err(|e| covenant_x402::X402Error::Sign(e.to_string()))?; let mut child = Command::new(&self.bin) @@ -60,7 +63,9 @@ fn encode(s: &str) -> String { let mut out = String::with_capacity(s.len()); for b in s.bytes() { match b { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char), + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char) + } _ => out.push_str(&format!("%{b:02X}")), } } @@ -71,7 +76,8 @@ fn encode(s: &str) -> String { async fn main() { let bin = std::env::var("WURK_SIGNER_BIN").expect("set WURK_SIGNER_BIN"); let keypair = std::env::var("WURK_KEYPAIR").expect("set WURK_KEYPAIR"); - let rpc = std::env::var("WURK_RPC").unwrap_or_else(|_| "https://api.mainnet-beta.solana.com".into()); + let rpc = + std::env::var("WURK_RPC").unwrap_or_else(|_| "https://api.mainnet-beta.solana.com".into()); let args: Vec = std::env::args().collect(); let description = args diff --git a/agent-os/crates/covenant-wurk/src/config.rs b/agent-os/crates/covenant-wurk/src/config.rs index 873ac82e7..8ee3b1df3 100644 --- a/agent-os/crates/covenant-wurk/src/config.rs +++ b/agent-os/crates/covenant-wurk/src/config.rs @@ -86,7 +86,15 @@ mod tests { fn only_agent_to_human_paths_are_allowed() { assert!(is_allowed("agenttohuman")); assert!(is_allowed("agenttohumanadvanced")); - for blocked in ["xraid", "xlikes", "xfollowers", "dex", "cmcvote", "cgvote", "tgmembers"] { + for blocked in [ + "xraid", + "xlikes", + "xfollowers", + "dex", + "cmcvote", + "cgvote", + "tgmembers", + ] { assert!(!is_allowed(blocked), "{blocked} must be blocked"); } } diff --git a/agent-os/crates/covenant-wurk/src/tools.rs b/agent-os/crates/covenant-wurk/src/tools.rs index 31c6fb6bc..b4ea16163 100644 --- a/agent-os/crates/covenant-wurk/src/tools.rs +++ b/agent-os/crates/covenant-wurk/src/tools.rs @@ -98,13 +98,20 @@ impl Tool for HireHumansTool { async fn call(&self, arguments: Value) -> Result { let args = as_object(&arguments)?; let description = req_str(args, "description")?; - let winners = args.get("winners").and_then(Value::as_u64).unwrap_or(10).max(1); + let winners = args + .get("winners") + .and_then(Value::as_u64) + .unwrap_or(10) + .max(1); let per_user = args .get("per_user_usdc") .and_then(Value::as_f64) .unwrap_or(0.05) .max(0.01); - let advanced = args.get("advanced").and_then(Value::as_bool).unwrap_or(false) + let advanced = args + .get("advanced") + .and_then(Value::as_bool) + .unwrap_or(false) || args.contains_key("selection_type") || args.contains_key("selection_time_minutes") || args.contains_key("human_verified"); @@ -334,9 +341,9 @@ impl WurkExecutor for SpecOnly { fn as_object(arguments: &Value) -> Result<&Map, ToolError> { match arguments { Value::Object(m) => Ok(m), - Value::Null => { - Err(ToolError::InvalidArguments("arguments must be a JSON object".into())) - } + Value::Null => Err(ToolError::InvalidArguments( + "arguments must be a JSON object".into(), + )), _ => Err(ToolError::InvalidArguments( "arguments must be a JSON object".into(), )), @@ -411,7 +418,10 @@ mod tests { } fn find<'a>(tools: &'a [Arc], name: &str) -> &'a Arc { - tools.iter().find(|t| t.name() == name).expect("tool present") + tools + .iter() + .find(|t| t.name() == name) + .expect("tool present") } #[test] @@ -440,7 +450,9 @@ mod tests { let req = exec.last_create.lock().unwrap().clone().unwrap(); assert_eq!(req.method, "GET"); assert_eq!(req.slug, "agenttohuman"); - assert!(req.url.starts_with("https://wurkapi.fun/solana/agenttohuman?")); + assert!(req + .url + .starts_with("https://wurkapi.fun/solana/agenttohuman?")); assert!(req.url.contains("description=judge%20this")); assert!(req.url.contains("winners=10")); assert!(req.url.contains("perUser=0.05")); diff --git a/agent-os/crates/covenant-wurk/src/view.rs b/agent-os/crates/covenant-wurk/src/view.rs index 00ab44065..7fc87f70e 100644 --- a/agent-os/crates/covenant-wurk/src/view.rs +++ b/agent-os/crates/covenant-wurk/src/view.rs @@ -84,10 +84,7 @@ mod tests { let first = &view.submissions[0]; assert_eq!(first.id, "ba5db22d"); assert!(first.content_text.contains("1: no")); - assert_eq!( - first.attachment_urls.as_deref().map(|a| a.len()), - Some(1) - ); + assert_eq!(first.attachment_urls.as_deref().map(|a| a.len()), Some(1)); assert!(!first.is_winner()); } diff --git a/agent-os/crates/covenant-wurk/src/x402.rs b/agent-os/crates/covenant-wurk/src/x402.rs index 5c599889e..66c2ffbd7 100644 --- a/agent-os/crates/covenant-wurk/src/x402.rs +++ b/agent-os/crates/covenant-wurk/src/x402.rs @@ -28,7 +28,8 @@ use crate::tools::PaidRequest; use crate::{Result, WurkError}; const PAYMENT_HEADER: &str = "PAYMENT-SIGNATURE"; -const B64: base64::engine::general_purpose::GeneralPurpose = base64::engine::general_purpose::STANDARD; +const B64: base64::engine::general_purpose::GeneralPurpose = + base64::engine::general_purpose::STANDARD; /// One payment option from a WURK 402 challenge. #[derive(Debug, Clone, Deserialize)] @@ -77,7 +78,8 @@ pub fn parse_challenge(body: &str) -> Result> { accepts_value(body)? .into_iter() .map(|v| { - serde_json::from_value(v).map_err(|e| WurkError::Challenge(format!("decode accept: {e}"))) + serde_json::from_value(v) + .map_err(|e| WurkError::Challenge(format!("decode accept: {e}"))) }) .collect() } @@ -335,10 +337,7 @@ mod tests { struct FakeEnvelopeSigner; #[async_trait] impl Signer for FakeEnvelopeSigner { - async fn build_payment( - &self, - _r: &PaymentRequirements, - ) -> covenant_x402::Result { + async fn build_payment(&self, _r: &PaymentRequirements) -> covenant_x402::Result { let env = serde_json::json!({ "x402Version": 1, "scheme": "exact", "network": "solana", "payload": { "transaction": "FAKETX==" } @@ -427,8 +426,7 @@ mod tests { "maxTimeoutSeconds": 60, "extra": { "feePayer": "2wKupLR9q6wXYppw8Gr2NvWxKBUqm4PPJKkQfoxHDBg4" } }); let header = wrap_v2(&accepted, "TXDATA"); - let decoded: Value = - serde_json::from_slice(&B64.decode(header).unwrap()).unwrap(); + let decoded: Value = serde_json::from_slice(&B64.decode(header).unwrap()).unwrap(); assert_eq!(decoded["x402Version"], 2); assert_eq!(decoded["payload"]["transaction"], "TXDATA"); // The accepted block must be echoed verbatim for the server's @@ -466,9 +464,13 @@ mod tests { .await; let url = format!("{}/solana/agenttohuman?description=ping", server.uri()); - let out = execute_paid(&reqwest::Client::new(), &FakeEnvelopeSigner, &plan(&url, 1_000_000)) - .await - .expect("paid"); + let out = execute_paid( + &reqwest::Client::new(), + &FakeEnvelopeSigner, + &plan(&url, 1_000_000), + ) + .await + .expect("paid"); assert_eq!(out.status, 200); assert_eq!(out.paid_amount.as_deref(), Some("10000")); let body: Value = serde_json::from_str(&out.body).unwrap(); @@ -485,9 +487,13 @@ mod tests { .mount(&server) .await; let url = format!("{}/solana/agenttohuman?description=ping", server.uri()); - let err = execute_paid(&reqwest::Client::new(), &FakeEnvelopeSigner, &plan(&url, 999_999)) - .await - .expect_err("over cap"); + let err = execute_paid( + &reqwest::Client::new(), + &FakeEnvelopeSigner, + &plan(&url, 999_999), + ) + .await + .expect_err("over cap"); assert!(matches!(err, WurkError::NotAllowed(_)), "got {err:?}"); } @@ -496,13 +502,19 @@ mod tests { let server = MockServer::start().await; Mock::given(method("GET")) .and(path("/solana/agenttohuman")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "ok": true }))) + .respond_with( + ResponseTemplate::new(200).set_body_json(serde_json::json!({ "ok": true })), + ) .mount(&server) .await; let url = format!("{}/solana/agenttohuman?action=view&secret=x", server.uri()); - let out = execute_paid(&reqwest::Client::new(), &FakeEnvelopeSigner, &plan(&url, 1_000_000)) - .await - .expect("free"); + let out = execute_paid( + &reqwest::Client::new(), + &FakeEnvelopeSigner, + &plan(&url, 1_000_000), + ) + .await + .expect("free"); assert_eq!(out.status, 200); assert!(out.paid_amount.is_none()); } diff --git a/agent-os/crates/covenantd/src/wurk.rs b/agent-os/crates/covenantd/src/wurk.rs index f22862a73..7dddb4000 100644 --- a/agent-os/crates/covenantd/src/wurk.rs +++ b/agent-os/crates/covenantd/src/wurk.rs @@ -154,7 +154,12 @@ impl WurkExecutor for DaemonWurkExecutor { secret, page ); - let resp = self.http.get(&url).send().await.map_err(|e| e.to_string())?; + let resp = self + .http + .get(&url) + .send() + .await + .map_err(|e| e.to_string())?; let status = resp.status(); let body = resp.text().await.map_err(|e| e.to_string())?; if !status.is_success() { @@ -230,7 +235,11 @@ mod tests { #[tokio::test] async fn create_refuses_when_x402_disabled() { - let e = exec(X402Config::default(), agent(1), Arc::new(InMemoryLedger::new())); + let e = exec( + X402Config::default(), + agent(1), + Arc::new(InMemoryLedger::new()), + ); let err = e.create(req()).await.expect_err("disabled"); assert!(err.contains("disabled"), "got: {err}"); } From f0e3dd0b81009cdefcbf90a9b4501c752e17f19a Mon Sep 17 00:00:00 2001 From: mizuki0x <197570892+mizuki0x@users.noreply.github.com> Date: Wed, 17 Jun 2026 18:11:37 +0200 Subject: [PATCH 4/4] test(wurk): live daemon job_status integration test (env-gated, ignored) --- agent-os/crates/covenantd/src/lib.rs | 86 ++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/agent-os/crates/covenantd/src/lib.rs b/agent-os/crates/covenantd/src/lib.rs index b678602bd..f362ba172 100644 --- a/agent-os/crates/covenantd/src/lib.rs +++ b/agent-os/crates/covenantd/src/lib.rs @@ -46651,6 +46651,92 @@ required = {caps:?} } } + /// Live WURK daemon path: capability gate, routing, wurk_tool_call, + /// DaemonWurkExecutor.view, the real WURK API. Free (no payment), so + /// no signer or USDC is needed. The job secret comes from + /// WURK_VIEW_SECRET so nothing sensitive is committed. Ignored by + /// default because it hits the network; run with `--ignored`. + #[cfg(unix)] + #[tokio::test] + #[ignore = "live: hits the real WURK API; set WURK_VIEW_SECRET"] + async fn live_wurk_job_status_through_daemon() { + let Ok(secret) = std::env::var("WURK_VIEW_SECRET") else { + eprintln!("skip: set WURK_VIEW_SECRET to run the live WURK daemon view test"); + return; + }; + let identity = Arc::new(LocalIdentity::generate("user@local")); + let cfg = covenant_wurk::WurkConfig { + enabled: true, + ..Default::default() + }; + let s = Server::new( + Arc::new(Router::from_cards(vec![])), + Arc::new(MockRunner::new("")), + Arc::new(InMemoryStore::new()), + Arc::new(InMemorySettlement::new()), + Arc::new(covenant_audit::InMemoryAuditLog::new()), + Arc::new(covenant_permissions::InMemoryCapabilityStore::new()), + Arc::new(covenant_llm::MockEmbedder::new(64)), + identity.clone(), + Arc::new(IgnoreSet::default()), + Arc::new(ToolRegistry::default()), + Arc::new(covenant_a2a::InMemoryMailbox::new()), + Arc::new(covenant_peer_auth::InMemoryPeerRegistry::new()), + Arc::new(covenant_budget::InMemoryLedger::new()), + ) + .with_x402_dispatch(x402::X402Config { + enabled: true, + signer_binary: "/nonexistent-signer".into(), + signer_env: vec![], + }) + .with_wurk(wurk::WurkState::new(cfg)); + + match s.op_respond(Request::ListTools).await { + Response::ToolList { tools } => { + let names: Vec = tools.into_iter().map(|t| t.name).collect(); + assert!( + names.iter().any(|n| n == "wurk.job_status"), + "wurk tools must be advertised: {names:?}" + ); + } + other => panic!("unexpected: {other:?}"), + } + + s.op_respond(Request::GrantCapability { + action: "tool.call.wurk.job_status".into(), + scope: None, + expires_at: None, + }) + .await; + + let resp = s + .op_respond(Request::CallTool { + name: "wurk.job_status".into(), + arguments: serde_json::json!({ "secret": secret, "page": 1 }), + }) + .await; + + match resp { + Response::ToolResult { content, is_error } => { + assert!(!is_error, "expected success, got {content:?}"); + let data = content + .iter() + .find_map(|c| match c { + covenant_mcp::Content::Json { value } => Some(value.clone()), + _ => None, + }) + .expect("json content"); + assert_eq!(data["ok"], true, "live view ok"); + assert!(data["submissions"].is_array(), "live submissions present"); + eprintln!( + "live wurk.job_status via daemon: {} submissions", + data["submissions"].as_array().map(|a| a.len()).unwrap_or(0) + ); + } + other => panic!("expected ToolResult, got {other:?}"), + } + } + /// Full Hyre path: capability gate → executor → 402-then-pay loop /// (against the live Hyre challenge shape) → budget debit + /// settlement receipt + audit event. The signer is a shell script