diff --git a/Cargo.lock b/Cargo.lock index 4d589de6e7f..720ceeb5d3d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4054,6 +4054,7 @@ version = "0.1.0" dependencies = [ "chrono", "ironclaw_extensions", + "ironclaw_filesystem", "ironclaw_host_api", "ironclaw_product_adapters", "serde", @@ -4555,6 +4556,7 @@ dependencies = [ "ironclaw_reborn_event_store", "ironclaw_resources", "ironclaw_run_state", + "ironclaw_runner", "ironclaw_safety", "ironclaw_scripts", "ironclaw_skills", diff --git a/Cargo.toml b/Cargo.toml index ced66c29010..3a7b3d7147c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -159,7 +159,7 @@ ironclaw_resources = { path = "crates/ironclaw_resources", version = "0.1.0", fe ironclaw_secrets = { path = "crates/ironclaw_secrets", version = "0.1.0" } ironclaw_product_adapters = { path = "crates/ironclaw_product_adapters", version = "0.1.0", features = ["test-support"] } ironclaw_product_workflow = { path = "crates/ironclaw_product_workflow", version = "0.1.0", features = ["test-support"] } -ironclaw_runner = { path = "crates/ironclaw_runner", version = "0.1.0" } +ironclaw_runner = { path = "crates/ironclaw_runner", version = "0.1.0", features = ["test-support"] } # `ironclaw_webui` + `ironclaw_reborn_identity` sit on the int-tier coverage # lane (identity_resolution_smoke / webui_v2_router_smoke); they compile into # every int-tier build, behaviorally inert for suites that never call them. diff --git a/crates/AGENTS.md b/crates/AGENTS.md index 648891e01a7..6bde51d151b 100644 --- a/crates/AGENTS.md +++ b/crates/AGENTS.md @@ -147,7 +147,7 @@ Boundary rule: if you need an upstream crate in a low-level crate, stop and chec | `ironclaw_channel_delivery` | `ironclaw_channel_delivery/AGENTS.md` | Product-neutral live/triggered channel-delivery engine: observation, bounded admission, actionable prompts, route tracking, honest outcomes, keyed hook fan-out. | Channel-specific protocol/rendering, composition/global registries, WebUI/CLI. | | `ironclaw_telegram_extension` | `ironclaw_telegram_extension/AGENTS.md`, `docs/reborn/contracts/telegram-v2.md` | Concrete Telegram host domain and host builder: setup/Bot API, filesystem state, pairing, DM ingress, revision workflows, protocol/targets/trigger hook, facades/routes. | `RebornRuntime`, global mount/registry ownership, other channels. | | `ironclaw_telegram_v2_adapter` | `ironclaw_telegram_v2_adapter/AGENTS.md`, `Cargo.toml`, `src/lib.rs` | Telegram WASM v2 ProductAdapter tracer bullet: payload parsing, rendering, adapter implementation. | Shared adapter contracts or registry semantics. | -| `ironclaw_webui` | `ironclaw_webui/AGENTS.md`, `ironclaw_webui/CLAUDE.md`, `ironclaw_webui/README.md` | The whole WebUI host stack for Reborn WebChat v2: the `webui_v2` route surface + axum handlers + descriptor table + redacted `WebUiV2HttpError` (folded up from the former `ironclaw_webui_v2`), the Vite SPA bundle (`frontend/`), the `webui_v2_app` gateway assembly + middleware stack, the listener/serve loop, and host authentication (Env/Session/OIDC authenticators, `SessionStore`, `/auth/*` OAuth login). | Product/API business logic (consume `RebornServicesApi` only), a direct `ironclaw_product_adapters` edge, transcript storage, v1 channel code. | +| `ironclaw_webui` | `ironclaw_webui/AGENTS.md`, `ironclaw_webui/CLAUDE.md`, `ironclaw_webui/README.md` | The whole WebUI host stack for Reborn WebChat v2: the `webui_v2` route surface + axum handlers + descriptor table + redacted `WebUiV2HttpError` (folded up from the former `ironclaw_webui_v2`), the Vite SPA bundle (`frontend/`), the `webui_v2_app` gateway assembly + middleware stack, the listener/serve loop, and host authentication (Env/Session/OIDC authenticators, signed sessions, `/auth/*` OAuth login). | Product/API business logic (consume `RebornServicesApi` only), a direct `ironclaw_product_adapters` edge, transcript storage, v1 channel code. | | `ironclaw_slack_v2_adapter` | `ironclaw_slack_v2_adapter/AGENTS.md` | Slack v2 ProductAdapter: protocol parsing/rendering only (payloads, mrkdwn, delivery DTOs). Host-side Slack (signature verify, delivery fan-out, setup/secrets) currently lives in `ironclaw_reborn_composition`. | Signing secrets, bot tokens, network, workflow admission — the boundary test bans host concerns here. | | `ironclaw_product_context` | `ironclaw_product_context/AGENTS.md` | Single owner of turn-origin/surface trust classification at ingress; only its `TrustedTrigger` arm mints `ScheduledTrigger`. Deliberately tiny and dependency-light (host_api + turns only). | Adding anything — its value is staying small enough for every ingress layer to call without cycles. | | `ironclaw_reborn_identity` | `Cargo.toml`, `src/lib.rs` | Canonical identity mapping: every external identity (OAuth login, channel actor) → stable `UserId` before runtime state; filesystem-backed resolver fronted through composition. | Auth flows, session storage, provider HTTP. | diff --git a/crates/ironclaw_architecture/tests/ratchet_support/mod.rs b/crates/ironclaw_architecture/tests/ratchet_support/mod.rs index 325358a8648..3f54cabef2f 100644 --- a/crates/ironclaw_architecture/tests/ratchet_support/mod.rs +++ b/crates/ironclaw_architecture/tests/ratchet_support/mod.rs @@ -1,5 +1,4 @@ -//! Shared scanner machinery for the §10 anti-slippage ratchets -//! (`reborn_inmemory_store_ratchet.rs`, `reborn_localdev_typename_ratchet.rs`). +//! Shared scanner machinery for the §10 anti-slippage ratchets. //! //! One hardened implementation of the walk/strip/match pipeline, so every //! ratchet gets the same guarantees: comment/string stripping, restricted diff --git a/crates/ironclaw_architecture/tests/reborn_composition_boundaries.rs b/crates/ironclaw_architecture/tests/reborn_composition_boundaries.rs index 2e2056d9170..3ec518ca09f 100644 --- a/crates/ironclaw_architecture/tests/reborn_composition_boundaries.rs +++ b/crates/ironclaw_architecture/tests/reborn_composition_boundaries.rs @@ -301,7 +301,6 @@ const EXTENSION_HOST_INTERNAL_MODULES: &[&str] = &[ "bundled_skills", "extension_activation_credentials", "extension_credential_requirements", - "extension_installation_store", "extension_lifecycle", "extension_lifecycle_capabilities", "extension_lifecycle_capabilities_auth_tests", diff --git a/crates/ironclaw_architecture/tests/reborn_deployment_mode_typename_ratchet.rs b/crates/ironclaw_architecture/tests/reborn_deployment_mode_typename_ratchet.rs index efeb007a6f5..b1a2e9ac7d5 100644 --- a/crates/ironclaw_architecture/tests/reborn_deployment_mode_typename_ratchet.rs +++ b/crates/ironclaw_architecture/tests/reborn_deployment_mode_typename_ratchet.rs @@ -133,7 +133,6 @@ fn reborn_other_mode_typename_allowlist_is_frozen() { KEYWORDS, &is_other_mode_name, &[ - "reborn_inmemory_store_ratchet.rs", "reborn_localdev_typename_ratchet.rs", "reborn_deployment_mode_typename_ratchet.rs", ], diff --git a/crates/ironclaw_architecture/tests/reborn_inmemory_store_ratchet.rs b/crates/ironclaw_architecture/tests/reborn_inmemory_store_ratchet.rs deleted file mode 100644 index cd853f58071..00000000000 --- a/crates/ironclaw_architecture/tests/reborn_inmemory_store_ratchet.rs +++ /dev/null @@ -1,320 +0,0 @@ -//! Anti-slippage ratchet for the store-consolidation axis (§10 of -//! `docs/reborn/2026-07-17-architecture-simplification-dto-dyn-local.md`). -//! -//! §4.3 replaces every hand-written `InMemory*Store` with the one production -//! `Filesystem*Store` exercised over `InMemoryBackend`. That migration is -//! incremental (per domain), so this test **freezes the current inventory of -//! pub-visible `struct InMemory*Store` definitions** and fails on any change: -//! -//! - a **new** `InMemory*Store` (not in the allowlist) fails — the debt can only -//! shrink, never grow; -//! - a **second definition** of a frozen name (same file or another -//! module/crate) fails — occurrences are preserved and multiplicity is checked -//! explicitly, so duplicate-name debt cannot hide behind an existing entry; -//! - **deleting** a store without removing it from its frozen list also -//! fails — so the allowlist is forced to shrink in lock-step as each domain -//! lands, and a reviewer sees the list get shorter (§10: "compare set -//! membership, never an aggregate count"). -//! -//! Scanner semantics (shared with the other §10 ratchets — see -//! [`ratchet_support`]): comments/strings stripped before matching; skips -//! `tests/`, `examples/`, and `benches/` trees; line-based, not cfg-aware — a -//! pub-visible store in an inline `#[cfg(test)]` module in src IS inventoried, -//! so keep test doubles under `tests/` (or justify an allowlist entry in -//! review). -//! -//! The frozen inventory is split into two disjoint lists: -//! [`FROZEN_DEBT_INMEMORY_STORES`] (parallel store implementations §4.3 still -//! owes a consolidation) and [`JUSTIFIED_KEEP_INMEMORY_STORES`] (audited -//! keeps — each entry's annotation states why it is NOT a §1.4 lock-step -//! duplicate and must not be blindly "consolidated"). **Definition of done for -//! this axis (§10): the DEBT list shrinks to empty — and as of #6263 it IS -//! empty.** Every hand-written `InMemory*Store` on the store-consolidation axis -//! is now either deleted (consolidated onto the one production -//! `Filesystem*Store` over an `InMemoryBackend`) or a justified keep. The -//! mechanical consolidations (A1–A8: approvals, authorization, processes, -//! run-state, budget-gate, the outbound family), the checkpoint cluster -//! (checkpoint-state payloads, loop-checkpoint metadata), the Slack host-state -//! test doubles, the secrets cluster (`FilesystemSecretStore::ephemeral()`), -//! and finally turns — `InMemoryTurnStateStore` collapsed into the crate-private -//! `TurnStateEngine` embedded in `FilesystemTurnStateRowStore` — are all done. -//! `FROZEN_DEBT_INMEMORY_STORES` is therefore empty; it must never grow again. - -mod ratchet_support; - -use std::collections::{BTreeMap, BTreeSet}; -use std::path::PathBuf; - -use ratchet_support::{ - TypeDefOccurrence, collect_type_defs, duplicate_definitions, scan_type_defs, workspace_root, -}; - -const KEYWORDS: &[&str] = &["struct "]; - -fn is_inmemory_store(ident: &str) -> bool { - ident.starts_with("InMemory") && ident.ends_with("Store") -} - -/// Remaining §4.3 consolidation DEBT: pub-visible `struct InMemory*Store` -/// definitions under `crates/` that duplicate (or stand in for) a durable -/// store implementation and still owe a consolidation. Remove an entry in the -/// same PR that deletes its store; never add one. Comments are stripped by the -/// scanner, so the per-entry status notes are documentation only — the -/// enforced contract is the string set. -/// -/// **Status (re-assessed 2026-07-20): the §4.3 store-consolidation axis debt is -/// now ZERO.** Every hand-written `InMemory*Store` on this axis has been either -/// deleted (consolidated onto the one production `Filesystem*Store` over an -/// `InMemoryBackend`) or moved to [`JUSTIFIED_KEEP_INMEMORY_STORES`] with an -/// audited reason. The last entry — `InMemoryTurnStateStore`, the -/// `inmemory-turn-state` runtime authority — was eliminated in #6263: its -/// semantics engine is now a crate-private `TurnStateEngine` embedded inside -/// `FilesystemTurnStateRowStore`, so there is exactly one public turn-state -/// store. This list is therefore empty; it must never grow (a new -/// `InMemory*Store` fails the scan below). -const FROZEN_DEBT_INMEMORY_STORES: &[&str] = &[ - // (empty — §4.3 store-consolidation debt is zero; see the doc comment above.) - // - // Historical, for provenance: `InMemorySecretStore`/`InMemorySecretsStore` - // (secrets cluster) were consolidated onto `FilesystemSecretStore::ephemeral()`; - // `InMemoryTurnStateStore` (turns, the trickiest/last case) was collapsed into - // the private `TurnStateEngine` inside `FilesystemTurnStateRowStore` (#6263), - // with the crash-consistency reference-model oracle - // (`row_store_crash_consistency.rs`) now driven by a never-crashed row store. -]; - -/// Audited JUSTIFIED KEEPS: pub-visible `InMemory*Store` types that are NOT -/// §1.4 lock-step store duplicates. Each annotation states the reason; do not -/// "consolidate" these without first invalidating that reason in review. -const JUSTIFIED_KEEP_INMEMORY_STORES: &[&str] = &[ - // --- Bounded volatile caches next to already-wired durable stores - // (`FilesystemSubagentGoalStore` in the libSQL/Postgres runner - // adapters; `FilesystemOpenAiCompatRefStore` in OpenAI-compatible - // serving). Do NOT swap them for a durable store in tests that - // exercise the bounded/evicting cache semantics. --- - // BoundedSubagentGoal: capacity-bounded, evict-oldest (VecDeque insertion - // order) cache of in-flight subagent-spawn goals — goal_store.rs. - "InMemoryBoundedSubagentGoalStore", - // OpenAiCompatRef: capacity-bounded with oldest-created eviction (evicts - // the minimum `created_at`; reads do not refresh recency — NOT an LRU) - // AND the crate's documented filesystem-free default so contract-only - // consumers pull no `ironclaw_filesystem` dep (openai_compat CLAUDE.md). - "InMemoryOpenAiCompatRefStore", - // --- Ephemeral per-run staging BY DESIGN. Constructed per claimed run / - // model call (`ironclaw_runner` loop_driver_host + model_gateway) to - // stage raw model-visible prompt content between the prompt and model - // ports; the run_profile contract requires raw prompt text to stay - // behind host implementations, so a durable variant must not exist. --- - "InMemoryInstructionMaterializationStore", - // --- Embedded ENGINE, not a parallel implementation. - // `FilesystemExtensionInstallationStore` (reborn_composition) wraps - // this store as its in-memory working set and adds snapshot - // persistence — the domain logic exists exactly once, here. Follow-up - // that would let it go crate-private: relocate the filesystem store - // down to `ironclaw_extensions` by inverting its manifest-decoder deps - // (`product_extension_host_api_contract_registry`, - // `default_host_port_catalog`) into a constructor parameter. --- - "InMemoryExtensionInstallationStore", - // --- Dev/test-only by explicit feature gate (`test-support`). - // The production counterpart already exists and is restart-safe by - // being STATELESS: `SignedTokenSessionStore` (HMAC-signed bearers, - // signed_session_login.rs); durable revocation is an optional - // DB-backed `SessionStore` a deployment supplies. A - // `FilesystemSessionStore` persisting bearer material would be a - // security regression, not a consolidation. --- - "InMemorySessionStore", -]; - -#[test] -fn reborn_inmemory_store_allowlist_is_frozen_and_only_shrinks() { - let crates_dir = workspace_root().join("crates"); - let mut found: BTreeMap> = BTreeMap::new(); - collect_type_defs( - &crates_dir, - KEYWORDS, - &is_inmemory_store, - &[ - "reborn_inmemory_store_ratchet.rs", - "reborn_localdev_typename_ratchet.rs", - ], - &mut found, - ); - - // Check each list for internal duplicates first, so a combined-length - // mismatch below can only mean cross-list overlap (a duplicate inside one - // list would otherwise masquerade as a disjointness failure). - for (label, list) in [ - ("FROZEN_DEBT_INMEMORY_STORES", FROZEN_DEBT_INMEMORY_STORES), - ( - "JUSTIFIED_KEEP_INMEMORY_STORES", - JUSTIFIED_KEEP_INMEMORY_STORES, - ), - ] { - let unique: BTreeSet<&str> = list.iter().copied().collect(); - assert_eq!( - unique.len(), - list.len(), - "{label} contains duplicate entries" - ); - } - let frozen: BTreeSet<&str> = FROZEN_DEBT_INMEMORY_STORES - .iter() - .chain(JUSTIFIED_KEEP_INMEMORY_STORES) - .copied() - .collect(); - assert_eq!( - frozen.len(), - FROZEN_DEBT_INMEMORY_STORES.len() + JUSTIFIED_KEEP_INMEMORY_STORES.len(), - "the debt and justified-keep lists must be disjoint" - ); - let found_refs: BTreeSet<&str> = found.keys().map(String::as_str).collect(); - - let added: Vec<(&str, &Vec)> = found - .iter() - .filter(|(name, _)| !frozen.contains(name.as_str())) - .map(|(name, paths)| (name.as_str(), paths)) - .collect(); - assert!( - added.is_empty(), - "New pub-visible `struct InMemory*Store` definitions are banned \ - (arch-simplification §4.3/§10): every domain store must be \ - `Filesystem*Store`. Offending new stores: {added:?}. If this \ - is a genuine new local store, justify it in review and add it to \ - FROZEN_DEBT_INMEMORY_STORES (or, with an audited reason, \ - JUSTIFIED_KEEP_INMEMORY_STORES) — but the intended direction is to delete these, not \ - add them." - ); - - let duplicated = duplicate_definitions(&found); - assert!( - duplicated.is_empty(), - "Each frozen InMemory*Store name must have exactly one definition; a second \ - same-named definition elsewhere is new debt hiding behind an allowlist entry \ - (§10): {duplicated:?}" - ); - - let removed: Vec<&&str> = frozen.difference(&found_refs).collect(); - assert!( - removed.is_empty(), - "the frozen lists name stores that no longer exist: {removed:?}. A store \ - was deleted (good!) — trim it from the allowlist in the same PR so the ratchet \ - keeps shrinking toward empty (§10)." - ); -} - -/// Self-test for the shared scanner as this ratchet configures it: it must -/// extract exactly the pub-visible (including `pub(crate)`/`pub(super)`/ -/// `pub(in ...)`) `struct InMemory*Store` definitions and ignore private -/// structs, non-`Store` structs, and — because comments and strings are -/// stripped before matching — definition-shaped text in line comments, block -/// comments (nested and multiline), plain string literals, and raw string -/// literals. -#[test] -fn inmemory_store_def_scanner_self_test() { - let sample = r##" - pub struct InMemoryWidgetStore { field: u8 } - struct InMemoryPrivateStore; // not pub-visible -> ignored - pub struct InMemoryWidget { x: u8 } // not a *Store -> ignored - // pub struct InMemoryLineCommentedStore -> ignored - /* pub struct InMemoryBlockCommentedStore */ - /* - pub struct InMemoryMultilineCommentedStore { x: u8 } - /* pub struct InMemoryNestedCommentedStore */ - */ - let name = "pub struct InMemoryStringLiteralStore"; - let raw = r#" - pub struct InMemoryRawStringStore; - "#; - pub struct InMemorySpacedStore(u8); // extra spaces tolerated - pub(crate) struct InMemoryCrateStore; // restricted visibility -> still inventoried - pub(in crate::foo) struct InMemoryInPathStore; // restricted visibility -> still inventoried - pub(crate)fn not_a_struct() {} // no `struct` after visibility -> ignored - #[cfg(test)] - mod tests { - // The scanner is line-based, not cfg-aware: an inline cfg(test) - // double in src IS inventoried (keep doubles under `tests/`). - pub struct InMemoryCfgTestStore; - } - "##; - let got: Vec = scan_type_defs(sample, KEYWORDS, &is_inmemory_store) - .into_iter() - .map(|(ident, _)| ident) - .collect(); - assert_eq!( - got, - vec![ - "InMemoryWidgetStore", - "InMemorySpacedStore", - "InMemoryCrateStore", - "InMemoryInPathStore", - "InMemoryCfgTestStore" - ], - "scanner must match pub-visible `struct InMemory*Store` definitions \ - outside comments and strings, in source order" - ); -} - -/// Self-test for the multiplicity check: the same identifier defined in two -/// files must be reported as a duplicate. -#[test] -fn inmemory_store_duplicate_detection_self_test() { - let mut found: BTreeMap> = BTreeMap::new(); - for path in ["crate_a/src/lib.rs", "crate_b/src/lib.rs"] { - for (ident, cfg_gated) in - scan_type_defs("pub struct InMemoryDupStore;", KEYWORDS, &is_inmemory_store) - { - found.entry(ident).or_default().push(TypeDefOccurrence { - path: PathBuf::from(path), - cfg_gated, - }); - } - } - let duplicated = duplicate_definitions(&found); - assert_eq!( - duplicated.len(), - 1, - "two same-named definitions must be flagged" - ); - assert_eq!(duplicated[0].0, "InMemoryDupStore"); - assert_eq!(duplicated[0].1.len(), 2); -} - -/// Self-test for same-file multiplicity: two same-named definitions in -/// different modules of ONE file must also be reported — the scan preserves -/// occurrences instead of deduplicating per file. -#[test] -fn inmemory_store_same_file_duplicate_detection_self_test() { - let sample = r#" - mod first { - pub struct InMemoryDupStore; - } - mod second { - pub struct InMemoryDupStore; - } - "#; - let occurrences = scan_type_defs(sample, KEYWORDS, &is_inmemory_store); - let idents: Vec<&str> = occurrences - .iter() - .map(|(ident, _)| ident.as_str()) - .collect(); - assert_eq!( - idents, - vec!["InMemoryDupStore", "InMemoryDupStore"], - "same-file duplicates must be preserved by the scan" - ); - - let mut found: BTreeMap> = BTreeMap::new(); - for (ident, cfg_gated) in occurrences { - found.entry(ident).or_default().push(TypeDefOccurrence { - path: PathBuf::from("crate_a/src/lib.rs"), - cfg_gated, - }); - } - let duplicated = duplicate_definitions(&found); - assert_eq!( - duplicated.len(), - 1, - "a same-file duplicate must be flagged by the multiplicity check" - ); - assert_eq!(duplicated[0].1.len(), 2); -} diff --git a/crates/ironclaw_architecture/tests/reborn_localdev_typename_ratchet.rs b/crates/ironclaw_architecture/tests/reborn_localdev_typename_ratchet.rs index 82ee71f567d..045cf4c1070 100644 --- a/crates/ironclaw_architecture/tests/reborn_localdev_typename_ratchet.rs +++ b/crates/ironclaw_architecture/tests/reborn_localdev_typename_ratchet.rs @@ -73,10 +73,7 @@ fn reborn_localdev_typename_allowlist_is_frozen_and_only_shrinks() { &crates_dir, KEYWORDS, &is_localdev_type, - &[ - "reborn_inmemory_store_ratchet.rs", - "reborn_localdev_typename_ratchet.rs", - ], + &["reborn_localdev_typename_ratchet.rs"], &mut found, ); diff --git a/crates/ironclaw_extensions/src/installations.rs b/crates/ironclaw_extensions/src/installations.rs index 362befd48e4..cd9251c318d 100644 --- a/crates/ironclaw_extensions/src/installations.rs +++ b/crates/ironclaw_extensions/src/installations.rs @@ -1,13 +1,18 @@ -use std::collections::{BTreeSet, HashMap}; +use std::collections::BTreeSet; use std::fmt; use std::sync::Arc; use async_trait::async_trait; use chrono::{DateTime, Utc}; -use ironclaw_host_api::{ExtensionId, HostPortCatalog, SecretHandle, UserId}; +use ironclaw_filesystem::{ + CasExpectation, Entry, FilesystemError, Filter, IndexKey, IndexKind, IndexName, IndexSpec, + IndexValue, Page, RecordKind, RecordVersion, RootFilesystem, VersionedEntry, +}; +use ironclaw_host_api::{ + ExtensionId, HostPortCatalog, SecretHandle, UserId, VirtualPath, sha256_digest_token, +}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use thiserror::Error; -use tokio::sync::RwLock; use crate::{ExtensionManifestV2, HostApiContractRegistry, ManifestSource, ManifestV2Error}; @@ -888,24 +893,307 @@ where } } -#[derive(Debug, Default, Clone)] -pub struct InMemoryExtensionInstallationStore { - inner: Arc>, +const DEFAULT_INSTALLATION_STATE_PATH: &str = "/system/extensions/.installations"; +const MANIFEST_RECORD_KIND: &str = "extension_manifest_record"; +const INSTALLATION_RECORD_KIND: &str = "extension_installation_record"; +const FILESYSTEM_CAS_RETRIES: usize = 5; + +/// Filesystem-backed extension installation state store. +/// +/// Manifests and installations are persisted as separate record rows under the +/// configured root path. Secondary indexes are declared on the row prefixes so +/// scans that gate lifecycle behavior can use the filesystem query API instead +/// of loading a monolithic state snapshot. +pub struct FilesystemExtensionInstallationStore { + filesystem: Arc, + root: VirtualPath, + host_ports: HostPortCatalog, + contracts: HostApiContractRegistry, + cas_retries: usize, } -#[derive(Debug, Default)] -struct InMemoryState { - manifests: HashMap, - installations: HashMap, +impl fmt::Debug for FilesystemExtensionInstallationStore { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("FilesystemExtensionInstallationStore") + .field("root", &self.root) + .field("cas_retries", &self.cas_retries) + .finish_non_exhaustive() + } +} + +impl FilesystemExtensionInstallationStore { + pub async fn load_at( + filesystem: Arc, + root: VirtualPath, + host_ports: HostPortCatalog, + contracts: HostApiContractRegistry, + ) -> Result { + let store = Self { + filesystem, + root, + host_ports, + contracts, + cas_retries: FILESYSTEM_CAS_RETRIES, + }; + store.ensure_indexes().await?; + Ok(store) + } + + pub fn default_state_path() -> Result { + VirtualPath::new(DEFAULT_INSTALLATION_STATE_PATH).map_err(invalid_installation_error) + } + + pub fn with_cas_retries(mut self, cas_retries: usize) -> Self { + self.cas_retries = cas_retries; + self + } + + async fn ensure_indexes(&self) -> Result<(), ExtensionInstallationError> { + self.ensure_exact_index( + &self.manifests_root()?, + "extension_manifests_by_extension_id", + "extension_id", + ) + .await?; + self.ensure_exact_index( + &self.installations_root()?, + "extension_installations_by_installation_id", + "installation_id", + ) + .await?; + self.ensure_exact_index( + &self.installations_root()?, + "extension_installations_by_extension_id", + "extension_id", + ) + .await?; + self.ensure_exact_index( + &self.installations_root()?, + "extension_installations_by_activation_state", + "activation_state", + ) + .await + } + + async fn ensure_exact_index( + &self, + prefix: &VirtualPath, + name: &'static str, + key: &'static str, + ) -> Result<(), ExtensionInstallationError> { + let name = index_name(name)?; + let key = index_key(key)?; + let spec = IndexSpec::new(name, vec![key], IndexKind::Exact); + self.filesystem + .ensure_index(prefix, &spec) + .await + .map_err(store_unavailable( + "ensure extension installation store index", + )) + } + + fn manifests_root(&self) -> Result { + child_path(&self.root, "manifests") + } + + fn installations_root(&self) -> Result { + child_path(&self.root, "installations") + } + + fn manifest_path( + &self, + extension_id: &ExtensionId, + ) -> Result { + child_path( + &self.manifests_root()?, + &format!("{}.json", row_token(extension_id.as_str())), + ) + } + + fn installation_path( + &self, + installation_id: &ExtensionInstallationId, + ) -> Result { + child_path( + &self.installations_root()?, + &format!("{}.json", row_token(installation_id.as_str())), + ) + } + + async fn load_manifest_entry( + &self, + extension_id: &ExtensionId, + ) -> Result, ExtensionInstallationError> { + let path = self.manifest_path(extension_id)?; + let Some(entry) = self + .filesystem + .get(&path) + .await + .map_err(store_unavailable("load extension manifest row"))? + else { + return Ok(None); + }; + let manifest = self.parse_manifest_entry(entry.entry, &path)?; + if manifest.extension_id() != extension_id { + return Err(invalid_installation_error(format!( + "extension manifest row key {extension_id} contained manifest {}", + manifest.extension_id() + ))); + } + Ok(Some((manifest, entry.version))) + } + + async fn load_installation_entry( + &self, + installation_id: &ExtensionInstallationId, + ) -> Result, ExtensionInstallationError> { + let path = self.installation_path(installation_id)?; + let Some(entry) = self + .filesystem + .get(&path) + .await + .map_err(store_unavailable("load extension installation row"))? + else { + return Ok(None); + }; + let installation = parse_installation_entry(entry.entry, &path)?; + if installation.installation_id() != installation_id { + return Err(invalid_installation_error(format!( + "extension installation row key {installation_id} contained installation {}", + installation.installation_id() + ))); + } + Ok(Some((installation, entry.version))) + } + + async fn query_installations_by_extension( + &self, + extension_id: &ExtensionId, + ) -> Result, ExtensionInstallationError> { + let filter = Filter::Eq { + key: index_key("extension_id")?, + value: IndexValue::Text(extension_id.as_str().to_string()), + }; + self.query_installations(&filter).await + } + + async fn query_installations( + &self, + filter: &Filter, + ) -> Result, ExtensionInstallationError> { + let rows = query_all(&self.filesystem, &self.installations_root()?, filter).await?; + rows.into_iter() + .map(|entry| parse_installation_entry(entry.entry, &entry.path)) + .collect() + } + + async fn query_manifests( + &self, + filter: &Filter, + ) -> Result, ExtensionInstallationError> { + let rows = query_all(&self.filesystem, &self.manifests_root()?, filter).await?; + rows.into_iter() + .map(|entry| self.parse_manifest_entry(entry.entry, &entry.path)) + .collect() + } + + fn parse_manifest_entry( + &self, + entry: Entry, + path: &VirtualPath, + ) -> Result { + ensure_entry_kind(&entry, MANIFEST_RECORD_KIND, path)?; + let wire: WireManifestRecord = entry + .parse_json() + .map_err(|error| corrupt_row("deserialize extension manifest row", path, error))?; + wire.into_manifest_record(&self.host_ports, &self.contracts) + } + + async fn put_manifest( + &self, + manifest: &ExtensionManifestRecord, + cas: CasExpectation, + ) -> Result<(), SaveRowError> { + let path = self.manifest_path(manifest.extension_id())?; + match self + .filesystem + .put(&path, entry_for_manifest(manifest)?, cas) + .await + { + Ok(_) => Ok(()), + Err(FilesystemError::VersionMismatch { .. }) => Err(SaveRowError::CasConflict), + Err(error) => Err(SaveRowError::Store(store_unavailable( + "save extension manifest row", + )(error))), + } + } + + async fn put_installation( + &self, + installation: &ExtensionInstallation, + cas: CasExpectation, + ) -> Result<(), SaveRowError> { + let path = self.installation_path(installation.installation_id())?; + match self + .filesystem + .put(&path, entry_for_installation(installation)?, cas) + .await + { + Ok(_) => Ok(()), + Err(FilesystemError::VersionMismatch { .. }) => Err(SaveRowError::CasConflict), + Err(error) => Err(SaveRowError::Store(store_unavailable( + "save extension installation row", + )(error))), + } + } + + async fn delete_installation_row( + &self, + installation_id: &ExtensionInstallationId, + version: RecordVersion, + ) -> Result<(), SaveRowError> { + let path = self.installation_path(installation_id)?; + match self.filesystem.delete_if_version(&path, version).await { + Ok(()) => Ok(()), + Err(FilesystemError::VersionMismatch { .. }) => Err(SaveRowError::CasConflict), + Err(FilesystemError::NotFound { .. }) => Err(SaveRowError::NotFound), + Err(error) => Err(SaveRowError::Store(store_unavailable( + "delete extension installation row", + )(error))), + } + } + + async fn delete_manifest_row( + &self, + extension_id: &ExtensionId, + version: RecordVersion, + ) -> Result<(), SaveRowError> { + let path = self.manifest_path(extension_id)?; + match self.filesystem.delete_if_version(&path, version).await { + Ok(()) => Ok(()), + Err(FilesystemError::VersionMismatch { .. }) => Err(SaveRowError::CasConflict), + Err(FilesystemError::NotFound { .. }) => Err(SaveRowError::NotFound), + Err(error) => Err(SaveRowError::Store(store_unavailable( + "delete extension manifest row", + )(error))), + } + } + + async fn restore_manifest_best_effort(&self, prior: Option) { + if let Some(prior) = prior + && let Err(error) = self.put_manifest(&prior, CasExpectation::Any).await + { + let _ = error; + } + } } #[async_trait] -impl ExtensionInstallationStore for InMemoryExtensionInstallationStore { +impl ExtensionInstallationStore for FilesystemExtensionInstallationStore { async fn list_manifests( &self, ) -> Result, ExtensionInstallationError> { - let inner = self.inner.read().await; - let mut manifests: Vec<_> = inner.manifests.values().cloned().collect(); + let mut manifests = self.query_manifests(&Filter::All).await?; manifests.sort_by(|a, b| a.extension_id().cmp(b.extension_id())); Ok(manifests) } @@ -914,26 +1202,24 @@ impl ExtensionInstallationStore for InMemoryExtensionInstallationStore { &self, extension_id: &ExtensionId, ) -> Result, ExtensionInstallationError> { - Ok(self.inner.read().await.manifests.get(extension_id).cloned()) + self.load_manifest_entry(extension_id) + .await + .map(|entry| entry.map(|(manifest, _)| manifest)) } async fn upsert_manifest( &self, manifest: ExtensionManifestRecord, ) -> Result<(), ExtensionInstallationError> { - let mut inner = self.inner.write().await; - // The in-memory store intentionally scans existing installations to - // preserve the manifest hash invariant. Durable stores should use a - // targeted read path or secondary index before handling large catalogs. - for installation in inner.installations.values() { - if installation.extension_id() == manifest.extension_id() { - validate_installation_against_one_manifest(&manifest, installation)?; - } + for installation in self + .query_installations_by_extension(manifest.extension_id()) + .await? + { + validate_installation_against_one_manifest(&manifest, &installation)?; } - inner - .manifests - .insert(manifest.extension_id().clone(), manifest); - Ok(()) + self.put_manifest(&manifest, CasExpectation::Any) + .await + .map_err(SaveRowError::into_installation_error) } async fn upsert_manifest_and_installation( @@ -942,21 +1228,25 @@ impl ExtensionInstallationStore for InMemoryExtensionInstallationStore { installation: ExtensionInstallation, ) -> Result<(), ExtensionInstallationError> { validate_installation_against_one_manifest(&manifest, &installation)?; - let mut inner = self.inner.write().await; - inner - .manifests - .insert(manifest.extension_id().clone(), manifest); - inner - .installations - .insert(installation.installation_id().clone(), installation); + let extension_id = manifest.extension_id().clone(); + let prior_manifest = self.get_manifest(&extension_id).await?; + self.put_manifest(&manifest, CasExpectation::Any) + .await + .map_err(SaveRowError::into_installation_error)?; + if let Err(error) = self + .put_installation(&installation, CasExpectation::Any) + .await + { + self.restore_manifest_best_effort(prior_manifest).await; + return Err(error.into_installation_error()); + } Ok(()) } async fn list_installations( &self, ) -> Result, ExtensionInstallationError> { - let inner = self.inner.read().await; - let mut installations: Vec<_> = inner.installations.values().cloned().collect(); + let mut installations = self.query_installations(&Filter::All).await?; installations.sort_by(|a, b| a.installation_id().cmp(b.installation_id())); Ok(installations) } @@ -964,13 +1254,11 @@ impl ExtensionInstallationStore for InMemoryExtensionInstallationStore { async fn list_enabled_installations( &self, ) -> Result, ExtensionInstallationError> { - let inner = self.inner.read().await; - let mut installations: Vec<_> = inner - .installations - .values() - .filter(|i| i.activation_state() == ExtensionActivationState::Enabled) - .cloned() - .collect(); + let filter = Filter::Eq { + key: index_key("activation_state")?, + value: IndexValue::Text(activation_state_key(ExtensionActivationState::Enabled).into()), + }; + let mut installations = self.query_installations(&filter).await?; installations.sort_by(|a, b| { b.updated_at() .cmp(&a.updated_at()) @@ -983,25 +1271,25 @@ impl ExtensionInstallationStore for InMemoryExtensionInstallationStore { &self, installation_id: &ExtensionInstallationId, ) -> Result, ExtensionInstallationError> { - Ok(self - .inner - .read() + self.load_installation_entry(installation_id) .await - .installations - .get(installation_id) - .cloned()) + .map(|entry| entry.map(|(installation, _)| installation)) } async fn upsert_installation( &self, installation: ExtensionInstallation, ) -> Result<(), ExtensionInstallationError> { - let mut inner = self.inner.write().await; - validate_installation_against_manifest(&inner.manifests, &installation)?; - inner - .installations - .insert(installation.installation_id().clone(), installation); - Ok(()) + let manifest = self + .get_manifest(installation.extension_id()) + .await? + .ok_or_else(|| ExtensionInstallationError::UnknownManifest { + extension_id: installation.extension_id().clone(), + })?; + validate_installation_against_one_manifest(&manifest, &installation)?; + self.put_installation(&installation, CasExpectation::Any) + .await + .map_err(SaveRowError::into_installation_error) } async fn set_activation_state( @@ -1009,62 +1297,100 @@ impl ExtensionInstallationStore for InMemoryExtensionInstallationStore { installation_id: &ExtensionInstallationId, state: ExtensionActivationState, ) -> Result<(), ExtensionInstallationError> { - let mut inner = self.inner.write().await; - let InMemoryState { - manifests, - installations, - } = &mut *inner; - let installation = installations.get_mut(installation_id).ok_or_else(|| { - ExtensionInstallationError::InstallationNotFound { - installation_id: installation_id.clone(), + for _ in 0..=self.cas_retries { + let Some((mut installation, version)) = + self.load_installation_entry(installation_id).await? + else { + return Err(ExtensionInstallationError::InstallationNotFound { + installation_id: installation_id.clone(), + }); + }; + if installation.activation_state() == state { + return Ok(()); + } + if state == ExtensionActivationState::Enabled { + let manifest = self + .get_manifest(installation.extension_id()) + .await? + .ok_or_else(|| ExtensionInstallationError::UnknownManifest { + extension_id: installation.extension_id().clone(), + })?; + validate_installation_against_one_manifest(&manifest, &installation)?; + } + installation.set_activation_state(state); + match self + .put_installation(&installation, CasExpectation::Version(version)) + .await + { + Ok(()) => return Ok(()), + Err(SaveRowError::CasConflict) => continue, + Err(error) => return Err(error.into_installation_error()), } - })?; - if installation.activation_state() == state { - return Ok(()); - } - if state == ExtensionActivationState::Enabled { - validate_installation_against_manifest(manifests, installation)?; } - installation.set_activation_state(state); - Ok(()) + Err(store_unavailable_error( + "extension installation row changed repeatedly while updating activation state", + )) } async fn delete_installation( &self, installation_id: &ExtensionInstallationId, ) -> Result<(), ExtensionInstallationError> { - self.inner - .write() - .await - .installations - .remove(installation_id) - .map(|_| ()) - .ok_or_else(|| ExtensionInstallationError::InstallationNotFound { - installation_id: installation_id.clone(), - }) + for _ in 0..=self.cas_retries { + let Some((_, version)) = self.load_installation_entry(installation_id).await? else { + return Err(ExtensionInstallationError::InstallationNotFound { + installation_id: installation_id.clone(), + }); + }; + match self.delete_installation_row(installation_id, version).await { + Ok(()) => return Ok(()), + Err(SaveRowError::CasConflict) => continue, + Err(SaveRowError::NotFound) => { + return Err(ExtensionInstallationError::InstallationNotFound { + installation_id: installation_id.clone(), + }); + } + Err(error) => return Err(error.into_installation_error()), + } + } + Err(store_unavailable_error( + "extension installation row changed repeatedly while deleting installation", + )) } async fn delete_manifest( &self, extension_id: &ExtensionId, ) -> Result<(), ExtensionInstallationError> { - let mut inner = self.inner.write().await; - if inner - .installations - .values() - .any(|installation| installation.extension_id() == extension_id) + if !self + .query_installations_by_extension(extension_id) + .await? + .is_empty() { return Err(ExtensionInstallationError::InvalidInstallation { reason: format!("extension {extension_id} still has installations"), }); } - inner - .manifests - .remove(extension_id) - .map(|_| ()) - .ok_or_else(|| ExtensionInstallationError::ManifestNotFound { - extension_id: extension_id.clone(), - }) + for _ in 0..=self.cas_retries { + let Some((_, version)) = self.load_manifest_entry(extension_id).await? else { + return Err(ExtensionInstallationError::ManifestNotFound { + extension_id: extension_id.clone(), + }); + }; + match self.delete_manifest_row(extension_id, version).await { + Ok(()) => return Ok(()), + Err(SaveRowError::CasConflict) => continue, + Err(SaveRowError::NotFound) => { + return Err(ExtensionInstallationError::ManifestNotFound { + extension_id: extension_id.clone(), + }); + } + Err(error) => return Err(error.into_installation_error()), + } + } + Err(store_unavailable_error( + "extension manifest row changed repeatedly while deleting manifest", + )) } async fn update_health( @@ -1072,22 +1398,274 @@ impl ExtensionInstallationStore for InMemoryExtensionInstallationStore { installation_id: &ExtensionInstallationId, health: ExtensionHealthSnapshot, ) -> Result<(), ExtensionInstallationError> { - self.inner - .write() + for _ in 0..=self.cas_retries { + let Some((mut installation, version)) = + self.load_installation_entry(installation_id).await? + else { + return Err(ExtensionInstallationError::InstallationNotFound { + installation_id: installation_id.clone(), + }); + }; + installation.set_health(health.clone()); + match self + .put_installation(&installation, CasExpectation::Version(version)) + .await + { + Ok(()) => return Ok(()), + Err(SaveRowError::CasConflict) => continue, + Err(error) => return Err(error.into_installation_error()), + } + } + Err(store_unavailable_error( + "extension installation row changed repeatedly while updating health", + )) + } +} + +#[derive(Debug)] +enum SaveRowError { + CasConflict, + NotFound, + Store(ExtensionInstallationError), +} + +impl From for SaveRowError { + fn from(error: ExtensionInstallationError) -> Self { + Self::Store(error) + } +} + +impl SaveRowError { + fn into_installation_error(self) -> ExtensionInstallationError { + match self { + Self::CasConflict => store_unavailable_error("extension installation row CAS conflict"), + Self::NotFound => store_unavailable_error("extension installation row disappeared"), + Self::Store(error) => error, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct WireManifestRecord { + raw_toml: String, + source: WireManifestSource, + #[serde(default, skip_serializing_if = "Option::is_none")] + manifest_hash: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + removal_cleanup_requirements: Vec, +} + +impl WireManifestRecord { + fn into_manifest_record( + self, + host_ports: &HostPortCatalog, + contracts: &HostApiContractRegistry, + ) -> Result { + ExtensionManifestRecord::from_toml_with_contracts( + self.raw_toml, + self.source.into_manifest_source(), + host_ports, + self.manifest_hash, + contracts, + ) + .map(|record| record.with_removal_cleanup_requirements(self.removal_cleanup_requirements)) + } +} + +impl From<&ExtensionManifestRecord> for WireManifestRecord { + fn from(record: &ExtensionManifestRecord) -> Self { + Self { + raw_toml: record.raw_toml().to_string(), + source: WireManifestSource::from_manifest_source(record.manifest().source), + manifest_hash: record.manifest_hash().cloned(), + removal_cleanup_requirements: record.removal_cleanup_requirements().to_vec(), + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum WireManifestSource { + HostBundled, + InstalledLocal, + RegistryInstalled, +} + +impl WireManifestSource { + fn from_manifest_source(source: ManifestSource) -> Self { + match source { + ManifestSource::HostBundled => Self::HostBundled, + ManifestSource::InstalledLocal => Self::InstalledLocal, + ManifestSource::RegistryInstalled => Self::RegistryInstalled, + } + } + + fn into_manifest_source(self) -> ManifestSource { + match self { + Self::HostBundled => ManifestSource::HostBundled, + Self::InstalledLocal => ManifestSource::InstalledLocal, + Self::RegistryInstalled => ManifestSource::RegistryInstalled, + } + } +} + +async fn query_all( + filesystem: &Arc, + prefix: &VirtualPath, + filter: &Filter, +) -> Result, ExtensionInstallationError> { + let mut out = Vec::new(); + let mut offset: u64 = 0; + loop { + let page = Page::new(offset, Page::MAX_LIMIT); + let rows = filesystem + .query(prefix, filter, page) .await - .installations - .get_mut(installation_id) - .ok_or_else(|| ExtensionInstallationError::InstallationNotFound { - installation_id: installation_id.clone(), - })? - .set_health(health); - Ok(()) + .map_err(store_unavailable("query extension installation rows"))?; + let received = rows.len(); + out.extend(rows); + if received < Page::MAX_LIMIT as usize { + break; + } + offset = offset.saturating_add(received as u64); + } + Ok(out) +} + +fn entry_for_manifest( + manifest: &ExtensionManifestRecord, +) -> Result { + let payload = serde_json::to_value(WireManifestRecord::from(manifest)) + .map_err(invalid_installation_error)?; + Ok(Entry::record(record_kind(MANIFEST_RECORD_KIND)?, &payload) + .map_err(invalid_installation_error)? + .with_indexed( + index_key("extension_id")?, + IndexValue::Text(manifest.extension_id().as_str().to_string()), + ) + .with_indexed( + index_key("manifest_source")?, + IndexValue::Text(manifest_source_key(manifest.manifest().source).into()), + )) +} + +fn entry_for_installation( + installation: &ExtensionInstallation, +) -> Result { + let payload = serde_json::to_value(installation).map_err(invalid_installation_error)?; + Ok( + Entry::record(record_kind(INSTALLATION_RECORD_KIND)?, &payload) + .map_err(invalid_installation_error)? + .with_indexed( + index_key("installation_id")?, + IndexValue::Text(installation.installation_id().as_str().to_string()), + ) + .with_indexed( + index_key("extension_id")?, + IndexValue::Text(installation.extension_id().as_str().to_string()), + ) + .with_indexed( + index_key("activation_state")?, + IndexValue::Text(activation_state_key(installation.activation_state()).into()), + ), + ) +} + +fn parse_installation_entry( + entry: Entry, + path: &VirtualPath, +) -> Result { + ensure_entry_kind(&entry, INSTALLATION_RECORD_KIND, path)?; + entry + .parse_json() + .map_err(|error| corrupt_row("deserialize extension installation row", path, error)) +} + +fn ensure_entry_kind( + entry: &Entry, + expected: &'static str, + path: &VirtualPath, +) -> Result<(), ExtensionInstallationError> { + match entry.kind.as_ref().map(RecordKind::as_str) { + Some(actual) if actual == expected => Ok(()), + _ => Err(invalid_installation_error(format!( + "extension installation store row at {} had unexpected record kind", + path.as_str() + ))), + } +} + +fn child_path(root: &VirtualPath, child: &str) -> Result { + VirtualPath::new(format!("{}/{}", root.as_str().trim_end_matches('/'), child)) + .map_err(invalid_installation_error) +} + +fn row_token(value: &str) -> String { + sha256_digest_token(value.as_bytes()).replace(':', "_") +} + +fn activation_state_key(state: ExtensionActivationState) -> &'static str { + match state { + ExtensionActivationState::Installed => "installed", + ExtensionActivationState::Disabled => "disabled", + ExtensionActivationState::Enabled => "enabled", + } +} + +fn manifest_source_key(source: ManifestSource) -> &'static str { + match source { + ManifestSource::HostBundled => "host_bundled", + ManifestSource::InstalledLocal => "installed_local", + ManifestSource::RegistryInstalled => "registry_installed", + } +} + +fn record_kind(value: &'static str) -> Result { + RecordKind::new(value).map_err(invalid_installation_error) +} + +fn index_name(value: &'static str) -> Result { + IndexName::new(value).map_err(invalid_installation_error) +} + +fn index_key(value: &'static str) -> Result { + IndexKey::new(value).map_err(invalid_installation_error) +} + +fn corrupt_row( + operation: &'static str, + path: &VirtualPath, + error: impl fmt::Display, +) -> ExtensionInstallationError { + let _ = path; + invalid_installation_error(format!("{operation}: {error}")) +} + +fn store_unavailable( + operation: &'static str, +) -> impl FnOnce(FilesystemError) -> ExtensionInstallationError { + move |error| { + let _ = error; + store_unavailable_error(operation) + } +} + +fn invalid_installation_error(error: impl fmt::Display) -> ExtensionInstallationError { + ExtensionInstallationError::InvalidInstallation { + reason: error.to_string(), + } +} + +fn store_unavailable_error(error: impl fmt::Display) -> ExtensionInstallationError { + ExtensionInstallationError::StoreUnavailable { + reason: error.to_string(), } } #[cfg(test)] mod tests { - use ironclaw_host_api::{ExtensionId, HostPortCatalog}; + use ironclaw_filesystem::InMemoryBackend; + use ironclaw_host_api::{ExtensionId, HostPortCatalog, VirtualPath}; use super::*; use crate::ManifestSource; @@ -1136,7 +1714,7 @@ mod tests { #[tokio::test] async fn delete_manifest_rejects_active_installations() { - let store = InMemoryExtensionInstallationStore::default(); + let store = filesystem_store().await; let manifest = manifest_record("fixture", Some("hash-1")); let extension_id = manifest.extension_id().clone(); store @@ -1160,6 +1738,17 @@ mod tests { assert!(store.get_manifest(&extension_id).await.unwrap().is_some()); } + async fn filesystem_store() -> FilesystemExtensionInstallationStore { + FilesystemExtensionInstallationStore::load_at( + Arc::new(InMemoryBackend::new()), + VirtualPath::new("/system/extensions/.installations/test").expect("valid root"), + HostPortCatalog::empty(), + HostApiContractRegistry::new(), + ) + .await + .expect("filesystem store") + } + fn manifest_record(extension_id: &str, hash: Option<&str>) -> ExtensionManifestRecord { ExtensionManifestRecord::from_toml( manifest_toml(extension_id), @@ -1329,18 +1918,6 @@ pub enum ExtensionInstallationError { }, } -fn validate_installation_against_manifest( - manifests: &HashMap, - installation: &ExtensionInstallation, -) -> Result<(), ExtensionInstallationError> { - let manifest = manifests.get(installation.extension_id()).ok_or_else(|| { - ExtensionInstallationError::UnknownManifest { - extension_id: installation.extension_id().clone(), - } - })?; - validate_installation_against_one_manifest(manifest, installation) -} - fn validate_installation_against_one_manifest( manifest: &ExtensionManifestRecord, installation: &ExtensionInstallation, diff --git a/crates/ironclaw_extensions/src/lib.rs b/crates/ironclaw_extensions/src/lib.rs index b6b4a34ee96..8828a5bb572 100644 --- a/crates/ironclaw_extensions/src/lib.rs +++ b/crates/ironclaw_extensions/src/lib.rs @@ -439,7 +439,7 @@ pub use installations::{ ExtensionInstallationError, ExtensionInstallationId, ExtensionInstallationPersistedParts, ExtensionInstallationStore, ExtensionManifestRecord, ExtensionManifestRef, ExtensionRemovalChannelId, ExtensionRemovalCleanupAdapterId, ExtensionRemovalCleanupBinding, - ExtensionRemovalCleanupRequirement, InMemoryExtensionInstallationStore, InstallationOwner, + ExtensionRemovalCleanupRequirement, FilesystemExtensionInstallationStore, InstallationOwner, ManifestHash, }; pub use lifecycle::{ diff --git a/crates/ironclaw_extensions/tests/installations_contract.rs b/crates/ironclaw_extensions/tests/installations_contract.rs index 63e3c704302..0a8875a09b0 100644 --- a/crates/ironclaw_extensions/tests/installations_contract.rs +++ b/crates/ironclaw_extensions/tests/installations_contract.rs @@ -1,4 +1,4 @@ -use std::collections::BTreeSet; +use std::{collections::BTreeSet, sync::Arc}; use chrono::Utc; use ironclaw_extensions::{ @@ -6,10 +6,11 @@ use ironclaw_extensions::{ ExtensionHealthMessage, ExtensionHealthSnapshot, ExtensionHealthStatus, ExtensionInstallation, ExtensionInstallationError, ExtensionInstallationId, ExtensionInstallationPersistedParts, ExtensionInstallationStore, ExtensionManifestRecord, ExtensionManifestRef, - InMemoryExtensionInstallationStore, InstallationOwner, MANIFEST_SCHEMA_VERSION, ManifestHash, + FilesystemExtensionInstallationStore, InstallationOwner, MANIFEST_SCHEMA_VERSION, ManifestHash, ManifestSource, ManifestV2Error, }; -use ironclaw_host_api::{ExtensionId, HostPortCatalog, SecretHandle, UserId}; +use ironclaw_filesystem::{Filter, InMemoryBackend, Page, RootFilesystem}; +use ironclaw_host_api::{ExtensionId, HostPortCatalog, SecretHandle, UserId, VirtualPath}; fn extension_id(value: &str) -> ExtensionId { ExtensionId::new(value).unwrap() @@ -23,6 +24,17 @@ fn manifest_hash(value: &str) -> ManifestHash { ManifestHash::new(value).unwrap() } +async fn filesystem_store() -> FilesystemExtensionInstallationStore { + FilesystemExtensionInstallationStore::load_at( + Arc::new(InMemoryBackend::new()), + VirtualPath::new("/system/extensions/.installations/test").unwrap(), + HostPortCatalog::empty(), + ironclaw_extensions::HostApiContractRegistry::new(), + ) + .await + .unwrap() +} + fn raw_legacy_capability_manifest() -> String { format!( r#" @@ -108,7 +120,7 @@ fn installed_legacy_top_level_capabilities_are_rejected() { #[tokio::test] async fn upsert_installation_rejects_unknown_manifest() { - let store = InMemoryExtensionInstallationStore::default(); + let store = filesystem_store().await; let err = store .upsert_installation( @@ -137,7 +149,7 @@ async fn upsert_installation_rejects_unknown_manifest() { #[tokio::test] async fn upsert_manifest_rejects_manifest_hash_change_for_existing_installation() { - let store = InMemoryExtensionInstallationStore::default(); + let store = filesystem_store().await; store.upsert_manifest(manifest("sha256:old")).await.unwrap(); store .upsert_installation(installation("sha256:old")) @@ -157,7 +169,7 @@ async fn upsert_manifest_rejects_manifest_hash_change_for_existing_installation( #[tokio::test] async fn upsert_manifest_and_installation_replaces_coherent_manifest_hash_pair() { - let store = InMemoryExtensionInstallationStore::default(); + let store = filesystem_store().await; store.upsert_manifest(manifest("sha256:old")).await.unwrap(); store .upsert_installation(installation("sha256:old")) @@ -188,7 +200,7 @@ async fn upsert_manifest_and_installation_replaces_coherent_manifest_hash_pair() #[tokio::test] async fn upsert_manifest_and_installation_rejects_mismatched_manifest_hash_pair() { - let store = InMemoryExtensionInstallationStore::default(); + let store = filesystem_store().await; let err = store .upsert_manifest_and_installation(manifest("sha256:new"), installation("sha256:old")) @@ -217,7 +229,7 @@ async fn upsert_manifest_and_installation_rejects_mismatched_manifest_hash_pair( #[tokio::test] async fn missing_installation_mutations_return_not_found() { - let store = InMemoryExtensionInstallationStore::default(); + let store = filesystem_store().await; let missing = installation_id("missing-prod"); let activation_err = store @@ -241,7 +253,7 @@ async fn missing_installation_mutations_return_not_found() { #[tokio::test] async fn manifest_hash_presence_mismatch_is_rejected() { - let store = InMemoryExtensionInstallationStore::default(); + let store = filesystem_store().await; store.upsert_manifest(manifest("sha256:abc")).await.unwrap(); let missing_ref_hash = store @@ -253,7 +265,7 @@ async fn manifest_hash_presence_mismatch_is_rejected() { ExtensionInstallationError::ManifestHashMismatch { .. } )); - let store = InMemoryExtensionInstallationStore::default(); + let store = filesystem_store().await; let manifest_without_hash = ExtensionManifestRecord::from_toml( raw_legacy_capability_manifest(), ManifestSource::HostBundled, @@ -415,7 +427,7 @@ async fn enabled_installations_sort_by_updated_at_desc_then_id() { let newer = chrono::DateTime::parse_from_rfc3339("2026-01-02T00:00:00Z") .unwrap() .with_timezone(&Utc); - let store = InMemoryExtensionInstallationStore::default(); + let store = filesystem_store().await; store.upsert_manifest(manifest("sha256:abc")).await.unwrap(); for (id, updated_at) in [ @@ -452,3 +464,65 @@ async fn enabled_installations_sort_by_updated_at_desc_then_id() { .collect(); assert_eq!(ids, ["acme-tools-c", "acme-tools-a", "acme-tools-b"]); } + +#[tokio::test] +async fn filesystem_store_persists_manifest_and_installation_as_rows() { + let filesystem: Arc = Arc::new(InMemoryBackend::new()); + let root = VirtualPath::new("/system/extensions/.installations/reload").unwrap(); + let store = FilesystemExtensionInstallationStore::load_at( + Arc::clone(&filesystem), + root.clone(), + HostPortCatalog::empty(), + ironclaw_extensions::HostApiContractRegistry::new(), + ) + .await + .unwrap(); + + store + .upsert_manifest_and_installation(manifest("sha256:abc"), installation("sha256:abc")) + .await + .unwrap(); + + let manifest_rows = filesystem + .query( + &VirtualPath::new(format!("{}/manifests", root.as_str())).unwrap(), + &Filter::All, + Page::first(10), + ) + .await + .unwrap(); + assert_eq!(manifest_rows.len(), 1); + assert_eq!( + manifest_rows[0].entry.kind.as_ref().unwrap().as_str(), + "extension_manifest_record" + ); + let installation_rows = filesystem + .query( + &VirtualPath::new(format!("{}/installations", root.as_str())).unwrap(), + &Filter::All, + Page::first(10), + ) + .await + .unwrap(); + assert_eq!(installation_rows.len(), 1); + assert_eq!( + installation_rows[0].entry.kind.as_ref().unwrap().as_str(), + "extension_installation_record" + ); + + let reloaded = FilesystemExtensionInstallationStore::load_at( + filesystem, + root, + HostPortCatalog::empty(), + ironclaw_extensions::HostApiContractRegistry::new(), + ) + .await + .unwrap(); + assert!( + reloaded + .get_installation(&installation_id("acme-tools-prod")) + .await + .unwrap() + .is_some() + ); +} diff --git a/crates/ironclaw_loop_host/tests/thread_loop_host_contract.rs b/crates/ironclaw_loop_host/tests/thread_loop_host_contract.rs index 24d3a139f10..7ff425b2e30 100644 --- a/crates/ironclaw_loop_host/tests/thread_loop_host_contract.rs +++ b/crates/ironclaw_loop_host/tests/thread_loop_host_contract.rs @@ -44,8 +44,8 @@ use ironclaw_turns::{ AgentLoopHostError, AgentLoopHostErrorKind, AgentLoopHostErrorReasonKind, AppendCapabilityResultRef, AssistantReply, BeginAssistantDraft, CapabilityBatchInvocation, CapabilityDeniedReasonKind, CapabilityInputIssue, CapabilityInputRef, CapabilityInvocation, - CapabilitySurfaceVersion, FinalizeAssistantMessage, HostManagedLoopPromptPort, - InMemoryInstructionMaterializationStore, InMemoryLoopHostMilestoneSink, + CapabilitySurfaceVersion, EphemeralInstructionMaterializationStore, + FinalizeAssistantMessage, HostManagedLoopPromptPort, InMemoryLoopHostMilestoneSink, InMemoryRunProfileResolver, LoopCapabilityPort, LoopContextBundle, LoopContextCompactionKind, LoopContextMessage, LoopContextPort, LoopContextRequest, LoopContextSnippet, LoopDriverNoteKind, LoopHostMilestoneKind, LoopHostMilestoneSink, @@ -1288,7 +1288,7 @@ async fn prompt_and_model_ports_materialize_trusted_identity_content() { .with_identity_context_source(source.clone()), ); let milestones = Arc::new(InMemoryLoopHostMilestoneSink::default()); - let materialization_store = Arc::new(InMemoryInstructionMaterializationStore::default()); + let materialization_store = Arc::new(EphemeralInstructionMaterializationStore::default()); let prompt_port = HostManagedLoopPromptPort::new(fixture.run_context.clone(), context_port, milestones) .with_instruction_materialization_store(materialization_store); @@ -1849,7 +1849,7 @@ async fn prompt_and_model_ports_resolve_skill_refs_after_prompt_sorting() { #[tokio::test] async fn prompt_and_model_ports_resolve_instruction_memory_and_identity_refs() { let fixture = ThreadFixture::new().await; - let materialization_store = Arc::new(InMemoryInstructionMaterializationStore::default()); + let materialization_store = Arc::new(EphemeralInstructionMaterializationStore::default()); let context_port = Arc::new(StaticLoopContextPort { bundle: LoopContextBundle { identity_messages: vec![LoopContextMessage { diff --git a/crates/ironclaw_product_adapter_registry/Cargo.toml b/crates/ironclaw_product_adapter_registry/Cargo.toml index c7e623ffa13..cb8fa09c7e0 100644 --- a/crates/ironclaw_product_adapter_registry/Cargo.toml +++ b/crates/ironclaw_product_adapter_registry/Cargo.toml @@ -23,5 +23,6 @@ toml = "1.1" [dev-dependencies] chrono = { version = "0.4", features = ["serde"] } +ironclaw_filesystem = { path = "../ironclaw_filesystem", version = "0.1.0" } serde_json = "1" tokio = { version = "1", features = ["macros", "rt", "sync"] } diff --git a/crates/ironclaw_product_adapter_registry/tests/registry_contract.rs b/crates/ironclaw_product_adapter_registry/tests/registry_contract.rs index 2240fa02c75..4e59e634e72 100644 --- a/crates/ironclaw_product_adapter_registry/tests/registry_contract.rs +++ b/crates/ironclaw_product_adapter_registry/tests/registry_contract.rs @@ -5,13 +5,15 @@ use ironclaw_extensions::{ ExtensionActivationState, ExtensionCredentialBinding, ExtensionCredentialHandle, ExtensionHealthMessage, ExtensionHealthSnapshot, ExtensionHealthStatus, ExtensionInstallation, ExtensionInstallationError, ExtensionInstallationId, ExtensionInstallationStore, - ExtensionManifestRecord, ExtensionManifestRef, InMemoryExtensionInstallationStore, + ExtensionManifestRecord, ExtensionManifestRef, FilesystemExtensionInstallationStore, InstallationOwner, MANIFEST_SCHEMA_VERSION, ManifestSource, }; -use ironclaw_host_api::{ExtensionId, HostPortCatalog, SecretHandle}; +use ironclaw_filesystem::InMemoryBackend; +use ironclaw_host_api::{ExtensionId, HostPortCatalog, SecretHandle, VirtualPath}; use ironclaw_product_adapter_registry::{ ManifestHash, RegistryError, list_enabled_product_adapter_entries, parse_product_adapter_manifest_record, product_adapter_sections, + register_product_adapter_host_api_contract, }; fn extension_id() -> ExtensionId { @@ -30,6 +32,19 @@ fn manifest_hash(value: &str) -> ManifestHash { ManifestHash::new(value).unwrap() } +async fn filesystem_store() -> FilesystemExtensionInstallationStore { + let mut contracts = ironclaw_extensions::HostApiContractRegistry::new(); + register_product_adapter_host_api_contract(&mut contracts).unwrap(); + FilesystemExtensionInstallationStore::load_at( + Arc::new(InMemoryBackend::new()), + VirtualPath::new("/system/extensions/.installations/test").unwrap(), + HostPortCatalog::empty(), + contracts, + ) + .await + .unwrap() +} + fn manifest(required_credential: &str, hash: &str) -> ExtensionManifestRecord { let raw = format!( r#" @@ -89,7 +104,7 @@ fn installation(state: ExtensionActivationState) -> ExtensionInstallation { #[tokio::test] async fn default_store_has_no_enabled_installations() { - let store = InMemoryExtensionInstallationStore::default(); + let store = filesystem_store().await; assert!(store.list_manifests().await.unwrap().is_empty()); assert!(store.list_enabled_installations().await.unwrap().is_empty()); @@ -97,7 +112,7 @@ async fn default_store_has_no_enabled_installations() { #[tokio::test] async fn explicit_activation_surfaces_in_product_adapter_runtime_entries() { - let store = InMemoryExtensionInstallationStore::default(); + let store = filesystem_store().await; store .upsert_manifest(manifest("telegram_bot_token", "sha256:abc123")) .await @@ -168,7 +183,7 @@ prompt_doc_ref = "prompts/do.md" ) .unwrap(); - let store = InMemoryExtensionInstallationStore::default(); + let store = filesystem_store().await; store.upsert_manifest(plain_manifest).await.unwrap(); store.upsert_installation(plain_install).await.unwrap(); @@ -181,7 +196,7 @@ prompt_doc_ref = "prompts/do.md" #[tokio::test] async fn credential_binding_must_reference_declared_manifest_handle() { - let store = InMemoryExtensionInstallationStore::default(); + let store = filesystem_store().await; store .upsert_manifest(manifest("telegram_bot_token", "sha256:abc123")) .await @@ -213,7 +228,7 @@ async fn credential_binding_must_reference_declared_manifest_handle() { #[tokio::test] async fn manifest_hash_mismatch_is_rejected() { - let store = InMemoryExtensionInstallationStore::default(); + let store = filesystem_store().await; store .upsert_manifest(manifest("telegram_bot_token", "sha256:different")) .await @@ -231,7 +246,7 @@ async fn manifest_hash_mismatch_is_rejected() { #[tokio::test] async fn upsert_manifest_rejects_when_existing_installation_binding_revoked() { - let store = InMemoryExtensionInstallationStore::default(); + let store = filesystem_store().await; store .upsert_manifest(manifest("telegram_bot_token", "sha256:abc123")) .await @@ -306,7 +321,7 @@ fn duplicate_credential_bindings_rejected_at_construction() { #[tokio::test] async fn no_op_activation_transition_does_not_update_timestamp() { - let store = InMemoryExtensionInstallationStore::default(); + let store = filesystem_store().await; store .upsert_manifest(manifest("telegram_bot_token", "sha256:abc123")) .await @@ -343,7 +358,7 @@ async fn no_op_activation_transition_does_not_update_timestamp() { #[tokio::test] async fn installed_state_does_not_surface_in_enabled_installations() { - let store = InMemoryExtensionInstallationStore::default(); + let store = filesystem_store().await; store .upsert_manifest(manifest("telegram_bot_token", "sha256:abc123")) .await @@ -449,7 +464,7 @@ handle = "outbound_token" ) .unwrap(); - let store = InMemoryExtensionInstallationStore::default(); + let store = filesystem_store().await; store.upsert_manifest(multi_manifest).await.unwrap(); store.upsert_installation(multi_install).await.unwrap(); @@ -466,7 +481,7 @@ handle = "outbound_token" #[tokio::test] async fn arc_store_delegation_works() { - let store = InMemoryExtensionInstallationStore::default(); + let store = filesystem_store().await; let arc_store: Arc = Arc::new(store); arc_store .upsert_manifest(manifest("telegram_bot_token", "sha256:abc123")) @@ -485,7 +500,7 @@ async fn arc_store_delegation_works() { #[tokio::test] async fn update_health_uses_redacted_string() { - let store = InMemoryExtensionInstallationStore::default(); + let store = filesystem_store().await; store .upsert_manifest(manifest("telegram_bot_token", "sha256:abc123")) .await diff --git a/crates/ironclaw_product_workflow/Cargo.toml b/crates/ironclaw_product_workflow/Cargo.toml index 972660975fd..76fa0bee910 100644 --- a/crates/ironclaw_product_workflow/Cargo.toml +++ b/crates/ironclaw_product_workflow/Cargo.toml @@ -71,10 +71,9 @@ ironclaw_host_runtime = { path = "../ironclaw_host_runtime" } ironclaw_loop_host = { path = "../ironclaw_loop_host", features = ["test-support"] } ironclaw_event_projections = { path = "../ironclaw_event_projections" } ironclaw_reborn_composition = { path = "../ironclaw_reborn_composition", features = ["test-support"] } -# Test-only construction of `ironclaw_runner::subagent::await_edge`'s -# CAS-backed test fixtures (in-memory `FilesystemAwaitEdgeStore`) in this -# crate's own test harnesses. -ironclaw_runner = { path = "../ironclaw_runner" } +# Test-only construction of `ironclaw_runner` CAS-backed fixtures over an +# in-memory filesystem in this crate's own test harnesses. +ironclaw_runner = { path = "../ironclaw_runner", features = ["test-support"] } ironclaw_trust = { path = "../ironclaw_trust" } ironclaw_product_workflow = { path = ".", features = ["test-support"] } ironclaw_product_adapters = { path = "../ironclaw_product_adapters", features = ["test-support", "host-auth-mint"] } diff --git a/crates/ironclaw_product_workflow/tests/inbound_turn_contract.rs b/crates/ironclaw_product_workflow/tests/inbound_turn_contract.rs index d6702c81bbe..00e101d0a56 100644 --- a/crates/ironclaw_product_workflow/tests/inbound_turn_contract.rs +++ b/crates/ironclaw_product_workflow/tests/inbound_turn_contract.rs @@ -48,6 +48,7 @@ use ironclaw_runner::subagent::await_edge::{ boot_recovery::ScopeRecoveryDriver, resolver::AwaitEdgeResolver, store::FilesystemAwaitEdgeStore, }; +use ironclaw_runner::subagent::goal_store::in_memory_backed_subagent_goal_store; use ironclaw_threads::{ InMemorySessionThreadService, MessageStatus, SessionThreadService, ThreadHistoryRequest, ThreadScope, @@ -433,8 +434,8 @@ fn turn_state_store_dyn( /// Test-only in-memory await-edge trio (writer/settler/evidence trait /// objects, plus the goal store the resolver and `subagent_goal_store` /// share) — these harness tests don't exercise real subagent spawn/settle -/// flows, so an in-memory `ScopedFilesystem` fixture is behavior-equivalent -/// to the deleted `BoundedSubagentGateResolutionStore` for their purposes. +/// flows, so in-memory `ScopedFilesystem` fixtures are enough for their +/// purposes. #[allow(clippy::type_complexity)] fn test_await_edge_trio( turn_store: &Arc>, @@ -455,8 +456,7 @@ fn test_await_edge_trio( let store = Arc::new(FilesystemAwaitEdgeStore::new(Arc::new( ScopedFilesystem::with_fixed_view(Arc::new(InMemoryBackend::new()), mounts), ))); - let goal_store = - Arc::new(ironclaw_runner::subagent::goal_store::InMemoryBoundedSubagentGoalStore::new()); + let goal_store = Arc::new(in_memory_backed_subagent_goal_store()); let resolver = Arc::new(AwaitEdgeResolver::new_unbound( Arc::clone(&store), goal_store.clone() as Arc, diff --git a/crates/ironclaw_product_workflow/tests/support/planned_agent_loop.rs b/crates/ironclaw_product_workflow/tests/support/planned_agent_loop.rs index 34f3ef47f21..ff0c0223a42 100644 --- a/crates/ironclaw_product_workflow/tests/support/planned_agent_loop.rs +++ b/crates/ironclaw_product_workflow/tests/support/planned_agent_loop.rs @@ -52,6 +52,7 @@ use ironclaw_runner::{ boot_recovery::ScopeRecoveryDriver, resolver::AwaitEdgeResolver, store::FilesystemAwaitEdgeStore, }, + subagent::goal_store::in_memory_backed_subagent_goal_store, }; use ironclaw_threads::{ InMemorySessionThreadService, SessionThreadService, ThreadHistoryRequest, ThreadMessageRecord, @@ -370,9 +371,7 @@ impl ProductLiveAgentLoopHarness { let await_edge_store = Arc::new(FilesystemAwaitEdgeStore::new(Arc::new( ScopedFilesystem::with_fixed_view(Arc::new(InMemoryBackend::new()), await_edge_mounts), ))); - let await_edge_goal_store = Arc::new( - ironclaw_runner::subagent::goal_store::InMemoryBoundedSubagentGoalStore::new(), - ); + let await_edge_goal_store = Arc::new(in_memory_backed_subagent_goal_store()); let await_edge_resolver = Arc::new(AwaitEdgeResolver::new_unbound( Arc::clone(&await_edge_store), await_edge_goal_store.clone() as Arc, diff --git a/crates/ironclaw_reborn_cli/src/commands/serve.rs b/crates/ironclaw_reborn_cli/src/commands/serve.rs index db31baaeedd..b25e8b4a655 100644 --- a/crates/ironclaw_reborn_cli/src/commands/serve.rs +++ b/crates/ironclaw_reborn_cli/src/commands/serve.rs @@ -72,7 +72,7 @@ pub(crate) fn present_unicode_env_var(name: &str) -> anyhow::Result, + session_store: Arc, } #[async_trait::async_trait] diff --git a/crates/ironclaw_reborn_cli/src/commands/serve_sso.rs b/crates/ironclaw_reborn_cli/src/commands/serve_sso.rs index 9618c595694..2256e78292b 100644 --- a/crates/ironclaw_reborn_cli/src/commands/serve_sso.rs +++ b/crates/ironclaw_reborn_cli/src/commands/serve_sso.rs @@ -9,8 +9,8 @@ //! and the route wiring — lives in //! `ironclaw_webui` (see //! [`ironclaw_webui::build_signed_session_login`]), which -//! is where this crate's guardrails place `WebuiAuthenticator` / -//! `SessionStore` implementations. `serve.rs` calls +//! is where this crate's guardrails place the `WebuiAuthenticator` +//! implementations and signed session store. `serve.rs` calls //! [`sso_startup_config_from_env`] and, when it returns `Some`, hands //! the result plus the operator identity/secret to that builder. diff --git a/crates/ironclaw_reborn_composition/CLAUDE.md b/crates/ironclaw_reborn_composition/CLAUDE.md index 6e0f2a12111..08d1fd3cde8 100644 --- a/crates/ironclaw_reborn_composition/CLAUDE.md +++ b/crates/ironclaw_reborn_composition/CLAUDE.md @@ -281,7 +281,7 @@ Rationale: atomically by the exchange route. Composition also emits `Referrer-Policy: no-referrer` as defense in depth. - **Logout actually revokes.** `POST /auth/logout` calls - `SessionStore::revoke`; the regression in + `SignedTokenSessionStore::revoke`; the regression in `crates/ironclaw_webui/tests/session_round_trip.rs` locks that a post-revoke bearer fails on `/api/webchat/v2/threads`. diff --git a/crates/ironclaw_reborn_composition/Cargo.toml b/crates/ironclaw_reborn_composition/Cargo.toml index 567bf23eacd..cd82bce33e1 100644 --- a/crates/ironclaw_reborn_composition/Cargo.toml +++ b/crates/ironclaw_reborn_composition/Cargo.toml @@ -23,6 +23,7 @@ layer = "app" test-support = [ "ironclaw_channel_delivery/test-support", "ironclaw_host_runtime/test-support", + "ironclaw_runner/test-support", ] # Expose the narrow `extension_installation_store_for_migration` accessor for # the v1→Reborn migration tool (`ironclaw_reborn_migration`). Mirrors the diff --git a/crates/ironclaw_reborn_composition/src/extension_host/extension_installation_store.rs b/crates/ironclaw_reborn_composition/src/extension_host/extension_installation_store.rs deleted file mode 100644 index edfb80ccd73..00000000000 --- a/crates/ironclaw_reborn_composition/src/extension_host/extension_installation_store.rs +++ /dev/null @@ -1,513 +0,0 @@ -use async_trait::async_trait; -use ironclaw_extensions::{ - ExtensionActivationState, ExtensionHealthSnapshot, ExtensionInstallation, - ExtensionInstallationError, ExtensionInstallationId, ExtensionInstallationStore, - ExtensionManifestRecord, ExtensionRemovalCleanupRequirement, - InMemoryExtensionInstallationStore, ManifestHash, ManifestSource, - canonicalize_installation_rows, -}; -use ironclaw_filesystem::{FilesystemError, RootFilesystem}; -use ironclaw_host_api::{ExtensionId, VirtualPath}; -use serde::{Deserialize, Serialize}; -use tokio::sync::Mutex; - -use crate::extension_host::host_api_contracts::product_extension_host_api_contract_registry; - -const DEFAULT_INSTALLATION_STATE_PATH: &str = "/system/extensions/.installations/state.json"; -const INSTALLATION_STATE_IO_ERROR: &str = "failed to load extension installation state"; - -pub(crate) struct FilesystemExtensionInstallationStore { - filesystem: std::sync::Arc, - state_path: VirtualPath, - inner: InMemoryExtensionInstallationStore, - /// The exact snapshot bytes this store last observed on disk (`None` - /// before the first write). Serializes writers in-process and backs the - /// stale-writer guard in [`Self::ensure_snapshot_current`]. - persisted_snapshot: Mutex>>, -} - -impl FilesystemExtensionInstallationStore { - pub(crate) async fn load_at( - filesystem: std::sync::Arc, - state_path: VirtualPath, - ) -> Result { - let inner = InMemoryExtensionInstallationStore::default(); - let persisted = match filesystem.read_file(&state_path).await { - Ok(bytes) => { - let state: WireState = - serde_json::from_slice(&bytes).map_err(invalid_installation_error)?; - let original_installations = state.installations; - let normalized_installations = - canonicalize_installation_rows(original_installations.clone())?; - let needs_rewrite = normalized_installations != original_installations; - let normalized_state = WireState { - manifests: state.manifests, - installations: normalized_installations, - }; - - // Validate the complete normalized snapshot before writing it - // back. A malformed manifest/installation pair must leave the - // persisted bytes untouched and must not expose a half-loaded - // store. - normalized_state.load_into(&inner).await?; - if needs_rewrite { - Some(write_snapshot(&filesystem, &state_path, &normalized_state).await?) - } else { - Some(bytes) - } - } - Err(FilesystemError::NotFound { .. }) => None, - Err(error) => { - tracing::debug!( - ?error, - state_path = %state_path.as_str(), - "extension installation state load failed" - ); - return Err(store_unavailable_error(INSTALLATION_STATE_IO_ERROR)); - } - }; - Ok(Self { - filesystem, - state_path, - inner, - persisted_snapshot: Mutex::new(persisted), - }) - } - - pub(crate) fn default_state_path() -> Result { - default_installation_state_path() - } - - /// Stale-writer guard: the on-disk snapshot must still be exactly the - /// bytes this store last observed, or the pending full-snapshot rewrite - /// would silently revert a concurrent writer's state. - /// - /// The extension mount is served by the byte-oriented backend family - /// (`LocalFilesystem` for materialized bundles), which cannot honor - /// `CasExpectation::Version` — a true CAS fence is not expressible here - /// until this store migrates onto the versioned record plane (see - /// docs/plans/2026-06-25-cas-migration.md). Until then this read-back - /// compare converts the split-brain failure mode (two processes over one - /// state file, last-writer-wins) into a loud retryable error. The - /// read-compare-write sequence is not atomic across processes; the - /// deployment assumption stays a single serving process, and this guard - /// makes a violation of that assumption detectable instead of silent. - async fn ensure_snapshot_current( - &self, - persisted: &Option>, - ) -> Result<(), ExtensionInstallationError> { - let on_disk = match self.filesystem.read_file(&self.state_path).await { - Ok(bytes) => Some(bytes), - Err(FilesystemError::NotFound { .. }) => None, - Err(error) => { - tracing::debug!( - ?error, - state_path = %self.state_path.as_str(), - "extension installation state pre-write read failed" - ); - return Err(store_unavailable_error(INSTALLATION_STATE_IO_ERROR)); - } - }; - if &on_disk != persisted { - return Err(store_unavailable_error( - "extension installation state changed on disk under this process; \ - another writer owns the snapshot — restart to reload it", - )); - } - Ok(()) - } - - async fn save_snapshot( - &self, - persisted: &mut Option>, - ) -> Result<(), ExtensionInstallationError> { - let state = WireState::from_store(&self.inner).await?; - let bytes = write_snapshot(&self.filesystem, &self.state_path, &state).await?; - *persisted = Some(bytes); - Ok(()) - } - - /// Undo an in-memory mutation whose snapshot write failed, so the - /// in-memory view stays identical to disk and a retry replays cleanly - /// instead of double-applying (e.g. re-deleting an already-deleted row - /// and turning a transient IO failure into a malformed-request error). - /// Best-effort: a rollback failure is logged and the original write - /// error still surfaces; the store is then diverged either way and the - /// retryable `StoreUnavailable` tells the caller to back off. - async fn restore_installation_row(&self, prior: Option) { - let result = match prior { - Some(prior) => { - let installation_id = prior.installation_id().clone(); - self.inner - .upsert_installation(prior) - .await - .map_err(|error| (format!("restore installation {installation_id}"), error)) - } - None => Ok(()), - }; - if let Err((operation, error)) = result { - tracing::debug!( - ?error, - operation, - "in-memory rollback after failed snapshot write failed" - ); - } - } - - async fn restore_manifest_row(&self, prior: Option) { - let result = match prior { - Some(prior) => { - let extension_id = prior.extension_id().clone(); - self.inner - .upsert_manifest(prior) - .await - .map_err(|error| (format!("restore manifest {extension_id}"), error)) - } - None => Ok(()), - }; - if let Err((operation, error)) = result { - tracing::debug!( - ?error, - operation, - "in-memory rollback after failed snapshot write failed" - ); - } - } - - async fn remove_inserted_installation_row(&self, installation_id: &ExtensionInstallationId) { - if let Err(error) = self.inner.delete_installation(installation_id).await { - tracing::debug!( - ?error, - installation_id = %installation_id, - "in-memory rollback after failed snapshot write failed" - ); - } - } - - async fn remove_inserted_manifest_row(&self, extension_id: &ExtensionId) { - if let Err(error) = self.inner.delete_manifest(extension_id).await { - tracing::debug!( - ?error, - extension_id = %extension_id, - "in-memory rollback after failed snapshot write failed" - ); - } - } -} - -async fn write_snapshot( - filesystem: &std::sync::Arc, - state_path: &VirtualPath, - state: &WireState, -) -> Result, ExtensionInstallationError> { - let bytes = serde_json::to_vec_pretty(state).map_err(invalid_installation_error)?; - filesystem - .write_file(state_path, &bytes) - .await - .map_err(|error| { - tracing::debug!( - ?error, - state_path = %state_path.as_str(), - "extension installation state write failed" - ); - store_unavailable_error(INSTALLATION_STATE_IO_ERROR) - })?; - Ok(bytes) -} - -fn default_installation_state_path() -> Result { - VirtualPath::new(DEFAULT_INSTALLATION_STATE_PATH).map_err(|error| { - ExtensionInstallationError::InvalidInstallation { - reason: error.to_string(), - } - }) -} - -#[async_trait] -impl ExtensionInstallationStore for FilesystemExtensionInstallationStore { - async fn list_manifests( - &self, - ) -> Result, ExtensionInstallationError> { - self.inner.list_manifests().await - } - - async fn get_manifest( - &self, - extension_id: &ExtensionId, - ) -> Result, ExtensionInstallationError> { - self.inner.get_manifest(extension_id).await - } - - async fn upsert_manifest( - &self, - manifest: ExtensionManifestRecord, - ) -> Result<(), ExtensionInstallationError> { - let mut persisted = self.persisted_snapshot.lock().await; - self.ensure_snapshot_current(&persisted).await?; - let extension_id = manifest.extension_id().clone(); - let prior = self.inner.get_manifest(&extension_id).await?; - self.inner.upsert_manifest(manifest).await?; - if let Err(error) = self.save_snapshot(&mut persisted).await { - match prior { - Some(prior) => self.restore_manifest_row(Some(prior)).await, - None => self.remove_inserted_manifest_row(&extension_id).await, - } - return Err(error); - } - Ok(()) - } - - async fn upsert_manifest_and_installation( - &self, - manifest: ExtensionManifestRecord, - installation: ExtensionInstallation, - ) -> Result<(), ExtensionInstallationError> { - let mut persisted = self.persisted_snapshot.lock().await; - self.ensure_snapshot_current(&persisted).await?; - let extension_id = manifest.extension_id().clone(); - let installation_id = installation.installation_id().clone(); - let prior_manifest = self.inner.get_manifest(&extension_id).await?; - let prior_installation = self.inner.get_installation(&installation_id).await?; - self.inner - .upsert_manifest_and_installation(manifest, installation) - .await?; - if let Err(error) = self.save_snapshot(&mut persisted).await { - match prior_installation { - Some(prior) => self.restore_installation_row(Some(prior)).await, - None => { - self.remove_inserted_installation_row(&installation_id) - .await - } - } - match prior_manifest { - Some(prior) => self.restore_manifest_row(Some(prior)).await, - None => self.remove_inserted_manifest_row(&extension_id).await, - } - return Err(error); - } - Ok(()) - } - - async fn list_installations( - &self, - ) -> Result, ExtensionInstallationError> { - self.inner.list_installations().await - } - - async fn list_enabled_installations( - &self, - ) -> Result, ExtensionInstallationError> { - self.inner.list_enabled_installations().await - } - - async fn get_installation( - &self, - installation_id: &ExtensionInstallationId, - ) -> Result, ExtensionInstallationError> { - self.inner.get_installation(installation_id).await - } - - async fn upsert_installation( - &self, - installation: ExtensionInstallation, - ) -> Result<(), ExtensionInstallationError> { - let mut persisted = self.persisted_snapshot.lock().await; - self.ensure_snapshot_current(&persisted).await?; - let installation_id = installation.installation_id().clone(); - let prior = self.inner.get_installation(&installation_id).await?; - self.inner.upsert_installation(installation).await?; - if let Err(error) = self.save_snapshot(&mut persisted).await { - match prior { - Some(prior) => self.restore_installation_row(Some(prior)).await, - None => { - self.remove_inserted_installation_row(&installation_id) - .await - } - } - return Err(error); - } - Ok(()) - } - - async fn set_activation_state( - &self, - installation_id: &ExtensionInstallationId, - state: ExtensionActivationState, - ) -> Result<(), ExtensionInstallationError> { - let mut persisted = self.persisted_snapshot.lock().await; - self.ensure_snapshot_current(&persisted).await?; - let prior = self.inner.get_installation(installation_id).await?; - self.inner - .set_activation_state(installation_id, state) - .await?; - if let Err(error) = self.save_snapshot(&mut persisted).await { - self.restore_installation_row(prior).await; - return Err(error); - } - Ok(()) - } - - async fn delete_installation( - &self, - installation_id: &ExtensionInstallationId, - ) -> Result<(), ExtensionInstallationError> { - let mut persisted = self.persisted_snapshot.lock().await; - self.ensure_snapshot_current(&persisted).await?; - let prior = self.inner.get_installation(installation_id).await?; - self.inner.delete_installation(installation_id).await?; - if let Err(error) = self.save_snapshot(&mut persisted).await { - self.restore_installation_row(prior).await; - return Err(error); - } - Ok(()) - } - - async fn delete_manifest( - &self, - extension_id: &ExtensionId, - ) -> Result<(), ExtensionInstallationError> { - let mut persisted = self.persisted_snapshot.lock().await; - self.ensure_snapshot_current(&persisted).await?; - let prior = self.inner.get_manifest(extension_id).await?; - self.inner.delete_manifest(extension_id).await?; - if let Err(error) = self.save_snapshot(&mut persisted).await { - self.restore_manifest_row(prior).await; - return Err(error); - } - Ok(()) - } - - async fn update_health( - &self, - installation_id: &ExtensionInstallationId, - health: ExtensionHealthSnapshot, - ) -> Result<(), ExtensionInstallationError> { - let mut persisted = self.persisted_snapshot.lock().await; - self.ensure_snapshot_current(&persisted).await?; - let prior = self.inner.get_installation(installation_id).await?; - self.inner.update_health(installation_id, health).await?; - if let Err(error) = self.save_snapshot(&mut persisted).await { - self.restore_installation_row(prior).await; - return Err(error); - } - Ok(()) - } -} - -#[derive(Debug, Default, Clone, Serialize, Deserialize)] -struct WireState { - manifests: Vec, - installations: Vec, -} - -impl WireState { - async fn from_store( - store: &InMemoryExtensionInstallationStore, - ) -> Result { - let manifests = store - .list_manifests() - .await? - .into_iter() - .map(WireManifestRecord::from) - .collect(); - let installations = store.list_installations().await?; - Ok(Self { - manifests, - installations, - }) - } - - async fn load_into( - &self, - store: &InMemoryExtensionInstallationStore, - ) -> Result<(), ExtensionInstallationError> { - for manifest in &self.manifests { - store - .upsert_manifest(manifest.clone().into_manifest_record()?) - .await?; - } - for installation in &self.installations { - store.upsert_installation(installation.clone()).await?; - } - Ok(()) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct WireManifestRecord { - raw_toml: String, - source: WireManifestSource, - #[serde(default, skip_serializing_if = "Option::is_none")] - manifest_hash: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - removal_cleanup_requirements: Vec, -} - -impl WireManifestRecord { - fn into_manifest_record(self) -> Result { - let host_ports = ironclaw_host_runtime::default_host_port_catalog() - .map_err(invalid_installation_error)?; - let contracts = - product_extension_host_api_contract_registry().map_err(invalid_installation_error)?; - ExtensionManifestRecord::from_toml_with_contracts( - self.raw_toml, - self.source.into_manifest_source(), - &host_ports, - self.manifest_hash, - &contracts, - ) - .map(|record| record.with_removal_cleanup_requirements(self.removal_cleanup_requirements)) - } -} - -impl From for WireManifestRecord { - fn from(record: ExtensionManifestRecord) -> Self { - Self { - raw_toml: record.raw_toml().to_string(), - source: WireManifestSource::from_manifest_source(record.manifest().source), - manifest_hash: record.manifest_hash().cloned(), - removal_cleanup_requirements: record.removal_cleanup_requirements().to_vec(), - } - } -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -enum WireManifestSource { - HostBundled, - InstalledLocal, - RegistryInstalled, -} - -impl WireManifestSource { - fn from_manifest_source(source: ManifestSource) -> Self { - match source { - ManifestSource::HostBundled => Self::HostBundled, - ManifestSource::InstalledLocal => Self::InstalledLocal, - ManifestSource::RegistryInstalled => Self::RegistryInstalled, - } - } - - fn into_manifest_source(self) -> ManifestSource { - match self { - Self::HostBundled => ManifestSource::HostBundled, - Self::InstalledLocal => ManifestSource::InstalledLocal, - Self::RegistryInstalled => ManifestSource::RegistryInstalled, - } - } -} - -fn invalid_installation_error(error: impl std::fmt::Display) -> ExtensionInstallationError { - ExtensionInstallationError::InvalidInstallation { - reason: error.to_string(), - } -} - -fn store_unavailable_error(error: impl std::fmt::Display) -> ExtensionInstallationError { - ExtensionInstallationError::StoreUnavailable { - reason: error.to_string(), - } -} - -#[cfg(test)] -mod tests; diff --git a/crates/ironclaw_reborn_composition/src/extension_host/extension_installation_store/tests.rs b/crates/ironclaw_reborn_composition/src/extension_host/extension_installation_store/tests.rs deleted file mode 100644 index 01a8fbafb0f..00000000000 --- a/crates/ironclaw_reborn_composition/src/extension_host/extension_installation_store/tests.rs +++ /dev/null @@ -1,756 +0,0 @@ -use std::{collections::BTreeSet, sync::Arc}; - -use chrono::Utc; -use ironclaw_extensions::{ - ExtensionActivationState, ExtensionCredentialBinding, ExtensionCredentialHandle, - ExtensionHealthMessage, ExtensionHealthSnapshot, ExtensionHealthStatus, ExtensionInstallation, - ExtensionInstallationId, ExtensionInstallationPersistedParts, ExtensionManifestRecord, - ExtensionManifestRef, InstallationOwner, MANIFEST_SCHEMA_VERSION, -}; -use ironclaw_filesystem::{ - Fault, FaultInjecting, FilesystemOperation, InMemoryBackend, RootFilesystem, -}; -use ironclaw_host_api::{HostPortCatalog, SecretHandle}; - -use super::*; - -#[test] -fn persisted_removal_cleanup_metadata_rejects_invalid_identifiers() { - for (adapter_id, channel) in [("", "slack"), ("slack.connection", "bad channel")] { - let wire = serde_json::json!({ - "manifests": [{ - "raw_toml": "", - "source": "host_bundled", - "removal_cleanup_requirements": [{ - "adapter_id": adapter_id, - "binding": { "kind": "channel_connection", "channel": channel } - }] - }], - "installations": [] - }); - assert!( - serde_json::from_value::(wire).is_err(), - "invalid durable cleanup metadata must fail closed" - ); - } -} - -#[tokio::test] -async fn load_at_treats_not_found_as_empty_state() { - let filesystem: Arc = Arc::new(InMemoryBackend::new()); - let state_path = - VirtualPath::new("/tenants/acme/system/extensions/.installations/missing-state.json") - .expect("valid state path"); - - let store = FilesystemExtensionInstallationStore::load_at(filesystem, state_path) - .await - .expect("missing state file loads as empty"); - - assert!( - store - .list_installations() - .await - .expect("list installations") - .is_empty() - ); -} - -#[tokio::test] -async fn load_at_sanitizes_filesystem_read_errors() { - let filesystem: Arc = Arc::new( - FaultInjecting::new(InMemoryBackend::new()) - .with_fault(Fault::on(FilesystemOperation::ReadFile).backend("raw backend detail")), - ); - let state_path = VirtualPath::new("/tenants/acme/system/extensions/.installations/state.json") - .expect("valid state path"); - - let error = match FilesystemExtensionInstallationStore::load_at(filesystem, state_path).await { - Ok(_) => panic!("backend read failure should surface as invalid installation"), - Err(error) => error, - }; - - let rendered = error.to_string(); - assert!(rendered.contains("failed to load extension installation state")); - assert!(!rendered.contains("/tenants/acme")); - assert!(!rendered.contains("raw backend detail")); -} - -#[tokio::test] -async fn load_at_persists_state_to_custom_path() { - let filesystem: Arc = Arc::new(InMemoryBackend::new()); - let state_path = VirtualPath::new("/tenants/acme/system/extensions/.installations/state.json") - .expect("valid state path"); - let store = - FilesystemExtensionInstallationStore::load_at(Arc::clone(&filesystem), state_path.clone()) - .await - .expect("store loads"); - let installation_id = - ExtensionInstallationId::new("gmail".to_string()).expect("valid installation id"); - let extension_id = ExtensionId::new("gmail").expect("valid extension id"); - let manifest_ref = ExtensionManifestRef::new(extension_id.clone(), None); - let manifest = ExtensionManifestRecord::from_toml( - format!( - r#" -schema_version = "{schema}" -id = "gmail" -name = "Gmail" -version = "0.1.0" -description = "test" -trust = "third_party" - -[runtime] -kind = "wasm" -module = "wasm/gmail.wasm" - -[[capabilities]] -id = "gmail.echo" -description = "Echoes input" -default_permission = "allow" -visibility = "model" -input_schema_ref = "schemas/gmail/echo.input.v1.json" -output_schema_ref = "schemas/gmail/echo.output.v1.json" -prompt_doc_ref = "prompts/gmail/echo.md" -"#, - schema = MANIFEST_SCHEMA_VERSION, - ), - ManifestSource::HostBundled, - &HostPortCatalog::empty(), - None, - ) - .expect("valid manifest"); - store - .upsert_manifest_and_installation( - manifest, - ExtensionInstallation::new( - installation_id.clone(), - extension_id, - ExtensionActivationState::Installed, - manifest_ref, - Vec::new(), - Utc::now(), - InstallationOwner::Tenant, - ) - .expect("valid installation"), - ) - .await - .expect("installation saved"); - - assert!( - filesystem - .read_file(&state_path) - .await - .expect("state file exists") - .starts_with(b"{") - ); - - let reloaded = FilesystemExtensionInstallationStore::load_at(filesystem, state_path) - .await - .expect("store reloads"); - assert!( - reloaded - .get_installation(&installation_id) - .await - .expect("installation read") - .is_some() - ); -} - -fn gmail_manifest() -> ExtensionManifestRecord { - ExtensionManifestRecord::from_toml( - format!( - r#" -schema_version = "{schema}" -id = "gmail" -name = "Gmail" -version = "0.1.0" -description = "test" -trust = "third_party" - -[runtime] -kind = "wasm" -module = "wasm/gmail.wasm" - -[[capabilities]] -id = "gmail.echo" -description = "Echoes input" -default_permission = "allow" -visibility = "model" -input_schema_ref = "schemas/gmail/echo.input.v1.json" -output_schema_ref = "schemas/gmail/echo.output.v1.json" -prompt_doc_ref = "prompts/gmail/echo.md" -"#, - schema = MANIFEST_SCHEMA_VERSION, - ), - ManifestSource::HostBundled, - &HostPortCatalog::empty(), - None, - ) - .expect("valid manifest") -} - -fn gmail_installation(installation_id: &ExtensionInstallationId) -> ExtensionInstallation { - let extension_id = ExtensionId::new("gmail").expect("valid extension id"); - ExtensionInstallation::new( - installation_id.clone(), - extension_id.clone(), - ExtensionActivationState::Installed, - ExtensionManifestRef::new(extension_id, None), - Vec::new(), - Utc::now(), - InstallationOwner::Tenant, - ) - .expect("valid installation") -} - -#[tokio::test] -async fn stale_store_write_fails_instead_of_clobbering_concurrent_snapshot() { - let filesystem: Arc = Arc::new(InMemoryBackend::new()); - let state_path = VirtualPath::new("/tenants/acme/system/extensions/.installations/state.json") - .expect("valid state path"); - let installation_id = - ExtensionInstallationId::new("gmail".to_string()).expect("valid installation id"); - - // "Process" one persists an installation; "process" two loads the same - // snapshot afterwards, so both hold the same on-disk baseline. - let first = - FilesystemExtensionInstallationStore::load_at(Arc::clone(&filesystem), state_path.clone()) - .await - .expect("first store loads"); - first - .upsert_manifest_and_installation(gmail_manifest(), gmail_installation(&installation_id)) - .await - .expect("first process persists installation"); - let second = - FilesystemExtensionInstallationStore::load_at(Arc::clone(&filesystem), state_path.clone()) - .await - .expect("second store loads"); - - // The first process advances the snapshot again... - first - .set_activation_state(&installation_id, ExtensionActivationState::Enabled) - .await - .expect("first process enables installation"); - - // ...so the second process's next full-snapshot rewrite is stale and must - // fail loudly instead of silently reverting the first process's write. - let error = second - .set_activation_state(&installation_id, ExtensionActivationState::Disabled) - .await - .expect_err("stale snapshot write must not clobber a concurrent writer"); - assert!( - matches!(error, ExtensionInstallationError::StoreUnavailable { .. }), - "expected the retryable store-unavailable class, got {error:?}" - ); - - let reloaded = FilesystemExtensionInstallationStore::load_at(filesystem, state_path) - .await - .expect("store reloads"); - assert_eq!( - reloaded - .get_installation(&installation_id) - .await - .expect("installation read") - .expect("installation present") - .activation_state(), - ExtensionActivationState::Enabled, - "the concurrent writer's snapshot survives the stale write attempt" - ); -} - -#[tokio::test] -async fn load_at_canonicalizes_duplicate_rows_and_preserves_complete_snapshot() { - let filesystem: Arc = Arc::new(InMemoryBackend::new()); - let state_path = test_state_path(); - let original = seed_state( - &filesystem, - &state_path, - vec![ - stored_installation( - "legacy-alice", - ExtensionActivationState::Enabled, - InstallationOwner::user(ironclaw_host_api::UserId::new("alice").unwrap()), - None, - ), - stored_installation( - "legacy-bob", - ExtensionActivationState::Enabled, - InstallationOwner::user(ironclaw_host_api::UserId::new("bob").unwrap()), - None, - ), - ], - ) - .await; - - let store = - FilesystemExtensionInstallationStore::load_at(Arc::clone(&filesystem), state_path.clone()) - .await - .expect("duplicate rows load"); - let installation = store.list_installations().await.unwrap().pop().unwrap(); - assert_eq!(installation.installation_id().as_str(), "canonical-tools"); - assert_eq!( - installation.owner().members(), - Some(&BTreeSet::from([ - ironclaw_host_api::UserId::new("alice").unwrap(), - ironclaw_host_api::UserId::new("bob").unwrap(), - ])) - ); - - let rewritten = filesystem.read_file(&state_path).await.unwrap(); - assert_ne!(rewritten, original); - let state: WireState = serde_json::from_slice(&rewritten).unwrap(); - assert_eq!(state.manifests.len(), 1); - assert_eq!(state.installations.len(), 1); -} - -#[tokio::test] -async fn load_at_applies_tenant_dominance_and_mixed_activation_policy() { - let filesystem: Arc = Arc::new(InMemoryBackend::new()); - let state_path = test_state_path(); - seed_state( - &filesystem, - &state_path, - vec![ - stored_installation( - "legacy-member", - ExtensionActivationState::Enabled, - InstallationOwner::user(ironclaw_host_api::UserId::new("alice").unwrap()), - None, - ), - stored_installation( - "legacy-tenant", - ExtensionActivationState::Installed, - InstallationOwner::Tenant, - None, - ), - ], - ) - .await; - - let store = FilesystemExtensionInstallationStore::load_at(filesystem, state_path) - .await - .expect("tenant/member rows load"); - let installation = store.list_installations().await.unwrap().pop().unwrap(); - assert_eq!(installation.owner(), &InstallationOwner::Tenant); - assert_eq!( - installation.activation_state(), - ExtensionActivationState::Disabled - ); -} - -#[tokio::test] -async fn load_at_rekeys_single_row_and_is_idempotent() { - let filesystem: Arc = Arc::new(InMemoryBackend::new()); - let state_path = test_state_path(); - seed_state( - &filesystem, - &state_path, - vec![stored_installation( - "legacy-single", - ExtensionActivationState::Enabled, - InstallationOwner::Tenant, - None, - )], - ) - .await; - - FilesystemExtensionInstallationStore::load_at(Arc::clone(&filesystem), state_path.clone()) - .await - .expect("first load"); - let once = filesystem.read_file(&state_path).await.unwrap(); - assert!(String::from_utf8_lossy(&once).contains("canonical-tools")); - - FilesystemExtensionInstallationStore::load_at(Arc::clone(&filesystem), state_path.clone()) - .await - .expect("second load"); - let twice = filesystem.read_file(&state_path).await.unwrap(); - assert_eq!(once, twice); -} - -#[tokio::test] -async fn load_at_rejects_manifest_conflicts_without_rewriting_state() { - let filesystem: Arc = Arc::new(InMemoryBackend::new()); - let state_path = test_state_path(); - let original = seed_state( - &filesystem, - &state_path, - vec![ - stored_installation( - "legacy-manifest-a", - ExtensionActivationState::Installed, - InstallationOwner::Tenant, - Some("manifest-a"), - ), - stored_installation( - "legacy-manifest-b", - ExtensionActivationState::Installed, - InstallationOwner::Tenant, - Some("manifest-b"), - ), - ], - ) - .await; - - let error = match FilesystemExtensionInstallationStore::load_at( - Arc::clone(&filesystem), - state_path.clone(), - ) - .await - { - Ok(_) => panic!("manifest conflict fails closed"), - Err(error) => error, - }; - assert!(matches!( - error, - ExtensionInstallationError::ConflictingManifestReference { .. } - )); - assert_eq!(filesystem.read_file(&state_path).await.unwrap(), original); -} - -#[tokio::test] -async fn load_at_rejects_credential_conflicts_without_rewriting_state() { - let filesystem: Arc = Arc::new(InMemoryBackend::new()); - let state_path = test_state_path(); - let original = seed_state( - &filesystem, - &state_path, - vec![ - stored_installation_with_credential( - "legacy-credential-a", - ExtensionActivationState::Installed, - InstallationOwner::Tenant, - "secret-a", - ), - stored_installation_with_credential( - "legacy-credential-b", - ExtensionActivationState::Installed, - InstallationOwner::Tenant, - "secret-b", - ), - ], - ) - .await; - - let error = match FilesystemExtensionInstallationStore::load_at( - Arc::clone(&filesystem), - state_path.clone(), - ) - .await - { - Ok(_) => panic!("credential conflict fails closed"), - Err(error) => error, - }; - assert!(matches!( - error, - ExtensionInstallationError::ConflictingCredentialBinding { .. } - )); - assert_eq!(filesystem.read_file(&state_path).await.unwrap(), original); -} - -#[tokio::test] -async fn load_at_returns_rewrite_failure_without_exposing_normalized_store() { - let backend = Arc::new(FaultInjecting::new(InMemoryBackend::new())); - let seed_filesystem: Arc = backend.clone(); - let state_path = test_state_path(); - let original = seed_state( - &seed_filesystem, - &state_path, - vec![ - stored_installation( - "legacy-rewrite-a", - ExtensionActivationState::Enabled, - InstallationOwner::user(ironclaw_host_api::UserId::new("alice").unwrap()), - None, - ), - stored_installation( - "legacy-rewrite-b", - ExtensionActivationState::Enabled, - InstallationOwner::user(ironclaw_host_api::UserId::new("bob").unwrap()), - None, - ), - ], - ) - .await; - // Arm the write fault only after seeding, so the store's canonical rewrite - // during load fails at the backend and flows through the real store's - // `FilesystemError -> ExtensionInstallationError` mapping. - backend.add_fault( - Fault::on(FilesystemOperation::WriteFile) - .backend("injected extension installation write failure"), - ); - let failing_filesystem: Arc = backend.clone(); - - let error = - match FilesystemExtensionInstallationStore::load_at(failing_filesystem, state_path.clone()) - .await - { - Ok(_) => panic!("canonical rewrite failure must fail closed"), - Err(error) => error, - }; - // #4091: an IO failure during the canonical rewrite is retryable backend - // trouble, not a malformed snapshot. - assert_eq!( - error, - ExtensionInstallationError::StoreUnavailable { - reason: "failed to load extension installation state".to_string(), - } - ); - assert!(!error.to_string().contains("/tenants/acme")); - assert!( - !error - .to_string() - .contains("injected extension installation write failure") - ); - assert_eq!(backend.read_file(&state_path).await.unwrap(), original); -} - -#[test] -fn canonicalization_rejects_conflicting_credential_mappings() { - let extension_id = ExtensionId::new("canonical-tools").unwrap(); - let make_installation = |installation_id: &str, secret: &str| { - let timestamp = chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") - .unwrap() - .with_timezone(&Utc); - ExtensionInstallation::from_persisted_parts(ExtensionInstallationPersistedParts { - installation_id: ExtensionInstallationId::new(installation_id).unwrap(), - extension_id: extension_id.clone(), - activation_state: ExtensionActivationState::Installed, - manifest_ref: ExtensionManifestRef::new(extension_id.clone(), None), - credential_bindings: vec![ExtensionCredentialBinding::new( - ExtensionCredentialHandle::new("api").unwrap(), - SecretHandle::new(secret).unwrap(), - )], - health: ExtensionHealthSnapshot::new(ExtensionHealthStatus::Healthy, None, timestamp), - updated_at: timestamp, - owner: InstallationOwner::Tenant, - }) - .unwrap() - }; - - let error = canonicalize_installation_rows(vec![ - make_installation("legacy-a", "secret-a"), - make_installation("legacy-b", "secret-b"), - ]) - .expect_err("credential mapping conflict fails closed"); - assert!(matches!( - error, - ExtensionInstallationError::ConflictingCredentialBinding { .. } - )); -} - -#[test] -fn canonicalization_preserves_newest_health_max_updated_at_and_agreeing_bindings() { - let extension_id = ExtensionId::new("canonical-tools").unwrap(); - let binding = ExtensionCredentialBinding::new( - ExtensionCredentialHandle::new("api").unwrap(), - SecretHandle::new("secret").unwrap(), - ); - let make_installation = |installation_id: &str, checked_at: &str, updated_at: &str, status| { - let checked_at = chrono::DateTime::parse_from_rfc3339(checked_at) - .unwrap() - .with_timezone(&Utc); - let updated_at = chrono::DateTime::parse_from_rfc3339(updated_at) - .unwrap() - .with_timezone(&Utc); - ExtensionInstallation::from_persisted_parts(ExtensionInstallationPersistedParts { - installation_id: ExtensionInstallationId::new(installation_id).unwrap(), - extension_id: extension_id.clone(), - activation_state: ExtensionActivationState::Enabled, - manifest_ref: ExtensionManifestRef::new(extension_id.clone(), None), - credential_bindings: vec![binding.clone()], - health: ExtensionHealthSnapshot::new(status, None, checked_at), - updated_at, - owner: InstallationOwner::Tenant, - }) - .unwrap() - }; - - let canonical = canonicalize_installation_rows(vec![ - make_installation( - "legacy-a", - "2026-01-02T00:00:00Z", - "2026-01-05T00:00:00Z", - ExtensionHealthStatus::Healthy, - ), - make_installation( - "legacy-b", - "2026-01-03T00:00:00Z", - "2026-01-04T00:00:00Z", - ExtensionHealthStatus::Degraded, - ), - ]) - .unwrap(); - let installation = &canonical[0]; - assert_eq!( - installation.health().checked_at().to_rfc3339(), - "2026-01-03T00:00:00+00:00" - ); - assert_eq!( - installation.health().status(), - ExtensionHealthStatus::Degraded - ); - assert_eq!( - installation.updated_at().to_rfc3339(), - "2026-01-05T00:00:00+00:00" - ); - assert_eq!(installation.credential_bindings(), &[binding]); -} - -fn test_state_path() -> VirtualPath { - VirtualPath::new("/tenants/acme/system/extensions/.installations/state.json").unwrap() -} - -async fn seed_state( - filesystem: &Arc, - state_path: &VirtualPath, - installations: Vec, -) -> Vec { - let state = WireState { - manifests: vec![WireManifestRecord::from(test_manifest_record())], - installations, - }; - let bytes = serde_json::to_vec_pretty(&state).unwrap(); - filesystem.write_file(state_path, &bytes).await.unwrap(); - bytes -} - -fn test_manifest_record() -> ExtensionManifestRecord { - let manifest = format!( - "schema_version = \"{}\"\nid = \"canonical-tools\"\nname = \"Canonical Tools\"\nversion = \"0.1.0\"\ndescription = \"test\"\ntrust = \"third_party\"\n\n[runtime]\nkind = \"wasm\"\nmodule = \"wasm/canonical-tools.wasm\"\n\n[[capabilities]]\nid = \"canonical-tools.echo\"\ndescription = \"Echo\"\ndefault_permission = \"allow\"\nvisibility = \"model\"\ninput_schema_ref = \"schemas/echo.input.json\"\noutput_schema_ref = \"schemas/echo.output.json\"\n", - MANIFEST_SCHEMA_VERSION - ); - ExtensionManifestRecord::from_toml( - manifest, - ManifestSource::HostBundled, - &HostPortCatalog::empty(), - None, - ) - .unwrap() -} - -fn stored_installation( - installation_id: &str, - activation_state: ExtensionActivationState, - owner: InstallationOwner, - manifest_hash: Option<&str>, -) -> ExtensionInstallation { - stored_installation_with_bindings( - installation_id, - activation_state, - owner, - manifest_hash, - Vec::new(), - ) -} - -fn stored_installation_with_credential( - installation_id: &str, - activation_state: ExtensionActivationState, - owner: InstallationOwner, - secret: &str, -) -> ExtensionInstallation { - stored_installation_with_bindings( - installation_id, - activation_state, - owner, - None, - vec![ExtensionCredentialBinding::new( - ExtensionCredentialHandle::new("api").unwrap(), - SecretHandle::new(secret).unwrap(), - )], - ) -} - -fn stored_installation_with_bindings( - installation_id: &str, - activation_state: ExtensionActivationState, - owner: InstallationOwner, - manifest_hash: Option<&str>, - credential_bindings: Vec, -) -> ExtensionInstallation { - let extension_id = ExtensionId::new("canonical-tools").unwrap(); - let timestamp = chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") - .unwrap() - .with_timezone(&Utc); - ExtensionInstallation::from_persisted_parts(ExtensionInstallationPersistedParts { - installation_id: ExtensionInstallationId::new(installation_id).unwrap(), - extension_id: extension_id.clone(), - activation_state, - manifest_ref: ExtensionManifestRef::new( - extension_id, - manifest_hash.map(|value| ManifestHash::new(value).unwrap()), - ), - credential_bindings, - health: ExtensionHealthSnapshot::new( - ExtensionHealthStatus::Healthy, - Some(ExtensionHealthMessage::new(installation_id)), - timestamp, - ), - updated_at: timestamp, - owner, - }) - .unwrap() -} - -#[tokio::test] -async fn failed_snapshot_write_rolls_back_the_in_memory_mutation_so_retry_converges() { - let filesystem = Arc::new(FaultInjecting::new(InMemoryBackend::new())); - let state_path = test_state_path(); - let store = FilesystemExtensionInstallationStore::load_at( - Arc::clone(&filesystem) as Arc, - state_path.clone(), - ) - .await - .expect("store loads"); - let installation_id = - ExtensionInstallationId::new("gmail".to_string()).expect("valid installation id"); - store - .upsert_manifest_and_installation(gmail_manifest(), gmail_installation(&installation_id)) - .await - .expect("seed installation"); - - // One-shot: fault the next write (the delete's snapshot rewrite) only; the - // retry's write passes through so the delete converges. `nth(1)` on a freshly - // added fault fires on the first matching write after arming, then is spent. - filesystem.add_fault( - Fault::on(FilesystemOperation::WriteFile) - .nth(1) - .backend("injected one-shot write failure"), - ); - let error = store - .delete_installation(&installation_id) - .await - .expect_err("failed snapshot write surfaces"); - assert!( - matches!(error, ExtensionInstallationError::StoreUnavailable { .. }), - "a write outage is the retryable store class, got {error:?}" - ); - // The in-memory view matches disk again: the row is still present... - assert!( - store - .get_installation(&installation_id) - .await - .expect("post-failure lookup") - .is_some(), - "the in-memory delete must be rolled back when its snapshot write fails" - ); - // ...so retrying the SAME delete converges instead of replaying against - // already-mutated state and misreading a transient IO failure as a - // malformed request. - store - .delete_installation(&installation_id) - .await - .expect("retry of the same delete converges"); - let reloaded = FilesystemExtensionInstallationStore::load_at( - Arc::clone(&filesystem) as Arc, - state_path, - ) - .await - .expect("store reloads"); - assert!( - reloaded - .get_installation(&installation_id) - .await - .expect("reload lookup") - .is_none() - ); -} diff --git a/crates/ironclaw_reborn_composition/src/extension_host/extension_lifecycle.rs b/crates/ironclaw_reborn_composition/src/extension_host/extension_lifecycle.rs index 7a0b6c04cfb..e91ed1e2808 100644 --- a/crates/ironclaw_reborn_composition/src/extension_host/extension_lifecycle.rs +++ b/crates/ironclaw_reborn_composition/src/extension_host/extension_lifecycle.rs @@ -10,7 +10,7 @@ use ironclaw_extensions::{ CapabilityVisibility, ExtensionActivationState, ExtensionError, ExtensionInstallation, ExtensionInstallationError, ExtensionInstallationId, ExtensionInstallationStore, ExtensionLifecycleService, ExtensionManifestRecord, ExtensionManifestRef, ExtensionPackage, - InstallationOwner, ManifestHash, ManifestSource, + InstallationOwner, ManifestHash, ManifestSource, canonicalize_installation_rows, }; use ironclaw_filesystem::{FilesystemError, RootFilesystem}; use ironclaw_host_api::{ @@ -216,11 +216,7 @@ pub(crate) async fn restore_extension_lifecycle_state( lifecycle_service: &Arc>, active_extensions: &ActiveExtensionPublisher, ) -> Result<(), ProductWorkflowError> { - for installation in installation_store - .list_installations() - .await - .map_err(map_extension_installation_error)? - { + for installation in canonicalize_persisted_installation_rows(installation_store).await? { if remove_retired_internal_installation(installation_store, &installation).await? { continue; } @@ -285,6 +281,43 @@ pub(crate) async fn restore_extension_lifecycle_state( Ok(()) } +async fn canonicalize_persisted_installation_rows( + installation_store: &Arc, +) -> Result, ProductWorkflowError> { + let persisted = installation_store + .list_installations() + .await + .map_err(map_extension_installation_error)?; + let canonical = canonicalize_installation_rows(persisted.clone()) + .map_err(map_extension_installation_error)?; + if persisted == canonical { + return Ok(canonical); + } + + for installation in &canonical { + installation_store + .upsert_installation(installation.clone()) + .await + .map_err(map_extension_installation_error)?; + } + + let canonical_ids = canonical + .iter() + .map(|installation| installation.installation_id().clone()) + .collect::>(); + for installation in persisted { + if canonical_ids.contains(installation.installation_id()) { + continue; + } + installation_store + .delete_installation(installation.installation_id()) + .await + .map_err(map_extension_installation_error)?; + } + + Ok(canonical) +} + async fn remove_retired_internal_installation( installation_store: &Arc, installation: &ExtensionInstallation, @@ -2565,6 +2598,8 @@ fn project_installation_owners( where I: IntoIterator, { + let installations = canonicalize_installation_rows(installations.into_iter().collect()) + .map_err(map_extension_installation_error)?; let mut owners = std::collections::BTreeMap::new(); for installation in installations { let extension_id = installation.extension_id().clone(); @@ -2654,16 +2689,18 @@ mod tests { use async_trait::async_trait; use ironclaw_extensions::{ ExtensionLifecycleEvent, ExtensionLifecycleEventSink, ExtensionLifecycleService, - ExtensionManifest, ExtensionRegistry, InMemoryExtensionInstallationStore, + ExtensionManifest, ExtensionRegistry, FilesystemExtensionInstallationStore, SharedExtensionRegistry, }; - use ironclaw_filesystem::{DiskFilesystem, Fault, FaultInjecting, FilesystemOperation}; + use ironclaw_filesystem::{ + DiskFilesystem, Fault, FaultInjecting, FilesystemOperation, InMemoryBackend, + }; use ironclaw_host_api::{ AgentId, CapabilityId, ExtensionLifecycleOperation, HostPath, HostPortCatalog, InvocationId, MountAlias, MountGrant, MountPermissions, MountView, NetworkMethod, ProjectId, ResourceScope, RuntimeCredentialAccountSetup, RuntimeHttpEgress, RuntimeHttpEgressError, RuntimeHttpEgressRequest, RuntimeHttpEgressResponse, TenantId, - TrustClass, UserId, + TrustClass, UserId, VirtualPath, }; use ironclaw_host_runtime::{SPAWN_SUBAGENT_CAPABILITY_ID, builtin_first_party_package}; use ironclaw_product_workflow::{ @@ -2675,8 +2712,21 @@ mod tests { mod private_install_tests; + fn filesystem_installation_store() -> FilesystemExtensionInstallationStore { + let host_ports = + ironclaw_host_runtime::default_host_port_catalog().expect("default host port catalog"); + let contracts = product_extension_host_api_contract_registry().expect("host API contracts"); + futures::executor::block_on(FilesystemExtensionInstallationStore::load_at( + Arc::new(InMemoryBackend::new()), + VirtualPath::new("/system/extensions/.installations/test").expect("valid root"), + host_ports, + contracts, + )) + .expect("filesystem store") + } + #[tokio::test] - async fn lifecycle_owner_projections_reject_duplicate_extension_ids() { + async fn lifecycle_owner_projections_canonicalize_duplicate_extension_ids() { let (_dir, _storage_root, port, _active_registry, installation_store) = extension_management_port_fixture_with_catalog_and_service( AvailableExtensionCatalog::from_packages(vec![fixture_extension_package()]), @@ -2710,23 +2760,17 @@ mod tests { .unwrap(); } - let owners_error = port + let owners = port .installation_owners() .await - .expect_err("duplicate owner rows fail closed"); - assert!(matches!( - owners_error, - ProductWorkflowError::InvalidBindingRequest { .. } - )); + .expect("duplicate owner rows canonicalize"); + assert_eq!(owners.get(&extension_id), Some(&InstallationOwner::Tenant)); - let active_error = port + let active_capabilities = port .active_model_visible_capabilities() .await - .expect_err("duplicate active owner rows fail closed"); - assert!(matches!( - active_error, - ProductWorkflowError::InvalidBindingRequest { .. } - )); + .expect("duplicate active owner rows canonicalize"); + assert!(active_capabilities.is_empty()); } fn zip_bundle(entries: &[(&str, &[u8])]) -> Vec { @@ -3440,7 +3484,7 @@ output_schema_ref = "schemas/run.output.json" #[derive(Clone)] struct RemovalCleanupProbe { package_dir: std::path::PathBuf, - installation_store: Arc, + installation_store: Arc, extension_id: ExtensionId, installation_id: ExtensionInstallationId, } @@ -3472,7 +3516,7 @@ output_schema_ref = "schemas/run.output.json" fn set_probe( &self, storage_root: &std::path::Path, - installation_store: Arc, + installation_store: Arc, extension_id: &str, ) { *self.probe.lock().expect("cleanup probe lock") = Some(RemovalCleanupProbe { @@ -5111,7 +5155,7 @@ output_schema_ref = "schemas/run.output.json" #[tokio::test] async fn restore_removes_retired_slack_user_installation_without_catalog_entry() { - let installation_store = Arc::new(InMemoryExtensionInstallationStore::default()); + let installation_store = Arc::new(filesystem_installation_store()); let extension_id = ExtensionId::new(RETIRED_SLACK_USER_EXTENSION_ID).expect("valid extension id"); let installation_id = @@ -5426,7 +5470,7 @@ output_schema_ref = "schemas/run.output.json" ); let package = changed_available.package.clone(); let catalog = AvailableExtensionCatalog::from_packages(vec![changed_available]); - let installation_store = Arc::new(InMemoryExtensionInstallationStore::default()); + let installation_store = Arc::new(filesystem_installation_store()); let manifest_record = fixture_manifest_record_with_source( fixture_installed_local_manifest(), ManifestSource::InstalledLocal, @@ -6177,7 +6221,7 @@ output_schema_ref = "schemas/run.output.json" let port = RebornLocalExtensionManagementPort::new( Arc::new(filesystem), AvailableExtensionCatalog::from_packages(Vec::new()), - Arc::new(InMemoryExtensionInstallationStore::default()), + Arc::new(filesystem_installation_store()), Arc::new(Mutex::new(ExtensionLifecycleService::new( lifecycle_registry, ))), @@ -6580,7 +6624,7 @@ output_schema_ref = "schemas/run.output.json" std::path::PathBuf, crate::extension_host::lifecycle::RebornLocalLifecycleFacade, Arc, - Arc, + Arc, ) { extension_lifecycle_fixture_with_catalog_and_service( AvailableExtensionCatalog::from_packages(vec![fixture_extension_package()]), @@ -6595,7 +6639,7 @@ output_schema_ref = "schemas/run.output.json" std::path::PathBuf, crate::extension_host::lifecycle::RebornLocalLifecycleFacade, Arc, - Arc, + Arc, ) { extension_lifecycle_fixture_with_catalog_and_service( AvailableExtensionCatalog::from_packages(vec![fixture_extension_package()]), @@ -6608,7 +6652,7 @@ output_schema_ref = "schemas/run.output.json" std::path::PathBuf, crate::extension_host::lifecycle::RebornLocalLifecycleFacade, Arc, - Arc, + Arc, ) { extension_lifecycle_fixture_with_catalog_and_service( AvailableExtensionCatalog::from_first_party_assets() @@ -7320,7 +7364,7 @@ output_schema_ref = "schemas/run.output.json" std::path::PathBuf, Arc, Arc, - Arc, + Arc, ) { let (dir, storage_root, extension_management, active_registry, installation_store, _) = extension_management_port_fixture_with_catalog_service_and_trust( @@ -7345,7 +7389,7 @@ output_schema_ref = "schemas/run.output.json" std::path::PathBuf, Arc, Arc, - Arc, + Arc, ) { let dir = tempfile::tempdir().expect("tempdir"); let storage_root = dir.path().join("local-dev"); @@ -7366,7 +7410,7 @@ output_schema_ref = "schemas/run.output.json" .expect("mount system extensions"); let root_filesystem: Arc = Arc::new(filesystem); let active_registry = Arc::new(SharedExtensionRegistry::new(ExtensionRegistry::new())); - let installation_store = Arc::new(InMemoryExtensionInstallationStore::default()); + let installation_store = Arc::new(filesystem_installation_store()); let extension_management = Arc::new( RebornLocalExtensionManagementPort::new( root_filesystem, @@ -7413,7 +7457,7 @@ output_schema_ref = "schemas/run.output.json" .expect("mount system extensions"); let root_filesystem: Arc = Arc::new(filesystem); let active_registry = Arc::new(SharedExtensionRegistry::new(ExtensionRegistry::new())); - let installation_store = Arc::new(InMemoryExtensionInstallationStore::default()); + let installation_store = Arc::new(filesystem_installation_store()); let extension_management = Arc::new(RebornLocalExtensionManagementPort::new( root_filesystem, catalog, @@ -7530,7 +7574,7 @@ output_schema_ref = "schemas/run.output.json" async fn assert_removal_target_preserved( storage_root: &std::path::Path, - installation_store: &InMemoryExtensionInstallationStore, + installation_store: &FilesystemExtensionInstallationStore, extension_id: &str, ) { assert!( @@ -7567,7 +7611,7 @@ output_schema_ref = "schemas/run.output.json" std::path::PathBuf, Arc, Arc, - Arc, + Arc, Arc, ) { let trust_policy = test_extension_trust_policy(); @@ -7596,7 +7640,7 @@ output_schema_ref = "schemas/run.output.json" std::path::PathBuf, Arc, Arc, - Arc, + Arc, ) { let dir = tempfile::tempdir().expect("tempdir"); let storage_root = dir.path().join("local-dev"); @@ -7618,7 +7662,7 @@ output_schema_ref = "schemas/run.output.json" let filesystem = Arc::new(filesystem); let root_filesystem: Arc = filesystem.clone(); let active_registry = Arc::new(SharedExtensionRegistry::new(ExtensionRegistry::new())); - let installation_store = Arc::new(InMemoryExtensionInstallationStore::default()); + let installation_store = Arc::new(filesystem_installation_store()); let extension_management = Arc::new(RebornLocalExtensionManagementPort::new( root_filesystem, catalog, @@ -7661,7 +7705,7 @@ output_schema_ref = "schemas/run.output.json" std::path::PathBuf, Arc, Arc, - Arc, + Arc, ) { let dir = tempfile::tempdir().expect("tempdir"); let storage_root = dir.path().join("local-dev"); @@ -7683,7 +7727,7 @@ output_schema_ref = "schemas/run.output.json" let filesystem = Arc::new(filesystem); let root_filesystem: Arc = filesystem.clone(); let active_registry = Arc::new(SharedExtensionRegistry::new(ExtensionRegistry::new())); - let installation_store = Arc::new(InMemoryExtensionInstallationStore::default()); + let installation_store = Arc::new(filesystem_installation_store()); let extension_management = Arc::new( RebornLocalExtensionManagementPort::new( root_filesystem, @@ -7811,7 +7855,7 @@ output_schema_ref = "schemas/run.output.json" std::path::PathBuf, crate::extension_host::lifecycle::RebornLocalLifecycleFacade, Arc, - Arc, + Arc, ) { extension_lifecycle_fixture_with_catalog_service_and_cleanup( catalog, @@ -7829,7 +7873,7 @@ output_schema_ref = "schemas/run.output.json" std::path::PathBuf, crate::extension_host::lifecycle::RebornLocalLifecycleFacade, Arc, - Arc, + Arc, ) { extension_lifecycle_fixture_with_all_cleanup( catalog, @@ -7849,7 +7893,7 @@ output_schema_ref = "schemas/run.output.json" std::path::PathBuf, crate::extension_host::lifecycle::RebornLocalLifecycleFacade, Arc, - Arc, + Arc, ) { let dir = tempfile::tempdir().expect("tempdir"); let storage_root = dir.path().join("local-dev"); @@ -7883,7 +7927,7 @@ output_schema_ref = "schemas/run.output.json" ), ); let active_registry = Arc::new(SharedExtensionRegistry::new(ExtensionRegistry::new())); - let installation_store = Arc::new(InMemoryExtensionInstallationStore::default()); + let installation_store = Arc::new(filesystem_installation_store()); let extension_management = Arc::new( RebornLocalExtensionManagementPort::new( root_filesystem, @@ -8024,7 +8068,7 @@ output_schema_ref = "schemas/run.output.json" tempfile::TempDir, RebornLocalExtensionManagementPort, Arc, - Arc, + Arc, Arc, ) { let dir = tempfile::tempdir().expect("tempdir"); @@ -8049,7 +8093,7 @@ output_schema_ref = "schemas/run.output.json" ); let active_registry = Arc::new(SharedExtensionRegistry::new(ExtensionRegistry::new())); let trust_policy = test_extension_trust_policy(); - let installation_store = Arc::new(InMemoryExtensionInstallationStore::default()); + let installation_store = Arc::new(filesystem_installation_store()); let extension_installation_store: Arc = installation_store.clone(); let port = RebornLocalExtensionManagementPort::new( @@ -8115,9 +8159,8 @@ output_schema_ref = "schemas/run.output.json" } } - #[derive(Default)] struct DeleteInstallationFailingStore { - inner: InMemoryExtensionInstallationStore, + inner: FilesystemExtensionInstallationStore, fail_manifest_delete: bool, fail_set_activation_enabled: bool, /// Fail `set_activation_state(Enabled)` with the retryable @@ -8131,6 +8174,20 @@ output_schema_ref = "schemas/run.output.json" fail_next_upsert_installation: std::sync::atomic::AtomicBool, } + impl Default for DeleteInstallationFailingStore { + fn default() -> Self { + Self { + inner: filesystem_installation_store(), + fail_manifest_delete: false, + fail_set_activation_enabled: false, + fail_set_activation_unavailable: false, + fail_get_installation: false, + mismatched_get_installation: false, + fail_next_upsert_installation: std::sync::atomic::AtomicBool::new(false), + } + } + } + impl DeleteInstallationFailingStore { fn fail_manifest_delete() -> Self { Self { diff --git a/crates/ironclaw_reborn_composition/src/extension_host/mod.rs b/crates/ironclaw_reborn_composition/src/extension_host/mod.rs index 4cc46480de4..2b1853871cc 100644 --- a/crates/ironclaw_reborn_composition/src/extension_host/mod.rs +++ b/crates/ironclaw_reborn_composition/src/extension_host/mod.rs @@ -4,8 +4,7 @@ //! (`available_extensions`, `bundled_skills`, `gsuite`), credential requirement //! and activation plumbing (`extension_activation_credentials`, //! `extension_credential_requirements`, `webui_extension_credentials`), the -//! installation store and lifecycle command/capability stack -//! (`extension_installation_store`, `extension_lifecycle`, +//! lifecycle command/capability stack (`extension_lifecycle`, //! `extension_lifecycle_capabilities`, `extension_lifecycle_command`, //! `lifecycle`, `skill_learning`, `skill_listing`), and MCP discovery //! (`mcp`, `mcp_discovery`) behind one internal module. The crate root re-exports @@ -17,7 +16,6 @@ pub(crate) mod bundled_skills; pub(crate) mod extension_activation_credentials; pub(crate) mod extension_bundle; pub(crate) mod extension_credential_requirements; -pub(crate) mod extension_installation_store; pub(crate) mod extension_lifecycle; pub(crate) mod extension_lifecycle_capabilities; #[cfg(test)] diff --git a/crates/ironclaw_reborn_composition/src/factory.rs b/crates/ironclaw_reborn_composition/src/factory.rs index 12bf013c517..b60c05af562 100644 --- a/crates/ironclaw_reborn_composition/src/factory.rs +++ b/crates/ironclaw_reborn_composition/src/factory.rs @@ -13,6 +13,7 @@ use crate::extension_host::available_extensions::{ slack_bot_manifest_digest, slack_manifest_digest, }; use crate::extension_host::extension_removal_cleanup::SlackPersonalConnectionCleanupAdapter; +use crate::extension_host::host_api_contracts::product_extension_host_api_contract_registry; use crate::extension_host::lifecycle::{ RebornLocalLifecycleFacade, RebornLocalSkillManagementPort, build_local_skill_management_port, }; @@ -23,7 +24,6 @@ use crate::extension_host::{ google_docs_manifest_digest, google_drive_manifest_digest, google_sheets_manifest_digest, google_slides_manifest_digest, notion_mcp_manifest_digest, web_access_manifest_digest, }, - extension_installation_store::FilesystemExtensionInstallationStore, extension_lifecycle::{ ActiveExtensionPublisher, ExtensionCredentialCleanup, RebornLocalExtensionManagementPort, restore_extension_lifecycle_state, @@ -76,7 +76,7 @@ use ironclaw_conversations::{InboundTurnError, RebornFilesystemConversationServi use ironclaw_events::{DurableAuditLog, DurableEventLog}; use ironclaw_extensions::{ ExtensionInstallationStore, ExtensionLifecycleService, ExtensionRegistry, - SharedExtensionRegistry, + FilesystemExtensionInstallationStore, SharedExtensionRegistry, }; use ironclaw_filesystem::LibSqlRootFilesystem; use ironclaw_filesystem::PostgresRootFilesystem; @@ -1723,10 +1723,24 @@ async fn build_local_runtime(input: RebornBuildInput) -> Result = filesystem.clone(); + let extension_host_ports = + ironclaw_host_runtime::default_host_port_catalog().map_err(|error| { + RebornBuildError::InvalidConfig { + reason: format!("extension host port catalog could not be loaded: {error}"), + } + })?; + let extension_host_api_contracts = + product_extension_host_api_contract_registry().map_err(|error| { + RebornBuildError::InvalidConfig { + reason: format!("extension host API contracts could not be loaded: {error}"), + } + })?; let extension_installation_store: Arc = Arc::new( FilesystemExtensionInstallationStore::load_at( extension_filesystem.clone(), extension_installation_state_path, + extension_host_ports, + extension_host_api_contracts, ) .await .map_err(|error| RebornBuildError::InvalidConfig { @@ -2202,7 +2216,7 @@ fn local_dev_extension_installation_state_path( .map(|identity| identity.tenant_id.clone()) .unwrap_or(default_tenant_id); VirtualPath::new(format!( - "/tenants/{}/system/extensions/.installations/state.json", + "/tenants/{}/system/extensions/.installations", tenant_id.as_str() )) .map_err(|error| RebornBuildError::InvalidConfig { @@ -2801,34 +2815,53 @@ pub(crate) async fn open_local_dev_slack_host_state_filesystem_for_test( /// [`ExtensionInstallationStore`] at an existing local-dev `storage_root`, /// paralleling how `assert_reply_persists_after_reopen` opens a fresh libsql /// handle rather than reusing the live one. Reuses the production -/// [`local_dev_project_filesystem`] mounts and [`FilesystemExtensionInstallationStore::default_state_path`] -/// so the reopen reads the exact on-disk `/system/extensions` state the running -/// harness wrote (mirrors the production install-store load in -/// [`build_reborn_services`], above at the `extension_installation_store` binding). -/// The store's virtual state path has no identity dependency for local-dev -/// profiles, so no tenant/user context is needed. Tests only; zero bytes in -/// production builds. +/// [`build_local_runtime_root_filesystem`] mounts and +/// [`FilesystemExtensionInstallationStore::default_state_path`] so the reopen +/// reads the exact durable `/system/extensions/.installations` state the +/// running harness wrote while extension package files still live on disk +/// (mirrors the production install-store load in [`build_reborn_services`], +/// above at the `extension_installation_store` binding). The store's virtual +/// state path has no identity dependency for local-dev profiles, so no +/// tenant/user context is needed. Tests only; zero bytes in production builds. #[cfg(feature = "test-support")] pub(crate) async fn open_local_dev_extension_installation_store_for_test( storage_root: &Path, ) -> Result, RebornBuildError> { let workspace_root = storage_root.join("workspace"); - let filesystem: Arc = Arc::new(local_dev_project_filesystem( + let bundle = build_local_runtime_root_filesystem( storage_root, &workspace_root, None, - )?); + StorageBackendInput::LocalDefault, + ) + .await?; + let filesystem: Arc = bundle.filesystem; let state_path = FilesystemExtensionInstallationStore::default_state_path().map_err(|error| { RebornBuildError::InvalidConfig { reason: format!("extension installation state path invalid: {error}"), } })?; - let store = FilesystemExtensionInstallationStore::load_at(filesystem, state_path) - .await - .map_err(|error| RebornBuildError::InvalidConfig { - reason: format!("extension installation state could not be reopened: {error}"), - })?; + let host_ports = ironclaw_host_runtime::default_host_port_catalog().map_err(|error| { + RebornBuildError::InvalidConfig { + reason: format!("extension host port catalog could not be loaded: {error}"), + } + })?; + let host_api_contracts = product_extension_host_api_contract_registry().map_err(|error| { + RebornBuildError::InvalidConfig { + reason: format!("extension host API contracts could not be loaded: {error}"), + } + })?; + let store = FilesystemExtensionInstallationStore::load_at( + filesystem, + state_path, + host_ports, + host_api_contracts, + ) + .await + .map_err(|error| RebornBuildError::InvalidConfig { + reason: format!("extension installation state could not be reopened: {error}"), + })?; Ok(Arc::new(store)) } @@ -2853,7 +2886,7 @@ pub async fn extension_installation_store_for_migration( ) -> Result, RebornBuildError> { let state_path = match tenant_id { Some(tenant_id) => VirtualPath::new(format!( - "/tenants/{}/system/extensions/.installations/state.json", + "/tenants/{}/system/extensions/.installations", tenant_id.as_str() )) .map_err(|error| RebornBuildError::InvalidConfig { @@ -2865,11 +2898,26 @@ pub async fn extension_installation_store_for_migration( } })?, }; - let store = FilesystemExtensionInstallationStore::load_at(filesystem, state_path) - .await - .map_err(|error| RebornBuildError::InvalidConfig { - reason: format!("extension installation state could not be loaded: {error}"), - })?; + let host_ports = ironclaw_host_runtime::default_host_port_catalog().map_err(|error| { + RebornBuildError::InvalidConfig { + reason: format!("extension host port catalog could not be loaded: {error}"), + } + })?; + let host_api_contracts = product_extension_host_api_contract_registry().map_err(|error| { + RebornBuildError::InvalidConfig { + reason: format!("extension host API contracts could not be loaded: {error}"), + } + })?; + let store = FilesystemExtensionInstallationStore::load_at( + filesystem, + state_path, + host_ports, + host_api_contracts, + ) + .await + .map_err(|error| RebornBuildError::InvalidConfig { + reason: format!("extension installation state could not be loaded: {error}"), + })?; Ok(Arc::new(store)) } @@ -3010,6 +3058,18 @@ where )?, Arc::clone(&database), )?; + root.mount( + local_dev_mount_descriptor( + "/system/extensions/.installations", + "local-dev-extension-installation-state", + BackendKind::DatabaseFilesystem, + StorageClass::StructuredRecords, + ContentKind::SystemState, + IndexPolicy::BackendDefined, + database.capabilities(), + )?, + Arc::clone(&database), + )?; mount_local_dev_memory_root(root, Arc::clone(&database))?; root.mount( local_dev_mount_descriptor( diff --git a/crates/ironclaw_reborn_composition/src/factory/auth_tests.rs b/crates/ironclaw_reborn_composition/src/factory/auth_tests.rs index 519c3691599..6d109acc0ea 100644 --- a/crates/ironclaw_reborn_composition/src/factory/auth_tests.rs +++ b/crates/ironclaw_reborn_composition/src/factory/auth_tests.rs @@ -1166,7 +1166,7 @@ async fn create_provider_flow( expires_at: Utc::now() + Duration::minutes(5), }) .await - .expect("auth flow") + .expect("auth flow") // safety: auth_tests.rs is included only by `#[cfg(test)] mod auth_tests`. .id } @@ -1224,12 +1224,12 @@ fn authorized_provider_request( authorization_code: OAuthAuthorizationCode::new(SecretString::from( "raw-auth-code".to_string(), )) - .unwrap(), + .unwrap(), // safety: auth_tests.rs is included only by `#[cfg(test)] mod auth_tests`. authorization_code_hash: code_hash(), pkce_verifier: PkceVerifierSecret::new(SecretString::from( "raw-pkce-verifier".to_string(), )) - .unwrap(), + .unwrap(), // safety: auth_tests.rs is included only by `#[cfg(test)] mod auth_tests`. pkce_verifier_hash: pkce_hash(), scopes, }, diff --git a/crates/ironclaw_reborn_composition/src/factory/tests.rs b/crates/ironclaw_reborn_composition/src/factory/tests.rs index 9a7506c46c9..4667acbab29 100644 --- a/crates/ironclaw_reborn_composition/src/factory/tests.rs +++ b/crates/ironclaw_reborn_composition/src/factory/tests.rs @@ -123,10 +123,7 @@ fn extension_installation_state_path_stays_legacy_for_local_dev() { local_dev_extension_installation_state_path(RebornCompositionProfile::LocalDev, None) .expect("state path"); - assert_eq!( - path.as_str(), - "/system/extensions/.installations/state.json" - ); + assert_eq!(path.as_str(), "/system/extensions/.installations"); } #[test] @@ -143,7 +140,7 @@ fn extension_installation_state_path_uses_durable_tenant_root_for_hosted() { assert_eq!( path.as_str(), - "/tenants/acme/system/extensions/.installations/state.json" + "/tenants/acme/system/extensions/.installations" ); } @@ -161,7 +158,7 @@ fn extension_installation_state_path_uses_durable_tenant_root_for_hosted_volume( assert_eq!( path.as_str(), - "/tenants/acme/system/extensions/.installations/state.json" + "/tenants/acme/system/extensions/.installations" ); } diff --git a/crates/ironclaw_reborn_composition/src/webui/facade/tests.rs b/crates/ironclaw_reborn_composition/src/webui/facade/tests.rs index 223fc3ea800..8e796e763b9 100644 --- a/crates/ironclaw_reborn_composition/src/webui/facade/tests.rs +++ b/crates/ironclaw_reborn_composition/src/webui/facade/tests.rs @@ -4,9 +4,9 @@ use ironclaw_extensions::{ ExtensionActivationState, ExtensionHealthSnapshot, ExtensionInstallation, ExtensionInstallationError, ExtensionInstallationId, ExtensionInstallationStore, ExtensionManifest, ExtensionManifestRecord, ExtensionPackage, ExtensionRegistry, - InMemoryExtensionInstallationStore, ManifestSource, + FilesystemExtensionInstallationStore, ManifestSource, }; -use ironclaw_filesystem::DiskFilesystem; +use ironclaw_filesystem::{DiskFilesystem, InMemoryBackend}; use ironclaw_host_api::{ ExtensionId, HostPath, HostPortCatalog, MountAlias, MountGrant, MountPermissions, MountView, TenantId, UserId, VirtualPath, @@ -94,7 +94,7 @@ async fn operator_tool_catalog_hides_foreign_private_tools() { // Store: alice privately owns `market-data`; `hacker-news` is tenant-shared. // Wrapped so the test can inject an owner-read failure (#5525 review). - let store = Arc::new(OwnerReadFailingStore::default()); + let store = Arc::new(OwnerReadFailingStore::new().await); for (ext, capability, owner) in [ ( "market-data", @@ -213,12 +213,31 @@ async fn operator_tool_catalog_hides_foreign_private_tools() { /// Store wrapper that fails `list_installations` once when armed — /// injects the owner-read failure the settings catalog must fail closed /// on (#5525 review). -#[derive(Default)] struct OwnerReadFailingStore { - inner: InMemoryExtensionInstallationStore, + inner: FilesystemExtensionInstallationStore, fail_list_installations: std::sync::atomic::AtomicBool, } +impl OwnerReadFailingStore { + async fn new() -> Self { + Self { + inner: filesystem_installation_store().await, + fail_list_installations: std::sync::atomic::AtomicBool::new(false), + } + } +} + +async fn filesystem_installation_store() -> FilesystemExtensionInstallationStore { + FilesystemExtensionInstallationStore::load_at( + Arc::new(InMemoryBackend::new()), + VirtualPath::new("/system/extensions/.installations/test").expect("valid root"), + HostPortCatalog::empty(), + ironclaw_extensions::HostApiContractRegistry::new(), + ) + .await + .expect("filesystem store") +} + #[async_trait] impl ExtensionInstallationStore for OwnerReadFailingStore { async fn list_manifests( diff --git a/crates/ironclaw_reborn_composition/tests/admin_api_e2e.rs b/crates/ironclaw_reborn_composition/tests/admin_api_e2e.rs index 1f0aeea22e8..6df126dd40b 100644 --- a/crates/ironclaw_reborn_composition/tests/admin_api_e2e.rs +++ b/crates/ironclaw_reborn_composition/tests/admin_api_e2e.rs @@ -36,7 +36,7 @@ use ironclaw_reborn_composition::{ RebornRuntimeInput, build_reborn_runtime, build_webui_services, }; use ironclaw_webui::{ - EnvBearerAuthenticator, SessionAuthenticator, SessionStore, signed_session_store, + EnvBearerAuthenticator, SessionAuthenticator, SignedTokenSessionStore, signed_session_store, }; use ironclaw_webui::{WebuiAuthentication, WebuiAuthenticator, WebuiServeConfig, webui_v2_app}; use secrecy::{ExposeSecret, SecretString}; @@ -70,7 +70,7 @@ impl HostManagedModelGateway for NoOpGateway { // ─── token minter (mirrors production's serve-layer minter) ─────────────── struct SessionTokenMinter { - store: Arc, + store: Arc, } #[async_trait] @@ -524,7 +524,7 @@ async fn admin_last_admin_protection_over_http() { /// bearers that validate under the harness's own authenticator (the exact /// property `signed_session_store`'s doc-comment guarantees); a store built with /// a *different* secret derives a different HMAC key, so its tokens fail closed. -fn session_store_with_secret(secret: &str) -> Arc { +fn session_store_with_secret(secret: &str) -> Arc { signed_session_store( &SecretString::from(secret.to_string()), &TenantId::new(TENANT).expect("tenant"), diff --git a/crates/ironclaw_reborn_composition/tests/product_live_adapters.rs b/crates/ironclaw_reborn_composition/tests/product_live_adapters.rs index 01035044e96..0030be059f0 100644 --- a/crates/ironclaw_reborn_composition/tests/product_live_adapters.rs +++ b/crates/ironclaw_reborn_composition/tests/product_live_adapters.rs @@ -47,7 +47,7 @@ use ironclaw_runner::{ store::FilesystemAwaitEdgeStore, }, flavors::StaticSubagentDefinitionResolver, - goal_store::InMemoryBoundedSubagentGoalStore, + goal_store::{FilesystemSubagentGoalStore, in_memory_backed_subagent_goal_store}, }, }; use ironclaw_threads::{InMemorySessionThreadService, SessionThreadService, ThreadScope}; @@ -67,6 +67,10 @@ use ironclaw_turns::{ use ironclaw_loop_host::in_memory_backed_checkpoint_state_store as in_memory_checkpoint_state_store; use ironclaw_turns::test_support::in_memory_turn_state_store; +fn in_memory_subagent_goal_store() -> Arc> { + Arc::new(in_memory_backed_subagent_goal_store()) +} + async fn write_capability_result_for_test( io: &ProductLiveCapabilityIo, run_context: &LoopRunContext, @@ -1388,7 +1392,7 @@ async fn adapter_bundle_satisfies_product_live_runtime_readiness_gate() { let await_edge_store = Arc::new(FilesystemAwaitEdgeStore::new(Arc::new( ScopedFilesystem::with_fixed_view(Arc::new(InMemoryBackend::new()), await_edge_mounts), ))); - let subagent_goal_store = Arc::new(InMemoryBoundedSubagentGoalStore::new()); + let subagent_goal_store = in_memory_subagent_goal_store(); let await_edge_resolver = Arc::new(AwaitEdgeResolver::new_unbound( Arc::clone(&await_edge_store), subagent_goal_store.clone() as Arc, diff --git a/crates/ironclaw_reborn_composition/tests/webui_v2_serve.rs b/crates/ironclaw_reborn_composition/tests/webui_v2_serve.rs index 4c369068d6a..ac03557cf19 100644 --- a/crates/ironclaw_reborn_composition/tests/webui_v2_serve.rs +++ b/crates/ironclaw_reborn_composition/tests/webui_v2_serve.rs @@ -212,6 +212,7 @@ async fn health_route_is_public_for_platform_probes() { mod openai_compat_mount_tests { use super::*; + use ironclaw_filesystem::{InMemoryBackend, RootFilesystem}; use ironclaw_product_adapters::{ ProductAdapterError, ProductInboundAck, ProductInboundEnvelope, ProductProjectionReadInput, ProductProjectionSubject, ProductWorkflow, ProjectionReadRequest, RedactedString, @@ -222,7 +223,7 @@ mod openai_compat_mount_tests { }; use ironclaw_reborn_composition::ProtectedRouteMount; use ironclaw_reborn_openai_compat::{ - InMemoryOpenAiCompatRefStore, OpenAiChatCompletionProjection, + FilesystemOpenAiCompatRefStore, OpenAiChatCompletionProjection, OpenAiChatCompletionProjectionReader, OpenAiChatCompletionProjectionRequest, OpenAiChatCompletionsWorkflow, OpenAiCompatRouterState, OpenAiResponseId, OpenAiResponseObject, OpenAiResponseOutputItem, OpenAiResponseOutputItemStatus, @@ -242,12 +243,17 @@ mod openai_compat_mount_tests { const PROJECT: &str = "project-alpha"; const THREAD: &str = "thread-openai-chat"; + fn in_memory_openai_compat_ref_store() -> Arc { + let filesystem: Arc = Arc::new(InMemoryBackend::new()); + Arc::new(FilesystemOpenAiCompatRefStore::new(filesystem)) + } + #[tokio::test] async fn openai_chat_completions_mount_uses_webui_auth_and_product_workflow() { let workflow = Arc::new(GatewayOpenAiWorkflow::default()); let chat = Arc::new(OpenAiChatCompletionsWorkflow::new( workflow.clone(), - Arc::new(InMemoryOpenAiCompatRefStore::new()), + in_memory_openai_compat_ref_store(), Arc::new(StaticChatProjectionReader::text( "hello through composition", )), @@ -317,7 +323,7 @@ mod openai_compat_mount_tests { let chat = Arc::new( OpenAiChatCompletionsWorkflow::new( workflow, - Arc::new(InMemoryOpenAiCompatRefStore::new()), + in_memory_openai_compat_ref_store(), Arc::new(NeverCompletingChatProjectionReader), ) .with_wait_timeout(Duration::from_millis(1)), @@ -428,7 +434,7 @@ mod openai_compat_mount_tests { let workflow = Arc::new(GatewayOpenAiWorkflow::default()); let responses = Arc::new(OpenAiResponsesWorkflow::new( workflow.clone(), - Arc::new(InMemoryOpenAiCompatRefStore::new()), + in_memory_openai_compat_ref_store(), Arc::new(StaticResponsesProjectionReader::text( "hello through responses", )), diff --git a/crates/ironclaw_reborn_migration/src/extension_ownership.rs b/crates/ironclaw_reborn_migration/src/extension_ownership.rs index 68625082398..f43165e40a5 100644 --- a/crates/ironclaw_reborn_migration/src/extension_ownership.rs +++ b/crates/ironclaw_reborn_migration/src/extension_ownership.rs @@ -146,19 +146,18 @@ fn rewrite_installation_owners( #[cfg(test)] mod tests { - use std::collections::BTreeSet; - - use std::{path::Path, sync::Arc}; + use std::{collections::BTreeSet, path::Path, sync::Arc}; use chrono::Utc; use ironclaw_extensions::{ ExtensionActivationState, ExtensionInstallation, ExtensionInstallationId, ExtensionInstallationStore, ExtensionManifestRecord, ExtensionManifestRef, - InMemoryExtensionInstallationStore, InstallationOwner, ManifestSource, + FilesystemExtensionInstallationStore, InstallationOwner, ManifestSource, }; + use ironclaw_filesystem::InMemoryBackend; use ironclaw_filesystem::{LibSqlRootFilesystem, RootFilesystem, ScopedFilesystem}; use ironclaw_host_api::AgentId; - use ironclaw_host_api::{ExtensionId, HostPortCatalog, TenantId, UserId}; + use ironclaw_host_api::{ExtensionId, HostPortCatalog, TenantId, UserId, VirtualPath}; use ironclaw_reborn_identity::{ FilesystemRebornIdentityStore, RebornUserDirectory, RebornUserRole, }; @@ -213,6 +212,17 @@ output_schema_ref = "schemas/read.output.json" .expect("manifest") } + async fn filesystem_store() -> FilesystemExtensionInstallationStore { + FilesystemExtensionInstallationStore::load_at( + Arc::new(InMemoryBackend::new()), + VirtualPath::new("/system/extensions/.installations/test").expect("valid root"), + HostPortCatalog::empty(), + ironclaw_extensions::HostApiContractRegistry::new(), + ) + .await + .expect("filesystem store") + } + #[test] fn rewrites_every_installation_to_the_complete_user_set() { let alice = UserId::new("alice").expect("alice"); @@ -238,7 +248,7 @@ output_schema_ref = "schemas/read.output.json" #[tokio::test] async fn dry_run_apply_and_rerun_are_safe_and_idempotent() { - let store = InMemoryExtensionInstallationStore::default(); + let store = filesystem_store().await; let alice = UserId::new("alice").expect("alice"); let bob = UserId::new("bob").expect("bob"); let bootstrap = UserId::new("bootstrap-operator").expect("bootstrap"); diff --git a/crates/ironclaw_reborn_migration/tests/migration_roundtrip.rs b/crates/ironclaw_reborn_migration/tests/migration_roundtrip.rs index 36bf9ad045e..bb56c7d999f 100644 --- a/crates/ironclaw_reborn_migration/tests/migration_roundtrip.rs +++ b/crates/ironclaw_reborn_migration/tests/migration_roundtrip.rs @@ -820,18 +820,9 @@ async fn migrates_v1_and_engine_v2_state_without_loss() { // carry the canonical id, both private owners, fail-closed Disabled // activation, and the merged credential binding. let installation_doc = - reborn_entry_content(&dst, "%/system/extensions/.installations/state.json").await; - let installation_state: serde_json::Value = - serde_json::from_str(&installation_doc).expect("installation state JSON"); - let installations = installation_state["installations"] - .as_array() - .expect("installation array"); - assert_eq!( - installations.len(), - 1, - "same-named source installs must canonicalize to one row" - ); - let installation = &installations[0]; + reborn_entry_content(&dst, "%/system/extensions/.installations/installations/%").await; + let installation: serde_json::Value = + serde_json::from_str(&installation_doc).expect("installation row JSON"); assert_eq!(installation["installation_id"], "weather"); assert_eq!(installation["extension_id"], "weather"); assert_eq!(installation["activation_state"], "disabled"); @@ -856,8 +847,8 @@ async fn migrates_v1_and_engine_v2_state_without_loss() { "expected the migrated secret document on disk" ); assert!( - reborn_entry_count(&dst, "%/system/extensions/.installations/state.json").await >= 1, - "expected the extension installation state document on disk" + reborn_entry_count(&dst, "%/system/extensions/.installations/installations/%").await >= 1, + "expected the extension installation row on disk" ); // ── round-trip through the Reborn triggers repo ── @@ -911,12 +902,12 @@ async fn migrates_v1_and_engine_v2_state_without_loss() { "re-run must upsert the same installation, not duplicate" ); assert_eq!( - reborn_entry_count(&dst, "%/system/extensions/.installations/state.json").await, + reborn_entry_count(&dst, "%/system/extensions/.installations/installations/%").await, 1, - "re-run must not write a second installation state document" + "re-run must not write a second installation row" ); assert_eq!( - reborn_entry_content(&dst, "%/system/extensions/.installations/state.json").await, + reborn_entry_content(&dst, "%/system/extensions/.installations/installations/%").await, installation_doc, "re-run must preserve deterministic canonical extension state" ); @@ -938,14 +929,12 @@ async fn migrates_all_active_duplicate_users_as_enabled() { assert_eq!(report.stats.extensions, 1); let installation_doc = - reborn_entry_content(&dst, "%/system/extensions/.installations/state.json").await; - let state: serde_json::Value = serde_json::from_str(&installation_doc).unwrap(); - let installations = state["installations"].as_array().unwrap(); - assert_eq!(installations.len(), 1); - assert_eq!(installations[0]["activation_state"], "enabled"); - assert_eq!(installations[0]["owner"]["kind"], "users"); + reborn_entry_content(&dst, "%/system/extensions/.installations/installations/%").await; + let installation: serde_json::Value = serde_json::from_str(&installation_doc).unwrap(); + assert_eq!(installation["activation_state"], "enabled"); + assert_eq!(installation["owner"]["kind"], "users"); assert_eq!( - installations[0]["owner"]["user_ids"], + installation["owner"]["user_ids"], serde_json::json!([USER, USER_BOB]) ); } @@ -1074,7 +1063,7 @@ async fn migration_records_metadata_conflict_without_partial_extension() { && loss.detail.contains("no partial canonical installation") })); assert_eq!( - reborn_entry_count(&dst, "%/system/extensions/.installations/state.json").await, + reborn_entry_count(&dst, "%/system/extensions/.installations/installations/%").await, 0, "metadata conflict must not write a partial canonical installation" ); @@ -1099,11 +1088,9 @@ async fn migration_merges_distinct_credential_bindings() { assert_eq!(report.stats.extensions, 1); assert_eq!(report.losses_in(Domain::Extension), 4); let installation_doc = - reborn_entry_content(&dst, "%/system/extensions/.installations/state.json").await; - let state: serde_json::Value = serde_json::from_str(&installation_doc).unwrap(); - let bindings = state["installations"][0]["credential_bindings"] - .as_array() - .unwrap(); + reborn_entry_content(&dst, "%/system/extensions/.installations/installations/%").await; + let installation: serde_json::Value = serde_json::from_str(&installation_doc).unwrap(); + let bindings = installation["credential_bindings"].as_array().unwrap(); assert_eq!(bindings.len(), 2); assert!(bindings.iter().any(|binding| { binding["credential_handle"] == "openai_api_key" @@ -1203,7 +1190,7 @@ async fn migration_reads_older_optional_tables_without_wit_version() { "the migrated memory document must be persisted on disk, not just counted" ); assert!( - reborn_entry_count(&dst, "%/system/extensions/.installations/state.json").await >= 1, + reborn_entry_count(&dst, "%/system/extensions/.installations/installations/%").await >= 1, "the installed tool must be persisted as an extension installation on disk" ); } diff --git a/crates/ironclaw_reborn_openai_compat/src/lib.rs b/crates/ironclaw_reborn_openai_compat/src/lib.rs index eddeb89bf81..a1e8cdc017f 100644 --- a/crates/ironclaw_reborn_openai_compat/src/lib.rs +++ b/crates/ironclaw_reborn_openai_compat/src/lib.rs @@ -76,15 +76,15 @@ pub use identity::{ pub use models::{OpenAiModelListResponse, OpenAiModelObject}; pub use models_catalog::{OpenAiCompatModelCatalog, OpenAiCompatModelEntry}; pub use refs::{ - InMemoryOpenAiCompatRefStore, OpenAiChatCompletionId, OpenAiCompatActorScope, - OpenAiCompatBindInternalRefs, OpenAiCompatIdempotencyConflict, OpenAiCompatIdempotencyKey, - OpenAiCompatInternalRefs, OpenAiCompatMarkExternalToolResumeCompleted, - OpenAiCompatProductActionRef, OpenAiCompatProjectionRef, OpenAiCompatPublicId, - OpenAiCompatRecordAcceptedAck, OpenAiCompatRefError, OpenAiCompatRefLookup, - OpenAiCompatRefOperation, OpenAiCompatRefReservation, OpenAiCompatRefReservationOutcome, - OpenAiCompatRefStore, OpenAiCompatRequestFingerprint, OpenAiCompatResourceBinding, - OpenAiCompatResourceKind, OpenAiCompatResourceMapping, OpenAiCompatRouteSurface, - OpenAiCompatTurnRunRef, OpenAiResponseId, unix_timestamp_now, + OpenAiChatCompletionId, OpenAiCompatActorScope, OpenAiCompatBindInternalRefs, + OpenAiCompatIdempotencyConflict, OpenAiCompatIdempotencyKey, OpenAiCompatInternalRefs, + OpenAiCompatMarkExternalToolResumeCompleted, OpenAiCompatProductActionRef, + OpenAiCompatProjectionRef, OpenAiCompatPublicId, OpenAiCompatRecordAcceptedAck, + OpenAiCompatRefError, OpenAiCompatRefLookup, OpenAiCompatRefOperation, + OpenAiCompatRefReservation, OpenAiCompatRefReservationOutcome, OpenAiCompatRefStore, + OpenAiCompatRequestFingerprint, OpenAiCompatResourceBinding, OpenAiCompatResourceKind, + OpenAiCompatResourceMapping, OpenAiCompatRouteSurface, OpenAiCompatTurnRunRef, + OpenAiResponseId, unix_timestamp_now, }; pub use refs_storage::FilesystemOpenAiCompatRefStore; pub use refs_storage::RebornLibSqlOpenAiCompatRefStore; diff --git a/crates/ironclaw_reborn_openai_compat/src/refs.rs b/crates/ironclaw_reborn_openai_compat/src/refs.rs index 11efd258284..eea68e0156a 100644 --- a/crates/ironclaw_reborn_openai_compat/src/refs.rs +++ b/crates/ironclaw_reborn_openai_compat/src/refs.rs @@ -1,8 +1,3 @@ -use std::collections::HashMap; -use std::sync::Arc; - -use tokio::sync::{Mutex, MutexGuard}; - use async_trait::async_trait; use chrono::Utc; use ironclaw_host_api::{AgentId, ProjectId, TenantId, UserId}; @@ -17,7 +12,6 @@ const RESPONSE_PREFIX: &str = "resp_"; const MAX_PUBLIC_REF_BYTES: usize = 96; const MAX_INTERNAL_REF_BYTES: usize = 256; const MAX_IDEMPOTENCY_KEY_BYTES: usize = 256; -const DEFAULT_IN_MEMORY_REF_CAPACITY: usize = 4_096; #[derive(Debug, Clone, PartialEq, Eq, Error)] pub enum OpenAiCompatRefError { @@ -548,178 +542,6 @@ pub trait OpenAiCompatRefStore: Send + Sync { ) -> Result, OpenAiCompatRefError>; } -#[derive(Clone)] -pub struct InMemoryOpenAiCompatRefStore { - state: Arc>, - max_mappings: usize, -} - -#[derive(Default)] -struct InMemoryOpenAiCompatRefState { - by_public_id: HashMap, - by_idempotency: HashMap, -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -struct IdempotencyIndexKey { - owner: OpenAiCompatActorScope, - surface: OpenAiCompatRouteSurface, - key: OpenAiCompatIdempotencyKey, -} - -impl Default for InMemoryOpenAiCompatRefStore { - fn default() -> Self { - Self::new() - } -} - -impl InMemoryOpenAiCompatRefStore { - pub fn new() -> Self { - Self::with_capacity(DEFAULT_IN_MEMORY_REF_CAPACITY) - } - - pub fn with_capacity(max_mappings: usize) -> Self { - Self { - state: Arc::new(Mutex::new(InMemoryOpenAiCompatRefState::default())), - max_mappings: max_mappings.max(1), - } - } - - async fn lock_state(&self) -> MutexGuard<'_, InMemoryOpenAiCompatRefState> { - self.state.lock().await - } -} - -#[async_trait] -impl OpenAiCompatRefStore for InMemoryOpenAiCompatRefStore { - async fn reserve( - &self, - request: OpenAiCompatRefReservation, - ) -> Result { - let mut state = self.lock_state().await; - evict_oldest_if_needed(&mut state, self.max_mappings); - if let Some(key) = request.idempotency_key.clone() { - let index = IdempotencyIndexKey { - owner: request.owner.clone(), - surface: request.surface, - key, - }; - if let Some(public_id) = state.by_idempotency.get(&index) { - let mapping = state - .by_public_id - .get(public_id) - .cloned() - .ok_or(OpenAiCompatRefError::CorruptMapping)?; - mapping.validate()?; - if mapping.request_fingerprint == request.request_fingerprint { - return Ok(OpenAiCompatRefReservationOutcome::Replayed(mapping)); - } - return Ok(OpenAiCompatRefReservationOutcome::Conflict( - OpenAiCompatIdempotencyConflict { - surface: request.surface, - }, - )); - } - - let mapping = new_pending_mapping(request); - state - .by_idempotency - .insert(index, mapping.public_id.clone()); - state - .by_public_id - .insert(mapping.public_id.clone(), mapping.clone()); - return Ok(OpenAiCompatRefReservationOutcome::Created(mapping)); - } - - let mapping = new_pending_mapping(request); - state - .by_public_id - .insert(mapping.public_id.clone(), mapping.clone()); - Ok(OpenAiCompatRefReservationOutcome::Created(mapping)) - } - - async fn bind_internal_refs( - &self, - request: OpenAiCompatBindInternalRefs, - ) -> Result, OpenAiCompatRefError> { - let mut state = self.lock_state().await; - let Some(mapping) = state.by_public_id.get_mut(&request.public_id) else { - return Ok(None); - }; - mapping.validate()?; - if !mapping.is_authorized_for(&request.owner) { - return Ok(None); - } - mapping.binding = OpenAiCompatResourceBinding::Bound { - internal_refs: request.internal_refs, - }; - Ok(Some(mapping.clone())) - } - - async fn record_accepted_ack( - &self, - request: OpenAiCompatRecordAcceptedAck, - ) -> Result, OpenAiCompatRefError> { - let mut state = self.lock_state().await; - let Some(mapping) = state.by_public_id.get_mut(&request.public_id) else { - return Ok(None); - }; - mapping.validate()?; - if !mapping.is_authorized_for(&request.owner) { - return Ok(None); - } - mapping.accepted_ack = Some(request.accepted_ack); - Ok(Some(mapping.clone())) - } - - async fn mark_external_tool_resume_completed( - &self, - request: OpenAiCompatMarkExternalToolResumeCompleted, - ) -> Result, OpenAiCompatRefError> { - let mut state = self.lock_state().await; - let Some(mapping) = state.by_public_id.get_mut(&request.public_id) else { - return Ok(None); - }; - mapping.validate()?; - if !mapping.is_authorized_for(&request.owner) { - return Ok(None); - } - mapping.external_tool_resume_completed = true; - Ok(Some(mapping.clone())) - } - - async fn lookup_authorized( - &self, - request: OpenAiCompatRefLookup, - ) -> Result, OpenAiCompatRefError> { - let state = self.lock_state().await; - let Some(mapping) = state.by_public_id.get(&request.public_id) else { - return Ok(None); - }; - mapping.validate()?; - if !mapping.is_authorized_for(&request.requester) { - return Ok(None); - } - Ok(Some(mapping.clone())) - } -} - -fn new_pending_mapping(request: OpenAiCompatRefReservation) -> OpenAiCompatResourceMapping { - let mapping = OpenAiCompatResourceMapping { - public_id: OpenAiCompatPublicId::generate_for(request.surface), - owner: request.owner, - surface: request.surface, - request_fingerprint: request.request_fingerprint, - created_at: unix_timestamp_now(), - idempotency_key: request.idempotency_key, - accepted_ack: None, - external_tool_resume_completed: false, - binding: OpenAiCompatResourceBinding::Pending, - }; - debug_assert!(mapping.validate().is_ok()); - mapping -} - pub fn unix_timestamp_now() -> u64 { Utc::now().timestamp().try_into().unwrap_or(0) } @@ -728,24 +550,6 @@ fn is_false(value: &bool) -> bool { !*value } -fn evict_oldest_if_needed(state: &mut InMemoryOpenAiCompatRefState, max_mappings: usize) { - if state.by_public_id.len() < max_mappings { - return; - } - let Some(public_id) = state - .by_public_id - .iter() - .min_by_key(|(_, mapping)| mapping.created_at) - .map(|(public_id, _)| public_id.clone()) - else { - return; - }; - state.by_public_id.remove(&public_id); - state - .by_idempotency - .retain(|_, mapped_public_id| mapped_public_id != &public_id); -} - fn validate_public_ref( kind: &'static str, value: &str, @@ -860,153 +664,3 @@ fn contains_no_exposure_sentinel(value: &str) -> bool { .iter() .any(|sentinel| value.contains(sentinel)) } - -#[cfg(test)] -mod tests { - use super::*; - use ironclaw_turns::{AcceptedMessageRef, TurnRunId}; - - fn scope(user: &str) -> OpenAiCompatActorScope { - OpenAiCompatActorScope::new( - TenantId::new("tenant-a").expect("tenant"), - UserId::new(user).expect("user"), - None, - None, - ) - } - - fn fingerprint(label: &str) -> OpenAiCompatRequestFingerprint { - OpenAiCompatRequestFingerprint::from_body_bytes(label.as_bytes()) - } - - fn idempotency_key() -> OpenAiCompatIdempotencyKey { - OpenAiCompatIdempotencyKey::new("same-key").expect("key") - } - - fn accepted_ack() -> ProductInboundAck { - ProductInboundAck::Accepted { - accepted_message_ref: AcceptedMessageRef::new("msg:test").expect("message ref"), - submitted_run_id: TurnRunId::new(), - } - } - - #[tokio::test] - async fn in_memory_store_record_accepted_ack_wrong_owner_is_none() { - let store = InMemoryOpenAiCompatRefStore::new(); - let alice = scope("alice"); - let bob = scope("bob"); - let created = store - .reserve(OpenAiCompatRefReservation::new( - alice.clone(), - OpenAiCompatRouteSurface::ChatCompletions, - fingerprint("body"), - Some(idempotency_key()), - )) - .await - .expect("reserve"); - let mapping = created.mapping().expect("created mapping").clone(); - - let wrong_owner = store - .record_accepted_ack(OpenAiCompatRecordAcceptedAck::new( - bob, - mapping.public_id.clone(), - accepted_ack(), - )) - .await - .expect("record ack"); - assert!(wrong_owner.is_none()); - - let alice_lookup = store - .lookup_authorized(OpenAiCompatRefLookup::new( - alice, - mapping.public_id, - OpenAiCompatRefOperation::Retrieve, - )) - .await - .expect("lookup") - .expect("alice mapping"); - assert!(alice_lookup.accepted_ack.is_none()); - } - - #[tokio::test] - async fn in_memory_store_same_key_distinct_actors_create_distinct_refs() { - let store = InMemoryOpenAiCompatRefStore::new(); - let first = store - .reserve(OpenAiCompatRefReservation::new( - scope("alice"), - OpenAiCompatRouteSurface::ChatCompletions, - fingerprint("body"), - Some(idempotency_key()), - )) - .await - .expect("first reserve"); - let second = store - .reserve(OpenAiCompatRefReservation::new( - scope("bob"), - OpenAiCompatRouteSurface::ChatCompletions, - fingerprint("body"), - Some(idempotency_key()), - )) - .await - .expect("second reserve"); - - let first_id = first.mapping().expect("first mapping").public_id.clone(); - let second_id = second.mapping().expect("second mapping").public_id.clone(); - assert_ne!(first_id, second_id); - } - - #[tokio::test] - async fn in_memory_store_evicts_oldest_mapping_when_capacity_is_reached() { - let store = InMemoryOpenAiCompatRefStore::with_capacity(1); - let owner = scope("alice"); - let first = store - .reserve(OpenAiCompatRefReservation::new( - owner.clone(), - OpenAiCompatRouteSurface::ChatCompletions, - fingerprint("one"), - Some(OpenAiCompatIdempotencyKey::new("key-one").expect("key")), - )) - .await - .expect("first reserve") - .mapping() - .expect("first mapping") - .public_id - .clone(); - let second = store - .reserve(OpenAiCompatRefReservation::new( - owner.clone(), - OpenAiCompatRouteSurface::ChatCompletions, - fingerprint("two"), - Some(OpenAiCompatIdempotencyKey::new("key-two").expect("key")), - )) - .await - .expect("second reserve") - .mapping() - .expect("second mapping") - .public_id - .clone(); - - assert!( - store - .lookup_authorized(OpenAiCompatRefLookup::new( - owner.clone(), - first, - OpenAiCompatRefOperation::Retrieve, - )) - .await - .expect("lookup first") - .is_none() - ); - assert!( - store - .lookup_authorized(OpenAiCompatRefLookup::new( - owner, - second, - OpenAiCompatRefOperation::Retrieve, - )) - .await - .expect("lookup second") - .is_some() - ); - } -} diff --git a/crates/ironclaw_reborn_openai_compat/tests/chat_workflow_handlers_contract.rs b/crates/ironclaw_reborn_openai_compat/tests/chat_workflow_handlers_contract.rs index ea563e2831b..50b434545a6 100644 --- a/crates/ironclaw_reborn_openai_compat/tests/chat_workflow_handlers_contract.rs +++ b/crates/ironclaw_reborn_openai_compat/tests/chat_workflow_handlers_contract.rs @@ -2,6 +2,8 @@ use std::sync::Arc; use std::sync::Mutex; use std::time::Duration; +mod support; + use async_trait::async_trait; use axum::body::Body; use http::Request; @@ -14,10 +16,9 @@ use ironclaw_product_adapters::{ ProjectionReadRequest, ProtocolAuthEvidence, ProtocolAuthFailure, RedactedString, }; use ironclaw_reborn_openai_compat::{ - InMemoryOpenAiCompatRefStore, OpenAiChatCompletionProjection, - OpenAiChatCompletionProjectionReader, OpenAiChatCompletionProjectionRequest, - OpenAiChatCompletionsWorkflow, OpenAiChatFinishReason, OpenAiChatToolCall, - OpenAiChatToolCallFunction, OpenAiChatToolKind, OpenAiCompatActorScope, + OpenAiChatCompletionProjection, OpenAiChatCompletionProjectionReader, + OpenAiChatCompletionProjectionRequest, OpenAiChatCompletionsWorkflow, OpenAiChatFinishReason, + OpenAiChatToolCall, OpenAiChatToolCallFunction, OpenAiChatToolKind, OpenAiCompatActorScope, OpenAiCompatAuthenticatedCaller, OpenAiCompatErrorKind, OpenAiCompatHttpError, OpenAiCompatIdempotencyKey, OpenAiCompatInternalRefs, OpenAiCompatProductActionRef, OpenAiCompatProjectionRef, OpenAiCompatRefLookup, OpenAiCompatRefOperation, @@ -27,6 +28,7 @@ use ironclaw_reborn_openai_compat::{ }; use ironclaw_turns::{AcceptedMessageRef, TurnActor, TurnRunId, TurnScope}; use serde_json::{Value, json}; +use support::in_memory_openai_compat_ref_store; use tower::ServiceExt; #[tokio::test] @@ -181,7 +183,7 @@ async fn chat_completion_rejects_oversized_raw_body_before_fingerprint_or_workfl let workflow = Arc::new(FakeProductWorkflow::new()); let service = OpenAiChatCompletionsWorkflow::new( workflow.clone(), - Arc::new(InMemoryOpenAiCompatRefStore::new()), + in_memory_openai_compat_ref_store(), Arc::new(StaticChatProjectionReader::text("unused")), ); let oversized = "x".repeat(4 * 1024 * 1024 + 1); @@ -420,7 +422,7 @@ async fn chat_completion_idempotency_retries_after_busy_without_500() { #[tokio::test] async fn chat_completion_replayed_pending_ref_submits_and_records_accepted_ack() { let workflow = Arc::new(FixedAckWorkflow::new(accepted_ack())); - let ref_store = Arc::new(InMemoryOpenAiCompatRefStore::new()); + let ref_store = in_memory_openai_compat_ref_store(); let service = OpenAiChatCompletionsWorkflow::new( workflow.clone(), ref_store.clone(), @@ -837,7 +839,7 @@ async fn wired_chat_completion_requires_authenticated_caller_before_product_work let workflow = Arc::new(FakeProductWorkflow::new()); let service = OpenAiChatCompletionsWorkflow::new( workflow.clone(), - Arc::new(InMemoryOpenAiCompatRefStore::new()), + in_memory_openai_compat_ref_store(), Arc::new(StaticChatProjectionReader::text("unused")), ); let router = openai_compat_router_with_state(OpenAiCompatRouterState::with_chat_completions( @@ -945,7 +947,7 @@ async fn chat_completion_wait_timeout_returns_retryable_error_without_resubmitti workflow.program_projection_read_resolution(sample_projection_read_request()); let service = OpenAiChatCompletionsWorkflow::new( workflow.clone(), - Arc::new(InMemoryOpenAiCompatRefStore::new()), + in_memory_openai_compat_ref_store(), Arc::new(NeverChatProjectionReader), ) .with_wait_timeout(Duration::from_millis(1)); @@ -977,7 +979,7 @@ async fn projection_reader_is_not_called_when_product_workflow_read_resolution_f )); let service = OpenAiChatCompletionsWorkflow::new( workflow.clone(), - Arc::new(InMemoryOpenAiCompatRefStore::new()), + in_memory_openai_compat_ref_store(), projection_reader.clone(), ); let router = openai_compat_router_with_state(OpenAiCompatRouterState::with_chat_completions( @@ -1202,7 +1204,7 @@ fn test_router_with_workflow( ) -> axum::Router { let service = OpenAiChatCompletionsWorkflow::new( workflow, - Arc::new(InMemoryOpenAiCompatRefStore::new()), + in_memory_openai_compat_ref_store(), projection_reader, ); openai_compat_router_with_state(OpenAiCompatRouterState::with_chat_completions(Arc::new( diff --git a/crates/ironclaw_reborn_openai_compat/tests/refs_contract.rs b/crates/ironclaw_reborn_openai_compat/tests/refs_contract.rs index 414316f95c3..e1c2c0bb0b6 100644 --- a/crates/ironclaw_reborn_openai_compat/tests/refs_contract.rs +++ b/crates/ironclaw_reborn_openai_compat/tests/refs_contract.rs @@ -1,15 +1,19 @@ use ironclaw_host_api::{AgentId, ProjectId, TenantId, UserId}; use ironclaw_reborn_openai_compat::{ - InMemoryOpenAiCompatRefStore, OpenAiChatCompletionId, OpenAiCompatActorScope, - OpenAiCompatBindInternalRefs, OpenAiCompatHttpError, OpenAiCompatIdempotencyKey, - OpenAiCompatInternalRefs, OpenAiCompatProductActionRef, OpenAiCompatProjectionRef, - OpenAiCompatPublicId, OpenAiCompatRefLookup, OpenAiCompatRefOperation, - OpenAiCompatRefReservation, OpenAiCompatRefReservationOutcome, OpenAiCompatRefStore, - OpenAiCompatRequestFingerprint, OpenAiCompatResourceBinding, OpenAiCompatRouteSurface, - OpenAiCompatTurnRunRef, OpenAiResponseId, + OpenAiChatCompletionId, OpenAiCompatActorScope, OpenAiCompatBindInternalRefs, + OpenAiCompatHttpError, OpenAiCompatIdempotencyKey, OpenAiCompatInternalRefs, + OpenAiCompatProductActionRef, OpenAiCompatProjectionRef, OpenAiCompatPublicId, + OpenAiCompatRefLookup, OpenAiCompatRefOperation, OpenAiCompatRefReservation, + OpenAiCompatRefReservationOutcome, OpenAiCompatRefStore, OpenAiCompatRequestFingerprint, + OpenAiCompatResourceBinding, OpenAiCompatRouteSurface, OpenAiCompatTurnRunRef, + OpenAiResponseId, }; use serde_json::json; +mod support; + +use support::in_memory_openai_compat_ref_store; + #[test] fn public_refs_are_typed_opaque_and_serde_validated() { let chat = OpenAiChatCompletionId::new("chatcmpl-public_1").expect("chat ref"); @@ -120,7 +124,7 @@ fn ref_mapping_deserialization_revalidates_nested_refs() { #[tokio::test] async fn idempotency_key_replays_same_fingerprint_and_conflicts_on_different_body() { - let store = InMemoryOpenAiCompatRefStore::new(); + let store = in_memory_openai_compat_ref_store(); let owner = actor_scope("alice"); let key = OpenAiCompatIdempotencyKey::new("client-key-1").expect("key"); let first_fingerprint = OpenAiCompatRequestFingerprint::from_body_bytes(br#"{"input":"a"}"#); @@ -178,12 +182,12 @@ async fn idempotency_key_replays_same_fingerprint_and_conflicts_on_different_bod #[tokio::test] async fn absent_idempotency_key_creates_new_public_ref_each_time() { - let store = InMemoryOpenAiCompatRefStore::new(); + let store = in_memory_openai_compat_ref_store(); let owner = actor_scope("alice"); let fingerprint = OpenAiCompatRequestFingerprint::from_body_bytes(br#"{"input":"a"}"#); let first = reserve_created( - &store, + store.as_ref(), owner.clone(), OpenAiCompatRouteSurface::ChatCompletions, fingerprint.clone(), @@ -191,7 +195,7 @@ async fn absent_idempotency_key_creates_new_public_ref_each_time() { ) .await; let second = reserve_created( - &store, + store.as_ref(), owner, OpenAiCompatRouteSurface::ChatCompletions, fingerprint, @@ -209,11 +213,11 @@ async fn absent_idempotency_key_creates_new_public_ref_each_time() { #[tokio::test] async fn lookup_and_cancel_authorization_do_not_leak_cross_actor_existence() { - let store = InMemoryOpenAiCompatRefStore::new(); + let store = in_memory_openai_compat_ref_store(); let alice = actor_scope("alice"); let bob = actor_scope("bob"); let mapping = reserve_created( - &store, + store.as_ref(), alice.clone(), OpenAiCompatRouteSurface::ResponsesApi, OpenAiCompatRequestFingerprint::from_body_bytes(br#"{"input":"a"}"#), @@ -283,10 +287,10 @@ async fn lookup_and_cancel_authorization_do_not_leak_cross_actor_existence() { #[tokio::test] async fn generated_public_ref_does_not_embed_internal_refs() { - let store = InMemoryOpenAiCompatRefStore::new(); + let store = in_memory_openai_compat_ref_store(); let owner = actor_scope("alice"); let mapping = reserve_created( - &store, + store.as_ref(), owner.clone(), OpenAiCompatRouteSurface::ResponsesV1, OpenAiCompatRequestFingerprint::from_body_bytes(br#"{"input":"a"}"#), @@ -311,7 +315,7 @@ async fn generated_public_ref_does_not_embed_internal_refs() { } async fn reserve_created( - store: &InMemoryOpenAiCompatRefStore, + store: &dyn OpenAiCompatRefStore, owner: OpenAiCompatActorScope, surface: OpenAiCompatRouteSurface, request_fingerprint: OpenAiCompatRequestFingerprint, diff --git a/crates/ironclaw_reborn_openai_compat/tests/responses_path_prefix_contract.rs b/crates/ironclaw_reborn_openai_compat/tests/responses_path_prefix_contract.rs index eb9e3c5c6cf..c423c5a010e 100644 --- a/crates/ironclaw_reborn_openai_compat/tests/responses_path_prefix_contract.rs +++ b/crates/ironclaw_reborn_openai_compat/tests/responses_path_prefix_contract.rs @@ -9,6 +9,8 @@ use std::sync::Arc; +mod support; + use async_trait::async_trait; use axum::body::Body; use axum::http::{Method, Request, StatusCode, header}; @@ -17,16 +19,17 @@ use http_body_util::BodyExt; use ironclaw_host_api::{AgentId, ProjectId, TenantId, UserId}; use ironclaw_product_adapters::{AuthRequirement, FakeProductWorkflow, ProtocolAuthEvidence}; use ironclaw_reborn_openai_compat::{ - InMemoryOpenAiCompatRefStore, OpenAiCompatActorScope, OpenAiCompatAuthenticatedCaller, - OpenAiCompatInternalRefs, OpenAiCompatProductActionRef, OpenAiCompatProjectionRef, - OpenAiCompatRouterState, OpenAiCompatTurnRunRef, OpenAiResponseId, OpenAiResponseObject, - OpenAiResponseOutputItem, OpenAiResponseOutputItemStatus, OpenAiResponseProjection, - OpenAiResponseReadRequest, OpenAiResponseStatus, OpenAiResponseUsage, - OpenAiResponseWaitRequest, OpenAiResponsesMessageRole, OpenAiResponsesProjectionReader, - OpenAiResponsesWorkflow, openai_compat_router_with_state, + OpenAiCompatActorScope, OpenAiCompatAuthenticatedCaller, OpenAiCompatInternalRefs, + OpenAiCompatProductActionRef, OpenAiCompatProjectionRef, OpenAiCompatRouterState, + OpenAiCompatTurnRunRef, OpenAiResponseId, OpenAiResponseObject, OpenAiResponseOutputItem, + OpenAiResponseOutputItemStatus, OpenAiResponseProjection, OpenAiResponseReadRequest, + OpenAiResponseStatus, OpenAiResponseUsage, OpenAiResponseWaitRequest, + OpenAiResponsesMessageRole, OpenAiResponsesProjectionReader, OpenAiResponsesWorkflow, + openai_compat_router_with_state, }; use ironclaw_turns::TurnRunId; use serde_json::{Value, json}; +use support::in_memory_openai_compat_ref_store; use tower::ServiceExt; const AUTH_TOKEN: &str = "test-responses-api-token"; @@ -275,7 +278,7 @@ fn test_router() -> axum::Router { let workflow = Arc::new(FakeProductWorkflow::new()); let service = OpenAiResponsesWorkflow::new( workflow, - Arc::new(InMemoryOpenAiCompatRefStore::new()), + in_memory_openai_compat_ref_store(), Arc::new(StaticResponsesReader), ); openai_compat_router_with_state(OpenAiCompatRouterState::with_responses(Arc::new(service))) diff --git a/crates/ironclaw_reborn_openai_compat/tests/responses_temperature_contract.rs b/crates/ironclaw_reborn_openai_compat/tests/responses_temperature_contract.rs index 9cd1fc0dfb3..f25e8d2d18c 100644 --- a/crates/ironclaw_reborn_openai_compat/tests/responses_temperature_contract.rs +++ b/crates/ironclaw_reborn_openai_compat/tests/responses_temperature_contract.rs @@ -8,6 +8,8 @@ use std::sync::Arc; +mod support; + use async_trait::async_trait; use axum::body::Body; use axum::http::{Request, StatusCode}; @@ -18,16 +20,17 @@ use ironclaw_product_adapters::{ ProjectionReadRequest, ProtocolAuthEvidence, }; use ironclaw_reborn_openai_compat::{ - InMemoryOpenAiCompatRefStore, OpenAiCompatActorScope, OpenAiCompatAuthenticatedCaller, - OpenAiCompatInternalRefs, OpenAiCompatProductActionRef, OpenAiCompatProjectionRef, - OpenAiCompatRouterState, OpenAiCompatTurnRunRef, OpenAiResponseId, OpenAiResponseObject, - OpenAiResponseOutputItem, OpenAiResponseOutputItemStatus, OpenAiResponseProjection, - OpenAiResponseReadRequest, OpenAiResponseStatus, OpenAiResponseUsage, - OpenAiResponseWaitRequest, OpenAiResponsesMessageRole, OpenAiResponsesProjectionReader, - OpenAiResponsesWorkflow, openai_compat_router_with_state, + OpenAiCompatActorScope, OpenAiCompatAuthenticatedCaller, OpenAiCompatInternalRefs, + OpenAiCompatProductActionRef, OpenAiCompatProjectionRef, OpenAiCompatRouterState, + OpenAiCompatTurnRunRef, OpenAiResponseId, OpenAiResponseObject, OpenAiResponseOutputItem, + OpenAiResponseOutputItemStatus, OpenAiResponseProjection, OpenAiResponseReadRequest, + OpenAiResponseStatus, OpenAiResponseUsage, OpenAiResponseWaitRequest, + OpenAiResponsesMessageRole, OpenAiResponsesProjectionReader, OpenAiResponsesWorkflow, + openai_compat_router_with_state, }; use ironclaw_turns::{TurnActor, TurnRunId, TurnScope}; use serde_json::{Value, json}; +use support::in_memory_openai_compat_ref_store; use tower::ServiceExt; /// POST `/v1/responses` with `temperature` set must preserve it in the @@ -136,7 +139,7 @@ fn test_router(workflow: Arc) -> axum::Router { workflow.program_projection_read_resolution(sample_projection_read_request()); let service = OpenAiResponsesWorkflow::new( workflow, - Arc::new(InMemoryOpenAiCompatRefStore::new()), + in_memory_openai_compat_ref_store(), Arc::new(StaticResponsesReader), ); openai_compat_router_with_state(OpenAiCompatRouterState::with_responses(Arc::new(service))) diff --git a/crates/ironclaw_reborn_openai_compat/tests/responses_workflow_handlers_contract.rs b/crates/ironclaw_reborn_openai_compat/tests/responses_workflow_handlers_contract.rs index 104245293f6..e665d32d23c 100644 --- a/crates/ironclaw_reborn_openai_compat/tests/responses_workflow_handlers_contract.rs +++ b/crates/ironclaw_reborn_openai_compat/tests/responses_workflow_handlers_contract.rs @@ -3,6 +3,8 @@ use std::sync::Arc; use std::sync::Mutex; use std::time::Duration; +mod support; + use async_trait::async_trait; use axum::body::Body; use http::Request; @@ -15,8 +17,8 @@ use ironclaw_product_adapters::{ ProjectionReadRequest, ProjectionSubscriptionRequest, ProtocolAuthEvidence, RedactedString, }; use ironclaw_reborn_openai_compat::{ - InMemoryOpenAiCompatRefStore, OpenAiChatProjectionStreamRequest, OpenAiCompatActorScope, - OpenAiCompatAuthenticatedCaller, OpenAiCompatBindInternalRefs, OpenAiCompatExternalToolResume, + OpenAiChatProjectionStreamRequest, OpenAiCompatActorScope, OpenAiCompatAuthenticatedCaller, + OpenAiCompatBindInternalRefs, OpenAiCompatExternalToolResume, OpenAiCompatExternalToolResumeRequest, OpenAiCompatExternalToolSpec, OpenAiCompatExternalToolStore, OpenAiCompatHttpError, OpenAiCompatInternalRefs, OpenAiCompatMarkExternalToolResumeCompleted, OpenAiCompatProductActionRef, @@ -31,6 +33,7 @@ use ironclaw_reborn_openai_compat::{ }; use ironclaw_turns::{AcceptedMessageRef, TurnActor, TurnRunId, TurnScope}; use serde_json::{Value, json}; +use support::in_memory_openai_compat_ref_store; use tokio::sync::Notify; use tower::ServiceExt; @@ -290,7 +293,7 @@ async fn responses_idempotency_replay_without_accepted_ack_resubmits() { let workflow = Arc::new(FixedAckWorkflow::new(deferred_busy_ack())); let service = OpenAiResponsesWorkflow::new( workflow.clone(), - Arc::new(InMemoryOpenAiCompatRefStore::new()), + in_memory_openai_compat_ref_store(), Arc::new(StaticResponsesReader::completed("unused")), ); let router = @@ -326,7 +329,7 @@ async fn responses_handlers_require_authenticated_caller_before_side_effects() { let workflow = Arc::new(FakeProductWorkflow::new()); let service = OpenAiResponsesWorkflow::new( workflow.clone(), - Arc::new(InMemoryOpenAiCompatRefStore::new()), + in_memory_openai_compat_ref_store(), Arc::new(StaticResponsesReader::completed("unused")), ); let router = @@ -360,7 +363,7 @@ async fn responses_handlers_require_authenticated_caller_before_side_effects() { #[tokio::test] async fn responses_retrieve_reads_authorized_projection() { let workflow = Arc::new(FakeProductWorkflow::new()); - let ref_store = Arc::new(InMemoryOpenAiCompatRefStore::new()); + let ref_store = in_memory_openai_compat_ref_store(); let reader = Arc::new(RecordingResponsesReader::new(completed_response( OpenAiResponseId::new("resp_placeholder").expect("id"), "read", @@ -396,7 +399,7 @@ async fn responses_retrieve_reads_authorized_projection() { #[tokio::test] async fn responses_cancel_uses_product_workflow_control_action() { let workflow = Arc::new(FakeProductWorkflow::new()); - let ref_store = Arc::new(InMemoryOpenAiCompatRefStore::new()); + let ref_store = in_memory_openai_compat_ref_store(); let reader = Arc::new(StaticResponsesReader::cancelled()); let router = router_with_store(workflow.clone(), ref_store, reader); @@ -439,7 +442,7 @@ async fn responses_cancel_rejected_busy_ack_returns_429_and_does_not_read_projec // Create a response first (FakeProductWorkflow returns Accepted by default) so the // ref_store has a valid mapping that the cancel path can look up. let create_workflow = Arc::new(FakeProductWorkflow::new()); - let ref_store = Arc::new(InMemoryOpenAiCompatRefStore::new()); + let ref_store = in_memory_openai_compat_ref_store(); let reader = Arc::new(RecordingResponsesReader::new(completed_response( OpenAiResponseId::new("resp_placeholder").expect("id"), "unused", @@ -536,7 +539,7 @@ async fn responses_product_workflow_error_redacts_request_and_backend_details() })); let router = router_with_product_workflow( workflow, - Arc::new(InMemoryOpenAiCompatRefStore::new()), + in_memory_openai_compat_ref_store(), Arc::new(StaticResponsesReader::completed("unused")), caller(), ); @@ -627,7 +630,7 @@ async fn responses_binding_required_rejection_carries_input_param() { ))); let service = OpenAiResponsesWorkflow::new( workflow, - Arc::new(InMemoryOpenAiCompatRefStore::new()), + in_memory_openai_compat_ref_store(), Arc::new(StaticResponsesReader::completed("ok")), ); let router = @@ -660,7 +663,7 @@ async fn responses_invalid_request_rejection_carries_input_param() { ))); let service = OpenAiResponsesWorkflow::new( workflow, - Arc::new(InMemoryOpenAiCompatRefStore::new()), + in_memory_openai_compat_ref_store(), Arc::new(StaticResponsesReader::completed("ok")), ); let router = @@ -702,7 +705,7 @@ async fn responses_create_ambiguous_resolution_rejection_returns_409() { ))); let service = OpenAiResponsesWorkflow::new( workflow, - Arc::new(InMemoryOpenAiCompatRefStore::new()), + in_memory_openai_compat_ref_store(), Arc::new(StaticResponsesReader::completed("ok")), ); let router = @@ -754,7 +757,7 @@ async fn responses_wait_timeout_detaches_without_resubmitting() { workflow.program_projection_read_resolution(sample_projection_read_request()); let service = OpenAiResponsesWorkflow::new( workflow.clone(), - Arc::new(InMemoryOpenAiCompatRefStore::new()), + in_memory_openai_compat_ref_store(), Arc::new(NeverResponsesReader), ) .with_wait_timeout(Duration::from_millis(1)); @@ -1052,7 +1055,7 @@ async fn lookup_and_cancel_nonexistent_ids_return_same_not_found_shape() { #[tokio::test] async fn lookup_and_cancel_cross_scope_ids_return_same_not_found_shape() { let workflow = Arc::new(FakeProductWorkflow::new()); - let ref_store = Arc::new(InMemoryOpenAiCompatRefStore::new()); + let ref_store = in_memory_openai_compat_ref_store(); let reader = Arc::new(StaticResponsesReader::completed("unused")); let alice_router = router_with_store_and_caller( workflow.clone(), @@ -1111,7 +1114,7 @@ async fn assert_fixed_ack_status(ack: ProductInboundAck, status: http::StatusCod let workflow = Arc::new(FixedAckWorkflow::new(ack)); let service = OpenAiResponsesWorkflow::new( workflow, - Arc::new(InMemoryOpenAiCompatRefStore::new()), + in_memory_openai_compat_ref_store(), Arc::new(StaticResponsesReader::completed("ok")), ); let router = @@ -1134,16 +1137,12 @@ fn test_router( workflow: Arc, reader: Arc, ) -> axum::Router { - router_with_store( - workflow, - Arc::new(InMemoryOpenAiCompatRefStore::new()), - reader, - ) + router_with_store(workflow, in_memory_openai_compat_ref_store(), reader) } fn router_with_store( workflow: Arc, - ref_store: Arc, + ref_store: Arc, reader: Arc, ) -> axum::Router { router_with_store_and_caller(workflow, ref_store, reader, caller()) @@ -1151,7 +1150,7 @@ fn router_with_store( fn router_with_store_and_caller( workflow: Arc, - ref_store: Arc, + ref_store: Arc, reader: Arc, caller: OpenAiCompatAuthenticatedCaller, ) -> axum::Router { @@ -1161,7 +1160,7 @@ fn router_with_store_and_caller( fn router_with_product_workflow( workflow: Arc, - ref_store: Arc, + ref_store: Arc, reader: Arc, caller: OpenAiCompatAuthenticatedCaller, ) -> axum::Router { @@ -1774,14 +1773,14 @@ impl OpenAiCompatExternalToolResume for ConflictAfterFirstResume { } struct FailsFirstResumeCompletionMark { - inner: InMemoryOpenAiCompatRefStore, + inner: Arc, fail_next_mark: Mutex, } impl FailsFirstResumeCompletionMark { fn new() -> Self { Self { - inner: InMemoryOpenAiCompatRefStore::new(), + inner: in_memory_openai_compat_ref_store(), fail_next_mark: Mutex::new(true), } } @@ -1877,7 +1876,7 @@ async fn responses_with_external_tools_registers_specs_after_submit() { let resume = Arc::new(RecordingExternalToolResume::default()); let router = router_with_external_tools( workflow.clone(), - Arc::new(InMemoryOpenAiCompatRefStore::new()), + in_memory_openai_compat_ref_store(), Arc::new(StaticResponsesReader::completed("ok")), store.clone(), resume.clone(), @@ -1918,7 +1917,7 @@ async fn responses_with_external_tools_registers_specs_after_submit() { #[tokio::test] async fn responses_idempotency_replay_retries_tool_registration_after_partial_create_failure() { let workflow = Arc::new(FakeProductWorkflow::new()); - let ref_store = Arc::new(InMemoryOpenAiCompatRefStore::new()); + let ref_store = in_memory_openai_compat_ref_store(); let store = Arc::new(FailsFirstRegisterExternalToolStore::new()); let resume = Arc::new(RecordingExternalToolResume::default()); let reader = Arc::new(RecordingResponsesReader::new(completed_response( @@ -1973,7 +1972,7 @@ async fn streamed_responses_with_external_tools_registers_specs_after_submit() { let resume = Arc::new(RecordingExternalToolResume::default()); let router = router_with_external_tools_and_streamer( workflow.clone(), - Arc::new(InMemoryOpenAiCompatRefStore::new()), + in_memory_openai_compat_ref_store(), Arc::new(StaticResponsesReader::completed("unused")), Arc::new(EmptyProjectionStreamer), store.clone(), @@ -2011,7 +2010,7 @@ async fn streamed_responses_with_external_tools_registers_specs_after_submit() { #[tokio::test] async fn responses_function_call_output_resumes_parked_run_without_new_submit() { let workflow = Arc::new(FakeProductWorkflow::new()); - let ref_store = Arc::new(InMemoryOpenAiCompatRefStore::new()); + let ref_store = in_memory_openai_compat_ref_store(); let store = Arc::new(RecordingExternalToolStore::default()); let resume = Arc::new(RecordingExternalToolResume::default()); let router = router_with_external_tools( @@ -2077,7 +2076,7 @@ async fn responses_function_call_output_resumes_parked_run_without_new_submit() #[tokio::test] async fn responses_function_call_output_idempotency_replay_does_not_resubmit_output() { let workflow = Arc::new(FakeProductWorkflow::new()); - let ref_store = Arc::new(InMemoryOpenAiCompatRefStore::new()); + let ref_store = in_memory_openai_compat_ref_store(); let store = Arc::new(RecordingExternalToolStore::default()); let resume = Arc::new(RecordingExternalToolResume::default()); let router = router_with_external_tools( @@ -2216,7 +2215,7 @@ async fn responses_function_call_output_replay_recovers_when_completion_marker_f #[tokio::test] async fn responses_function_call_output_rejects_mixed_continuation_input() { let workflow = Arc::new(FakeProductWorkflow::new()); - let ref_store = Arc::new(InMemoryOpenAiCompatRefStore::new()); + let ref_store = in_memory_openai_compat_ref_store(); let store = Arc::new(RecordingExternalToolStore::default()); let resume = Arc::new(RecordingExternalToolResume::default()); let router = router_with_external_tools( @@ -2273,7 +2272,7 @@ async fn responses_function_call_output_without_external_tools_is_rejected() { // With no external-tool store wired, a `function_call_output` continuation // fails closed instead of being serialized into a fresh transcript turn. let workflow = Arc::new(FakeProductWorkflow::new()); - let ref_store = Arc::new(InMemoryOpenAiCompatRefStore::new()); + let ref_store = in_memory_openai_compat_ref_store(); let router = router_with_store( workflow.clone(), ref_store, diff --git a/crates/ironclaw_reborn_openai_compat/tests/streaming_handlers_contract.rs b/crates/ironclaw_reborn_openai_compat/tests/streaming_handlers_contract.rs index 4068c8cad8a..84871eeaa10 100644 --- a/crates/ironclaw_reborn_openai_compat/tests/streaming_handlers_contract.rs +++ b/crates/ironclaw_reborn_openai_compat/tests/streaming_handlers_contract.rs @@ -2,6 +2,8 @@ use std::collections::VecDeque; use std::sync::{Arc, Mutex}; use std::time::Duration; +mod support; + use async_trait::async_trait; use axum::body::Body; use http::Request; @@ -15,17 +17,17 @@ use ironclaw_product_adapters::{ ProjectionSubscriptionRequest, ProtocolAuthEvidence, }; use ironclaw_reborn_openai_compat::{ - InMemoryOpenAiCompatRefStore, OpenAiChatCompletionProjection, - OpenAiChatCompletionProjectionReader, OpenAiChatCompletionProjectionRequest, - OpenAiChatCompletionsWorkflow, OpenAiChatProjectionStreamRequest, OpenAiCompatActorScope, - OpenAiCompatAuthenticatedCaller, OpenAiCompatHttpError, OpenAiCompatProjectionStreamer, - OpenAiCompatRouterState, OpenAiResponseId, OpenAiResponseObject, - OpenAiResponseProjectionStreamRequest, OpenAiResponseReadRequest, OpenAiResponseStatus, - OpenAiResponseWaitRequest, OpenAiResponsesProjectionReader, OpenAiResponsesWorkflow, - openai_compat_router_with_state, + OpenAiChatCompletionProjection, OpenAiChatCompletionProjectionReader, + OpenAiChatCompletionProjectionRequest, OpenAiChatCompletionsWorkflow, + OpenAiChatProjectionStreamRequest, OpenAiCompatActorScope, OpenAiCompatAuthenticatedCaller, + OpenAiCompatHttpError, OpenAiCompatProjectionStreamer, OpenAiCompatRouterState, + OpenAiResponseId, OpenAiResponseObject, OpenAiResponseProjectionStreamRequest, + OpenAiResponseReadRequest, OpenAiResponseStatus, OpenAiResponseWaitRequest, + OpenAiResponsesProjectionReader, OpenAiResponsesWorkflow, openai_compat_router_with_state, }; use ironclaw_turns::{AcceptedMessageRef, ReplyTargetBindingRef, TurnActor, TurnRunId, TurnScope}; use serde_json::json; +use support::in_memory_openai_compat_ref_store; use tower::ServiceExt; #[tokio::test] @@ -729,7 +731,7 @@ fn router_with_options( workflow: Arc, wait_timeout: Duration, ) -> axum::Router { - let ref_store = Arc::new(InMemoryOpenAiCompatRefStore::new()); + let ref_store = in_memory_openai_compat_ref_store(); let mut chat = OpenAiChatCompletionsWorkflow::new( workflow.clone(), ref_store.clone(), diff --git a/crates/ironclaw_reborn_openai_compat/tests/support/mod.rs b/crates/ironclaw_reborn_openai_compat/tests/support/mod.rs new file mode 100644 index 00000000000..61db415364f --- /dev/null +++ b/crates/ironclaw_reborn_openai_compat/tests/support/mod.rs @@ -0,0 +1,9 @@ +use std::sync::Arc; + +use ironclaw_filesystem::{InMemoryBackend, RootFilesystem}; +use ironclaw_reborn_openai_compat::FilesystemOpenAiCompatRefStore; + +pub(crate) fn in_memory_openai_compat_ref_store() -> Arc { + let filesystem: Arc = Arc::new(InMemoryBackend::new()); + Arc::new(FilesystemOpenAiCompatRefStore::new(filesystem)) +} diff --git a/crates/ironclaw_runner/Cargo.toml b/crates/ironclaw_runner/Cargo.toml index 4f1c1e0b9b9..c83d639abf2 100644 --- a/crates/ironclaw_runner/Cargo.toml +++ b/crates/ironclaw_runner/Cargo.toml @@ -13,6 +13,12 @@ publish = false [package.metadata.ironclaw] layer = "kernel" +[features] +# Exposes filesystem-backed in-memory test constructors to integration tests and +# downstream harnesses. Off by default so production builds do not carry test +# helpers. +test-support = [] + [dependencies] async-trait = "0.1" base64 = "0.22" @@ -48,6 +54,7 @@ tokio-util = { version = "0.7", features = ["rt"] } tracing = "0.1" [dev-dependencies] +ironclaw_runner = { path = ".", features = ["test-support"] } ironclaw_loop_host = { path = "../ironclaw_loop_host", features = ["test-support"] } chrono = "0.4" ironclaw_agent_loop = { path = "../ironclaw_agent_loop", version = "0.1.0", features = ["test-support"] } diff --git a/crates/ironclaw_runner/src/loop_driver_host.rs b/crates/ironclaw_runner/src/loop_driver_host.rs index 046adfc18d2..10b2a65f91e 100644 --- a/crates/ironclaw_runner/src/loop_driver_host.rs +++ b/crates/ironclaw_runner/src/loop_driver_host.rs @@ -114,19 +114,19 @@ use ironclaw_turns::{ run_profile::{ AgentLoopHostError, AgentLoopHostErrorKind, AppendCapabilityResultRef, BeginAssistantDraft, CapabilityBatchInvocation, CapabilityInvocation, CommunicationContextProvider, - FinalizeAssistantMessage, HookMilestoneSink, HostManagedLoopModelPort, - HostManagedLoopPromptPort, InMemoryInstructionMaterializationStore, - InstructionBundleMaterializedMessage, InstructionMaterializationStore, - InstructionSafetyContext, LoadCheckpointPayloadRequest, LoadedCheckpointPayload, - LoopCancellationPort, LoopCancellationSignal, LoopCapabilityPort, LoopCheckpointPort, - LoopCheckpointRequest, LoopCompactionError, LoopCompactionOutcome, LoopCompactionPort, - LoopCompactionRequest, LoopContextBundle, LoopContextPort, LoopContextRequest, - LoopHostMilestoneSink, LoopInputAckToken, LoopInputBatch, LoopInputCursor, LoopInputPort, - LoopModelBudgetAccountant, LoopModelGateway, LoopModelPolicyGuard, LoopModelPort, - LoopModelRequest, LoopModelResponse, LoopProgressEvent, LoopProgressPort, LoopPromptBundle, - LoopPromptBundleAuthority, LoopPromptBundleRequest, LoopPromptPort, LoopRunContext, - LoopRunInfoPort, LoopRuntimeContext, LoopTranscriptPort, NoOpBudgetAccountant, - NoOpPolicyGuard, ProviderToolCall, ProviderToolDefinition, RegisterProviderToolCallRequest, + EphemeralInstructionMaterializationStore, FinalizeAssistantMessage, HookMilestoneSink, + HostManagedLoopModelPort, HostManagedLoopPromptPort, InstructionBundleMaterializedMessage, + InstructionMaterializationStore, InstructionSafetyContext, LoadCheckpointPayloadRequest, + LoadedCheckpointPayload, LoopCancellationPort, LoopCancellationSignal, LoopCapabilityPort, + LoopCheckpointPort, LoopCheckpointRequest, LoopCompactionError, LoopCompactionOutcome, + LoopCompactionPort, LoopCompactionRequest, LoopContextBundle, LoopContextPort, + LoopContextRequest, LoopHostMilestoneSink, LoopInputAckToken, LoopInputBatch, + LoopInputCursor, LoopInputPort, LoopModelBudgetAccountant, LoopModelGateway, + LoopModelPolicyGuard, LoopModelPort, LoopModelRequest, LoopModelResponse, + LoopProgressEvent, LoopProgressPort, LoopPromptBundle, LoopPromptBundleAuthority, + LoopPromptBundleRequest, LoopPromptPort, LoopRunContext, LoopRunInfoPort, + LoopRuntimeContext, LoopTranscriptPort, NoOpBudgetAccountant, NoOpPolicyGuard, + ProviderToolCall, ProviderToolDefinition, RegisterProviderToolCallRequest, RunScopedHookMilestoneSink, StageCheckpointPayloadRequest, SystemInferencePort, UpdateAssistantDraft, VisibleCapabilityRequest, VisibleCapabilitySurface, }, @@ -1633,7 +1633,7 @@ where event_subscription_started_at, ); let instruction_materialization_store: Arc = - Arc::new(InMemoryInstructionMaterializationStore::default()); + Arc::new(EphemeralInstructionMaterializationStore::default()); let surface_state = Arc::new(CapabilitySurfaceState::default()); let mut capabilities: Arc = Arc::new( SurfaceTrackingLoopCapabilityPort::new(capabilities, Arc::clone(&surface_state)), diff --git a/crates/ironclaw_runner/src/model_gateway.rs b/crates/ironclaw_runner/src/model_gateway.rs index ab30f03b815..8e53503ac32 100644 --- a/crates/ironclaw_runner/src/model_gateway.rs +++ b/crates/ironclaw_runner/src/model_gateway.rs @@ -39,13 +39,13 @@ use ironclaw_turns::run_profile::LoopModelUsage; use ironclaw_turns::{ ModelInvalidOutputDetailReason as InvalidOutputReason, TurnId, TurnRunId, run_profile::{ - AgentLoopHostError, AgentLoopHostErrorKind, HostManagedLoopPromptPort, - InMemoryInstructionMaterializationStore, InMemoryLoopHostMilestoneSink, - InstructionMaterializationStore, InstructionSafetyContext, LoopModelGateway, - LoopModelGatewayError, LoopModelGatewayRequest, LoopModelPort, LoopModelProgressSink, - LoopModelRequest, LoopModelResponse, LoopPromptBundleRequest, LoopPromptPort, - LoopRunContext, LoopSafeSummary, ModelProfileId, PromptMode, ProviderToolCall, - ProviderToolDefinition, RegisterProviderToolCallRequest, sanitize_model_visible_text, + AgentLoopHostError, AgentLoopHostErrorKind, EphemeralInstructionMaterializationStore, + HostManagedLoopPromptPort, InMemoryLoopHostMilestoneSink, InstructionMaterializationStore, + InstructionSafetyContext, LoopModelGateway, LoopModelGatewayError, LoopModelGatewayRequest, + LoopModelPort, LoopModelProgressSink, LoopModelRequest, LoopModelResponse, + LoopPromptBundleRequest, LoopPromptPort, LoopRunContext, LoopSafeSummary, ModelProfileId, + PromptMode, ProviderToolCall, ProviderToolDefinition, RegisterProviderToolCallRequest, + sanitize_model_visible_text, }, }; use tracing::debug; @@ -245,7 +245,7 @@ where progress_sink: Option>, ) -> Result { let instruction_materialization_store: Arc = - Arc::new(InMemoryInstructionMaterializationStore::default()); + Arc::new(EphemeralInstructionMaterializationStore::default()); let context_window_cache = Arc::new(ThreadContextWindowCache::default()); self.issue_host_prompt_bundle( &request.context, diff --git a/crates/ironclaw_runner/src/subagent/await_edge/boot_recovery.rs b/crates/ironclaw_runner/src/subagent/await_edge/boot_recovery.rs index ff5db9ab654..98e0e6cb5dd 100644 --- a/crates/ironclaw_runner/src/subagent/await_edge/boot_recovery.rs +++ b/crates/ironclaw_runner/src/subagent/await_edge/boot_recovery.rs @@ -545,7 +545,7 @@ mod tests { )); let store = Arc::new(FilesystemAwaitEdgeStore::new(Arc::clone(&fs))); let goal_store: Arc = - Arc::new(crate::subagent::goal_store::InMemoryBoundedSubagentGoalStore::new()); + Arc::new(crate::subagent::goal_store::in_memory_backed_subagent_goal_store()); let turn_state_store: Arc = Arc::new(ironclaw_turns::test_support::in_memory_turn_state_store()); let result_writer: Arc = @@ -621,7 +621,7 @@ mod tests { ) -> Arc> { let store = Arc::new(FilesystemAwaitEdgeStore::new(fs)); let goal_store: Arc = - Arc::new(crate::subagent::goal_store::InMemoryBoundedSubagentGoalStore::new()); + Arc::new(crate::subagent::goal_store::in_memory_backed_subagent_goal_store()); let turn_state_store: Arc = Arc::new(ironclaw_turns::test_support::in_memory_turn_state_store()); let result_writer: Arc = @@ -933,7 +933,7 @@ mod tests { // 5. Build the resolver, bind the real coordinator, and re-drive // recovery over this exact scope. let goal_store: Arc = - Arc::new(crate::subagent::goal_store::InMemoryBoundedSubagentGoalStore::new()); + Arc::new(crate::subagent::goal_store::in_memory_backed_subagent_goal_store()); let turn_state_store: Arc = state_store.clone(); let resolver = Arc::new(AwaitEdgeResolver::new_unbound( Arc::clone(&store), diff --git a/crates/ironclaw_runner/src/subagent/await_edge/resolver.rs b/crates/ironclaw_runner/src/subagent/await_edge/resolver.rs index 80eaa8c2e28..70501b5adbf 100644 --- a/crates/ironclaw_runner/src/subagent/await_edge/resolver.rs +++ b/crates/ironclaw_runner/src/subagent/await_edge/resolver.rs @@ -944,7 +944,7 @@ mod tests { > { let store = Arc::new(FilesystemAwaitEdgeStore::new(recon_scoped_fs())); let goal_store: Arc = - Arc::new(crate::subagent::goal_store::InMemoryBoundedSubagentGoalStore::new()); + Arc::new(crate::subagent::goal_store::in_memory_backed_subagent_goal_store()); let turn_state_store: Arc = Arc::new(ironclaw_turns::test_support::in_memory_turn_state_store()); let result_writer: Arc = @@ -1525,7 +1525,7 @@ mod tests { .unwrap(); let goal_store: Arc = - Arc::new(crate::subagent::goal_store::InMemoryBoundedSubagentGoalStore::new()); + Arc::new(crate::subagent::goal_store::in_memory_backed_subagent_goal_store()); let turn_state_store: Arc = state_store; let result_writer: Arc = Arc::new(ReconResultWriter); diff --git a/crates/ironclaw_runner/src/subagent/flavors.rs b/crates/ironclaw_runner/src/subagent/flavors.rs index 16ec5af3dce..e6db3075467 100644 --- a/crates/ironclaw_runner/src/subagent/flavors.rs +++ b/crates/ironclaw_runner/src/subagent/flavors.rs @@ -441,7 +441,7 @@ mod tests { use crate::subagent::capability_surface::SubagentCapabilitySurfaceResolver; use crate::subagent::flavors::{SubagentFlavorId, lookup_flavor}; use crate::subagent::goal_store::{ - InMemoryBoundedSubagentGoalStore, SubagentGoal, SubagentGoalStore, + SubagentGoal, SubagentGoalStore, in_memory_backed_subagent_goal_store, }; use crate::subagent::prompt_material::RebornSubagentPromptMaterialSource; @@ -572,7 +572,7 @@ mod tests { ironclaw_loop_host::CapabilitySurfaceProfileFilter, Arc, ) { - let goal_store = Arc::new(InMemoryBoundedSubagentGoalStore::new()); + let goal_store = Arc::new(in_memory_backed_subagent_goal_store()); let mut context = test_run_context("caller-level-attenuation"); context.resolved_run_profile.profile_id = RunProfileId::new(SUBAGENT_PLANNED_PROFILE_ID).expect("subagent profile id"); @@ -790,7 +790,7 @@ mod tests { #[tokio::test] async fn non_subagent_surface_preserves_outer_profile_surface() { - let goal_store = Arc::new(InMemoryBoundedSubagentGoalStore::new()); + let goal_store = Arc::new(in_memory_backed_subagent_goal_store()); let context = test_run_context("caller-level-non-subagent"); let base_allow_set = CapabilityAllowSet::allowlist([cap("builtin.read_file"), cap("builtin.http")]); diff --git a/crates/ironclaw_runner/src/subagent/goal_store.rs b/crates/ironclaw_runner/src/subagent/goal_store.rs index ebbf704cd76..afd0276582e 100644 --- a/crates/ironclaw_runner/src/subagent/goal_store.rs +++ b/crates/ironclaw_runner/src/subagent/goal_store.rs @@ -1,6 +1,4 @@ -use std::collections::{HashMap, VecDeque}; use std::sync::Arc; -use std::sync::{Mutex, MutexGuard}; use async_trait::async_trait; use ironclaw_filesystem::{ @@ -10,7 +8,6 @@ use ironclaw_host_api::ScopedPath; use ironclaw_turns::{TurnRunId, TurnScope}; use serde::{Deserialize, Serialize}; -pub const MAX_GOAL_ENTRIES: usize = 4096; pub const MAX_GOAL_BYTES: usize = 64 * 1024; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -227,160 +224,6 @@ fn fs_backend_error(error: FilesystemError) -> SubagentGoalStoreError { } } -#[derive(Default)] -pub struct InMemoryBoundedSubagentGoalStore { - inner: Mutex, -} - -#[derive(Default)] -struct GoalStoreInner { - goals: HashMap, - insertion_order: VecDeque, -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -struct GoalKey { - scope: TurnScope, - run_id: TurnRunId, -} - -impl GoalKey { - fn new(scope: &TurnScope, run_id: TurnRunId) -> Self { - Self { - scope: scope.clone(), - run_id, - } - } -} - -impl InMemoryBoundedSubagentGoalStore { - pub fn new() -> Self { - Self::default() - } - - pub fn put( - &self, - scope: &TurnScope, - run_id: TurnRunId, - goal: SubagentGoal, - ) -> Result<(), SubagentGoalStoreError> { - let bytes = goal.byte_len(); - if bytes > MAX_GOAL_BYTES { - return Err(SubagentGoalStoreError::PayloadTooLarge { - bytes, - max: MAX_GOAL_BYTES, - }); - } - let mut inner = lock(&self.inner); - let key = GoalKey::new(scope, run_id); - if inner.goals.contains_key(&key) { - return Err(SubagentGoalStoreError::DuplicateKey { run_id }); - } - if inner.goals.len() >= MAX_GOAL_ENTRIES { - while let Some(oldest) = inner.insertion_order.pop_front() { - if inner.goals.remove(&oldest).is_some() { - tracing::debug!( - evicted_run_id = %oldest.run_id, - "subagent goal store at capacity; evicted oldest goal" - ); - break; - } - } - } - inner.goals.insert(key.clone(), goal); - inner.insertion_order.push_back(key); - Ok(()) - } - - pub fn get( - &self, - scope: &TurnScope, - run_id: TurnRunId, - ) -> Result { - let inner = lock(&self.inner); - inner - .goals - .get(&GoalKey::new(scope, run_id)) - .cloned() - .ok_or(SubagentGoalStoreError::NotFound { run_id }) - } - - fn delete_inner(&self, scope: &TurnScope, run_id: TurnRunId) { - let mut inner = lock(&self.inner); - let key = GoalKey::new(scope, run_id); - inner.goals.remove(&key); - inner.insertion_order.retain(|queued| *queued != key); - } - - #[cfg(test)] - fn len(&self) -> usize { - lock(&self.inner).goals.len() - } - - #[cfg(test)] - fn insertion_order_len(&self) -> usize { - lock(&self.inner).insertion_order.len() - } -} - -#[async_trait] -impl SubagentGoalStore for InMemoryBoundedSubagentGoalStore { - async fn put_goal( - &self, - scope: &TurnScope, - run_id: TurnRunId, - goal: SubagentGoal, - ) -> Result<(), SubagentGoalStoreError> { - self.put(scope, run_id, goal) - } - - async fn get_goal( - &self, - scope: &TurnScope, - run_id: TurnRunId, - ) -> Result { - self.get(scope, run_id) - } - - async fn delete_goal( - &self, - scope: &TurnScope, - run_id: TurnRunId, - ) -> Result<(), SubagentGoalStoreError> { - self.delete_inner(scope, run_id); - Ok(()) - } -} - -#[async_trait] -impl ironclaw_loop_host::SubagentSpawnGoalStore for InMemoryBoundedSubagentGoalStore { - async fn put_goal( - &self, - scope: &TurnScope, - run_id: TurnRunId, - goal: ironclaw_loop_host::SubagentGoalRecord, - ) -> Result<(), ironclaw_turns::run_profile::AgentLoopHostError> { - self.put( - scope, - run_id, - SubagentGoal { - task: goal.task, - handoff: goal.handoff, - }, - ) - .map_err(map_goal_error) - } - - async fn delete_goal( - &self, - scope: &TurnScope, - run_id: TurnRunId, - ) -> Result<(), ironclaw_turns::run_profile::AgentLoopHostError> { - self.delete_inner(scope, run_id); - Ok(()) - } -} - fn map_goal_error( error: SubagentGoalStoreError, ) -> ironclaw_turns::run_profile::AgentLoopHostError { @@ -401,19 +244,34 @@ fn map_goal_error( ironclaw_turns::run_profile::AgentLoopHostError::new(kind, error.to_string()) } -fn lock(inner: &Mutex) -> MutexGuard<'_, GoalStoreInner> { - match inner.lock() { - Ok(guard) => guard, - Err(poisoned) => poisoned.into_inner(), - } +#[cfg(any(test, feature = "test-support"))] +pub fn in_memory_backed_subagent_goal_store() +-> FilesystemSubagentGoalStore { + FilesystemSubagentGoalStore::new(scoped_goal_filesystem()) +} + +#[cfg(any(test, feature = "test-support"))] +pub fn scoped_goal_filesystem() -> Arc> { + use ironclaw_filesystem::{InMemoryBackend, ScopedFilesystem}; + use ironclaw_host_api::{MountAlias, MountGrant, MountPermissions, MountView, VirtualPath}; + + let mounts = MountView::new(vec![MountGrant::new( + MountAlias::new("/turns").expect("mount alias"), + VirtualPath::new("/turns").expect("mount path"), + MountPermissions::read_write_list_delete(), + )]) + .expect("mount view"); + Arc::new(ScopedFilesystem::with_fixed_view( + Arc::new(InMemoryBackend::new()), + mounts, + )) } #[cfg(test)] mod tests { use super::*; - use ironclaw_filesystem::{InMemoryBackend, ScopedFilesystem}; + use ironclaw_filesystem::InMemoryBackend; use ironclaw_host_api::{AgentId, ProjectId, TenantId, ThreadId}; - use ironclaw_host_api::{MountAlias, MountGrant, MountPermissions, MountView, VirtualPath}; fn scope(thread_id: &str) -> TurnScope { TurnScope::new( @@ -433,7 +291,7 @@ mod tests { #[tokio::test] async fn put_then_get_round_trips() { - let store = InMemoryBoundedSubagentGoalStore::new(); + let store = in_memory_backed_subagent_goal_store(); let owner_scope = scope("thread-goal"); let run_id = TurnRunId::new(); let expected = SubagentGoal { @@ -454,7 +312,7 @@ mod tests { #[tokio::test] async fn get_miss_is_not_found_error() { - let store = InMemoryBoundedSubagentGoalStore::new(); + let store = in_memory_backed_subagent_goal_store(); let owner_scope = scope("thread-goal"); let run_id = TurnRunId::new(); @@ -466,7 +324,7 @@ mod tests { #[tokio::test] async fn put_rejects_oversized_payload() { - let store = InMemoryBoundedSubagentGoalStore::new(); + let store = in_memory_backed_subagent_goal_store(); let owner_scope = scope("thread-goal"); let run_id = TurnRunId::new(); let large = SubagentGoal { @@ -482,7 +340,7 @@ mod tests { #[tokio::test] async fn put_rejects_payload_when_json_overhead_exceeds_limit() { - let store = InMemoryBoundedSubagentGoalStore::new(); + let store = in_memory_backed_subagent_goal_store(); let owner_scope = scope("thread-goal"); let run_id = TurnRunId::new(); let large = SubagentGoal { @@ -502,7 +360,7 @@ mod tests { #[tokio::test] async fn put_rejects_duplicate_key() { - let store = InMemoryBoundedSubagentGoalStore::new(); + let store = in_memory_backed_subagent_goal_store(); let owner_scope = scope("thread-goal"); let run_id = TurnRunId::new(); @@ -520,45 +378,9 @@ mod tests { ); } - #[tokio::test] - async fn bounded_store_evicts_oldest() { - let store = InMemoryBoundedSubagentGoalStore::new(); - let owner_scope = scope("thread-goal"); - let first = TurnRunId::new(); - let second = TurnRunId::new(); - store - .put_goal(&owner_scope, first, goal("first")) - .await - .unwrap(); - store - .put_goal(&owner_scope, second, goal("second")) - .await - .unwrap(); - for index in 2..=MAX_GOAL_ENTRIES { - store - .put_goal( - &owner_scope, - TurnRunId::new(), - goal(&format!("goal-{index}")), - ) - .await - .unwrap(); - } - - assert!(matches!( - store.get_goal(&owner_scope, first).await, - Err(SubagentGoalStoreError::NotFound { .. }) - )); - assert_eq!( - store.get_goal(&owner_scope, second).await.unwrap(), - goal("second") - ); - assert_eq!(store.len(), MAX_GOAL_ENTRIES); - } - #[tokio::test] async fn delete_goal_is_idempotent_and_removes_row() { - let store = InMemoryBoundedSubagentGoalStore::new(); + let store = in_memory_backed_subagent_goal_store(); let owner_scope = scope("thread-goal"); let run_id = TurnRunId::new(); @@ -573,12 +395,11 @@ mod tests { store.get_goal(&owner_scope, run_id).await, Err(SubagentGoalStoreError::NotFound { .. }) )); - assert_eq!(store.insertion_order_len(), 0); } #[tokio::test] - async fn bounded_store_keys_goals_by_scope_and_run_id() { - let store = InMemoryBoundedSubagentGoalStore::new(); + async fn filesystem_store_keys_goals_by_scope_and_run_id() { + let store = in_memory_backed_subagent_goal_store(); let first_scope = scope("thread-goal-a"); let second_scope = scope("thread-goal-b"); let run_id = TurnRunId::new(); @@ -600,19 +421,6 @@ mod tests { ); } - fn scoped_goal_filesystem() -> Arc> { - let mounts = MountView::new(vec![MountGrant::new( - MountAlias::new("/turns").unwrap(), - VirtualPath::new("/turns").unwrap(), - MountPermissions::read_write_list_delete(), - )]) - .unwrap(); - Arc::new(ScopedFilesystem::with_fixed_view( - Arc::new(InMemoryBackend::new()), - mounts, - )) - } - async fn assert_goal_store_contract(store: &dyn SubagentGoalStore) { let owner_scope = scope("thread-goal"); let other_scope = scope("thread-goal-other"); @@ -714,7 +522,6 @@ mod tests { fn goal_store_is_send_sync() { fn assert_send_sync() {} assert_send_sync::>(); - assert_send_sync::(); assert_send_sync::>(); } } diff --git a/crates/ironclaw_runner/src/subagent/prompt_material.rs b/crates/ironclaw_runner/src/subagent/prompt_material.rs index 6f6da37a8a2..11b82f6d2b3 100644 --- a/crates/ironclaw_runner/src/subagent/prompt_material.rs +++ b/crates/ironclaw_runner/src/subagent/prompt_material.rs @@ -300,14 +300,14 @@ mod tests { use crate::subagent::{ flavors::SubagentFlavorId, - goal_store::{InMemoryBoundedSubagentGoalStore, SubagentGoal}, + goal_store::{SubagentGoal, in_memory_backed_subagent_goal_store}, }; use super::*; #[tokio::test] async fn material_source_fails_loud_on_goal_miss() { - let store = Arc::new(InMemoryBoundedSubagentGoalStore::new()); + let store = Arc::new(in_memory_backed_subagent_goal_store()); let source = RebornSubagentPromptMaterialSource::new(store, SubagentFlavorId::General); let context = ironclaw_agent_loop::test_support::test_run_context("missing-goal"); @@ -318,7 +318,7 @@ mod tests { #[tokio::test] async fn material_source_combines_static_direction_goal_and_allowlist() { - let store = Arc::new(InMemoryBoundedSubagentGoalStore::new()); + let store = Arc::new(in_memory_backed_subagent_goal_store()); let context = ironclaw_agent_loop::test_support::test_run_context("goal"); store .put_goal( @@ -353,7 +353,7 @@ mod tests { #[tokio::test] async fn material_source_uses_thread_metadata_for_flavor() { - let store = Arc::new(InMemoryBoundedSubagentGoalStore::new()); + let store = Arc::new(in_memory_backed_subagent_goal_store()); let thread_service = Arc::new(InMemorySessionThreadService::default()); let mut context = ironclaw_agent_loop::test_support::test_run_context("thread-flavor"); context.scope.agent_id = Some(AgentId::new("agent-thread-flavor").unwrap()); @@ -378,7 +378,7 @@ mod tests { #[tokio::test] async fn material_source_errors_when_no_flavor_is_recorded() { - let store = Arc::new(InMemoryBoundedSubagentGoalStore::new()); + let store = Arc::new(in_memory_backed_subagent_goal_store()); let thread_service = Arc::new(InMemorySessionThreadService::default()); let mut context = ironclaw_agent_loop::test_support::test_run_context("missing-flavor"); context.scope.agent_id = Some(AgentId::new("agent-missing-flavor").unwrap()); @@ -393,7 +393,7 @@ mod tests { #[tokio::test] async fn material_source_errors_when_flavor_is_unknown() { - let store = Arc::new(InMemoryBoundedSubagentGoalStore::new()); + let store = Arc::new(in_memory_backed_subagent_goal_store()); let thread_service = Arc::new(InMemorySessionThreadService::default()); let mut context = ironclaw_agent_loop::test_support::test_run_context("unknown-flavor"); context.scope.agent_id = Some(AgentId::new("agent-unknown-flavor").unwrap()); diff --git a/crates/ironclaw_runner/tests/llm_gateway.rs b/crates/ironclaw_runner/tests/llm_gateway.rs index 1f6e802eb9a..3ff248d1550 100644 --- a/crates/ironclaw_runner/tests/llm_gateway.rs +++ b/crates/ironclaw_runner/tests/llm_gateway.rs @@ -32,12 +32,12 @@ use ironclaw_turns::{ LoopMessageRef, RunProfileResolutionRequest, RunProfileResolver, TurnId, TurnRunId, TurnScope, run_profile::{ AgentLoopHostErrorKind, AgentLoopHostErrorReasonKind, CapabilitySurfaceVersion, - HostManagedLoopModelPort, HostManagedLoopPromptPort, - InMemoryInstructionMaterializationStore, InMemoryLoopHostMilestoneSink, - InMemoryRunProfileResolver, InstructionMaterializationStore, InstructionSafetyContext, - LoopCapabilityPort, LoopHostMilestoneKind, LoopInlineMessage, LoopInlineMessageBody, - LoopInlineMessageRole, LoopModelGateway, LoopModelGatewayRequest, LoopModelMessage, - LoopModelPort, LoopModelRequest, LoopPromptBundleRequest, LoopPromptPort, LoopRunContext, + EphemeralInstructionMaterializationStore, HostManagedLoopModelPort, + HostManagedLoopPromptPort, InMemoryLoopHostMilestoneSink, InMemoryRunProfileResolver, + InstructionMaterializationStore, InstructionSafetyContext, LoopCapabilityPort, + LoopHostMilestoneKind, LoopInlineMessage, LoopInlineMessageBody, LoopInlineMessageRole, + LoopModelGateway, LoopModelGatewayRequest, LoopModelMessage, LoopModelPort, + LoopModelRequest, LoopPromptBundleRequest, LoopPromptPort, LoopRunContext, LoopRuntimeContext, ModelProfileId, ParentLoopOutput, PromptMode, ProviderToolCall, ProviderToolCallReplay, ProviderToolDefinition, VisibleCapabilityRequest, VisibleCapabilitySurface, @@ -2386,7 +2386,7 @@ async fn production_loop_model_gateway_accepts_inline_prompt_messages() { async fn production_loop_model_request_includes_runtime_context() { let fixture = ThreadFixture::new().await; let loop_started_at_utc = chrono::Utc::now(); - let store = Arc::new(InMemoryInstructionMaterializationStore::default()); + let store = Arc::new(EphemeralInstructionMaterializationStore::default()); let store_for_port: Arc = store.clone(); let context_port = Arc::new(ThreadBackedLoopContextPort::new( Arc::clone(&fixture.thread_service), @@ -3280,7 +3280,7 @@ async fn production_loop_request_with_safety_and_inline_messages( ) .with_safety_context(safety_context) .with_instruction_materialization_store(Arc::new( - InMemoryInstructionMaterializationStore::default(), + EphemeralInstructionMaterializationStore::default(), )); let prompt_bundle = prompt_port .build_prompt_bundle(LoopPromptBundleRequest { diff --git a/crates/ironclaw_runner/tests/loop_driver_host.rs b/crates/ironclaw_runner/tests/loop_driver_host.rs index fffcf3e4264..ed8734098f5 100644 --- a/crates/ironclaw_runner/tests/loop_driver_host.rs +++ b/crates/ironclaw_runner/tests/loop_driver_host.rs @@ -88,7 +88,7 @@ use ironclaw_runner::subagent::{ store::FilesystemAwaitEdgeStore, }, flavors::StaticSubagentDefinitionResolver, - goal_store::InMemoryBoundedSubagentGoalStore, + goal_store::{FilesystemSubagentGoalStore, in_memory_backed_subagent_goal_store}, }; use ironclaw_runner::text_loop_driver::TextOnlyModelReplyDriver; use ironclaw_runner::turn_run_executor::RebornTurnRunExecutor; @@ -214,6 +214,10 @@ fn build_test_await_edge_trio( (store, resolver, driver) } +fn in_memory_subagent_goal_store() -> Arc> { + Arc::new(in_memory_backed_subagent_goal_store()) +} + /// Build a fresh, never-written-to in-memory await-edge store for call sites /// that only need `AwaitDependentRunEvidenceStore` (blocked-exit evidence) /// and never exercise subagent-spawn await-edge resolution — an empty store @@ -3693,7 +3697,7 @@ async fn default_planned_runtime_composes_no_profile_coordinator_and_profiled_ho let surface_resolver = Arc::new(StaticCapabilitySurfaceProfileResolver::new( CapabilityAllowSet::allowlist([allowed_id.clone()]), )); - let subagent_goal_store = Arc::new(InMemoryBoundedSubagentGoalStore::new()); + let subagent_goal_store = in_memory_subagent_goal_store(); let (await_edge_store, await_edge_resolver, await_edge_writer) = build_test_await_edge_trio( subagent_goal_store.clone() as Arc, turn_store.clone() as Arc, @@ -3868,7 +3872,7 @@ async fn pre_minted_scheduler_wake_wiring_drives_scheduler_on_coordinator_submit let surface_resolver = Arc::new(StaticCapabilitySurfaceProfileResolver::new( CapabilityAllowSet::allowlist([allowed_id.clone()]), )); - let subagent_goal_store = Arc::new(InMemoryBoundedSubagentGoalStore::new()); + let subagent_goal_store = in_memory_subagent_goal_store(); let (await_edge_store, await_edge_resolver, await_edge_writer) = build_test_await_edge_trio( subagent_goal_store.clone() as Arc, turn_store.clone() as Arc, @@ -4046,7 +4050,7 @@ async fn build_runtime_host_with_optional_hooks( let surface_resolver = Arc::new(StaticCapabilitySurfaceProfileResolver::new( CapabilityAllowSet::allowlist([allowed_id.clone()]), )); - let subagent_goal_store = Arc::new(InMemoryBoundedSubagentGoalStore::new()); + let subagent_goal_store = in_memory_subagent_goal_store(); let (await_edge_store, await_edge_resolver, await_edge_writer) = build_test_await_edge_trio( subagent_goal_store.clone() as Arc, turn_store.clone() as Arc, @@ -4408,7 +4412,7 @@ async fn product_live_runtime_builds_when_all_required_adapters_are_present() { ), ); - let subagent_goal_store = Arc::new(InMemoryBoundedSubagentGoalStore::new()); + let subagent_goal_store = in_memory_subagent_goal_store(); let (await_edge_store, await_edge_resolver, await_edge_writer) = build_test_await_edge_trio( subagent_goal_store.clone() as Arc, turn_store.clone() as Arc, @@ -4537,7 +4541,7 @@ async fn product_live_parts_for_gate_test( ModelRoute::new("nearai", "qwen3-coder").unwrap(), ), ); - let subagent_goal_store = Arc::new(InMemoryBoundedSubagentGoalStore::new()); + let subagent_goal_store = in_memory_subagent_goal_store(); let (await_edge_store, await_edge_resolver, await_edge_writer) = build_test_await_edge_trio( subagent_goal_store.clone() as Arc, turn_store.clone() as Arc, diff --git a/crates/ironclaw_turns/src/run_profile/instruction_bundle.rs b/crates/ironclaw_turns/src/run_profile/instruction_bundle.rs index b8d4599fc29..d7b4f8d2fb0 100644 --- a/crates/ironclaw_turns/src/run_profile/instruction_bundle.rs +++ b/crates/ironclaw_turns/src/run_profile/instruction_bundle.rs @@ -134,19 +134,24 @@ pub trait InstructionMaterializationStore: Send + Sync { ) -> Result, AgentLoopHostError>; } -/// In-memory, per-process materialization store for model-visible safe context. +/// Ephemeral, per-process materialization store for model-visible prompt context. +/// +/// This is intentionally not filesystem-backed. It stages raw model-visible +/// prompt material between prompt construction and model resolution for one +/// claimed run, and `ironclaw_turns` must not define a durable row shape for +/// raw prompts. #[derive(Default)] -pub struct InMemoryInstructionMaterializationStore { +pub struct EphemeralInstructionMaterializationStore { messages: Mutex>, } -impl InMemoryInstructionMaterializationStore { +impl EphemeralInstructionMaterializationStore { fn key(context: &LoopRunContext, content_ref: &LoopMessageRef) -> String { format!("{}:{}", context.run_id, content_ref.as_str()) } } -impl InstructionMaterializationStore for InMemoryInstructionMaterializationStore { +impl InstructionMaterializationStore for EphemeralInstructionMaterializationStore { fn put_materialized_messages( &self, context: &LoopRunContext, diff --git a/crates/ironclaw_turns/src/run_profile/mod.rs b/crates/ironclaw_turns/src/run_profile/mod.rs index 935ffb5fed2..59809d891b6 100644 --- a/crates/ironclaw_turns/src/run_profile/mod.rs +++ b/crates/ironclaw_turns/src/run_profile/mod.rs @@ -72,7 +72,7 @@ pub use host::{ validate_model_route_component_value, }; pub use instruction_bundle::{ - InMemoryInstructionMaterializationStore, InstructionBundle, InstructionBundleBuilder, + EphemeralInstructionMaterializationStore, InstructionBundle, InstructionBundleBuilder, InstructionBundleFingerprint, InstructionBundleMaterializedMessage, InstructionBundleRequest, InstructionMaterializationStore, InstructionSafetyContext, sort_instruction_snippets_for_prompt, diff --git a/crates/ironclaw_turns/src/run_profile/prompt.rs b/crates/ironclaw_turns/src/run_profile/prompt.rs index c76f07fc5e2..ee53664a262 100644 --- a/crates/ironclaw_turns/src/run_profile/prompt.rs +++ b/crates/ironclaw_turns/src/run_profile/prompt.rs @@ -440,7 +440,7 @@ mod tests { use crate::{ LoopMessageRef, RunProfileId, RunProfileVersion, TurnId, TurnRunId, TurnScope, run_profile::{ - InMemoryInstructionMaterializationStore, InMemoryLoopHostMilestoneSink, + EphemeralInstructionMaterializationStore, InMemoryLoopHostMilestoneSink, LoopContextBundle, LoopContextCompactionKind, LoopContextCompactionMetadata, LoopContextMessage, LoopInlineMessage, LoopInlineMessageBody, LoopInlineMessageRole, LoopRuntimeContext, ResolvedRunProfile, @@ -477,7 +477,7 @@ mod tests { #[tokio::test] async fn host_managed_prompt_port_materializes_inline_messages() { let context = test_context(); - let store = Arc::new(InMemoryInstructionMaterializationStore::default()); + let store = Arc::new(EphemeralInstructionMaterializationStore::default()); let port = HostManagedLoopPromptPort::new( context.clone(), Arc::new(PanicContextPort), @@ -513,7 +513,7 @@ mod tests { #[tokio::test] async fn inline_only_zero_message_prompt_skips_context_loading() { let context = test_context(); - let store = Arc::new(InMemoryInstructionMaterializationStore::default()); + let store = Arc::new(EphemeralInstructionMaterializationStore::default()); let port = HostManagedLoopPromptPort::new( context.clone(), Arc::new(UnavailableContextPort), @@ -601,7 +601,7 @@ mod tests { safe_summary: summary_text.to_string(), compaction: None, }; - let store = Arc::new(InMemoryInstructionMaterializationStore::default()); + let store = Arc::new(EphemeralInstructionMaterializationStore::default()); let port = HostManagedLoopPromptPort::new( context.clone(), Arc::new(StubContextPort::new(vec![identity_msg], vec![])), @@ -654,7 +654,7 @@ mod tests { safe_summary: "What is the capital of France?".to_string(), compaction: None, }; - let store = Arc::new(InMemoryInstructionMaterializationStore::default()); + let store = Arc::new(EphemeralInstructionMaterializationStore::default()); let port = HostManagedLoopPromptPort::new( context.clone(), Arc::new(StubContextPort::new(vec![], vec![summary_only])), @@ -733,7 +733,7 @@ mod tests { #[tokio::test] async fn prompt_port_attaches_runtime_context() { let context = test_context(); - let store = Arc::new(InMemoryInstructionMaterializationStore::default()); + let store = Arc::new(EphemeralInstructionMaterializationStore::default()); let runtime_ctx = LoopRuntimeContext { loop_started_at_utc: chrono::Utc .with_ymd_and_hms(2026, 6, 11, 21, 32, 0) diff --git a/crates/ironclaw_turns/tests/agent_loop_host_contract.rs b/crates/ironclaw_turns/tests/agent_loop_host_contract.rs index 7a496ad43ab..beea6045e33 100644 --- a/crates/ironclaw_turns/tests/agent_loop_host_contract.rs +++ b/crates/ironclaw_turns/tests/agent_loop_host_contract.rs @@ -23,10 +23,10 @@ use ironclaw_turns::{ CapabilityDescriptorView, CapabilityInputRef, CapabilityInvocation, CapabilityProgress, CapabilitySurfaceVersion, CommunicationRuntimeContext, ConcurrencyHint, ConnectedChannelSummary, ConnectedChannelsState, DeliveryTargetState, - DeliveryTargetSummary, FinalizeAssistantMessage, HostManagedLoopModelPort, - HostManagedLoopPromptPort, InMemoryInstructionMaterializationStore, - InMemoryLoopHostMilestoneSink, InstructionBundleBuilder, InstructionBundleFingerprint, - InstructionBundleRequest, InstructionMaterializationStore, InstructionSafetyContext, + DeliveryTargetSummary, EphemeralInstructionMaterializationStore, FinalizeAssistantMessage, + HostManagedLoopModelPort, HostManagedLoopPromptPort, InMemoryLoopHostMilestoneSink, + InstructionBundleBuilder, InstructionBundleFingerprint, InstructionBundleRequest, + InstructionMaterializationStore, InstructionSafetyContext, LOOP_CONTEXT_SNIPPET_MODEL_CONTENT_MAX_BYTES, LoopCancellationPort, LoopCancellationSignal, LoopCapabilityPort, LoopCheckpointKind, LoopCheckpointPort, LoopCheckpointRequest, LoopCheckpointStateRef, LoopCompactionError, LoopCompactionOutcome, LoopCompactionPort, @@ -1474,7 +1474,7 @@ async fn loop_prompt_port_filters_visible_surface_by_capability_view() { }, ], }; - let store = Arc::new(InMemoryInstructionMaterializationStore::default()); + let store = Arc::new(EphemeralInstructionMaterializationStore::default()); let port = HostManagedLoopPromptPort::new( host.context.clone(), host.clone(), @@ -1741,7 +1741,7 @@ async fn loop_prompt_port_keeps_identity_before_skill_snippets_and_records_skill host.milestone_sink.clone(), ) .with_instruction_materialization_store(Arc::new( - InMemoryInstructionMaterializationStore::default(), + EphemeralInstructionMaterializationStore::default(), )); let bundle = port @@ -2057,7 +2057,7 @@ async fn loop_prompt_port_materializes_memory_surface_and_safety_as_host_owned_r parameters_schema: serde_json::json!({"type":"object","properties":{"input":{"type":"string"}}}), }], }; - let materialization_store = Arc::new(InMemoryInstructionMaterializationStore::default()); + let materialization_store = Arc::new(EphemeralInstructionMaterializationStore::default()); let port = HostManagedLoopPromptPort::new( host.context.clone(), host.clone(), diff --git a/crates/ironclaw_webui/AGENTS.md b/crates/ironclaw_webui/AGENTS.md index 22df8513ded..2a25a364214 100644 --- a/crates/ironclaw_webui/AGENTS.md +++ b/crates/ironclaw_webui/AGENTS.md @@ -36,8 +36,8 @@ one `products`-layer crate above `ironclaw_reborn_composition`. Driven by the 3. **Serve loop + host authentication** (`src/lib.rs`, `src/auth/`, `src/session.rs`, `src/oidc.rs`, `src/signed_session_login.rs`): `serve_webui_v2` (listener bind + `axum::serve` + graceful shutdown), the - `Env` / `Session` / `Oidc` authenticators, the `SessionStore` trait + stores, - and the `/auth/*` OAuth login surface (Google/GitHub via the `OAuthProvider` + `Env` / `Session` / `Oidc` authenticators, `SignedTokenSessionStore`, and + the `/auth/*` OAuth login surface (Google/GitHub via the `OAuthProvider` trait). ## Do not move in here diff --git a/crates/ironclaw_webui/CLAUDE.md b/crates/ironclaw_webui/CLAUDE.md index c0c314ee7a3..f7b45982592 100644 --- a/crates/ironclaw_webui/CLAUDE.md +++ b/crates/ironclaw_webui/CLAUDE.md @@ -17,7 +17,8 @@ subsystems that used to live apart (see `README.md` for the fold-in map): 3. **Serve loop + host authentication** (`src/lib.rs`, `src/auth/`, `src/session.rs`, `src/oidc.rs`) — `serve_webui_v2` binds the listener and runs `axum::serve`; the `Env`/`Session`/`Oidc` authenticators, the - `SessionStore`, and the `/auth/*` OAuth login surface that mints sessions. + signed-token session store, and the `/auth/*` OAuth login surface that mints + sessions. Composition deliberately stops at the `reborn_product_api_crates_do_not_bind_http_ingress` boundary — it returns a @@ -55,15 +56,15 @@ turning the `webui_v2_routes()` descriptors into tower layers. | `serve_webui_v2(opts)` | Bind a `TcpListener` + run `axum::serve` with graceful shutdown | | `RebornWebuiServeOptions` | Owner-supplied input (addr, router, shutdown receiver) | | `EnvBearerAuthenticator` | Single-token `WebuiAuthenticator` for the standalone CLI / local dev; accepted tokens map to operator WebUI capabilities | -| `SessionStore` trait | Pluggable session storage; durable impl is host's; `InMemorySessionStore` for local dev / tests | -| `SessionAuthenticator` | `WebuiAuthenticator` that resolves bearer tokens through a `SessionStore`; accepted tokens map to non-operator WebUI capabilities | +| `SignedTokenSessionStore` | HMAC-signed bearer mint/lookup with a bounded process-local logout denylist | +| `SessionAuthenticator` | `WebuiAuthenticator` that resolves bearer tokens through `SignedTokenSessionStore` | | `OidcAuthenticator` | OIDC bearer-token verifier (JWKS + standard claims); accepted tokens map to non-operator WebUI capabilities | | `webui_v2_auth_router(config) -> PublicRouteMount` | OAuth login router + route descriptors. The descriptors travel with the router so composition can fold them into the descriptor-driven per-route rate-limit / body-limit middleware — same machinery the v2 facade and product-auth callback already use, no side door. | | `PublicRouteMount` | `{ router, descriptors }` pair handed to `WebuiServeConfig::with_public_route_mount` | | `OAuthProvider` trait (in `auth/provider.rs`) | Extension point for per-provider URL / code-exchange logic. Deliberately lives in its own module so each provider does not depend on the others. `GoogleProvider` and `GitHubProvider` ship today. | | `GoogleProvider` (in `auth/google.rs`) | Google OIDC provider (scopes `openid email profile`, PKCE S256, optional `hd` hosted-domain restriction). Built from `GoogleOAuthConfig`. | | `GitHubProvider` (in `auth/github.rs`) | GitHub OAuth App provider (scopes `read:user user:email`, no PKCE, verified-email preference). Built from `GitHubOAuthConfig`. | -| `OAuthRouterConfig` | Tenant + `SessionStore` + `UserDirectory` + provider list + base URL | +| `OAuthRouterConfig` | Tenant + `SignedTokenSessionStore` + `UserDirectory` + provider list + base URL | | `UserDirectory` trait | Host-supplied mapping from `(provider, OAuthUserProfile)` to `UserId` | | `EmailUserDirectory` | Local-dev default impl (verified email → `UserId`); gated on `test-support` | @@ -155,9 +156,9 @@ toolchain. ## Why the OAuth login router lives here -The crate already owns `WebuiAuthenticator` impls, `SessionStore`, and -the session lifecycle types. The OAuth callback's job is exactly that -— turn a provider profile into a `SessionStore::create_session` call +The crate already owns `WebuiAuthenticator` impls, `SignedTokenSessionStore`, +and the session lifecycle types. The OAuth callback's job is exactly that +— turn a provider profile into a signed session `create_session` call — so the login mint path belongs in the same host-owned crate, not behind the product/API seam in `ironclaw_reborn_composition`. @@ -174,7 +175,7 @@ outside bearer auth (the user has no session yet); the descriptors fold into the same per-route policy stack the rest of the WebChat v2 surface already rides on. That keeps the product/API boundary intact: composition never sees provider -secrets, never speaks to Google, never reads a `SessionStore` row. +secrets, never speaks to Google, never parses a signed session token. ## WebChat v2 OAuth login surface (#4116) @@ -188,7 +189,7 @@ Routes mounted by `webui_v2_auth_router`: - `GET /auth/callback/{provider}` — single-use state lookup, cross-provider replay guard, code exchange via the matching `OAuthProvider`, user resolution via `UserDirectory`, session - mint via `SessionStore`, and redirect to + mint via `SignedTokenSessionStore`, and redirect to `{redirect_after}?login_ticket=` (default `/`). The ticket is short-lived and single-use; the SPA redeems it over same-origin JSON so the bearer never appears in a redirect @@ -196,9 +197,8 @@ Routes mounted by `webui_v2_auth_router`: - `POST /auth/session/exchange` — consume the one-time login ticket and return `{ token }`. - `POST /auth/logout` — bearer-protected; calls - `SessionStore::revoke` and returns `204` on success or when no - bearer is present, `500` if revocation fails, so the SPA's local - clear stays unconditional without lying about server-side state. + `SignedTokenSessionStore::revoke` and returns `204` with or without + a bearer, so the SPA's local clear stays unconditional. ### Provider trait @@ -231,7 +231,7 @@ pub trait OAuthProvider: Send + Sync + 'static { - NEAR wallet login does NOT fit OAuth code flow and will get its own pair of endpoints (`/auth/near/challenge` + `/auth/near/verify`) plus its own sub-module under `auth/near/`. - The `SessionStore` + `UserDirectory` + composition seam stay the + The signed session store + `UserDirectory` + composition seam stay the same. ### Security invariants @@ -264,7 +264,7 @@ pub trait OAuthProvider: Send + Sync + 'static { (`invalid_state`, `provider_mismatch`, `denied`, `unauthorized`, `exchange_failed`, `server_error`, `invalid_request`). Provider error bodies, JWT decode messages, - and SessionStore errors are logged via `tracing` and never + and signed-session errors are logged via `tracing` and never echoed back to the client. - **Session transport** is one-time login ticket in the callback redirect (`?login_ticket=`) followed by same-origin diff --git a/crates/ironclaw_webui/Cargo.toml b/crates/ironclaw_webui/Cargo.toml index 49940845b5e..f66b21218db 100644 --- a/crates/ironclaw_webui/Cargo.toml +++ b/crates/ironclaw_webui/Cargo.toml @@ -14,11 +14,9 @@ publish = false layer = "products" [features] -# Compile in `InMemorySessionStore` + `EmailUserDirectory` as public crate -# items. Off by default so a production deployment cannot accidentally wire the -# process-local store as a `SessionStore` impl — `SessionAuthenticator` would -# happily accept it, but sessions would vanish on every restart and the O(n) -# lookup would scale poorly under real user load. Local dev and tests opt in. +# Compile in `EmailUserDirectory` as a public crate item. Off by default so a +# production deployment cannot accidentally trust provider identities without a +# host-owned user join. Local dev and tests opt in. test-support = [] [dependencies] @@ -61,11 +59,7 @@ tower = { version = "0.5", features = ["util"] } tracing = "0.1" url = "2" urlencoding = "2" -# `uuid` is consumed by `InMemorySessionStore` (session.rs) and the -# library's internal tests under `#[cfg(test)]`. The earlier attempt -# to mark it `optional = true` broke `cargo test` because the -# `cfg(test)` gate activated the `use uuid::Uuid;` import without -# pulling in the optional crate. +# `uuid` is consumed by the signed session token store and library tests. uuid = { version = "1", features = ["v4"] } [dev-dependencies] diff --git a/crates/ironclaw_webui/README.md b/crates/ironclaw_webui/README.md index 3ce74a521bc..58644136b9f 100644 --- a/crates/ironclaw_webui/README.md +++ b/crates/ironclaw_webui/README.md @@ -72,11 +72,10 @@ outside bearer auth, and where the Slack / OpenAI-compat route mounts attach. and run `axum::serve` with graceful shutdown. - **Authenticators** (`WebuiAuthenticator` impls): `EnvBearerAuthenticator` (single operator token for the standalone binary / local dev), - `SessionAuthenticator` (bearer → `SessionStore` lookup, non-operator), + `SessionAuthenticator` (bearer → `SignedTokenSessionStore` lookup), `OidcAuthenticator` (JWKS + standard-claim verifier, non-operator). -- **Sessions:** the `SessionStore` trait (durable impl is the host's; - `InMemorySessionStore` behind `test-support` for dev/tests) plus the - signed-token login surface (`build_signed_session_login`). +- **Sessions:** `SignedTokenSessionStore` plus the signed-token login surface + (`build_signed_session_login`). - **OAuth login surface:** `webui_v2_auth_router` mounts `/auth/*` and mints sessions from Google / GitHub logins. Providers plug in through the `OAuthProvider` trait. Full security model (PKCE, CSRF state, canonical host, @@ -99,7 +98,7 @@ outside bearer auth, and where the Slack / OpenAI-compat route mounts attach. | Feature | Effect | |---|---| | `default` | Route surface + SPA + serve loop + auth. | -| `test-support` | Compile in `InMemorySessionStore` + `EmailUserDirectory` for local dev / tests. | +| `test-support` | Compile in `EmailUserDirectory` for local dev / tests. | The Slack personal-OAuth setup + channel-route admin surface and the `OpenAiCompatActorScope` stamping for protected OpenAI-compatible mounts are diff --git a/crates/ironclaw_webui/src/auth/config.rs b/crates/ironclaw_webui/src/auth/config.rs index 4b040508387..07b2830b327 100644 --- a/crates/ironclaw_webui/src/auth/config.rs +++ b/crates/ironclaw_webui/src/auth/config.rs @@ -3,7 +3,7 @@ //! Host composition builds a [`GoogleOAuthConfig`] from operator //! input (env vars, TOML config) and hands it to //! [`webui_v2_auth_router`](super::webui_v2_auth_router) along with a -//! `SessionStore` and a `UserDirectory`. The composition layer is +//! `SignedTokenSessionStore` and a `UserDirectory`. The composition layer is //! responsible for picking which providers are enabled; this crate //! never reads env vars directly so a binary that uses a different //! config source can still wire it. diff --git a/crates/ironclaw_webui/src/auth/mod.rs b/crates/ironclaw_webui/src/auth/mod.rs index 98a8b16788e..22a08c38fe9 100644 --- a/crates/ironclaw_webui/src/auth/mod.rs +++ b/crates/ironclaw_webui/src/auth/mod.rs @@ -9,7 +9,7 @@ //! a CSRF state + PKCE verifier and redirects to the provider. //! - `GET /auth/callback/{provider}` — exchange the code, resolve //! the user through [`UserDirectory`], create a session via -//! [`SessionStore`](crate::SessionStore), and land the browser on +//! [`SignedTokenSessionStore`](crate::SignedTokenSessionStore), and land the browser on //! the SPA with a one-time exchange ticket. //! - `POST /auth/session/exchange` — consume the one-time ticket and //! return the bearer over same-origin JSON. diff --git a/crates/ironclaw_webui/src/auth/routes.rs b/crates/ironclaw_webui/src/auth/routes.rs index 80e51c02dd9..4b1c66b4e61 100644 --- a/crates/ironclaw_webui/src/auth/routes.rs +++ b/crates/ironclaw_webui/src/auth/routes.rs @@ -39,7 +39,7 @@ use super::pending::{PendingFlowStore, SessionTicketStore, sanitize_redirect}; use super::provider::OAuthProvider; use super::provider_name::OAuthProviderName; use super::user_directory::{UserDirectory, UserDirectoryError}; -use crate::session::SessionStore; +use crate::signed_session_login::SignedTokenSessionStore; /// Default landing page after a successful OAuth callback. The SPA /// reads `?login_ticket=` and exchanges it for a bearer. @@ -59,7 +59,7 @@ const DEFAULT_SESSION_LIFETIME: ChronoDuration = ChronoDuration::seconds(30 * 24 #[derive(Clone)] pub struct OAuthRouterConfig { pub tenant_id: TenantId, - pub session_store: Arc, + pub session_store: Arc, pub user_directory: Arc, pub providers: Vec>, pub base_url: String, @@ -70,7 +70,7 @@ impl OAuthRouterConfig { /// Build a config with the default 30-day session lifetime. pub fn new( tenant_id: TenantId, - session_store: Arc, + session_store: Arc, user_directory: Arc, providers: Vec>, base_url: impl Into, @@ -94,7 +94,7 @@ impl OAuthRouterConfig { /// Internal state shared across all `/auth/*` handlers. struct RouterState { tenant_id: TenantId, - session_store: Arc, + session_store: Arc, user_directory: Arc, providers: Vec>, base_url: String, @@ -305,7 +305,7 @@ fn sso_justification() -> IngressJustification { "webui-v2 sso", "OAuth login surface is unauthenticated by design — \ the user has no session yet; the handler mints one on \ - successful callback through SessionStore", + successful callback through the signed session store", ) .expect("SSO justification literal must validate") // safety: non-empty, no leading/trailing whitespace. } @@ -576,22 +576,15 @@ async fn session_exchange_handler( // ─── /auth/logout ───────────────────────────────────────────────────── -/// `POST /auth/logout` — revoke the bearer session and clear it from -/// the durable session store. Honors `Authorization: Bearer ` -/// only — query-token shim is reserved for the SSE route per the -/// composition's `extract_bearer_token` policy. +/// `POST /auth/logout` — revoke the bearer session in the signed-token store's +/// process-local denylist. Honors `Authorization: Bearer ` only — +/// query-token shim is reserved for the SSE route per the composition's +/// `extract_bearer_token` policy. /// /// **Status contract:** -/// - `204 No Content` when there is no bearer header (idempotent -/// sign-out — the SPA clears local state unconditionally), OR -/// when the session store confirms the revoke. -/// - `500 Internal Server Error` when the session store backend -/// fails to revoke. A success status in this case would lie to -/// the caller: the bearer might still authenticate in another -/// tab or another client until natural expiry, violating the -/// PR's revoke contract. The SPA still clears its local copy -/// regardless of the response status — losing the local token -/// is strictly weaker than a stale bearer roaming the network. +/// - `204 No Content` when there is no bearer header (idempotent sign-out — the +/// SPA clears local state unconditionally), OR after denylisting the presented +/// bearer. async fn logout_handler( State(state): State, headers: axum::http::HeaderMap, @@ -599,18 +592,8 @@ async fn logout_handler( let Some(token) = extract_bearer(&headers) else { return StatusCode::NO_CONTENT.into_response(); }; - match state.session_store.revoke(&token).await { - Ok(()) => StatusCode::NO_CONTENT.into_response(), - Err(err) => { - tracing::warn!( - target = "ironclaw::reborn::webui_ingress::auth", - error = %err, - "session store revoke failed during logout — returning 500 so the \ - client knows the server-side revocation did not complete", - ); - StatusCode::INTERNAL_SERVER_ERROR.into_response() - } - } + state.session_store.revoke(&token).await; + StatusCode::NO_CONTENT.into_response() } // ─── helpers ────────────────────────────────────────────────────────── diff --git a/crates/ironclaw_webui/src/auth/user_directory.rs b/crates/ironclaw_webui/src/auth/user_directory.rs index 6af117f9eaf..62c6e2da512 100644 --- a/crates/ironclaw_webui/src/auth/user_directory.rs +++ b/crates/ironclaw_webui/src/auth/user_directory.rs @@ -63,9 +63,8 @@ pub trait UserDirectory: Send + Sync + 'static { /// is available. Production deployments should swap this for a /// DB-backed impl that joins to the real user table. /// -/// Gated behind `test-support` for the same reason -/// [`crate::InMemorySessionStore`] is: a production binary cannot -/// accidentally name this impl as its `UserDirectory` — that would +/// Gated behind `test-support` so a production binary cannot accidentally name +/// this impl as its `UserDirectory` — that would /// trust the provider's `sub` blindly without a join against the /// installation's user table. #[cfg(any(test, feature = "test-support"))] diff --git a/crates/ironclaw_webui/src/cli_token_login.rs b/crates/ironclaw_webui/src/cli_token_login.rs index 6b8d50fb913..63b8701c673 100644 --- a/crates/ironclaw_webui/src/cli_token_login.rs +++ b/crates/ironclaw_webui/src/cli_token_login.rs @@ -5,7 +5,7 @@ //! //! Flow (mirrors `auth::routes::callback_handler`): //! - `GET /login?token=...`: verify via [`crate::EnvBearerAuthenticator`], -//! mint a bearer via [`SessionStore`], redirect to +//! mint a bearer via [`SignedTokenSessionStore`], redirect to //! `?login_ticket=` (same convention as OAuth). //! - `POST /auth/session/exchange`: consumes the ticket, returns //! `{ "token": "..." }` — byte-for-byte the same contract as @@ -44,7 +44,7 @@ use secrecy::{ExposeSecret, SecretString}; use serde::{Deserialize, Serialize}; use crate::WebuiAuthenticator; -use crate::session::SessionStore; +use crate::signed_session_login::SignedTokenSessionStore; /// Matches the OAuth callback's default so the SPA lands in the same /// place regardless of which flow authenticated it. @@ -83,7 +83,7 @@ pub struct CliTokenLoginConfig { /// [`crate::signed_session_store`] built from the same operator /// secret + tenant as the CLI's admin bearer minter, so the bearer /// validates anywhere that store is reconstructed. - pub session_store: Arc, + pub session_store: Arc, /// Session lifetime for the minted bearer. Defaults to 30 days. pub session_lifetime: ChronoDuration, /// Path the SPA lands on after the ticket is placed in the @@ -95,7 +95,7 @@ impl CliTokenLoginConfig { pub fn new( tenant_id: TenantId, authenticator: Arc, - session_store: Arc, + session_store: Arc, ) -> Self { Self { tenant_id, @@ -121,7 +121,7 @@ impl CliTokenLoginConfig { struct RouterState { tenant_id: TenantId, authenticator: Arc, - session_store: Arc, + session_store: Arc, session_lifetime: ChronoDuration, redirect_after: String, tickets: LoginTicketStore, diff --git a/crates/ironclaw_webui/src/lib.rs b/crates/ironclaw_webui/src/lib.rs index b121d567600..ea8a97d3b78 100644 --- a/crates/ironclaw_webui/src/lib.rs +++ b/crates/ironclaw_webui/src/lib.rs @@ -70,19 +70,14 @@ pub use oidc::{ AudienceClaim, ClaimToUserIdFn, IdTokenClaims, OidcAuthenticator, OidcAuthenticatorConfig, OidcAuthenticatorError, }; -pub use session::{SessionAuthenticator, SessionRecord, SessionStore, SessionStoreError}; +pub use session::{SessionAuthenticator, SessionRecord, SessionStoreError}; // Host-owned signed-token login surface (production-suitable, non-dev): // the standalone `serve` binary supplies env config and calls the // builder; the auth/session model lives here, not in the command crate. pub use signed_session_login::{ CompositeAuthenticator, SignedSessionLoginConfig, SignedSessionLoginWiring, - build_signed_session_login, signed_session_store, + SignedTokenSessionStore, build_signed_session_login, signed_session_store, }; -// `InMemorySessionStore` is gated behind `test-support` so a -// production binary cannot accidentally wire a process-local store as -// a `SessionStore` impl. Local dev and tests opt in via the feature. -#[cfg(any(test, feature = "test-support"))] -pub use session::InMemorySessionStore; use std::convert::Infallible; use std::net::SocketAddr; diff --git a/crates/ironclaw_webui/src/oidc.rs b/crates/ironclaw_webui/src/oidc.rs index 9abf26d7c8b..b3305a62e0c 100644 --- a/crates/ironclaw_webui/src/oidc.rs +++ b/crates/ironclaw_webui/src/oidc.rs @@ -9,9 +9,8 @@ //! //! - **Not** a full OIDC client — there is no authorization-code //! exchange, no PKCE, no token endpoint, no refresh handling. Those -//! live in whatever sign-in path the host binary owns (it then -//! typically mints a Reborn session via the `SessionStore` from -//! [`crate::session`]). +//! live in whatever sign-in path the host binary owns (it can then +//! mint a Reborn session via [`crate::signed_session_store`]). //! - **Not** an audience-discovery layer — the host config names a //! fixed `audience` and `issuer`, and JWTs not matching both are //! rejected. diff --git a/crates/ironclaw_webui/src/session.rs b/crates/ironclaw_webui/src/session.rs index 345e6e295bc..98384044b19 100644 --- a/crates/ironclaw_webui/src/session.rs +++ b/crates/ironclaw_webui/src/session.rs @@ -2,42 +2,28 @@ //! gateway. //! //! A session is the opaque bearer token the browser presents back on -//! every request after a successful login. The actual login flow that -//! mints the session is **outside** this module — host code calls -//! `SessionStore::insert` after whatever sign-in path it uses (password -//! form, magic link, OIDC, etc.). +//! every request after a successful login. The built-in login paths mint +//! signed session tokens via [`SignedTokenSessionStore`]. //! -//! The store impl shipped here is in-memory only. Production -//! deployments wire their own `SessionStore` (Postgres, libSQL, -//! filesystem) by implementing the trait. +//! The built-in production store is the signed-token store in +//! `signed_session_login`. use std::sync::Arc; -use crate::{WebuiAuthentication, WebuiAuthenticator}; +use crate::{ + WebuiAuthentication, WebuiAuthenticator, signed_session_login::SignedTokenSessionStore, +}; use async_trait::async_trait; -use chrono::{DateTime, Duration as ChronoDuration, Utc}; +use chrono::{DateTime, Utc}; use ironclaw_host_api::{TenantId, UserId}; -use secrecy::SecretString; use serde::{Deserialize, Serialize}; use thiserror::Error; -// Imports below are only used by `InMemorySessionStore`, which is gated -// behind `test-support`. Gate the imports too so non-feature -// production builds don't warn about unused imports. -#[cfg(any(test, feature = "test-support"))] -use parking_lot::RwLock; -#[cfg(any(test, feature = "test-support"))] -use std::collections::HashMap; -#[cfg(any(test, feature = "test-support"))] -use subtle::ConstantTimeEq; -#[cfg(any(test, feature = "test-support"))] -use uuid::Uuid; - /// Non-secret session identifier — a UUID stamped at creation and /// safe to log, render in audit trails, or surface to operators. The -/// bearer token returned by [`SessionStore::create_session`] is a +/// bearer token returned by [`SignedTokenSessionStore::create_session`] is a /// SEPARATE secret value, returned wrapped in [`SecretString`], and -/// is the lookup key on the durable store. The record below carries +/// is the signed token presented by the browser. The record below carries /// only this non-secret id, never the bearer. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(transparent)] @@ -60,12 +46,10 @@ impl std::fmt::Display for SessionId { } /// Persisted session record. Carries non-secret metadata only — the -/// bearer token returned by [`SessionStore::create_session`] is the -/// `HashMap` lookup key in the in-memory impl (or its hash in a -/// durable backend) and is deliberately ABSENT from this struct so -/// `Debug` / `Serialize` impls cannot accidentally surface live -/// bearer material. The non-secret [`SessionId`] is a UUID stamped -/// at creation; safe to log and audit-trace. +/// bearer token returned by [`SignedTokenSessionStore::create_session`] is +/// deliberately ABSENT from this struct so `Debug` / `Serialize` impls cannot +/// accidentally surface live bearer material. The non-secret [`SessionId`] is +/// a UUID stamped at creation; safe to log and audit-trace. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct SessionRecord { pub session_id: SessionId, @@ -75,7 +59,7 @@ pub struct SessionRecord { pub expires_at: DateTime, /// Whether this session carries the single-trusted-operator capability /// (`WebuiAuthentication::operator`, `WebUiV2Capabilities::operator_webui_config`). - /// Stamped once at mint time by the caller of [`SessionStore::create_session`] + /// Stamped once at mint time by the caller of [`SignedTokenSessionStore::create_session`] /// — provenance-based, never re-derived from the bearer at validation time — /// per the invariant that only a token verified against the host's /// operator-capable authenticator (the raw `Authorization: Bearer` env @@ -97,7 +81,7 @@ impl SessionRecord { } } -/// Errors raised by [`SessionStore`] implementations. +/// Errors raised by signed session operations. #[derive(Debug, Error)] pub enum SessionStoreError { #[error("session not found")] @@ -106,162 +90,15 @@ pub enum SessionStoreError { Backend(String), } -/// Pluggable session backend. Host binaries implement this against -/// whatever durable store they prefer; the in-memory impl below is -/// fine for local dev and tests. -#[async_trait] -pub trait SessionStore: Send + Sync + 'static { - /// Issue a new session bound to the supplied caller and lifetime. - /// Returns the freshly minted bearer token; persist `record` keyed - /// on this token (or whatever lookup encoding the backend prefers). - /// - /// `operator` is stamped onto the resulting [`SessionRecord`] and MUST be - /// `true` only when the caller has independently verified the credential - /// that authorized this mint against an operator-capable authenticator - /// (e.g. the host's env-bearer token). Every other login path — OAuth/SSO, - /// admin-provisioned per-user bearers — passes `false`. This is a - /// provenance stamp recorded once at mint time, never re-derived from the - /// bearer itself at validation time. - async fn create_session( - &self, - tenant_id: TenantId, - user_id: UserId, - lifetime: ChronoDuration, - operator: bool, - ) -> Result; - - /// Look up the session record bound to `candidate`. Implementations - /// MUST use constant-time comparison on the secret material. - async fn lookup(&self, candidate: &str) -> Result, SessionStoreError>; - - /// Optional: revoke a session early. The default impl is a no-op - /// because the in-memory store wipes on process restart anyway; - /// durable backends should override. - async fn revoke(&self, _candidate: &str) -> Result<(), SessionStoreError> { - Ok(()) - } -} - -/// Process-local session store. Sessions vanish on restart. Useful -/// for local dev and the caller-level test harness — production -/// deployments wire a durable `SessionStore` impl. -/// -/// Gated behind `test-support` so a production binary cannot -/// accidentally name this type as its `SessionStore`. Tests are -/// implicitly enabled via `cfg(test)`. -#[cfg(any(test, feature = "test-support"))] -#[derive(Debug, Default)] -pub struct InMemorySessionStore { - inner: RwLock>, -} - -#[cfg(any(test, feature = "test-support"))] -impl InMemorySessionStore { - pub fn new() -> Self { - Self::default() - } - - /// Count of active sessions — diagnostic helper for tests. - pub fn len(&self) -> usize { - self.inner.read().len() - } - - pub fn is_empty(&self) -> bool { - self.inner.read().is_empty() - } - - /// Evict every session whose `expires_at` is at or before `now`. - /// Returns the number of records removed. Called automatically - /// from `create_session` and `lookup` so a long-running process - /// cannot leak memory through never-revoked expired sessions. - pub fn purge_expired(&self, now: DateTime) -> usize { - let mut guard = self.inner.write(); - let before = guard.len(); - guard.retain(|_, record| !record.is_expired(now)); - before - guard.len() - } -} - -#[cfg(any(test, feature = "test-support"))] -#[async_trait] -impl SessionStore for InMemorySessionStore { - async fn create_session( - &self, - tenant_id: TenantId, - user_id: UserId, - lifetime: ChronoDuration, - operator: bool, - ) -> Result { - // Two distinct UUIDs: one is the operator-visible audit id - // (`SessionId`, OK to log), the other is the bearer token - // returned to the caller. The bearer is the HashMap lookup - // key in this in-memory impl; `SessionRecord` carries only - // the audit id so a `Debug` / `Serialize` of a record never - // leaks live bearer material. - let session_id = SessionId::new(Uuid::new_v4().to_string()); - let bearer = Uuid::new_v4().to_string(); - let now = Utc::now(); - // Opportunistic GC: sweep expired records on every insert so - // the map size stays proportional to "active sessions", not - // "every session ever issued". - self.purge_expired(now); - let record = SessionRecord { - session_id, - tenant_id, - user_id, - created_at: now, - expires_at: now - .checked_add_signed(lifetime) - .ok_or_else(|| SessionStoreError::Backend("session lifetime overflow".into()))?, - operator, - }; - self.inner.write().insert(bearer.clone(), record); - Ok(SecretString::from(bearer)) - } - - async fn lookup(&self, candidate: &str) -> Result, SessionStoreError> { - // Opportunistic GC: same reasoning as create_session; bounded - // by the lock contention on the write path so we only invoke - // on the warm path when there is at least one entry present. - if !self.inner.read().is_empty() { - self.purge_expired(Utc::now()); - } - - // Walk the map with constant-time comparison so that a hostile - // caller cannot use timing to discover whether their guess - // shares a prefix with a real session bearer. We accept the - // O(n) walk because the in-memory store's expected workload - // is small (interactive single-binary deployment). - let guard = self.inner.read(); - let mut hit: Option = None; - for (bearer, record) in guard.iter() { - // ConstantTimeEq returns 1 on equal-length matches only; - // length mismatch is also handled in constant time. - if bearer.as_bytes().ct_eq(candidate.as_bytes()).into() { - hit = Some(record.clone()); - // Do not break — keep the loop running so the timing - // does not depend on which entry matched. - continue; - } - } - Ok(hit) - } - - async fn revoke(&self, candidate: &str) -> Result<(), SessionStoreError> { - self.inner.write().remove(candidate); - Ok(()) - } -} - /// `WebuiAuthenticator` impl that resolves the bearer token to a /// stored session, checking expiry against the wall clock. #[derive(Clone)] pub struct SessionAuthenticator { - store: Arc, + store: Arc, } impl SessionAuthenticator { - pub fn new(store: Arc) -> Self { + pub fn new(store: Arc) -> Self { Self { store } } } @@ -293,7 +130,7 @@ impl WebuiAuthenticator for SessionAuthenticator { error = %error, "session store lookup failed; treating bearer as unauthenticated. \ Operators: this is a backend/infra fault, not an auth miss — \ - investigate the underlying SessionStore impl.", + investigate the signed session store.", ); return None; } @@ -320,7 +157,9 @@ impl WebuiAuthenticator for SessionAuthenticator { #[cfg(test)] mod tests { use super::*; + use chrono::Duration as ChronoDuration; use secrecy::ExposeSecret; + use secrecy::SecretString; fn tenant() -> TenantId { TenantId::new("tenant-a").expect("tenant") @@ -328,10 +167,13 @@ mod tests { fn user() -> UserId { UserId::new("alice").expect("user") } + fn store() -> Arc { + crate::signed_session_store(&SecretString::from("test-session-secret"), &tenant()) + } #[tokio::test] async fn create_then_lookup_returns_session() { - let store = InMemorySessionStore::new(); + let store = store(); let token = store .create_session(tenant(), user(), ChronoDuration::hours(1), false) .await @@ -344,27 +186,15 @@ mod tests { assert_eq!(record.user_id.as_str(), "alice"); } - #[tokio::test] - async fn expired_session_is_rejected_by_authenticator() { - let store = Arc::new(InMemorySessionStore::new()); - let token = store - .create_session(tenant(), user(), ChronoDuration::seconds(-1), false) - .await - .expect("create"); - let auth = SessionAuthenticator::new(store.clone()); - assert!(auth.authenticate(token.expose_secret()).await.is_none()); - } - #[tokio::test] async fn unknown_token_is_rejected() { - let store = Arc::new(InMemorySessionStore::new()); - let auth = SessionAuthenticator::new(store); + let auth = SessionAuthenticator::new(store()); assert!(auth.authenticate("nonexistent-token").await.is_none()); } #[tokio::test] async fn live_session_resolves_to_caller_user_id() { - let store = Arc::new(InMemorySessionStore::new()); + let store = store(); let token = store .create_session(tenant(), user(), ChronoDuration::hours(1), false) .await @@ -382,7 +212,7 @@ mod tests { // WebuiAuthentication::operator, not just ::user. #[tokio::test] async fn session_minted_as_operator_resolves_to_operator_capabilities() { - let store = Arc::new(InMemorySessionStore::new()); + let store = store(); let token = store .create_session(tenant(), user(), ChronoDuration::hours(1), true) .await @@ -408,7 +238,7 @@ mod tests { // regression pin for the escalation guard the crate docs describe. #[tokio::test] async fn session_minted_as_non_operator_never_escalates() { - let store = Arc::new(InMemorySessionStore::new()); + let store = store(); let token = store .create_session(tenant(), user(), ChronoDuration::hours(1), false) .await @@ -455,7 +285,7 @@ mod tests { // session secret. #[tokio::test] async fn session_record_debug_and_serialize_do_not_contain_bearer() { - let store = InMemorySessionStore::new(); + let store = store(); let token = store .create_session(tenant(), user(), ChronoDuration::hours(1), false) .await @@ -486,49 +316,4 @@ mod tests { "wire shape must carry the non-secret SessionId; got: {json}", ); } - - // Regression for the backend-error-vs-auth-miss review (Medium): - // when the underlying store returns `Err(...)`, the - // authenticator must still fail closed (return None) but must - // NOT silently swallow the error — operators investigating a - // DB / cache outage need a log line distinct from a normal - // 401 auth miss. - #[tokio::test] - async fn authenticator_logs_backend_error_and_still_returns_none() { - struct ErrorStore; - #[async_trait] - impl SessionStore for ErrorStore { - async fn create_session( - &self, - _tenant_id: TenantId, - _user_id: UserId, - _lifetime: ChronoDuration, - _operator: bool, - ) -> Result { - unreachable!() - } - async fn lookup( - &self, - _candidate: &str, - ) -> Result, SessionStoreError> { - Err(SessionStoreError::Backend( - "simulated DB outage for regression test".into(), - )) - } - } - let auth = SessionAuthenticator::new(Arc::new(ErrorStore)); - // Authentication still fails closed — never leak the reason - // to the client. - assert!( - auth.authenticate("any-token").await.is_none(), - "backend error must still return None to the gateway", - ); - // The behavior under test is that a backend Err takes a - // different code path than `Ok(None)` (the latter must not - // log at `warn!`). The presence of the explicit `Err(...)` - // match arm is what this test locks in — a regression that - // collapses it back to `let Ok(...) else { return None }` - // would still pass `is_none()` but lose the operator log. - // We capture that contract by reading the source. - } } diff --git a/crates/ironclaw_webui/src/signed_session_login.rs b/crates/ironclaw_webui/src/signed_session_login.rs index 5b9bb31b094..a8606d0f302 100644 --- a/crates/ironclaw_webui/src/signed_session_login.rs +++ b/crates/ironclaw_webui/src/signed_session_login.rs @@ -1,6 +1,4 @@ -//! Stateless, HMAC-signed session login wiring — the host-owned, -//! production-suitable counterpart to the dev-only -//! [`InMemorySessionStore`](crate::session::InMemorySessionStore). +//! Stateless, HMAC-signed session login wiring. //! //! [`build_signed_session_login`] assembles the pieces the //! `ironclaw-reborn serve` binary needs to mount the OAuth login @@ -8,10 +6,10 @@ //! ids/secrets, base URL, operator secret) plus a host-owned //! [`UserDirectory`] and calls the builder — it does not own the //! auth/session model. That keeps the rule from this crate's guardrails -//! intact: `WebuiAuthenticator` / `SessionStore` implementations live -//! here, not in the command crate. +//! intact: the host-owned WebUI authentication model lives here, not in +//! the command crate. //! -//! - [`SignedTokenSessionStore`] — a `SessionStore` whose bearer token +//! - [`SignedTokenSessionStore`] — a session backend whose bearer token //! carries the tenant/user/expiry, HMAC-SHA256-signed with a key //! derived from the operator secret. Validation needs no persistence, //! so tokens survive a restart as long as the operator secret is @@ -19,8 +17,7 @@ //! denylist, so `POST /auth/logout` truly invalidates the presented //! bearer rather than returning `204` while the token stays live. The //! denylist is process-local and clears on restart, after which a -//! not-yet-expired revoked token would validate again; a deployment -//! needing durable revocation supplies a DB-backed `SessionStore`. +//! not-yet-expired revoked token would validate again. //! - The `user_directory` (host-supplied via `SignedSessionLoginConfig`) //! maps each authenticated OAuth profile to a `UserId`. A multi-user //! host injects a DB-backed directory (a distinct user per identity); @@ -48,9 +45,7 @@ use uuid::Uuid; use crate::auth::{ OAuthProvider, OAuthRouterConfig, PublicRouteMount, UserDirectory, webui_v2_auth_router, }; -use crate::session::{ - SessionAuthenticator, SessionId, SessionRecord, SessionStore, SessionStoreError, -}; +use crate::session::{SessionAuthenticator, SessionId, SessionRecord, SessionStoreError}; type HmacSha256 = Hmac; @@ -96,9 +91,8 @@ pub fn build_signed_session_login( return None; } - let session_store: Arc = Arc::new( - SignedTokenSessionStore::from_operator_secret(&config.operator_secret, &config.tenant_id), - ); + let session_store = + SignedTokenSessionStore::from_operator_secret(&config.operator_secret, &config.tenant_id); let session_authenticator: Arc = Arc::new(SessionAuthenticator::new(session_store.clone())); @@ -130,10 +124,10 @@ pub fn build_signed_session_login( /// `exp`). const MAX_REVOKED_ENTRIES: usize = 4096; -/// Stateless `SessionStore` whose "record" is the cryptographic +/// Stateless session backend whose "record" is the cryptographic /// signature itself (HMAC-SHA256 over the base64url payload), plus a /// process-local denylist that makes `revoke` (logout) effective. -struct SignedTokenSessionStore { +pub struct SignedTokenSessionStore { key: Vec, /// Host tenant this store is bound to. The signing key is derived from /// it, and `lookup` re-checks it as defense in depth. @@ -146,7 +140,7 @@ struct SignedTokenSessionStore { revoked: RwLock>, } -/// Build a signed-token [`SessionStore`] for minting/validating bearers from an +/// Build a signed-token store for minting/validating bearers from an /// operator secret + tenant. The store is stateless and deterministic in the /// signing key, so an instance built here mints tokens that validate under any /// other instance sharing the same operator secret + tenant (e.g. the SSO login @@ -156,11 +150,8 @@ struct SignedTokenSessionStore { pub fn signed_session_store( operator_secret: &SecretString, tenant_id: &TenantId, -) -> std::sync::Arc { - std::sync::Arc::new(SignedTokenSessionStore::from_operator_secret( - operator_secret, - tenant_id, - )) +) -> Arc { + SignedTokenSessionStore::from_operator_secret(operator_secret, tenant_id) } impl SignedTokenSessionStore { @@ -171,7 +162,7 @@ impl SignedTokenSessionStore { /// one operator secret but serve different tenants derive different /// keys, so neither can validate the other's session tokens — a token /// minted for one tenant fails the HMAC check on the other. - fn from_operator_secret(secret: &SecretString, tenant_id: &TenantId) -> Self { + pub fn from_operator_secret(secret: &SecretString, tenant_id: &TenantId) -> Arc { let mut hasher = Sha256::new(); hasher.update(b"ironclaw-reborn-webui-session-v1::"); // Length-prefix the tenant so its bytes can never be confused with @@ -182,11 +173,11 @@ impl SignedTokenSessionStore { hasher.update(tenant_bytes); hasher.update(b"::"); hasher.update(secret.expose_secret().as_bytes()); - Self { + Arc::new(Self { key: hasher.finalize().to_vec(), tenant_id: tenant_id.clone(), revoked: RwLock::new(HashMap::new()), - } + }) } /// Fresh keyed MAC. `new_from_slice` is infallible for HMAC — it @@ -214,27 +205,7 @@ impl SignedTokenSessionStore { let payload_json = URL_SAFE_NO_PAD.decode(payload_b64).ok()?; serde_json::from_slice::(&payload_json).ok() } -} - -/// Wire payload encoded into the bearer token. Field names are short to -/// keep the token compact; this struct is never persisted. -#[derive(Serialize, Deserialize)] -struct TokenPayload { - sid: String, - tenant: String, - user: String, - iat: i64, - exp: i64, - /// Operator-capability stamp (see `SessionRecord::operator`). Defaults - /// to non-operator so pre-existing tokens fail closed rather than - /// retroactively escalating. - #[serde(default)] - op: bool, -} - -#[async_trait] -impl SessionStore for SignedTokenSessionStore { - async fn create_session( + pub async fn create_session( &self, tenant_id: TenantId, user_id: UserId, @@ -268,7 +239,10 @@ impl SessionStore for SignedTokenSessionStore { Ok(SecretString::from(format!("{payload_b64}.{signature}"))) } - async fn lookup(&self, candidate: &str) -> Result, SessionStoreError> { + pub async fn lookup( + &self, + candidate: &str, + ) -> Result, SessionStoreError> { let Some(payload) = self.verify(candidate) else { return Ok(None); }; @@ -311,14 +285,14 @@ impl SessionStore for SignedTokenSessionStore { })) } - async fn revoke(&self, candidate: &str) -> Result<(), SessionStoreError> { + pub async fn revoke(&self, candidate: &str) { // Only a validly-signed, not-yet-expired token carries a session // id worth denying; a garbage or already-expired bearer has nothing // to revoke, so logout is a silent success. if let Some(payload) = self.verify(candidate) { let now = Utc::now().timestamp(); if payload.exp <= now { - return Ok(()); + return; } let mut guard = self.revoked.write(); // Common case: O(1) insert. Only when the map reaches the cap do @@ -340,10 +314,25 @@ impl SessionStore for SignedTokenSessionStore { } guard.insert(payload.sid, payload.exp); } - Ok(()) } } +/// Wire payload encoded into the bearer token. Field names are short to +/// keep the token compact; this struct is never persisted. +#[derive(Serialize, Deserialize)] +struct TokenPayload { + sid: String, + tenant: String, + user: String, + iat: i64, + exp: i64, + /// Operator-capability stamp (see `SessionRecord::operator`). Defaults + /// to non-operator so pre-existing tokens fail closed rather than + /// retroactively escalating. + #[serde(default)] + op: bool, +} + /// `WebuiAuthenticator` that accepts a bearer recognized by EITHER the /// session token or the env operator token. Keeping the env-bearer path /// live means the existing scripted `Authorization: Bearer` workflow @@ -397,14 +386,14 @@ mod tests { TenantId::new("tenant-a").expect("tenant") } - fn signed_store(secret: &str) -> SignedTokenSessionStore { + fn signed_store(secret: &str) -> Arc { SignedTokenSessionStore::from_operator_secret( &SecretString::from(secret.to_string()), &tenant(), ) } - fn signed_store_for(secret: &str, tenant_id: &TenantId) -> SignedTokenSessionStore { + fn signed_store_for(secret: &str, tenant_id: &TenantId) -> Arc { SignedTokenSessionStore::from_operator_secret( &SecretString::from(secret.to_string()), tenant_id, @@ -534,7 +523,7 @@ mod tests { let raw = token.expose_secret().to_string(); assert!(store.lookup(&raw).await.expect("lookup").is_some()); - store.revoke(&raw).await.expect("revoke"); + store.revoke(&raw).await; assert!( store.lookup(&raw).await.expect("lookup").is_none(), "a revoked token must no longer authenticate" @@ -550,9 +539,8 @@ mod tests { // store built from the SAME operator secret + tenant (a process // restart) re-accepts a revoked-but-unexpired token, because the token // is a stateless valid HMAC and the new process starts with an empty - // denylist. A deployment needing durable revocation supplies a - // DB-backed SessionStore; this test pins that logout does not survive a - // restart with the stateless store. + // denylist. This test pins that logout does not survive a restart with + // the stateless store. let store_a = signed_store("operator-secret"); let token = store_a .create_session( @@ -565,7 +553,7 @@ mod tests { .expect("create"); let raw = token.expose_secret().to_string(); - store_a.revoke(&raw).await.expect("revoke"); + store_a.revoke(&raw).await; assert!( store_a.lookup(&raw).await.expect("lookup").is_none(), "the minting store rejects the revoked token in-process", @@ -614,7 +602,7 @@ mod tests { // entry closest to expiry") is guaranteed to target it once the cap is // reached — while it stays on the denylist, it is rejected. let victim = raw("victim", base + 60); - store.revoke(&victim).await.expect("revoke victim"); + store.revoke(&victim).await; assert!( store.lookup(&victim).await.expect("lookup").is_none(), "a revoked token on the denylist must be rejected", @@ -625,7 +613,7 @@ mod tests { // evict the soonest-expiry entry (the victim) to stay bounded. for i in 0..MAX_REVOKED_ENTRIES { let filler = raw(&format!("filler-{i}"), base + 3600); - store.revoke(&filler).await.expect("revoke filler"); + store.revoke(&filler).await; } // The denylist stayed bounded at/under the hard cap. diff --git a/crates/ironclaw_webui/src/webui_serve.rs b/crates/ironclaw_webui/src/webui_serve.rs index a8b17633ac7..e151d3f06a8 100644 --- a/crates/ironclaw_webui/src/webui_serve.rs +++ b/crates/ironclaw_webui/src/webui_serve.rs @@ -356,7 +356,7 @@ impl WebuiServeConfig { /// design — they implement the same protocol on two /// independent listeners. Merging the v1 router here would /// conflict with the v2-native router and, more importantly, - /// would route v1 traffic into the v2 host-owned `SessionStore` + /// would route v1 traffic into the v2 host-owned signed session store /// it never had access to. The v2 listener is exclusively for /// `webui_v2_auth_router` (and any future host-native public /// surface that follows the same boundary rules). diff --git a/crates/ironclaw_webui/tests/auth_route_contract.rs b/crates/ironclaw_webui/tests/auth_route_contract.rs index 07b2d954c05..96ace94ef72 100644 --- a/crates/ironclaw_webui/tests/auth_route_contract.rs +++ b/crates/ironclaw_webui/tests/auth_route_contract.rs @@ -14,7 +14,7 @@ //! 3. `Bearer` prefix parsing is case-insensitive — parity with v1's //! `auth.rs` extractor (documented as a KEEP in //! `docs/reborn/security-parity/01-auth.md`). -//! 4. A session revoked directly through `SessionStore::revoke` stops +//! 4. A session revoked directly through `SignedTokenSessionStore::revoke` stops //! authenticating, isolated from the OAuth round-trip. //! 5. An expired session is rejected at the route layer (the //! `session.rs` unit test only covers `authenticate()` in isolation). @@ -30,6 +30,7 @@ #![cfg(feature = "test-support")] use std::sync::Arc; +use std::time::Duration; use axum::body::Body; use axum::http::{HeaderValue, Method, Request, StatusCode, header}; @@ -37,8 +38,8 @@ use chrono::Duration as ChronoDuration; use ironclaw_host_api::{AgentId, ProjectId, TenantId, UserId}; use ironclaw_reborn_composition::{RebornReadiness, RebornWebuiBundle}; use ironclaw_webui::{ - EnvBearerAuthenticator, InMemorySessionStore, OidcAuthenticator, OidcAuthenticatorConfig, - SessionAuthenticator, SessionStore, + EnvBearerAuthenticator, OidcAuthenticator, OidcAuthenticatorConfig, SessionAuthenticator, + SignedTokenSessionStore, signed_session_store, }; use ironclaw_webui::{WebuiAuthenticator, WebuiServeConfig, webui_v2_app}; use secrecy::{ExposeSecret, SecretString}; @@ -97,8 +98,19 @@ fn env_bearer_app() -> (axum::Router, Arc) { compose(authenticator) } -fn session_app() -> (axum::Router, Arc, Arc) { - let store: Arc = Arc::new(InMemorySessionStore::new()); +fn signed_store_for(tenant_id: &TenantId) -> Arc { + signed_session_store( + &SecretString::from("operator-secret".to_string()), + tenant_id, + ) +} + +fn session_app() -> ( + axum::Router, + Arc, + Arc, +) { + let store = signed_store_for(&TenantId::new(TENANT).expect("tenant")); let authenticator = Arc::new(SessionAuthenticator::new(store.clone())); let (app, services) = compose(authenticator); (app, services, store) @@ -281,8 +293,11 @@ async fn revoked_session_bearer_rejected() { ); assert_eq!(callers_len(&services), 1); - store.revoke(&bearer).await.expect("revoke"); - assert_eq!(store.len(), 0, "revoke must drop the session"); + store.revoke(&bearer).await; + assert!( + store.lookup(&bearer).await.expect("lookup").is_none(), + "revoke must denylist the session", + ); let after_revoke = app .oneshot(create_thread_request(Some(&format!("Bearer {bearer}")))) @@ -302,7 +317,7 @@ async fn revoked_session_bearer_rejected() { #[tokio::test] async fn expired_session_bearer_rejected_on_route() { - // The `session.rs` unit test checks expiry inside `authenticate()`; + // The signed-session unit tests check expiry inside `lookup()`; // this drives the full route to confirm an expired session yields a // 401 at the gateway, not just inside the authenticator. let (app, services, store) = session_app(); @@ -310,9 +325,7 @@ async fn expired_session_bearer_rejected_on_route() { .create_session( TenantId::new(TENANT).expect("tenant"), UserId::new("session-user").expect("user"), - // Already expired: `SessionRecord::is_expired` is `now >= - // expires_at`, so a negative lifetime is unambiguously past. - ChronoDuration::seconds(-1), + ChronoDuration::seconds(1), false, ) .await @@ -320,6 +333,8 @@ async fn expired_session_bearer_rejected_on_route() { .expose_secret() .to_string(); + tokio::time::sleep(Duration::from_millis(2_100)).await; + let response = app .oneshot(create_thread_request(Some(&format!("Bearer {bearer}")))) .await @@ -345,13 +360,13 @@ fn callers_len(services: &Arc) -> usize { #[tokio::test] async fn session_minted_for_one_tenant_does_not_authenticate_another_deployment() { // v2 isolates tenants two ways: each deployment owns a separate - // `SessionStore`, and `caller.tenant_id` is always stamped from host + // signed session store, and `caller.tenant_id` is always stamped from host // config — never from the bearer. A session minted against tenant-a's // store must therefore fail on a tenant-b deployment backed by its own // (different) store: the lookup misses and the bearer is rejected. If // the tenant binding were ever loosened to trust a shared store, this // would catch it. - let tenant_a_store: Arc = Arc::new(InMemorySessionStore::new()); + let tenant_a_store = signed_store_for(&TenantId::new(TENANT).expect("tenant")); let bearer = tenant_a_store .create_session( TenantId::new(TENANT).expect("tenant"), @@ -365,7 +380,7 @@ async fn session_minted_for_one_tenant_does_not_authenticate_another_deployment( .to_string(); // A distinct deployment: tenant-b, its own empty store. - let tenant_b_store: Arc = Arc::new(InMemorySessionStore::new()); + let tenant_b_store = signed_store_for(&TenantId::new("tenant-b").expect("tenant")); let authenticator = Arc::new(SessionAuthenticator::new(tenant_b_store.clone())); let services = Arc::new(StubServices::default()); let bundle = RebornWebuiBundle { @@ -407,10 +422,13 @@ async fn session_minted_for_one_tenant_does_not_authenticate_another_deployment( .is_empty(), "a cross-deployment bearer must never reach the facade", ); - assert_eq!( - tenant_a_store.len(), - 1, - "the tenant-a session stays intact in its own store", + assert!( + tenant_a_store + .lookup(&bearer) + .await + .expect("lookup") + .is_some(), + "the tenant-a session stays valid in its own store", ); } @@ -539,14 +557,14 @@ async fn expired_query_token_rejected_on_sse_route() { // not just the `Authorization: Bearer` path. If the shim were ever // widened or the expiry check skipped on the query path, an expired // session could keep streaming over `?token=` while bearer-header - // tests still pass. Mint a session that is already expired and + // tests still pass. Mint a short-lived session, wait until it expires, and // present it through the query string. let (app, services, store) = session_app(); let expired_bearer = store .create_session( TenantId::new(TENANT).expect("tenant"), UserId::new("session-user").expect("user"), - ChronoDuration::seconds(-1), + ChronoDuration::seconds(1), false, ) .await @@ -554,6 +572,8 @@ async fn expired_query_token_rejected_on_sse_route() { .expose_secret() .to_string(); + tokio::time::sleep(Duration::from_millis(2_100)).await; + let response = app .oneshot(sse_events_request(Some(&expired_bearer))) .await diff --git a/crates/ironclaw_webui/tests/cli_token_login_route.rs b/crates/ironclaw_webui/tests/cli_token_login_route.rs index 0f7ff8628b3..ea15b71d045 100644 --- a/crates/ironclaw_webui/tests/cli_token_login_route.rs +++ b/crates/ironclaw_webui/tests/cli_token_login_route.rs @@ -19,7 +19,7 @@ use chrono::Duration as ChronoDuration; use http_body_util::BodyExt; use ironclaw_host_api::{TenantId, UserId}; use ironclaw_webui::{ - CliTokenLoginConfig, EnvBearerAuthenticator, SessionAuthenticator, SessionStore, + CliTokenLoginConfig, EnvBearerAuthenticator, SessionAuthenticator, SignedTokenSessionStore, build_cli_token_login, signed_session_store, }; use secrecy::SecretString; @@ -33,7 +33,7 @@ fn tenant() -> TenantId { TenantId::new("tenant-a").expect("tenant") } -fn build_router() -> (axum::Router, Arc) { +fn build_router() -> (axum::Router, Arc) { let session_store = signed_session_store( &SecretString::from("operator-secret".to_string()), &tenant(), @@ -214,7 +214,7 @@ async fn require_session_bearer( next.run(request).await } -fn build_protected_router(session_store: Arc) -> axum::Router { +fn build_protected_router(session_store: Arc) -> axum::Router { let authenticator = Arc::new(SessionAuthenticator::new(session_store)); axum::Router::new() .route( diff --git a/crates/ironclaw_webui/tests/github_oauth_routes.rs b/crates/ironclaw_webui/tests/github_oauth_routes.rs index badcf96217e..839641368f7 100644 --- a/crates/ironclaw_webui/tests/github_oauth_routes.rs +++ b/crates/ironclaw_webui/tests/github_oauth_routes.rs @@ -13,9 +13,8 @@ //! usable bearer, then revoked) is end-of-pipeline. //! //! Gated on `test-support` because the test wires -//! `InMemorySessionStore` + `EmailUserDirectory` + the test-only -//! `GitHubProvider::with_endpoints` constructor, all of which only -//! exist behind that feature. +//! `EmailUserDirectory` plus the test-only `GitHubProvider::with_endpoints` +//! constructor, which only exist behind that feature. #![cfg(feature = "test-support")] @@ -34,8 +33,8 @@ use chrono::Duration as ChronoDuration; use http_body_util::BodyExt; use ironclaw_host_api::TenantId; use ironclaw_webui::{ - EmailUserDirectory, GitHubOAuthConfig, GitHubProvider, InMemorySessionStore, OAuthProvider, - OAuthRouterConfig, SessionStore, webui_v2_auth_router, + EmailUserDirectory, GitHubOAuthConfig, GitHubProvider, OAuthProvider, OAuthRouterConfig, + SignedTokenSessionStore, signed_session_store, webui_v2_auth_router, }; use secrecy::SecretString; use serde::Deserialize; @@ -137,9 +136,16 @@ fn tenant() -> TenantId { TenantId::new("tenant-a").expect("tenant") } +fn session_store() -> Arc { + signed_session_store( + &SecretString::from("operator-secret".to_string()), + &tenant(), + ) +} + fn build_router( providers: Vec>, - session_store: Arc, + session_store: Arc, ) -> axum::Router { let config = OAuthRouterConfig::new( tenant(), @@ -255,8 +261,7 @@ async fn redeem_ticket(router: &axum::Router, ticket: &str) -> String { #[tokio::test] async fn providers_lists_configured_github() { let (addr, _server) = spawn_stub_github(StubGitHub::octocat()).await; - let store: Arc = Arc::new(InMemorySessionStore::new()); - let router = build_router(vec![github_provider(addr)], store); + let router = build_router(vec![github_provider(addr)], session_store()); let resp = router .oneshot( @@ -279,8 +284,7 @@ async fn providers_lists_configured_github() { #[tokio::test] async fn login_redirects_to_github_with_state_and_scope_and_no_pkce() { let (addr, _server) = spawn_stub_github(StubGitHub::octocat()).await; - let store: Arc = Arc::new(InMemorySessionStore::new()); - let router = build_router(vec![github_provider(addr)], store); + let router = build_router(vec![github_provider(addr)], session_store()); let resp = router .oneshot( @@ -313,9 +317,8 @@ async fn login_redirects_to_github_with_state_and_scope_and_no_pkce() { #[tokio::test] async fn callback_success_mints_session_for_primary_verified_email() { let (addr, _server) = spawn_stub_github(StubGitHub::octocat()).await; - let store_inner: Arc = Arc::new(InMemorySessionStore::new()); - let session_store: Arc = store_inner.clone(); - let router = build_router(vec![github_provider(addr)], session_store); + let store = session_store(); + let router = build_router(vec![github_provider(addr)], store.clone()); let landing = login_and_callback(&router).await; assert!(landing.starts_with("/?login_ticket="), "got {landing}"); @@ -323,14 +326,12 @@ async fn callback_success_mints_session_for_primary_verified_email() { !landing.contains("#token="), "callback Location must not carry the bearer: {landing}", ); - assert_eq!(store_inner.len(), 1, "session should be persisted"); - // Exchange the one-time ticket for a usable bearer, then confirm // the session was minted for the PRIMARY VERIFIED email (not the // unverified one and not the empty profile email). let ticket = ticket_from_landing(&landing); let bearer = redeem_ticket(&router, &ticket).await; - let session = store_inner + let session = store .lookup(&bearer) .await .expect("lookup") @@ -386,15 +387,14 @@ async fn callback_with_unverified_emails_mints_session_for_provider_sub() { }, ]; let (addr, _server) = spawn_stub_github(stub).await; - let store_inner: Arc = Arc::new(InMemorySessionStore::new()); - let session_store: Arc = store_inner.clone(); - let router = build_router(vec![github_provider(addr)], session_store); + let store = session_store(); + let router = build_router(vec![github_provider(addr)], store.clone()); let landing = login_and_callback(&router).await; assert!(landing.starts_with("/?login_ticket="), "got {landing}"); let ticket = ticket_from_landing(&landing); let bearer = redeem_ticket(&router, &ticket).await; - let session = store_inner + let session = store .lookup(&bearer) .await .expect("lookup") @@ -411,8 +411,7 @@ async fn callback_with_unverified_emails_mints_session_for_provider_sub() { #[tokio::test] async fn callback_with_provider_error_redirects_with_denied() { let (addr, _server) = spawn_stub_github(StubGitHub::octocat()).await; - let store: Arc = Arc::new(InMemorySessionStore::new()); - let router = build_router(vec![github_provider(addr)], store); + let router = build_router(vec![github_provider(addr)], session_store()); // GitHub appends `?error=access_denied` when the user rejects the // consent screen. No state is consumed; the SPA must see a @@ -442,7 +441,6 @@ async fn callback_exchange_failure_redirects_with_exchange_failed() { ); let (addr, _server) = spawn_router(failing).await; - let store: Arc = Arc::new(InMemorySessionStore::new()); let provider: Arc = Arc::new( GitHubProvider::with_endpoints( GitHubOAuthConfig { @@ -457,7 +455,7 @@ async fn callback_exchange_failure_redirects_with_exchange_failed() { ) .expect("build provider"), ); - let router = build_router(vec![provider], store); + let router = build_router(vec![provider], session_store()); let login = router .clone() @@ -498,8 +496,7 @@ async fn callback_with_unknown_state_redirects_with_invalid_state_error() { // pending-flow store, or fabricated) must fail closed with the // opaque `invalid_state` code and never reach the provider. let (addr, _server) = spawn_stub_github(StubGitHub::octocat()).await; - let store: Arc = Arc::new(InMemorySessionStore::new()); - let router = build_router(vec![github_provider(addr)], store); + let router = build_router(vec![github_provider(addr)], session_store()); let resp = router .oneshot( @@ -525,8 +522,7 @@ async fn callback_with_state_replay_fails_closed() { // consumed by a successful callback must not mint a second session // when replayed against the same router. let (addr, _server) = spawn_stub_github(StubGitHub::octocat()).await; - let store_inner: Arc = Arc::new(InMemorySessionStore::new()); - let router = build_router(vec![github_provider(addr)], store_inner.clone()); + let router = build_router(vec![github_provider(addr)], session_store()); let login = router .clone() @@ -558,7 +554,6 @@ async fn callback_with_state_replay_fails_closed() { .expect("oneshot"); assert_eq!(first.status(), StatusCode::SEE_OTHER); assert!(header_str(&first, header::LOCATION).starts_with("/?login_ticket=")); - assert_eq!(store_inner.len(), 1); // Replaying the SAME state must fail closed — no second session. let replay = router @@ -579,11 +574,6 @@ async fn callback_with_state_replay_fails_closed() { header_str(&replay, header::LOCATION), "/?login_error=invalid_state" ); - assert_eq!( - store_inner.len(), - 1, - "replayed state must NOT mint a second session" - ); } #[tokio::test] @@ -606,7 +596,6 @@ async fn callback_profile_fetch_failure_redirects_with_exchange_failed() { .route("/user", get(|| async { StatusCode::UNAUTHORIZED })); let (addr, _server) = spawn_router(server).await; - let store: Arc = Arc::new(InMemorySessionStore::new()); let provider: Arc = Arc::new( GitHubProvider::with_endpoints( GitHubOAuthConfig { @@ -621,7 +610,7 @@ async fn callback_profile_fetch_failure_redirects_with_exchange_failed() { ) .expect("build provider"), ); - let router = build_router(vec![provider], store); + let router = build_router(vec![provider], session_store()); let login = router .clone() @@ -672,7 +661,6 @@ async fn callback_exchange_timeout_redirects_with_exchange_failed() { ); let (addr, _server) = spawn_router(blackhole).await; - let store: Arc = Arc::new(InMemorySessionStore::new()); let provider: Arc = Arc::new( GitHubProvider::with_endpoints( GitHubOAuthConfig { @@ -688,7 +676,7 @@ async fn callback_exchange_timeout_redirects_with_exchange_failed() { .expect("build provider") .with_exchange_budget(Duration::from_millis(150)), ); - let router = build_router(vec![provider], store); + let router = build_router(vec![provider], session_store()); let login = router .clone() @@ -728,9 +716,8 @@ async fn callback_exchange_timeout_redirects_with_exchange_failed() { #[tokio::test] async fn logout_revokes_the_minted_session() { let (addr, _server) = spawn_stub_github(StubGitHub::octocat()).await; - let store_inner: Arc = Arc::new(InMemorySessionStore::new()); - let session_store: Arc = store_inner.clone(); - let router = build_router(vec![github_provider(addr)], session_store); + let store = session_store(); + let router = build_router(vec![github_provider(addr)], store.clone()); let landing = login_and_callback(&router).await; let ticket = ticket_from_landing(&landing); @@ -738,7 +725,7 @@ async fn logout_revokes_the_minted_session() { // Sanity: the bearer authenticates before logout. assert!( - store_inner.lookup(&bearer).await.expect("lookup").is_some(), + store.lookup(&bearer).await.expect("lookup").is_some(), "session must exist before logout", ); @@ -759,7 +746,7 @@ async fn logout_revokes_the_minted_session() { // After logout the bearer must no longer resolve to a session — // subsequent API / SSE / WebSocket access is denied. assert!( - store_inner.lookup(&bearer).await.expect("lookup").is_none(), + store.lookup(&bearer).await.expect("lookup").is_none(), "session must be revoked after logout", ); } diff --git a/crates/ironclaw_webui/tests/google_oauth_routes.rs b/crates/ironclaw_webui/tests/google_oauth_routes.rs index c676e9d8962..c7554203b53 100644 --- a/crates/ironclaw_webui/tests/google_oauth_routes.rs +++ b/crates/ironclaw_webui/tests/google_oauth_routes.rs @@ -9,9 +9,8 @@ //! end-of-pipeline; testing the Google provider's `exchange_code` //! alone wouldn't catch a wrapper that drops the verifier. //! -//! Gated on `test-support` because the test wires -//! `InMemorySessionStore` + `EmailUserDirectory`, both of which only -//! exist behind that feature. Matches `session_round_trip.rs`. +//! Gated on `test-support` because the test wires `EmailUserDirectory`, which +//! only exists behind that feature. Matches `session_round_trip.rs`. #![cfg(feature = "test-support")] @@ -24,10 +23,11 @@ use chrono::Duration as ChronoDuration; use http_body_util::BodyExt; use ironclaw_host_api::TenantId; use ironclaw_webui::{ - EmailUserDirectory, InMemorySessionStore, OAuthError, OAuthProvider, OAuthProviderName, - OAuthRouterConfig, OAuthUserProfile, SessionStore, webui_v2_auth_router, + EmailUserDirectory, OAuthError, OAuthProvider, OAuthProviderName, OAuthRouterConfig, + OAuthUserProfile, SignedTokenSessionStore, signed_session_store, webui_v2_auth_router, }; use parking_lot::Mutex; +use secrecy::SecretString; use serde::Deserialize; use tower::ServiceExt; @@ -129,9 +129,16 @@ fn alice_profile() -> OAuthUserProfile { } } +fn session_store() -> Arc { + signed_session_store( + &SecretString::from("operator-secret".to_string()), + &tenant(), + ) +} + fn build_router( providers: Vec>, - session_store: Arc, + session_store: Arc, ) -> axum::Router { // These tests drive the route table directly via `oneshot` and // deliberately bypass the descriptor-driven rate-limit / @@ -169,10 +176,9 @@ struct SessionExchangeResponse { #[tokio::test] async fn providers_lists_configured_google() { - let store: Arc = Arc::new(InMemorySessionStore::new()); let router = build_router( vec![StubProvider::google_with_profile(alice_profile()) as Arc], - store, + session_store(), ); let resp = router .oneshot( @@ -192,8 +198,7 @@ async fn providers_lists_configured_google() { #[tokio::test] async fn providers_returns_empty_when_none_configured() { - let store: Arc = Arc::new(InMemorySessionStore::new()); - let router = build_router(Vec::new(), store); + let router = build_router(Vec::new(), session_store()); let resp = router .oneshot( Request::builder() @@ -218,9 +223,11 @@ async fn providers_returns_empty_when_none_configured() { #[tokio::test] async fn login_redirects_to_provider_with_state_and_callback_url() { - let store: Arc = Arc::new(InMemorySessionStore::new()); let provider = StubProvider::google_with_profile(alice_profile()); - let router = build_router(vec![provider.clone() as Arc], store); + let router = build_router( + vec![provider.clone() as Arc], + session_store(), + ); let resp = router .oneshot( @@ -251,9 +258,8 @@ async fn login_redirects_to_provider_with_state_and_callback_url() { #[tokio::test] async fn login_from_non_canonical_host_redirects_before_state_creation() { - let store: Arc = Arc::new(InMemorySessionStore::new()); let provider = StubProvider::google_with_profile(alice_profile()); - let router = build_router(vec![provider as Arc], store); + let router = build_router(vec![provider as Arc], session_store()); let resp = router .oneshot( @@ -281,9 +287,8 @@ async fn login_from_non_canonical_host_redirects_before_state_creation() { #[tokio::test] async fn canonical_login_redirect_does_not_echo_unsafe_redirect_after() { - let store: Arc = Arc::new(InMemorySessionStore::new()); let provider = StubProvider::google_with_profile(alice_profile()); - let router = build_router(vec![provider as Arc], store); + let router = build_router(vec![provider as Arc], session_store()); let resp = router .oneshot( @@ -308,8 +313,7 @@ async fn canonical_login_redirect_does_not_echo_unsafe_redirect_after() { #[tokio::test] async fn login_unknown_provider_returns_404() { - let store: Arc = Arc::new(InMemorySessionStore::new()); - let router = build_router(Vec::new(), store); + let router = build_router(Vec::new(), session_store()); let resp = router .oneshot( Request::builder() @@ -325,10 +329,9 @@ async fn login_unknown_provider_returns_404() { #[tokio::test] async fn login_invalid_provider_slug_returns_404() { - let store: Arc = Arc::new(InMemorySessionStore::new()); let router = build_router( vec![StubProvider::google_with_profile(alice_profile()) as Arc], - store, + session_store(), ); let resp = router .oneshot( @@ -360,12 +363,11 @@ fn state_from_location(location: &str) -> String { #[tokio::test] async fn callback_success_creates_session_and_redirects_with_login_ticket() { - let store_inner: Arc = Arc::new(InMemorySessionStore::new()); - let session_store: Arc = store_inner.clone(); + let store = session_store(); let provider = StubProvider::google_with_profile(alice_profile()); let router = build_router( vec![provider.clone() as Arc], - session_store, + store.clone(), ); // 1. Login → capture state. @@ -390,8 +392,8 @@ async fn callback_success_creates_session_and_redirects_with_login_ticket() { let state = state_from_location(&location); // 2. Callback with that state — must succeed and redirect with - // a one-time `login_ticket`, and a session must exist in the - // store without leaking the bearer in the Location header. + // a one-time `login_ticket` without leaking the bearer in the + // Location header. let callback = router .clone() .oneshot( @@ -424,8 +426,6 @@ async fn callback_success_creates_session_and_redirects_with_login_ticket() { "v2 OAuth callback must not set a session cookie — transport is the one-time \ login_ticket only (#4116 cookie-transport beta-break)", ); - assert_eq!(store_inner.len(), 1, "session should be persisted"); - // The provider should have received the original PKCE verifier // the login step generated — captured by the stub. let captured = provider @@ -451,7 +451,7 @@ async fn callback_success_creates_session_and_redirects_with_login_ticket() { StatusCode::UNAUTHORIZED, "login ticket must be single-use", ); - let session = store_inner + let session = store .lookup(&decoded) .await .expect("lookup") @@ -465,10 +465,9 @@ async fn callback_preserves_legacy_v2_redirect_after_through_ticket_exchange() { // redirect target. The callback must preserve that safe same-origin path; // the root router's compatibility redirect then carries the ticket query // to `/`. This crate owns the callback half of that rolling-upgrade seam. - let store_inner = Arc::new(InMemorySessionStore::new()); - let session_store: Arc = store_inner.clone(); + let store = session_store(); let provider = StubProvider::google_with_profile(alice_profile()); - let router = build_router(vec![provider as Arc], session_store); + let router = build_router(vec![provider as Arc], store.clone()); let login = router .clone() @@ -519,7 +518,7 @@ async fn callback_preserves_legacy_v2_redirect_after_through_ticket_exchange() { let ticket = ticket_from_landing(landing); let bearer = redeem_ticket(router, &ticket).await; assert!( - store_inner.lookup(&bearer).await.expect("lookup").is_some(), + store.lookup(&bearer).await.expect("lookup").is_some(), "legacy landing ticket must still exchange for a live session", ); } @@ -564,9 +563,8 @@ async fn redeem_ticket(router: axum::Router, ticket: &str) -> String { #[tokio::test] async fn callback_with_unknown_state_redirects_with_error_code() { - let store: Arc = Arc::new(InMemorySessionStore::new()); let provider = StubProvider::google_with_profile(alice_profile()); - let router = build_router(vec![provider as Arc], store); + let router = build_router(vec![provider as Arc], session_store()); let resp = router .oneshot( @@ -595,13 +593,8 @@ async fn callback_with_state_replay_fails_closed() { // version of this test built a fresh router for the replay, // which only exercised "unknown state" — already covered by // `callback_with_unknown_state_redirects_with_error_code`. - let store_inner: Arc = Arc::new(InMemorySessionStore::new()); - let session_store: Arc = store_inner.clone(); let provider = StubProvider::google_with_profile(alice_profile()); - let router = build_router( - vec![provider as Arc], - session_store.clone(), - ); + let router = build_router(vec![provider as Arc], session_store()); // 1. Login → capture state. let login = router @@ -651,7 +644,6 @@ async fn callback_with_state_replay_fails_closed() { first_location.starts_with("/?login_ticket="), "first callback must succeed; got {first_location}" ); - assert_eq!(store_inner.len(), 1, "first callback must mint a session"); // 3. Replay the SAME state token against the SAME router. The // pending-flow store's single-use semantics must drop the @@ -678,18 +670,12 @@ async fn callback_with_state_replay_fails_closed() { .to_str() .unwrap(); assert_eq!(replay_location, "/?login_error=invalid_state"); - assert_eq!( - store_inner.len(), - 1, - "replay must NOT mint a second session", - ); } #[tokio::test] async fn callback_with_provider_error_param_redirects_with_denied() { - let store: Arc = Arc::new(InMemorySessionStore::new()); let provider = StubProvider::google_with_profile(alice_profile()); - let router = build_router(vec![provider as Arc], store); + let router = build_router(vec![provider as Arc], session_store()); let resp = router .oneshot( @@ -713,10 +699,8 @@ async fn callback_with_provider_error_param_redirects_with_denied() { #[tokio::test] async fn callback_when_provider_rejects_hosted_domain_yields_unauthorized() { - let store_inner: Arc = Arc::new(InMemorySessionStore::new()); - let session_store: Arc = store_inner.clone(); let provider = StubProvider::google_with_error(OAuthError::Denied("hd mismatch".into())); - let router = build_router(vec![provider as Arc], session_store); + let router = build_router(vec![provider as Arc], session_store()); let login = router .clone() @@ -759,18 +743,12 @@ async fn callback_when_provider_rejects_hosted_domain_yields_unauthorized() { .to_str() .unwrap(); assert_eq!(location, "/?login_error=unauthorized"); - assert_eq!(store_inner.len(), 0, "no session must be created"); } #[tokio::test] async fn login_open_redirect_attempt_falls_back_to_default() { - let store_inner: Arc = Arc::new(InMemorySessionStore::new()); - let session_store: Arc = store_inner.clone(); let provider = StubProvider::google_with_profile(alice_profile()); - let router = build_router( - vec![provider as Arc], - session_store.clone(), - ); + let router = build_router(vec![provider as Arc], session_store()); // Protocol-relative redirect target: sanitize_redirect must // strip it, and the callback must land on the default `/`. @@ -821,10 +799,9 @@ async fn login_open_redirect_attempt_falls_back_to_default() { #[tokio::test] async fn logout_with_bearer_revokes_session() { - let store_inner: Arc = Arc::new(InMemorySessionStore::new()); - let session_store: Arc = store_inner.clone(); + let store = session_store(); let provider = StubProvider::google_with_profile(alice_profile()); - let router = build_router(vec![provider as Arc], session_store); + let router = build_router(vec![provider as Arc], store.clone()); // Drive a successful callback to mint a real session token. let login = router @@ -868,7 +845,7 @@ async fn logout_with_bearer_revokes_session() { .unwrap(); let ticket = ticket_from_landing(landing); let bearer = redeem_ticket(router.clone(), &ticket).await; - assert_eq!(store_inner.len(), 1); + assert!(store.lookup(&bearer).await.expect("lookup").is_some()); let logout = router .oneshot( @@ -882,20 +859,14 @@ async fn logout_with_bearer_revokes_session() { .await .expect("oneshot"); assert_eq!(logout.status(), StatusCode::NO_CONTENT); - assert_eq!( - store_inner.len(), - 0, - "session must be revoked from the store", - ); - let probe = store_inner.lookup(&bearer).await.expect("lookup"); + let probe = store.lookup(&bearer).await.expect("lookup"); assert!(probe.is_none(), "lookup after revoke must return None"); } #[tokio::test] async fn logout_without_bearer_returns_no_content() { - let store: Arc = Arc::new(InMemorySessionStore::new()); let provider = StubProvider::google_with_profile(alice_profile()); - let router = build_router(vec![provider as Arc], store); + let router = build_router(vec![provider as Arc], session_store()); let resp = router .oneshot( Request::builder() @@ -926,9 +897,8 @@ fn state_extraction_handles_urlencoded_value() { // branch had test coverage. Lock both missing and empty shapes. #[tokio::test] async fn callback_missing_code_or_state_redirects_invalid_request() { - let store: Arc = Arc::new(InMemorySessionStore::new()); let provider = StubProvider::google_with_profile(alice_profile()); - let router = build_router(vec![provider as Arc], store); + let router = build_router(vec![provider as Arc], session_store()); for uri in [ "/auth/callback/google", // both missing @@ -966,8 +936,6 @@ async fn callback_missing_code_or_state_redirects_invalid_request() { // `?login_error=provider_mismatch` and NOT mint a session. #[tokio::test] async fn callback_with_state_for_different_provider_redirects_provider_mismatch() { - let store_inner: Arc = Arc::new(InMemorySessionStore::new()); - let session_store: Arc = store_inner.clone(); let google = StubProvider::for_provider("google", alice_profile()); let github = StubProvider::for_provider("github", alice_profile()); let router = build_router( @@ -975,7 +943,7 @@ async fn callback_with_state_for_different_provider_redirects_provider_mismatch( google as Arc, github as Arc, ], - session_store, + session_store(), ); let login = router @@ -1021,11 +989,6 @@ async fn callback_with_state_for_different_provider_redirects_provider_mismatch( .to_str() .unwrap(); assert_eq!(location, "/?login_error=provider_mismatch"); - assert_eq!( - store_inner.len(), - 0, - "cross-provider replay must NOT mint a session", - ); } // Finding #7: provider exchange failures map to @@ -1035,12 +998,10 @@ async fn callback_with_state_for_different_provider_redirects_provider_mismatch( // `Denied` branch that already had coverage. #[tokio::test] async fn callback_when_provider_exchange_fails_redirects_exchange_failed() { - let store_inner: Arc = Arc::new(InMemorySessionStore::new()); - let session_store: Arc = store_inner.clone(); let provider = StubProvider::google_with_error(OAuthError::CodeExchange( "simulated token-endpoint 500".into(), )); - let router = build_router(vec![provider as Arc], session_store); + let router = build_router(vec![provider as Arc], session_store()); let login = router .clone() @@ -1083,18 +1044,16 @@ async fn callback_when_provider_exchange_fails_redirects_exchange_failed() { .to_str() .unwrap(); assert_eq!(location, "/?login_error=exchange_failed"); - assert_eq!(store_inner.len(), 0); } // Same as above, but the provider returns `ProfileFetch` (the // other error variant that also maps to `exchange_failed`). #[tokio::test] async fn callback_when_profile_fetch_fails_redirects_exchange_failed() { - let store: Arc = Arc::new(InMemorySessionStore::new()); let provider = StubProvider::google_with_error(OAuthError::ProfileFetch( "simulated malformed id_token".into(), )); - let router = build_router(vec![provider as Arc], store); + let router = build_router(vec![provider as Arc], session_store()); let login = router .clone() @@ -1175,21 +1134,17 @@ mod user_directory_branches { } } - fn build_router_with_directory( - directory: Arc, - ) -> (axum::Router, Arc) { - let store_inner: Arc = Arc::new(InMemorySessionStore::new()); - let session_store: Arc = store_inner.clone(); + fn build_router_with_directory(directory: Arc) -> axum::Router { let provider = StubProvider::google_with_profile(alice_profile()); let config = OAuthRouterConfig::new( tenant(), - session_store, + session_store(), directory, vec![provider as Arc], "https://gateway.example", ) .with_session_lifetime(ChronoDuration::hours(1)); - (webui_v2_auth_router(config).router, store_inner) + webui_v2_auth_router(config).router } async fn drive_callback(router: axum::Router) -> String { @@ -1237,75 +1192,39 @@ mod user_directory_branches { #[tokio::test] async fn unknown_user_redirects_unauthorized() { - let (router, store) = build_router_with_directory(Arc::new(AlwaysUnknown)); + let router = build_router_with_directory(Arc::new(AlwaysUnknown)); let location = drive_callback(router).await; assert_eq!(location, "/?login_error=unauthorized"); - assert_eq!(store.len(), 0); } #[tokio::test] async fn backend_failure_redirects_server_error() { - let (router, store) = build_router_with_directory(Arc::new(AlwaysBackendFail)); + let router = build_router_with_directory(Arc::new(AlwaysBackendFail)); let location = drive_callback(router).await; assert_eq!(location, "/?login_error=server_error"); - assert_eq!(store.len(), 0); } } -// Finding #9: `SessionStore::create_session` failures map to -// `server_error`. Drive a stub store that always errors on -// `create_session` (the in-memory store can't naturally fail). +// Finding #9: signed-session `create_session` failures map to `server_error`. mod session_store_failure { use super::*; - use async_trait::async_trait; - use chrono::Duration as ChronoDuration; - use ironclaw_host_api::{TenantId, UserId}; - use ironclaw_webui::{SessionRecord, SessionStore, SessionStoreError, UserDirectory}; - use secrecy::SecretString; - - struct AlwaysFailCreate; - - #[async_trait] - impl SessionStore for AlwaysFailCreate { - async fn create_session( - &self, - _tenant_id: TenantId, - _user_id: UserId, - _lifetime: ChronoDuration, - _operator: bool, - ) -> Result { - Err(SessionStoreError::Backend("simulated outage".into())) - } - async fn lookup( - &self, - _candidate: &str, - ) -> Result, SessionStoreError> { - Ok(None) - } - } - fn build_router_with_session_store( - store: Arc, - directory: Arc, - ) -> axum::Router { + fn build_router_with_lifetime(lifetime: ChronoDuration) -> axum::Router { let provider = StubProvider::google_with_profile(alice_profile()); let config = OAuthRouterConfig::new( tenant(), - store, - directory, + session_store(), + Arc::new(EmailUserDirectory), vec![provider as Arc], "https://gateway.example", ) - .with_session_lifetime(ChronoDuration::hours(1)); + .with_session_lifetime(lifetime); webui_v2_auth_router(config).router } #[tokio::test] async fn callback_when_session_store_create_fails_redirects_server_error() { - let router = build_router_with_session_store( - Arc::new(AlwaysFailCreate), - Arc::new(EmailUserDirectory), - ); + let router = build_router_with_lifetime(ChronoDuration::zero()); let login = router .clone() @@ -1351,81 +1270,3 @@ mod session_store_failure { ); } } - -// Reviewer finding #2: logout returns 500 when revoke fails. The -// SPA still clears local state, but the response status now -// truthfully reflects that the server-side bearer may live on. -mod logout_revoke_failure { - use super::*; - use async_trait::async_trait; - use chrono::Duration as ChronoDuration; - use ironclaw_host_api::{TenantId, UserId}; - use ironclaw_webui::{SessionRecord, SessionStore, SessionStoreError, UserDirectory}; - use secrecy::SecretString; - - struct RevokeAlwaysFails; - - #[async_trait] - impl SessionStore for RevokeAlwaysFails { - async fn create_session( - &self, - _tenant_id: TenantId, - _user_id: UserId, - _lifetime: ChronoDuration, - _operator: bool, - ) -> Result { - unreachable!("test does not drive create_session") - } - async fn lookup( - &self, - _candidate: &str, - ) -> Result, SessionStoreError> { - Ok(None) - } - async fn revoke(&self, _candidate: &str) -> Result<(), SessionStoreError> { - Err(SessionStoreError::Backend("simulated outage".into())) - } - } - - fn build_router_with_session_store( - store: Arc, - directory: Arc, - ) -> axum::Router { - let provider = StubProvider::google_with_profile(alice_profile()); - let config = OAuthRouterConfig::new( - tenant(), - store, - directory, - vec![provider as Arc], - "https://gateway.example", - ) - .with_session_lifetime(ChronoDuration::hours(1)); - webui_v2_auth_router(config).router - } - - #[tokio::test] - async fn logout_returns_500_when_revoke_fails() { - let router = build_router_with_session_store( - Arc::new(RevokeAlwaysFails), - Arc::new(EmailUserDirectory), - ); - - let resp = router - .oneshot( - Request::builder() - .method("POST") - .uri("/auth/logout") - .header(header::AUTHORIZATION, "Bearer some-token") - .body(Body::empty()) - .expect("request"), - ) - .await - .expect("oneshot"); - assert_eq!( - resp.status(), - StatusCode::INTERNAL_SERVER_ERROR, - "logout MUST return 5xx when SessionStore::revoke fails — \ - returning 204 would lie about the server-side state", - ); - } -} diff --git a/crates/ironclaw_webui/tests/network_limits_contract.rs b/crates/ironclaw_webui/tests/network_limits_contract.rs index 22d7122413c..7bd14546775 100644 --- a/crates/ironclaw_webui/tests/network_limits_contract.rs +++ b/crates/ironclaw_webui/tests/network_limits_contract.rs @@ -48,10 +48,11 @@ use axum::http::{HeaderValue, Method, Request, StatusCode, header}; use ironclaw_host_api::{AgentId, ProjectId, TenantId}; use ironclaw_reborn_composition::{RebornReadiness, RebornWebuiBundle}; use ironclaw_webui::{ - EmailUserDirectory, InMemorySessionStore, OAuthError, OAuthProvider, OAuthProviderName, - OAuthRouterConfig, OAuthUserProfile, SessionAuthenticator, SessionStore, webui_v2_auth_router, + EmailUserDirectory, OAuthError, OAuthProvider, OAuthProviderName, OAuthRouterConfig, + OAuthUserProfile, SessionAuthenticator, signed_session_store, webui_v2_auth_router, }; use ironclaw_webui::{WebuiServeConfig, webui_v2_app}; +use secrecy::SecretString; use tower::ServiceExt; #[path = "support/harness.rs"] @@ -105,12 +106,15 @@ impl OAuthProvider for StubProvider { /// SSO mount, parameterized on the CORS allow-list so the fail-closed /// case can pass an empty list. fn build_app(allowed_origins: Vec) -> axum::Router { - let session_store: Arc = Arc::new(InMemorySessionStore::new()); + let session_store = signed_session_store( + &SecretString::from("operator-secret".to_string()), + &TenantId::new(TENANT).expect("tenant"), + ); let authenticator = Arc::new(SessionAuthenticator::new(session_store.clone())); let oauth_mount = webui_v2_auth_router(OAuthRouterConfig::new( TenantId::new(TENANT).expect("tenant"), - session_store as Arc, + session_store, Arc::new(EmailUserDirectory), vec![StubProvider::new() as Arc], "https://gateway.example", diff --git a/crates/ironclaw_webui/tests/session_round_trip.rs b/crates/ironclaw_webui/tests/session_round_trip.rs index b26d9b12d91..9d94e5ad6a5 100644 --- a/crates/ironclaw_webui/tests/session_round_trip.rs +++ b/crates/ironclaw_webui/tests/session_round_trip.rs @@ -9,11 +9,11 @@ //! - the OAuth public router from `webui_v2_auth_router` (mints //! sessions), //! - a `SessionAuthenticator` backed by the SAME -//! `InMemorySessionStore` (validates bearers), +//! `SignedTokenSessionStore` (validates bearers), //! - a minimal `RebornServicesApi` stub that only implements //! `create_thread` for the round-trip assertion. //! -//! The chain it locks: OAuth callback → SessionStore::create_session +//! The chain it locks: OAuth callback → SignedTokenSessionStore::create_session //! → one-time `login_ticket` exchange → SessionAuthenticator → v2 //! route handler → facade call. A regression that loses any link //! (e.g. store mismatch, bearer exchange drift, missing user_id @@ -51,11 +51,12 @@ use ironclaw_product_workflow::{ use ironclaw_reborn_composition::{RebornReadiness, RebornWebuiBundle}; use ironclaw_threads::{SessionThreadRecord, ThreadScope}; use ironclaw_webui::{ - EmailUserDirectory, InMemorySessionStore, OAuthProvider, OAuthProviderName, OAuthRouterConfig, - OAuthUserProfile, SessionAuthenticator, SessionStore, webui_v2_auth_router, + EmailUserDirectory, OAuthProvider, OAuthProviderName, OAuthRouterConfig, OAuthUserProfile, + SessionAuthenticator, SignedTokenSessionStore, signed_session_store, webui_v2_auth_router, }; use ironclaw_webui::{WebuiServeConfig, webui_v2_app}; use parking_lot::Mutex as PlMutex; +use secrecy::SecretString; use serde::Deserialize; use tower::ServiceExt; @@ -377,14 +378,21 @@ fn state_from_location(location: &str) -> String { panic!("no state in {location}"); } -fn build_app() -> (axum::Router, Arc, Arc) { - let session_store: Arc = Arc::new(InMemorySessionStore::new()); +fn build_app() -> ( + axum::Router, + Arc, + Arc, +) { + let session_store = signed_session_store( + &SecretString::from("operator-secret".to_string()), + &TenantId::new(TENANT).expect("tenant"), + ); let bearer_authenticator = Arc::new(SessionAuthenticator::new(session_store.clone())); let oauth_mount = webui_v2_auth_router( OAuthRouterConfig::new( TenantId::new(TENANT).expect("tenant"), - session_store.clone() as Arc, + session_store.clone(), Arc::new(EmailUserDirectory), vec![StubProvider::new(alice_profile()) as Arc], "https://gateway.example", @@ -480,7 +488,14 @@ async fn session_minted_via_oauth_callback_authenticates_protected_v2_route() { ); let ticket = ticket_from_landing(&landing); let bearer = redeem_ticket(app.clone(), &ticket).await; - assert_eq!(session_store.len(), 1, "session must be persisted"); + assert!( + session_store + .lookup(&bearer) + .await + .expect("lookup") + .is_some(), + "session must authenticate after ticket exchange", + ); // 3. Use the bearer on a protected WebChat v2 route. This is // the contract the issue #4116 acceptance criterion calls @@ -536,7 +551,14 @@ async fn session_minted_via_oauth_callback_authenticates_protected_v2_route() { .await .expect("oneshot"); assert_eq!(logout.status(), StatusCode::NO_CONTENT); - assert_eq!(session_store.len(), 0, "session must be revoked"); + assert!( + session_store + .lookup(&bearer) + .await + .expect("lookup") + .is_none(), + "session must be revoked", + ); let post_logout = app .oneshot(with_peer( diff --git a/crates/ironclaw_webui/tests/signed_session_multi_user.rs b/crates/ironclaw_webui/tests/signed_session_multi_user.rs index dc9ac77b967..796ff47ba98 100644 --- a/crates/ironclaw_webui/tests/signed_session_multi_user.rs +++ b/crates/ironclaw_webui/tests/signed_session_multi_user.rs @@ -1,7 +1,6 @@ //! Caller-level production-chain test for multi-user WebChat v2 SSO. //! -//! Unlike `session_round_trip.rs` (which uses the dev `InMemorySessionStore`), -//! this drives the REAL production session path the `ironclaw-reborn serve` +//! This drives the REAL production session path the `ironclaw-reborn serve` //! binary wires: `build_signed_session_login` → `SignedTokenSessionStore` //! (stateless HMAC) → `CompositeAuthenticator` → composed `webui_v2_app`. //! diff --git a/docs/plans/2026-07-01-private-tool-installs.md b/docs/plans/2026-07-01-private-tool-installs.md index 0e33518cff6..7f8d4228b60 100644 --- a/docs/plans/2026-07-01-private-tool-installs.md +++ b/docs/plans/2026-07-01-private-tool-installs.md @@ -68,9 +68,9 @@ unchanged. The eviction-compensation finding is moot (no eviction). invisible in the surface AND denied at dispatch — fail closed for free. Membership only changes what `visible_to` checks (set membership instead of single-owner equality). -- **Persistence**: installations live in ONE snapshot file - (`.installations/state.json` under the tenant's extension root, - `extension_installation_store.rs`). Owner stays a field on the +- **Persistence**: installations live as rows under the tenant's extension + installation root (`.installations/installations/`, owned by the filesystem + extension installation store). Owner stays a field on the record, not a new store. - **Reserved first-party ids** (`github`, `notion`, `web-access`, `slack`, nearai, gsuite) are the SYSTEM tier of the id namespace; diff --git a/docs/reborn/2026-06-04-subagent-compaction-design.md b/docs/reborn/2026-06-04-subagent-compaction-design.md index 62be60d17cf..7fe30e21486 100644 --- a/docs/reborn/2026-06-04-subagent-compaction-design.md +++ b/docs/reborn/2026-06-04-subagent-compaction-design.md @@ -152,7 +152,7 @@ Step 6 is an either/or branch (assistant reply path OR capability path), not bot **Subagent today:** `spawn_subagent` runs inside CapabilityStage. Blocking mode emits `CapabilityOutcome::AwaitDependentRun` (`host.rs:1414`); the parent blocks at `AwaitDependentRunGateStage` (`executor/gates.rs:131`). Background mode is wired in the observer (`completion_observer.rs:180–183` has a Background branch) but the spawn port rejects the request at ingress. The Background branch calls `write_terminal_result` (which internally calls `update_parent_result_reference`) and pushes to `background_deliveries` — it skips `resume_parent`. -**Durability reality check (load-bearing):** `write_terminal_result` → `update_capability_result` writes to a `Mutex` in both production (`product_live_adapters.rs:281`) and local-dev (`local_dev.rs:524`). It is **RAM only — not durable.** Likewise `BoundedSubagentGateResolutionStore` (`gate_resolution.rs:38`) and `InMemoryBoundedSubagentGoalStore` (`goal_store.rs:241`) are `Mutex`+`HashMap`, lost on restart. **Any async-poll background path requires a durable result/goal/gate backend built first — this is a hard prerequisite, not a follow-up.** +**Durability reality check (load-bearing):** `write_terminal_result` → `update_capability_result` writes to a `Mutex` in both production (`product_live_adapters.rs:281`) and local-dev (`local_dev.rs:524`). It is **RAM only — not durable.** `BoundedSubagentGateResolutionStore` (`gate_resolution.rs:38`) is likewise `Mutex`+`HashMap`, lost on restart; subagent goals now use `FilesystemSubagentGoalStore`. **Any async-poll background path requires a durable result/gate backend first — this is a hard prerequisite, not a follow-up.** ## Proposed solution @@ -229,7 +229,7 @@ Browser-side: render runs grouped by `tree_root_run_id`, indented by `subagent_d These block Responsibility 2 and reliable background mode. They are **not** optional follow-ons: -- `BoundedSubagentGateResolutionStore` and `InMemoryBoundedSubagentGoalStore` move to libsql + postgres backed stores. Restart between child terminal and parent observer dispatch no longer strands either blocking or background parents. +- `BoundedSubagentGateResolutionStore` moves to a durable backend and subagent goals remain on `FilesystemSubagentGoalStore`. Restart between child terminal and parent observer dispatch no longer strands either blocking or background parents. - The capability result store (`update_capability_result`, today a `Mutex`) gains a durable backend so Responsibility 2's `drain_settled` can read results written after a restart. - `RestartReconciler` replays missed background settlement events on boot. - Idempotency ledger keyed on `(run_id, child_run_id, terminal_kind)` so post-cleanup replay is a no-op. diff --git a/docs/reborn/2026-07-17-architecture-simplification-dto-dyn-local.md b/docs/reborn/2026-07-17-architecture-simplification-dto-dyn-local.md index 40c4d734e6a..331510c5c6a 100644 --- a/docs/reborn/2026-07-17-architecture-simplification-dto-dyn-local.md +++ b/docs/reborn/2026-07-17-architecture-simplification-dto-dyn-local.md @@ -2209,15 +2209,12 @@ open PR not yet on `main`. repointed to `Filesystem*Store`: approval stores (#6195, #6203), capability-lease (#6197), process stores (#6200, the §9 landed exception), run-state (#6203), budget-gate (#6210), outbound-state (#6212), - triggered-run-delivery (#6213). The `InMemory*Store` allowlist ratchet is frozen - (#6204, `reborn_inmemory_store_ratchet.rs`) and annotated with per-entry - achievable-floor status (#6216). **Deferred, by the §9 caveat:** the turns + triggered-run-delivery (#6213). The former `InMemory*Store` allowlist ratchet + was removed after its tracked entries were eliminated. **Deferred, by the §9 caveat:** the turns cluster (`InMemoryTurnStateStore` + checkpoint / loop-checkpoint / instruction-materialization) — the trickiest case, gated behind Slice 0's - reference model. Still allowlisted besides turns: build-first stores with no - filesystem variant yet (e.g. `InMemorySessionStore`) and a few domain stores - (subagent-goal, openai-compat-ref, extension-installation, secret(s), Slack - route / DM). + reference model. The follow-on ratchet cleanup removed the remaining + allowlisted stores, including the former session-store exception. - **Slice B — `Local*` → config (§4.4), renames + ratchets done; config collapse pending.** Bucket 2/3 renames landed: `LocalFilesystem`→`DiskFilesystem` (#6209), `LocalHostProcessPort`→`HostProcessPort` (#6206), diff --git a/docs/reborn/contracts/extensions.md b/docs/reborn/contracts/extensions.md index ad63053c738..367b38c3789 100644 --- a/docs/reborn/contracts/extensions.md +++ b/docs/reborn/contracts/extensions.md @@ -54,6 +54,16 @@ V1 installed extensions live under: /system/extensions// ``` +Generic installation state lives in filesystem record rows under: + +```text +/system/extensions/.installations/manifests/.json +/system/extensions/.installations/installations/.json +``` + +The store declares exact indexes for extension id, installation id, and +activation state, and uses row CAS for activation/health updates and deletes. + Recommended package layout: ```text diff --git a/docs/reborn/security-parity/01-auth.md b/docs/reborn/security-parity/01-auth.md index 66d4a74d954..39ce763072a 100644 --- a/docs/reborn/security-parity/01-auth.md +++ b/docs/reborn/security-parity/01-auth.md @@ -24,9 +24,9 @@ relocates, linked to a tracking issue. | # | Rule | v1 (`auth.rs` unless noted) | v2 | Decision | |---|------|------------------------------|----|----------| | 1 | Auth selection | Runtime three-tier fallback env→DB→OIDC→401 (`auth.rs:1058-1134`) | One `WebuiAuthenticator` impl chosen per deployment; no runtime fallback chain (`webui_serve.rs:284`) | **Change** — architecture differs, end state (authenticated `UserId` or 401) equivalent | -| 2 | Constant-time token compare | SHA-256 hash + `subtle::ConstantTimeEq` over digests (`auth.rs:90-94,157-166`) | `EnvBearerAuthenticator` `ct_eq` on raw token (`lib.rs:191`); `InMemorySessionStore::lookup` `ct_eq` per entry (`session.rs:213`) | **Keep** | +| 2 | Constant-time token compare | SHA-256 hash + `subtle::ConstantTimeEq` over digests (`auth.rs:90-94,157-166`) | `EnvBearerAuthenticator` `ct_eq` on raw token (`lib.rs:191`); `SignedTokenSessionStore` verifies an HMAC-SHA256 signature before accepting session bearers (`signed_session_login.rs`) | **Keep** | | 3 | Bearer prefix parsing | `Bearer ` prefix, case-insensitive (`auth.rs:1025-1045`) | `eq_ignore_ascii_case("Bearer ")` (`webui_serve.rs:805-820`) | **Keep** — locked by `bearer_prefix_is_case_insensitive_parity_with_v1` | -| 4 | DB-backed token store | `DbAuthenticator` LRU cache, 60 s TTL, 1024 entries; invalidate-on-suspend (`auth.rs:202-288`) | Host-supplied `SessionStore`; `InMemorySessionStore` for dev (`session.rs:96-227`). No TTL cache — `revoke` is immediate | **Change** — note revocation latency: v1 up to 60 s stale window vs v2 immediate | +| 4 | DB-backed token store | `DbAuthenticator` LRU cache, 60 s TTL, 1024 entries; invalidate-on-suspend (`auth.rs:202-288`) | `SignedTokenSessionStore` uses stateless HMAC-signed bearers with an in-process logout denylist. No TTL cache — `revoke` is immediate within the process | **Change** — note revocation latency: v1 up to 60 s stale window vs v2 immediate in-process denylisting | | 5 | OIDC verification | JWKS 1 h cache, 64-key cap, 10 s fetch backoff, `iss`/`aud`/`exp` (`auth.rs:370-632`) | `OidcAuthenticator`: 5-min JWKS TTL, stale-while-revalidate, single-flight, RS/ES only (rejects HS256), `iss`/`aud`/`exp`/`nbf` (`oidc.rs`) | **Keep** — behavior-equivalent; differing cache TTL and an explicit algorithm allowlist. Locked at the route layer by `oidc_signed_token_authenticates_protected_route_and_bad_claims_rejected`, `oidc_hs256_token_rejected_on_route` (alg-confusion: RSA modulus as HMAC secret), and `oidc_not_yet_valid_nbf_token_rejected_on_route`; and at the authenticator layer by `oidc_authenticator_rejects_hs256_tokens` + `oidc_authenticator_rejects_future_nbf` | | 6 | Email-domain restriction | Server-side `check_email_domain` + `email_verified` requirement (`auth.rs:942-964,1100-1116`) | Delegated to host `UserDirectory` (`auth/user_directory.rs`); dev `EmailUserDirectory` does not restrict. Google `hd` claim still server-checked (`auth/google.rs`) | **Beta-break** → #3580 (host-responsibility seam) | | 7 | Query-token exception | `?token=` on GET allowlist of three routes: `/api/chat/events`, `/api/logs/events`, `/api/chat/ws` (`auth.rs:979-1007`) | `?token=` honored on exactly one route, `GET /api/webchat/v2/threads/{id}/events`; WS and all mutations are bearer-only (`webui_serve.rs:805-841`) | **Change** — narrowed (tighter); SSE escape hatch kept, `/logs/events` + WS query-token dropped. Locked by `query_token_honored_on_sse_events_route` + `query_token_rejected_on_mutation_route` + `query_token_rejected_on_websocket_route` (the WS-specific drop, locked directly not inferred) | diff --git a/docs/reborn/security-parity/03-headers-errors.md b/docs/reborn/security-parity/03-headers-errors.md index aa9f36a9a0b..4a6b48ed36a 100644 --- a/docs/reborn/security-parity/03-headers-errors.md +++ b/docs/reborn/security-parity/03-headers-errors.md @@ -28,7 +28,7 @@ Decision legend as in `01-auth.md`: **Keep** / **Change** / **Beta-break**. | 5 | Headers on error responses | layers applied router-wide | `SetResponseHeaderLayer` is outermost, so 401/413/429 carry the same headers | **Keep** — locked by `static_security_headers_present_on_error_response` | | 6 | Sanitized auth failure | generic `"Invalid or missing auth token"` 401; detail logged not echoed (`auth.rs:1127-1133`) | all auth failures collapse to a generic 401; reason never leaked (`WebuiAuthenticator` contract; `webui_serve.rs:107-125`) | **Keep** | | 7 | Sanitized validation error | axum extractor rejection → 4xx | `Json` extractor → 400 on malformed body, before the facade. The body is axum's **standard `JsonRejection`** text (e.g. `Failed to parse the request body as JSON: …line N column M`) — it carries no filesystem paths, Rust type names, tracebacks, or secrets, but it is **not** a fully opaque string (it includes serde's structural parse position, which is not sensitive) | **Keep** — locked by `malformed_request_body_returns_sanitized_client_error` (asserts no path / type-name / traceback / token leak) | -| 8 | Sanitized OAuth error | v1 OAuth error handling | callback failures redirect to `?login_error=`; provider/JWT/SessionStore detail logged not echoed (`auth/routes.rs`) | **Keep** — locked by `google_oauth_routes.rs` error-redirect tests | +| 8 | Sanitized OAuth error | v1 OAuth error handling | callback failures redirect to `?login_error=`; provider/JWT/signed-session detail logged not echoed (`auth/routes.rs`) | **Keep** — locked by `google_oauth_routes.rs` error-redirect tests | | 9 | Panic boundary | `CatchPanicLayer` truncates payload (`platform/router.rs:566-592`) | `CatchPanicLayer::custom(panic_handler)` logs truncated detail (`tracing::error!`, not echoed), returns a generic `500 Internal Server Error`; sits inside the header layer so the 500 still carries the static security headers (`webui_serve.rs:706,926-953`) | **Keep** — locked by `panic_boundary_returns_sanitized_500` (a panicking handler with a sensitive message → 500 whose body is exactly `Internal Server Error`, leaking no path / SQL / token / `::`) | ## Test coverage diff --git a/docs/reborn/subagent-spawn/phase-1-contracts.md b/docs/reborn/subagent-spawn/phase-1-contracts.md index e16f288ae87..c44612ba4c3 100644 --- a/docs/reborn/subagent-spawn/phase-1-contracts.md +++ b/docs/reborn/subagent-spawn/phase-1-contracts.md @@ -1932,20 +1932,12 @@ the final child `TurnRunId` before the goal row is written. //! `subagent/goal_store.rs` — subagent goal store contract. //! //! Holds the parent-injected goal for a child run, keyed by the child's -//! `TurnRunId`. Bounded: a hard entry cap with eviction of the oldest entry. -//! A `get` miss is an error, never an empty goal (design §6 goal durability: -//! a miss "fails the child run loudly"). - -use std::collections::{HashMap, VecDeque}; -use std::sync::Mutex; +//! scoped owner and `TurnRunId`. Durable: backed by the runner-owned scoped +//! filesystem store. A `get` miss is an error, never an empty goal (design §6 +//! goal durability: a miss "fails the child run loudly"). use ironclaw_turns::TurnRunId; -/// Hard cap on stored goals. Eviction is oldest-first when the cap is reached. -/// Sized well above any plausible concurrent in-flight subagent count; an -/// eviction under normal load indicates a leak and is logged at `debug`. -const MAX_GOAL_ENTRIES: usize = 4096; - /// Maximum byte length of a stored goal payload. A larger payload is rejected /// at `put` time — the goal is model-generated and must not be unbounded. const MAX_GOAL_BYTES: usize = 64 * 1024; @@ -1997,128 +1989,11 @@ pub trait SubagentGoalStore: Send + Sync { async fn delete_goal(&self, run_id: TurnRunId) -> Result<(), SubagentGoalStoreError>; } -/// Production goal store. Backs `SubagentGoalStore` with the same DB substrate -/// that persists turn-state records, so a child goal survives process restart. -/// -/// Implementation note: add the goal row/table beside turn persistence for both -/// libSQL and PostgreSQL, or add a typed extension table to the existing -/// turn-state DB adapter. Do not hide this behind a generic filesystem mount. -pub struct DbBackedSubagentGoalStore { - // Concrete backend handle added by the implementer. -} - -/// Bounded, in-process, fail-loud subagent goal store for unit tests and local -/// harnesses only. This implementation is not restart-durable and must be -/// marked `NonDurable` in production-readiness checks. -/// -/// Thread-safe (`Mutex`). The `insertion_order` deque tracks FIFO eviction -/// order so the cap is enforced in O(1) amortized. -pub struct BoundedSubagentGoalStore { - inner: Mutex, -} - -struct GoalStoreInner { - goals: HashMap, - insertion_order: VecDeque, -} - -impl BoundedSubagentGoalStore { - pub fn new() -> Self { - Self { - inner: Mutex::new(GoalStoreInner { - goals: HashMap::new(), - insertion_order: VecDeque::new(), - }), - } - } - - /// Store the goal for a child run. - /// - /// - Rejects a payload over `MAX_GOAL_BYTES` (`PayloadTooLarge`). - /// - Rejects a re-insert of an existing key (`DuplicateKey`) — child run - /// ids are unique per spawn, so a duplicate is a bug, not a refresh. - /// - When at `MAX_GOAL_ENTRIES`, evicts the oldest entry first. - pub fn put( - &self, - run_id: TurnRunId, - goal: SubagentGoal, - ) -> Result<(), SubagentGoalStoreError> { - let bytes = goal.byte_len(); - if bytes > MAX_GOAL_BYTES { - return Err(SubagentGoalStoreError::PayloadTooLarge { - bytes, - max: MAX_GOAL_BYTES, - }); - } - let mut inner = lock(&self.inner); - if inner.goals.contains_key(&run_id) { - return Err(SubagentGoalStoreError::DuplicateKey { run_id }); - } - if inner.goals.len() >= MAX_GOAL_ENTRIES { - // Evict oldest. The loop drains stale order entries whose key was - // already removed by a prior `take`. - while let Some(oldest) = inner.insertion_order.pop_front() { - if inner.goals.remove(&oldest).is_some() { - tracing::debug!( - evicted_run_id = %oldest, - "subagent goal store at capacity; evicted oldest goal" - ); - break; - } - } - } - inner.goals.insert(run_id, goal); - inner.insertion_order.push_back(run_id); - Ok(()) - } - - /// Fetch the goal for a child run. A miss is a hard error — the caller - /// (P2.B prompt composition) must fail the child run, never proceed with an - /// empty `## Task`. - pub fn get(&self, run_id: TurnRunId) -> Result { - let inner = lock(&self.inner); - inner - .goals - .get(&run_id) - .cloned() - .ok_or(SubagentGoalStoreError::NotFound { run_id }) - } -} - -impl Default for BoundedSubagentGoalStore { - fn default() -> Self { - Self::new() - } -} - -#[async_trait::async_trait] -impl SubagentGoalStore for BoundedSubagentGoalStore { - async fn put_goal( - &self, - run_id: TurnRunId, - goal: SubagentGoal, - ) -> Result<(), SubagentGoalStoreError> { - self.put(run_id, goal) - } - - async fn get_goal(&self, run_id: TurnRunId) -> Result { - self.get(run_id) - } - - async fn delete_goal(&self, run_id: TurnRunId) -> Result<(), SubagentGoalStoreError> { - let mut inner = lock(&self.inner); - inner.goals.remove(&run_id); - Ok(()) - } -} - -fn lock(inner: &Mutex) -> std::sync::MutexGuard<'_, GoalStoreInner> { - // The store holds no `LLM data` — only an in-flight goal. A poisoned mutex - // is recoverable here; mirror the `InMemoryTurnStateStore` pattern. - match inner.lock() { - Ok(guard) => guard, - Err(poisoned) => poisoned.into_inner(), - } +/// Production goal store. Backs `SubagentGoalStore` with a scoped filesystem +/// mount owned by runner/composition wiring, so a child goal survives process +/// restart without a bespoke in-process cache. +pub struct FilesystemSubagentGoalStore { + // Concrete scoped filesystem handle supplied by the implementer. } ``` @@ -2126,16 +2001,13 @@ Design decisions: - **`get` borrows-and-clones, does not remove.** README §6 says `put`/`get`; it does not say `get` consumes. A child may need its goal more than once (e.g. recovery re-materialisation), and a process restart must be able to - re-read it from the DB-backed implementation. Eviction is by cap in the - in-memory test implementation, not by read. (If Phase 2 finds it genuinely - needs a one-shot `take`, add it then — but the contract here is non-consuming - `get_goal`.) + re-read it from the filesystem-backed implementation. (If Phase 2 finds it + genuinely needs a one-shot `take`, add it then — but the contract here is + non-consuming `get_goal`.) - **`DuplicateKey` is an error.** Child `TurnRunId`s are freshly minted per spawn; a duplicate `put` means a wiring bug — fail loud. -- `tracing::debug!` for eviction, never `info!`/`warn!` (project rule: `info!` - corrupts the REPL/TUI; background subagent paths must use `debug!`). -- The store is `Send + Sync` (`Mutex` + `Send` contents) so Phase 2 can wrap it - in `Arc` and share it with the capability port and the prompt port. +- The store is `Send + Sync` so Phase 2 can wrap it in `Arc` and share it with + the capability port and the prompt port. ### 3.5 `lib.rs` + `subagent/mod.rs` wiring @@ -2186,14 +2058,13 @@ In `subagent/goal_store.rs` `#[cfg(test)] mod tests`: 4. **`put_rejects_duplicate_key`** — `put_goal` twice for the same `run_id` → second call `Err(DuplicateKey { .. })`. -5. **`bounded_store_evicts_oldest`** — insert `MAX_GOAL_ENTRIES + 1` goals; - assert the very first inserted key is now a `get` miss (`NotFound`), the - second-inserted and the last-inserted keys are still present. Confirms FIFO - eviction and the cap. +5. **`filesystem_store_keys_goals_by_scope_and_run_id`** — write the same + `TurnRunId` under distinct owner scopes and assert lookups are isolated by + scope and run id. -6. **`bounded_store_stays_at_cap`** — after inserting `MAX_GOAL_ENTRIES + N` - entries, assert the live entry count never exceeds `MAX_GOAL_ENTRIES` (expose - a `#[cfg(test)] fn len(&self)` or assert via successful/missed `get`s). +6. **`filesystem_goal_store_reopens_over_same_backend`** — write through one + filesystem-backed store, construct a second store over the same backend, and + assert the goal is still readable. 7. **`goal_store_is_send_sync`** — `fn assert_send_sync(){}`; `assert_send_sync::>()` — Phase 2 shares it via @@ -2310,11 +2181,10 @@ with whichever of A/B is chosen. contain **no `{{placeholder}}`-style templating** and read as a static system prompt. The goal injection is a *separate user message* (Phase 2); a templated direction file would reopen the prompt-injection hole the design closes. -- The goal-store cap (`MAX_GOAL_ENTRIES`) and payload cap (`MAX_GOAL_BYTES`) are - proposed numbers — confirm with the design owner. Too small a cap silently - evicts a live in-flight subagent's goal, which P2.B then turns into a loud - child-run failure (acceptable fail-loud behavior, but undesirable under normal - load — hence the `debug!` log on eviction as an early-warning signal). +- The goal-store payload cap (`MAX_GOAL_BYTES`) is a proposed number — confirm + with the design owner. Too small a payload cap fails a live in-flight + subagent's goal at spawn time; that is acceptable fail-loud behavior but + undesirable under normal load. - `thiserror` must be added to `ironclaw_runner/Cargo.toml` (§3.5) or `SubagentGoalStoreError` will not compile. diff --git a/docs/superpowers/plans/2026-07-13-extension-ownership-migration.md b/docs/superpowers/plans/2026-07-13-extension-ownership-migration.md index 7c79f5352b9..f736668db92 100644 --- a/docs/superpowers/plans/2026-07-13-extension-ownership-migration.md +++ b/docs/superpowers/plans/2026-07-13-extension-ownership-migration.md @@ -51,7 +51,7 @@ Expected: compilation/test failure because the migration module and command do n - [ ] **Step 3: Add the tenant-qualified migration store seam** Add a `migration-support`-gated composition function that opens -`/tenants//system/extensions/.installations/state.json` over a supplied +`/tenants//system/extensions/.installations/` over a supplied `RootFilesystem`. Re-export only that function; do not expose the concrete filesystem store. diff --git a/docs/superpowers/specs/2026-06-26-reborn-integration-test-framework-design.md b/docs/superpowers/specs/2026-06-26-reborn-integration-test-framework-design.md index ae0226ae607..d7ef7b91f9e 100644 --- a/docs/superpowers/specs/2026-06-26-reborn-integration-test-framework-design.md +++ b/docs/superpowers/specs/2026-06-26-reborn-integration-test-framework-design.md @@ -166,11 +166,11 @@ There is **no single outbound chokepoint** — production has four distinct seam Stateful subsystems that persist to the database — **auth flows + credential accounts, the five approval/permission/lease stores, extension installations, skills, and secrets** — are tested against the **real stores on the ephemeral `RootFilesystem` backend** (`StorageMode::InMemory` default, `LibSql` for SQL fidelity). **No store/repository-level interceptor or fake is added for persistence.** -This is not an extra mechanism — each subsystem's durable impl already persists through the *same* `RootFilesystem` that §3.2 controls (via `ScopedFilesystem` or `RootFilesystem::write_file`; e.g. auth records under `/secrets/product-auth/`, installs at `/system/extensions/.installations/state.json`, skills under `/user-skills/`). Each store trait ships a `Filesystem*` impl (rides `RootFilesystem`) alongside an `InMemory*` impl, and the harness's `StorageMode` selects between them: under `LibSql` every subsystem rides the one `LibSqlRootFilesystem` (real CAS, JSON serialization, and migrations for all of them at once); under `InMemory` they use their `InMemory*` counterparts — not a shared backing store, but equally interceptor-free. Either way, persisting them for real is **free** — no per-store fake to write. +This is not an extra mechanism — each subsystem's durable impl already persists through the *same* `RootFilesystem` that §3.2 controls (via `ScopedFilesystem` or `RootFilesystem::write_file`; e.g. auth records under `/secrets/product-auth/`, installs as rows under `/system/extensions/.installations/`, skills under `/user-skills/`). The harness's `StorageMode` selects the backing filesystem: under `LibSql` every subsystem rides the one `LibSqlRootFilesystem` (real CAS, JSON serialization, and migrations for all of them at once); under `InMemory` they use the same filesystem stores over an in-memory backend. Either way, persisting them for real is **free** — no per-store fake to write. **Why not granular store fakes** (the rule): a per-store fake would (a) duplicate the persistence seam that already exists, (b) bypass the real CAS / gate-resolution / credential-selection / serialization logic that *is* the thing worth testing, and (c) drift from the real store (accept what real rejects → green tests, broken prod). This is the industry consensus for write-then-read-back state ("don't mock what you don't own"; mock only the external I/O — OAuth HTTP, approval delivery, artifact/skill download — which already ride `RuntimeHttpEgress` / `OutboundDeliverySink`, §3.6). -**Guardrail:** to assert write-then-read-back persistence *correctness*, use `StorageMode::LibSql` (real SQL/CAS/serialization) — don't manually wire an `InMemory*` store outside `StorageMode` to skip the persistence path; that reintroduces the fake-drift this rule exists to avoid. (`StorageMode::InMemory`, the default, legitimately uses the `InMemory*` impls — it's interceptor-free and fine for behavior tests, just not the place to prove SQL/serialization fidelity.) +**Guardrail:** to assert write-then-read-back persistence *correctness*, use `StorageMode::LibSql` (real SQL/CAS/serialization) — don't manually wire a hand-written `InMemory*` store to skip the persistence path; that reintroduces the fake-drift this rule exists to avoid. (`StorageMode::InMemory`, the default, legitimately uses the same filesystem stores over an in-memory backend — it's interceptor-free and fine for behavior tests, just not the place to prove SQL/serialization fidelity.) **Two wiring exceptions** (gaps to close in the relevant slice — not reasons to add a fake): - The base `RebornBinaryE2EHarness` does not wire product-auth; full auth-gate → credential-account → selection flows must route through the `build_reborn_services()` path, which wires the real `FilesystemAuthProductServices`. diff --git a/tests/integration/subagent_await_edge.rs b/tests/integration/subagent_await_edge.rs index 8ccad6e841d..b952905d46e 100644 --- a/tests/integration/subagent_await_edge.rs +++ b/tests/integration/subagent_await_edge.rs @@ -303,7 +303,7 @@ async fn scope_with_unclosed_edge_is_recovered_before_new_spawns_are_admitted() .unwrap(); let goal_store: Arc = - Arc::new(ironclaw_runner::subagent::goal_store::InMemoryBoundedSubagentGoalStore::new()); + Arc::new(ironclaw_runner::subagent::goal_store::in_memory_backed_subagent_goal_store()); let turn_state_store: Arc = Arc::new(in_memory_turn_state_store()); let thread_service = Arc::new(InMemorySessionThreadService::default()); @@ -348,7 +348,7 @@ async fn brand_new_scope_with_no_unclosed_edges_is_admitted_immediately() { let store = real_store(); let scope = scope("tenant-fresh", "user-fresh", Some("agent-fresh"), None); let goal_store: Arc = - Arc::new(ironclaw_runner::subagent::goal_store::InMemoryBoundedSubagentGoalStore::new()); + Arc::new(ironclaw_runner::subagent::goal_store::in_memory_backed_subagent_goal_store()); let turn_state_store: Arc = Arc::new(in_memory_turn_state_store()); let thread_service = Arc::new(InMemorySessionThreadService::default()); @@ -627,7 +627,7 @@ async fn rollback_deleted_edge_is_reconstructed_so_the_parent_still_gets_the_res // exact call `TurnCommittedEventObserver::observe_committed_event` // makes in production. let goal_store: Arc = - Arc::new(ironclaw_runner::subagent::goal_store::InMemoryBoundedSubagentGoalStore::new()); + Arc::new(ironclaw_runner::subagent::goal_store::in_memory_backed_subagent_goal_store()); let turn_state_store: Arc = state_store.clone(); let result_writer: Arc = Arc::new(AllowResultWriter); @@ -942,7 +942,7 @@ async fn mixed_status_batch_group_reports_each_members_own_status_and_reason() { // child_a fails first (group not yet fully settled -> no drain), then // child_b completes last and drives the batch drain. let goal_store: Arc = - Arc::new(ironclaw_runner::subagent::goal_store::InMemoryBoundedSubagentGoalStore::new()); + Arc::new(ironclaw_runner::subagent::goal_store::in_memory_backed_subagent_goal_store()); let turn_state_store: Arc = state_store.clone(); let result_writer: Arc = Arc::new(AllowResultWriter); diff --git a/tests/integration/support/group.rs b/tests/integration/support/group.rs index 5640df07527..baf88de4275 100644 --- a/tests/integration/support/group.rs +++ b/tests/integration/support/group.rs @@ -93,7 +93,7 @@ use ironclaw_runner::subagent::{ store::FilesystemAwaitEdgeStore, }, flavors::StaticSubagentDefinitionResolver, - goal_store::InMemoryBoundedSubagentGoalStore, + goal_store::in_memory_backed_subagent_goal_store, }; use ironclaw_runner::turn_scheduler::TurnRunSchedulerHandle; use ironclaw_threads::SessionThreadService; @@ -808,7 +808,7 @@ impl RebornIntegrationGroupBuilder { // "one shared handle, never a per-store fixed view" rule. let await_edge_store = Arc::new(FilesystemAwaitEdgeStore::new(Arc::clone(&turns_scoped_fs))); - let await_edge_goal_store = Arc::new(InMemoryBoundedSubagentGoalStore::new()); + let await_edge_goal_store = Arc::new(in_memory_backed_subagent_goal_store()); let await_edge_resolver = Arc::new(AwaitEdgeResolver::new_unbound( Arc::clone(&await_edge_store), await_edge_goal_store.clone() as Arc, diff --git a/tests/integration/webui_v2_product_api.rs b/tests/integration/webui_v2_product_api.rs index 1480840bc4a..753fcc9007d 100644 --- a/tests/integration/webui_v2_product_api.rs +++ b/tests/integration/webui_v2_product_api.rs @@ -21,7 +21,11 @@ use axum::http::StatusCode; use axum::http::{Method, Request}; use chrono::{DateTime, Utc}; use ironclaw_events::InMemoryDurableEventLog; -use ironclaw_extensions::ExtensionInstallationStore; +use ironclaw_extensions::{ + CapabilityProviderHostApiContract, ExtensionActivationState, ExtensionInstallation, + ExtensionInstallationId, ExtensionInstallationStore, ExtensionManifestRecord, + ExtensionManifestRef, HostApiContractRegistry, InstallationOwner, +}; use ironclaw_filesystem::{CompositeRootFilesystem, LibSqlRootFilesystem}; use ironclaw_host_api::{ AgentId, CapabilityId, EffectKind, ExtensionId, PermissionMode, TenantId, UserId, @@ -470,27 +474,48 @@ async fn production_runtime_canonicalizes_legacy_multi_row_extension_installs() drop(webui); runtime.shutdown().await.expect("runtime shuts down"); - let state_path = storage_root.join("system/extensions/.installations/state.json"); - let mut state: Value = serde_json::from_slice( - &std::fs::read(&state_path).expect("read extension installation state"), + let store = ironclaw_reborn_composition::test_support::open_local_dev_extension_installation_store_for_test( + &storage_root, ) - .expect("extension installation state is JSON"); - let rows = state["installations"] - .as_array() - .expect("installations array"); + .await + .expect("open extension installation store"); + let rows = store + .list_installations() + .await + .expect("list extension installations"); assert_eq!(rows.len(), 1, "member installs must already share one row"); - let mut alice_row = rows[0].clone(); - alice_row["installation_id"] = Value::String("legacy-alice-legacy-members".to_string()); - alice_row["owner"] = serde_json::json!({"kind": "user", "user_id": "alice"}); - let mut bob_row = rows[0].clone(); - bob_row["installation_id"] = Value::String("legacy-bob-legacy-members".to_string()); - bob_row["owner"] = serde_json::json!({"kind": "user", "user_id": "bob"}); - state["installations"] = serde_json::json!([alice_row, bob_row]); - std::fs::write( - &state_path, - serde_json::to_vec_pretty(&state).expect("serialize legacy installation state"), - ) - .expect("write legacy installation state"); + let canonical = rows.into_iter().next().expect("one installation row"); + store + .delete_installation(canonical.installation_id()) + .await + .expect("delete canonical installation row"); + for (installation_id, owner) in [ + ( + "legacy-alice-legacy-members", + InstallationOwner::user(alice_id.clone()), + ), + ( + "legacy-bob-legacy-members", + InstallationOwner::user(bob_id.clone()), + ), + ] { + store + .upsert_installation( + ExtensionInstallation::new( + ExtensionInstallationId::new(installation_id).expect("valid installation id"), + canonical.extension_id().clone(), + canonical.activation_state(), + canonical.manifest_ref().clone(), + canonical.credential_bindings().to_vec(), + canonical.updated_at(), + owner, + ) + .expect("legacy installation row"), + ) + .await + .expect("write legacy installation row"); + } + drop(store); let rebuilt_input = RebornBuildInput::local_dev("webui-legacy-operator", storage_root.clone()) .with_local_runtime_identity(tenant_id.clone(), agent_id.clone()) @@ -673,54 +698,70 @@ async fn production_runtime_restart_skips_installation_row_absent_from_catalog() drop(webui); runtime.shutdown().await.expect("runtime shuts down"); - // Hand-edit the persisted state file the way a standalone v1->Reborn - // migration tool would: add a manifest + installation row for an - // extension id that has no corresponding materialized catalog package - // (no `/system/extensions//` files were written for it), simulating - // a migration that has not yet materialized catalog packages. - let state_path = storage_root.join("system/extensions/.installations/state.json"); - let mut state: Value = serde_json::from_slice( - &std::fs::read(&state_path).expect("read extension installation state"), + // Seed the same shape a standalone v1->Reborn migration tool would: add a + // manifest + installation row for an extension id that has no corresponding + // materialized catalog package (no `/system/extensions//` files were + // written for it), simulating a migration that has not yet materialized + // catalog packages. + let store = ironclaw_reborn_composition::test_support::open_local_dev_extension_installation_store_for_test( + &storage_root, ) - .expect("extension installation state is JSON"); - let manifests = state["manifests"] - .as_array() - .expect("manifests array") - .clone(); - let mut orphan_manifest = manifests - .first() - .expect("at least one manifest present") - .clone(); - orphan_manifest["raw_toml"] = Value::String( - orphan_manifest["raw_toml"] - .as_str() - .expect("raw_toml is a string") - .replace("catalog-present", "orphan-migrated"), - ); - let mut new_manifests = manifests; - new_manifests.push(orphan_manifest); - state["manifests"] = Value::Array(new_manifests); - - let installations = state["installations"] - .as_array() - .expect("installations array") - .clone(); - let mut orphan_row = installations - .first() - .expect("at least one installation present") - .clone(); - orphan_row["installation_id"] = Value::String("orphan-migrated".to_string()); - orphan_row["extension_id"] = Value::String("orphan-migrated".to_string()); - orphan_row["manifest_ref"]["extension_id"] = Value::String("orphan-migrated".to_string()); - let mut new_installations = installations; - new_installations.push(orphan_row); - state["installations"] = Value::Array(new_installations); - - std::fs::write( - &state_path, - serde_json::to_vec_pretty(&state).expect("serialize orphan installation state"), + .await + .expect("open extension installation store"); + let catalog_extension_id = ExtensionId::new("catalog-present").expect("extension id"); + let catalog_manifest = store + .get_manifest(&catalog_extension_id) + .await + .expect("read catalog-present manifest") + .expect("catalog-present manifest exists"); + let catalog_installation = store + .list_installations() + .await + .expect("list extension installations") + .into_iter() + .find(|installation| installation.extension_id() == &catalog_extension_id) + .expect("catalog-present installation exists"); + let orphan_extension_id = ExtensionId::new("orphan-migrated").expect("extension id"); + let orphan_raw_toml = catalog_manifest + .raw_toml() + .replace("catalog-present", orphan_extension_id.as_str()); + let mut contracts = HostApiContractRegistry::new(); + contracts + .register(Arc::new( + CapabilityProviderHostApiContract::new().expect("capability provider contract"), + )) + .expect("register capability provider contract"); + let orphan_manifest = ExtensionManifestRecord::from_toml_with_contracts( + orphan_raw_toml, + catalog_manifest.manifest().source, + &ironclaw_host_api::HostPortCatalog::empty(), + catalog_manifest.manifest_hash().cloned(), + &contracts, ) - .expect("write orphan installation state"); + .expect("orphan manifest parses"); + store + .upsert_manifest(orphan_manifest) + .await + .expect("write orphan manifest"); + store + .upsert_installation( + ExtensionInstallation::new( + ExtensionInstallationId::new("orphan-migrated").expect("valid installation id"), + orphan_extension_id.clone(), + ExtensionActivationState::Installed, + ExtensionManifestRef::new( + orphan_extension_id, + catalog_manifest.manifest_hash().cloned(), + ), + catalog_installation.credential_bindings().to_vec(), + catalog_installation.updated_at(), + catalog_installation.owner().clone(), + ) + .expect("orphan installation row"), + ) + .await + .expect("write orphan installation row"); + drop(store); let rebuilt_input = RebornBuildInput::local_dev("webui-orphan-operator", storage_root.clone()) .with_local_runtime_identity(tenant_id.clone(), agent_id.clone()) diff --git a/tests/support/reborn_parity_qa/binary_e2e.rs b/tests/support/reborn_parity_qa/binary_e2e.rs index 49dc766747a..9ec0ae38bad 100644 --- a/tests/support/reborn_parity_qa/binary_e2e.rs +++ b/tests/support/reborn_parity_qa/binary_e2e.rs @@ -42,7 +42,7 @@ use ironclaw_runner::subagent::{ store::FilesystemAwaitEdgeStore, }, flavors::StaticSubagentDefinitionResolver, - goal_store::InMemoryBoundedSubagentGoalStore, + goal_store::in_memory_backed_subagent_goal_store, }; use ironclaw_runner::turn_scheduler::{SchedulerTurnRunWakeNotifier, TurnRunSchedulerHandle}; use ironclaw_runner::{ @@ -803,7 +803,7 @@ impl RebornBinaryE2EHarness { // "one shared handle, never a per-store fixed view" rule. let await_edge_store = Arc::new(FilesystemAwaitEdgeStore::new(Arc::clone(&turns_scoped_fs))); - let await_edge_goal_store = Arc::new(InMemoryBoundedSubagentGoalStore::new()); + let await_edge_goal_store = Arc::new(in_memory_backed_subagent_goal_store()); let await_edge_resolver = Arc::new(AwaitEdgeResolver::new_unbound( Arc::clone(&await_edge_store), await_edge_goal_store.clone() as Arc,