mirror of
https://github.com/nearai/ironclaw.git
synced 2026-09-03 08:06:01 +08:00
* test(replay): add approval round-trip fixtures (Phase 2 of #2828) First fixture-driven Layer 1 (replay) coverage of the full v1 approval cycle: pause -> user resolution -> resume. Companion to the existing no_done_emitted_while_awaiting_approval test in e2e_response_order.rs, which covers the pause but not the resume. Three scenarios: - approval_yes: user approves -> tool runs once -> final LLM response - approval_no: user denies -> tool does NOT run -> agent surfaces a built-in rejection message (no follow-up LLM call, by design) - approval_always: allow-always on first call -> second call runs without re-prompting, exactly one ApprovalNeeded total Uses a test-only NeedsApprovalProbe tool with ApprovalRequirement::UnlessAutoApproved registered via TestRig::with_extra_tools, with auto_approve_tools(false) so the agent actually pauses for resolution. The deny-path discovery (no LLM follow-up on rejection) is documented in the test so future readers don't reintroduce the trailing text step. Updates tests/fixtures/llm_traces/README.md to list the new fixtures. Bumps approvals coverage in the harness-testing matrix from ~ to (closer to) full at Layer 1. * test(replay): expand approval coverage with 4 missing scenarios Adds the four approval scenarios that the original three-test set omitted, completing the state-space matrix across ApprovalRequirement variants, the master kill-switch config, and submission-routing edge cases. New tests (all in tests/e2e_approval_traces.rs): - always_requirement_ignores_allow_always_persistence ApprovalRequirement::Always is the unbypassable hard floor — even an 'allow-always' resolution must NOT skip the pause on subsequent calls of an Always-tool. Two pauses for two calls. - slash_approve_routes_as_approval_response '/approve' is parsed as Submission::ApprovalResponse even though bare 'yes' downgrades to UserInput when nothing is pending. Pins the divergent routing in submission.rs. - bare_yes_with_no_pending_approval_is_user_input Bare 'yes' with no pending approval must downgrade to UserInput and reach the LLM as a normal user message. Asserts the routing layer in agent_loop.rs performs the downgrade (parser is stateless). - config_auto_approve_bypasses_unless_auto_approved Agent-config auto_approve_tools=true is the master kill-switch — no ApprovalNeeded is ever emitted, even for UnlessAutoApproved tools. Also adds AlwaysApprovalProbe (mirrors NeedsApprovalProbe but returns ApprovalRequirement::Always) and three fixtures: - approval_always_floor.json - approval_slash.json - approval_bare_yes_no_pending.json README updated to list the new fixtures. Phase 2 of #2828. * test(replay): add auth-gate round-trip fixtures (Phase 2 of #2828) Five replay fixtures covering the engine v2 auth-gate state space: - auth_credential_provided: happy path (CredentialProvided -> resume) - auth_cancelled: user rejects (Cancelled -> resume) - auth_retry_invalid_then_valid: invalid credential, retry path - auth_external_callback: ExternalCallback submission path - auth_gate_request_id: AuthRequired populates request_id (v2 only) Probe tool: MockActivateTool (name "tool_activate") with scriptable output queue, installed via TestRegistry::replace_for_test to bypass PROTECTED_TOOL_NAMES. Planted minimal SKILL.md provides the credential spec needed by AuthManager's submit_auth_token path (otherwise the auth flow short-circuits with "Extension not installed"). Rig additions: - send_gate_auth_resolution(request_id, AuthGateResolution) - send_external_callback(request_id) - with_test_tool_override(tool) builder - TestChannel::channel_name / user_id accessors Serialization: all auth-gate tests share engine_v2_test_lock() (per-file static Mutex) because engine v2 uses a process-global OnceLock<RwLock<Option<EngineState>>>. Fixtures omit tools_used / all_tools_succeeded because engine v2 suppresses ToolStarted/ToolCompleted events when a tool output becomes a gate pause; verification uses the mock's internal execution counter instead. * test(router): cover auth fallback caller path (Phase 2 of #2828) * test(harness): add gateway-ops trace replay runner (#643, Phase 2 of #2828) Introduces Trace/TraceOperation/TraceExpectation types and TraceRunner that replays an ordered sequence of tool invocations against a libSQL test DB. The runner creates ActionRecords via the same save_action path gateway handlers use and matches outcomes against declared expectations. This is the inverse of the agentic TraceLlm harness: where TraceLlm replays an LLM stream and asserts the agent re-produces tool calls, TraceRunner replays caller-dispatched tool calls and asserts the Tool -> ActionRecord -> save_action pipeline matches expectations. Deliverables: - tests/support/trace_runner.rs: Trace, TraceOperation, TraceExpectation (Success { assertions } / Failure { error_contains }), TraceResult (with job_id for DB cross-checks), TraceFailure, TraceRunner with replay(). Assertion DSL supports eq / contains_text / fields (dot-path). - tests/e2e_gateway_trace_harness.rs: 7 integration tests covering echo roundtrip, idempotency, unknown-tool failure, mix assertions, forced mismatch detection, DB persistence via get_job_actions, and cross-run determinism. - tests/fixtures/gateway_traces/: 4 JSON fixtures + README documenting the wire format and the deferred settings_* / extension_* roadmap (blocked on #640 and network-stub work respectively). Pitfalls addressed: - Parent agent_jobs row is created via save_job before the first save_action; job_actions.job_id has a FK to agent_jobs(id) ON DELETE CASCADE that would otherwise fail. - Deterministic-field check in the determinism test excludes id / executed_at / duration (intentionally variable across replays). - ToolError has no NotFound variant; missing-tool lookups are reported via ExecutionFailed("tool not registered: {name}") so Failure expectations can substring-match on "not registered". * fix: address review findings (iteration 1)
443 lines
17 KiB
Rust
443 lines
17 KiB
Rust
//! Replay coverage for the engine-v2 authentication gate round-trip.
|
|
//!
|
|
//! Phase 2 of #2828 — extends the approval fixtures with the typed
|
|
//! `Submission::GateAuthResolution` / `Submission::ExternalCallback`
|
|
//! path that only engine v2 recognizes. The tests drive the full
|
|
//! cycle: LLM → `tool_activate` → GatePaused(Authentication) →
|
|
//! typed resolution → re-run → final response.
|
|
//!
|
|
//! Scenarios:
|
|
//! - `auth_credential_provided_resumes_action` — happy path
|
|
//! - `auth_cancelled_stops_thread` — cancel path
|
|
//! - `auth_retry_after_invalid_credential` — second pause gets a new request_id
|
|
//! - `auth_external_callback_resolves_oauth_gate` — OAuth callback path
|
|
//! - `auth_gate_emits_request_id_for_v2` — invariant: gate carries a Uuid
|
|
|
|
#[cfg(feature = "libsql")]
|
|
mod support;
|
|
|
|
#[cfg(feature = "libsql")]
|
|
mod auth_gate_trace_tests {
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
use std::sync::{Arc, OnceLock};
|
|
use std::time::Duration;
|
|
|
|
use async_trait::async_trait;
|
|
use tokio::sync::Mutex;
|
|
|
|
use crate::support::test_rig::{TestRig, TestRigBuilder};
|
|
use crate::support::trace_llm::LlmTrace;
|
|
use ironclaw::agent::submission::AuthGateResolution;
|
|
use ironclaw::channels::StatusUpdate;
|
|
use ironclaw::context::JobContext;
|
|
use ironclaw::tools::{Tool, ToolError, ToolOutput};
|
|
|
|
const TIMEOUT: Duration = Duration::from_secs(15);
|
|
|
|
/// Serialize all tests in this file because engine v2 stores its state
|
|
/// in a process-global `OnceLock<RwLock<Option<EngineState>>>`.
|
|
/// Running these tests in parallel would let one test's gate_paused
|
|
/// state leak into the next test's engine instance.
|
|
fn engine_v2_test_lock() -> &'static Mutex<()> {
|
|
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
|
|
LOCK.get_or_init(|| Mutex::new(()))
|
|
}
|
|
|
|
/// Test stub for the `tool_activate` built-in. Returns a pre-configured
|
|
/// sequence of JSON outputs so the test can shape the GatePaused path.
|
|
///
|
|
/// Each call pops the next output off the queue; after the queue is
|
|
/// drained the last output is reused (so resumes after the final
|
|
/// gate always see the "ready" state).
|
|
struct MockActivateTool {
|
|
outputs: Arc<std::sync::Mutex<Vec<serde_json::Value>>>,
|
|
executions: Arc<AtomicUsize>,
|
|
}
|
|
|
|
impl MockActivateTool {
|
|
fn new(outputs: Vec<serde_json::Value>) -> (Arc<Self>, Arc<AtomicUsize>) {
|
|
let executions = Arc::new(AtomicUsize::new(0));
|
|
let tool = Arc::new(Self {
|
|
outputs: Arc::new(std::sync::Mutex::new(outputs)),
|
|
executions: executions.clone(),
|
|
});
|
|
(tool, executions)
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Tool for MockActivateTool {
|
|
fn name(&self) -> &str {
|
|
// Must match the protected built-in name so the effect adapter's
|
|
// `auth_gate_from_extension_result` path inspects our output.
|
|
"tool_activate"
|
|
}
|
|
|
|
fn description(&self) -> &str {
|
|
"Test stub for tool_activate that emits scripted auth-gate outputs"
|
|
}
|
|
|
|
fn parameters_schema(&self) -> serde_json::Value {
|
|
serde_json::json!({
|
|
"type": "object",
|
|
"properties": {
|
|
"name": { "type": "string" },
|
|
"token": { "type": "string" }
|
|
},
|
|
"required": ["name"]
|
|
})
|
|
}
|
|
|
|
async fn execute(
|
|
&self,
|
|
_params: serde_json::Value,
|
|
_ctx: &JobContext,
|
|
) -> Result<ToolOutput, ToolError> {
|
|
self.executions.fetch_add(1, Ordering::SeqCst);
|
|
let mut q = self.outputs.lock().expect("outputs lock poisoned");
|
|
let next = if q.len() > 1 {
|
|
q.remove(0)
|
|
} else {
|
|
// Last output — reuse on every subsequent call so the
|
|
// post-credential re-run returns the "ready" state.
|
|
q.first().cloned().unwrap_or(serde_json::json!({}))
|
|
};
|
|
Ok(ToolOutput::success(next, Duration::from_millis(1)))
|
|
}
|
|
}
|
|
|
|
/// Poll `captured_status_events` until an `AuthRequired` with a
|
|
/// `request_id` is observed (past `initial_count`). Returns the first
|
|
/// new `request_id` as a `Uuid`, or a descriptive error when the
|
|
/// router emitted a malformed ID or the status never arrived.
|
|
async fn wait_for_auth_required(
|
|
rig: &TestRig,
|
|
initial_count: usize,
|
|
timeout: Duration,
|
|
) -> Result<uuid::Uuid, String> {
|
|
let deadline = tokio::time::Instant::now() + timeout;
|
|
loop {
|
|
let events = rig.captured_status_events();
|
|
let with_id: Vec<_> = events
|
|
.iter()
|
|
.filter_map(|s| match s {
|
|
StatusUpdate::AuthRequired {
|
|
request_id: Some(id),
|
|
..
|
|
} => Some(id.clone()),
|
|
_ => None,
|
|
})
|
|
.collect();
|
|
if with_id.len() > initial_count {
|
|
let raw = with_id
|
|
.get(initial_count)
|
|
.expect("length checked above; request_id must exist");
|
|
return uuid::Uuid::parse_str(raw)
|
|
.map_err(|e| format!("malformed AuthRequired.request_id {raw:?}: {e}"));
|
|
}
|
|
if tokio::time::Instant::now() >= deadline {
|
|
return Err(format!(
|
|
"timed out waiting for AuthRequired #{initial_count}; saw events: {events:?}"
|
|
));
|
|
}
|
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
|
}
|
|
}
|
|
|
|
fn fixture_path(name: &str) -> String {
|
|
format!(
|
|
"{}/tests/fixtures/llm_traces/coverage/{}",
|
|
env!("CARGO_MANIFEST_DIR"),
|
|
name
|
|
)
|
|
}
|
|
|
|
/// Write a minimal SKILL.md that declares a credential spec for
|
|
/// `credential_name` into `skills_dir`. This is the bare-minimum fixture
|
|
/// that makes `AuthManager::submit_auth_token` accept the credential
|
|
/// (it requires a matching skill credential spec when no extension is
|
|
/// installed). The skill body is intentionally trivial — the test does
|
|
/// not exercise prompt/activation behavior.
|
|
fn plant_probe_skill(skills_dir: &std::path::Path, credential_name: &str) {
|
|
let skill_dir = skills_dir.join(format!("probe_{credential_name}"));
|
|
std::fs::create_dir_all(&skill_dir).expect("create probe skill dir");
|
|
let manifest = format!(
|
|
r#"---
|
|
name: probe_{credential_name}
|
|
version: "0.0.0"
|
|
description: Probe skill for auth-gate replay coverage.
|
|
activation:
|
|
keywords:
|
|
- "probe-skill-should-never-activate-in-this-test"
|
|
credentials:
|
|
- name: {credential_name}
|
|
provider: test
|
|
location:
|
|
type: bearer
|
|
hosts:
|
|
- "example.com"
|
|
---
|
|
|
|
Probe skill.
|
|
"#
|
|
);
|
|
std::fs::write(skill_dir.join("SKILL.md"), manifest).expect("write probe SKILL.md");
|
|
}
|
|
|
|
/// Common rig setup: engine v2 + a `MockActivateTool` override keyed by
|
|
/// the caller's scripted output sequence. Also plants a probe skill for
|
|
/// each credential name referenced in `outputs` so the auth-manager
|
|
/// credential-store path accepts the token during `CredentialProvided`.
|
|
async fn auth_rig(
|
|
trace: LlmTrace,
|
|
outputs: Vec<serde_json::Value>,
|
|
) -> (TestRig, Arc<AtomicUsize>) {
|
|
// Extract credential names so we can plant matching probe skills.
|
|
let credential_names: std::collections::HashSet<String> = outputs
|
|
.iter()
|
|
.filter_map(|v| v.get("name").and_then(|n| n.as_str()).map(String::from))
|
|
.collect();
|
|
|
|
// Use `.into_path()` to release the tempdir from auto-cleanup so
|
|
// skill discovery can read from it after the builder finishes. OS
|
|
// cleanup handles the directory between runs.
|
|
let skills_tempdir = tempfile::tempdir().expect("probe skills tempdir");
|
|
let skills_path = skills_tempdir.keep();
|
|
for name in &credential_names {
|
|
plant_probe_skill(&skills_path, name);
|
|
}
|
|
|
|
let (tool, executions) = MockActivateTool::new(outputs);
|
|
let rig = TestRigBuilder::new()
|
|
.with_trace(trace)
|
|
.with_engine_v2()
|
|
.with_test_tool_override(tool as Arc<dyn Tool>)
|
|
.with_skills_dir(skills_path)
|
|
.build()
|
|
.await;
|
|
rig.clear().await;
|
|
(rig, executions)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn auth_credential_provided_resumes_action() {
|
|
let _guard = engine_v2_test_lock().lock().await;
|
|
let trace = LlmTrace::from_file(fixture_path("auth_credential_provided.json"))
|
|
.expect("failed to load auth_credential_provided.json");
|
|
let outputs = vec![
|
|
serde_json::json!({
|
|
"status": "awaiting_token",
|
|
"name": "test_credential",
|
|
"instructions": "Provide test token"
|
|
}),
|
|
serde_json::json!({
|
|
"status": "ready",
|
|
"name": "test_credential",
|
|
"message": "Credential configured"
|
|
}),
|
|
];
|
|
let (rig, executions) = auth_rig(trace.clone(), outputs).await;
|
|
|
|
rig.send_message("Set up the test credential").await;
|
|
|
|
let request_id = wait_for_auth_required(&rig, 0, TIMEOUT)
|
|
.await
|
|
.expect("expected AuthRequired with request_id before token was provided");
|
|
|
|
rig.send_gate_auth_resolution(
|
|
request_id,
|
|
AuthGateResolution::CredentialProvided {
|
|
token: "test-token-value".to_string(),
|
|
},
|
|
)
|
|
.await;
|
|
|
|
let responses = rig.wait_for_responses(1, TIMEOUT).await;
|
|
assert!(
|
|
executions.load(Ordering::SeqCst) >= 2,
|
|
"tool must re-run after credential submission (executions={})",
|
|
executions.load(Ordering::SeqCst)
|
|
);
|
|
rig.verify_trace_expects(&trace, &responses);
|
|
rig.shutdown();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn auth_cancelled_stops_thread() {
|
|
let _guard = engine_v2_test_lock().lock().await;
|
|
let trace = LlmTrace::from_file(fixture_path("auth_cancelled.json"))
|
|
.expect("failed to load auth_cancelled.json");
|
|
let outputs = vec![serde_json::json!({
|
|
"status": "awaiting_token",
|
|
"name": "test_credential",
|
|
"instructions": "Provide test token"
|
|
})];
|
|
let (rig, executions) = auth_rig(trace.clone(), outputs).await;
|
|
|
|
rig.send_message("Set up the credential I'll cancel").await;
|
|
|
|
let request_id = wait_for_auth_required(&rig, 0, TIMEOUT)
|
|
.await
|
|
.expect("expected AuthRequired before cancel");
|
|
|
|
rig.send_gate_auth_resolution(request_id, AuthGateResolution::Cancelled)
|
|
.await;
|
|
|
|
let responses = rig.wait_for_responses(1, TIMEOUT).await;
|
|
assert_eq!(
|
|
executions.load(Ordering::SeqCst),
|
|
1,
|
|
"tool must run exactly once (cancel must not trigger a re-run)"
|
|
);
|
|
assert!(
|
|
responses.iter().any(|r| r.content.contains("Cancelled")),
|
|
"expected 'Cancelled.' response after auth-gate cancel, got: {:?}",
|
|
responses.iter().map(|r| &r.content).collect::<Vec<_>>()
|
|
);
|
|
rig.verify_trace_expects(&trace, &responses);
|
|
rig.shutdown();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn auth_retry_after_invalid_credential() {
|
|
let _guard = engine_v2_test_lock().lock().await;
|
|
let trace = LlmTrace::from_file(fixture_path("auth_retry_invalid_then_valid.json"))
|
|
.expect("failed to load auth_retry_invalid_then_valid.json");
|
|
// First two calls pause the gate (first token is "invalid"), third
|
|
// call returns ready. The mock doesn't actually validate tokens —
|
|
// it just emits the scripted output sequence.
|
|
let outputs = vec![
|
|
serde_json::json!({
|
|
"status": "awaiting_token",
|
|
"name": "test_credential",
|
|
"instructions": "Provide test token"
|
|
}),
|
|
serde_json::json!({
|
|
"status": "awaiting_token",
|
|
"name": "test_credential",
|
|
"instructions": "That token was invalid; try again"
|
|
}),
|
|
serde_json::json!({
|
|
"status": "ready",
|
|
"name": "test_credential"
|
|
}),
|
|
];
|
|
let (rig, executions) = auth_rig(trace.clone(), outputs).await;
|
|
|
|
rig.send_message("Set up the credential").await;
|
|
|
|
let first_id = wait_for_auth_required(&rig, 0, TIMEOUT)
|
|
.await
|
|
.expect("expected first AuthRequired");
|
|
rig.send_gate_auth_resolution(
|
|
first_id,
|
|
AuthGateResolution::CredentialProvided {
|
|
token: "invalid".to_string(),
|
|
},
|
|
)
|
|
.await;
|
|
|
|
let second_id = wait_for_auth_required(&rig, 1, TIMEOUT)
|
|
.await
|
|
.expect("expected second AuthRequired after invalid token");
|
|
assert_ne!(
|
|
first_id, second_id,
|
|
"re-pause must emit a fresh request_id, not reuse the first"
|
|
);
|
|
|
|
rig.send_gate_auth_resolution(
|
|
second_id,
|
|
AuthGateResolution::CredentialProvided {
|
|
token: "valid".to_string(),
|
|
},
|
|
)
|
|
.await;
|
|
|
|
let responses = rig.wait_for_responses(1, TIMEOUT).await;
|
|
assert!(
|
|
executions.load(Ordering::SeqCst) >= 3,
|
|
"tool must run at least three times (initial + two resumes), got {}",
|
|
executions.load(Ordering::SeqCst)
|
|
);
|
|
rig.verify_trace_expects(&trace, &responses);
|
|
rig.shutdown();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn auth_external_callback_resolves_oauth_gate() {
|
|
let _guard = engine_v2_test_lock().lock().await;
|
|
let trace = LlmTrace::from_file(fixture_path("auth_external_callback.json"))
|
|
.expect("failed to load auth_external_callback.json");
|
|
let outputs = vec![
|
|
serde_json::json!({
|
|
"status": "awaiting_authorization",
|
|
"name": "oauth_service",
|
|
"auth_url": "https://example.com/oauth/start",
|
|
"instructions": "Complete the OAuth flow in your browser"
|
|
}),
|
|
serde_json::json!({
|
|
"status": "ready",
|
|
"name": "oauth_service"
|
|
}),
|
|
];
|
|
let (rig, executions) = auth_rig(trace.clone(), outputs).await;
|
|
|
|
rig.send_message("Connect the OAuth service").await;
|
|
|
|
let request_id = wait_for_auth_required(&rig, 0, TIMEOUT)
|
|
.await
|
|
.expect("expected AuthRequired with request_id for OAuth");
|
|
|
|
// Confirm the gate carried an auth_url (OAuth shape, not bare token).
|
|
assert!(
|
|
rig.captured_status_events().iter().any(|s| matches!(
|
|
s,
|
|
StatusUpdate::AuthRequired { auth_url: Some(url), .. } if url.starts_with("https://example.com/oauth")
|
|
)),
|
|
"AuthRequired for OAuth must surface the auth_url"
|
|
);
|
|
|
|
rig.send_external_callback(request_id).await;
|
|
|
|
let responses = rig.wait_for_responses(1, TIMEOUT).await;
|
|
assert!(
|
|
executions.load(Ordering::SeqCst) >= 2,
|
|
"tool must re-run after external callback (executions={})",
|
|
executions.load(Ordering::SeqCst)
|
|
);
|
|
rig.verify_trace_expects(&trace, &responses);
|
|
rig.shutdown();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn auth_gate_emits_request_id_for_v2() {
|
|
let _guard = engine_v2_test_lock().lock().await;
|
|
let trace = LlmTrace::from_file(fixture_path("auth_gate_request_id.json"))
|
|
.expect("failed to load auth_gate_request_id.json");
|
|
let outputs = vec![serde_json::json!({
|
|
"status": "awaiting_token",
|
|
"name": "probe_credential",
|
|
"instructions": "Provide"
|
|
})];
|
|
let (rig, _executions) = auth_rig(trace, outputs).await;
|
|
|
|
rig.send_message("Trigger the auth gate").await;
|
|
|
|
let request_id = wait_for_auth_required(&rig, 0, TIMEOUT)
|
|
.await
|
|
.expect("engine v2 must always emit a request_id on AuthRequired");
|
|
// Invariant: the gate's request_id must round-trip through Uuid parsing.
|
|
// (`wait_for_auth_required` already parsed it; this asserts non-nil.)
|
|
assert_ne!(
|
|
request_id,
|
|
uuid::Uuid::nil(),
|
|
"request_id on AuthRequired must be a real Uuid, not nil"
|
|
);
|
|
|
|
// Clean up the pending gate so the test doesn't leak an active thread.
|
|
rig.send_gate_auth_resolution(request_id, AuthGateResolution::Cancelled)
|
|
.await;
|
|
rig.shutdown();
|
|
}
|
|
}
|