mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-31 16:49:34 +00:00
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:
@@ -56,6 +56,7 @@ fn bootstrap_env_round_trips_llm_backend() {
|
||||
for backend in &[
|
||||
"nearai",
|
||||
"anthropic",
|
||||
"github_copilot",
|
||||
"ollama",
|
||||
"openai_compatible",
|
||||
"tinfoil",
|
||||
|
||||
@@ -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
@@ -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 ──────────────────────────────────────────
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,6 +198,7 @@ mod tests {
|
||||
http_interceptor: None,
|
||||
transcription: None,
|
||||
document_extraction: None,
|
||||
sandbox_readiness: ironclaw::agent::SandboxReadiness::DisabledByConfig,
|
||||
builder: None,
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
|
||||
Reference in New Issue
Block a user