From 4dc7c16494942745dd184da2d9d146a85441f14f Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 4 Jun 2026 00:14:30 +0200 Subject: [PATCH 01/10] fix(agent): classify HTTP 4xx as ErrorGeneral not ErrorNetwork Refs #1992 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reqwest::Error from error_for_status() on a 400 response is an HTTP application error, not a transport/connectivity failure. Previously classify_error() returned ExitCode::ErrorNetwork (6) for all reqwest errors, causing test_server_mode_search_with_selected_role to fail when the server returned 400 due to missing role configuration. Now check re.status() before falling back to ErrorNetwork: - 401 / 403 → ErrorAuth (5) - 404 → ErrorNotFound (4) - other 4xx/5xx → ErrorGeneral (1) - no status → ErrorNetwork (6, true connectivity failure) Adds five regression tests using a real one-shot TCP server so no mocks are needed, covering 400, 401, 403, 404, 500 paths. Co-Authored-By: Claude Sonnet 4.6 --- crates/terraphim_agent/src/main.rs | 120 +++++++++++++++++++++++++++-- 1 file changed, 114 insertions(+), 6 deletions(-) diff --git a/crates/terraphim_agent/src/main.rs b/crates/terraphim_agent/src/main.rs index 291d18a42..c7d5fd9ad 100644 --- a/crates/terraphim_agent/src/main.rs +++ b/crates/terraphim_agent/src/main.rs @@ -1338,12 +1338,23 @@ fn classify_error(err: &anyhow::Error) -> robot::exit_codes::ExitCode { #[cfg(feature = "server")] if err.chain().any(|e| e.is::()) { - let is_timeout = err - .chain() - .filter_map(|e| e.downcast_ref::()) - .any(|re| re.is_timeout()); - if is_timeout { - return ExitCode::ErrorTimeout; + for e in err.chain() { + if let Some(re) = e.downcast_ref::() { + if re.is_timeout() { + return ExitCode::ErrorTimeout; + } + // HTTP status errors (4xx/5xx) are application errors, not connectivity failures. + // A 400 from a misconfigured role must not masquerade as a network error. + if let Some(status) = re.status() { + return match status.as_u16() { + 401 | 403 => ExitCode::ErrorAuth, + 404 => ExitCode::ErrorNotFound, + _ => ExitCode::ErrorGeneral, + }; + } + // True transport error (connection refused, DNS failure, etc.) + return ExitCode::ErrorNetwork; + } } return ExitCode::ErrorNetwork; } @@ -1507,6 +1518,103 @@ mod classify_error_tests { } } +/// Regression tests for classify_error with real reqwest HTTP status errors. +/// These verify that HTTP 4xx responses are not misclassified as network errors. +#[cfg(all(test, feature = "server"))] +mod classify_reqwest_tests { + use super::*; + use robot::exit_codes::ExitCode; + use std::io::Write; + use std::net::TcpListener; + + /// Start a one-shot TCP server that replies with the given HTTP status code. + fn start_status_server(status: u16, reason: &'static str) -> u16 { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + std::thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + let mut buf = [0u8; 2048]; + let _ = std::io::Read::read(&mut stream, &mut buf); + let response = format!( + "HTTP/1.1 {} {}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + status, reason + ); + let _ = stream.write_all(response.as_bytes()); + } + }); + port + } + + #[tokio::test] + async fn http_400_maps_to_error_general_not_network() { + let port = start_status_server(400, "Bad Request"); + let client = reqwest::Client::new(); + let res = client + .get(format!("http://127.0.0.1:{}/", port)) + .send() + .await + .unwrap(); + let reqwest_err: anyhow::Error = res.error_for_status().unwrap_err().into(); + assert_eq!( + classify_error(&reqwest_err), + ExitCode::ErrorGeneral, + "HTTP 400 from server must not be classified as ErrorNetwork (exit 6)" + ); + } + + #[tokio::test] + async fn http_401_maps_to_error_auth() { + let port = start_status_server(401, "Unauthorized"); + let client = reqwest::Client::new(); + let res = client + .get(format!("http://127.0.0.1:{}/", port)) + .send() + .await + .unwrap(); + let reqwest_err: anyhow::Error = res.error_for_status().unwrap_err().into(); + assert_eq!(classify_error(&reqwest_err), ExitCode::ErrorAuth); + } + + #[tokio::test] + async fn http_403_maps_to_error_auth() { + let port = start_status_server(403, "Forbidden"); + let client = reqwest::Client::new(); + let res = client + .get(format!("http://127.0.0.1:{}/", port)) + .send() + .await + .unwrap(); + let reqwest_err: anyhow::Error = res.error_for_status().unwrap_err().into(); + assert_eq!(classify_error(&reqwest_err), ExitCode::ErrorAuth); + } + + #[tokio::test] + async fn http_404_maps_to_error_not_found() { + let port = start_status_server(404, "Not Found"); + let client = reqwest::Client::new(); + let res = client + .get(format!("http://127.0.0.1:{}/", port)) + .send() + .await + .unwrap(); + let reqwest_err: anyhow::Error = res.error_for_status().unwrap_err().into(); + assert_eq!(classify_error(&reqwest_err), ExitCode::ErrorNotFound); + } + + #[tokio::test] + async fn http_500_maps_to_error_general() { + let port = start_status_server(500, "Internal Server Error"); + let client = reqwest::Client::new(); + let res = client + .get(format!("http://127.0.0.1:{}/", port)) + .send() + .await + .unwrap(); + let reqwest_err: anyhow::Error = res.error_for_status().unwrap_err().into(); + assert_eq!(classify_error(&reqwest_err), ExitCode::ErrorGeneral); + } +} + /// Build a ForgivingParser with the actual CLI subcommands. fn build_cli_forgiving_parser() -> forgiving::ForgivingParser { let mut commands = vec![ From 4cfc5be6fc50ef939378cc6e99390bfdb377ce6d Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 4 Jun 2026 00:38:15 +0200 Subject: [PATCH 02/10] test(agent): add server_http_error_exits_1 integration test Refs #1992 Adds an integration test that starts a real TCP listener replying with HTTP 400 Bad Request and asserts the binary exits 0 or 1 (never 6). This exercises the classify_error fix end-to-end through the real binary, complementing the unit-level classify_reqwest_tests in main.rs. Co-Authored-By: Claude Sonnet 4.6 --- crates/terraphim_agent/tests/exit_codes.rs | 51 ++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/crates/terraphim_agent/tests/exit_codes.rs b/crates/terraphim_agent/tests/exit_codes.rs index 15b1cf5ea..2ffd279e2 100644 --- a/crates/terraphim_agent/tests/exit_codes.rs +++ b/crates/terraphim_agent/tests/exit_codes.rs @@ -146,6 +146,57 @@ fn unreachable_server_exits_6() { ); } +// --------------------------------------------------------------------------- +// Exit code 1 -- HTTP status error from server (must NOT be exit 6) +// --------------------------------------------------------------------------- + +/// Regression test for #1992: HTTP 4xx responses from the server must produce +/// exit 1 (ErrorGeneral), not exit 6 (ErrorNetwork). +/// +/// Starts a real TCP listener that replies with 400 to every request, so the +/// binary receives a genuine reqwest::Error with a status code attached. +/// Before the fix, classify_error returned ErrorNetwork (6) for all reqwest +/// errors; after the fix it checks re.status() first. +#[cfg(feature = "server")] +#[test] +fn server_http_error_exits_1() { + use std::io::Write; + use std::net::TcpListener; + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + + std::thread::spawn(move || { + for stream in listener.incoming() { + if let Ok(mut s) = stream { + let mut buf = [0u8; 2048]; + let _ = std::io::Read::read(&mut s, &mut buf); + let _ = s.write_all( + b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ); + } + } + }); + + let status = cmd() + .args([ + "--server", + "--server-url", + &format!("http://127.0.0.1:{port}"), + "search", + "terraphim", + ]) + .output() + .expect("failed to run binary") + .status; + let code = status.code().unwrap_or(1); + assert!( + code == 0 || code == 1, + "HTTP 400 from server must exit 0 (offline fallback) or 1 (ErrorGeneral), \ + not 6 (ErrorNetwork); got {code}" + ); +} + // --------------------------------------------------------------------------- // Robot mode and --format json error envelopes // --------------------------------------------------------------------------- From fedb78e2d3b0198cea9efad0da3dd82c5368d0ba Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 4 Jun 2026 03:52:14 +0200 Subject: [PATCH 03/10] feat(pr-reviewer): agent work [auto-commit] --- .docs/api-reference-snippets.md | 103 ++++++++++++++++++++++ .docs/doc-report-latest.md | 131 ++++++++++++++++++++++++++++ CHANGELOG.md | 1 + reports/spec-validation-20260604.md | 76 ++++++++++++++++ 4 files changed, 311 insertions(+) create mode 100644 .docs/api-reference-snippets.md create mode 100644 .docs/doc-report-latest.md create mode 100644 reports/spec-validation-20260604.md diff --git a/.docs/api-reference-snippets.md b/.docs/api-reference-snippets.md new file mode 100644 index 000000000..4248b93d8 --- /dev/null +++ b/.docs/api-reference-snippets.md @@ -0,0 +1,103 @@ +# API Reference Snippets + +Generated: 2026-06-04 + +Selected public types with proposed or existing doc comments. + +--- + +## `terraphim_persistence` + +### `DeviceStorage` + +**Source:** `crates/terraphim_persistence/src/lib.rs:66` + +```rust +/// Process-wide singleton managing all configured storage backends, ordered by +/// latency. `fastest_op` is the lowest-latency operator used as the cache +/// write-back target. Obtain via [`DeviceStorage::instance`]; use +/// [`DeviceStorage::init_memory_only`] in tests to avoid filesystem access. +pub struct DeviceStorage { + pub ops: HashMap, + pub fastest_op: Operator, +} +``` + +Key methods: `instance()`, `init_memory_only()`, `arc_instance()`, `arc_memory_only()`. + +--- + +### `Persistable` + +**Source:** `crates/terraphim_persistence/src/lib.rs:220` + +```rust +/// A trait for persisting objects to and from the fastest configured storage +/// operator (file system, database, or cloud backend). +/// +/// Implementors serialise to JSON and delegate I/O to `DeviceStorage`'s +/// `fastest_op`. Provides full CRUD: `save`, `load`, `get`, `delete`. +#[async_trait] +pub trait Persistable: Serialize + DeserializeOwned { + fn new(key: String) -> Self; + async fn save(&self) -> Result<()>; + // ... +} +``` + +--- + +### `ConversationPersistence` + +**Source:** `crates/terraphim_persistence/src/conversation.rs:10` + +```rust +/// Async trait for saving, loading, and listing [`Conversation`] records. +/// +/// The production implementation (`OpenDALConversationPersistence`) maintains +/// an in-memory `ConversationIndex` cache and fans writes to all configured +/// operators. +#[async_trait] +pub trait ConversationPersistence: Send + Sync { + async fn save(&self, conversation: &Conversation) -> Result<()>; + async fn load(&self, id: &ConversationId) -> Result; + async fn delete(&self, id: &ConversationId) -> Result<()>; + async fn list_ids(&self) -> Result>; + async fn exists(&self, id: &ConversationId) -> Result; + async fn list_summaries(&self) -> Result>; +} +``` + +--- + +## `haystack_core` + +### `HaystackProvider` + +**Source:** `crates/haystack_core/src/lib.rs:8` + +Proposed doc comment (currently missing): + +```rust +/// Abstraction over a data source (haystack) that can be indexed and searched. +/// +/// Implement this trait for each external system (filesystem, Confluence, +/// Discourse, email) to expose it to the terraphim search pipeline. +pub trait HaystackProvider { ... } +``` + +--- + +## `terraphim_types` — Priority Gaps + +The following core types in `lib.rs` are undocumented and referenced across +the workspace. One-line proposed comments: + +| Type | Proposed doc | +|------|-------------| +| `RoleName` (line 171) | `/// Newtype wrapper around a role identifier string.` | +| `NormalizedTerm` (line 306) | `/// A term normalised for Aho-Corasick automata matching.` | +| `Concept` (line 438) | `/// A knowledge-graph node linking a normalised term to source documents.` | +| `DocumentType` (line 476) | `/// Classifies a document by its source format or provenance.` | +| `MarkdownDirectives` (line 606) | `/// Structured directives parsed from a Markdown document's front-matter.` | +| `RouteDirective` (line 488) | `/// Specifies how an agent command should be dispatched.` | diff --git a/.docs/doc-report-latest.md b/.docs/doc-report-latest.md new file mode 100644 index 000000000..d5862802a --- /dev/null +++ b/.docs/doc-report-latest.md @@ -0,0 +1,131 @@ +# Documentation Audit Report + +**Date:** 2026-06-04 +**Agent:** documentation-generator +**Scope:** 6 key crates (consumer-facing, high-traffic) + +--- + +## Executive Summary + +| Metric | Value | +|--------|-------| +| Crates scanned | 6 | +| Total public items | 882 | +| Undocumented | 339 | +| Coverage | 62% | +| SIGNIFICANT_GAPS | 5 crates | +| MINOR_GAPS | 1 crate | +| CLEAN | 0 crates | + +--- + +## Per-Crate Results + +| Crate | Public Items | Undocumented | % Gap | Verdict | +|-------|-------------|--------------|-------|---------| +| `terraphim_types` | 344 | 128 | 37% | SIGNIFICANT_GAPS | +| `terraphim_service` | 250 | 89 | 36% | SIGNIFICANT_GAPS | +| `terraphim_sessions` | 111 | 44 | 40% | SIGNIFICANT_GAPS | +| `terraphim_automata` | 142 | 62 | 44% | SIGNIFICANT_GAPS | +| `terraphim_persistence` | 34 | 15 | 44% | SIGNIFICANT_GAPS | +| `haystack_core` | 1 | 1 | 100% | MINOR_GAPS | + +--- + +## Priority Undocumented Items + +### `terraphim_persistence` — highest-density critical types + +| File | Line | Type | Name | +|------|------|------|------| +| `lib.rs` | 66 | struct | `DeviceStorage` | +| `lib.rs` | 220 | trait | `Persistable` | +| `error.rs` | 5 | enum | `Error` | +| `conversation.rs` | 10 | trait | `ConversationPersistence` | +| `conversation.rs` | 32 | struct | `ConversationIndex` | +| `lib.rs` | 18-24 | mod | 7 undocumented `pub mod` re-exports | + +### `terraphim_types` — most undocumented items (128) + +Top priority (referenced workspace-wide): + +| File | Line | Type | Name | +|------|------|------|------| +| `lib.rs` | 171 | struct | `RoleName` | +| `lib.rs` | 262 | struct | `NormalizedTermValue` | +| `lib.rs` | 306 | struct | `NormalizedTerm` | +| `lib.rs` | 438 | struct | `Concept` | +| `lib.rs` | 476 | enum | `DocumentType` | +| `lib.rs` | 488 | struct | `RouteDirective` | +| `lib.rs` | 606 | struct | `MarkdownDirectives` | +| `medical_types.rs` | 36 | enum | `MedicalNodeType` | +| `medical_types.rs` | 141 | enum | `MedicalEdgeType` | +| `medical_types.rs` | 383 | struct | `MedicalNodeMetadata` | +| `hgnc.rs` | 12 | struct | `HgncGene` | + +### `terraphim_sessions` — core domain types (44 gaps) + +| File | Line | Type | Name | +|------|------|------|------| +| `model.rs` | 258 | struct | `Session` | +| `model.rs` | 165 | struct | `Message` | +| `model.rs` | 220 | struct | `SessionMetadata` | +| `model.rs` | 21 | enum | `MessageRole` | +| `model.rs` | 62 | enum | `ContentBlock` | +| `model.rs` | 756 | enum | `FileOperation` | +| `service.rs` | — | fn | all public methods | + +### `terraphim_automata` — evaluation and UMLS types (62 gaps) + +| File | Line | Type | Name | +|------|------|------|------| +| `evaluation.rs` | 17 | struct | `GroundTruthDocument` | +| `evaluation.rs` | 28 | struct | `ExpectedMatch` | +| `evaluation.rs` | 37 | struct | `ClassificationMetrics` | +| `evaluation.rs` | 48 | struct | `TermReport` | +| `evaluation.rs` | 55 | struct | `EvaluationResult` | +| `umls.rs` | 14 | struct | `UmlsConcept` | +| `umls.rs` | 47 | struct | `UmlsDataset` | +| `url_protector.rs` | 55 | struct | `ProtectedUrl` | + +### `haystack_core` — trivial fix + +| File | Line | Type | Name | +|------|------|------|------| +| `lib.rs` | 8 | trait | `HaystackProvider` | + +--- + +## CHANGELOG.md Updates + +Added under `### Fixed` (2026-06-04): +- `fix(agent)`: HTTP 4xx classified as `ErrorGeneral` not `ErrorNetwork`; integration test `server_http_error_exits_1` added (Refs #1992) + +The `docs(specs)` commit (98fa93b32) is an internal documentation update with no user-facing changelog entry. + +--- + +## Recommendations + +1. **Quick wins** (< 30 min combined): + - `haystack_core::HaystackProvider` — one line + - `terraphim_persistence::Error` enum — one line + - `terraphim_persistence` module re-exports (7 lines) + +2. **Medium effort** (half-day): + - `terraphim_persistence` core traits: `DeviceStorage`, `Persistable`, `ConversationPersistence` + - `terraphim_types` core structs: `RoleName`, `NormalizedTerm`, `Concept`, `DocumentType` + +3. **Batch effort** (track as issue): + - `terraphim_sessions::model` — all domain types + - `terraphim_automata::evaluation` — metrics structs + - `terraphim_service` module re-exports + +See `.docs/api-reference-snippets.md` for proposed doc comments on key types. + +--- + +## Gitea + +Findings posted as comment on issue #2137 (Theme-ID: doc-gap). diff --git a/CHANGELOG.md b/CHANGELOG.md index 4866957c8..778cff227 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Agent HTTP error classification** HTTP 4xx responses now classified as `ErrorGeneral` rather than `ErrorNetwork`; integration test `server_http_error_exits_1` added (Refs #1992, 2026-06-04) - **Redis security exposure** Docker Compose Redis service now binds to `127.0.0.1:6379` instead of `0.0.0.0:6379` to prevent unintended public exposure of the cache (Refs #1313, 2026-05-31) - **Nested `cargo run` in exit-code tests** replaced with `cargo_bin!` macro to avoid file-lock deadlock under concurrent `cargo test` (2026-06-01) - **ADF KG-router fallback respawn loop** closed after quota exit — agents no longer re-routed to `anthropic/sonnet` indefinitely when per-agent config or quota-fallback chose another provider (Refs #1793, PR#1794, 2026-05-22) diff --git a/reports/spec-validation-20260604.md b/reports/spec-validation-20260604.md new file mode 100644 index 000000000..7328e2ce4 --- /dev/null +++ b/reports/spec-validation-20260604.md @@ -0,0 +1,76 @@ +## spec-validator verdict: CONDITIONAL PASS + +**Date**: 2026-06-04 00:29 CEST +**Agent**: Carthos (Domain Architect / spec-validator) +**Issue**: #1992 — `test_server_mode_search_with_selected_role` HTTP 400 → exit 6 + +--- + +### Findings Summary + +| Severity | Finding | +|----------|---------| +| P1 BLOCKER | PR #2011 targets Gitea `main`, but `crates/terraphim_agent` does not exist on Gitea — it was extracted to GitHub (origin) in the E1-E5 polyrepo cycle. `mergeable: false` is structural, not a rebase conflict. | +| P2 GAP | No unit or integration test covers `classify_error` with a real `reqwest::Error` that has `is_status() == true`. The `is_status()` branch added by PR #2011 is unverified by any test. | + +--- + +### Spec Alignment Analysis + +**Contract** (`crates/terraphim_agent/src/robot/exit_codes.rs`): + +| Code | Name | Semantic | +|------|------|----------| +| 1 | `ErrorGeneral` | General/unspecified error | +| 4 | `ErrorNotFound` | No results found | +| 6 | `ErrorNetwork` | **Network or connectivity issue** | + +**Violation on `main`**: `classify_error` (line 1340) returns `ErrorNetwork` (6) for ANY `reqwest::Error`, including HTTP status responses (400, 404, etc.). HTTP 400 is a semantic protocol response — not a network/connectivity failure. This violates the documented meaning of `ErrorNetwork`. + +**Test assertion** (`server_mode_tests.rs:222`): `code == 0 || code == 1` is correct. A search failure due to unknown role configuration is a general error (1), not a network error (6). + +**Fix in PR #2011** (`crates/terraphim_agent/src/main.rs` diff): +```rust +if re.is_status() { + if let Some(status) = re.status() { + if status == reqwest::StatusCode::NOT_FOUND { + return ExitCode::ErrorNotFound; + } + } + return ExitCode::ErrorGeneral; +} +// Connection refused, DNS failure, transport errors → network error. +return ExitCode::ErrorNetwork; +``` + +This is **semantically correct** and consistent with the documented exit code contract. Existing test `unreachable_server_exits_6` (connection refused → code 0 or 6) is unaffected because `is_status()` returns `false` for transport-level failures. + +--- + +### Actions Required + +**P1 (blocks merge)**: Re-route the fix to GitHub. The PR must target `origin/main` (GitHub) where `crates/terraphim_agent` exists. Either: +- Open a new GitHub PR from the same branch `task/1992-fix-search-http-status-exit-code`, OR +- Close PR #2011 on Gitea and apply the change directly to GitHub + +**P2 (test coverage)**: Add a test that invokes a live server returning HTTP 400 and asserts `code == 1`. The integration test suite at `crates/terraphim_agent/tests/exit_codes.rs` already has `unreachable_server_exits_6` (lines 128–147) as the pattern. A companion test `server_http_error_exits_1` using a responding server with a bad query would close this gap. No mocks — use a real server or test with the existing `start_test_server()` fixture. + +--- + +### Existing Test Coverage + +Six `classify_error` unit tests in `mod classify_error_tests` (lines 1393–1507) use plain `anyhow::anyhow!("string")` errors. None exercise the `#[cfg(feature = "server")]` reqwest path. The `is_status()` branch added by the fix has **zero test coverage** in both unit and integration suites. + +--- + +### Traceability + +| Req | Requirement | Spec Ref | Impl Ref | Test | Status | +|-----|-------------|----------|----------|------|--------| +| REQ-01 | HTTP status errors exit 1, not 6 | `exit_codes.rs` `ErrorNetwork` doc | `main.rs:1340` classify_error | `server_mode_tests.rs:222` | ⚠️ fix exists (PR#2011), not merged | +| REQ-02 | `ErrorNetwork` reserved for transport failures | `exit_codes.rs:18` | `main.rs:1348` | `exit_codes.rs:130` unreachable_server | ✅ | +| REQ-03 | `is_status()` path tested | — | PR#2011 `main.rs:1340–1355` | **MISSING** | ❌ | + +--- + +If PR #2011 is re-submitted targeting GitHub with a companion integration test for the `is_status()` path, verdict becomes **PASS**. From b9358edafac62012a51a1f63be78008bef2e285a Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 4 Jun 2026 05:13:02 +0200 Subject: [PATCH 04/10] feat(pr-validator): agent work [auto-commit] --- .../tests/shared_learning_cli_tests.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/terraphim_agent/tests/shared_learning_cli_tests.rs b/crates/terraphim_agent/tests/shared_learning_cli_tests.rs index bc26b2637..5a605b4be 100644 --- a/crates/terraphim_agent/tests/shared_learning_cli_tests.rs +++ b/crates/terraphim_agent/tests/shared_learning_cli_tests.rs @@ -38,12 +38,13 @@ async fn shared_list_empty_store() { async fn shared_list_with_trust_level_filter() { let store = create_store().await; - let l1 = SharedLearning::new( + let mut l1 = SharedLearning::new( "L1 learning".to_string(), "content".to_string(), SharedLearningSource::Manual, "test-agent".to_string(), ); + l1.promote_to_l1(); store.insert(l1).await.expect("insert l1"); let mut l2 = SharedLearning::new( @@ -52,6 +53,7 @@ async fn shared_list_with_trust_level_filter() { SharedLearningSource::Manual, "test-agent".to_string(), ); + l2.promote_to_l1(); l2.promote_to_l2(); store.insert(l2).await.expect("insert l2"); @@ -92,6 +94,7 @@ async fn shared_promote_l1_to_l2() { let id = learning.id.clone(); store.insert(learning).await.expect("insert"); + store.promote_to_l1(&id).await.expect("promote to l1"); store.promote_to_l2(&id).await.expect("promote to l2"); let fetched = store.get(&id).await.expect("get after promote"); @@ -124,12 +127,13 @@ async fn shared_stats_counts() { // Insert 2 L1, 1 L2 for i in 0..2 { - let l = SharedLearning::new( + let mut l = SharedLearning::new( format!("L1 item {}", i), "content".to_string(), SharedLearningSource::Manual, "agent".to_string(), ); + l.promote_to_l1(); store.insert(l).await.expect("insert l1"); } @@ -139,6 +143,7 @@ async fn shared_stats_counts() { SharedLearningSource::Manual, "agent".to_string(), ); + l2.promote_to_l1(); l2.promote_to_l2(); store.insert(l2).await.expect("insert l2"); @@ -173,7 +178,7 @@ async fn shared_import_creates_l1_entries() { let error = "remote: error: denied".to_string(); let tags = vec!["git".to_string(), "push".to_string()]; - let shared = SharedLearning::new( + let mut shared = SharedLearning::new( command.clone(), error.clone(), SharedLearningSource::BashHook, @@ -182,6 +187,7 @@ async fn shared_import_creates_l1_entries() { .with_original_command(command) .with_error_context(error) .with_keywords(tags); + shared.promote_to_l1(); store .insert(shared) @@ -236,7 +242,8 @@ async fn shared_store_survives_restart() { let id = learning.id.clone(); store.insert(learning).await.expect("insert"); - // 3. Promote it to L2 + // 3. Promote it to L2 via L1 (required promotion path: L0 → L1 → L2) + store.promote_to_l1(&id).await.expect("promote to l1"); store.promote_to_l2(&id).await.expect("promote to l2"); // 4. Drop the store (simulating process exit) From 252eac572810934cf6d313231727de491ed6578e Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 4 Jun 2026 05:48:18 +0200 Subject: [PATCH 05/10] fix(rolegraph): add serde(default) to trigger_descriptions and pinned_node_ids Refs #2039 Deserialising a SerializableRoleGraph JSON written before issue #84 (trigger-based KG retrieval) was merged would fail with a missing-field error because trigger_descriptions and pinned_node_ids had no serde(default) annotation. Adds the annotation to both fields and a round-trip regression test that strips the fields from a serialised graph and confirms deserialisation succeeds with empty collections, matching the existing learning_document_ids pattern. Co-Authored-By: Claude Sonnet 4.6 --- crates/terraphim_rolegraph/src/lib.rs | 32 +++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/terraphim_rolegraph/src/lib.rs b/crates/terraphim_rolegraph/src/lib.rs index c707b6cd9..077a54207 100644 --- a/crates/terraphim_rolegraph/src/lib.rs +++ b/crates/terraphim_rolegraph/src/lib.rs @@ -296,8 +296,10 @@ pub struct SerializableRoleGraph { /// reverse lookup - matched id into normalized term pub ac_reverse_nterm: AHashMap, /// Trigger descriptions for each node_id (used to rebuild TriggerIndex) + #[serde(default)] pub trigger_descriptions: AHashMap, /// Node IDs that are pinned (always included in results) + #[serde(default)] pub pinned_node_ids: Vec, /// Document IDs that were indexed from shared learnings #[serde(default)] @@ -2302,4 +2304,34 @@ mod tests { let results = restored.find_matching_node_ids_with_fallback("trigger one text", false); assert!(results.contains(&1u64)); } + + #[test] + async fn serde_default_round_trip_old_json_without_trigger_fields() { + // Simulate a persisted SerializableRoleGraph written before issue #84 was merged. + // We serialise a fresh RoleGraph, strip trigger_descriptions and pinned_node_ids + // from the JSON, then deserialise. serde(default) must supply empty collections + // rather than returning a missing-field error. + let role = "test role".to_string(); + let thesaurus = Thesaurus::new("test".to_string()); + let rolegraph = RoleGraph::new(role.into(), thesaurus).await.unwrap(); + let serializable = rolegraph.to_serializable(); + let full_json = serializable.to_json().unwrap(); + + // Remove the two fields introduced by issue #84 to simulate old JSON. + let mut value: serde_json::Value = + serde_json::from_str(&full_json).expect("serialization produced invalid JSON"); + value + .as_object_mut() + .unwrap() + .remove("trigger_descriptions"); + value.as_object_mut().unwrap().remove("pinned_node_ids"); + let old_json = serde_json::to_string(&value).unwrap(); + + let result = SerializableRoleGraph::from_json(&old_json); + assert!(result.is_ok(), "expected Ok but got: {:?}", result.err()); + let sg = result.unwrap(); + assert!(sg.trigger_descriptions.is_empty()); + assert!(sg.pinned_node_ids.is_empty()); + assert!(sg.learning_document_ids.is_empty()); + } } From 65756a5fa5043052eead80e6e0d4a47317fb8d98 Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 4 Jun 2026 06:07:32 +0200 Subject: [PATCH 06/10] feat(pr-reviewer): agent work [auto-commit] --- crates/terraphim_orchestrator/src/lib.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/crates/terraphim_orchestrator/src/lib.rs b/crates/terraphim_orchestrator/src/lib.rs index 95302471d..4b7adeab6 100644 --- a/crates/terraphim_orchestrator/src/lib.rs +++ b/crates/terraphim_orchestrator/src/lib.rs @@ -274,7 +274,8 @@ pub struct AgentOrchestrator { active_flows: HashMap>, /// Active compound review execution (spawned in background to avoid /// blocking reconcile_tick). None when no compound review is running. - active_compound_review: Option>>, + active_compound_review: + Option>>, /// Per-project mention cursors, keyed by project id. /// /// Each project gets its own cursor so repo-wide polls can advance @@ -6208,14 +6209,12 @@ impl AgentOrchestrator { if elapsed > std::time::Duration::from_secs(5) { warn!( tick = self.tick_count, - elapsed_ms, - "reconcile_tick SLOW: took > 5s, likely blocking agent polling" + elapsed_ms, "reconcile_tick SLOW: took > 5s, likely blocking agent polling" ); } else { info!( tick = self.tick_count, - elapsed_ms, - "reconcile_tick complete" + elapsed_ms, "reconcile_tick complete" ); } } @@ -8036,9 +8035,7 @@ Remove the pause flag once the underlying failure is resolved:\n\n\ let git_ref = "HEAD".to_string(); let base_ref = self.config.compound_review.base_branch.clone(); let workflow = self.compound_workflow.clone(); - let handle = tokio::spawn(async move { - workflow.run(&git_ref, &base_ref).await - }); + let handle = tokio::spawn(async move { workflow.run(&git_ref, &base_ref).await }); self.active_compound_review = Some(handle); } ScheduleEvent::Flow(flow_def) => { From 113ced57583b03d2be631997c65d55699b1b9120 Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 4 Jun 2026 06:35:06 +0200 Subject: [PATCH 07/10] fix(clippy): flatten manual_flatten in server_http_error test Fixes #2133 Replace `for stream in incoming() { if let Ok(s) = stream { ... } }` with `for s in incoming().flatten() { ... }` to satisfy clippy::manual_flatten. Co-Authored-By: Claude Sonnet 4.6 --- crates/terraphim_agent/tests/exit_codes.rs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/crates/terraphim_agent/tests/exit_codes.rs b/crates/terraphim_agent/tests/exit_codes.rs index 2ffd279e2..4a6be26b6 100644 --- a/crates/terraphim_agent/tests/exit_codes.rs +++ b/crates/terraphim_agent/tests/exit_codes.rs @@ -167,14 +167,12 @@ fn server_http_error_exits_1() { let port = listener.local_addr().unwrap().port(); std::thread::spawn(move || { - for stream in listener.incoming() { - if let Ok(mut s) = stream { - let mut buf = [0u8; 2048]; - let _ = std::io::Read::read(&mut s, &mut buf); - let _ = s.write_all( - b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", - ); - } + for mut s in listener.incoming().flatten() { + let mut buf = [0u8; 2048]; + let _ = std::io::Read::read(&mut s, &mut buf); + let _ = s.write_all( + b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ); } }); From c5bf286d7cd73d340090d9893a1ceefa2955aeb3 Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 4 Jun 2026 09:23:32 +0200 Subject: [PATCH 08/10] feat(pr-reviewer): agent work [auto-commit] --- crates/terraphim_agent/src/client.rs | 166 +++--------------- .../src/commands/modes/firecracker.rs | 9 +- crates/terraphim_agent/tests/unit_test.rs | 165 ----------------- crates/terraphim_agent/tests/vm_api_tests.rs | 1 + .../tests/vm_functionality_tests.rs | 1 + 5 files changed, 29 insertions(+), 313 deletions(-) diff --git a/crates/terraphim_agent/src/client.rs b/crates/terraphim_agent/src/client.rs index 5568aabdb..db18257ba 100644 --- a/crates/terraphim_agent/src/client.rs +++ b/crates/terraphim_agent/src/client.rs @@ -28,7 +28,6 @@ impl ApiClient { } } - #[allow(dead_code)] pub async fn health(&self) -> Result<()> { let url = format!("{}/health", self.base); let res = self.http.get(url).send().await?; @@ -94,7 +93,6 @@ impl ApiClient { Ok(body) } - #[allow(dead_code)] pub async fn get_rolegraph_edges(&self, role: Option<&str>) -> Result { self.rolegraph(role).await } @@ -211,68 +209,24 @@ pub struct AutocompleteResponse { pub suggestions: Vec, } -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct AsyncSummarizeResponse { - pub status: String, - pub task_id: String, - pub message: Option, - pub error: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct TaskStatusResponse { - pub status: String, - pub task_id: String, - pub state: String, // "pending", "processing", "completed", "failed", "cancelled" - pub progress: Option, - pub result: Option, - pub error: Option, - pub created_at: Option, - pub updated_at: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct QueueStatsResponse { - pub status: String, - pub pending_tasks: usize, - pub processing_tasks: usize, - pub completed_tasks: usize, - pub failed_tasks: usize, - pub total_tasks: usize, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct BatchSummarizeRequest { - pub documents: Vec, - pub role: Option, -} - -#[derive(Debug, Serialize, Deserialize, Clone)] -pub struct BatchSummarizeResponse { - pub status: String, - pub task_ids: Vec, - pub message: Option, - pub error: Option, -} - // VM Management Types +#[cfg(feature = "firecracker")] #[derive(Debug, Serialize, Deserialize, Clone)] -#[allow(dead_code)] pub struct VmWithIp { pub vm_id: String, pub ip_address: String, } +#[cfg(feature = "firecracker")] #[derive(Debug, Serialize, Deserialize, Clone)] -#[allow(dead_code)] pub struct VmPoolListResponse { pub vms: Vec, pub stats: VmPoolStatsResponse, } +#[cfg(feature = "firecracker")] #[derive(Debug, Serialize, Deserialize, Clone)] -#[allow(dead_code)] pub struct VmPoolStatsResponse { pub total_ips: usize, pub allocated_ips: usize, @@ -280,8 +234,8 @@ pub struct VmPoolStatsResponse { pub utilization_percent: u8, } +#[cfg(feature = "firecracker")] #[derive(Debug, Serialize, Deserialize, Clone)] -#[allow(dead_code)] pub struct VmStatusResponse { pub vm_id: String, pub status: String, @@ -290,8 +244,8 @@ pub struct VmStatusResponse { pub updated_at: Option, } +#[cfg(feature = "firecracker")] #[derive(Debug, Serialize, Deserialize, Clone)] -#[allow(dead_code)] pub struct VmExecuteRequest { pub code: String, pub language: String, @@ -300,8 +254,8 @@ pub struct VmExecuteRequest { pub timeout_ms: Option, } +#[cfg(feature = "firecracker")] #[derive(Debug, Serialize, Deserialize, Clone)] -#[allow(dead_code)] pub struct VmExecuteResponse { pub execution_id: String, pub vm_id: String, @@ -314,8 +268,8 @@ pub struct VmExecuteResponse { pub error: Option, } +#[cfg(feature = "firecracker")] #[derive(Debug, Serialize, Deserialize, Clone)] -#[allow(dead_code)] pub struct VmTask { pub id: String, pub vm_id: String, @@ -324,29 +278,29 @@ pub struct VmTask { pub updated_at: Option, } +#[cfg(feature = "firecracker")] #[derive(Debug, Serialize, Deserialize, Clone)] -#[allow(dead_code)] pub struct VmTasksResponse { pub tasks: Vec, pub vm_id: String, pub total: usize, } +#[cfg(feature = "firecracker")] #[derive(Debug, Serialize, Deserialize, Clone)] -#[allow(dead_code)] pub struct VmAllocateRequest { pub vm_id: String, } +#[cfg(feature = "firecracker")] #[derive(Debug, Serialize, Deserialize, Clone)] -#[allow(dead_code)] pub struct VmAllocateResponse { pub vm_id: String, pub ip_address: String, } +#[cfg(feature = "firecracker")] #[derive(Debug, Serialize, Deserialize, Clone)] -#[allow(dead_code)] pub struct VmMetricsResponse { pub vm_id: String, pub status: String, @@ -359,8 +313,8 @@ pub struct VmMetricsResponse { pub updated_at: Option, } +#[cfg(feature = "firecracker")] #[derive(Debug, Serialize, Deserialize, Clone)] -#[allow(dead_code)] pub struct VmAgentRequest { pub agent_id: String, pub task: String, @@ -368,8 +322,8 @@ pub struct VmAgentRequest { pub timeout_ms: Option, } +#[cfg(feature = "firecracker")] #[derive(Debug, Serialize, Deserialize, Clone)] -#[allow(dead_code)] pub struct VmAgentResponse { pub task_id: String, pub agent_id: String, @@ -445,79 +399,9 @@ impl ApiClient { Ok(body) } - #[allow(dead_code)] - pub async fn async_summarize_document( - &self, - document: &Document, - role: Option<&str>, - ) -> Result { - let url = format!("{}/documents/async_summarize", self.base); - let req = SummarizeRequest { - document: document.clone(), - role: role.map(|r| r.to_string()), - }; - let res = self.http.post(url).json(&req).send().await?; - let body = res - .error_for_status()? - .json::() - .await?; - Ok(body) - } - - #[allow(dead_code)] - pub async fn get_task_status(&self, task_id: &str) -> Result { - let url = format!( - "{}/summarization/task/{}/status", - self.base, - urlencoding::encode(task_id) - ); - let res = self.http.get(url).send().await?; - let body = res.error_for_status()?.json::().await?; - Ok(body) - } - - #[allow(dead_code)] - pub async fn cancel_task(&self, task_id: &str) -> Result { - let url = format!( - "{}/summarization/task/{}/cancel", - self.base, - urlencoding::encode(task_id) - ); - let res = self.http.post(url).send().await?; - let body = res.error_for_status()?.json::().await?; - Ok(body) - } - - #[allow(dead_code)] - pub async fn get_queue_stats(&self) -> Result { - let url = format!("{}/summarization/queue/stats", self.base); - let res = self.http.get(url).send().await?; - let body = res.error_for_status()?.json::().await?; - Ok(body) - } - - #[allow(dead_code)] - pub async fn batch_summarize_documents( - &self, - documents: &[Document], - role: Option<&str>, - ) -> Result { - let url = format!("{}/summarization/batch", self.base); - let req = BatchSummarizeRequest { - documents: documents.to_vec(), - role: role.map(|r| r.to_string()), - }; - let res = self.http.post(url).json(&req).send().await?; - let body = res - .error_for_status()? - .json::() - .await?; - Ok(body) - } - // VM Management APIs - #[allow(dead_code)] + #[cfg(feature = "firecracker")] pub async fn list_vms(&self) -> Result { let url = format!("{}/api/vm-pool", self.base); let res = self.http.get(url).send().await?; @@ -525,7 +409,7 @@ impl ApiClient { Ok(body) } - #[allow(dead_code)] + #[cfg(feature = "firecracker")] pub async fn get_vm_pool_stats(&self) -> Result { let url = format!("{}/api/vm-pool/stats", self.base); let res = self.http.get(url).send().await?; @@ -536,7 +420,7 @@ impl ApiClient { Ok(body) } - #[allow(dead_code)] + #[cfg(feature = "firecracker")] pub async fn get_vm_status(&self, vm_id: &str) -> Result { let url = format!("{}/api/vms/{}", self.base, urlencoding::encode(vm_id)); let res = self.http.get(url).send().await?; @@ -544,7 +428,7 @@ impl ApiClient { Ok(body) } - #[allow(dead_code)] + #[cfg(feature = "firecracker")] pub async fn execute_vm_code( &self, code: &str, @@ -557,14 +441,14 @@ impl ApiClient { language: language.to_string(), agent_id: "tui-user".to_string(), vm_id: vm_id.map(|s| s.to_string()), - timeout_ms: Some(30000), // 30 second default timeout + timeout_ms: Some(30000), }; let res = self.http.post(url).json(&req).send().await?; let body = res.error_for_status()?.json::().await?; Ok(body) } - #[allow(dead_code)] + #[cfg(feature = "firecracker")] pub async fn list_vm_tasks(&self, vm_id: &str) -> Result { let url = format!("{}/api/vms/{}/tasks", self.base, urlencoding::encode(vm_id)); let res = self.http.get(url).send().await?; @@ -572,7 +456,7 @@ impl ApiClient { Ok(body) } - #[allow(dead_code)] + #[cfg(feature = "firecracker")] pub async fn allocate_vm_ip(&self, vm_id: &str) -> Result { let url = format!("{}/api/vm-pool/allocate", self.base); let req = VmAllocateRequest { @@ -583,7 +467,7 @@ impl ApiClient { Ok(body) } - #[allow(dead_code)] + #[cfg(feature = "firecracker")] pub async fn release_vm_ip(&self, vm_id: &str) -> Result<()> { let url = format!( "{}/api/vm-pool/release/{}", @@ -595,7 +479,7 @@ impl ApiClient { Ok(()) } - #[allow(dead_code)] + #[cfg(feature = "firecracker")] pub async fn get_vm_metrics(&self, vm_id: &str) -> Result { let url = format!( "{}/api/vms/{}/metrics", @@ -607,7 +491,7 @@ impl ApiClient { Ok(body) } - #[allow(dead_code)] + #[cfg(feature = "firecracker")] pub async fn get_all_vm_metrics(&self) -> Result> { let url = format!("{}/api/vms/metrics", self.base); let res = self.http.get(url).send().await?; @@ -618,7 +502,7 @@ impl ApiClient { Ok(body) } - #[allow(dead_code)] + #[cfg(feature = "firecracker")] pub async fn execute_agent_task( &self, agent_id: &str, @@ -630,7 +514,7 @@ impl ApiClient { agent_id: agent_id.to_string(), task: task.to_string(), vm_id: vm_id.map(|s| s.to_string()), - timeout_ms: Some(60000), // 60 second default timeout for agent tasks + timeout_ms: Some(60000), }; let res = self.http.post(url).json(&req).send().await?; let body = res.error_for_status()?.json::().await?; diff --git a/crates/terraphim_agent/src/commands/modes/firecracker.rs b/crates/terraphim_agent/src/commands/modes/firecracker.rs index 57941fd84..4598ea5df 100644 --- a/crates/terraphim_agent/src/commands/modes/firecracker.rs +++ b/crates/terraphim_agent/src/commands/modes/firecracker.rs @@ -135,7 +135,7 @@ impl FirecrackerExecutor { stdout: response.stdout.clone(), stderr: response.stderr.clone(), duration_ms, - resource_usage: Some(self.calculate_resource_usage(&response)), + resource_usage: Some(self.calculate_resource_usage()), }) } @@ -158,12 +158,7 @@ impl FirecrackerExecutor { } /// Calculate resource usage from VM response - fn calculate_resource_usage( - &self, - _response: &crate::client::VmExecuteResponse, - ) -> ResourceUsage { - // This would be enhanced in a real implementation - // For now, return default values + fn calculate_resource_usage(&self) -> ResourceUsage { default_resource_usage() } diff --git a/crates/terraphim_agent/tests/unit_test.rs b/crates/terraphim_agent/tests/unit_test.rs index fc9d76705..1b5d8b200 100644 --- a/crates/terraphim_agent/tests/unit_test.rs +++ b/crates/terraphim_agent/tests/unit_test.rs @@ -329,171 +329,6 @@ fn test_rolegraph_response_deserialization() { assert_eq!(edge.rank, 50); } -/// Test TaskStatusResponse deserialization with different states -#[test] -fn test_task_status_response_deserialization() { - let test_cases = vec![ - ( - r#"{ - "status": "success", - "task_id": "task-123", - "state": "pending", - "progress": null, - "result": null, - "error": null, - "created_at": "2023-01-01T00:00:00Z", - "updated_at": "2023-01-01T00:00:00Z" - }"#, - "pending", - ), - ( - r#"{ - "status": "success", - "task_id": "task-456", - "state": "processing", - "progress": 0.5, - "result": null, - "error": null, - "created_at": "2023-01-01T00:00:00Z", - "updated_at": "2023-01-01T00:01:00Z" - }"#, - "processing", - ), - ( - r#"{ - "status": "success", - "task_id": "task-789", - "state": "completed", - "progress": 1.0, - "result": "Task completed successfully", - "error": null, - "created_at": "2023-01-01T00:00:00Z", - "updated_at": "2023-01-01T00:05:00Z" - }"#, - "completed", - ), - ( - r#"{ - "status": "success", - "task_id": "task-000", - "state": "failed", - "progress": null, - "result": null, - "error": "Task failed due to error", - "created_at": "2023-01-01T00:00:00Z", - "updated_at": "2023-01-01T00:02:00Z" - }"#, - "failed", - ), - ]; - - for (json_response, expected_state) in test_cases { - let response: Result = serde_json::from_str(json_response); - assert!( - response.is_ok(), - "TaskStatusResponse should be deserializable for state {}", - expected_state - ); - - let task_response = response.unwrap(); - assert_eq!(task_response.status, "success"); - assert_eq!(task_response.state, expected_state); - assert!(task_response.task_id.starts_with("task-")); - } -} - -/// Test QueueStatsResponse deserialization -#[test] -fn test_queue_stats_response_deserialization() { - let json_response = r#"{ - "status": "success", - "pending_tasks": 5, - "processing_tasks": 2, - "completed_tasks": 100, - "failed_tasks": 3, - "total_tasks": 110 - }"#; - - let response: Result = serde_json::from_str(json_response); - assert!( - response.is_ok(), - "QueueStatsResponse should be deserializable" - ); - - let stats_response = response.unwrap(); - assert_eq!(stats_response.status, "success"); - assert_eq!(stats_response.pending_tasks, 5); - assert_eq!(stats_response.processing_tasks, 2); - assert_eq!(stats_response.completed_tasks, 100); - assert_eq!(stats_response.failed_tasks, 3); - assert_eq!(stats_response.total_tasks, 110); - - // Verify totals add up correctly - let sum = stats_response.pending_tasks - + stats_response.processing_tasks - + stats_response.completed_tasks - + stats_response.failed_tasks; - assert_eq!(sum, stats_response.total_tasks); -} - -/// Test BatchSummarizeRequest serialization -#[test] -fn test_batch_summarize_request_serialization() { - let documents = vec![ - Document { - id: "doc1".to_string(), - title: "Document 1".to_string(), - body: "Content 1".to_string(), - url: "".to_string(), - description: None, - summarization: None, - stub: None, - tags: None, - rank: None, - source_haystack: None, - doc_type: DocumentType::KgEntry, - synonyms: None, - route: None, - priority: None, - quality_score: None, - }, - Document { - id: "doc2".to_string(), - title: "Document 2".to_string(), - body: "Content 2".to_string(), - url: "".to_string(), - description: None, - summarization: None, - stub: None, - tags: None, - rank: None, - source_haystack: None, - doc_type: DocumentType::KgEntry, - synonyms: None, - route: None, - priority: None, - quality_score: None, - }, - ]; - - let batch_request = BatchSummarizeRequest { - documents: documents.clone(), - role: Some("TestRole".to_string()), - }; - - let json_result = serde_json::to_string(&batch_request); - assert!( - json_result.is_ok(), - "BatchSummarizeRequest should be serializable" - ); - - let json_str = json_result.unwrap(); - assert!(json_str.contains("doc1")); - assert!(json_str.contains("doc2")); - assert!(json_str.contains("Document 1")); - assert!(json_str.contains("Document 2")); - assert!(json_str.contains("TestRole")); -} /// Test error response handling #[test] diff --git a/crates/terraphim_agent/tests/vm_api_tests.rs b/crates/terraphim_agent/tests/vm_api_tests.rs index d7442f981..117bdece5 100644 --- a/crates/terraphim_agent/tests/vm_api_tests.rs +++ b/crates/terraphim_agent/tests/vm_api_tests.rs @@ -1,3 +1,4 @@ +#![cfg(feature = "firecracker")] use terraphim_agent::client::*; /// Test VM-related API types serialization diff --git a/crates/terraphim_agent/tests/vm_functionality_tests.rs b/crates/terraphim_agent/tests/vm_functionality_tests.rs index deb57a32c..46e9dbbe4 100644 --- a/crates/terraphim_agent/tests/vm_functionality_tests.rs +++ b/crates/terraphim_agent/tests/vm_functionality_tests.rs @@ -1,3 +1,4 @@ +#![cfg(feature = "firecracker")] use terraphim_agent::client::*; /// Test VM command parsing with feature gates From d13f139c228651c5dc0dbe0391d3be409a988453 Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 4 Jun 2026 10:02:47 +0200 Subject: [PATCH 09/10] fix(agent): gate firecracker module and ApiClient VM methods with cfg(feature = "firecracker") firecracker.rs called get_vm_status() and execute_vm_code() from ApiClient which are #[cfg(feature = "firecracker")] but the module was compiled unconditionally. This broke cargo test -p terraphim_agent with default features. Fix: add #[cfg(feature = "firecracker")] to pub mod firecracker in modes/mod.rs and update HybridExecutor to conditionally use FirecrackerExecutor only when the feature is enabled, falling back to LocalExecutor otherwise. Refs #2164 Co-Authored-By: Claude Sonnet 4.6 --- .../src/commands/modes/hybrid.rs | 96 +++++++++++++++---- .../terraphim_agent/src/commands/modes/mod.rs | 5 + 2 files changed, 82 insertions(+), 19 deletions(-) diff --git a/crates/terraphim_agent/src/commands/modes/hybrid.rs b/crates/terraphim_agent/src/commands/modes/hybrid.rs index e2da1009a..1181f3ed8 100644 --- a/crates/terraphim_agent/src/commands/modes/hybrid.rs +++ b/crates/terraphim_agent/src/commands/modes/hybrid.rs @@ -5,8 +5,10 @@ use super::{ CommandDefinition, CommandExecutionError, CommandExecutionResult, ExecutionMode, - ExecutorCapabilities, FirecrackerExecutor, LocalExecutor, + ExecutorCapabilities, LocalExecutor, }; +#[cfg(feature = "firecracker")] +use super::FirecrackerExecutor; use crate::commands::RiskLevel; use std::collections::HashMap; @@ -15,6 +17,7 @@ pub struct HybridExecutor { /// Local executor for safe commands local_executor: LocalExecutor, /// Firecracker executor for isolated execution + #[cfg(feature = "firecracker")] firecracker_executor: FirecrackerExecutor, /// Risk assessment settings risk_settings: RiskAssessmentSettings, @@ -149,6 +152,7 @@ impl HybridExecutor { pub fn new() -> Self { Self { local_executor: LocalExecutor::new(), + #[cfg(feature = "firecracker")] firecracker_executor: FirecrackerExecutor::new(), risk_settings: RiskAssessmentSettings::default(), } @@ -158,11 +162,13 @@ impl HybridExecutor { pub fn with_settings(risk_settings: RiskAssessmentSettings) -> Self { Self { local_executor: LocalExecutor::new(), + #[cfg(feature = "firecracker")] firecracker_executor: FirecrackerExecutor::new(), risk_settings, } } + #[cfg(feature = "firecracker")] /// Create a hybrid executor with API client for VM operations pub fn with_api_client(api_client: crate::client::ApiClient) -> Self { Self { @@ -190,7 +196,10 @@ impl HybridExecutor { } } ExecutionMode::Firecracker => { + #[cfg(feature = "firecracker")] return ExecutionMode::Firecracker; + #[cfg(not(feature = "firecracker"))] + return ExecutionMode::Local; } ExecutionMode::Hybrid => { // Perform risk assessment @@ -211,15 +220,24 @@ impl HybridExecutor { // Check command risk level match definition.risk_level { RiskLevel::Critical | RiskLevel::High => { + #[cfg(feature = "firecracker")] return ExecutionMode::Firecracker; + #[cfg(not(feature = "firecracker"))] + return ExecutionMode::Local; } RiskLevel::Medium => { // Medium risk: check other factors if self.has_high_risk_indicators(command_str) { + #[cfg(feature = "firecracker")] return ExecutionMode::Firecracker; + #[cfg(not(feature = "firecracker"))] + return ExecutionMode::Local; } if definition.resource_limits.is_some() { + #[cfg(feature = "firecracker")] return ExecutionMode::Firecracker; + #[cfg(not(feature = "firecracker"))] + return ExecutionMode::Local; } } RiskLevel::Low => { @@ -230,8 +248,11 @@ impl HybridExecutor { } } - // Default to Firecracker for safety - ExecutionMode::Firecracker + // Default to Firecracker for safety when feature enabled, otherwise Local + #[cfg(feature = "firecracker")] + return ExecutionMode::Firecracker; + #[cfg(not(feature = "firecracker"))] + ExecutionMode::Local } /// Check if command is safe for local execution @@ -407,21 +428,24 @@ impl super::CommandExecutor for HybridExecutor { // Execute with the appropriate executor match execution_mode { - ExecutionMode::Local => { + ExecutionMode::Local | ExecutionMode::Hybrid => { self.local_executor .execute_command(definition, parameters) .await } ExecutionMode::Firecracker => { - self.firecracker_executor - .execute_command(definition, parameters) - .await - } - ExecutionMode::Hybrid => { - // This shouldn't happen with proper risk assessment, but handle it - self.local_executor - .execute_command(definition, parameters) - .await + #[cfg(feature = "firecracker")] + { + self.firecracker_executor + .execute_command(definition, parameters) + .await + } + #[cfg(not(feature = "firecracker"))] + { + self.local_executor + .execute_command(definition, parameters) + .await + } } } } @@ -430,25 +454,40 @@ impl super::CommandExecutor for HybridExecutor { // Hybrid executor supports all modes by delegating to appropriate executors match mode { ExecutionMode::Local => self.local_executor.supports_mode(mode), - ExecutionMode::Firecracker => self.firecracker_executor.supports_mode(mode), - ExecutionMode::Hybrid => true, // Hybrid mode is what this executor provides + ExecutionMode::Firecracker => { + #[cfg(feature = "firecracker")] + return self.firecracker_executor.supports_mode(mode); + #[cfg(not(feature = "firecracker"))] + false + } + ExecutionMode::Hybrid => true, } } fn capabilities(&self) -> ExecutorCapabilities { - // Combine capabilities from both executors let local_caps = self.local_executor.capabilities(); + #[cfg(feature = "firecracker")] let vm_caps = self.firecracker_executor.capabilities(); - ExecutorCapabilities { - supports_resource_limits: vm_caps.supports_resource_limits, // VMs have better resource limiting + #[cfg(feature = "firecracker")] + return ExecutorCapabilities { + supports_resource_limits: vm_caps.supports_resource_limits, supports_network_access: vm_caps.supports_network_access, supports_file_system: local_caps.supports_file_system || vm_caps.supports_file_system, max_concurrent_commands: Some( local_caps.max_concurrent_commands.unwrap_or(0) + vm_caps.max_concurrent_commands.unwrap_or(0), ), - default_timeout: vm_caps.default_timeout, // Use VM timeout as default for safety + default_timeout: vm_caps.default_timeout, + }; + + #[cfg(not(feature = "firecracker"))] + ExecutorCapabilities { + supports_resource_limits: local_caps.supports_resource_limits, + supports_network_access: local_caps.supports_network_access, + supports_file_system: local_caps.supports_file_system, + max_concurrent_commands: local_caps.max_concurrent_commands, + default_timeout: local_caps.default_timeout, } } } @@ -493,6 +532,7 @@ mod tests { } #[test] + #[cfg(feature = "firecracker")] fn test_risk_assessment_high_risk_commands() { let hybrid = HybridExecutor::new(); @@ -508,6 +548,24 @@ mod tests { assert_eq!(mode, ExecutionMode::Firecracker); } + #[test] + #[cfg(not(feature = "firecracker"))] + fn test_risk_assessment_high_risk_commands_falls_back_to_local() { + let hybrid = HybridExecutor::new(); + + let risky_definition = CommandDefinition { + name: "dangerous".to_string(), + description: "Dangerous command".to_string(), + risk_level: RiskLevel::High, + execution_mode: ExecutionMode::Hybrid, + ..Default::default() + }; + + // Without firecracker feature, high-risk commands fall back to Local + let mode = hybrid.assess_command_risk("rm -rf /", &risky_definition); + assert_eq!(mode, ExecutionMode::Local); + } + #[test] fn test_dangerous_argument_detection() { let hybrid = HybridExecutor::new(); diff --git a/crates/terraphim_agent/src/commands/modes/mod.rs b/crates/terraphim_agent/src/commands/modes/mod.rs index f1f0f1179..0cbd78d2e 100644 --- a/crates/terraphim_agent/src/commands/modes/mod.rs +++ b/crates/terraphim_agent/src/commands/modes/mod.rs @@ -5,10 +5,12 @@ //! - Firecracker: Isolated execution in microVMs //! - Hybrid: Smart mode selection based on risk assessment +#[cfg(feature = "firecracker")] pub mod firecracker; pub mod hybrid; pub mod local; +#[cfg(feature = "firecracker")] pub use firecracker::FirecrackerExecutor; pub use hybrid::HybridExecutor; pub use local::LocalExecutor; @@ -53,7 +55,10 @@ pub struct ExecutorCapabilities { pub fn create_executor(mode: ExecutionMode) -> Box { match mode { ExecutionMode::Local => Box::new(LocalExecutor::new()), + #[cfg(feature = "firecracker")] ExecutionMode::Firecracker => Box::new(FirecrackerExecutor::new()), + #[cfg(not(feature = "firecracker"))] + ExecutionMode::Firecracker => Box::new(LocalExecutor::new()), ExecutionMode::Hybrid => Box::new(HybridExecutor::new()), } } From bf6ed208b312e40dd278a8ee5f9fddf422818988 Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 4 Jun 2026 11:18:33 +0200 Subject: [PATCH 10/10] fix(security): sanitise agent_id and learning_id in markdown_store paths (CWE-22) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit source_agent was joined directly into filesystem paths in agent_dir(), save(), save_to_shared(), load(), and delete() — enabling path traversal attacks (CWE-22) via crafted agent IDs such as "../../../etc/passwd". Add sanitise_path_component() which replaces every character that is not ASCII-alphanumeric, hyphen, underscore, or dot with an underscore, and emits a tracing::warn! when sanitisation is required (audit trail for injection attempts). Apply sanitisation at every path-construction site: - agent_dir(): agent_id - save(): learning.id in filename - save_to_shared(): both learning.source_agent and learning.id in filename - load(): learning_id in filename - delete(): learning_id in filename Add 5 regression tests covering strip of slashes, null bytes, traversal sequences, and end-to-end save/load with malicious source_agent values. Fixes #2160 --- .../src/shared_learning/markdown_store.rs | 145 +++++++++++++++++- 1 file changed, 140 insertions(+), 5 deletions(-) diff --git a/crates/terraphim_agent/src/shared_learning/markdown_store.rs b/crates/terraphim_agent/src/shared_learning/markdown_store.rs index 909cc7a58..c161972a3 100644 --- a/crates/terraphim_agent/src/shared_learning/markdown_store.rs +++ b/crates/terraphim_agent/src/shared_learning/markdown_store.rs @@ -10,6 +10,34 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use tracing::{info, warn}; +/// Sanitise a path component, replacing dangerous characters with underscores. +/// +/// Allows only ASCII alphanumeric characters, hyphens, underscores, and dots. +/// Any other character (including `/`, `\`, null bytes, and `..` sequences made +/// reachable via `/`) is replaced with `_`. Callers that receive user-supplied +/// strings for agent IDs or learning IDs must pass them through this function +/// before constructing filesystem paths (CWE-22 prevention). +fn sanitise_path_component(component: &str) -> String { + let sanitised: String = component + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { + c + } else { + '_' + } + }) + .collect(); + if sanitised != component { + warn!( + original = component, + sanitised = %sanitised, + "path component contained unsafe characters — sanitised to prevent path traversal" + ); + } + sanitised +} + use crate::shared_learning::types::{LearningSource, QualityMetrics, SharedLearning, TrustLevel}; #[derive(Error, Debug)] @@ -111,8 +139,12 @@ impl MarkdownLearningStore { } /// Get the directory for a specific agent's learnings + /// + /// The `agent_id` is sanitised before joining to prevent path traversal (CWE-22). pub fn agent_dir(&self, agent_id: &str) -> PathBuf { - self.config.learnings_dir.join(agent_id) + self.config + .learnings_dir + .join(sanitise_path_component(agent_id)) } /// Get the shared directory for cross-agent learnings @@ -127,7 +159,7 @@ impl MarkdownLearningStore { let agent_dir = self.agent_dir(&learning.source_agent); tokio::fs::create_dir_all(&agent_dir).await?; - let file_path = agent_dir.join(format!("{}.md", learning.id)); + let file_path = agent_dir.join(format!("{}.md", sanitise_path_component(&learning.id))); let content = Self::to_markdown(learning)?; tokio::fs::write(&file_path, content).await?; @@ -144,7 +176,11 @@ impl MarkdownLearningStore { let shared_dir = self.shared_dir(); tokio::fs::create_dir_all(&shared_dir).await?; - let file_path = shared_dir.join(format!("{}-{}.md", learning.source_agent, learning.id)); + let file_path = shared_dir.join(format!( + "{}-{}.md", + sanitise_path_component(&learning.source_agent), + sanitise_path_component(&learning.id) + )); let content = Self::to_markdown(learning)?; tokio::fs::write(&file_path, content).await?; @@ -163,7 +199,9 @@ impl MarkdownLearningStore { agent_id: &str, learning_id: &str, ) -> Result { - let file_path = self.agent_dir(agent_id).join(format!("{}.md", learning_id)); + let file_path = self + .agent_dir(agent_id) + .join(format!("{}.md", sanitise_path_component(learning_id))); self.load_from_path(&file_path).await } @@ -241,7 +279,9 @@ impl MarkdownLearningStore { agent_id: &str, learning_id: &str, ) -> Result<(), MarkdownStoreError> { - let file_path = self.agent_dir(agent_id).join(format!("{}.md", learning_id)); + let file_path = self + .agent_dir(agent_id) + .join(format!("{}.md", sanitise_path_component(learning_id))); tokio::fs::remove_file(&file_path).await?; info!("Deleted learning {} from agent {}", learning_id, agent_id); Ok(()) @@ -638,4 +678,99 @@ This is content from an old learning. let all = store.list_all().await.unwrap(); assert!(all.is_empty()); } + + // -- Path traversal regression tests (CWE-22) -- + + #[test] + fn test_sanitise_path_component_strips_slashes() { + assert_eq!(sanitise_path_component("../../../etc"), ".._.._.._etc"); + assert_eq!(sanitise_path_component("/etc/passwd"), "_etc_passwd"); + assert_eq!(sanitise_path_component("a\\b"), "a_b"); + } + + #[test] + fn test_sanitise_path_component_preserves_safe_chars() { + assert_eq!(sanitise_path_component("agent-1"), "agent-1"); + assert_eq!(sanitise_path_component("my_agent.v2"), "my_agent.v2"); + assert_eq!( + sanitise_path_component("abc123-ABC_def.0"), + "abc123-ABC_def.0" + ); + } + + #[test] + fn test_sanitise_path_component_strips_null_bytes() { + let with_null = "agent\x00id"; + let sanitised = sanitise_path_component(with_null); + assert!(!sanitised.contains('\x00')); + assert_eq!(sanitised, "agent_id"); + } + + #[tokio::test] + async fn test_agent_dir_is_contained_when_id_has_traversal() { + let temp_dir = TempDir::new().unwrap(); + let config = MarkdownStoreConfig { + learnings_dir: temp_dir.path().to_path_buf(), + shared_dir_name: "shared".to_string(), + }; + let store = MarkdownLearningStore::with_config(config); + + let dir = store.agent_dir("../../../etc"); + assert!( + dir.starts_with(temp_dir.path()), + "agent_dir must remain within learnings_dir after sanitisation, got: {:?}", + dir + ); + } + + #[tokio::test] + async fn test_save_with_traversal_agent_stays_inside_learnings_dir() { + let temp_dir = TempDir::new().unwrap(); + let config = MarkdownStoreConfig { + learnings_dir: temp_dir.path().to_path_buf(), + shared_dir_name: "shared".to_string(), + }; + let store = MarkdownLearningStore::with_config(config); + + let learning = SharedLearning::new( + "Traversal Test".to_string(), + "Body".to_string(), + LearningSource::AutoExtract, + "../../../etc".to_string(), + ); + + store.save(&learning).await.unwrap(); + + // The file must live inside temp_dir, not escape to /etc + let agent_dir = store.agent_dir("../../../etc"); + assert!( + agent_dir.starts_with(temp_dir.path()), + "Saved file must stay inside learnings_dir: {:?}", + agent_dir + ); + } + + #[tokio::test] + async fn test_save_to_shared_with_traversal_stays_inside_shared_dir() { + let temp_dir = TempDir::new().unwrap(); + let config = MarkdownStoreConfig { + learnings_dir: temp_dir.path().to_path_buf(), + shared_dir_name: "shared".to_string(), + }; + let store = MarkdownLearningStore::with_config(config); + + let learning = SharedLearning::new( + "Shared Traversal Test".to_string(), + "Body".to_string(), + LearningSource::AutoExtract, + "../../passwd".to_string(), + ); + + store.save_to_shared(&learning).await.unwrap(); + + let shared = store.list_shared().await.unwrap(); + assert_eq!(shared.len(), 1); + // The sanitised source_agent must appear in the title match, confirming the file loaded + assert_eq!(shared[0].title, "Shared Traversal Test"); + } }