merge: resolve origin/staging into feat/gemini-cli-oauth

Merge staging to pick up GitHub Copilot provider, OpenAI Codex provider,
and other recent changes. Both gemini_oauth and openai_codex backends are
now registered as dedicated configs with proper credential guards.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
2026-03-21 15:18:13 -07:00
co-authored by Claude Opus 4.6
152 changed files with 18517 additions and 2122 deletions
+1
View File
@@ -56,6 +56,7 @@ fn bootstrap_env_round_trips_llm_backend() {
for backend in &[
"nearai",
"anthropic",
"github_copilot",
"ollama",
"openai_compatible",
"tinfoil",
-1
View File
@@ -45,7 +45,6 @@ mod tests {
title: "Test job".to_string(),
description: "Test description".to_string(),
max_iterations: 5,
tool_permissions: vec![],
},
guardrails: RoutineGuardrails {
cooldown: std::time::Duration::from_secs(0),
+14 -4
View File
@@ -267,14 +267,24 @@ async def _stream_tool_call(request: web.Request, cid: str, tc: dict) -> web.Str
async def oauth_exchange(request: web.Request) -> web.Response:
"""Mock OAuth token exchange proxy for E2E tests.
Accepts form params (code, redirect_uri, code_verifier) and returns
a fake token response. Called by ironclaw's exchange_via_proxy() when
IRONCLAW_OAUTH_EXCHANGE_URL is set.
Accepts the generic hosted OAuth proxy contract used by IronClaw and
returns a fake token response. MCP callback tests assert that provider-
specific token params such as RFC 8707 `resource` are forwarded here.
"""
data = await request.post()
code = data.get("code", "")
access_token_field = data.get("access_token_field", "access_token")
if code == "mock_mcp_code":
if not data.get("token_url", "").endswith("/oauth/token"):
return web.json_response({"error": "missing_token_url"}, status=400)
if not data.get("client_id"):
return web.json_response({"error": "missing_client_id"}, status=400)
if not data.get("resource"):
return web.json_response({"error": "missing_resource"}, status=400)
return web.json_response({
"access_token": f"mock-token-{code}",
access_token_field: f"mock-token-{code}",
"refresh_token": "mock-refresh-token",
"expires_in": 3600,
})
@@ -99,6 +99,10 @@ async def test_mcp_activate_triggers_auth(ironclaw_server):
assert auth_url is not None or awaiting_token, (
f"Activate should require auth, got: {data}"
)
if auth_url is not None:
assert _extract_state(auth_url).startswith("ic2."), (
f"Hosted MCP OAuth should emit versioned state, got: {auth_url}"
)
# ── Section C: OAuth Round-Trip ──────────────────────────────────────────
+82 -36
View File
@@ -4,7 +4,6 @@ These tests exercise the explicit owner model across:
- the web gateway chat UI
- the owner-scoped HTTP webhook channel
- routine tools / routines tab
- job creation via routine execution / jobs tab
"""
import asyncio
@@ -13,7 +12,13 @@ import uuid
import httpx
from helpers import SEL, AUTH_TOKEN, signed_http_webhook_headers
from helpers import (
AUTH_TOKEN,
SEL,
api_get,
api_post,
signed_http_webhook_headers,
)
async def _send_and_get_response(
@@ -58,13 +63,14 @@ async def _post_http_webhook(
content: str,
sender_id: str,
thread_id: str,
) -> str:
wait_for_response: bool = True,
) -> str | None:
"""Send a signed request to the owner-scoped HTTP webhook channel."""
payload = {
"user_id": sender_id,
"thread_id": thread_id,
"content": content,
"wait_for_response": True,
"wait_for_response": wait_for_response,
}
body = json.dumps(payload).encode("utf-8")
@@ -81,8 +87,9 @@ async def _post_http_webhook(
)
data = response.json()
assert data["status"] == "accepted", f"Unexpected webhook response: {data}"
assert data["response"], f"Expected synchronous response body, got: {data}"
return data["response"]
if wait_for_response:
assert data["response"], f"Expected synchronous response body, got: {data}"
return data.get("response")
async def _open_tab(page, tab: str) -> None:
@@ -112,22 +119,60 @@ async def _wait_for_routine(base_url: str, name: str, timeout: float = 20.0) ->
raise AssertionError(f"Routine '{name}' was not created within {timeout}s")
async def _wait_for_job(base_url: str, title: str, timeout: float = 30.0) -> dict:
"""Poll the jobs API until the named job exists."""
async with httpx.AsyncClient() as client:
for _ in range(int(timeout * 2)):
response = await client.get(
f"{base_url}/api/jobs",
headers={"Authorization": f"Bearer {AUTH_TOKEN}"},
timeout=10,
)
response.raise_for_status()
jobs = response.json()["jobs"]
for job in jobs:
if job["title"] == title:
return job
await _poll_sleep()
raise AssertionError(f"Job '{title}' was not created within {timeout}s")
async def _wait_for_http_thread(base_url: str, title_fragment: str, timeout: float = 20.0) -> str:
"""Poll the chat thread list until the matching HTTP thread is visible."""
for _ in range(int(timeout * 2)):
response = await api_get(base_url, "/api/chat/threads", timeout=10)
response.raise_for_status()
threads = response.json()["threads"]
for thread in threads:
if thread.get("channel") != "http":
continue
if title_fragment in (thread.get("title") or ""):
return thread["id"]
await _poll_sleep()
raise AssertionError(
f"HTTP thread containing '{title_fragment}' was not visible within {timeout}s"
)
async def _wait_for_pending_approval(
base_url: str,
thread_id: str,
timeout: float = 20.0,
) -> dict:
"""Poll chat history until the thread exposes a pending approval payload."""
for _ in range(int(timeout * 2)):
response = await api_get(
base_url,
f"/api/chat/history?thread_id={thread_id}",
timeout=10,
)
response.raise_for_status()
pending = response.json().get("pending_approval")
if pending:
return pending
await _poll_sleep()
raise AssertionError(f"Thread '{thread_id}' did not expose a pending approval")
async def _approve_pending_request(base_url: str, thread_id: str, request_id: str) -> None:
"""Approve a pending tool request through the web gateway API."""
response = await api_post(
base_url,
"/api/chat/approval",
json={
"request_id": request_id,
"action": "approve",
"thread_id": thread_id,
},
timeout=10,
)
assert response.status_code == 202, (
f"Approval submission failed: {response.status_code} {response.text[:400]}"
)
data = response.json()
assert data["status"] == "accepted", f"Unexpected approval response: {data}"
async def _poll_sleep() -> None:
@@ -194,33 +239,34 @@ async def test_web_created_routine_is_listed_from_http_channel_across_senders(
assert routine_name in second_sender_text, second_sender_text
async def test_http_created_full_job_routine_can_be_run_from_web_and_shows_in_jobs(
async def test_http_created_full_job_routine_is_visible_in_web_after_approval(
page,
ironclaw_server,
http_channel_server,
):
"""A full-job routine created via HTTP can be run from the web UI and create a job."""
"""A full-job routine created via HTTP appears in the web owner UI after approval."""
routine_name = f"owner-job-{uuid.uuid4().hex[:8]}"
response_text = await _post_http_webhook(
await _post_http_webhook(
http_channel_server,
content=f"create full-job owner routine {routine_name}",
sender_id="http-job-sender",
thread_id="owner-job-thread",
wait_for_response=False,
)
assert routine_name in response_text
await _wait_for_routine(ironclaw_server, routine_name)
thread_id = await _wait_for_http_thread(ironclaw_server, routine_name)
pending = await _wait_for_pending_approval(ironclaw_server, thread_id)
assert pending["tool_name"] == "routine_create"
await _approve_pending_request(
ironclaw_server,
thread_id,
pending["request_id"],
)
routine = await _wait_for_routine(ironclaw_server, routine_name)
assert routine["action_type"] == "full_job"
await _open_tab(page, "routines")
routine_row = page.locator(SEL["routine_row"]).filter(has_text=routine_name).first
await routine_row.wait_for(state="visible", timeout=15000)
await routine_row.locator('button[data-action="trigger-routine"]').click()
await _wait_for_job(ironclaw_server, routine_name, timeout=45.0)
await _open_tab(page, "jobs")
await page.locator(SEL["job_row"]).filter(has_text=routine_name).first.wait_for(
state="visible",
timeout=20000,
)
+206
View File
@@ -705,4 +705,210 @@ mod advanced {
mock_server.shutdown().await;
rig.shutdown();
}
// -----------------------------------------------------------------------
// 9. Bootstrap greeting fires on fresh workspace
// -----------------------------------------------------------------------
/// Verifies that a fresh workspace triggers a static bootstrap greeting
/// before the user sends any message (no LLM call needed).
#[tokio::test]
async fn bootstrap_greeting_fires() {
let rig = TestRigBuilder::new().with_bootstrap().build().await;
// The static bootstrap greeting should arrive without us sending any
// message and without an LLM call.
let responses = rig.wait_for_responses(1, TIMEOUT).await;
assert!(
!responses.is_empty(),
"bootstrap greeting should produce a response"
);
let greeting = &responses[0].content;
assert!(
greeting.contains("chief of staff"),
"bootstrap greeting should contain the static text, got: {greeting}"
);
// The bootstrap greeting must carry a thread_id so the gateway can
// route it to the correct assistant conversation.
assert!(
responses[0].thread_id.is_some(),
"bootstrap greeting response should have a thread_id set"
);
rig.shutdown();
}
// -----------------------------------------------------------------------
// 10. Bootstrap onboarding completes and clears BOOTSTRAP.md
// -----------------------------------------------------------------------
/// Exercises the full onboarding flow: bootstrap greeting fires, user
/// converses for 3 turns, agent writes profile + memory + identity,
/// clears BOOTSTRAP.md, and the workspace reflects all writes.
#[tokio::test]
async fn bootstrap_onboarding_clears_bootstrap() {
use ironclaw::workspace::paths;
let trace = LlmTrace::from_file(format!("{FIXTURES}/bootstrap_onboarding.json")).unwrap();
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_bootstrap()
.build()
.await;
// 1. Wait for the static bootstrap greeting (no user message needed).
let greeting_responses = rig.wait_for_responses(1, TIMEOUT).await;
assert!(
!greeting_responses.is_empty(),
"bootstrap greeting should arrive"
);
assert!(
greeting_responses[0].content.contains("chief of staff"),
"expected bootstrap greeting, got: {}",
greeting_responses[0].content
);
// 2. BOOTSTRAP.md should exist (non-empty) before onboarding completes.
let ws = rig.workspace().expect("workspace should exist");
let bootstrap_before = ws.read(paths::BOOTSTRAP).await;
assert!(
bootstrap_before.is_ok_and(|d| !d.content.is_empty()),
"BOOTSTRAP.md should be non-empty before onboarding"
);
// 3. Run the 3-turn conversation. The trace has the agent write
// profile, memory, identity, and then clear bootstrap.
let mut total = 1; // already have the greeting
for turn in &trace.turns {
rig.send_message(&turn.user_input).await;
total += 1;
let _ = rig.wait_for_responses(total, TIMEOUT).await;
}
// 4. Verify all memory_write calls succeeded.
let completed = rig.tool_calls_completed();
let memory_writes: Vec<_> = completed
.iter()
.filter(|(name, _)| name == "memory_write")
.collect();
assert!(
memory_writes.len() >= 4,
"expected at least 4 memory_write calls (profile, memory, identity, bootstrap), got: {memory_writes:?}"
);
assert!(
memory_writes.iter().all(|(_, ok)| *ok),
"all memory_write calls should succeed: {memory_writes:?}"
);
// 5. BOOTSTRAP.md should now be empty (cleared by memory_write target=bootstrap).
let bootstrap_after = ws.read(paths::BOOTSTRAP).await.expect("read BOOTSTRAP");
assert!(
bootstrap_after.content.is_empty(),
"BOOTSTRAP.md should be empty after onboarding, got: {:?}",
bootstrap_after.content
);
// 6. The bootstrap-completed flag should be set (prevents re-injection).
assert!(
ws.is_bootstrap_completed(),
"bootstrap_completed flag should be set after profile write"
);
// 7. Profile should exist in workspace with expected fields.
let profile = ws.read(paths::PROFILE).await.expect("read profile");
assert!(
!profile.content.is_empty(),
"profile.json should not be empty"
);
assert!(
profile.content.contains("Alex"),
"profile should contain preferred_name, got: {:?}",
&profile.content[..profile.content.len().min(200)]
);
// Try parsing the stored profile to catch deserialization issues early.
let stored = ws
.read(paths::PROFILE)
.await
.expect("read profile for deser test");
let deser_result =
serde_json::from_str::<ironclaw::profile::PsychographicProfile>(&stored.content);
assert!(
deser_result.is_ok(),
"profile should deserialize: {:?}\ncontent: {:?}",
deser_result.err(),
&stored.content[..stored.content.len().min(300)]
);
let parsed = deser_result.unwrap();
assert!(
parsed.is_populated(),
"profile should be populated: name={:?}, profession={:?}, goals={:?}",
parsed.preferred_name,
parsed.context.profession,
parsed.assistance.goals
);
// Manually trigger sync.
let synced = ws
.sync_profile_documents()
.await
.expect("sync_profile_documents");
assert!(
synced,
"sync_profile_documents should return true for a populated profile"
);
assert!(
profile.content.contains("backend engineer"),
"profile should contain profession"
);
assert!(
profile.content.contains("distributed systems"),
"profile should contain interests"
);
// 8. USER.md should have been synced from the profile via sync_profile_documents().
let user_doc = ws.read(paths::USER).await.expect("read USER.md");
assert!(
user_doc.content.contains("Alex"),
"USER.md should contain user name from profile, got: {:?}",
&user_doc.content[..user_doc.content.len().min(300)]
);
assert!(
user_doc.content.contains("direct"),
"USER.md should contain communication tone from profile, got: {:?}",
&user_doc.content[..user_doc.content.len().min(300)]
);
assert!(
user_doc.content.contains("backend engineer"),
"USER.md should contain profession from profile, got: {:?}",
&user_doc.content[..user_doc.content.len().min(300)]
);
// 9. Assistant directives should have been synced from the profile.
let directives = ws
.read(paths::ASSISTANT_DIRECTIVES)
.await
.expect("read assistant-directives.md");
assert!(
directives.content.contains("Alex"),
"assistant-directives should reference user name, got: {:?}",
&directives.content[..directives.content.len().min(300)]
);
assert!(
directives.content.contains("direct"),
"assistant-directives should reflect communication style, got: {:?}",
&directives.content[..directives.content.len().min(300)]
);
// 10. IDENTITY.md should have been written by the agent.
let identity = ws.read(paths::IDENTITY).await.expect("read IDENTITY.md");
assert!(
identity.content.contains("Claw"),
"IDENTITY.md should contain the chosen agent name, got: {:?}",
identity.content
);
rig.shutdown();
}
}
+2 -15
View File
@@ -356,13 +356,8 @@ mod tests {
}
match &routine.action {
RoutineAction::FullJob {
description,
tool_permissions,
..
} => {
RoutineAction::FullJob { description, .. } => {
assert!(description.contains("Summarize the new issue"));
assert_eq!(tool_permissions, &vec!["shell".to_string()]);
}
other => panic!("expected full_job action, got {other:?}"),
}
@@ -410,16 +405,8 @@ mod tests {
}
match &routine.action {
RoutineAction::FullJob {
description,
tool_permissions,
..
} => {
RoutineAction::FullJob { description, .. } => {
assert!(description.contains("Prepare the morning digest"));
assert_eq!(
tool_permissions,
&vec!["message".to_string(), "http".to_string()]
);
}
other => panic!("expected full_job action, got {other:?}"),
}
+673 -13
View File
@@ -8,39 +8,117 @@ mod support;
#[cfg(feature = "libsql")]
mod tests {
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use chrono::Utc;
use libsql::params;
use secrecy::SecretString;
use uuid::Uuid;
use ironclaw::agent::routine::{
NotifyConfig, Routine, RoutineAction, RoutineGuardrails, Trigger,
NotifyConfig, Routine, RoutineAction, RoutineGuardrails, RoutineRun, RunStatus, Trigger,
};
use ironclaw::agent::routine_engine::RoutineEngine;
use ironclaw::agent::{HeartbeatConfig, HeartbeatRunner};
use ironclaw::agent::{
HeartbeatConfig, HeartbeatRunner, SandboxReadiness, Scheduler, SchedulerDeps,
};
use ironclaw::channels::IncomingMessage;
use ironclaw::config::{RoutineConfig, SafetyConfig};
use ironclaw::db::Database;
use ironclaw::config::{AgentConfig, RoutineConfig, SafetyConfig};
use ironclaw::context::{ContextManager, JobContext};
use ironclaw::db::{Database, libsql::LibSqlBackend};
use ironclaw::extensions::ExtensionManager;
use ironclaw::hooks::HookRegistry;
use ironclaw::llm::LlmProvider;
use ironclaw::safety::SafetyLayer;
use ironclaw::tools::ToolRegistry;
use ironclaw::secrets::{InMemorySecretsStore, SecretsCrypto, SecretsStore};
use ironclaw::tools::builtin::routine::RoutineUpdateTool;
use ironclaw::tools::mcp::{McpProcessManager, McpSessionManager};
use ironclaw::tools::{ApprovalRequirement, Tool, ToolError, ToolOutput, ToolRegistry};
use ironclaw::workspace::Workspace;
use ironclaw::workspace::hygiene::HygieneConfig;
use crate::support::trace_llm::{LlmTrace, TraceLlm, TraceResponse, TraceStep};
use crate::support::trace_llm::{LlmTrace, TraceLlm, TraceResponse, TraceStep, TraceToolCall};
const OWNER_GATE_COUNT_SETTING_KEY: &str = "tests.owner_gate_count";
struct OwnerGateTool {
store: Arc<dyn Database>,
}
#[async_trait::async_trait]
impl Tool for OwnerGateTool {
fn name(&self) -> &str {
"owner_gate"
}
fn description(&self) -> &str {
"Test-only tool gated by owner full_job permissions"
}
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({
"type": "object",
"properties": {}
})
}
async fn execute(
&self,
_params: serde_json::Value,
ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
let start = std::time::Instant::now();
let current = self
.store
.get_setting(&ctx.user_id, OWNER_GATE_COUNT_SETTING_KEY)
.await
.map_err(|e| {
ToolError::ExecutionFailed(format!("failed to read owner gate count: {e}"))
})?
.and_then(|value| value.as_i64())
.unwrap_or(0);
self.store
.set_setting(
&ctx.user_id,
OWNER_GATE_COUNT_SETTING_KEY,
&serde_json::json!(current + 1),
)
.await
.map_err(|e| {
ToolError::ExecutionFailed(format!("failed to persist owner gate count: {e}"))
})?;
Ok(ToolOutput::text("owner gate executed", start.elapsed()))
}
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
ApprovalRequirement::Always
}
fn requires_sanitization(&self) -> bool {
false
}
}
/// Create a temp libSQL database with migrations applied.
async fn create_test_db() -> (Arc<dyn Database>, tempfile::TempDir) {
use ironclaw::db::libsql::LibSqlBackend;
let (backend, temp_dir) = create_test_backend().await;
let db: Arc<dyn Database> = backend;
(db, temp_dir)
}
async fn create_test_backend() -> (Arc<LibSqlBackend>, tempfile::TempDir) {
let temp_dir = tempfile::tempdir().expect("tempdir");
let db_path = temp_dir.path().join("test.db");
let backend = LibSqlBackend::new_local(&db_path)
.await
.expect("LibSqlBackend");
let backend = Arc::new(
LibSqlBackend::new_local(&db_path)
.await
.expect("LibSqlBackend"),
);
backend.run_migrations().await.expect("migrations");
let db: Arc<dyn Database> = Arc::new(backend);
(db, temp_dir)
(backend, temp_dir)
}
/// Create a workspace backed by the test database.
@@ -93,6 +171,246 @@ mod tests {
}
}
fn make_full_job_routine(name: &str) -> Routine {
Routine {
id: Uuid::new_v4(),
name: name.to_string(),
description: format!("Full-job test routine: {name}"),
user_id: "default".to_string(),
enabled: true,
trigger: Trigger::Manual,
action: RoutineAction::FullJob {
title: name.to_string(),
description: "Use the owner-gated tool when permitted.".to_string(),
max_iterations: 3,
},
guardrails: RoutineGuardrails {
cooldown: Duration::from_secs(0),
max_concurrent: 1,
dedup_window: None,
},
notify: NotifyConfig::default(),
last_run_at: None,
next_fire_at: None,
run_count: 0,
consecutive_failures: 0,
state: serde_json::json!({}),
created_at: Utc::now(),
updated_at: Utc::now(),
}
}
fn owner_gate_trace(include_completion: bool) -> LlmTrace {
let mut steps = vec![TraceStep {
request_hint: None,
response: TraceResponse::ToolCalls {
tool_calls: vec![TraceToolCall {
id: "call_owner_gate".to_string(),
name: "owner_gate".to_string(),
arguments: serde_json::json!({}),
}],
input_tokens: 40,
output_tokens: 10,
},
expected_tool_results: vec![],
}];
if include_completion {
// The worker first calls `select_tools()`, then falls back to
// `respond_with_tools()` when no tool calls are returned. Both
// methods consume a trace step, so the successful completion path
// needs two text responses after the tool call.
for _ in 0..2 {
steps.push(TraceStep {
request_hint: None,
response: TraceResponse::Text {
content: "I have completed the task.".to_string(),
input_tokens: 20,
output_tokens: 5,
},
expected_tool_results: vec![],
});
}
}
LlmTrace::single_turn("test-owner-gate", "run owner gate", steps)
}
fn owner_gate_lightweight_trace() -> LlmTrace {
LlmTrace::single_turn(
"test-owner-gate-lightweight",
"run owner gate",
vec![
TraceStep {
request_hint: None,
response: TraceResponse::ToolCalls {
tool_calls: vec![TraceToolCall {
id: "call_owner_gate".to_string(),
name: "owner_gate".to_string(),
arguments: serde_json::json!({}),
}],
input_tokens: 40,
output_tokens: 10,
},
expected_tool_results: vec![],
},
TraceStep {
request_hint: None,
response: TraceResponse::Text {
content: "ROUTINE_OK".to_string(),
input_tokens: 20,
output_tokens: 5,
},
expected_tool_results: vec![],
},
],
)
}
async fn write_test_extension_wasm(tools_dir: &Path, name: &str) {
tokio::fs::create_dir_all(tools_dir)
.await
.expect("create test wasm tools dir");
tokio::fs::write(tools_dir.join(format!("{name}.wasm")), b"\0asm")
.await
.expect("write test wasm tool marker");
}
fn make_test_extension_manager(
tools: Arc<ToolRegistry>,
tools_dir: &Path,
owner_id: &str,
) -> Arc<ExtensionManager> {
let crypto = Arc::new(
SecretsCrypto::new(SecretString::from(
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
))
.expect("test crypto"),
);
let secrets: Arc<dyn SecretsStore + Send + Sync> =
Arc::new(InMemorySecretsStore::new(crypto));
Arc::new(ExtensionManager::new(
Arc::new(McpSessionManager::new()),
Arc::new(McpProcessManager::new()),
secrets,
tools,
None,
None,
tools_dir.to_path_buf(),
tools_dir.join("channels"),
None,
owner_id.to_string(),
None,
Vec::new(),
))
}
async fn setup_owner_gate_engine(
db: Arc<dyn Database>,
trace: LlmTrace,
tools_dir: &Path,
extension_owner_id: Option<&str>,
activate_owner_gate: bool,
) -> Arc<RoutineEngine> {
let ws = create_workspace(&db);
let (notify_tx, _rx) = tokio::sync::mpsc::channel(16);
let registry = Arc::new(ToolRegistry::new());
if extension_owner_id.is_some() {
registry
.register(Arc::new(OwnerGateTool { store: db.clone() }))
.await;
}
if activate_owner_gate {
write_test_extension_wasm(tools_dir, "owner_gate").await;
}
let safety = Arc::new(SafetyLayer::new(&SafetyConfig {
max_output_length: 100_000,
injection_check_enabled: false,
}));
let llm: Arc<dyn LlmProvider> = Arc::new(TraceLlm::from_trace(trace));
let extension_manager = extension_owner_id
.map(|owner_id| make_test_extension_manager(registry.clone(), tools_dir, owner_id));
let scheduler = Arc::new(Scheduler::new(
AgentConfig::for_testing(),
Arc::new(ContextManager::new(5)),
llm.clone(),
safety.clone(),
SchedulerDeps {
tools: registry.clone(),
extension_manager: extension_manager.clone(),
store: Some(db.clone()),
hooks: Arc::new(HookRegistry::new()),
},
));
Arc::new(RoutineEngine::new(
RoutineConfig::default(),
db,
llm,
ws,
notify_tx,
Some(scheduler),
extension_manager,
registry,
safety,
SandboxReadiness::Available,
))
}
async fn owner_gate_count(db: &Arc<dyn Database>) -> i64 {
db.get_setting("default", OWNER_GATE_COUNT_SETTING_KEY)
.await
.expect("get owner gate count")
.and_then(|value| value.as_i64())
.unwrap_or(0)
}
async fn wait_for_run_completion(
db: &Arc<dyn Database>,
routine_id: Uuid,
run_id: Uuid,
) -> RoutineRun {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
let runs = db
.list_routine_runs(routine_id, 10)
.await
.expect("list_routine_runs");
if let Some(run) = runs.into_iter().find(|run| run.id == run_id)
&& run.status != RunStatus::Running
{
return run;
}
assert!(
std::time::Instant::now() < deadline,
"timed out waiting for routine run {run_id} to complete"
);
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
async fn wait_for_any_run_completion(db: &Arc<dyn Database>, routine_id: Uuid) -> RoutineRun {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
let runs = db
.list_routine_runs(routine_id, 10)
.await
.expect("list_routine_runs");
if let Some(run) = runs
.into_iter()
.find(|run| run.status != RunStatus::Running)
{
return run;
}
assert!(
std::time::Instant::now() < deadline,
"timed out waiting for any routine run for {routine_id} to complete"
);
tokio::time::sleep(Duration::from_millis(100)).await;
}
}
// -----------------------------------------------------------------------
// Test 1: cron_routine_fires
// -----------------------------------------------------------------------
@@ -135,8 +453,10 @@ mod tests {
ws,
notify_tx,
None,
None,
tools,
safety,
SandboxReadiness::DisabledByConfig,
));
// Insert a cron routine with next_fire_at in the past.
@@ -212,8 +532,10 @@ mod tests {
ws,
notify_tx,
None,
None,
tools,
safety,
SandboxReadiness::DisabledByConfig,
));
// Insert an event routine matching "deploy.*production".
@@ -305,8 +627,10 @@ mod tests {
ws,
notify_tx,
None,
None,
tools,
safety,
SandboxReadiness::DisabledByConfig,
));
let routine = make_routine(
@@ -412,8 +736,10 @@ mod tests {
ws,
notify_tx,
None,
None,
tools,
safety,
SandboxReadiness::DisabledByConfig,
));
let mut filters = std::collections::HashMap::new();
@@ -553,8 +879,10 @@ mod tests {
ws,
notify_tx,
None,
None,
tools,
safety,
SandboxReadiness::DisabledByConfig,
));
// Insert an event routine with 1-hour cooldown.
@@ -738,8 +1066,10 @@ mod tests {
ws,
notify_tx,
None,
None,
tools,
safety,
SandboxReadiness::DisabledByConfig,
));
(engine, db, dir)
@@ -867,8 +1197,10 @@ mod tests {
ws,
notify_tx,
None, // no scheduler — rejected before dispatch
None,
tools,
safety,
SandboxReadiness::DisabledByConfig,
));
// Create a full_job routine with max_concurrent = 1
@@ -883,7 +1215,6 @@ mod tests {
title: "t".to_string(),
description: "d".to_string(),
max_iterations: 3,
tool_permissions: vec![],
},
guardrails: RoutineGuardrails {
cooldown: Duration::from_secs(0),
@@ -974,8 +1305,10 @@ mod tests {
ws,
notify_tx,
None,
None,
tools,
safety,
SandboxReadiness::DisabledByConfig,
));
// Insert a due cron routine
@@ -1029,4 +1362,331 @@ mod tests {
"cron routine should fire after global slot is released"
);
}
// -----------------------------------------------------------------------
// Test: lightweight manual routines use the owner's active extension tools
// -----------------------------------------------------------------------
#[tokio::test]
async fn lightweight_manual_routine_uses_active_owner_extension_tool() {
let (backend, tmp) = create_test_backend().await;
let db: Arc<dyn Database> = backend;
let tools_dir = tmp.path().join("wasm-tools");
let engine = setup_owner_gate_engine(
db.clone(),
owner_gate_lightweight_trace(),
tools_dir.as_path(),
Some("default"),
true,
)
.await;
let mut routine = make_routine("manual-owner-gate", Trigger::Manual, "Use owner_gate.");
if let RoutineAction::Lightweight { use_tools, .. } = &mut routine.action {
*use_tools = true;
}
db.create_routine(&routine).await.expect("create_routine");
let run_id = engine
.fire_manual(routine.id, None)
.await
.expect("fire manual");
let run = wait_for_run_completion(&db, routine.id, run_id).await;
assert_eq!(run.status, RunStatus::Ok);
assert_eq!(owner_gate_count(&db).await, 1);
}
// -----------------------------------------------------------------------
// Test: full_job cron routines use the owner's active extension tools
// -----------------------------------------------------------------------
#[tokio::test]
async fn full_job_cron_routine_uses_active_owner_extension_tool() {
let (backend, tmp) = create_test_backend().await;
let db: Arc<dyn Database> = backend;
let tools_dir = tmp.path().join("wasm-tools");
let engine = setup_owner_gate_engine(
db.clone(),
owner_gate_trace(true),
tools_dir.as_path(),
Some("default"),
true,
)
.await;
let mut routine = make_full_job_routine("cron-owner-gate");
routine.trigger = Trigger::Cron {
schedule: "* * * * *".to_string(),
timezone: None,
};
routine.next_fire_at = Some(Utc::now() - chrono::Duration::minutes(1));
db.create_routine(&routine).await.expect("create_routine");
engine.check_cron_triggers().await;
let run = wait_for_any_run_completion(&db, routine.id).await;
assert_eq!(run.status, RunStatus::Ok);
assert_eq!(owner_gate_count(&db).await, 1);
}
// -----------------------------------------------------------------------
// Test: lightweight event routines use the owner's active extension tools
// -----------------------------------------------------------------------
#[tokio::test]
async fn lightweight_event_routine_uses_active_owner_extension_tool() {
let (backend, tmp) = create_test_backend().await;
let db: Arc<dyn Database> = backend;
let tools_dir = tmp.path().join("wasm-tools");
let engine = setup_owner_gate_engine(
db.clone(),
owner_gate_lightweight_trace(),
tools_dir.as_path(),
Some("default"),
true,
)
.await;
let mut routine = make_routine(
"event-owner-gate",
Trigger::Event {
channel: None,
pattern: "owner-gate".to_string(),
},
"Use owner_gate.",
);
if let RoutineAction::Lightweight { use_tools, .. } = &mut routine.action {
*use_tools = true;
}
db.create_routine(&routine).await.expect("create_routine");
engine.refresh_event_cache().await;
let fired = engine
.check_event_triggers("default", "test", "owner-gate")
.await;
assert_eq!(fired, 1, "expected one matching event routine");
let run = wait_for_any_run_completion(&db, routine.id).await;
assert_eq!(run.status, RunStatus::Ok);
assert_eq!(owner_gate_count(&db).await, 1);
}
// -----------------------------------------------------------------------
// Test: full_job system-event routines use the owner's active extension tools
// -----------------------------------------------------------------------
#[tokio::test]
async fn full_job_system_event_routine_uses_active_owner_extension_tool() {
let (backend, tmp) = create_test_backend().await;
let db: Arc<dyn Database> = backend;
let tools_dir = tmp.path().join("wasm-tools");
let engine = setup_owner_gate_engine(
db.clone(),
owner_gate_trace(true),
tools_dir.as_path(),
Some("default"),
true,
)
.await;
let mut routine = make_full_job_routine("system-owner-gate");
routine.trigger = Trigger::SystemEvent {
source: "github".to_string(),
event_type: "issue.opened".to_string(),
filters: std::collections::HashMap::new(),
};
db.create_routine(&routine).await.expect("create_routine");
engine.refresh_event_cache().await;
let fired = engine
.emit_system_event(
"github",
"issue.opened",
&serde_json::json!({"issue_number": 7}),
Some("default"),
)
.await;
assert_eq!(fired, 1, "expected one matching system_event routine");
let run = wait_for_any_run_completion(&db, routine.id).await;
assert_eq!(run.status, RunStatus::Ok);
assert_eq!(owner_gate_count(&db).await, 1);
}
// -----------------------------------------------------------------------
// Test: autonomous runs fail loudly when an extension tool is inactive
// -----------------------------------------------------------------------
#[tokio::test]
async fn full_job_blocks_without_active_owner_extension_tool() {
let (backend, tmp) = create_test_backend().await;
let db: Arc<dyn Database> = backend;
let tools_dir = tmp.path().join("wasm-tools");
let engine = setup_owner_gate_engine(
db.clone(),
owner_gate_trace(false),
tools_dir.as_path(),
Some("default"),
false,
)
.await;
let routine = make_full_job_routine("inactive-owner-gate");
db.create_routine(&routine).await.expect("create_routine");
let run_id = engine
.fire_manual(routine.id, None)
.await
.expect("fire manual");
let run = wait_for_run_completion(&db, routine.id, run_id).await;
assert_eq!(run.status, RunStatus::Failed);
assert_eq!(owner_gate_count(&db).await, 0);
let failure_reason = db
.get_agent_job_failure_reason(run.job_id.expect("linked job id"))
.await
.expect("load job failure reason")
.expect("missing job failure reason");
assert!(
failure_reason.contains("owner_gate"),
"expected missing-tool failure reason, got {failure_reason}"
);
}
// -----------------------------------------------------------------------
// Test: extension tools activated for another owner are not inherited
// -----------------------------------------------------------------------
#[tokio::test]
async fn full_job_blocks_when_extension_belongs_to_another_owner() {
let (backend, tmp) = create_test_backend().await;
let db: Arc<dyn Database> = backend;
let tools_dir = tmp.path().join("wasm-tools");
let engine = setup_owner_gate_engine(
db.clone(),
owner_gate_trace(false),
tools_dir.as_path(),
Some("someone-else"),
true,
)
.await;
let routine = make_full_job_routine("other-owner-gate");
db.create_routine(&routine).await.expect("create_routine");
let run_id = engine
.fire_manual(routine.id, None)
.await
.expect("fire manual");
let run = wait_for_run_completion(&db, routine.id, run_id).await;
assert_eq!(run.status, RunStatus::Failed);
assert_eq!(owner_gate_count(&db).await, 0);
let failure_reason = db
.get_agent_job_failure_reason(run.job_id.expect("linked job id"))
.await
.expect("load job failure reason")
.expect("missing job failure reason");
assert!(
failure_reason.contains("owner_gate"),
"expected owner-mismatch failure reason, got {failure_reason}"
);
}
// -----------------------------------------------------------------------
// Test: legacy permission fields are ignored on read and removed on rewrite
// -----------------------------------------------------------------------
#[tokio::test]
async fn legacy_full_job_permission_fields_are_ignored_and_removed_on_update() {
let (backend, tmp) = create_test_backend().await;
let db: Arc<dyn Database> = backend.clone();
let legacy_routine = make_full_job_routine("legacy-full-job");
db.create_routine(&legacy_routine)
.await
.expect("create_routine");
let conn = backend.connect().await.expect("connect");
conn.execute(
"UPDATE routines SET action_config = ?1 WHERE id = ?2",
params![
serde_json::json!({
"title": legacy_routine.name,
"description": "Use the owner-gated tool when permitted.",
"max_iterations": 3,
"tool_permissions": ["owner_gate"],
"permission_mode": "inherit_owner",
})
.to_string(),
legacy_routine.id.to_string(),
],
)
.await
.expect("inject legacy permission fields into action_config");
let loaded = db
.get_routine(legacy_routine.id)
.await
.expect("get_routine")
.expect("routine should still exist");
assert!(matches!(
loaded.action,
RoutineAction::FullJob {
ref title,
ref description,
max_iterations,
} if title == "legacy-full-job"
&& description == "Use the owner-gated tool when permitted."
&& max_iterations == 3
));
let tools_dir = tmp.path().join("wasm-tools");
let engine = setup_owner_gate_engine(
db.clone(),
owner_gate_trace(false),
tools_dir.as_path(),
None,
false,
)
.await;
let update_tool = RoutineUpdateTool::new(db.clone(), engine);
let update_ctx = JobContext::with_user("default", "update", "update legacy routine");
update_tool
.execute(
serde_json::json!({
"name": legacy_routine.name,
"prompt": "Updated legacy description",
}),
&update_ctx,
)
.await
.expect("routine_update should succeed");
let mut rows = conn
.query(
"SELECT action_config FROM routines WHERE id = ?1",
params![legacy_routine.id.to_string()],
)
.await
.expect("select updated action_config");
let row = rows
.next()
.await
.expect("next row")
.expect("updated routine row");
let action_config_raw: String = row.get(0).expect("action_config text");
let action_config: serde_json::Value =
serde_json::from_str(&action_config_raw).expect("parse updated action_config");
assert_eq!(
action_config,
serde_json::json!({
"title": "legacy-full-job",
"description": "Updated legacy description",
"max_iterations": 3,
})
);
}
}
+1
View File
@@ -198,6 +198,7 @@ mod tests {
http_interceptor: None,
transcription: None,
document_extraction: None,
sandbox_readiness: ironclaw::agent::SandboxReadiness::DisabledByConfig,
builder: None,
};
+408
View File
@@ -343,4 +343,412 @@ mod tests {
rig.shutdown();
}
/// Fixture tool that mirrors the github WASM tool's `oneOf` discriminated
/// union schema. Uses `#[serde(tag = "action")]` deserialization — exactly
/// what the real tool does — so if coercion fails the test reproduces:
/// `invalid type: string "100", expected u32`
struct GitHubFixtureTool;
#[derive(Debug, Deserialize)]
#[serde(tag = "action")]
enum GitHubFixtureAction {
#[serde(rename = "list_issues")]
ListIssues {
owner: String,
repo: String,
#[serde(default)]
state: Option<String>,
#[serde(default)]
limit: Option<u32>,
},
#[serde(rename = "get_issue")]
GetIssue {
owner: String,
repo: String,
issue_number: u32,
},
#[serde(rename = "list_pull_requests")]
ListPullRequests {
owner: String,
repo: String,
#[serde(default)]
limit: Option<u32>,
#[serde(default)]
page: Option<u32>,
},
#[serde(rename = "create_pull_request")]
CreatePullRequest {
owner: String,
repo: String,
title: String,
head: String,
base: String,
#[serde(default)]
draft: Option<bool>,
},
}
use serde::Deserialize;
#[async_trait]
impl Tool for GitHubFixtureTool {
fn name(&self) -> &str {
"github_fixture"
}
fn description(&self) -> &str {
"Fixture mirroring the github WASM tool's oneOf schema"
}
fn parameters_schema(&self) -> serde_json::Value {
json!({
"type": "object",
"required": ["action"],
"oneOf": [
{
"properties": {
"action": { "const": "list_issues" },
"owner": { "type": "string" },
"repo": { "type": "string" },
"state": { "type": "string", "enum": ["open", "closed", "all"] },
"limit": { "type": "integer", "default": 30 }
},
"required": ["action", "owner", "repo"]
},
{
"properties": {
"action": { "const": "get_issue" },
"owner": { "type": "string" },
"repo": { "type": "string" },
"issue_number": { "type": "integer" }
},
"required": ["action", "owner", "repo", "issue_number"]
},
{
"properties": {
"action": { "const": "list_pull_requests" },
"owner": { "type": "string" },
"repo": { "type": "string" },
"limit": { "type": "integer", "default": 30 },
"page": { "type": "integer" }
},
"required": ["action", "owner", "repo"]
},
{
"properties": {
"action": { "const": "create_pull_request" },
"owner": { "type": "string" },
"repo": { "type": "string" },
"title": { "type": "string" },
"head": { "type": "string" },
"base": { "type": "string" },
"draft": { "type": "boolean", "default": false }
},
"required": ["action", "owner", "repo", "title", "head", "base"]
}
]
})
}
async fn execute(
&self,
params: serde_json::Value,
_ctx: &JobContext,
) -> Result<ToolOutput, ToolError> {
// Deserialize exactly like the real github WASM tool does.
// Without coercion, this fails: `invalid type: string "100", expected u32`
let action: GitHubFixtureAction = serde_json::from_value(params).map_err(|e| {
ToolError::InvalidParameters(format!("serde deserialization failed: {e}"))
})?;
let result = match action {
GitHubFixtureAction::ListIssues {
owner,
repo,
state,
limit,
} => json!({
"action": "list_issues",
"owner": owner,
"repo": repo,
"state": state.unwrap_or_else(|| "open".to_string()),
"limit": limit.unwrap_or(30),
}),
GitHubFixtureAction::GetIssue {
owner,
repo,
issue_number,
} => json!({
"action": "get_issue",
"owner": owner,
"repo": repo,
"issue_number": issue_number,
}),
GitHubFixtureAction::ListPullRequests {
owner,
repo,
limit,
page,
} => json!({
"action": "list_pull_requests",
"owner": owner,
"repo": repo,
"limit": limit.unwrap_or(30),
"page": page.unwrap_or(1),
}),
GitHubFixtureAction::CreatePullRequest {
owner,
repo,
title,
head,
base,
draft,
} => json!({
"action": "create_pull_request",
"owner": owner,
"repo": repo,
"title": title,
"head": head,
"base": base,
"draft": draft.unwrap_or(false),
}),
};
Ok(ToolOutput::success(result, Duration::from_millis(1)))
}
fn requires_sanitization(&self) -> bool {
false
}
}
/// Reproduces the exact bug: LLM sends `limit: "100"` and `issue_number: "42"`
/// as strings to a `oneOf` discriminated union schema. Without coercion support
/// for combinators, serde fails with `invalid type: string "100", expected u32`.
#[tokio::test]
async fn e2e_coerces_oneof_discriminated_union_params() {
let trace = LlmTrace {
model_name: "test-coercion-oneof".to_string(),
turns: vec![crate::support::trace_llm::TraceTurn {
user_input: "List issues in nearai/ironclaw with limit 100".to_string(),
steps: vec![
TraceStep {
request_hint: None,
response: TraceResponse::ToolCalls {
tool_calls: vec![TraceToolCall {
id: "call_gh_list".to_string(),
name: "github_fixture".to_string(),
// LLM sends numeric params as strings — the exact bug
arguments: json!({
"action": "list_issues",
"owner": "nearai",
"repo": "ironclaw",
"state": "open",
"limit": "100"
}),
}],
input_tokens: 100,
output_tokens: 30,
},
expected_tool_results: Vec::new(),
},
TraceStep {
request_hint: None,
response: TraceResponse::Text {
content: "Found issues in nearai/ironclaw with limit 100.".to_string(),
input_tokens: 150,
output_tokens: 20,
},
expected_tool_results: Vec::new(),
},
],
expects: TraceExpects::default(),
}],
memory_snapshot: Vec::new(),
http_exchanges: Vec::new(),
expects: TraceExpects {
tools_used: vec!["github_fixture".to_string()],
all_tools_succeeded: Some(true),
max_tool_calls: Some(1),
min_responses: Some(1),
..Default::default()
},
steps: Vec::new(),
};
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_extra_tools(vec![Arc::new(GitHubFixtureTool)])
.build()
.await;
rig.send_message("List issues in nearai/ironclaw with limit 100")
.await;
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
rig.verify_trace_expects(&trace, &responses);
let tool_results = rig.tool_results();
assert!(
tool_results
.iter()
.any(|(name, preview)| name == "github_fixture"
&& preview.contains("\"limit\"")
&& preview.contains("100")),
"expected coerced list_issues result, got {tool_results:?}"
);
rig.shutdown();
}
/// Tests a second oneOf variant with different string-to-integer coercions:
/// `issue_number: "42"` must be coerced to match the `get_issue` variant.
#[tokio::test]
async fn e2e_coerces_oneof_get_issue_variant() {
let trace = LlmTrace {
model_name: "test-coercion-oneof-issue".to_string(),
turns: vec![crate::support::trace_llm::TraceTurn {
user_input: "Get issue 42 from nearai/ironclaw".to_string(),
steps: vec![
TraceStep {
request_hint: None,
response: TraceResponse::ToolCalls {
tool_calls: vec![TraceToolCall {
id: "call_gh_issue".to_string(),
name: "github_fixture".to_string(),
arguments: json!({
"action": "get_issue",
"owner": "nearai",
"repo": "ironclaw",
"issue_number": "42"
}),
}],
input_tokens: 80,
output_tokens: 20,
},
expected_tool_results: Vec::new(),
},
TraceStep {
request_hint: None,
response: TraceResponse::Text {
content: "Issue 42 retrieved.".to_string(),
input_tokens: 100,
output_tokens: 10,
},
expected_tool_results: Vec::new(),
},
],
expects: TraceExpects::default(),
}],
memory_snapshot: Vec::new(),
http_exchanges: Vec::new(),
expects: TraceExpects {
tools_used: vec!["github_fixture".to_string()],
all_tools_succeeded: Some(true),
max_tool_calls: Some(1),
min_responses: Some(1),
..Default::default()
},
steps: Vec::new(),
};
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_extra_tools(vec![Arc::new(GitHubFixtureTool)])
.build()
.await;
rig.send_message("Get issue 42 from nearai/ironclaw").await;
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
rig.verify_trace_expects(&trace, &responses);
let tool_results = rig.tool_results();
assert!(
tool_results
.iter()
.any(|(name, preview)| name == "github_fixture"
&& preview.contains("\"issue_number\"")
&& preview.contains("42")),
"expected coerced get_issue result, got {tool_results:?}"
);
rig.shutdown();
}
/// Tests boolean coercion in a oneOf variant: `draft: "true"` must become
/// a boolean for the `create_pull_request` variant.
#[tokio::test]
async fn e2e_coerces_oneof_boolean_in_variant() {
let trace = LlmTrace {
model_name: "test-coercion-oneof-bool".to_string(),
turns: vec![crate::support::trace_llm::TraceTurn {
user_input: "Create a draft PR".to_string(),
steps: vec![
TraceStep {
request_hint: None,
response: TraceResponse::ToolCalls {
tool_calls: vec![TraceToolCall {
id: "call_gh_pr".to_string(),
name: "github_fixture".to_string(),
arguments: json!({
"action": "create_pull_request",
"owner": "nearai",
"repo": "ironclaw",
"title": "Fix coercion",
"head": "fix/coercion",
"base": "main",
"draft": "true"
}),
}],
input_tokens: 90,
output_tokens: 25,
},
expected_tool_results: Vec::new(),
},
TraceStep {
request_hint: None,
response: TraceResponse::Text {
content: "Draft PR created.".to_string(),
input_tokens: 110,
output_tokens: 10,
},
expected_tool_results: Vec::new(),
},
],
expects: TraceExpects::default(),
}],
memory_snapshot: Vec::new(),
http_exchanges: Vec::new(),
expects: TraceExpects {
tools_used: vec!["github_fixture".to_string()],
all_tools_succeeded: Some(true),
max_tool_calls: Some(1),
min_responses: Some(1),
..Default::default()
},
steps: Vec::new(),
};
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_extra_tools(vec![Arc::new(GitHubFixtureTool)])
.build()
.await;
rig.send_message("Create a draft PR").await;
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
rig.verify_trace_expects(&trace, &responses);
let tool_results = rig.tool_results();
assert!(
tool_results
.iter()
.any(|(name, preview)| name == "github_fixture"
&& preview.contains("\"draft\"")
&& preview.contains("true")),
"expected coerced create_pull_request result with draft=true, got {tool_results:?}"
);
rig.shutdown();
}
}
+277
View File
@@ -0,0 +1,277 @@
//! E2E test: real github WASM tool with parameter coercion via TestRig.
//!
//! Loads the compiled github WASM binary into the test rig, replays an LLM
//! trace that sends string-typed numeric params, and verifies the WASM tool
//! constructs the correct HTTP API call via `http_exchanges` in the trace.
//!
//! These tests are `#[ignore]` by default because they require a pre-compiled
//! WASM binary. Build it with:
//! cargo build -p github-tool --target wasm32-wasip2 --release
//! Then run with:
//! cargo test --features libsql --test e2e_wasm_github_coercion -- --ignored
#[cfg(feature = "libsql")]
mod support;
/// Note on URL verification: the `ReplayingHttpInterceptor` logs warnings on
/// URL mismatch but still returns the canned response. The real verification is
/// that the tool succeeds end-to-end: coercion produced the correct typed
/// parameters, serde deserialization succeeded, and the WASM tool constructed a
/// valid HTTP request. A URL mismatch warning in logs does not indicate test
/// failure — it is a soft check only.
#[cfg(feature = "libsql")]
mod tests {
use std::time::Duration;
use serde_json::json;
use ironclaw::llm::recording::{HttpExchange, HttpExchangeRequest, HttpExchangeResponse};
use crate::support::test_rig::TestRigBuilder;
use crate::support::trace_llm::{
LlmTrace, TraceExpects, TraceResponse, TraceStep, TraceToolCall,
};
const GITHUB_WASM: &str = "tools-src/github/target/wasm32-wasip2/release/github_tool.wasm";
const GITHUB_CAPS: &str = "tools-src/github/github-tool.capabilities.json";
fn github_ok(body: &str) -> HttpExchangeResponse {
HttpExchangeResponse {
status: 200,
headers: vec![
("content-type".to_string(), "application/json".to_string()),
("x-ratelimit-remaining".to_string(), "100".to_string()),
],
body: body.to_string(),
}
}
/// LLM sends `limit: "50"` (string) to `list_issues`. Coercion converts it
/// to integer, and the WASM tool must call `GET /repos/.../issues?...&per_page=50`.
#[tokio::test]
#[ignore] // requires pre-compiled WASM binary
async fn wasm_github_list_issues_coerces_string_limit() {
let expected_url =
"https://api.github.com/repos/nearai/ironclaw/issues?state=open&per_page=50";
let trace = LlmTrace {
model_name: "test-wasm-coercion-list-issues".to_string(),
turns: vec![crate::support::trace_llm::TraceTurn {
user_input: "List issues in nearai/ironclaw with limit 50".to_string(),
steps: vec![
TraceStep {
request_hint: None,
response: TraceResponse::ToolCalls {
tool_calls: vec![TraceToolCall {
id: "call_gh_1".to_string(),
name: "github".to_string(),
arguments: json!({
"action": "list_issues",
"owner": "nearai",
"repo": "ironclaw",
"state": "open",
"limit": "50"
}),
}],
input_tokens: 100,
output_tokens: 30,
},
expected_tool_results: Vec::new(),
},
TraceStep {
request_hint: None,
response: TraceResponse::Text {
content: "Found 1 issue.".to_string(),
input_tokens: 150,
output_tokens: 10,
},
expected_tool_results: Vec::new(),
},
],
expects: TraceExpects::default(),
}],
memory_snapshot: Vec::new(),
http_exchanges: vec![HttpExchange {
request: HttpExchangeRequest {
method: "GET".to_string(),
url: expected_url.to_string(),
headers: vec![],
body: None,
},
response: github_ok(r#"[{"number":1,"title":"Test issue","state":"open"}]"#),
}],
expects: TraceExpects {
tools_used: vec!["github".to_string()],
all_tools_succeeded: Some(true),
max_tool_calls: Some(1),
min_responses: Some(1),
..Default::default()
},
steps: Vec::new(),
};
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_wasm_tool("github", GITHUB_WASM, Some(GITHUB_CAPS.into()))
.build()
.await;
rig.send_message("List issues in nearai/ironclaw with limit 50")
.await;
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
rig.verify_trace_expects(&trace, &responses);
rig.shutdown();
}
/// LLM sends `issue_number: "42"` (string) to `get_issue`. Coercion converts
/// it to integer, and the URL must contain `/issues/42`.
#[tokio::test]
#[ignore] // requires pre-compiled WASM binary
async fn wasm_github_get_issue_coerces_string_issue_number() {
let expected_url = "https://api.github.com/repos/nearai/ironclaw/issues/42";
let trace = LlmTrace {
model_name: "test-wasm-coercion-get-issue".to_string(),
turns: vec![crate::support::trace_llm::TraceTurn {
user_input: "Get issue 42 from nearai/ironclaw".to_string(),
steps: vec![
TraceStep {
request_hint: None,
response: TraceResponse::ToolCalls {
tool_calls: vec![TraceToolCall {
id: "call_gh_2".to_string(),
name: "github".to_string(),
arguments: json!({
"action": "get_issue",
"owner": "nearai",
"repo": "ironclaw",
"issue_number": "42"
}),
}],
input_tokens: 80,
output_tokens: 20,
},
expected_tool_results: Vec::new(),
},
TraceStep {
request_hint: None,
response: TraceResponse::Text {
content: "Issue 42 retrieved.".to_string(),
input_tokens: 100,
output_tokens: 10,
},
expected_tool_results: Vec::new(),
},
],
expects: TraceExpects::default(),
}],
memory_snapshot: Vec::new(),
http_exchanges: vec![HttpExchange {
request: HttpExchangeRequest {
method: "GET".to_string(),
url: expected_url.to_string(),
headers: vec![],
body: None,
},
response: github_ok(r#"{"number":42,"title":"Test","state":"open","body":"desc"}"#),
}],
expects: TraceExpects {
tools_used: vec!["github".to_string()],
all_tools_succeeded: Some(true),
max_tool_calls: Some(1),
min_responses: Some(1),
..Default::default()
},
steps: Vec::new(),
};
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_wasm_tool("github", GITHUB_WASM, Some(GITHUB_CAPS.into()))
.build()
.await;
rig.send_message("Get issue 42 from nearai/ironclaw").await;
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
rig.verify_trace_expects(&trace, &responses);
rig.shutdown();
}
/// LLM sends `limit: "25"` (string) to `list_pull_requests`. URL must
/// contain `per_page=25`.
#[tokio::test]
#[ignore] // requires pre-compiled WASM binary
async fn wasm_github_list_prs_coerces_string_limit() {
let expected_url =
"https://api.github.com/repos/nearai/ironclaw/pulls?state=open&per_page=25";
let trace = LlmTrace {
model_name: "test-wasm-coercion-list-prs".to_string(),
turns: vec![crate::support::trace_llm::TraceTurn {
user_input: "List PRs in nearai/ironclaw".to_string(),
steps: vec![
TraceStep {
request_hint: None,
response: TraceResponse::ToolCalls {
tool_calls: vec![TraceToolCall {
id: "call_gh_3".to_string(),
name: "github".to_string(),
arguments: json!({
"action": "list_pull_requests",
"owner": "nearai",
"repo": "ironclaw",
"limit": "25"
}),
}],
input_tokens: 80,
output_tokens: 20,
},
expected_tool_results: Vec::new(),
},
TraceStep {
request_hint: None,
response: TraceResponse::Text {
content: "Found PRs.".to_string(),
input_tokens: 100,
output_tokens: 10,
},
expected_tool_results: Vec::new(),
},
],
expects: TraceExpects::default(),
}],
memory_snapshot: Vec::new(),
http_exchanges: vec![HttpExchange {
request: HttpExchangeRequest {
method: "GET".to_string(),
url: expected_url.to_string(),
headers: vec![],
body: None,
},
response: github_ok(r#"[{"number":1,"title":"Test PR","state":"open"}]"#),
}],
expects: TraceExpects {
tools_used: vec!["github".to_string()],
all_tools_succeeded: Some(true),
max_tool_calls: Some(1),
min_responses: Some(1),
..Default::default()
},
steps: Vec::new(),
};
let rig = TestRigBuilder::new()
.with_trace(trace.clone())
.with_wasm_tool("github", GITHUB_WASM, Some(GITHUB_CAPS.into()))
.build()
.await;
rig.send_message("List PRs in nearai/ironclaw").await;
let responses = rig.wait_for_responses(1, Duration::from_secs(15)).await;
rig.verify_trace_expects(&trace, &responses);
rig.shutdown();
}
}
@@ -0,0 +1,122 @@
{
"model_name": "bootstrap-onboarding-flow",
"expects": {
"tools_used": ["memory_write"],
"all_tools_succeeded": true,
"min_responses": 2
},
"turns": [
{
"user_input": "Hey! I'm Alex, a backend engineer working on distributed systems. I like direct, concise communication.",
"steps": [
{
"request_hint": { "last_user_message_contains": "Alex" },
"response": {
"type": "text",
"content": "Nice to meet you, Alex. Distributed systems — solid domain. I'll keep things direct.\n\nWhat's on your plate right now that I can help with? And any tools or services you use daily that I should know about?",
"input_tokens": 800,
"output_tokens": 40
}
}
]
},
{
"user_input": "I mainly use GitHub and Slack. I need help staying on top of PRs and incident response.",
"steps": [
{
"request_hint": { "last_user_message_contains": "GitHub" },
"response": {
"type": "text",
"content": "Got it — GitHub PRs and incident response are your priorities. I can set up routines for both.\n\nOne more thing: when something goes wrong at work, what's your instinct — jump in and debug, or step back and coordinate?",
"input_tokens": 900,
"output_tokens": 45
}
}
]
},
{
"user_input": "I usually jump in and start debugging. I'm pretty hands-on.",
"steps": [
{
"request_hint": { "last_user_message_contains": "debugging" },
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_profile_write",
"name": "memory_write",
"arguments": {
"content": "{\"version\":2,\"preferred_name\":\"Alex\",\"personality\":{\"empathy\":50,\"problem_solving\":50,\"emotional_intelligence\":50,\"adaptability\":50,\"communication\":50},\"communication\":{\"detail_level\":\"concise\",\"formality\":\"casual\",\"tone\":\"direct\",\"learning_style\":\"unknown\",\"social_energy\":\"unknown\",\"decision_making\":\"unknown\",\"pace\":\"fast\",\"response_speed\":\"unknown\"},\"cohort\":{\"cohort\":\"other\",\"confidence\":0,\"indicators\":[]},\"behavior\":{\"frictions\":[],\"desired_outcomes\":[],\"time_wasters\":[],\"pain_points\":[\"staying on top of PRs\",\"incident response\"],\"strengths\":[],\"suggested_support\":[]},\"friendship\":{\"style\":\"unknown\",\"values\":[],\"support_style\":\"unknown\",\"qualities\":{\"user_values\":[],\"friends_appreciate\":[],\"consistency_pattern\":null,\"primary_role\":null,\"secondary_roles\":[],\"challenging_aspects\":[]}},\"assistance\":{\"proactivity\":\"moderate\",\"formality\":\"unknown\",\"focus_areas\":[],\"routines\":[],\"goals\":[\"PR management\",\"incident response\"],\"interaction_style\":\"unknown\",\"notification_preferences\":\"moderate\"},\"context\":{\"profession\":\"backend engineer\",\"interests\":[\"distributed systems\"],\"life_stage\":null,\"challenges\":[]},\"relationship_values\":{\"primary\":[],\"secondary\":[],\"deal_breakers\":[]},\"interaction_preferences\":{\"proactivity_style\":\"reactive\",\"feedback_style\":\"direct\",\"decision_making\":\"guided\"},\"analysis_metadata\":{\"message_count\":0,\"analysis_date\":null,\"time_range\":null,\"model_used\":null,\"confidence_score\":0.0,\"analysis_method\":null,\"update_type\":null},\"confidence\":0.5,\"created_at\":\"2026-03-19T00:00:00Z\",\"updated_at\":\"2026-03-19T00:00:00Z\"}",
"target": "context/profile.json",
"append": false
}
}
],
"input_tokens": 1000,
"output_tokens": 300
}
},
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_memory_save",
"name": "memory_write",
"arguments": {
"content": "User: Alex, backend engineer. Uses GitHub and Slack daily. Priorities: PR management, incident response. Communication: direct, concise, casual. Hands-on debugger.",
"target": "daily_log"
}
}
],
"input_tokens": 1100,
"output_tokens": 50
}
},
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_identity_write",
"name": "memory_write",
"arguments": {
"content": "# Identity\n\nName: Claw\nVibe: Sharp, efficient, no-nonsense\nEmoji: ⚡",
"target": "IDENTITY.md",
"append": false
}
}
],
"input_tokens": 1200,
"output_tokens": 30
}
},
{
"response": {
"type": "tool_calls",
"tool_calls": [
{
"id": "call_bootstrap_clear",
"name": "memory_write",
"arguments": {
"content": "",
"target": "bootstrap"
}
}
],
"input_tokens": 1300,
"output_tokens": 20
}
},
{
"response": {
"type": "text",
"content": "All set, Alex. I'm Claw — I'll keep things fast and direct.\n\nI've saved your profile and preferences. Want me to set up a morning PR review routine for your GitHub repos?",
"input_tokens": 1400,
"output_tokens": 35
}
}
]
}
]
}
+78
View File
@@ -13,6 +13,10 @@ mod support;
mod tests {
use std::time::Duration;
use chrono::Utc;
use ironclaw::agent::routine::{
NotifyConfig, Routine, RoutineAction, RoutineGuardrails, Trigger,
};
use uuid::Uuid;
use crate::support::gateway_workflow_harness::GatewayWorkflowHarness;
@@ -260,4 +264,78 @@ mod tests {
harness.shutdown().await;
mock.shutdown().await;
}
#[tokio::test]
async fn routines_detail_omits_legacy_full_job_permission_surface() {
let mock = MockOpenAiServerBuilder::new()
.with_default_response(MockOpenAiResponse::Text("ack".to_string()))
.start()
.await;
let harness =
GatewayWorkflowHarness::start_openai_compatible(&mock.openai_base_url(), "mock-model")
.await;
let routine = Routine {
id: Uuid::new_v4(),
name: "wf-full-job-permissions".to_string(),
description: "Permission detail regression test".to_string(),
user_id: harness.user_id.clone(),
enabled: true,
trigger: Trigger::Manual,
action: RoutineAction::FullJob {
title: "permission-detail".to_string(),
description: "Check effective permission detail".to_string(),
max_iterations: 3,
},
guardrails: RoutineGuardrails {
cooldown: Duration::from_secs(0),
max_concurrent: 1,
dedup_window: None,
},
notify: NotifyConfig::default(),
last_run_at: None,
next_fire_at: None,
run_count: 0,
consecutive_failures: 0,
state: serde_json::json!({}),
created_at: Utc::now(),
updated_at: Utc::now(),
};
harness
.db
.create_routine(&routine)
.await
.expect("create routine");
let detail = harness
.client
.get(format!(
"{}/api/routines/{}",
harness.base_url(),
routine.id
))
.bearer_auth(&harness.auth_token)
.send()
.await
.expect("detail request failed")
.error_for_status()
.expect("detail non-2xx")
.json::<serde_json::Value>()
.await
.expect("invalid detail response");
assert!(
detail.get("full_job_permissions").is_none(),
"detail response should not expose legacy permission fields: {detail}"
);
assert_eq!(detail["action"]["type"].as_str(), Some("full_job"));
assert_eq!(
detail["action"]["description"].as_str(),
Some("Check effective permission detail")
);
harness.shutdown().await;
mock.shutdown().await;
}
}
+360
View File
@@ -0,0 +1,360 @@
#![cfg(feature = "libsql")]
//! Integration tests for layered memory using file-backed libSQL.
use std::sync::Arc;
use ironclaw::db::Database;
use ironclaw::db::libsql::LibSqlBackend;
use ironclaw::workspace::Workspace;
use ironclaw::workspace::layer::{LayerSensitivity, MemoryLayer};
use ironclaw::workspace::privacy::PatternPrivacyClassifier;
async fn setup() -> (Arc<dyn Database>, tempfile::TempDir) {
let dir = tempfile::tempdir().expect("create temp dir");
let db_path = dir.path().join("test.db");
let backend = LibSqlBackend::new_local(&db_path).await.expect("create db");
backend.run_migrations().await.expect("run migrations");
let db: Arc<dyn Database> = Arc::new(backend);
(db, dir)
}
fn test_layers() -> Vec<MemoryLayer> {
vec![
MemoryLayer {
name: "private".into(),
scope: "alice".into(),
writable: true,
sensitivity: LayerSensitivity::Private,
},
MemoryLayer {
name: "shared".into(),
scope: "shared".into(),
writable: true,
sensitivity: LayerSensitivity::Shared,
},
MemoryLayer {
name: "reports".into(),
scope: "reports".into(),
writable: false,
sensitivity: LayerSensitivity::Shared,
},
]
}
#[tokio::test]
async fn write_to_private_layer() {
let (db, _dir) = setup().await;
let ws = Workspace::new_with_db("alice", db).with_memory_layers(test_layers());
let result = ws
.write_to_layer("private", "notes/test.md", "Private note", false)
.await
.expect("write should succeed");
assert_eq!(result.document.content, "Private note");
assert!(!result.redirected);
assert_eq!(result.actual_layer, "private");
}
#[tokio::test]
async fn write_to_shared_layer() {
let (db, _dir) = setup().await;
let ws = Workspace::new_with_db("alice", db).with_memory_layers(test_layers());
let result = ws
.write_to_layer("shared", "plans/dinner.md", "Dinner Saturday at 6", false)
.await
.expect("write should succeed");
assert_eq!(result.document.content, "Dinner Saturday at 6");
assert!(!result.redirected);
assert_eq!(result.actual_layer, "shared");
}
#[tokio::test]
async fn write_to_read_only_layer_fails() {
let (db, _dir) = setup().await;
let ws = Workspace::new_with_db("alice", db).with_memory_layers(test_layers());
let result = ws
.write_to_layer("reports", "notes/budget.md", "Some budget note", false)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn write_to_unknown_layer_fails() {
let (db, _dir) = setup().await;
let ws = Workspace::new_with_db("alice", db).with_memory_layers(test_layers());
let result = ws
.write_to_layer("nonexistent", "notes/test.md", "content", false)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn no_redirect_without_classifier() {
let (db, _dir) = setup().await;
let ws = Workspace::new_with_db("alice", db).with_memory_layers(test_layers());
// Without a classifier, PII goes exactly where requested
let result = ws
.write_to_layer("shared", "notes/pii.md", "My SSN is 123-45-6789", false)
.await
.expect("write should succeed");
assert!(!result.redirected);
assert_eq!(result.actual_layer, "shared");
}
#[tokio::test]
async fn sensitive_content_redirected_to_private() {
let (db, _dir) = setup().await;
let db_clone = db.clone();
let ws = Workspace::new_with_db("alice", db)
.with_memory_layers(test_layers())
.with_privacy_classifier(Arc::new(PatternPrivacyClassifier::new().unwrap()));
// Write content containing hard PII to shared layer -- should be redirected
let result = ws
.write_to_layer("shared", "notes/pii.md", "My SSN is 123-45-6789", false)
.await
.expect("write should succeed (redirected)");
// WriteResult should indicate redirect to private layer
assert!(result.redirected, "Should be redirected");
assert_eq!(result.actual_layer, "private");
assert_eq!(result.document.content, "My SSN is 123-45-6789");
// Content should be in the private scope (alice), not the shared scope
let private_doc = ws.read("notes/pii.md").await;
assert!(
private_doc.is_ok(),
"Should find content in private scope (alice)"
);
assert_eq!(private_doc.unwrap().content, "My SSN is 123-45-6789");
// Verify content is NOT in the shared scope (same DB, different user_id)
let ws_shared = Workspace::new_with_db("shared", db_clone);
let shared_doc = ws_shared.read("notes/pii.md").await;
assert!(
shared_doc.is_err(),
"Should NOT find content in shared scope"
);
}
#[tokio::test]
async fn default_write_still_works() {
let (db, _dir) = setup().await;
let ws = Workspace::new_with_db("alice", db).with_memory_layers(test_layers());
// Regular write (no layer) should still work
let doc = ws
.write("notes/test.md", "Regular note")
.await
.expect("write should succeed");
assert_eq!(doc.content, "Regular note");
}
#[tokio::test]
async fn append_to_layer_works() {
let (db, _dir) = setup().await;
let ws = Workspace::new_with_db("alice", db).with_memory_layers(test_layers());
// Write initial content to a layer
ws.write_to_layer("private", "notes/log.md", "Entry one", false)
.await
.expect("initial write should succeed");
// Append to the same layer path
let result = ws
.append_to_layer("private", "notes/log.md", "Entry two", false)
.await
.expect("append should succeed");
// Content should be concatenated with double newline
assert!(
result.document.content.contains("Entry one"),
"Should contain first entry"
);
assert!(
result.document.content.contains("Entry two"),
"Should contain second entry"
);
}
#[tokio::test]
async fn sensitive_content_fails_without_private_layer() {
let (db, _dir) = setup().await;
// Workspace with classifier but only shared layers (no private layer for redirect)
let shared_only_layers = vec![MemoryLayer {
name: "shared".into(),
scope: "shared".into(),
writable: true,
sensitivity: LayerSensitivity::Shared,
}];
let ws = Workspace::new_with_db("alice", db)
.with_memory_layers(shared_only_layers)
.with_privacy_classifier(Arc::new(PatternPrivacyClassifier::new().unwrap()));
// Writing PII content should fail (no private layer to redirect to)
let result = ws
.write_to_layer("shared", "notes/pii.md", "My SSN is 123-45-6789", false)
.await;
assert!(
result.is_err(),
"Should fail when no private layer available for redirect"
);
}
#[tokio::test]
async fn append_sensitive_to_shared_redirects() {
let (db, _dir) = setup().await;
let ws = Workspace::new_with_db("alice", db)
.with_memory_layers(test_layers())
.with_privacy_classifier(Arc::new(PatternPrivacyClassifier::new().unwrap()));
// Append PII content to shared layer -- should be redirected
let result = ws
.append_to_layer(
"shared",
"notes/pii.md",
"Card number is 4111 1111 1111 1111",
false,
)
.await
.expect("append should succeed (redirected)");
assert!(result.redirected, "Should be redirected");
assert_eq!(result.actual_layer, "private");
assert!(result.document.content.contains("4111"));
}
#[tokio::test]
async fn force_skips_privacy_redirect() {
let (db, _dir) = setup().await;
let ws = Workspace::new_with_db("alice", db)
.with_memory_layers(test_layers())
.with_privacy_classifier(Arc::new(PatternPrivacyClassifier::new().unwrap()));
// PII content with force=true should stay in shared layer
let result = ws
.write_to_layer("shared", "notes/pii.md", "My SSN is 123-45-6789", true)
.await
.expect("write should succeed without redirect");
assert!(
!result.redirected,
"Should NOT be redirected with force=true"
);
assert_eq!(result.actual_layer, "shared");
}
#[tokio::test]
async fn search_finds_private_layer_content() {
let (db, _dir) = setup().await;
let ws = Workspace::new_with_db("alice", db).with_memory_layers(test_layers());
// Write to the private layer (scope = "alice" = user_id)
ws.write_to_layer(
"private",
"notes/private.md",
"My private thought about waffles",
false,
)
.await
.unwrap();
// Search should find content in the primary scope
let results = ws.search("waffles", 10).await.unwrap();
assert!(
!results.is_empty(),
"Should find results in the private layer"
);
}
#[tokio::test]
async fn write_to_private_invisible_from_shared_scope() {
let (db, _dir) = setup().await;
let db_clone = db.clone();
let ws = Workspace::new_with_db("alice", db).with_memory_layers(test_layers());
ws.write_to_layer("private", "notes/secret.md", "Private data", false)
.await
.expect("write should succeed");
let ws_shared = Workspace::new_with_db("shared", db_clone);
let result = ws_shared.read("notes/secret.md").await;
assert!(
result.is_err(),
"Shared scope must not read private layer content"
);
}
#[tokio::test]
async fn write_to_shared_invisible_from_private_scope() {
let (db, _dir) = setup().await;
let db_clone = db.clone();
let ws = Workspace::new_with_db("alice", db).with_memory_layers(test_layers());
ws.write_to_layer("shared", "plans/visible.md", "Shared plan", false)
.await
.expect("write should succeed");
let ws_alice = Workspace::new_with_db("alice", db_clone);
let result = ws_alice.read("plans/visible.md").await;
assert!(
result.is_err(),
"Private scope must not read shared layer content without multi-scope"
);
}
#[tokio::test]
async fn write_empty_path_to_layer() {
let (db, _dir) = setup().await;
let ws = Workspace::new_with_db("alice", db).with_memory_layers(test_layers());
let result = ws.write_to_layer("private", "", "content", false).await;
// normalize_path("") returns "" — the write succeeds with an empty-string path
assert!(result.is_ok(), "write with empty path should succeed");
let write_result = result.unwrap();
assert_eq!(write_result.document.content, "content");
assert!(!write_result.redirected);
assert_eq!(write_result.actual_layer, "private");
}
#[tokio::test]
async fn overwrite_existing_content_in_layer() {
let (db, _dir) = setup().await;
let ws = Workspace::new_with_db("alice", db).with_memory_layers(test_layers());
ws.write_to_layer("private", "notes/evolving.md", "Version 1", false)
.await
.expect("first write");
let result = ws
.write_to_layer("private", "notes/evolving.md", "Version 2", false)
.await
.expect("overwrite should succeed");
assert_eq!(result.document.content, "Version 2");
assert!(!result.redirected);
}
#[tokio::test]
async fn sensitive_write_to_private_layer_not_redirected() {
let (db, _dir) = setup().await;
let ws = Workspace::new_with_db("alice", db)
.with_memory_layers(test_layers())
.with_privacy_classifier(Arc::new(PatternPrivacyClassifier::new().unwrap()));
let result = ws
.write_to_layer("private", "notes/pii.md", "My SSN is 123-45-6789", false)
.await
.expect("write to private should succeed");
assert!(
!result.redirected,
"Private layer writes should not redirect"
);
assert_eq!(result.actual_layer, "private");
}
+2
View File
@@ -210,6 +210,7 @@ async fn start_test_server_with_provider(
skill_catalog: None,
chat_rate_limiter: ironclaw::channels::web::server::RateLimiter::new(30, 60),
oauth_rate_limiter: ironclaw::channels::web::server::RateLimiter::new(10, 60),
webhook_rate_limiter: ironclaw::channels::web::server::RateLimiter::new(10, 60),
registry_entries: Vec::new(),
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
@@ -702,6 +703,7 @@ async fn test_no_llm_provider_returns_503() {
skill_catalog: None,
chat_rate_limiter: ironclaw::channels::web::server::RateLimiter::new(30, 60),
oauth_rate_limiter: ironclaw::channels::web::server::RateLimiter::new(10, 60),
webhook_rate_limiter: ironclaw::channels::web::server::RateLimiter::new(10, 60),
registry_entries: Vec::new(),
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
@@ -230,6 +230,7 @@ impl GatewayWorkflowHarness {
skill_catalog: components.skill_catalog.clone(),
chat_rate_limiter: RateLimiter::new(120, 60),
oauth_rate_limiter: RateLimiter::new(10, 60),
webhook_rate_limiter: RateLimiter::new(10, 60),
registry_entries: Vec::new(),
cost_guard: Some(Arc::clone(&components.cost_guard)),
routine_engine: Arc::clone(&routine_slot),
@@ -257,6 +258,7 @@ impl GatewayWorkflowHarness {
http_interceptor: None,
transcription: None,
document_extraction: None,
sandbox_readiness: ironclaw::agent::SandboxReadiness::DisabledByConfig,
builder: None,
},
channels,
+14 -4
View File
@@ -25,6 +25,8 @@ use ironclaw::error::ChannelError;
/// A `Channel` implementation for injecting messages and capturing responses
/// in integration tests.
pub struct TestChannel {
/// Channel name returned by `Channel::name()`.
channel_name: String,
/// Sender half for injecting `IncomingMessage`s into the stream.
tx: mpsc::Sender<IncomingMessage>,
/// Receiver half, wrapped in Option so `start()` can take it exactly once.
@@ -59,6 +61,7 @@ impl TestChannel {
let (tx, rx) = mpsc::channel(256);
let (ready_tx, ready_rx) = oneshot::channel();
Self {
channel_name: "test".to_string(),
tx,
rx: Mutex::new(Some(rx)),
responses: Arc::new(Mutex::new(Vec::new())),
@@ -72,6 +75,12 @@ impl TestChannel {
}
}
/// Override the channel name (default: "test").
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.channel_name = name.into();
self
}
/// Signal the channel (and any listening agent) to shut down.
pub fn signal_shutdown(&self) {
self.shutdown.store(true, Ordering::SeqCst);
@@ -87,7 +96,7 @@ impl TestChannel {
/// Inject a user message into the channel stream.
pub async fn send_message(&self, content: &str) {
let msg = IncomingMessage::new("test", &self.user_id, content);
let msg = IncomingMessage::new(&self.channel_name, &self.user_id, content);
self.tx.send(msg).await.expect("TestChannel tx closed");
}
@@ -98,7 +107,8 @@ impl TestChannel {
/// Inject a user message with a specific thread ID.
pub async fn send_message_in_thread(&self, content: &str, thread_id: &str) {
let msg = IncomingMessage::new("test", &self.user_id, content).with_thread(thread_id);
let msg =
IncomingMessage::new(&self.channel_name, &self.user_id, content).with_thread(thread_id);
self.tx.send(msg).await.expect("TestChannel tx closed");
}
@@ -281,7 +291,7 @@ impl Channel for TestChannelHandle {
#[async_trait]
impl Channel for TestChannel {
fn name(&self) -> &str {
"test"
&self.channel_name
}
async fn start(&self) -> Result<MessageStream, ChannelError> {
@@ -291,7 +301,7 @@ impl Channel for TestChannel {
.await
.take()
.ok_or_else(|| ChannelError::StartupFailed {
name: "test".to_string(),
name: self.channel_name.clone(),
reason: "start() already called".to_string(),
})?;
+137 -16
View File
@@ -23,7 +23,7 @@ use crate::support::metrics::{ToolInvocation, TraceMetrics};
use crate::support::test_channel::{TestChannel, TestChannelHandle};
use crate::support::trace_llm::{LlmTrace, TraceLlm};
use ironclaw::llm::recording::{HttpExchange, ReplayingHttpInterceptor};
use ironclaw::llm::recording::{HttpExchange, HttpInterceptor, ReplayingHttpInterceptor};
// ---------------------------------------------------------------------------
// TestRig
@@ -343,6 +343,13 @@ impl Drop for TestRig {
// TestRigBuilder
// ---------------------------------------------------------------------------
/// Specification for loading a real WASM tool in the test rig.
pub struct WasmToolSpec {
pub name: String,
pub wasm_path: std::path::PathBuf,
pub capabilities_path: Option<std::path::PathBuf>,
}
/// Builder for constructing a `TestRig`.
pub struct TestRigBuilder {
trace: Option<LlmTrace>,
@@ -354,6 +361,8 @@ pub struct TestRigBuilder {
enable_routines: bool,
http_exchanges: Vec<HttpExchange>,
extra_tools: Vec<Arc<dyn Tool>>,
wasm_tools: Vec<WasmToolSpec>,
keep_bootstrap: bool,
}
impl TestRigBuilder {
@@ -369,9 +378,34 @@ impl TestRigBuilder {
enable_routines: false,
http_exchanges: Vec::new(),
extra_tools: Vec::new(),
wasm_tools: Vec::new(),
keep_bootstrap: false,
}
}
/// Load a real WASM tool binary into the test rig.
///
/// The tool will be compiled, registered, and wired with the same HTTP
/// interceptor used for `with_http_exchanges()`, so `http_exchanges` in
/// the trace can specify expected requests/responses for WASM tool HTTP calls.
///
/// If the WASM binary does not exist at build time, the tool is silently
/// skipped (logged as a warning). Tests should use `#[ignore]` or check
/// for the binary in a preamble if the tool is required.
pub fn with_wasm_tool(
mut self,
name: impl Into<String>,
wasm_path: impl Into<std::path::PathBuf>,
capabilities_path: Option<std::path::PathBuf>,
) -> Self {
self.wasm_tools.push(WasmToolSpec {
name: name.into(),
wasm_path: wasm_path.into(),
capabilities_path,
});
self
}
/// Set the LLM trace to replay.
pub fn with_trace(mut self, trace: LlmTrace) -> Self {
self.trace = Some(trace);
@@ -426,6 +460,12 @@ impl TestRigBuilder {
self
}
/// Keep `bootstrap_pending` so the proactive greeting fires on startup.
pub fn with_bootstrap(mut self) -> Self {
self.keep_bootstrap = true;
self
}
/// Add pre-recorded HTTP exchanges for the `ReplayingHttpInterceptor`.
///
/// When set, all `http` tool calls will return these responses in order
@@ -457,6 +497,8 @@ impl TestRigBuilder {
enable_routines,
http_exchanges: explicit_http_exchanges,
extra_tools,
wasm_tools,
keep_bootstrap,
} = self;
// 1. Create temp dir + libSQL database + run migrations.
@@ -537,6 +579,12 @@ impl TestRigBuilder {
.await
.expect("AppBuilder::build_all() failed in test rig");
// Clear bootstrap flag so tests don't get an unexpected proactive greeting
// (unless the test explicitly wants to test the bootstrap flow).
if !keep_bootstrap && let Some(ref ws) = components.workspace {
ws.take_bootstrap_pending();
}
// AppBuilder may re-resolve config from env/TOML and override test defaults.
// Force test-rig agent flags to the requested deterministic values.
components.config.agent.auto_approve_tools = auto_approve_tools.unwrap_or(true);
@@ -545,6 +593,20 @@ impl TestRigBuilder {
let scheduler_slot: ironclaw::tools::builtin::SchedulerSlot =
Arc::new(tokio::sync::RwLock::new(None));
// Build HTTP interceptor once — shared by both AgentDeps and WASM tools.
let http_interceptor: Option<Arc<dyn HttpInterceptor>> = {
let exchanges = if explicit_http_exchanges.is_empty() {
trace_http_exchanges
} else {
explicit_http_exchanges
};
if exchanges.is_empty() {
None
} else {
Some(Arc::new(ReplayingHttpInterceptor::new(exchanges)) as Arc<dyn HttpInterceptor>)
}
};
// 6. Register job tools, routine tools, and extra tools.
{
// Ensure filesystem/shell dev tools are always available in the
@@ -576,8 +638,10 @@ impl TestRigBuilder {
Arc::clone(ws),
notify_tx,
None,
None,
components.tools.clone(),
components.safety.clone(),
ironclaw::agent::SandboxReadiness::Available, // tests don't use real Docker
));
components
.tools
@@ -603,6 +667,69 @@ impl TestRigBuilder {
for tool in extra_tools {
components.tools.register(tool).await;
}
// Register WASM tools with the shared HTTP interceptor.
if !wasm_tools.is_empty() {
use ironclaw::tools::wasm::{
Capabilities, CapabilitiesFile, WasmRuntimeConfig, WasmToolRuntime,
WasmToolWrapper,
};
let runtime = Arc::new(
WasmToolRuntime::new(WasmRuntimeConfig::default())
.expect("create WASM runtime for test rig"),
);
for spec in wasm_tools {
if !spec.wasm_path.exists() {
tracing::warn!(
name = %spec.name,
path = %spec.wasm_path.display(),
"WASM tool binary not found, skipping"
);
continue;
}
let wasm_bytes = tokio::fs::read(&spec.wasm_path)
.await
.unwrap_or_else(|e| panic!("read {}: {e}", spec.wasm_path.display()));
let (capabilities, description, schema) =
if let Some(cap_path) = &spec.capabilities_path {
if cap_path.exists() {
let cap_bytes = tokio::fs::read(cap_path)
.await
.unwrap_or_else(|e| panic!("read {}: {e}", cap_path.display()));
let cap_file = CapabilitiesFile::from_bytes(&cap_bytes)
.expect("parse capabilities.json");
(
cap_file.to_capabilities(),
cap_file.description.clone(),
cap_file.parameters.clone(),
)
} else {
(Capabilities::default(), None, None)
}
} else {
(Capabilities::default(), None, None)
};
let prepared = runtime
.prepare(&spec.name, &wasm_bytes, None)
.await
.unwrap_or_else(|e| panic!("prepare WASM tool '{}': {e}", spec.name));
let mut wrapper =
WasmToolWrapper::new(Arc::clone(&runtime), prepared, capabilities);
if let Some(desc) = description {
wrapper = wrapper.with_description(desc);
}
if let Some(s) = schema {
wrapper = wrapper.with_schema(s);
}
if let Some(interceptor) = &http_interceptor {
wrapper = wrapper.with_http_interceptor(Arc::clone(interceptor));
}
components.tools.register(Arc::new(wrapper)).await;
}
}
}
// Save references for test accessors.
@@ -626,27 +753,21 @@ impl TestRigBuilder {
hooks: components.hooks,
cost_guard: components.cost_guard,
sse_tx: None,
http_interceptor: {
// Prefer explicit exchanges from with_http_exchanges(), fall back to trace.
let exchanges = if explicit_http_exchanges.is_empty() {
trace_http_exchanges
} else {
explicit_http_exchanges
};
if exchanges.is_empty() {
None
} else {
Some(Arc::new(ReplayingHttpInterceptor::new(exchanges))
as Arc<dyn ironclaw::llm::recording::HttpInterceptor>)
}
},
http_interceptor,
transcription: None,
document_extraction: None,
sandbox_readiness: ironclaw::agent::SandboxReadiness::Available, // tests don't use real Docker
builder: None,
};
// 7. Create TestChannel and ChannelManager.
let test_channel = Arc::new(TestChannel::new());
// When testing bootstrap, the channel must be named "gateway" because
// the bootstrap greeting targets only the gateway channel.
let test_channel = if keep_bootstrap {
Arc::new(TestChannel::new().with_name("gateway"))
} else {
Arc::new(TestChannel::new())
};
let handle = TestChannelHandle::new(Arc::clone(&test_channel));
let channel_manager = ChannelManager::new();
channel_manager.add(Box::new(handle)).await;
+1 -1
View File
@@ -308,7 +308,7 @@ async fn test_workspace_hybrid_search_with_mock_embeddings() {
// Create workspace with mock embeddings (1536 dimensions to match OpenAI)
let embeddings = Arc::new(MockEmbeddings::new(1536));
let workspace = Workspace::new(user_id, pool.clone()).with_embeddings(embeddings);
let workspace = Workspace::new(user_id, pool.clone()).with_embeddings_uncached(embeddings);
// Write documents
workspace
+1
View File
@@ -58,6 +58,7 @@ async fn start_test_server() -> (
skill_catalog: None,
chat_rate_limiter: ironclaw::channels::web::server::RateLimiter::new(30, 60),
oauth_rate_limiter: ironclaw::channels::web::server::RateLimiter::new(10, 60),
webhook_rate_limiter: ironclaw::channels::web::server::RateLimiter::new(10, 60),
registry_entries: Vec::new(),
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),