Closes the remaining v0.29.0 follow-ups + post-audit MEDIUMs. ## HttpDriver (kei-spawn http-driver feature) - Real reqwest::blocking POST to api.anthropic.com/v1/messages - Feature flag `http-driver = ["dep:reqwest"]` (default off, zero breaking) - KEI_ANTHROPIC_KEY read at invoke time (rotation-friendly) - 5 httpmock tests (missing key, 200, 4xx, 5xx, malformed json) - Endpoint override via KEI_ANTHROPIC_ENDPOINT env for tests - Files: drive.rs, drive_http.rs (new), drive_http_parse.rs (new), tests/http_driver.rs ## agent_id path-traversal validator (HIGH) - New validate.rs with validate_agent_id() — whitelist grammar, 64-char cap, rejects /, \, .., leading dot/dash, NUL, :, whitespace, non-ASCII, Windows-reserved (CON/PRN/AUX/NUL/COM1-9/LPT1-9) - Wired into all 5 agent_id→path sinks: load_task, resolve_agent_id, prepare, simulated_merge, verify_task - autogen_agent_id moved to validate.rs with slugify_role helper — output passes validator by construction (100-draw property test) - 33 new tests in agent_id_validator.rs ## safe_join symlink escape (MEDIUM) - Base must canonicalize (nonexistent → Canonicalize error) - Joined must start_with base_canon OR joined.parent() must start_with base_canon - Blocks symlink-to-outside-base with non-existent tail file - walk.rs refactored into 5 ≤17-LOC helpers - 7 new tests in safe_join_hardening.rs ## entity-store 4 MEDIUM fixes - ddl.rs: panic on unsupported FieldKind → typed DdlError::UnsupportedExtraColumn propagated through Store::open as VerbError::InvalidInput (exit 2). Extracted ddl_edge.rs + ddl_error.rs modules. Backward-compat shim preserved. - search.rs: FTS5 empty-tokenization → typed InvalidInput on queries with no alphanumeric tokens (was opaque rusqlite error). Unicode-aware via char::is_alphanumeric. - engine.rs: WAL pragma failure now logged to stderr with path + rusqlite source; fallback to rollback journal preserved (exit-code contract intact). - bug_fixes_smoke: added fts5_phrase_quoting_preserves_legitimate_queries — catches over-broad sanitizer that passes injection test alone. ## Verified - cargo check --workspace clean (both with and without http-driver feature) - cargo test --workspace: 668 tests green (up from 620) - substrate_integration.sh ✓, hook_wiring_integration.sh ✓ Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
181 lines
6 KiB
Rust
181 lines
6 KiB
Rust
//! http_driver — end-to-end tests for the `http-driver` feature.
|
|
//!
|
|
//! Uses `httpmock` to stand up a local HTTP server and `KEI_ANTHROPIC_ENDPOINT`
|
|
//! to redirect the driver at it. `KEI_ANTHROPIC_KEY` is set per-test so the
|
|
//! tests never require real credentials.
|
|
//!
|
|
//! Every test is self-contained: fresh MockServer + per-test env vars. The
|
|
//! env_lock mutex below ensures concurrent tests don't trample each other's
|
|
//! process-global env.
|
|
|
|
#![cfg(feature = "http-driver")]
|
|
|
|
use std::sync::Mutex;
|
|
|
|
use httpmock::prelude::*;
|
|
use kei_spawn::{AnthropicDriver, DriveError, HttpDriver};
|
|
|
|
/// Cargo test harness runs tests in parallel by default — env vars are
|
|
/// process-global, so serialize access.
|
|
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
|
|
|
struct EnvGuard {
|
|
key_prev: Option<String>,
|
|
endpoint_prev: Option<String>,
|
|
_guard: std::sync::MutexGuard<'static, ()>,
|
|
}
|
|
|
|
impl EnvGuard {
|
|
fn new(key: Option<&str>, endpoint: Option<&str>) -> Self {
|
|
let guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
|
let key_prev = std::env::var("KEI_ANTHROPIC_KEY").ok();
|
|
let endpoint_prev = std::env::var("KEI_ANTHROPIC_ENDPOINT").ok();
|
|
match key {
|
|
Some(v) => std::env::set_var("KEI_ANTHROPIC_KEY", v),
|
|
None => std::env::remove_var("KEI_ANTHROPIC_KEY"),
|
|
}
|
|
match endpoint {
|
|
Some(v) => std::env::set_var("KEI_ANTHROPIC_ENDPOINT", v),
|
|
None => std::env::remove_var("KEI_ANTHROPIC_ENDPOINT"),
|
|
}
|
|
Self {
|
|
key_prev,
|
|
endpoint_prev,
|
|
_guard: guard,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for EnvGuard {
|
|
fn drop(&mut self) {
|
|
match &self.key_prev {
|
|
Some(v) => std::env::set_var("KEI_ANTHROPIC_KEY", v),
|
|
None => std::env::remove_var("KEI_ANTHROPIC_KEY"),
|
|
}
|
|
match &self.endpoint_prev {
|
|
Some(v) => std::env::set_var("KEI_ANTHROPIC_ENDPOINT", v),
|
|
None => std::env::remove_var("KEI_ANTHROPIC_ENDPOINT"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn missing_key_returns_transport_error() {
|
|
let _env = EnvGuard::new(None, Some("http://127.0.0.1:1/never"));
|
|
let d = HttpDriver;
|
|
let err = d.invoke("hi", "code-implementer", Some("worktree")).unwrap_err();
|
|
match err {
|
|
DriveError::Transport { message } => {
|
|
assert!(message.contains("KEI_ANTHROPIC_KEY"), "msg: {message}");
|
|
}
|
|
other => panic!("expected Transport, got {other}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ok_200_roundtrip_populates_agent_result() {
|
|
let server = MockServer::start();
|
|
let _env = EnvGuard::new(Some("test-key-xxx"), Some(&server.url("/v1/messages")));
|
|
|
|
let m = server.mock(|when, then| {
|
|
when.method(POST)
|
|
.path("/v1/messages")
|
|
.header("x-api-key", "test-key-xxx")
|
|
.header("anthropic-version", "2023-06-01")
|
|
.header("content-type", "application/json")
|
|
.body_contains("[kei-spawn routing] subagent_type=code-implementer")
|
|
.body_contains("claude-opus-4-7");
|
|
then.status(200)
|
|
.header("content-type", "application/json")
|
|
.body(
|
|
r#"{
|
|
"id": "msg_test_01",
|
|
"content": [
|
|
{"type":"text","text":"hello "},
|
|
{"type":"text","text":"world"}
|
|
],
|
|
"stop_reason": "end_turn"
|
|
}"#,
|
|
);
|
|
});
|
|
|
|
let d = HttpDriver;
|
|
let out = d
|
|
.invoke("please do X", "code-implementer", Some("worktree"))
|
|
.expect("ok roundtrip");
|
|
|
|
m.assert();
|
|
assert_eq!(out.agent_id, "msg_test_01");
|
|
assert_eq!(out.transcript, "hello world");
|
|
assert_eq!(out.finish_reason, "end_turn");
|
|
}
|
|
|
|
#[test]
|
|
fn http_4xx_maps_to_transport_with_body_excerpt() {
|
|
let server = MockServer::start();
|
|
let _env = EnvGuard::new(Some("bad-key"), Some(&server.url("/v1/messages")));
|
|
|
|
let body_msg = "{\"type\":\"error\",\"error\":{\"type\":\"invalid_api_key\",\"message\":\"bad key\"}}";
|
|
server.mock(|when, then| {
|
|
when.method(POST).path("/v1/messages");
|
|
then.status(401)
|
|
.header("content-type", "application/json")
|
|
.body(body_msg);
|
|
});
|
|
|
|
let d = HttpDriver;
|
|
let err = d.invoke("x", "code-implementer", None).unwrap_err();
|
|
match err {
|
|
DriveError::Transport { message } => {
|
|
assert!(message.contains("HTTP 401"), "msg: {message}");
|
|
assert!(message.contains("invalid_api_key"), "msg: {message}");
|
|
}
|
|
other => panic!("expected Transport, got {other}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn http_5xx_maps_to_transport() {
|
|
let server = MockServer::start();
|
|
let _env = EnvGuard::new(Some("k"), Some(&server.url("/v1/messages")));
|
|
|
|
server.mock(|when, then| {
|
|
when.method(POST).path("/v1/messages");
|
|
then.status(503)
|
|
.header("content-type", "text/plain")
|
|
.body("upstream overloaded");
|
|
});
|
|
|
|
let d = HttpDriver;
|
|
let err = d.invoke("x", "y", None).unwrap_err();
|
|
match err {
|
|
DriveError::Transport { message } => {
|
|
assert!(message.contains("HTTP 503"), "msg: {message}");
|
|
assert!(message.contains("upstream overloaded"), "msg: {message}");
|
|
}
|
|
other => panic!("expected Transport, got {other}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn malformed_json_on_200_maps_to_transport() {
|
|
let server = MockServer::start();
|
|
let _env = EnvGuard::new(Some("k"), Some(&server.url("/v1/messages")));
|
|
|
|
server.mock(|when, then| {
|
|
when.method(POST).path("/v1/messages");
|
|
then.status(200)
|
|
.header("content-type", "application/json")
|
|
.body("{not-json");
|
|
});
|
|
|
|
let d = HttpDriver;
|
|
let err = d.invoke("x", "y", None).unwrap_err();
|
|
match err {
|
|
DriveError::Transport { message } => {
|
|
assert!(message.contains("parse response"), "msg: {message}");
|
|
assert!(message.contains("body[:512]="), "msg: {message}");
|
|
}
|
|
other => panic!("expected Transport, got {other}"),
|
|
}
|
|
}
|