diff --git a/tests/reborn_integration_greeting.rs b/tests/reborn_integration_greeting.rs index c4396d1f8fd..318fc5f2591 100644 --- a/tests/reborn_integration_greeting.rs +++ b/tests/reborn_integration_greeting.rs @@ -5,6 +5,12 @@ //! `ironclaw_llm` decorator chain (hermetic passthrough) → scripted `TraceLlm` //! → assistant reply finalized in thread history. InMemory storage, no services, //! no keys, no Docker, no `integration` feature. +//! +//! Asserts BOTH facets of the one default turn: the finalized reply (output +//! seam) and the model-visible system prompt (input seam, T0-SYSPROMPT). The +//! system-prompt assertion rides this smoke test rather than a redundant file — +//! it exercises the same `build → submit_turn` path, so consolidating avoids a +//! second full support-tree compile for zero new path coverage. // The support tree is large and shared; a single-test file exercises only a // slice of it, so suppress dead-code warnings on the includes (matches @@ -33,4 +39,20 @@ async fn replies_to_greeting() { .assert_reply_contains("Hello! How can I help?") .await .expect("reply finalized in thread history"); + // Input seam (T0-SYSPROMPT): the composed capability policy is rendered + // into a `System`-role message the model actually saw this turn. + harness + .assert_system_prompt_contains("Use only visible capabilities.") + .await + .expect("composed capability policy reached the model as a system prompt"); + // Negative guard: the user's own turn text appears in the captured request + // but only in a `User`-role message, so the `System`-only filter must not + // match it — proves the assertion discriminates on role, not mere presence. + assert!( + harness + .assert_system_prompt_contains("hi there") + .await + .is_err(), + "system-prompt assertion must not match user-role text" + ); } diff --git a/tests/reborn_integration_secret_injection.rs b/tests/reborn_integration_secret_injection.rs new file mode 100644 index 00000000000..a3b6076fbeb --- /dev/null +++ b/tests/reborn_integration_secret_injection.rs @@ -0,0 +1,103 @@ +//! Reborn integration-test tier — T0-SECRET-INJECT. +//! +//! Proves credential/secret injection reaches the wire: a scripted `github.*` +//! tool call executes the real first-party GitHub WASM capability behind a +//! `GithubHarnessAuthorizer` that attaches an `InjectCredentialAccountOnce` +//! obligation. The host egress pipeline resolves the synthetic access token +//! (`ghp_fake_fixture_token`, from the harness `StaticSecretStore`) and injects +//! it as `Authorization: Bearer ` onto the outbound request before the +//! recording network egress captures it. The assertion reads that captured +//! request and confirms the injected credential is present on the header. +//! +//! Note on the egress lane: this harness's runtime egress recorder +//! (`runtime_http_requests()`) is inert — `try_with_host_http_egress` overwrites +//! the runtime port with the host pipeline over the recording *network* egress — +//! so injection is observable on the network lane. See +//! `assert_network_egress_header_contains` for the full mechanism. +//! +//! Security: the token is a synthetic test fixture, never a real credential. + +// The support tree is large and shared; a single-test file exercises only a +// slice of it, so suppress dead-code warnings on the includes (matches +// `reborn_integration_greeting.rs`). +#[allow(dead_code)] +#[path = "support/reborn/mod.rs"] +mod reborn_support; +#[allow(dead_code)] +mod support; + +use reborn_support::builder::RebornIntegrationHarness; +use reborn_support::reply::RebornScriptedReply; +use serde_json::json; + +#[tokio::test] +async fn injects_credential_onto_github_egress() { + let harness = RebornIntegrationHarness::test_default() + .with_github_issue_tools() + .script([ + RebornScriptedReply::tool_call( + "github.get_repo", + json!({"owner": "nearai", "repo": "ironclaw"}), + ), + RebornScriptedReply::text("done"), + ]) + .build() + .await + .expect("harness builds"); + harness + .submit_turn("fetch the ironclaw repo") + .await + .expect("turn completes"); + harness + .assert_reply_contains("done") + .await + .expect("reply finalized in thread history"); + // The synthetic access token was injected onto the outbound request as a + // Bearer credential by the host egress pipeline — proving injection reaches + // the wire, not just the authorizer's obligation. + harness + .assert_network_egress_header_contains( + "api.github.com/repos/nearai/ironclaw", + "authorization", + "Bearer ghp_fake_fixture_token", + ) + .await + .expect("injected credential present on github egress request"); + + // Negative-path coverage on the SAME captured request: a regression that + // ignored the url/header-name/value inputs must not let this assertion + // pass vacuously (review comment on PR #5483). + let wrong_url = harness + .assert_network_egress_header_contains( + "api.github.com/repos/nonexistent/repo", + "authorization", + "Bearer ghp_fake_fixture_token", + ) + .await + .expect_err("no captured request should match an unrelated url"); + assert!( + wrong_url + .to_string() + .contains("no captured network egress request matching url") + ); + + let wrong_header_name = harness + .assert_network_egress_header_contains( + "api.github.com/repos/nearai/ironclaw", + "x-not-a-real-header", + "Bearer ghp_fake_fixture_token", + ) + .await + .expect_err("matching url has no such header name"); + assert!(wrong_header_name.to_string().contains("has header")); + + let wrong_value = harness + .assert_network_egress_header_contains( + "api.github.com/repos/nearai/ironclaw", + "authorization", + "Bearer wrong-token", + ) + .await + .expect_err("matching url/header present but value doesn't match"); + assert!(wrong_value.to_string().contains("has header")); +} diff --git a/tests/support/reborn/CLAUDE.md b/tests/support/reborn/CLAUDE.md index 7f29fa9a281..9c4555fe214 100644 --- a/tests/support/reborn/CLAUDE.md +++ b/tests/support/reborn/CLAUDE.md @@ -79,7 +79,7 @@ conversation; `submit_turn`/`assert_reply_contains` take just the text. MCP assertion (`assert_mcp_tool_called`), approval methods (`submit_turn_until_blocked` / `approve_gate` / `deny_gate` / `enable_auto_approve`), and the `pub(super)` capture accessors (`captured_egress_requests` / - `captured_capability_results`) the assertion file reads. + `captured_capability_results` / `captured_system_prompts`) the assertion file reads. - `harness_mcp.rs` — the mock-MCP scaffolding extracted from `harness.rs`: `LoopbackMcpRuntimeHttpEgress` (the real-HTTP loopback egress), the `LoopbackMcpRuntime` type alias + `build_loopback_mcp_runtime` factory, @@ -101,7 +101,10 @@ conversation; `submit_turn`/`assert_reply_contains` take just the text. - `assertions.rs` — the richer egress + tool-result assertions (`assert_egress_count` / `assert_egress_url_order` / `assert_egress_method_order` / `assert_egress_body_contains` / `assert_tool_result_contains` / - `assert_tool_error`). + `assert_tool_error` / `assert_network_egress_header_contains`), plus the + model-prompt assertion `assert_system_prompt_contains` (reads the scripted + `TraceLlm`'s captured requests via `captured_system_prompts`, not the egress + log). - Tests live as flat `tests/reborn_*.rs` (Cargo requires top-level test files). Module paths: each `tests/reborn_*.rs` declares both `#[path = "support/reborn/mod.rs"] mod reborn_support;` and `mod support;`, then `use reborn_support::builder::RebornIntegrationHarness;` / `use reborn_support::reply::RebornScriptedReply;`. Inside the support tree, siblings reference each other via `super::` and `trace_llm` via `crate::support::trace_llm` (there is no `crate::support::reborn` path). Copy the includes from `tests/reborn_integration_greeting.rs`. @@ -151,6 +154,7 @@ Richer assertions in `assertions.rs` (all check the `[baseline..]` delta per thr - `assert_egress_body_contains(url_substr, body_substr)` — body of the captured egress request whose URL contains the substring. - `assert_tool_result_contains(needle)` — a recorded capability result's output contains the text (proves the scripted body surfaced back to the model on the *Completed* path; reads the in-process recorder). - `assert_tool_error(class, reason)` — a persisted `ToolResultReference` envelope's parsed `safe_summary` field is of outcome `class` (`ToolErrorClass::{Failed, Denied}`) and carries `reason`. Distinct from `assert_tool_result_contains`: this reads the *Failed*/*Denied* capability-error path (persisted via `append_tool_result_reference`), not the in-process recorder, so it's the assertion for `egress_error`-scripted responses and other capability failures/denials. `class` is a typed arg (not a needle prefix) so it discriminates Failed-vs-Denied structurally — a `Failed{PolicyDenied}` and a `Denied{policy_denied}` render the same `reason` token but different classes. Parses the `safe_summary` field (not a raw-JSON substring). Scans full thread history (not baseline-sliced) — safe only for single-turn harnesses today; a multi-turn/group reuse must add baseline scoping first. +- `assert_network_egress_header_contains(url_substr, header_name, value_substr)` — reads the **network** egress lane (`captured_network_requests()`), not the runtime lane the four assertions above read. Needed for `.with_github_issue_tools()`: that harness's `try_with_host_http_egress` overwrites the runtime port with the host egress pipeline over the network recorder, so the runtime-lane `assert_egress_*` family is inert for it — assert here instead. ### Keyed HTTP responses @@ -197,6 +201,22 @@ Script with `RebornScriptedReply::tool_call("mock-mcp.search", json!({}))`. - `assert_mcp_tool_called(tool_name)` — maps `tool_name` → `"mock-mcp."` and delegates to `assert_tool_invoked`. +### Credential injection (GitHub) + +`.with_github_issue_tools()` wires the real GitHub first-party WASM +capabilities behind a `GithubHarnessAuthorizer`, which authorizes every +dispatch with an `InjectCredentialAccountOnce` obligation. A scripted +`github.*` tool call executes the real WASM module; its outbound HTTP +request gets a synthetic `Authorization: Bearer ` credential +injected by the host egress pipeline before it reaches the recording +network egress. This is the credential-injection-reaches-the-wire proof +(T0-SECRET-INJECT). + +Script with `RebornScriptedReply::tool_call("github.get_repo", json!({"owner": ..., "repo": ...}))` +followed by a trailing `RebornScriptedReply::text(..)` turn. + +- `assert_network_egress_header_contains(url_substr, header_name, value_substr)` — see the "Richer assertions" list below; this is the assertion for this capability. + ### OAuth / product-auth Available from crate `ironclaw_reborn_composition::test_support`, gated on @@ -364,7 +384,9 @@ pub async fn run(g: &RebornIntegrationGroup) -> HarnessResult<()> { ### Per-thread baseline (R2) Each `RebornIntegrationHarness` records `baseline_invocation_count`, -`baseline_egress_count`, and `baseline_result_count` at construction from the -shared recorder's current lengths. All assertion methods (`assert_tool_invoked`, -`assert_egress_request_matching`, etc.) slice `[baseline..]` so a thread never -spuriously passes on a prior thread's entries. +`baseline_egress_count`, `baseline_result_count`, `baseline_process_count`, and +`baseline_network_count` at construction from the shared recorder's current +lengths. All assertion methods (`assert_tool_invoked`, +`assert_egress_request_matching`, `assert_network_egress_header_contains`, +etc.) slice `[baseline..]` so a thread never spuriously passes on a prior +thread's entries. diff --git a/tests/support/reborn/assertions.rs b/tests/support/reborn/assertions.rs index 606d47a933d..7b766ff0b58 100644 --- a/tests/support/reborn/assertions.rs +++ b/tests/support/reborn/assertions.rs @@ -1,6 +1,6 @@ -//! Egress + tool-result assertions for [`RebornIntegrationHarness`] — the -//! canonical, richer egress-assertion API (design §3.3 `assertions.rs`, §3.6 -//! P1 ergonomics). +//! Egress + tool-result + model-prompt assertions for [`RebornIntegrationHarness`] +//! — the canonical, richer egress-assertion API (design §3.3 `assertions.rs`, +//! §3.6 P1 ergonomics). //! //! Slice 2 co-located three asserts in `builder.rs` //! (`assert_reply_contains`/`assert_tool_invoked`/`assert_egress_request_matching`, @@ -11,9 +11,18 @@ //! `pub(super)` accessors on the harness (`captured_egress_requests` / //! `captured_capability_results`) rather than re-reaching internals. //! -//! All of these assert over the SAME captured `RecordingRuntimeHttpEgress` -//! request log slice 2 wired — there is one egress-assertion API, not a parallel -//! one (the O-egress MCP/OAuth interceptor folds its per-URL needs in here). +//! The egress-assertion group (`assert_egress_count` / `assert_egress_url_order` +//! / `assert_egress_method_order` / `assert_egress_body_contains`) all assert +//! over the SAME captured `RecordingRuntimeHttpEgress` request log slice 2 wired +//! — there is one runtime-lane egress-assertion API, not a parallel one (the +//! O-egress MCP/OAuth interceptor folds its per-URL needs in here). The one +//! exception is `assert_network_egress_header_contains`, which reads the +//! recording *network* egress lane — required for the T0-SECRET-INJECT +//! credential-injection proof, whose harness routes through the host egress +//! pipeline over the network recorder (see that method's docs for why). +//! `assert_system_prompt_contains` reads a different capture source — the +//! scripted `TraceLlm`'s captured requests, via the harness's +//! `captured_system_prompts` accessor. // Shared integration-test support: not every binary that mounts the // `reborn_support` tree consumes this module (e.g. `support_unit_tests.rs`), so @@ -137,6 +146,30 @@ impl RebornIntegrationHarness { .into()) } + /// Assert some model-visible `System`-role prompt captured across all + /// requests captured by the harness so far contains `text`. Reads the + /// scripted `TraceLlm` retained before the `dyn LlmProvider` upcast — + /// proves prompt-injected content (safety banners, skill instructions, + /// profile lines) actually reached the model. + pub async fn assert_system_prompt_contains(&self, text: &str) -> HarnessResult<()> { + let prompts = self.captured_system_prompts(); + if prompts.iter().any(|prompt| prompt.contains(text)) { + return Ok(()); + } + let seen: Vec = prompts + .iter() + .map(|prompt| match prompt.char_indices().nth(200) { + Some((cutoff, _)) => format!("{}...[truncated]", &prompt[..cutoff]), + None => prompt.clone(), + }) + .collect(); + Err(format!( + "no captured system prompt containing {text:?}; saw {} system message(s): {seen:?}", + prompts.len() + ) + .into()) + } + /// Assert a model-visible tool error of `class` carrying `reason` was /// persisted for this thread. Unlike [`assert_tool_result_contains`] (which /// reads the in-process recorder, populated only on the *Completed* write @@ -198,6 +231,70 @@ impl RebornIntegrationHarness { .into()) } + /// Assert that any captured **network** egress request whose URL + /// contains `url_substr` carried a header named `header_name` + /// (case-insensitive) whose value contains `value_substr`. This is the + /// credential-injection-on-the-wire proof for T0-SECRET-INJECT: a + /// host-injected `Authorization: Bearer ` lands on the outbound + /// request only after the egress pipeline's `apply_credential_injections` + /// step, which the recording network egress captures. + /// + /// **Why the network lane, not the runtime lane:** the GitHub WASM harness + /// (`with_github_issue_tools`) wires its recording `RuntimeHttpEgress` and + /// then calls `try_with_host_http_egress`, which overwrites the runtime port + /// with the host egress pipeline over the recording *network* egress. So the + /// injected request flows through the network recorder, and the runtime-lane + /// `assert_egress_*` family (which reads `runtime_http_requests()`) is inert + /// for this wiring. Assert here instead. + /// + /// Checks only the `[baseline_network_count..]` delta so a group thread never + /// spuriously matches a prior thread's request (R2), mirroring the runtime-lane + /// `assert_egress_*` family's baseline discipline even though no group + /// constructor wires `GithubIssueTools` today. + pub async fn assert_network_egress_header_contains( + &self, + url_substr: &str, + header_name: &str, + value_substr: &str, + ) -> HarnessResult<()> { + let requests = self.captured_network_requests(); + let mut matching = requests + .iter() + .filter(|r| r.url.contains(url_substr)) + .peekable(); + if matching.peek().is_none() { + let seen: Vec<&str> = requests.iter().map(|r| r.url.as_str()).collect(); + return Err(format!( + "no captured network egress request matching url {url_substr:?}; saw {seen:?}" + ) + .into()); + } + let mut first_seen: Option> = None; + for request in matching { + if request.headers.iter().any(|(name, value)| { + name.eq_ignore_ascii_case(header_name) && value.contains(value_substr) + }) { + return Ok(()); + } + if first_seen.is_none() { + first_seen = Some( + request + .headers + .iter() + .map(|(name, _)| name.as_str()) + .collect(), + ); + } + } + let seen = first_seen.unwrap_or_default(); + Err(format!( + "no network egress request matching url {url_substr:?} has header {header_name:?} \ + with the expected value (redacted, not logged); header names present (first \ + matching request): {seen:?}" + ) + .into()) + } + /// Assert some recorded capability result (tool output) — i.e. a surfaced /// HTTP response — serializes to text containing `needle`. Proves the keyed /// scripted body actually surfaced back to the model as a tool result. diff --git a/tests/support/reborn/builder.rs b/tests/support/reborn/builder.rs index 3e12ad064c9..23f4ab85329 100644 --- a/tests/support/reborn/builder.rs +++ b/tests/support/reborn/builder.rs @@ -35,6 +35,8 @@ use ironclaw_filesystem::{ use ironclaw_host_api::{ MountAlias, MountGrant, MountPermissions, MountView, RuntimeHttpEgressRequest, VirtualPath, }; +use ironclaw_llm::Role; +use ironclaw_network::NetworkHttpRequest; use ironclaw_product_adapters::{ProductInboundAck, ProductTriggerReason, ProductWorkflow}; use ironclaw_product_workflow::{ DefaultProductWorkflow, ProductConversationRouteKind, ResolveBindingRequest, ResolvedBinding, @@ -56,6 +58,7 @@ use super::process::ScriptedProcessResult; use super::reply::RebornScriptedReply; use super::session_thread::RebornThreadHarness; use super::test_adapter::RebornTestIngress; +use crate::support::trace_llm::TraceLlm; type HarnessResult = Result>; @@ -104,6 +107,13 @@ enum RebornCapabilityBackend { /// Uses `LoopbackMcpRuntimeHttpEgress` which makes real HTTP connections to /// the mock server; no real credentials or network policy are required. MockMcp { mcp_url: String }, + /// GitHub first-party WASM capabilities with a `GithubHarnessAuthorizer` + /// that attaches an `InjectCredentialAccountOnce` obligation, so a dispatched + /// `github.*` tool call gets a synthetic access token injected onto the + /// outbound request (T0-SECRET-INJECT). The credential lands on the recorded + /// **network** egress (`assert_network_egress_header_contains`); the runtime + /// egress recorder is inert for this wiring — see that assertion's docs. + GithubIssueTools, } /// Builder for [`RebornIntegrationHarness`]. The script is fixed at build time @@ -211,6 +221,23 @@ impl RebornIntegrationHarnessBuilder { self } + /// Wire the GitHub first-party WASM capabilities behind a + /// `GithubHarnessAuthorizer`, which allows every dispatch with an + /// `InjectCredentialAccountOnce` obligation. A scripted `github.*` tool call + /// then executes the real WASM module, whose outbound HTTP request has a + /// synthetic `Authorization: Bearer ` credential injected by the host + /// egress pipeline before it reaches the recording network egress. Proves + /// credential injection reaches the wire (T0-SECRET-INJECT). + /// + /// Script the model with + /// `RebornScriptedReply::tool_call("github.get_repo", json!({"owner": ..., "repo": ...}))` + /// followed by a `RebornScriptedReply::text(..)` turn, then assert with + /// [`assert_network_egress_header_contains`](RebornIntegrationHarness::assert_network_egress_header_contains). + pub fn with_github_issue_tools(mut self) -> Self { + self.capability = RebornCapabilityBackend::GithubIssueTools; + self + } + /// Wire the real MCP runtime backed by a loopback mock MCP server (slice 6). /// /// `mcp_url` is the full mock endpoint URL (e.g. `server.mcp_url()`). The @@ -274,6 +301,13 @@ impl RebornIntegrationHarnessBuilder { .await?; GroupCapability::HostRuntime(Arc::new(host_runtime)) } + RebornCapabilityBackend::GithubIssueTools => { + // T0-SECRET-INJECT: GitHub WASM caps behind `GithubHarnessAuthorizer` + // (InjectCredentialAccountOnce). No approval gate / user alignment — + // the authorizer allows every dispatch outright. + let host_runtime = HostRuntimeCapabilityHarness::github_issue_tools().await?; + GroupCapability::HostRuntime(Arc::new(host_runtime)) + } }; // Routed through the group/thread builder (one assembly path for both @@ -308,6 +342,12 @@ pub struct RebornIntegrationHarness { pub(crate) coordinator: Arc, pub(crate) event_seq: AtomicU64, pub(crate) capability_recorder: HarnessCapabilityRecorder, + /// The concrete scripted `TraceLlm` retained before it was upcast to + /// `dyn LlmProvider` in the per-thread gateway build. Its + /// `captured_requests()` lets assertions inspect the model-visible prompt + /// (system-prompt injection: safety banners, skill instructions, profile + /// lines). Read via `captured_system_prompts()`. + pub(crate) scripted_llm: Arc, /// Shared storage bundle keeping the composite, TempDir, product harness, and /// capability alive for this harness's lifetime. For a single-shot harness the /// Arc is the sole owner; for a group thread it is shared with the group and @@ -324,6 +364,8 @@ pub struct RebornIntegrationHarness { pub(crate) baseline_result_count: usize, /// Recorded-process-command count at harness construction. See `baseline_invocation_count`. pub(crate) baseline_process_count: usize, + /// Network-egress-request count at harness construction. See `baseline_invocation_count`. + pub(crate) baseline_network_count: usize, } impl RebornIntegrationHarness { @@ -536,6 +578,34 @@ impl RebornIntegrationHarness { all[self.baseline_egress_count..].to_vec() } + /// Every `System`-role prompt the model saw across the captured requests, in + /// call order. Reads the scripted `TraceLlm` retained before the + /// `dyn LlmProvider` upcast (`scripted_llm`). Empty until the first turn is + /// submitted. Read by `assert_system_prompt_contains` in `assertions.rs`. + /// + /// No `[baseline..]` slice (unlike `captured_egress_requests`): `scripted_llm` + /// is a fresh per-thread `Arc` built in `RebornThreadBuilder::build`, + /// not a group-shared recorder, so it only ever holds this thread's requests. + pub(super) fn captured_system_prompts(&self) -> Vec { + self.scripted_llm + .captured_requests() + .into_iter() + .flatten() + .filter(|message| matches!(message.role, Role::System)) + .map(|message| message.content) + .collect() + } + + /// Snapshot of the captured **network** egress requests for this thread only + /// (`[baseline_network_count..]` delta), in call order. Read by + /// `assert_network_egress_header_contains` (assertions.rs) — the T0-SECRET-INJECT + /// credential-injection assertion, which observes a different recorder lane + /// than `captured_egress_requests` (see that assertion's docs for why). + pub(super) fn captured_network_requests(&self) -> Vec { + let mut all = self.capability_recorder.network_http_requests(); + all.split_off(self.baseline_network_count) + } + /// Assert that a `builtin.shell` command was recorded by the inert process /// port and that the recorded command string contains `substr`. This proves /// the shell tool call was dispatched through the process port without diff --git a/tests/support/reborn/group.rs b/tests/support/reborn/group.rs index 9d9f9a96817..52af76f5295 100644 --- a/tests/support/reborn/group.rs +++ b/tests/support/reborn/group.rs @@ -126,6 +126,7 @@ use super::scope_gateway::ScopeRegistryGateway; use super::scripted_provider::{SCRIPTED_MODEL_NAME, scripted_trace_llm}; use super::session_thread::RebornThreadHarness; use super::test_adapter::{RebornTestIngress, RebornTestProductAdapter}; +use crate::support::trace_llm::TraceLlm; /// Convenience alias matching `builder.rs` and `harness.rs`. pub type HarnessResult = Result>; @@ -767,7 +768,11 @@ impl<'g> RebornThreadBuilder<'g> { // --- per-thread scripted gateway, registered before any submit --------- // Session path is per-conversation so group threads do not clobber each // other's LLM session cache under the same `turn_root`. - let raw: Arc = Arc::new(scripted_trace_llm(self.replies)); + // Retain the concrete `TraceLlm` before the `dyn LlmProvider` upcast so + // the harness can inspect the model-visible system prompt via + // `captured_requests()` (T0-SYSPROMPT — unblocks prompt-injection asserts). + let scripted_llm: Arc = Arc::new(scripted_trace_llm(self.replies)); + let raw: Arc = scripted_llm.clone(); let session = create_session_manager(SessionConfig { session_path: shared .turn_root @@ -800,6 +805,7 @@ impl<'g> RebornThreadBuilder<'g> { let baseline_egress_count = capability_recorder.runtime_http_requests().len(); let baseline_result_count = capability_recorder.capability_results().len(); let baseline_process_count = capability_recorder.recorded_process_commands().len(); + let baseline_network_count = capability_recorder.network_http_requests().len(); // --- per-thread workflow over the SHARED coordinator -------------------- let binding_service: Arc = @@ -839,11 +845,13 @@ impl<'g> RebornThreadBuilder<'g> { coordinator: Arc::clone(&shared.coordinator), event_seq: AtomicU64::new(1), capability_recorder, + scripted_llm, _shared: Arc::clone(&shared), baseline_invocation_count, baseline_egress_count, baseline_result_count, baseline_process_count, + baseline_network_count, }) } } diff --git a/tests/support/reborn/harness.rs b/tests/support/reborn/harness.rs index a64bd7b929d..acb3e1bf8e7 100644 --- a/tests/support/reborn/harness.rs +++ b/tests/support/reborn/harness.rs @@ -305,7 +305,7 @@ impl HarnessCapabilityRecorder { } } - fn network_http_requests(&self) -> Vec { + pub(crate) fn network_http_requests(&self) -> Vec { match self { Self::Recording(_) => Vec::new(), Self::HostRuntime(harness) => harness.network_http_requests(), @@ -2322,7 +2322,10 @@ impl HostRuntimeCapabilityHarness { }) } - async fn github_issue_tools() -> HarnessResult { + /// Wires the GitHub first-party WASM capabilities behind `GithubHarnessAuthorizer`. + /// See `github_issue_tools_with_credential_result` for the credential-injection + /// coupling this relies on (T0-SECRET-INJECT). + pub(crate) async fn github_issue_tools() -> HarnessResult { // Credential account resolves to a real handle → capability dispatches. Self::github_issue_tools_with_credential_result(Ok(SecretHandle::new( "github_manual_access", @@ -2339,6 +2342,29 @@ impl HostRuntimeCapabilityHarness { /// Shared GitHub-extension constructor (E-AUTHGATE): the only difference /// between the happy-path and auth-blocked variants is the credential account /// resolver result, so the full `Self {..}` literal lives here once. + /// + /// **Credential injection runs through two mechanisms here, not one — worth + /// knowing before you change either.** The authorizer's + /// `InjectCredentialAccountOnce` obligation is one path. The + /// `local_dev_host_runtime_with_registry_and_egress` helper this calls into + /// separately auto-wires `SharedHostWasmRuntimeCredentials` with product-auth + /// restaging via `try_with_wasm_runtime` (since both `.with_secret_store` and + /// `.with_runtime_credential_account_resolver` are always set on that path), + /// which independently resolves the GitHub manifest's declared + /// `runtime_credentials` and stages the same secret. That staging path runs + /// unconditionally on every WASM HTTP call (`WasmRuntimeHttpAdapter::request`) + /// — it is not gated on the authorizer's `Decision`. So a test asserting on the + /// injected header proves the *end-to-end* wire outcome, not that the + /// authorizer's obligation specifically is the sole producer of the header. + /// + /// As currently wired (manually verified once, not re-checked by CI — treat as + /// current-harness observation, not a guaranteed contract): removing the + /// obligation does not make the call fall back to an unauthenticated request; + /// the run instead hangs and never reaches `Completed`. That's why the + /// mutation-verify in `reborn_integration_secret_injection.rs` proves the + /// obligation's secret reaches the wire by flipping the secret *value* (a fast, + /// specific assertion failure) rather than by removing the obligation (which + /// would only yield a slow, ambiguous timeout — a poor mutation-test signal). fn github_issue_tools_with_credential_result( credential_account_result: Result, ) -> HarnessResult {