diff --git a/Cargo.toml b/Cargo.toml index 870cd91d8..e7cc01ffe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "io-uti rusqlite = { version = "0.39", features = ["bundled"] } regex = "1" sha2 = "0.10" +base64 = "0.22" serde_norway = "0.9.42" semver = "1" # `default-features = false` excludes network transports, credential helpers, and filter-driver command execution from the dependency graph. diff --git a/crates/mc-kernel/src/admission.rs b/crates/mc-kernel/src/admission.rs index ef4afa2c8..3a9d40d06 100644 --- a/crates/mc-kernel/src/admission.rs +++ b/crates/mc-kernel/src/admission.rs @@ -1110,7 +1110,21 @@ impl Envelope<'_> { replacement: DecisionSpec, ) -> Result { let facts = load_subject_facts(self, replaced_object_id)?; - if load_prior_decision(self, &facts)?.is_some_and(|stored| { + self.refuse_barred_lineage(&facts)?; + // A live replacement is a fold target, so a barred survivor is refused before correction. + if self + .object_state(&replacement.object_id)? + .is_some_and(|state| state.object.invalidated_commit_seq.is_none()) + { + let survivor = load_subject_facts(self, &replacement.object_id)?; + self.refuse_barred_lineage(&survivor)?; + } + self.correct_decision(replaced_object_id, replacement) + } + + /// A lineage barred by its latest admission decision cannot be corrected or folded. + fn refuse_barred_lineage(&self, facts: &SubjectFacts) -> Result<(), KernelError> { + if load_prior_decision(self, facts)?.is_some_and(|stored| { matches!( stored.decision.disposition, Disposition::Quarantined | Disposition::Rejected | Disposition::Contradicted @@ -1118,7 +1132,7 @@ impl Envelope<'_> { }) { return Err(KernelError::AdmissionPolicy); } - self.correct_decision(replaced_object_id, replacement) + Ok(()) } pub fn revoke_approval( diff --git a/crates/mc-kernel/src/cas/mod.rs b/crates/mc-kernel/src/cas/mod.rs index ac9de2475..c63623c39 100644 --- a/crates/mc-kernel/src/cas/mod.rs +++ b/crates/mc-kernel/src/cas/mod.rs @@ -206,6 +206,35 @@ pub enum ArtifactErrorKind { PurgeUnlinkPending, } +impl ArtifactErrorKind { + /// Every variant, in declaration order, so consumers can prove they map + /// each one. Adding a variant fails `all_names_every_variant_once` to + /// compile until its match is extended, which is where this list is + /// revisited. + pub const ALL: &'static [Self] = &[ + Self::PayloadTooLarge, + Self::Capacity, + Self::StorageExhausted, + Self::IngestionFailClosed, + Self::ReAdmissionBlocked, + Self::MissingObject, + Self::CorruptObject, + Self::ReferenceUnavailable, + Self::ReferenceCommit, + Self::AlignmentRebuild, + Self::ReclaimInProgress, + Self::UnredactableSecret, + Self::ScanIncomplete, + Self::DetectionLimit, + Self::TextFieldTooLong, + Self::InvalidInput, + Self::OperationKeyReused, + Self::StorageConstraint, + Self::PurgeIntent, + Self::PurgeUnlinkPending, + ]; +} + /// Bounded artifact failure with optional capacity or digest context. /// /// Display and debug output expose no payload bytes. @@ -474,6 +503,44 @@ pub(crate) fn is_artifact_digest(value: &str) -> bool { .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) } +#[cfg(test)] +mod error_kind_tests { + use super::ArtifactErrorKind; + + #[test] + fn all_names_every_variant_once() { + for kind in ArtifactErrorKind::ALL { + // The wildcard-free match forces a new arm, and with it a review + // of `ALL`, whenever a variant is added. + match kind { + ArtifactErrorKind::PayloadTooLarge + | ArtifactErrorKind::Capacity + | ArtifactErrorKind::StorageExhausted + | ArtifactErrorKind::IngestionFailClosed + | ArtifactErrorKind::ReAdmissionBlocked + | ArtifactErrorKind::MissingObject + | ArtifactErrorKind::CorruptObject + | ArtifactErrorKind::ReferenceUnavailable + | ArtifactErrorKind::ReferenceCommit + | ArtifactErrorKind::AlignmentRebuild + | ArtifactErrorKind::ReclaimInProgress + | ArtifactErrorKind::UnredactableSecret + | ArtifactErrorKind::ScanIncomplete + | ArtifactErrorKind::DetectionLimit + | ArtifactErrorKind::TextFieldTooLong + | ArtifactErrorKind::InvalidInput + | ArtifactErrorKind::OperationKeyReused + | ArtifactErrorKind::StorageConstraint + | ArtifactErrorKind::PurgeIntent + | ArtifactErrorKind::PurgeUnlinkPending => {} + } + } + for (index, kind) in ArtifactErrorKind::ALL.iter().enumerate() { + assert!(!ArtifactErrorKind::ALL[..index].contains(kind)); + } + } +} + #[cfg(test)] mod lattice_tests { use super::ProviderEgress; diff --git a/crates/mc-kernel/src/envelope.rs b/crates/mc-kernel/src/envelope.rs index 504ca4c5e..afb1b1b4b 100644 --- a/crates/mc-kernel/src/envelope.rs +++ b/crates/mc-kernel/src/envelope.rs @@ -49,7 +49,9 @@ impl Sensitivity { } } - pub(super) fn restrictive(self, other: Self) -> Self { + /// The stricter of two labels; a successor never relabels its + /// predecessor's content below the label it was admitted under. + pub fn restrictive(self, other: Self) -> Self { match (self, other) { (Self::Secret, _) | (_, Self::Secret) => Self::Secret, (Self::Sensitive, _) | (_, Self::Sensitive) => Self::Sensitive, diff --git a/crates/mc-kernel/src/slice/read.rs b/crates/mc-kernel/src/slice/read.rs index 925f8532d..56a5ed30a 100644 --- a/crates/mc-kernel/src/slice/read.rs +++ b/crates/mc-kernel/src/slice/read.rs @@ -57,8 +57,10 @@ impl KernelStore { /// /// Returns [`KernelError::InvalidInput`] for a negative sequence, /// [`KernelError::FutureSnapshot`] when `requested` exceeds the transaction's current tip, - /// [`KernelError::CorruptCanonicalRow`] for invalid stored JSON payloads, and - /// [`KernelError::Io`] for lock, SQLite, conversion, or commit failures. + /// [`KernelError::CorruptCanonicalRow`] for invalid stored JSON payloads, + /// the SQLite classification (`Busy`, `Conflict`, `Io`) for a row that + /// fails while being read, and [`KernelError::Io`] for lock, statement, or + /// commit failures. pub fn slice_as_of(&self, requested: i64) -> Result { let mut reader = self.lock_reader()?; let tx = reader @@ -78,8 +80,10 @@ impl KernelStore { /// /// Returns [`KernelError::InvalidInput`] for a negative sequence, /// [`KernelError::FutureSnapshot`] when `requested` exceeds the transaction's current tip, - /// [`KernelError::CorruptCanonicalRow`] for invalid stored JSON payloads, and - /// [`KernelError::Io`] for lock, SQLite, conversion, or commit failures. + /// [`KernelError::CorruptCanonicalRow`] for invalid stored JSON payloads, + /// the SQLite classification (`Busy`, `Conflict`, `Io`) for a row that + /// fails while being read, and [`KernelError::Io`] for lock, statement, or + /// commit failures. pub fn decisions_for_objects_as_of( &self, object_ids: &[String], @@ -335,6 +339,6 @@ fn classify_row_error(error: rusqlite::Error) -> KernelError { { KernelError::CorruptCanonicalRow } - _ => KernelError::Io, + _ => crate::map_sqlite(error), } } diff --git a/crates/mc-kernel/tests/kernel_slice.rs b/crates/mc-kernel/tests/kernel_slice.rs index 202eed205..b624542d2 100644 --- a/crates/mc-kernel/tests/kernel_slice.rs +++ b/crates/mc-kernel/tests/kernel_slice.rs @@ -972,3 +972,53 @@ fn supersede_decision_refuses_a_quarantined_predecessor() { .unwrap_err(); assert_eq!(missing, KernelError::NotFound); } + +/// A supersession whose replacement is already live folds the predecessor +/// into that survivor, so the survivor's lineage is judged as well. +#[test] +fn a_fold_refuses_a_quarantined_survivor() { + let directory = tempfile::tempdir().unwrap(); + let store = KernelStore::open(directory.path()).unwrap(); + seed_domain(&store); + store + .commit(intent("seed", '1'), |envelope| { + envelope.insert_decision(decision(1))?; + envelope.insert_decision(decision(2))?; + envelope.insert_decision(decision(3))?; + envelope + .record_admission(subject_request("decision-object-3", EventKind::Quarantine))?; + Ok(String::new()) + }) + .unwrap(); + + // The survivor's revision advances past the predecessor's, so only the + // lineage guard stands between this fold and the write. + let barred_survivor = store + .commit(intent("fold-into-quarantined", '2'), |envelope| { + envelope.supersede_decision("decision-object-2", decision(3))?; + Ok(String::new()) + }) + .unwrap_err(); + assert_eq!(barred_survivor, KernelError::AdmissionPolicy); + assert_eq!( + inspect_i64( + directory.path(), + "SELECT COUNT(*) FROM object_registry WHERE invalidated_commit_seq IS NOT NULL OR superseded_by IS NOT NULL" + ), + 0 + ); + + store + .commit(intent("fold", '3'), |envelope| { + envelope.supersede_decision("decision-object-1", decision(2))?; + Ok(String::new()) + }) + .unwrap(); + assert_eq!( + inspect_i64( + directory.path(), + "SELECT COUNT(*) FROM object_registry WHERE object_id = 'decision-object-1' AND superseded_by = 'decision-object-2'" + ), + 1 + ); +} diff --git a/crates/mc-module/Cargo.toml b/crates/mc-module/Cargo.toml index 592429284..6d2a71f15 100644 --- a/crates/mc-module/Cargo.toml +++ b/crates/mc-module/Cargo.toml @@ -46,8 +46,8 @@ memchr = "2" tokio = { workspace = true, features = ["signal"] } tokio-util = { version = "0.7", features = ["rt"] } sha2 = { workspace = true } -# Artifact pages cross the JSON ring as base64; same version mc-tokenizer uses. -base64 = "0.22" +# Artifact pages cross the JSON ring as base64. +base64 = { workspace = true } # Page decoding: SIMD with runtime dispatch and a scalar fallback; decodes # the same inputs to the same bytes as `base64`'s STANDARD engine and rejects # the same malformed ones. diff --git a/crates/mc-module/src/kernel_routes/commit.rs b/crates/mc-module/src/kernel_routes/commit.rs index 531cd4747..84e42836d 100644 --- a/crates/mc-module/src/kernel_routes/commit.rs +++ b/crates/mc-module/src/kernel_routes/commit.rs @@ -51,6 +51,7 @@ const PROJECTED_DEPENDENCY_KINDS: [&str; 2] = [ ]; #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct CommitRequest { intent: IntentRequest, #[serde(default)] @@ -66,13 +67,14 @@ struct CommitRequest { } #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct TokenRequest { object_id: String, known_as_of: i64, } #[derive(Debug, Deserialize)] -#[serde(tag = "op", rename_all = "snake_case")] +#[serde(tag = "op", rename_all = "snake_case", deny_unknown_fields)] enum Operation { InsertDecision { spec: DecisionRequest, @@ -92,6 +94,7 @@ enum Operation { /// A decision as the wire carries it: `source_kind` comes from the request /// and `scope_id` from the binding, so neither is accepted per row. #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] struct DecisionRequest { decision_id: String, object_id: String, @@ -131,6 +134,7 @@ impl DecisionRequest { } #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] struct ObservationRequest { observation_id: String, object_id: String, @@ -153,6 +157,7 @@ struct ObservationRequest { } #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] struct DependencyRequest { dependency_object_id: String, dependency_kind: String, @@ -354,20 +359,36 @@ fn admit( Ok(()) } -/// Materializes the project scope the first time an operation needs it; the -/// check and the insert share the envelope's transaction, so two commits for -/// a new project cannot both insert it. +/// The domain every route-materialized project scope belongs to. Scopes are +/// route-owned rows, so they hang off a route-owned domain that callers +/// cannot write into. Scope rows created under an earlier layout keep their +/// domain; the scope check compares terms only. +const PROJECT_SCOPES_DOMAIN_ID: &str = "project-scopes"; + +/// A caller-named domain; the route-owned one is refused as invalid input. +fn caller_domain(domain_id: &str) -> Result<&str, KernelError> { + if domain_id == PROJECT_SCOPES_DOMAIN_ID { + return Err(KernelError::InvalidInput); + } + Ok(domain_id) +} + +/// Materializes the project scope, and the route-owned domain it belongs to, +/// the first time an operation needs it; the checks and the inserts share the +/// envelope's transaction, so two commits for a new project cannot both +/// insert either row. fn ensure_scope( envelope: &mut Envelope<'_>, project: &ProjectBinding, - domain_id: &str, ready: &mut bool, + domains_ready: &mut HashSet, refused: &mut Option, ) -> Result<(), KernelError> { if *ready { return Ok(()); } - let expected = project.scope_spec(domain_id); + ensure_domain(envelope, PROJECT_SCOPES_DOMAIN_ID, domains_ready)?; + let expected = project.scope_spec(PROJECT_SCOPES_DOMAIN_ID); match envelope.scope_terms(&expected.scope_id)? { None => { envelope.insert_scope(expected)?; @@ -445,12 +466,16 @@ fn apply( for operation in &plan.operations { match operation { Operation::InsertDecision { spec } => { - ensure_domain(envelope, &spec.domain_id, &mut domains_ready)?; + ensure_domain( + envelope, + caller_domain(&spec.domain_id)?, + &mut domains_ready, + )?; ensure_scope( envelope, &plan.project, - &spec.domain_id, &mut scope_ready, + &mut domains_ready, refused, )?; let spec = spec.clone().into_spec(&plan.source_kind, &scope_id); @@ -462,12 +487,16 @@ fn apply( replaced_object_id, spec, } => { - ensure_domain(envelope, &spec.domain_id, &mut domains_ready)?; + ensure_domain( + envelope, + caller_domain(&spec.domain_id)?, + &mut domains_ready, + )?; ensure_scope( envelope, &plan.project, - &spec.domain_id, &mut scope_ready, + &mut domains_ready, refused, )?; let predecessor = scoped_object_state(envelope, filter, replaced_object_id)?; @@ -482,6 +511,17 @@ fn apply( Some(state) => { scoped_object_state(envelope, filter, &spec.object_id)?; if state.object.invalidated_commit_seq.is_none() { + // A fold discards the spec and keeps the + // survivor's stored label, so the floor is + // judged against that label instead. + if state + .object + .sensitivity + .restrictive(predecessor.object.sensitivity) + != state.object.sensitivity + { + return Err(KernelError::AdmissionPolicy); + } (true, state.object.source_revision) } else { (false, spec.source_revision) @@ -493,7 +533,11 @@ fn apply( *refused = Some(CommitFailure::RevisionNotAdvanced); return Err(KernelError::Conflict); } - let spec = spec.clone().into_spec(&plan.source_kind, &scope_id); + let mut spec = spec.clone().into_spec(&plan.source_kind, &scope_id); + // The label floor is enforced here as well as in the client: + // a successor may raise its predecessor's sensitivity but not + // lower it. + spec.sensitivity = spec.sensitivity.restrictive(predecessor.object.sensitivity); let outcome = envelope.supersede_decision(replaced_object_id, spec)?; if replacement_live { result.merged.push(outcome.object_id.clone()); @@ -509,12 +553,16 @@ fn apply( result.touched.push(object_id.clone()); } Operation::InsertObservation { spec } => { - ensure_domain(envelope, &spec.domain_id, &mut domains_ready)?; + ensure_domain( + envelope, + caller_domain(&spec.domain_id)?, + &mut domains_ready, + )?; ensure_scope( envelope, &plan.project, - &spec.domain_id, &mut scope_ready, + &mut domains_ready, refused, )?; // The alignment projection pairs an observation with the @@ -578,18 +626,13 @@ impl McHandler { pub(crate) async fn handle_kernel_commit( &self, channel: RouteHandle, - request: &Value, + request: Value, ) -> PreparedOutcome { - let scope = match self.kernel_route_scope(channel, request, OPERATION) { - Ok(scope) => scope, - Err(outcome) => return outcome, - }; - let parsed = match CommitRequest::deserialize(request) { - Ok(parsed) => parsed, - Err(error) => { - return crate::invalid_params_error(format!("invalid {OPERATION}: {error}")) - } - }; + let (scope, parsed) = + match self.kernel_request::(channel, request, OPERATION) { + Ok(bound) => bound, + Err(outcome) => return outcome, + }; if parsed.operations.len() > MAX_OPERATIONS { return crate::invalid_params_error(format!( "{OPERATION} carries at most {MAX_OPERATIONS} operations" diff --git a/crates/mc-module/src/kernel_routes/egress.rs b/crates/mc-module/src/kernel_routes/egress.rs index 1bd1bc665..d7e5ec92c 100644 --- a/crates/mc-module/src/kernel_routes/egress.rs +++ b/crates/mc-module/src/kernel_routes/egress.rs @@ -38,6 +38,18 @@ pub enum RefusalReason { } impl RefusalReason { + /// Every refusal, so a wire test covers each one the route can answer. + pub const ALL: &'static [Self] = &[ + Self::UnderDeclared, + Self::WrongScope, + Self::OwnerSensitive, + Self::Eligibility(EligibilityDeniedReason::UnknownSensitive), + Self::Eligibility(EligibilityDeniedReason::SensitiveRemote), + Self::Eligibility(EligibilityDeniedReason::ProviderRestricted), + Self::Eligibility(EligibilityDeniedReason::Secret), + Self::Eligibility(EligibilityDeniedReason::Tombstoned), + ]; + pub fn as_str(self) -> &'static str { match self { Self::UnderDeclared => "under_declared", @@ -93,6 +105,7 @@ pub fn decide_egress( } #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct EgressRequest { artifact_digest: String, destination: String, @@ -170,18 +183,13 @@ impl McHandler { pub(crate) async fn handle_kernel_egress_decide( &self, channel: RouteHandle, - request: &Value, + request: Value, ) -> PreparedOutcome { - let scope = match self.kernel_route_scope(channel, request, OPERATION) { - Ok(scope) => scope, - Err(outcome) => return outcome, - }; - let parsed = match EgressRequest::deserialize(request) { - Ok(parsed) => parsed, - Err(error) => { - return crate::invalid_params_error(format!("invalid {OPERATION}: {error}")) - } - }; + let (scope, parsed) = + match self.kernel_request::(channel, request, OPERATION) { + Ok(bound) => bound, + Err(outcome) => return outcome, + }; let Some(destination) = parse_destination(&parsed.destination) else { return crate::invalid_params_error(format!( "{OPERATION} destination must be local or remote" @@ -309,16 +317,39 @@ mod tests { #[test] fn wire_shape_is_allowed_or_a_snake_case_refusal() { assert_eq!(EgressDecision::Allowed.to_json(), json!("allowed")); - assert_eq!( - EgressDecision::Refused(RefusalReason::UnderDeclared).to_json(), - json!({"refused": "under_declared"}) - ); - assert_eq!( - EgressDecision::Refused(RefusalReason::Eligibility( - EligibilityDeniedReason::UnknownSensitive - )) - .to_json(), - json!({"refused": "unknown_sensitive"}) - ); + let expected = [ + (RefusalReason::UnderDeclared, "under_declared"), + (RefusalReason::WrongScope, "wrong_scope"), + (RefusalReason::OwnerSensitive, "owner_sensitive"), + ( + RefusalReason::Eligibility(EligibilityDeniedReason::UnknownSensitive), + "unknown_sensitive", + ), + ( + RefusalReason::Eligibility(EligibilityDeniedReason::SensitiveRemote), + "sensitive_remote", + ), + ( + RefusalReason::Eligibility(EligibilityDeniedReason::ProviderRestricted), + "provider_restricted", + ), + ( + RefusalReason::Eligibility(EligibilityDeniedReason::Secret), + "secret", + ), + ( + RefusalReason::Eligibility(EligibilityDeniedReason::Tombstoned), + "tombstoned", + ), + ]; + assert_eq!(expected.len(), RefusalReason::ALL.len()); + for (reason, name) in expected { + assert!(RefusalReason::ALL.contains(&reason), "{name}"); + assert_eq!(reason.as_str(), name); + assert_eq!( + EgressDecision::Refused(reason).to_json(), + json!({"refused": name}) + ); + } } } diff --git a/crates/mc-module/src/kernel_routes/eligibility.rs b/crates/mc-module/src/kernel_routes/eligibility.rs index 0d02c6e68..881c385bd 100644 --- a/crates/mc-module/src/kernel_routes/eligibility.rs +++ b/crates/mc-module/src/kernel_routes/eligibility.rs @@ -48,12 +48,14 @@ pub enum Verdict { } #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct BatchRequest { destination: String, candidates: Vec, } #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] struct Candidate { object_id: String, source_revision: i64, @@ -375,18 +377,13 @@ impl McHandler { pub(crate) async fn handle_kernel_eligibility_batch( &self, channel: RouteHandle, - request: &Value, + request: Value, ) -> PreparedOutcome { - let scope = match self.kernel_route_scope(channel, request, OPERATION) { - Ok(scope) => scope, + let (scope, parsed) = match self.kernel_request::(channel, request, OPERATION) + { + Ok(bound) => bound, Err(outcome) => return outcome, }; - let parsed = match BatchRequest::deserialize(request) { - Ok(parsed) => parsed, - Err(error) => { - return crate::invalid_params_error(format!("invalid {OPERATION}: {error}")) - } - }; let Some(destination) = parse_destination(&parsed.destination) else { return crate::invalid_params_error(format!( "{OPERATION} destination must be local or remote" diff --git a/crates/mc-module/src/kernel_routes/ingest.rs b/crates/mc-module/src/kernel_routes/ingest.rs index e233b93a1..211b4a88f 100644 --- a/crates/mc-module/src/kernel_routes/ingest.rs +++ b/crates/mc-module/src/kernel_routes/ingest.rs @@ -28,8 +28,8 @@ use serde_json::{json, Value}; use super::project::IntentRequest; use super::{ - blocking, kernel_response, state_only, InvalidReason, KernelOpenCoordinator, KernelOutcome, - UnavailableReason, + blocking, kernel_response, parse_request_body, state_only, InvalidReason, + KernelOpenCoordinator, KernelOutcome, UnavailableReason, }; use crate::dispatch::PreparedOutcome; use crate::{sha256_hex, McHandler}; @@ -133,6 +133,7 @@ impl Default for StagingBudget { /// Everything `ArtifactIngestRequest` carries besides the intent and the /// bytes, as the wire spells it. #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct ArtifactRequest { evidence_id: String, object_id: String, @@ -152,6 +153,7 @@ struct ArtifactRequest { } #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct ProvenanceRequest { repository_id: String, revision: String, @@ -166,6 +168,7 @@ fn parse_provider_egress(value: &str) -> Option { } #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct BeginRequest { upload_id: String, total_bytes: u64, @@ -176,6 +179,7 @@ struct BeginRequest { } #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct PageRequest { upload_id: String, index: u32, @@ -184,6 +188,7 @@ struct PageRequest { } #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct FinishRequest { upload_id: String, } @@ -582,16 +587,12 @@ impl McHandler { pub(crate) async fn handle_kernel_ingest_begin( &self, channel: RouteHandle, - request: &Value, + request: Value, ) -> PreparedOutcome { - let scope = match self.kernel_route_scope(channel, request, BEGIN) { - Ok(scope) => scope, + let (scope, parsed) = match self.kernel_request::(channel, request, BEGIN) { + Ok(bound) => bound, Err(outcome) => return outcome, }; - let parsed = match BeginRequest::deserialize(request) { - Ok(parsed) => parsed, - Err(error) => return crate::invalid_params_error(format!("invalid {BEGIN}: {error}")), - }; if parsed.upload_id.is_empty() || parsed.upload_id.len() > MAX_UPLOAD_ID_BYTES { return crate::invalid_params_error(format!( "{BEGIN} upload_id must be 1..={MAX_UPLOAD_ID_BYTES} bytes" @@ -676,9 +677,9 @@ impl McHandler { if let Err(outcome) = self.kernel_route_scope(channel, &request, PAGE) { return outcome; } - let parsed = match serde_json::from_value::(request) { + let parsed = match parse_request_body::(request, PAGE) { Ok(parsed) => parsed, - Err(error) => return crate::invalid_params_error(format!("invalid {PAGE}: {error}")), + Err(outcome) => return outcome, }; if !is_sha256_hex(&parsed.page_digest) { return crate::invalid_params_error(format!( @@ -769,16 +770,12 @@ impl McHandler { pub(crate) async fn handle_kernel_ingest_finish( &self, channel: RouteHandle, - request: &Value, + request: Value, ) -> PreparedOutcome { - let scope = match self.kernel_route_scope(channel, request, FINISH) { - Ok(scope) => scope, + let (scope, parsed) = match self.kernel_request::(channel, request, FINISH) { + Ok(bound) => bound, Err(outcome) => return outcome, }; - let parsed = match FinishRequest::deserialize(request) { - Ok(parsed) => parsed, - Err(error) => return crate::invalid_params_error(format!("invalid {FINISH}: {error}")), - }; let upload = match self .kernel .uploads() diff --git a/crates/mc-module/src/kernel_routes/mod.rs b/crates/mc-module/src/kernel_routes/mod.rs index de823caae..4f7ab2952 100644 --- a/crates/mc-module/src/kernel_routes/mod.rs +++ b/crates/mc-module/src/kernel_routes/mod.rs @@ -22,6 +22,7 @@ use std::time::Instant; use mc_host::RouteHandle; use mc_kernel::{KernelError, KernelStore}; +use serde::de::DeserializeOwned; use serde_json::{json, Value}; use tokio_util::sync::CancellationToken; @@ -133,6 +134,10 @@ pub(crate) struct KernelOpenCoordinator { /// against, so they are dropped whenever the slot changes hands. uploads: Mutex, background_sampler: AtomicBool, + /// Whether the deployment provisions a kernel at all. A backend that + /// cannot host one reports `unavailable` on the routes without pulling + /// daemon health down, since nothing is missing that was promised. + expected: AtomicBool, } impl KernelOpenCoordinator { @@ -144,9 +149,20 @@ impl KernelOpenCoordinator { eligibility_cache: Mutex::new(eligibility::VerdictCache::default()), uploads: Mutex::new(ingest::UploadCoordinator::default()), background_sampler: AtomicBool::new(true), + expected: AtomicBool::new(true), } } + pub(crate) fn set_expected(&self, expected: bool) { + self.expected.store(expected, Ordering::Release); + } + + /// Whether `block` downgrades the daemon's health status; never for a + /// deployment that does not provision a kernel. + pub(crate) fn health_degrades(&self, block: &health::KernelHealthBlock) -> bool { + self.expected.load(Ordering::Acquire) && block.degrades_health() + } + pub(crate) fn uploads(&self) -> std::sync::MutexGuard<'_, ingest::UploadCoordinator> { self.uploads.lock().expect("upload coordinator mutex") } @@ -335,10 +351,16 @@ fn open_failure_kind(error: KernelError) -> UnavailableKind { } } +/// A worker panic is reported as a failed open rather than re-raised: the +/// open task owns the phase transition, and a panic would end it while the +/// phase still reads `Starting`. commentlint: allow(JUDGE) async fn open_once(root: PathBuf) -> Result { match tokio::task::spawn_blocking(move || KernelStore::open(&root)).await { Ok(result) => result, - Err(error) => panic!("kernel store open worker failed: {error}"), + Err(error) => { + eprintln!("mc-module: kernel store open worker failed: {error}"); + Err(KernelError::Fault) + } } } @@ -401,6 +423,38 @@ impl McHandler { let store = self.kernel.kernel_store().map_err(state_only)?; Ok(RouteScope { store, project }) } + + /// Binds the route scope, then parses the request body with the transport + /// envelope removed. Request structs deny unknown fields, so a misspelled + /// key is refused instead of falling back to a field default. + pub(crate) fn kernel_request( + &self, + channel: RouteHandle, + request: Value, + operation: &str, + ) -> Result<(RouteScope, T), PreparedOutcome> { + let scope = self.kernel_route_scope(channel, &request, operation)?; + let parsed = parse_request_body(request, operation)?; + Ok((scope, parsed)) + } +} + +/// Keys the transport adds around every `kernel.*` body. They are removed +/// before the body is parsed so `deny_unknown_fields` judges only the route's +/// own fields. +const ENVELOPE_KEYS: [&str; 5] = ["method", "kind", "v", "session_id", "project_root"]; + +pub(crate) fn parse_request_body( + mut request: Value, + operation: &str, +) -> Result { + if let Some(fields) = request.as_object_mut() { + for key in ENVELOPE_KEYS { + fields.remove(key); + } + } + serde_json::from_value::(request) + .map_err(|error| crate::invalid_params_error(format!("invalid {operation}: {error}"))) } /// Runs kernel work off the async workers; a panic inside the store closure is @@ -557,4 +611,19 @@ mod tests { assert!(KernelError::ALL.contains(&error), "{error:?} not in ALL"); } } + + #[test] + fn an_unexpected_kernel_reports_unavailable_without_degrading_health() { + let coordinator = KernelOpenCoordinator::new(); + coordinator.mark_unavailable(UnavailableKind::Unsupported); + let block = coordinator.health_block().reported().0; + assert_eq!(block.kernel_state, KernelState::Unavailable); + assert!(coordinator.health_degrades(&block)); + + coordinator.set_expected(false); + let block = coordinator.health_block().reported().0; + assert_eq!(block.kernel_state, KernelState::Unavailable); + assert!(!coordinator.health_degrades(&block)); + assert!(coordinator.kernel_store().is_err()); + } } diff --git a/crates/mc-module/src/kernel_routes/project.rs b/crates/mc-module/src/kernel_routes/project.rs index 88eacbcbe..9291cd1da 100644 --- a/crates/mc-module/src/kernel_routes/project.rs +++ b/crates/mc-module/src/kernel_routes/project.rs @@ -118,6 +118,7 @@ fn identity_bytes(root: &Path) -> std::borrow::Cow<'_, [u8]> { /// A commit intent as the wire carries it; `kernel.commit` and artifact /// ingestion accept the same shape. #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] pub(crate) struct IntentRequest { pub(crate) producer: String, pub(crate) operation_key: String, diff --git a/crates/mc-module/src/kernel_routes/read.rs b/crates/mc-module/src/kernel_routes/read.rs index 8f4c91149..ef3e403f1 100644 --- a/crates/mc-module/src/kernel_routes/read.rs +++ b/crates/mc-module/src/kernel_routes/read.rs @@ -13,7 +13,7 @@ use serde_json::{json, Value}; use super::project::{stored_terms, ProjectBinding, ScopeFilter}; use super::serving; -use super::{blocking, kernel_response, state_only, KernelOutcome}; +use super::{blocking, kernel_response, state_only, InvalidReason, KernelOutcome}; use crate::dispatch::PreparedOutcome; use crate::McHandler; @@ -29,6 +29,7 @@ pub const MAX_READ_ROW_BYTES: usize = crate::dispatch::MAX_WIRE_BODY_BYTES / 8; pub const MAX_READ_OBJECT_IDS: usize = 64; #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] pub(crate) struct ReadRequest { surface: String, /// `None` reads the tip. @@ -244,18 +245,13 @@ impl McHandler { pub(crate) async fn handle_kernel_read( &self, channel: RouteHandle, - request: &Value, + request: Value, ) -> PreparedOutcome { - let scope = match self.kernel_route_scope(channel, request, OPERATION) { - Ok(scope) => scope, + let (scope, parsed) = match self.kernel_request::(channel, request, OPERATION) + { + Ok(bound) => bound, Err(outcome) => return outcome, }; - let parsed = match ReadRequest::deserialize(request) { - Ok(parsed) => parsed, - Err(error) => { - return crate::invalid_params_error(format!("invalid {OPERATION}: {error}")) - } - }; if parsed .object_ids .as_ref() @@ -266,9 +262,7 @@ impl McHandler { )); } let Ok(surface) = Surface::try_from(parsed.surface.as_str()) else { - return crate::invalid_params_error(format!( - "{OPERATION} surface must be one of auto_inject, auto_search, explicit_search" - )); + return state_only(KernelOutcome::invalid(InvalidReason::InvalidInput)); }; let mut as_of = parsed.as_of; if parsed.gated { diff --git a/crates/mc-module/src/kernel_routes/serving.rs b/crates/mc-module/src/kernel_routes/serving.rs index 279f76d0b..ff471a7a5 100644 --- a/crates/mc-module/src/kernel_routes/serving.rs +++ b/crates/mc-module/src/kernel_routes/serving.rs @@ -1,5 +1,5 @@ -//! Numeric serving policy from kh8.1 R12d: a gated read past either lag -//! threshold is served as stale or abstained rather than fresh. +//! Numeric serving policy: a gated read past either lag threshold is served +//! as stale or abstained rather than fresh. //! //! The store phase is judged before any read reaches this module, by //! [`super::KernelOpenCoordinator::kernel_store`]; the policy here starts from diff --git a/crates/mc-module/src/kernel_routes/state.rs b/crates/mc-module/src/kernel_routes/state.rs index 4156bc565..e357f3a9b 100644 --- a/crates/mc-module/src/kernel_routes/state.rs +++ b/crates/mc-module/src/kernel_routes/state.rs @@ -307,34 +307,73 @@ impl From for KernelOutcome { mod tests { use super::*; - const ARTIFACT_ERRORS: &[ArtifactErrorKind] = &[ - ArtifactErrorKind::PayloadTooLarge, - ArtifactErrorKind::Capacity, - ArtifactErrorKind::StorageExhausted, - ArtifactErrorKind::IngestionFailClosed, - ArtifactErrorKind::ReAdmissionBlocked, - ArtifactErrorKind::MissingObject, - ArtifactErrorKind::CorruptObject, - ArtifactErrorKind::ReferenceUnavailable, - ArtifactErrorKind::ReferenceCommit, - ArtifactErrorKind::AlignmentRebuild, - ArtifactErrorKind::ReclaimInProgress, - ArtifactErrorKind::UnredactableSecret, - ArtifactErrorKind::ScanIncomplete, - ArtifactErrorKind::DetectionLimit, - ArtifactErrorKind::TextFieldTooLong, - ArtifactErrorKind::InvalidInput, - ArtifactErrorKind::OperationKeyReused, - ArtifactErrorKind::StorageConstraint, - ArtifactErrorKind::PurgeIntent, - ArtifactErrorKind::PurgeUnlinkPending, - ]; + fn expected_kernel(error: KernelError) -> KernelOutcome { + use KernelError as E; + match error { + E::Held | E::Busy | E::Deadline | E::ConsumerPending => { + KernelOutcome::unavailable(UnavailableReason::StoreBusy) + } + E::EngineUnsupported + | E::Foreign + | E::Inconclusive + | E::IdentityMismatch + | E::CorruptCanonicalRow => { + KernelOutcome::unavailable(UnavailableReason::StoreUnsupported) + } + E::FenceLost | E::Io | E::Fault => { + KernelOutcome::unavailable(UnavailableReason::StoreUnavailable) + } + E::FutureSnapshot => KernelOutcome::unavailable(UnavailableReason::SnapshotDiverged), + E::NoRequiredConsumers => { + KernelOutcome::unavailable(UnavailableReason::NoRequiredConsumer) + } + E::Conflict => KernelOutcome::conflict(ConflictReason::KnownAsOfAdvanced), + E::InvalidInput => KernelOutcome::invalid(InvalidReason::InvalidInput), + E::AdmissionPolicy => KernelOutcome::invalid(InvalidReason::AdmissionPolicy), + E::NotFound => KernelOutcome::invalid(InvalidReason::NotFound), + E::InvalidCheckpoint | E::UnsafeDestination | E::InvalidBackup | E::InvalidRestore => { + KernelOutcome::invalid(InvalidReason::Internal) + } + } + } + + fn expected_artifact(kind: ArtifactErrorKind) -> KernelOutcome { + use ArtifactErrorKind as K; + match kind { + K::Capacity | K::ReclaimInProgress => { + KernelOutcome::unavailable(UnavailableReason::StoreBusy) + } + K::StorageExhausted + | K::CorruptObject + | K::MissingObject + | K::ReferenceCommit + | K::AlignmentRebuild + | K::PurgeIntent + | K::PurgeUnlinkPending => { + KernelOutcome::unavailable(UnavailableReason::StoreUnavailable) + } + K::PayloadTooLarge => KernelOutcome::invalid(InvalidReason::PayloadTooLarge), + K::OperationKeyReused => KernelOutcome::invalid(InvalidReason::OperationKeyReused), + K::StorageConstraint => KernelOutcome::invalid(InvalidReason::AlreadyExists), + K::InvalidInput | K::TextFieldTooLong | K::DetectionLimit => { + KernelOutcome::invalid(InvalidReason::InvalidInput) + } + K::IngestionFailClosed => KernelOutcome::invalid(InvalidReason::IngestionFailClosed), + K::ReAdmissionBlocked + | K::ReferenceUnavailable + | K::UnredactableSecret + | K::ScanIncomplete => KernelOutcome::invalid(InvalidReason::ArtifactUnusable), + } + } + /// The mapping is spelled out a second time here so a change to the + /// production table has to be made deliberately in both places. #[test] - fn every_kernel_error_serializes_to_a_tagged_non_available_state() { + fn every_kernel_error_maps_to_its_tagged_non_available_state() { for error in KernelError::ALL { let outcome = KernelOutcome::from(*error); assert!(!outcome.is_available(), "{error:?}"); + assert_eq!(outcome, expected_kernel(*error), "{error:?}"); let value = serde_json::to_value(&outcome).unwrap(); assert!(value["kind"].is_string(), "{error:?}: {value}"); assert!(value["reason"].is_string(), "{error:?}: {value}"); @@ -342,10 +381,11 @@ mod tests { } #[test] - fn every_artifact_error_serializes_to_a_tagged_non_available_state() { - for kind in ARTIFACT_ERRORS { + fn every_artifact_error_maps_to_its_tagged_non_available_state() { + for kind in ArtifactErrorKind::ALL { let outcome = KernelOutcome::from(*kind); assert!(!outcome.is_available(), "{kind:?}"); + assert_eq!(outcome, expected_artifact(*kind), "{kind:?}"); let value = serde_json::to_value(&outcome).unwrap(); assert!(value["kind"].is_string(), "{kind:?}: {value}"); assert!(value["reason"].is_string(), "{kind:?}: {value}"); diff --git a/crates/mc-module/src/lib.rs b/crates/mc-module/src/lib.rs index 33137440e..484275d51 100644 --- a/crates/mc-module/src/lib.rs +++ b/crates/mc-module/src/lib.rs @@ -3533,6 +3533,10 @@ impl McHandler { .policy .lock() .expect("store open policy mutex"); + // Only SQLite hosts a kernel; the flag is set before the open + // so health never reads a kernel this deployment lacks as + // still opening. + kernel.set_expected(matches!(descriptor.backend, StorageBackend::Sqlite { .. })); let opened = Self::run_store_open(store, task_coordinator, &descriptor, cancel.clone()) .await; @@ -11798,7 +11802,7 @@ impl CompositeComponent for McHandler { // A sampler that stopped publishing must not leave its last `Ready` // block standing, so an old sample reads as unavailable. let (kernel, stale) = self.kernel.health_block().reported(); - if storage_state == "ready" && kernel.degrades_health() { + if storage_state == "ready" && self.kernel.health_degrades(&kernel) { report.status = HealthStatus::Degraded; let reason = match kernel.kernel_state { _ if stale => "kernel health sample is stale", @@ -12148,21 +12152,20 @@ impl McHandler { "session.status" => self.handle_session_status_value(channel, &request), "session.delete" => self.handle_session_delete_value(channel, &request), "session.wrapup" => self.handle_session_wrapup_value(channel, &request).await, - "kernel.read" => self.handle_kernel_read(channel, &request).await, - "kernel.commit" => self.handle_kernel_commit(channel, &request).await, + "kernel.read" => self.handle_kernel_read(channel, request).await, + "kernel.commit" => self.handle_kernel_commit(channel, request).await, "kernel.eligibility.batch" => { - self.handle_kernel_eligibility_batch(channel, &request) - .await + self.handle_kernel_eligibility_batch(channel, request).await } - "kernel.egress.decide" => self.handle_kernel_egress_decide(channel, &request).await, + "kernel.egress.decide" => self.handle_kernel_egress_decide(channel, request).await, "kernel.artifact.ingest.begin" => { - self.handle_kernel_ingest_begin(channel, &request).await + self.handle_kernel_ingest_begin(channel, request).await } "kernel.artifact.ingest.page" => { self.handle_kernel_ingest_page(channel, request).await } "kernel.artifact.ingest.finish" => { - self.handle_kernel_ingest_finish(channel, &request).await + self.handle_kernel_ingest_finish(channel, request).await } // The handler echoes only explicit wire-debugging requests. // Unknown request bodies must fail so misrouted callers cannot mistake an echo for success. diff --git a/crates/mc-module/src/tail_hygiene.rs b/crates/mc-module/src/tail_hygiene.rs index 8c71baa4c..ad9f82a9d 100644 --- a/crates/mc-module/src/tail_hygiene.rs +++ b/crates/mc-module/src/tail_hygiene.rs @@ -6,6 +6,8 @@ use std::collections::{BTreeSet, HashMap, HashSet}; use std::fmt::Write as _; +use base64::engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig}; +use base64::Engine; use mc_core::CoreState; use mc_store::{ CkOutputKind, McTagRow, MediaBlock, MediaKind, ResultBlockKind, TailHygieneBaseline, @@ -101,36 +103,21 @@ fn media_content(media: &MediaBlock) -> String { .unwrap_or_else(|| serde_json::to_string(&media.source).unwrap_or_default()) } +/// Standard alphabet, indifferent to padding and to non-zero trailing bits: +/// the TypeScript reference decodes the same prefix through `atob`, which +/// accepts both, and the two estimators agree on whitespace-free payloads. +const PREVIEW_BASE64: GeneralPurpose = GeneralPurpose::new( + &base64::alphabet::STANDARD, + GeneralPurposeConfig::new() + .with_decode_padding_mode(DecodePaddingMode::Indifferent) + .with_decode_allow_trailing_bits(true), +); + +/// Decodes the first 512 base64 characters, enough to hold every image +/// header the dimension parsers read. fn decode_base64_preview(payload: &str) -> Option> { - let mut output = Vec::with_capacity(payload.len() * 3 / 4); - let mut quartet = [0u8; 4]; - let mut filled = 0usize; - for byte in payload.bytes().take(512) { - let value = match byte { - b'A'..=b'Z' => byte - b'A', - b'a'..=b'z' => byte - b'a' + 26, - b'0'..=b'9' => byte - b'0' + 52, - b'+' => 62, - b'/' => 63, - b'=' => break, - _ => return None, - }; - quartet[filled] = value; - filled += 1; - if filled == 4 { - output.push((quartet[0] << 2) | (quartet[1] >> 4)); - output.push((quartet[1] << 4) | (quartet[2] >> 2)); - output.push((quartet[2] << 6) | quartet[3]); - filled = 0; - } - } - if filled >= 2 { - output.push((quartet[0] << 2) | (quartet[1] >> 4)); - } - if filled >= 3 { - output.push((quartet[1] << 4) | (quartet[2] >> 2)); - } - Some(output) + let bytes = payload.as_bytes(); + PREVIEW_BASE64.decode(&bytes[..bytes.len().min(512)]).ok() } fn image_dimensions(header: &str, bytes: &[u8]) -> Option<(u64, u64)> { diff --git a/crates/mc-module/tests/direct_host.rs b/crates/mc-module/tests/direct_host.rs index 90a2c6ba1..51647d275 100644 --- a/crates/mc-module/tests/direct_host.rs +++ b/crates/mc-module/tests/direct_host.rs @@ -7,6 +7,7 @@ use std::fs; use std::process::Command; use std::time::{Duration, Instant}; +use base64::Engine; use mc_host::TargetKind; use mc_store::{McStore, StoredCompartment}; use serde_json::{json, Value}; @@ -16,26 +17,7 @@ use support::direct_host::{ }; fn base64(bytes: &[u8]) -> String { - const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - let mut encoded = String::with_capacity(bytes.len().div_ceil(3) * 4); - for chunk in bytes.chunks(3) { - let value = (u32::from(chunk[0]) << 16) - | (u32::from(*chunk.get(1).unwrap_or(&0)) << 8) - | u32::from(*chunk.get(2).unwrap_or(&0)); - encoded.push(TABLE[((value >> 18) & 0x3f) as usize] as char); - encoded.push(TABLE[((value >> 12) & 0x3f) as usize] as char); - encoded.push(if chunk.len() > 1 { - TABLE[((value >> 6) & 0x3f) as usize] as char - } else { - '=' - }); - encoded.push(if chunk.len() > 2 { - TABLE[(value & 0x3f) as usize] as char - } else { - '=' - }); - } - encoded + base64::engine::general_purpose::STANDARD.encode(bytes) } fn redaction_forms(publication: &str) -> Vec { diff --git a/crates/mc-module/tests/kernel_routes.rs b/crates/mc-module/tests/kernel_routes.rs index 99bf16dfd..33ce40627 100644 --- a/crates/mc-module/tests/kernel_routes.rs +++ b/crates/mc-module/tests/kernel_routes.rs @@ -1,6 +1,7 @@ //! Daemon-side kernel route proofs. use std::fs; +use std::os::unix::ffi::OsStrExt; use std::os::unix::fs::PermissionsExt; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; @@ -1239,6 +1240,326 @@ async fn a_plugin_route_cannot_declare_a_class_above_the_derived_one() { daemon.handler.shutdown().await.unwrap(); } +// --------------------------------------------------------------------------- +// Recorded replies. The TypeScript `FakeKernel` contract test replays each +// fixture, so the in-memory fake is held to the bytes this route produces. +// --------------------------------------------------------------------------- + +/// The directory the contract test loads fixtures from. +const ROUTE_FIXTURE_DIR: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../packages/plugin/src/shared/kernel-client-testing/fixtures" +); + +/// `scope_id` embeds the digest of a temporary root, so the fixture carries a +/// placeholder in its place. +const SCOPE_PLACEHOLDER: &str = "project:"; + +/// Replaces every row's `scope_id` with the placeholder after checking it is +/// `project:` plus the digest of the canonical root, the formula the fake +/// reproduces. +fn with_scope_placeholder(project: &Path, mut read: Value) -> Value { + // The binding hashes the canonical path's raw bytes. + let expected = { + use sha2::Digest as _; + let root = project.canonicalize().unwrap(); + format!( + "project:{:x}", + sha2::Sha256::digest(root.as_os_str().as_bytes()) + ) + }; + for row in read["rows"].as_array_mut().unwrap() { + assert_eq!(row["scope_id"], expected); + row["scope_id"] = json!(SCOPE_PLACEHOLDER); + } + read +} + +/// Compares `actual` with the checked-in fixture, or rewrites the fixture when +/// `UPDATE_KERNEL_ROUTE_FIXTURES=1`. +fn assert_matches_route_fixture(actual: &Value, name: &str) { + let path = Path::new(ROUTE_FIXTURE_DIR).join(name); + let mut serialized = serde_json::to_string_pretty(actual).unwrap(); + serialized.push('\n'); + if std::env::var_os("UPDATE_KERNEL_ROUTE_FIXTURES").is_some_and(|value| value == "1") { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, &serialized).unwrap(); + return; + } + let recorded = fs::read_to_string(&path).unwrap_or_else(|error| { + panic!( + "cannot read {}: {error}; regenerate it with \ + UPDATE_KERNEL_ROUTE_FIXTURES=1 cargo test -p mc-module --test kernel_routes", + path.display() + ) + }); + let recorded: Value = serde_json::from_str(&recorded).unwrap(); + assert_eq!( + actual, + &recorded, + "{} is out of date; regenerate it with \ + UPDATE_KERNEL_ROUTE_FIXTURES=1 cargo test -p mc-module --test kernel_routes\n\ + route reply:\n{serialized}", + path.display() + ); +} + +/// A plugin write is inference-class: the commit receipt names it, it serves +/// labeled on `explicit_search`, and `auto_inject` serves nothing. +#[tokio::test] +async fn recorded_create_serves_labeled_on_explicit_search_and_absent_on_auto_inject() { + let daemon = Daemon::start().await; + seed_domain(&daemon.store()); + let created = daemon + .commit("create", vec![insert_decision(1)], vec![]) + .await; + assert_state(&created, "available", None); + assert_matches_route_fixture(&created, "commit-available-create.json"); + + let explicit = daemon.read("explicit_search", None).await; + assert_state(&explicit, "available", None); + assert_eq!(object_ids(&explicit), ["decision-object-1"]); + assert_eq!(explicit["rows"][0]["labeled"], true); + assert_matches_route_fixture( + &with_scope_placeholder(&daemon.project, explicit), + "read-explicit-search-labeled.json", + ); + + let injected = daemon.read("auto_inject", None).await; + assert_state(&injected, "available", None); + assert!(object_ids(&injected).is_empty()); + assert_matches_route_fixture(&injected, "read-auto-inject-empty.json"); + daemon.handler.shutdown().await.unwrap(); +} + +/// A commit refused before any work answers with the state alone: a token +/// behind the object's last change, a supersede of an id the store never +/// held, and a successor whose revision does not advance. +#[tokio::test] +async fn recorded_refusals_answer_with_the_state_alone() { + let daemon = Daemon::start().await; + seed_domain(&daemon.store()); + let created = daemon + .commit("create", vec![insert_decision(1)], vec![]) + .await; + let n = created["known_as_of"].as_i64().unwrap(); + let changed = daemon + .commit( + "change", + vec![json!({"op": "supersede_decision", "replaced_object_id": "decision-object-1", "spec": decision_spec(2)})], + vec![], + ) + .await; + assert_state(&changed, "available", None); + + let stale = daemon + .commit( + "stale", + vec![json!({"op": "retire_decision", "object_id": "decision-object-2"})], + vec![token("decision-object-2", n)], + ) + .await; + assert_state(&stale, "conflict", Some("known_as_of_advanced")); + assert_matches_route_fixture(&stale, "commit-conflict-known-as-of-advanced.json"); + + let missing = daemon + .commit( + "supersede-missing", + vec![json!({"op": "supersede_decision", "replaced_object_id": "never-written", "spec": decision_spec(3)})], + vec![], + ) + .await; + assert_state(&missing, "invalid", Some("not_found")); + assert_matches_route_fixture(&missing, "commit-invalid-not-found.json"); + + let mut same_revision = decision_spec(3); + same_revision["source_revision"] = json!(2); + let not_advanced = daemon + .commit( + "supersede-same-revision", + vec![json!({"op": "supersede_decision", "replaced_object_id": "decision-object-2", "spec": same_revision})], + vec![], + ) + .await; + assert_state(¬_advanced, "invalid", Some("revision_not_advanced")); + assert_matches_route_fixture(¬_advanced, "commit-invalid-revision-not-advanced.json"); + daemon.handler.shutdown().await.unwrap(); +} + +/// A route bound to another project reads none of this project's rows. +#[tokio::test] +async fn recorded_cross_project_read_is_empty() { + let daemon = Daemon::start().await; + seed_domain(&daemon.store()); + let created = daemon + .commit("create", vec![insert_decision(1)], vec![]) + .await; + assert_state(&created, "available", None); + let (route_b, project_b) = daemon.bind_project("project-b").await; + let mut request_b = read_request(&project_b, "explicit_search", None); + request_b["session_id"] = json!("session-b"); + let read_b = daemon.call(route_b, request_b).await; + assert_state(&read_b, "available", None); + assert!(object_ids(&read_b).is_empty()); + assert_matches_route_fixture(&read_b, "read-cross-project-empty.json"); + daemon.handler.shutdown().await.unwrap(); +} + +fn supersede_decision(replaced: i64, spec: Value) -> Value { + json!({ + "op": "supersede_decision", + "replaced_object_id": format!("decision-object-{replaced}"), + "spec": spec, + }) +} + +/// Client `merge` creates the shared survivor from its first supersession and +/// folds each later predecessor into it; `merged` names the survivor once. +#[tokio::test] +async fn recorded_client_shaped_merge_names_the_survivor_once() { + let daemon = Daemon::start().await; + seed_domain(&daemon.store()); + let created = daemon + .commit( + "create", + vec![insert_decision(1), insert_decision(2), insert_decision(3)], + vec![], + ) + .await; + assert_state(&created, "available", None); + let merged = daemon + .commit( + "merge", + vec![ + supersede_decision(1, decision_spec(4)), + supersede_decision(2, decision_spec(4)), + supersede_decision(3, decision_spec(4)), + ], + vec![], + ) + .await; + assert_state(&merged, "available", None); + assert_eq!(merged["merged"], json!(["decision-object-4"])); + assert_matches_route_fixture(&merged, "commit-available-merge.json"); + let read = daemon.read("explicit_search", None).await; + assert_eq!(object_ids(&read), ["decision-object-4"]); + daemon.handler.shutdown().await.unwrap(); +} + +/// A fold into a survivor labeled below the predecessor is refused. +#[tokio::test] +async fn recorded_fold_below_the_sensitivity_floor_is_admission_policy() { + let daemon = Daemon::start().await; + seed_domain(&daemon.store()); + let mut guarded = decision_spec(1); + guarded["sensitivity"] = json!("sensitive"); + let created = daemon + .commit( + "create", + vec![ + json!({"op": "insert_decision", "spec": guarded}), + insert_decision(2), + ], + vec![], + ) + .await; + assert_state(&created, "available", None); + let tip = daemon.tip(); + let laundered = daemon + .commit( + "fold-down", + vec![supersede_decision(1, decision_spec(2))], + vec![], + ) + .await; + assert_state(&laundered, "invalid", Some("admission_policy")); + assert_eq!(daemon.tip(), tip); + assert_matches_route_fixture(&laundered, "commit-invalid-admission-policy.json"); + daemon.handler.shutdown().await.unwrap(); +} + +/// A replacement id this project retired is a duplicate write, not a fold. +#[tokio::test] +async fn recorded_supersede_into_a_retired_id_is_already_exists() { + let daemon = Daemon::start().await; + seed_domain(&daemon.store()); + let created = daemon + .commit( + "create", + vec![insert_decision(1), insert_decision(2)], + vec![], + ) + .await; + assert_state(&created, "available", None); + let retired = daemon + .commit( + "retire", + vec![json!({"op": "retire_decision", "object_id": "decision-object-2"})], + vec![], + ) + .await; + assert_state(&retired, "available", None); + let tip = daemon.tip(); + let duplicate = daemon + .commit( + "supersede-retired", + vec![supersede_decision(1, decision_spec(2))], + vec![], + ) + .await; + assert_state(&duplicate, "invalid", Some("already_exists")); + assert_eq!(daemon.tip(), tip); + assert_matches_route_fixture(&duplicate, "commit-invalid-already-exists.json"); + daemon.handler.shutdown().await.unwrap(); +} + +/// A body `project_root` other than the bound root is refused before any work. +#[tokio::test] +async fn recorded_body_project_root_mismatch_is_refused() { + let daemon = Daemon::start().await; + seed_domain(&daemon.store()); + let tip = daemon.tip(); + let elsewhere = daemon._data.path().join("elsewhere"); + fs::create_dir_all(&elsewhere).unwrap(); + let response = daemon + .call( + daemon.route, + commit_request(&elsewhere, "foreign", vec![insert_decision(1)], vec![]), + ) + .await; + assert_state(&response, "invalid", Some("project_mismatch")); + assert_eq!(daemon.tip(), tip); + assert_matches_route_fixture(&response, "commit-invalid-project-mismatch.json"); + daemon.handler.shutdown().await.unwrap(); +} + +#[test] +fn recorded_fixture_directory_holds_no_fixture_the_tests_do_not_record() { + let source = fs::read_to_string(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/kernel_routes.rs" + )) + .unwrap(); + let mut recorded: Vec<&str> = source + .split("assert_matches_route_fixture(") + .skip(1) + .map(|rest| { + let start = rest.find('"').unwrap() + 1; + let end = start + rest[start..].find('"').unwrap(); + &rest[start..end] + }) + .filter(|name| name.ends_with(".json")) + .collect(); + recorded.sort_unstable(); + recorded.dedup(); + let mut on_disk: Vec = fs::read_dir(ROUTE_FIXTURE_DIR) + .unwrap() + .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + on_disk.sort_unstable(); + assert_eq!(on_disk, recorded); +} + #[tokio::test] async fn a_request_naming_another_project_root_is_refused_before_any_work() { let daemon = Daemon::start().await; @@ -1271,6 +1592,178 @@ async fn a_request_naming_another_project_root_is_refused_before_any_work() { daemon.handler.shutdown().await.unwrap(); } +/// `gated` defaults to `false`, so a request that misspells it would otherwise +/// parse as an ungated read and serve rows the freshness gate should withhold. +#[tokio::test] +async fn a_read_with_an_unknown_key_is_refused_instead_of_served_ungated() { + let daemon = Daemon::start().await; + seed_domain(&daemon.store()); + daemon + .commit("create", vec![insert_decision(1)], vec![]) + .await; + let mut misspelled = read_request(&daemon.project, "explicit_search", None); + let fields = misspelled.as_object_mut().unwrap(); + fields.remove("gated"); + fields.insert("gatd".to_string(), json!(true)); + let outcome = daemon + .handler + .dispatch_value_for_test(daemon.route, misspelled) + .await; + assert!( + matches!( + &outcome, + PreparedOutcome::Error { code, message } + if code == "invalid_params" && message.contains("gatd") + ), + "{outcome:?}" + ); + // The transport envelope itself is not an unknown key. + let read = daemon.read("explicit_search", None).await; + assert_state(&read, "available", None); + assert_eq!(read["rows"].as_array().unwrap().len(), 1); + daemon.handler.shutdown().await.unwrap(); +} + +#[tokio::test] +async fn a_read_of_an_unknown_surface_answers_invalid_input() { + let daemon = Daemon::start().await; + seed_domain(&daemon.store()); + let read = daemon.read("auto_everything", None).await; + assert_state(&read, "invalid", Some("invalid_input")); + assert!(read.get("rows").is_none(), "{read}"); + daemon.handler.shutdown().await.unwrap(); +} + +/// A successor cannot lower its predecessor's sensitivity by omitting the field or naming a weaker label. +#[tokio::test] +async fn a_successor_inherits_its_predecessors_sensitivity() { + let daemon = Daemon::start().await; + seed_domain(&daemon.store()); + let mut guarded = decision_spec(1); + guarded["sensitivity"] = json!("sensitive"); + let created = daemon + .commit( + "create", + vec![json!({"op": "insert_decision", "spec": guarded})], + vec![], + ) + .await; + assert_state(&created, "available", None); + + // The supersession omits `sensitivity`; the replacement stays sensitive. + let superseded = daemon + .commit( + "supersede", + vec![json!({"op": "supersede_decision", "replaced_object_id": "decision-object-1", "spec": decision_spec(2)})], + vec![], + ) + .await; + assert_state(&superseded, "available", None); + let read = daemon.read("explicit_search", None).await; + assert_eq!(object_ids(&read), ["decision-object-2"]); + assert_eq!(read["rows"][0]["object"]["sensitivity"], json!("sensitive")); + + // An explicit `normal` on the next revision is lifted back to the floor. + let mut relaxed = decision_spec(3); + relaxed["sensitivity"] = json!("normal"); + let relabeled = daemon + .commit( + "relabel", + vec![json!({"op": "supersede_decision", "replaced_object_id": "decision-object-2", "spec": relaxed})], + vec![], + ) + .await; + assert_state(&relabeled, "available", None); + let read = daemon.read("explicit_search", None).await; + assert_eq!(object_ids(&read), ["decision-object-3"]); + assert_eq!(read["rows"][0]["object"]["sensitivity"], json!("sensitive")); + + // Raising the label is still the successor's call. + let mut secret = decision_spec(4); + secret["sensitivity"] = json!("secret"); + let raised = daemon + .commit( + "raise", + vec![json!({"op": "supersede_decision", "replaced_object_id": "decision-object-3", "spec": secret})], + vec![], + ) + .await; + assert_state(&raised, "available", None); + assert!(object_ids(&daemon.read("explicit_search", None).await).is_empty()); + daemon.handler.shutdown().await.unwrap(); +} + +/// A fold keeps the survivor's stored label, so a sensitive predecessor may +/// only fold into a survivor labeled at least as strictly. +#[tokio::test] +async fn a_fold_into_a_less_restrictive_survivor_is_refused() { + let daemon = Daemon::start().await; + seed_domain(&daemon.store()); + let mut guarded = decision_spec(1); + guarded["sensitivity"] = json!("sensitive"); + let mut guarded_survivor = decision_spec(3); + guarded_survivor["sensitivity"] = json!("sensitive"); + let created = daemon + .commit( + "create", + vec![ + json!({"op": "insert_decision", "spec": guarded}), + insert_decision(2), + json!({"op": "insert_decision", "spec": guarded_survivor}), + ], + vec![], + ) + .await; + assert_state(&created, "available", None); + let tip = daemon.tip(); + + let laundered = daemon + .commit( + "fold-down", + vec![json!({"op": "supersede_decision", "replaced_object_id": "decision-object-1", "spec": decision_spec(2)})], + vec![], + ) + .await; + assert_state(&laundered, "invalid", Some("admission_policy")); + assert_eq!(daemon.tip(), tip); + + let folded = daemon + .commit( + "fold-level", + vec![json!({"op": "supersede_decision", "replaced_object_id": "decision-object-1", "spec": decision_spec(3)})], + vec![], + ) + .await; + assert_state(&folded, "available", None); + assert_eq!(folded["merged"], json!(["decision-object-3"])); + let read = daemon.read("explicit_search", None).await; + assert_eq!( + object_ids(&read), + ["decision-object-2", "decision-object-3"] + ); + daemon.handler.shutdown().await.unwrap(); +} + +/// The route-owned domain that holds project scopes is not writable by +/// callers. +#[tokio::test] +async fn a_caller_cannot_write_into_the_project_scopes_domain() { + let daemon = Daemon::start().await; + seed_domain(&daemon.store()); + let mut squatting = decision_spec(1); + squatting["domain_id"] = json!("project-scopes"); + let refused = daemon + .commit( + "squat", + vec![json!({"op": "insert_decision", "spec": squatting})], + vec![], + ) + .await; + assert_state(&refused, "invalid", Some("invalid_input")); + assert!(object_ids(&daemon.read("explicit_search", None).await).is_empty()); + daemon.handler.shutdown().await.unwrap(); +} + #[tokio::test] async fn a_project_path_shaped_like_a_secret_still_serves_its_own_rows() { let daemon = Daemon::start().await; diff --git a/crates/mc-store/src/lib.rs b/crates/mc-store/src/lib.rs index 4f0250a15..8754c43a2 100644 --- a/crates/mc-store/src/lib.rs +++ b/crates/mc-store/src/lib.rs @@ -7436,8 +7436,7 @@ impl McStore { session_id: &str, after_state_read: impl FnOnce(), ) -> Result { - let snapshot = self.inner.with_conn(|conn| { - let transaction = conn; + let snapshot = self.inner.with_conn(|transaction| { let cache_state_started_at = Instant::now(); let state = transaction .query_row(CACHE_STATE_FULL_SELECT, params![session_id], |row| { @@ -7563,8 +7562,7 @@ impl McStore { session_id: &str, compartment_page: Option<(i64, usize)>, ) -> Result { - let snapshot = self.inner.with_conn(|conn| { - let transaction = conn; + let snapshot = self.inner.with_conn(|transaction| { let state = transaction .query_row(CACHE_STATE_FULL_SELECT, params![session_id], |row| { Ok(( @@ -11366,8 +11364,7 @@ impl McStore { session_id: &str, ) -> Result { self.inner - .with_conn(|conn| { - let transaction = conn; + .with_conn(|transaction| { let max_compartment_seq = transaction.query_row( "SELECT COALESCE(MAX(sequence), 0) FROM mc_compartments WHERE session_id = ?1", params![session_id], @@ -17546,6 +17543,37 @@ mod tests { assert_eq!(inherited, r#"{"block_ids":["password=legacy-id"]}"#); } + /// Session teardown deletes from every non-backend table that carries a + /// `session_id` column; the backend's `cortexkit_%` tables are skipped by + /// name, which is sound only while none of them holds session rows. + #[test] + fn backend_tables_carry_no_session_id_column() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("store.db"); + drop(McStore::open(&descriptor(dir.path())).unwrap()); + let conn = rusqlite::Connection::open(&path).unwrap(); + let backend_tables: Vec = conn + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name LIKE 'cortexkit_%'", + ) + .unwrap() + .query_map([], |row| row.get(0)) + .unwrap() + .collect::>() + .unwrap(); + assert!(!backend_tables.is_empty()); + for table in backend_tables { + let has_session_id: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM pragma_table_info(?1) WHERE name = 'session_id')", + params![table], + |row| row.get(0), + ) + .unwrap(); + assert!(!has_session_id, "{table} carries a session_id column"); + } + } + /// The transaction, not an earlier read, decides whether `session_id` is a new identity. /// A first insert refuses a detected secret; a replay of a stored row keeps its bytes. #[test] diff --git a/crates/mc-tokenizer/Cargo.toml b/crates/mc-tokenizer/Cargo.toml index bc0ded7f8..625938e8c 100644 --- a/crates/mc-tokenizer/Cargo.toml +++ b/crates/mc-tokenizer/Cargo.toml @@ -12,7 +12,7 @@ path = "src/lib.rs" [dependencies] # tiktoken-rs provides the byte-BPE engine. tiktoken-rs = "=0.11.0" -base64 = "0.22" +base64 = { workspace = true } rustc-hash = "1.1" [dev-dependencies] diff --git a/packages/pi-plugin/src/inject-compartments-pi.ts b/packages/pi-plugin/src/inject-compartments-pi.ts index 928585664..55528a65e 100644 --- a/packages/pi-plugin/src/inject-compartments-pi.ts +++ b/packages/pi-plugin/src/inject-compartments-pi.ts @@ -46,18 +46,18 @@ import { renderDecayedCompartments, } from "@magic-context/core/hooks/magic-context/decay-render"; import { - DEFAULT_MEMORY_BUDGET_TOKENS, DEFAULT_USER_PROFILE_BUDGET_TOKENS, stripMemoryMuralBlock, stripProjectMemoryBlock, trimUserMemoriesToBudget, } from "@magic-context/core/hooks/magic-context/inject-compartments"; import { + budgetedMemoryRows, + DEFAULT_MEMORY_BUDGET_TOKENS, memoryRowLocator, memoryRows, memorySnapshotKey, renderKernelMemoryBlock, - trimKernelRowsToBudget, withheldMemoryRowCount, } from "@magic-context/core/hooks/magic-context/kernel-memory-render"; import { estimateTokens } from "@magic-context/core/hooks/magic-context/read-session-formatting"; @@ -371,8 +371,8 @@ function memoryProjectPath(state: PiM0M1State): string | undefined { /** Rows the injector renders: only a session bound to a project has a kernel scope to read under. */ function renderedMemoryRows(state: PiM0M1State): ReadRow[] { return memoryProjectPath(state) - ? trimKernelRowsToBudget( - memoryRows(state.memory), + ? budgetedMemoryRows( + state.memory, state.injectionBudgetTokens ?? DEFAULT_MEMORY_BUDGET_TOKENS, ) : []; diff --git a/packages/pi-plugin/src/kernel-client-bundle.test.ts b/packages/pi-plugin/src/kernel-client-bundle.test.ts index be6389239..d976f8979 100644 --- a/packages/pi-plugin/src/kernel-client-bundle.test.ts +++ b/packages/pi-plugin/src/kernel-client-bundle.test.ts @@ -1,14 +1,11 @@ import { describe, expect, it } from "bun:test"; -import { resolve } from "node:path"; - -/** The `build` script's externals, so the bundle under test resolves the way the shipped one does. */ -const EXTERNALS = [ - "@cortexkit/mc-shm-native", - "@earendil-works/pi-coding-agent", - "@earendil-works/pi-tui", - "@huggingface/transformers", - "node:sqlite", -]; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { + bundleModuleGraph, + reachableModules, +} from "@magic-context/core/shared/kernel-client-testing/module-graph"; /** The shared client Pi imports through the `@magic-context/core/*` alias. */ const CLIENT_ENTRY = resolve( @@ -16,58 +13,70 @@ const CLIENT_ENTRY = resolve( "../../plugin/src/shared/kernel-client/index.ts", ); const PI_ENTRY = resolve(import.meta.dir, "kernel-client-pi.ts"); +const CLAIM_STORAGE = resolve( + import.meta.dir, + "../../plugin/src/features/magic-context/memory/storage-claim-applicability.ts", +); +const CLAIM_STORAGE_PATTERN = /storage-claim/; +/** The runtime-selected sqlite adapter, whose backend specifiers are concatenated at runtime and so never appear as import edges. */ +const SQLITE_ADAPTER = resolve(import.meta.dir, "../../plugin/src/shared/sqlite.ts"); +/** A binding left external (`node:sqlite`, `bun:sqlite`, `better-sqlite3`) or a source module under a `sqlite` path segment. */ +const SQLITE_PATTERN = + /(?:^|\/)(?:node:sqlite|bun:sqlite|better-sqlite3)(?:$|\/)|\/sqlite(?:\.|-|\/)/; /** The `build` script's real entry points, asserted only to bundle: both reach claim storage through sanctioned lanes (the claim-lane importer, the historian's lane staging, and the `kernel-claim-usage` retrieval-telemetry bridge), so a no-storage-claim scan over them fails on code the ban permits. The reachability invariant this file owns is narrower: the shared kernel client and Pi's client resolver stay free of SQLite bindings and claim storage, because they are the modules that must load where no database exists. commentlint: allow(JUDGE) */ const BUILD_ENTRIES = ["src/index.ts", "src/subagent-entry.ts"].map((entry) => resolve(import.meta.dir, "..", entry), ); -async function bundle(entry: string): Promise<{ success: boolean; text: string }> { - const result = await Bun.build({ - entrypoints: [entry], - target: "node", - format: "esm", - external: EXTERNALS, - write: false, - }); - const texts = await Promise.all(result.outputs.map((output) => output.text())); - return { success: result.success, text: texts.join("\n") }; -} - -async function bundleText(entry: string): Promise { - const { success, text } = await bundle(entry); - expect(success).toBe(true); - return text; -} - -/** Reports which specifiers a bundle names without echoing the bundle into a failure message. The scan reads unminified `Bun.build` output, where module paths survive as per-module comments and external import specifiers; a minified or path-rewritten bundle would defeat it, so the in-test build stays unminified even if the shipped one changes. commentlint: allow(JUDGE) */ -function specifiersFound(text: string, specifiers: readonly string[]): string[] { - return specifiers.filter((specifier) => text.includes(specifier)); +/** The graph of a throwaway entry that imports `specifier`: the positive control for a reachability check. */ +async function graphOfEntryImporting(specifier: string) { + const directory = mkdtempSync(join(tmpdir(), "mc-bundle-positive-control-")); + try { + const entry = join(directory, "entry.ts"); + writeFileSync( + entry, + `import * as m from ${JSON.stringify(specifier)};\nexport const keep = Object.keys(m).length;\n`, + ); + return await bundleModuleGraph(entry); + } finally { + rmSync(directory, { recursive: true, force: true }); + } } describe("Pi kernel-client bundle reachability", () => { it("reaches no SQLite binding or claim storage from the kernel-client entry", async () => { - const text = await bundleText(CLIENT_ENTRY); - expect( - specifiersFound(text, [ - "node:sqlite", - "bun:sqlite", - "better-sqlite3", - "storage-claim", - ]), - ).toEqual([]); + const graph = await bundleModuleGraph(CLIENT_ENTRY); + expect(graph.inputs.length).toBeGreaterThan(0); + expect(reachableModules(graph, SQLITE_PATTERN)).toEqual([]); + expect(reachableModules(graph, CLAIM_STORAGE_PATTERN)).toEqual([]); }); it("reaches no claim storage from Pi's resolver and leaves the native host module external", async () => { - const text = await bundleText(PI_ENTRY); - expect(specifiersFound(text, ["storage-claim"])).toEqual([]); - expect(text).toMatch(/from\s+["']@cortexkit\/mc-shm-native["']/); + const graph = await bundleModuleGraph(PI_ENTRY); + expect(graph.inputs.length).toBeGreaterThan(0); + expect(reachableModules(graph, CLAIM_STORAGE_PATTERN)).toEqual([]); + expect(graph.text).toMatch(/from\s+["']@cortexkit\/mc-shm-native["']/); + }); + + it("the reachability check fails on an entry that imports claim storage", async () => { + const graph = await graphOfEntryImporting(CLAIM_STORAGE); + expect(reachableModules(graph, CLAIM_STORAGE_PATTERN)).not.toEqual([]); + }); + + it("the reachability check fails on an entry that imports a sqlite binding or the sqlite adapter", async () => { + const external = await graphOfEntryImporting("node:sqlite"); + expect(reachableModules(external, SQLITE_PATTERN)).toEqual(["node:sqlite"]); + const adapter = await graphOfEntryImporting(SQLITE_ADAPTER); + expect(reachableModules(adapter, SQLITE_PATTERN)).toEqual([ + expect.stringMatching(/\/shared\/sqlite\.ts$/), + ]); }); it("the shipped entry points bundle under the build script's externals", async () => { for (const entry of BUILD_ENTRIES) { - const { success } = await bundle(entry); - expect(success).toBe(true); + const graph = await bundleModuleGraph(entry); + expect(graph.inputs.length).toBeGreaterThan(0); } }); }); diff --git a/packages/pi-plugin/src/pi-historian-runner.ts b/packages/pi-plugin/src/pi-historian-runner.ts index 8e4a9eeb5..bc86705b7 100644 --- a/packages/pi-plugin/src/pi-historian-runner.ts +++ b/packages/pi-plugin/src/pi-historian-runner.ts @@ -74,6 +74,7 @@ import { validateHistorianOutput, validateStoredCompartments, } from "@magic-context/core/hooks/magic-context/compartment-runner-validation"; +import { deriveHistorianMemoryTokens } from "@magic-context/core/hooks/magic-context/derive-budgets"; import { getHistorianRetryBackoffMs, isTransientHistorianPromptError, @@ -633,6 +634,7 @@ export async function runPiHistorian(deps: PiHistorianDeps): Promise { projectRoot: resolveProjectRootDirectory(directory), }), sessionId, + budgetTokens: deriveHistorianMemoryTokens(historianChunkTokens), }) : ""; diff --git a/packages/pi-plugin/src/tools/ctx-memory.ts b/packages/pi-plugin/src/tools/ctx-memory.ts index 1d752532c..a7a2a39f0 100644 --- a/packages/pi-plugin/src/tools/ctx-memory.ts +++ b/packages/pi-plugin/src/tools/ctx-memory.ts @@ -9,24 +9,27 @@ import { import type { ContextDatabase } from "@magic-context/core/features/magic-context/storage"; import type { KernelClientResolver } from "@magic-context/core/shared/kernel-client"; import { - CTX_MEMORY_ANTI_MEMORY_RULE, CTX_MEMORY_DESCRIPTION, + CTX_MEMORY_UNWRAP_RULES, } from "@magic-context/core/tools/ctx-memory/constants"; import { executeCtxMemory } from "@magic-context/core/tools/ctx-memory/execute"; -import type { CtxMemoryArgs } from "@magic-context/core/tools/ctx-memory/types"; +import { + CTX_MEMORY_ACTIONS, + CTX_MEMORY_DREAMER_ACTIONS, + type CtxMemoryAction, + type CtxMemoryArgs, + isCtxMemoryMutation, +} from "@magic-context/core/tools/ctx-memory/types"; import { assertCtxMemoryWriteShape } from "@magic-context/core/tools/ctx-memory/write-shape"; import { unwrapImitatedReducedArgs } from "@magic-context/core/tools/unwrap-imitated-reduced-args"; import { type Static, Type } from "typebox"; -const ALL_ACTIONS = [ - "create", - "get", - "list", - "revise", - "archive", - "merge", -] as const; -const DREAMER_ONLY_ACTIONS = new Set(["list"]); +const ALL_ACTIONS = CTX_MEMORY_DREAMER_ACTIONS; +const DREAMER_ONLY_ACTIONS: ReadonlySet = new Set( + ALL_ACTIONS.filter( + (action) => !(CTX_MEMORY_ACTIONS as readonly string[]).includes(action), + ), +); export const CTX_MEMORY_PI_ACTOR = "agent:pi"; export const CTX_MEMORY_PI_DREAMER_ACTOR = "agent:pi:dreamer"; @@ -140,16 +143,11 @@ export function createCtxMemoryTool( async execute(toolCallId, rawParams, signal, _onUpdate, ctx) { try { let params = rawParams as CtxMemoryParams & CtxMemoryArgs; - params = unwrapImitatedReducedArgs(params, ["action"], { - action: { type: "enum", values: ALL_ACTIONS }, - content: "string", - category: { type: "enum", values: WRITABLE_MEMORY_CATEGORIES }, - antiMemory: CTX_MEMORY_ANTI_MEMORY_RULE, - objectId: "string", - objectIds: { type: "array", items: "string", maxItems: 20 }, - limit: "number", - reason: "string", - }); + params = unwrapImitatedReducedArgs( + params, + ["action"], + CTX_MEMORY_UNWRAP_RULES, + ); const rawAction = (params as { action?: unknown }).action; if (rawAction === "approve" || rawAction === "enforce") { return err( @@ -158,13 +156,13 @@ export function createCtxMemoryTool( } if ( typeof rawAction !== "string" || - !ALL_ACTIONS.includes(rawAction as (typeof ALL_ACTIONS)[number]) + !ALL_ACTIONS.includes(rawAction as CtxMemoryAction) ) { return err( `Error: Action '${String(rawAction)}' is not allowed in this context.`, ); } - const action = rawAction as (typeof ALL_ACTIONS)[number]; + const action = rawAction as CtxMemoryAction; if (!dreamerAllowed && DREAMER_ONLY_ACTIONS.has(action)) { return err( `Error: Action '${action}' is not allowed in this context.`, @@ -191,8 +189,7 @@ export function createCtxMemoryTool( if (!sessionId) { return err("Error: ctx_memory requires an active session."); } - const mutation = !["get", "list"].includes(action); - if (!toolCallId && mutation) { + if (!toolCallId && isCtxMemoryMutation(action)) { return err( "Error: ctx_memory mutation requires a stable tool-call identity.", ); diff --git a/packages/pi-plugin/src/tools/ctx-search.ts b/packages/pi-plugin/src/tools/ctx-search.ts index f061a4fb5..9d844f9cf 100644 --- a/packages/pi-plugin/src/tools/ctx-search.ts +++ b/packages/pi-plugin/src/tools/ctx-search.ts @@ -10,7 +10,7 @@ import type { ContextDatabase } from "@magic-context/core/features/magic-context import type { KernelClientResolver } from "@magic-context/core/shared/kernel-client"; import { CTX_SEARCH_DESCRIPTION } from "@magic-context/core/tools/ctx-search/constants"; import { executeCtxSearch } from "@magic-context/core/tools/ctx-search/execute"; -import { unwrapImitatedReducedArgs } from "@magic-context/core/tools/unwrap-imitated-reduced-args"; +import { normalizeCtxSearchArgs } from "@magic-context/core/tools/ctx-search/query-input"; import { type Static, Type } from "typebox"; const ParamsSchema = Type.Object( @@ -79,16 +79,7 @@ export function createCtxSearchTool( _onUpdate, ctx, ) { - params = unwrapImitatedReducedArgs(params, ["query"], { - query: "string", - limit: "number", - sources: { - type: "array", - items: "string", - maxItems: 5, - values: ["memory", "message", "git_commit", "primer", "note"], - }, - }); + params = normalizeCtxSearchArgs(params); const execution = await executeCtxSearch( { db: deps.db, diff --git a/packages/plugin/src/config/schema/magic-context.ts b/packages/plugin/src/config/schema/magic-context.ts index b6c5da572..446f2d7bb 100644 --- a/packages/plugin/src/config/schema/magic-context.ts +++ b/packages/plugin/src/config/schema/magic-context.ts @@ -15,6 +15,8 @@ export const DEFAULT_EXECUTE_THRESHOLD_PERCENTAGE = 65; export const EXECUTE_THRESHOLD_CAP_MESSAGE = "execute_threshold is capped at 90% for cache safety: output capacity is reserved from the usable context window, and the remaining 10% absorbs mid-turn growth before the absolute 95% emergency wall. Use a value between 20 and 90."; export const DEFAULT_HISTORIAN_TIMEOUT_MS = 300_000; +/** Upper bound a session may configure for `memory.injection_budget_tokens`. */ +export const MAX_MEMORY_INJECTION_BUDGET_TOKENS = 20_000; export const DEFAULT_HISTORY_BUDGET_PERCENTAGE = 0.15; export const DEFAULT_LOCAL_EMBEDDING_MODEL = "Xenova/bge-small-en-v1.5"; @@ -948,7 +950,7 @@ export const MagicContextConfigSchema = z injection_budget_tokens: z .number() .min(500) - .max(20000) + .max(MAX_MEMORY_INJECTION_BUDGET_TOKENS) .default(4000) .describe( "Token budget for memory injection on session start (min: 500, max: 20000, default: 4000)", diff --git a/packages/plugin/src/hooks/magic-context/compartment-runner-incremental.ts b/packages/plugin/src/hooks/magic-context/compartment-runner-incremental.ts index 4290f0f33..844c918d3 100644 --- a/packages/plugin/src/hooks/magic-context/compartment-runner-incremental.ts +++ b/packages/plugin/src/hooks/magic-context/compartment-runner-incremental.ts @@ -54,6 +54,7 @@ import { validateChunkCoverage, validateStoredCompartments, } from "./compartment-runner-validation"; +import { deriveHistorianMemoryTokens } from "./derive-budgets"; import { cleanupHistorianStateFile } from "./historian-state-file"; import { clearInjectionCache } from "./inject-compartments"; import { commitPromotedFactsToKernel } from "./kernel-memory-promotion"; @@ -394,6 +395,7 @@ export async function runCompartmentAgent(deps: CompartmentRunnerDeps): Promise< projectRoot: resolveProjectRootDirectory(sessionDirectory || directory), }), sessionId, + budgetTokens: deriveHistorianMemoryTokens(historianChunkTokens), }) : ""; diff --git a/packages/plugin/src/hooks/magic-context/derive-budgets.test.ts b/packages/plugin/src/hooks/magic-context/derive-budgets.test.ts index ea44f95f0..e82eb3ab9 100644 --- a/packages/plugin/src/hooks/magic-context/derive-budgets.test.ts +++ b/packages/plugin/src/hooks/magic-context/derive-budgets.test.ts @@ -1,8 +1,10 @@ /// import { describe, expect, it } from "bun:test"; +import { MAX_MEMORY_INJECTION_BUDGET_TOKENS } from "../../config/schema/magic-context"; import { deriveHistorianChunkTokens, + deriveHistorianMemoryTokens, deriveTriggerBudget, resolveHistorianContextLimit, } from "./derive-budgets"; @@ -99,3 +101,22 @@ describe("resolveHistorianContextLimit", () => { } }); }); + +describe("deriveHistorianMemoryTokens", () => { + it("gives the baseline half the chunk so both fit a small window", () => { + // A 16k historian gets an 8k chunk; chunk plus baseline stay at 12k. + expect(deriveHistorianMemoryTokens(deriveHistorianChunkTokens(16_000))).toBe(4_000); + expect(deriveHistorianMemoryTokens(deriveHistorianChunkTokens(128_000))).toBe(16_000); + }); + + it("never exceeds the largest configurable injection budget", () => { + expect(deriveHistorianMemoryTokens(deriveHistorianChunkTokens(400_000))).toBe( + MAX_MEMORY_INJECTION_BUDGET_TOKENS, + ); + }); + + it("handles invalid inputs defensively", () => { + expect(deriveHistorianMemoryTokens(0)).toBe(0); + expect(deriveHistorianMemoryTokens(Number.NaN)).toBe(0); + }); +}); diff --git a/packages/plugin/src/hooks/magic-context/derive-budgets.ts b/packages/plugin/src/hooks/magic-context/derive-budgets.ts index 3339a8874..dbfcd961c 100644 --- a/packages/plugin/src/hooks/magic-context/derive-budgets.ts +++ b/packages/plugin/src/hooks/magic-context/derive-budgets.ts @@ -8,6 +8,7 @@ * Historian chunks scale with the historian model because historian is constrained by its own context. */ +import { MAX_MEMORY_INJECTION_BUDGET_TOKENS } from "../../config/schema/magic-context"; import { getSdkContextLimit } from "../../shared/models-dev-cache"; const TRIGGER_BUDGET_PERCENTAGE = 0.05; @@ -51,6 +52,23 @@ export function deriveHistorianChunkTokens(historianContextLimit: number): numbe return Math.max(HISTORIAN_CHUNK_MIN, Math.min(HISTORIAN_CHUNK_MAX, derived)); } +/** + * The historian's project-memory baseline is trimmed to this budget. + * + * The chunk is one quarter of the historian's window, so half a chunk keeps + * the baseline to one eighth and leaves the rest for instructions, reference + * blocks, and the model's answer. The configurable injection maximum caps it + * so the baseline never exceeds what any session's injector could have shown. + * + * @param historianChunkTokens Budget from `deriveHistorianChunkTokens`. + */ +export function deriveHistorianMemoryTokens(historianChunkTokens: number): number { + if (!Number.isFinite(historianChunkTokens) || historianChunkTokens <= 0) { + return 0; + } + return Math.min(MAX_MEMORY_INJECTION_BUDGET_TOKENS, Math.floor(historianChunkTokens / 2)); +} + /** * * Behavior: diff --git a/packages/plugin/src/hooks/magic-context/inject-compartments.ts b/packages/plugin/src/hooks/magic-context/inject-compartments.ts index ab3bf3830..3e882b59a 100644 --- a/packages/plugin/src/hooks/magic-context/inject-compartments.ts +++ b/packages/plugin/src/hooks/magic-context/inject-compartments.ts @@ -45,11 +45,12 @@ import { } from "./compartment-render-epoch"; import { extractM0Block, renderCompartmentAtTier, renderDecayedCompartments } from "./decay-render"; import { + budgetedMemoryRows, + DEFAULT_MEMORY_BUDGET_TOKENS, memoryRowLocator, memoryRows, memorySnapshotKey, renderKernelMemoryBlock, - trimKernelRowsToBudget, withheldMemoryRowCount, } from "./kernel-memory-render"; import { getMessageTimesFromOpenCodeDb } from "./read-session-db"; @@ -293,12 +294,11 @@ export function prepareCompartmentInjection( args: PrepareCompartmentInjectionArgs, ): PreparedCompartmentInjection | null { const { db, sessionId, messages, isCacheBusting, projectPath, temporalAwareness } = args; - const renderedRows = projectPath - ? trimKernelRowsToBudget( - memoryRows(args.memory), - args.injectionBudgetTokens ?? DEFAULT_MEMORY_BUDGET_TOKENS, - ) - : []; + const renderedRows = renderedMemoryRows( + args.memory, + projectPath, + args.injectionBudgetTokens ?? DEFAULT_MEMORY_BUDGET_TOKENS, + ); const snapshotKey = memorySnapshotKey(args.memory); const renderedRevisionLocators = JSON.stringify(renderedRows.map(memoryRowLocator).sort()); @@ -809,7 +809,6 @@ function lastCompartmentBoundaryId(compartments: readonly M0Compartment[]): stri } const DEFAULT_HISTORY_BUDGET_TOKENS = 60_000; -export const DEFAULT_MEMORY_BUDGET_TOKENS = 8_000; function renderBudgetIdentity(memoryBudget?: number, historyBudget?: number): string { return `m${memoryBudget ?? DEFAULT_MEMORY_BUDGET_TOKENS}-h${historyBudget ?? DEFAULT_HISTORY_BUDGET_TOKENS}`; @@ -849,7 +848,7 @@ function renderedMemoryRows( projectPath: string | undefined, budgetTokens: number, ): ReadRow[] { - return projectPath ? trimKernelRowsToBudget(memoryRows(memory), budgetTokens) : []; + return projectPath ? budgetedMemoryRows(memory, budgetTokens) : []; } /** The digest `memory_block_hashes` tracks per rendered row: the summary bytes. */ diff --git a/packages/plugin/src/hooks/magic-context/kernel-memory-render.test.ts b/packages/plugin/src/hooks/magic-context/kernel-memory-render.test.ts index a3d71d4d3..b03b3555d 100644 --- a/packages/plugin/src/hooks/magic-context/kernel-memory-render.test.ts +++ b/packages/plugin/src/hooks/magic-context/kernel-memory-render.test.ts @@ -19,6 +19,8 @@ import { } from "./kernel-memory-render"; const SESSION = "ses-kernel-render"; +/** Half of the smallest historian chunk: what a 16k-window historian gets. */ +const BASELINE_BUDGET = 4_000; const PROJECT = "git:kernel-render"; /** A kernel client over an in-memory fake; the resolver shape matches the transform's. */ @@ -127,12 +129,43 @@ describe("sensitivity filtering on automatic surfaces", () => { test("the historian baseline excludes sensitive rows while normal rows pass", async () => { const { client } = kernelHarness(seededKernel(seeds)); - const block = await readHistorianMemoryBlock({ client, sessionId: SESSION }); + const block = await readHistorianMemoryBlock({ + client, + sessionId: SESSION, + budgetTokens: BASELINE_BUDGET, + }); expect(block).toContain("a normal memory"); expect(block).not.toContain("a sensitive memory"); }); }); +describe("historian baseline budget", () => { + test("the baseline is trimmed to the caller's budget", async () => { + // Each summary is ~600 tokens; 40 of them exceed a 4k baseline budget. + const filler = "budget ".repeat(600); + const kernel = seededKernel( + Array.from({ length: 40 }, (_, index) => ({ + id: String.fromCharCode(97 + (index % 26)) + String(index), + summary: `memory ${index} ${filler}`, + })), + ); + const { client } = kernelHarness(kernel); + const block = await readHistorianMemoryBlock({ + client, + sessionId: SESSION, + budgetTokens: BASELINE_BUDGET, + }); + const rendered = block.split("\n").filter((line) => line.startsWith("mem_")).length; + const expected = trimKernelRowsToBudget( + memoryRows(kernel.snapshot()), + BASELINE_BUDGET, + ).length; + expect(expected).toBeGreaterThan(0); + expect(expected).toBeLessThan(40); + expect(rendered).toBe(expected); + }); +}); + describe("domain fence on rendered rows", () => { test("memoryRows keeps only memory-domain decision rows", () => { const kernel = seededKernel([{ id: "a", summary: "a memory-domain row" }]); @@ -170,7 +203,11 @@ describe("anti-memory fence on automatic surfaces", () => { test("the historian baseline omits anti-memory rows", async () => { const { client } = kernelHarness(kernelWithAntiMemory()); - const block = await readHistorianMemoryBlock({ client, sessionId: SESSION }); + const block = await readHistorianMemoryBlock({ + client, + sessionId: SESSION, + budgetTokens: BASELINE_BUDGET, + }); expect(block).toContain("a positive memory"); expect(block).not.toContain("unbounded retry loop"); }); @@ -210,7 +247,11 @@ describe("serving-policy gating on automatic reads", () => { oldest_unconsumed_age_ms: 60_000, }); const { client, transport } = kernelHarness(kernel); - const block = await readHistorianMemoryBlock({ client, sessionId: SESSION }); + const block = await readHistorianMemoryBlock({ + client, + sessionId: SESSION, + budgetTokens: BASELINE_BUDGET, + }); expect(transport.calls[0]?.body).toMatchObject({ gated: true }); expect(block).toBe(""); }); diff --git a/packages/plugin/src/hooks/magic-context/kernel-memory-render.ts b/packages/plugin/src/hooks/magic-context/kernel-memory-render.ts index 76b32bcbd..4e464292f 100644 --- a/packages/plugin/src/hooks/magic-context/kernel-memory-render.ts +++ b/packages/plugin/src/hooks/magic-context/kernel-memory-render.ts @@ -37,6 +37,9 @@ export const MEMORY_READ_SURFACE = "explicit_search"; /** The host waits at most this long for a memory read before the model call. */ export const INJECTION_READ_DEADLINE_MS = 3_000; +/** Token budget for the rendered memory block when the host names none. */ +export const DEFAULT_MEMORY_BUDGET_TOKENS = 8_000; + /** The snapshot an injector renders when no client can be reached at all. */ export function daemonAbsentSnapshot(): KernelMemorySnapshot { return { state: unavailable("daemon_absent"), rows: [], knownAsOf: null }; @@ -91,10 +94,14 @@ export function withholdLaggingMemory(snapshot: KernelMemorySnapshot): KernelMem /** * The historian deduplicates against the block the model saw; a non-`available` * read — including a gated read's `stale` answer — yields no baseline. + * + * `budgetTokens` bounds the rendered rows; the caller derives it from the + * historian model's window so the baseline and the raw chunk fit together. */ export async function readHistorianMemoryBlock(args: { client: KernelClient | undefined; sessionId: string; + budgetTokens: number; }): Promise { if (!args.client) return ""; const read = await args.client.read({ @@ -110,16 +117,28 @@ export async function readHistorianMemoryBlock(args: { return ""; } const snapshot = withoutSensitiveRows(kernelMemorySnapshotFrom(read)); - const rows = memoryRows(snapshot); return renderKernelMemoryBlock( - rows, + budgetedMemoryRows(snapshot, args.budgetTokens), read.state, - rows.length, + memoryRows(snapshot).length, snapshot.truncated === true, withheldMemoryRowCount(snapshot), ); } +/** + * Rows a surface renders from a snapshot, trimmed to `budgetTokens`. Only an + * `available` snapshot renders rows: the client attaches none to any other + * state, and a renderer handed rows anyway must still show the marker alone. + */ +export function budgetedMemoryRows( + snapshot: KernelMemorySnapshot, + budgetTokens: number, +): ReadRow[] { + if (snapshot.state.kind !== "available") return []; + return trimKernelRowsToBudget(memoryRows(snapshot), budgetTokens); +} + /** Excludes the store-wide `known_as_of`, which every commit to any project advances; a changed key rematerializes m[0] and the prompt prefix. The truncation flag keys because it changes the zero-row marker. commentlint: allow(JUDGE) */ export function memorySnapshotKey(snapshot: KernelMemorySnapshot): string { const rows = memoryRows(snapshot) diff --git a/packages/plugin/src/hooks/magic-context/memory-state-table.test.ts b/packages/plugin/src/hooks/magic-context/memory-state-table.test.ts index 2c85c77af..3048ed723 100644 --- a/packages/plugin/src/hooks/magic-context/memory-state-table.test.ts +++ b/packages/plugin/src/hooks/magic-context/memory-state-table.test.ts @@ -1,7 +1,11 @@ import { afterEach, describe, expect, test } from "bun:test"; import { getOrCreateSessionMeta } from "../../features/magic-context/storage"; import { createDirectTestDatabase } from "../../features/magic-context/test-database"; -import { renderMemoryStateMarker, renderToolStateText } from "../../shared/kernel-client"; +import { + type ReadRow, + renderMemoryStateMarker, + renderToolStateText, +} from "../../shared/kernel-client"; import { MEMORY_STATE_TABLE, stubKernelClient, @@ -46,6 +50,71 @@ describe("memory state table over the OpenCode surfaces", () => { expect(prepared?.block ?? "").toContain(renderMemoryStateMarker(state, 0)); }); + const row: ReadRow = { + object: { + object_id: "mem_state_table_row", + object_kind: "decision", + domain_id: "memory", + source_kind: "assistant", + source_id: "ctx_memory", + source_revision: 1, + created_commit_seq: 1, + invalidated_commit_seq: null, + superseded_by: null, + sensitivity: "normal", + }, + visibility: "labeled", + labeled: true, + scope_id: "project:state-table", + token: { object_id: "mem_state_table_row", known_as_of: 1 }, + decision: { + decision_kind: "ARCHITECTURE", + payload: { summary: "Rows render under their category.", rationale: "" }, + }, + }; + + test("an available snapshot with rows renders the rows and no marker line", () => { + const db = makeDb(); + const prepared = prepareCompartmentInjection({ + db, + sessionId: SESSION, + messages: [], + isCacheBusting: true, + memory: { state: { kind: "available" }, rows: [row], knownAsOf: 1 }, + projectPath: PROJECT, + }); + const block = prepared?.block ?? ""; + expect(block).toContain(""); + expect(block).toContain("mem_state_table_row [labeled]: Rows render under their category."); + expect(renderMemoryStateMarker({ kind: "available" }, 1)).toBe(""); + expect(block).not.toContain(renderMemoryStateMarker({ kind: "available" }, 0)); + }); + + test("a stale snapshot renders the marker alone even when rows are attached", () => { + // The route answers a lagging gated read with the state alone, so the + // client never carries rows for `stale`; a renderer handed rows anyway + // must still show only the marker. + const db = makeDb(); + const state = { + kind: "stale", + lag_positions: 7, + oldest_unconsumed_age_ms: 90_000, + } as const; + const prepared = prepareCompartmentInjection({ + db, + sessionId: SESSION, + messages: [], + isCacheBusting: true, + memory: { state, rows: [row], knownAsOf: 1 }, + projectPath: PROJECT, + }); + const block = prepared?.block ?? ""; + const marker = renderMemoryStateMarker(state, 0); + expect(marker.length).toBeGreaterThan(0); + expect(block).toContain(marker); + expect(block).not.toContain("mem_state_table_row"); + }); + test.each(MEMORY_STATE_TABLE)("ctx_memory list answers %s", async (_key, state) => { const tool = createCtxMemoryTools({ kernelClient: () => stubKernelClient(state), diff --git a/packages/plugin/src/shared/kernel-client-testing/fake-kernel.contract.test.ts b/packages/plugin/src/shared/kernel-client-testing/fake-kernel.contract.test.ts new file mode 100644 index 000000000..348e8ef71 --- /dev/null +++ b/packages/plugin/src/shared/kernel-client-testing/fake-kernel.contract.test.ts @@ -0,0 +1,271 @@ +import { describe, expect, test } from "bun:test"; +import { KernelClient, type KernelTransportCall } from "../kernel-client"; +import { FakeKernel, FakeKernelTransport, fakeProjectScopeId } from "./fake-kernel"; +import commitAvailableCreate from "./fixtures/commit-available-create.json"; +import commitAvailableMerge from "./fixtures/commit-available-merge.json"; +import commitConflictAdvanced from "./fixtures/commit-conflict-known-as-of-advanced.json"; +import commitInvalidAdmissionPolicy from "./fixtures/commit-invalid-admission-policy.json"; +import commitInvalidAlreadyExists from "./fixtures/commit-invalid-already-exists.json"; +import commitInvalidNotFound from "./fixtures/commit-invalid-not-found.json"; +import commitInvalidProjectMismatch from "./fixtures/commit-invalid-project-mismatch.json"; +import commitInvalidRevision from "./fixtures/commit-invalid-revision-not-advanced.json"; +import readAutoInjectEmpty from "./fixtures/read-auto-inject-empty.json"; +import readCrossProjectEmpty from "./fixtures/read-cross-project-empty.json"; +import readExplicitLabeled from "./fixtures/read-explicit-search-labeled.json"; + +/** + * Every fixture is a reply the `recorded_*` tests in + * `crates/mc-module/tests/kernel_routes.rs` captured from the daemon after the + * same steps this file drives against the fake. The daemon's store holds one + * seeded domain commit before the plugin writes, so the fake starts at tip 1. + */ + +const SEEDED_TIP = 1; +const PROJECT = "/tmp/contract-project"; +const OTHER_PROJECT = "/tmp/contract-project-b"; +const SESSION = "session-a"; +/** The route test's `decision_spec(index)`. */ +function decisionSpec(index: number) { + return { + decision_id: `decision-${index}`, + object_id: `decision-object-${index}`, + domain_id: "domain", + decision_kind: "memory", + payload: { summary: `decision ${index}`, rationale: `because ${index}` }, + source_id: "memory-lineage", + source_revision: index, + }; +} +/** The route test's `wire_intent(key, ..)`: the key is the operation identity, the cause is audit text. */ +function intent(operationId: string) { + return { actor: "assistant", operationId, cause: "ctx_memory" }; +} + +/** Keeps the raw reply of every call so a test can compare it with the recorded daemon bytes. */ +class RecordingTransport extends FakeKernelTransport { + readonly replies: unknown[] = []; + + override async call(args: KernelTransportCall): Promise { + const reply = await super.call(args); + this.replies.push(reply); + return reply; + } + + lastReply(): unknown { + return this.replies[this.replies.length - 1]; + } +} + +function harness() { + const kernel = new FakeKernel(); + kernel.tip = SEEDED_TIP; + const transport = new RecordingTransport(kernel); + const client = (projectRoot = PROJECT, sessionId = SESSION) => + new KernelClient({ transport, enabled: true, sessionId, projectRoot }); + return { kernel, transport, client }; +} + +/** The daemon embeds the digest of a temporary root in `scope_id`; the fixture carries a placeholder. */ +function withScopePlaceholder(reply: unknown): unknown { + const body = reply as { rows: { scope_id: string }[] }; + return { + ...body, + rows: body.rows.map((row) => { + expect(row.scope_id).toBe(fakeProjectScopeId(PROJECT)); + return { ...row, scope_id: "project:" }; + }), + }; +} + +async function createDecisionOne(h: ReturnType): Promise { + const created = await h.client().create(decisionSpec(1), intent("create")); + expect(created.state).toEqual({ kind: "available" }); +} + +describe("FakeKernel matches the daemon replies recorded in kernel_routes.rs", () => { + test("a create answers the receipt, its token, and an empty merged list", async () => { + const h = harness(); + await createDecisionOne(h); + expect(h.transport.lastReply()).toEqual(commitAvailableCreate); + }); + + test("a plugin write is labeled on explicit_search", async () => { + const h = harness(); + await createDecisionOne(h); + const read = await h.client().read({ surface: "explicit_search" }); + expect(read.state).toEqual({ kind: "available" }); + expect(withScopePlaceholder(h.transport.lastReply())).toEqual(readExplicitLabeled); + }); + + test("the same write is absent from auto_inject", async () => { + const h = harness(); + await createDecisionOne(h); + await h.client().read({ surface: "auto_inject" }); + expect(h.transport.lastReply()).toEqual(readAutoInjectEmpty); + }); + + test("another project reads an empty explicit_search", async () => { + const h = harness(); + await createDecisionOne(h); + await h.client(OTHER_PROJECT, "session-b").read({ surface: "explicit_search" }); + expect(h.transport.lastReply()).toEqual(readCrossProjectEmpty); + }); + + test("refused commits answer with the state alone", async () => { + const h = harness(); + await createDecisionOne(h); + const n = h.kernel.tip; + const changed = await h + .client() + .revise("decision-object-1", decisionSpec(2), intent("change")); + expect(changed.state).toEqual({ kind: "available" }); + + const stale = await h.client().commit({ + ...intent("stale"), + operations: [{ op: "retire_decision", object_id: "decision-object-2" }], + tokens: [{ object_id: "decision-object-2", known_as_of: n }], + }); + expect(stale).toEqual(commitConflictAdvanced); + + const missing = await h.client().commit({ + ...intent("supersede-missing"), + operations: [ + { + op: "supersede_decision", + replaced_object_id: "never-written", + spec: decisionSpec(3), + }, + ], + tokens: [], + }); + expect(missing).toEqual(commitInvalidNotFound); + + const notAdvanced = await h.client().commit({ + ...intent("supersede-same-revision"), + operations: [ + { + op: "supersede_decision", + replaced_object_id: "decision-object-2", + spec: { ...decisionSpec(3), source_revision: 2 }, + }, + ], + tokens: [], + }); + expect(notAdvanced).toEqual(commitInvalidRevision); + }); + + test("a client-shaped merge names the survivor once in merged", async () => { + const h = harness(); + const created = await h.client().commit({ + ...intent("create"), + operations: [1, 2, 3].map((index) => ({ + op: "insert_decision" as const, + spec: decisionSpec(index), + })), + tokens: [], + }); + expect(created.state).toEqual({ kind: "available" }); + const merged = await h + .client() + .merge( + ["decision-object-1", "decision-object-2", "decision-object-3"], + decisionSpec(4), + intent("merge"), + ); + expect(merged.state).toEqual({ kind: "available" }); + expect(h.transport.lastReply()).toEqual(commitAvailableMerge); + // The receipt replays with the same merged list; explicit empty tokens skip the client's refresh read of the now-superseded targets. commentlint: allow(JUDGE) + await h.client().commit({ + ...intent("merge"), + operations: ["decision-object-1", "decision-object-2", "decision-object-3"].map( + (objectId) => ({ + op: "supersede_decision" as const, + replaced_object_id: objectId, + spec: decisionSpec(4), + }), + ), + tokens: [], + }); + expect(h.transport.lastReply()).toEqual({ + ...commitAvailableMerge, + receipt: { ...commitAvailableMerge.receipt, replayed: true }, + }); + expect(h.kernel.liveRows().map((row) => row.object_id)).toEqual(["decision-object-4"]); + for (const predecessor of ["decision-object-1", "decision-object-2", "decision-object-3"]) { + expect(h.kernel.objects.get(predecessor)?.superseded_by).toBe("decision-object-4"); + } + }); + + test("a fold into a survivor below the sensitivity floor is admission_policy", async () => { + const h = harness(); + const created = await h.client().commit({ + ...intent("create"), + operations: [ + { + op: "insert_decision", + spec: { ...decisionSpec(1), sensitivity: "sensitive" }, + }, + { op: "insert_decision", spec: decisionSpec(2) }, + ], + tokens: [], + }); + expect(created.state).toEqual({ kind: "available" }); + const tip = h.kernel.tip; + const laundered = await h + .client() + .revise("decision-object-1", decisionSpec(2), intent("fold-down")); + expect(laundered).toEqual(commitInvalidAdmissionPolicy); + expect(h.kernel.tip).toBe(tip); + }); + + test("a supersede into a retired in-project id is already_exists", async () => { + const h = harness(); + const created = await h.client().commit({ + ...intent("create"), + operations: [ + { op: "insert_decision", spec: decisionSpec(1) }, + { op: "insert_decision", spec: decisionSpec(2) }, + ], + tokens: [], + }); + expect(created.state).toEqual({ kind: "available" }); + const retired = await h.client().archive("decision-object-2", intent("retire")); + expect(retired.state).toEqual({ kind: "available" }); + const tip = h.kernel.tip; + const duplicate = await h + .client() + .revise("decision-object-1", decisionSpec(2), intent("supersede-retired")); + expect(duplicate).toEqual(commitInvalidAlreadyExists); + expect(h.kernel.tip).toBe(tip); + }); + + test("a body project_root other than the bound root is project_mismatch", async () => { + const h = harness(); + const tip = h.kernel.tip; + // The client always mirrors its bound root into the body, so the mismatch is driven through the transport directly. commentlint: allow(JUDGE) + const reply = await h.transport.call({ + sessionId: SESSION, + projectRoot: PROJECT, + method: "kernel.commit", + body: { + method: "kernel.commit", + v: 1, + session_id: SESSION, + project_root: OTHER_PROJECT, + intent: { + producer: "plugin", + operation_key: "foreign", + request_digest: "foreign", + actor: "assistant", + cause: "ctx_memory", + }, + tokens: [], + operations: [{ op: "insert_decision", spec: decisionSpec(1) }], + source_kind: "assistant", + }, + }); + expect(reply).toEqual(commitInvalidProjectMismatch); + expect(h.kernel.tip).toBe(tip); + expect(h.kernel.objects.size).toBe(0); + }); +}); diff --git a/packages/plugin/src/shared/kernel-client-testing/fake-kernel.test.ts b/packages/plugin/src/shared/kernel-client-testing/fake-kernel.test.ts index 5018b3904..9929cb9b3 100644 --- a/packages/plugin/src/shared/kernel-client-testing/fake-kernel.test.ts +++ b/packages/plugin/src/shared/kernel-client-testing/fake-kernel.test.ts @@ -72,6 +72,55 @@ describe("FakeKernel commit prevalidation", () => { expect(kernel.objects.get("mem_b")?.superseded_by).toBe("mem_survivor"); expect(kernel.objects.get("mem_survivor")?.invalidated_commit_seq).toBeNull(); }); + + it("answers not_found for a replacement id another project holds", () => { + const kernel = new FakeKernel(); + kernel.seedDecision({ + object_id: "mem_a", + decision_kind: "ARCHITECTURE", + summary: "ours", + projectRoot: "/repo", + }); + kernel.seedDecision({ + object_id: "mem_theirs", + decision_kind: "ARCHITECTURE", + summary: "theirs", + projectRoot: "/elsewhere", + }); + const reply = kernel.reply( + commitCall([ + { + op: "supersede_decision", + replaced_object_id: "mem_a", + spec: decisionSpec("mem_theirs"), + }, + ]), + ); + expect(reply).toEqual({ state: { kind: "invalid", reason: "not_found" } }); + expect(kernel.objects.get("mem_a")?.invalidated_commit_seq).toBeNull(); + }); + + it("floors a non-fold successor's sensitivity at its predecessor's", () => { + const kernel = new FakeKernel(); + kernel.seedDecision({ + object_id: "mem_a", + decision_kind: "ARCHITECTURE", + summary: "guarded", + sensitivity: "sensitive", + }); + const reply = kernel.reply( + commitCall([ + { + op: "supersede_decision", + replaced_object_id: "mem_a", + spec: { ...decisionSpec("mem_b"), sensitivity: "normal" }, + }, + ]), + ) as { state: { kind: string }; merged: string[] }; + expect(reply.state).toEqual({ kind: "available" }); + expect(reply.merged).toEqual([]); + expect(kernel.objects.get("mem_b")?.sensitivity).toBe("sensitive"); + }); }); function readCall(body: Record): KernelTransportCall { diff --git a/packages/plugin/src/shared/kernel-client-testing/fake-kernel.ts b/packages/plugin/src/shared/kernel-client-testing/fake-kernel.ts index 99c6b79b3..7da726942 100644 --- a/packages/plugin/src/shared/kernel-client-testing/fake-kernel.ts +++ b/packages/plugin/src/shared/kernel-client-testing/fake-kernel.ts @@ -5,7 +5,9 @@ * `known_as_of` tokens, the three conflict reasons, replay by operation key, * supersession chains, and per-surface visibility: a `labeled` row serves only * on `explicit_search`, `sensitive` rows hide from the automatic surfaces, and - * `secret` rows hide everywhere. Anything else answers with a scripted state. + * `secret` rows hide everywhere. Rows carry the project root they were written + * under and serve only to that project. Scripted surface and commit states + * override the row-backed replies. */ import { @@ -15,6 +17,7 @@ import { type MemoryState, parseReadResponse, type Surface, + sha256Hex, } from "../kernel-client"; export interface FakeObject { @@ -29,6 +32,11 @@ export interface FakeObject { superseded_by: string | null; sensitivity: "normal" | "sensitive" | "secret"; labeled: boolean; + /** + * The project root the row was written under. `null` marks a seeded row + * that serves to every project, a shape no route commit can produce. + */ + project_root: string | null; decision?: { decision_kind: string; payload: { summary: string; rationale: string } }; } @@ -36,10 +44,44 @@ interface Receipt { commit_seq: number; request_digest: string; tokens: { object_id: string; known_as_of: number }[]; + merged: string[]; } type Operation = Record & { op: string }; +type Sensitivity = FakeObject["sensitivity"]; + +/** Keyed on the union so a new `Surface` member fails to typecheck here. */ +const SURFACE_SET: Record = { + auto_inject: true, + auto_search: true, + explicit_search: true, +}; +const SURFACES: readonly Surface[] = Object.keys(SURFACE_SET) as Surface[]; + +const SENSITIVITY_RANK: Record = { normal: 0, sensitive: 1, secret: 2 }; + +function restrictive(left: Sensitivity, right: Sensitivity): Sensitivity { + return SENSITIVITY_RANK[left] >= SENSITIVITY_RANK[right] ? left : right; +} + +/** + * `project:` plus the sha256 of the root path bytes, the scope id the daemon + * materializes per project. The daemon canonicalizes the root first; the fake + * hashes `projectRoot` without canonicalizing. + */ +export function fakeProjectScopeId(projectRoot: string): string { + return `project:${sha256Hex(projectRoot)}`; +} + +function invalid(reason: string): unknown { + return { state: { kind: "invalid", reason } }; +} + +function conflict(reason: string): unknown { + return { state: { kind: "conflict", reason } }; +} + export class FakeKernel { tip = 0; readonly objects = new Map(); @@ -67,7 +109,8 @@ export class FakeKernel { /** * Seeds a live decision object as if a prior commit had written it. Route * writes are `labeled`; `labeled: false` stands in for a verified object - * only a direct store commit can produce. + * only a direct store commit can produce. Without `projectRoot` the row + * serves to every project. */ seedDecision(input: { object_id: string; @@ -80,6 +123,7 @@ export class FakeKernel { domain_id?: string; source_kind?: string; source_id?: string; + projectRoot?: string; }): FakeObject { const seq = this.nextSeq(); const object: FakeObject = { @@ -94,6 +138,7 @@ export class FakeKernel { superseded_by: null, sensitivity: input.sensitivity ?? "normal", labeled: input.labeled ?? true, + project_root: input.projectRoot ?? null, decision: { decision_kind: input.decision_kind, payload: { summary: input.summary, rationale: input.rationale ?? "" }, @@ -113,10 +158,13 @@ export class FakeKernel { /** * The snapshot a client would hold after reading `surface` at the tip, * parsed through the same wire decoder, for consumers that take the - * snapshot as a value instead of dialing. + * snapshot as a value instead of dialing. Without `projectRoot` no + * project filter applies. */ - snapshot(surface: Surface = "explicit_search"): KernelMemorySnapshot { - const parsed = parseReadResponse(this.readReply({ surface, gated: false })); + snapshot(surface: Surface = "explicit_search", projectRoot?: string): KernelMemorySnapshot { + const parsed = parseReadResponse( + this.readReply({ surface, gated: false }, projectRoot ?? null), + ); return parsed.payload ? { state: parsed.state, @@ -139,9 +187,19 @@ export class FakeKernel { return !object.labeled && object.sensitivity !== "sensitive"; } - private readReply(body: Record): unknown { - const surface = body.surface as Surface; - const forced = this.surfaceStates.get(surface); + /** Whether a row is in the calling project's scope; a seeded row without a root, or a call without one, passes. */ + private static inProject(object: FakeObject, projectRoot: string | null): boolean { + return ( + projectRoot === null || + object.project_root === null || + object.project_root === projectRoot + ); + } + + private readReply(body: Record, projectRoot: string | null): unknown { + const surface = body.surface; + if (!(SURFACES as readonly unknown[]).includes(surface)) return invalid("invalid_input"); + const forced = this.surfaceStates.get(surface as Surface); if (forced && forced.kind !== "available") return { state: forced }; const asOf = typeof body.as_of === "number" ? body.as_of : this.tip; if (asOf > this.tip) return { state: { kind: "unavailable", reason: "snapshot_diverged" } }; @@ -152,7 +210,8 @@ export class FakeKernel { (object) => object.created_commit_seq <= asOf && (object.invalidated_commit_seq === null || asOf < object.invalidated_commit_seq) && - FakeKernel.servesOn(object, surface), + FakeKernel.inProject(object, projectRoot) && + FakeKernel.servesOn(object, surface as Surface), ); if (objectIds !== null) { visible = visible.filter((object) => objectIds.has(object.object_id)); @@ -172,12 +231,12 @@ export class FakeKernel { const rows = visible .sort((left, right) => (left.object_id < right.object_id ? -1 : 1)) .map((object) => { - const { labeled, decision, ...row } = object; + const { labeled, project_root, decision, ...row } = object; return { object: row, visibility: labeled ? "labeled" : "visible", labeled, - scope_id: "project:fake", + scope_id: fakeProjectScopeId(project_root ?? projectRoot ?? ""), token: { object_id: object.object_id, known_as_of: asOf }, decision: decision ?? null, }; @@ -192,23 +251,35 @@ export class FakeKernel { }; } - private conflictFor(tokens: { object_id: string; known_as_of: number }[]): unknown | null { + /** + * The daemon's token check: an object the store never held or another + * project's object is `not_found`, so foreign ids are not enumerable; a + * token from a snapshot past the tip is refused before the object is + * consulted; an invalidated object names the disposition that invalidated + * it; a live object that changed after the token's `known_as_of` is + * `known_as_of_advanced`. + */ + private conflictFor( + tokens: { object_id: string; known_as_of: number }[], + projectRoot: string | null, + ): unknown | null { for (const token of tokens) { const object = this.objects.get(token.object_id); - if (!object || object.invalidated_commit_seq !== null) { - if (object?.superseded_by) { - return { state: { kind: "conflict", reason: "superseded" } }; - } - return { state: { kind: "conflict", reason: "retracted" } }; + if (!object || !FakeKernel.inProject(object, projectRoot)) return invalid("not_found"); + if (token.known_as_of > this.tip) { + return { state: { kind: "unavailable", reason: "snapshot_diverged" } }; + } + if (object.invalidated_commit_seq !== null) { + return conflict(object.superseded_by ? "superseded" : "retracted"); } if ((this.lastChange.get(token.object_id) ?? 0) > token.known_as_of) { - return { state: { kind: "conflict", reason: "known_as_of_advanced" } }; + return conflict("known_as_of_advanced"); } } return null; } - private commitReply(body: Record): unknown { + private commitReply(body: Record, projectRoot: string | null): unknown { if (this.nextCommitState) { const state = this.nextCommitState; this.nextCommitState = null; @@ -218,130 +289,155 @@ export class FakeKernel { const replayed = this.receipts.get(intent.operation_key); if (replayed) { if (replayed.request_digest !== intent.request_digest) { - return { state: { kind: "invalid", reason: "operation_key_reused" } }; + return invalid("operation_key_reused"); } return { state: { kind: "available" }, receipt: { commit_seq: replayed.commit_seq, replayed: true }, known_as_of: replayed.commit_seq, tokens: replayed.tokens, + merged: replayed.merged, }; } this.beforeCommit?.(); const tokens = (body.tokens as { object_id: string; known_as_of: number }[]) ?? []; - const conflict = this.conflictFor(tokens); - if (conflict) return conflict; + const tokenConflict = this.conflictFor(tokens, projectRoot); + if (tokenConflict) return tokenConflict; const operations = body.operations as Operation[]; - const touched = new Set(); const sourceKind = typeof body.source_kind === "string" ? body.source_kind : "assistant"; - // Every inserted object id must be new to the store, matching the daemon's duplicate-id answer; retired ids stay in `objects`, so a re-insert of an archived id answers `already_exists` too. Two inserts of the same id within one envelope also collide (the daemon's object_registry primary key rejects the whole envelope), so prospective insert ids are tracked across the loop; only supersede operations may name the same survivor id repeatedly, because a merge emits one supersession per predecessor sharing a single survivor. commentlint: allow(JUDGE) - const prospectiveInsertIds = new Set(); - for (const operation of operations) { - if (operation.op !== "insert_decision" && operation.op !== "supersede_decision") { - continue; + // One envelope is atomic: rows change on a staged overlay in envelope order, and a refusal at any operation leaves the store and the tip untouched. commentlint: allow(JUDGE) + const seq = this.tip + 1; + const staged = new Map(); + const touched = new Set(); + const merged = new Set(); + const view = (objectId: string): FakeObject | undefined => + staged.get(objectId) ?? this.objects.get(objectId); + const stage = (objectId: string): FakeObject => { + let row = staged.get(objectId); + if (!row) { + row = { ...(this.objects.get(objectId) as FakeObject) }; + staged.set(objectId, row); } - const objectId = (operation.spec as Record).object_id as string; - if (this.objects.has(objectId)) { - return { state: { kind: "invalid", reason: "already_exists" } }; + return row; + }; + // A commit target is looked up among this project's live objects only, so a missing, foreign, or invalidated target is `not_found` alike. commentlint: allow(JUDGE) + const liveTarget = (objectId: string): FakeObject | null => { + const target = view(objectId); + if ( + !target || + !FakeKernel.inProject(target, projectRoot) || + target.invalidated_commit_seq !== null + ) { + return null; } + return target; + }; + const insert = (spec: Record, sensitivity: Sensitivity): void => { + const objectId = spec.object_id as string; + staged.set(objectId, { + object_id: objectId, + object_kind: "decision", + domain_id: spec.domain_id as string, + source_kind: sourceKind, + source_id: spec.source_id as string, + source_revision: spec.source_revision as number, + created_commit_seq: seq, + invalidated_commit_seq: null, + superseded_by: null, + sensitivity, + labeled: true, + project_root: projectRoot, + decision: { + decision_kind: spec.decision_kind as string, + payload: spec.payload as { summary: string; rationale: string }, + }, + }); + touched.add(objectId); + }; + const invalidate = (target: FakeObject, supersededBy: string | null): void => { + const row = stage(target.object_id); + row.invalidated_commit_seq = seq; + row.superseded_by = supersededBy; + touched.add(row.object_id); + }; + for (const operation of operations) { if (operation.op === "insert_decision") { - if (prospectiveInsertIds.has(objectId)) { - return { state: { kind: "invalid", reason: "already_exists" } }; + const spec = operation.spec as Record; + // The registry's primary key refuses any held id, live or retired, this project's or another's. commentlint: allow(JUDGE) + if (view(spec.object_id as string)) return invalid("already_exists"); + insert(spec, (spec.sensitivity as Sensitivity | undefined) ?? "normal"); + } else if (operation.op === "supersede_decision") { + const replaced = liveTarget(operation.replaced_object_id as string); + if (!replaced) return invalid("not_found"); + const spec = operation.spec as Record; + const replacementId = spec.object_id as string; + // A replacement id another project holds is `not_found` whether live or retired, so its state is not revealed. A live in-project replacement is a fold survivor: the spec is discarded, the survivor keeps its stored label and revision, and the predecessor is re-pointed at it. A retired in-project one is a duplicate insert. commentlint: allow(JUDGE) + const replacement = view(replacementId); + if (replacement && !FakeKernel.inProject(replacement, projectRoot)) { + return invalid("not_found"); } - prospectiveInsertIds.add(objectId); - } - } - // The daemon applies one envelope atomically: every operation is validated against pre-envelope state before any mutation, and `invalidating` rejects a second supersede or retire of a target an earlier operation in the envelope already invalidates. The commit sequence is allocated only after validation so a rejected envelope does not advance the snapshot. commentlint: allow(JUDGE) - const invalidating = new Set(); - for (const operation of operations) { - if (operation.op === "insert_decision") continue; - if (operation.op === "supersede_decision") { - const targetId = operation.replaced_object_id as string; - const replaced = this.objects.get(targetId); - // The daemon looks the target up among live objects only; a - // missing or invalidated one is `NotFound`, which maps to `internal`. + const survivor = + replacement && replacement.invalidated_commit_seq === null ? replacement : null; if ( - !replaced || - replaced.invalidated_commit_seq !== null || - invalidating.has(targetId) + survivor && + restrictive(survivor.sensitivity, replaced.sensitivity) !== survivor.sensitivity ) { - return { state: { kind: "invalid", reason: "internal" } }; + return invalid("admission_policy"); + } + const successor = survivor ?? { + domain_id: spec.domain_id as string, + source_kind: sourceKind, + source_id: spec.source_id as string, + source_revision: spec.source_revision as number, + }; + if (successor.source_revision <= replaced.source_revision) { + return invalid("revision_not_advanced"); } - const spec = operation.spec as Record; if ( - replaced.domain_id !== spec.domain_id || - replaced.source_id !== spec.source_id || - replaced.source_kind !== sourceKind + successor.domain_id !== replaced.domain_id || + successor.source_kind !== replaced.source_kind || + successor.source_id !== replaced.source_id ) { - return { state: { kind: "invalid", reason: "invalid_input" } }; + return invalid("invalid_input"); } - if ((spec.source_revision as number) <= replaced.source_revision) { - return { state: { kind: "conflict", reason: "known_as_of_advanced" } }; + if (replacement && !survivor) return invalid("already_exists"); + if (survivor) { + merged.add(survivor.object_id); + touched.add(survivor.object_id); + } else { + // A non-fold successor may raise its predecessor's label but not lower it. + insert( + spec, + restrictive( + (spec.sensitivity as Sensitivity | undefined) ?? "normal", + replaced.sensitivity, + ), + ); } - invalidating.add(targetId); + invalidate(replaced, replacementId); } else if (operation.op === "retire_decision") { - const targetId = operation.object_id as string; - const retired = this.objects.get(targetId); - if ( - !retired || - retired.invalidated_commit_seq !== null || - invalidating.has(targetId) - ) { - return { state: { kind: "invalid", reason: "internal" } }; - } - invalidating.add(targetId); + const retired = liveTarget(operation.object_id as string); + if (!retired) return invalid("not_found"); + invalidate(retired, null); } else { - return { state: { kind: "invalid", reason: "invalid_input" } }; + return invalid("invalid_input"); } } - const seq = this.nextSeq(); - const insert = (spec: Record): void => { - const objectId = spec.object_id as string; - if (!this.objects.has(objectId)) { - const payload = spec.payload as { summary: string; rationale: string }; - this.objects.set(objectId, { - object_id: objectId, - object_kind: "decision", - domain_id: spec.domain_id as string, - source_kind: sourceKind, - source_id: spec.source_id as string, - source_revision: spec.source_revision as number, - created_commit_seq: seq, - invalidated_commit_seq: null, - superseded_by: null, - // The daemon defaults an omitted spec sensitivity to `normal`. - sensitivity: (spec.sensitivity as FakeObject["sensitivity"]) ?? "normal", - labeled: true, - decision: { decision_kind: spec.decision_kind as string, payload }, - }); - } - this.lastChange.set(objectId, seq); - touched.add(objectId); - }; - for (const operation of operations) { - if (operation.op === "insert_decision") { - insert(operation.spec as Record); - } else if (operation.op === "supersede_decision") { - const replaced = this.objects.get(operation.replaced_object_id as string); - const spec = operation.spec as Record; - if (!replaced) continue; - insert(spec); - replaced.invalidated_commit_seq = seq; - replaced.superseded_by = spec.object_id as string; - this.lastChange.set(replaced.object_id, seq); - touched.add(replaced.object_id); - } else if (operation.op === "retire_decision") { - const retired = this.objects.get(operation.object_id as string); - if (!retired) continue; - retired.invalidated_commit_seq = seq; - this.lastChange.set(retired.object_id, seq); - touched.add(retired.object_id); + this.tip = seq; + for (const [objectId, row] of staged) { + const existing = this.objects.get(objectId); + if (existing) { + Object.assign(existing, row); + } else { + this.objects.set(objectId, row); } } + for (const objectId of touched) this.lastChange.set(objectId, seq); const receipt: Receipt = { commit_seq: seq, request_digest: intent.request_digest, tokens: [...touched].sort().map((object_id) => ({ object_id, known_as_of: seq })), + merged: [...merged].sort(), }; this.receipts.set(intent.operation_key, receipt); return { @@ -349,18 +445,24 @@ export class FakeKernel { receipt: { commit_seq: seq, replayed: false }, known_as_of: seq, tokens: receipt.tokens, + merged: receipt.merged, }; } reply(call: KernelTransportCall): unknown { const body = call.body as Record; + // The route is bound to the transport call's root; a body root that names another project is refused before any work. The daemon canonicalizes both roots first; the fake compares the strings. commentlint: allow(JUDGE) + if (typeof body.project_root === "string" && body.project_root !== call.projectRoot) { + return invalid("project_mismatch"); + } + const projectRoot = call.projectRoot; switch (call.method) { case "kernel.read": - return this.readReply(body); + return this.readReply(body, projectRoot); case "kernel.commit": - return this.commitReply(body); + return this.commitReply(body, projectRoot); default: - return { state: { kind: "invalid", reason: "invalid_input" } }; + return invalid("invalid_input"); } } } diff --git a/packages/plugin/src/shared/kernel-client-testing/fixtures/commit-available-create.json b/packages/plugin/src/shared/kernel-client-testing/fixtures/commit-available-create.json new file mode 100644 index 000000000..f0fd49a8f --- /dev/null +++ b/packages/plugin/src/shared/kernel-client-testing/fixtures/commit-available-create.json @@ -0,0 +1,17 @@ +{ + "known_as_of": 2, + "merged": [], + "receipt": { + "commit_seq": 2, + "replayed": false + }, + "state": { + "kind": "available" + }, + "tokens": [ + { + "known_as_of": 2, + "object_id": "decision-object-1" + } + ] +} diff --git a/packages/plugin/src/shared/kernel-client-testing/fixtures/commit-available-merge.json b/packages/plugin/src/shared/kernel-client-testing/fixtures/commit-available-merge.json new file mode 100644 index 000000000..03bc0a3be --- /dev/null +++ b/packages/plugin/src/shared/kernel-client-testing/fixtures/commit-available-merge.json @@ -0,0 +1,31 @@ +{ + "known_as_of": 3, + "merged": [ + "decision-object-4" + ], + "receipt": { + "commit_seq": 3, + "replayed": false + }, + "state": { + "kind": "available" + }, + "tokens": [ + { + "known_as_of": 3, + "object_id": "decision-object-1" + }, + { + "known_as_of": 3, + "object_id": "decision-object-2" + }, + { + "known_as_of": 3, + "object_id": "decision-object-3" + }, + { + "known_as_of": 3, + "object_id": "decision-object-4" + } + ] +} diff --git a/packages/plugin/src/shared/kernel-client-testing/fixtures/commit-conflict-known-as-of-advanced.json b/packages/plugin/src/shared/kernel-client-testing/fixtures/commit-conflict-known-as-of-advanced.json new file mode 100644 index 000000000..17465d549 --- /dev/null +++ b/packages/plugin/src/shared/kernel-client-testing/fixtures/commit-conflict-known-as-of-advanced.json @@ -0,0 +1,6 @@ +{ + "state": { + "kind": "conflict", + "reason": "known_as_of_advanced" + } +} diff --git a/packages/plugin/src/shared/kernel-client-testing/fixtures/commit-invalid-admission-policy.json b/packages/plugin/src/shared/kernel-client-testing/fixtures/commit-invalid-admission-policy.json new file mode 100644 index 000000000..e8980fa96 --- /dev/null +++ b/packages/plugin/src/shared/kernel-client-testing/fixtures/commit-invalid-admission-policy.json @@ -0,0 +1,6 @@ +{ + "state": { + "kind": "invalid", + "reason": "admission_policy" + } +} diff --git a/packages/plugin/src/shared/kernel-client-testing/fixtures/commit-invalid-already-exists.json b/packages/plugin/src/shared/kernel-client-testing/fixtures/commit-invalid-already-exists.json new file mode 100644 index 000000000..e501f19b5 --- /dev/null +++ b/packages/plugin/src/shared/kernel-client-testing/fixtures/commit-invalid-already-exists.json @@ -0,0 +1,6 @@ +{ + "state": { + "kind": "invalid", + "reason": "already_exists" + } +} diff --git a/packages/plugin/src/shared/kernel-client-testing/fixtures/commit-invalid-not-found.json b/packages/plugin/src/shared/kernel-client-testing/fixtures/commit-invalid-not-found.json new file mode 100644 index 000000000..87f52c4f9 --- /dev/null +++ b/packages/plugin/src/shared/kernel-client-testing/fixtures/commit-invalid-not-found.json @@ -0,0 +1,6 @@ +{ + "state": { + "kind": "invalid", + "reason": "not_found" + } +} diff --git a/packages/plugin/src/shared/kernel-client-testing/fixtures/commit-invalid-project-mismatch.json b/packages/plugin/src/shared/kernel-client-testing/fixtures/commit-invalid-project-mismatch.json new file mode 100644 index 000000000..2e27ff2d9 --- /dev/null +++ b/packages/plugin/src/shared/kernel-client-testing/fixtures/commit-invalid-project-mismatch.json @@ -0,0 +1,6 @@ +{ + "state": { + "kind": "invalid", + "reason": "project_mismatch" + } +} diff --git a/packages/plugin/src/shared/kernel-client-testing/fixtures/commit-invalid-revision-not-advanced.json b/packages/plugin/src/shared/kernel-client-testing/fixtures/commit-invalid-revision-not-advanced.json new file mode 100644 index 000000000..2aa9ec6e4 --- /dev/null +++ b/packages/plugin/src/shared/kernel-client-testing/fixtures/commit-invalid-revision-not-advanced.json @@ -0,0 +1,6 @@ +{ + "state": { + "kind": "invalid", + "reason": "revision_not_advanced" + } +} diff --git a/packages/plugin/src/shared/kernel-client-testing/fixtures/read-auto-inject-empty.json b/packages/plugin/src/shared/kernel-client-testing/fixtures/read-auto-inject-empty.json new file mode 100644 index 000000000..6aa6816d2 --- /dev/null +++ b/packages/plugin/src/shared/kernel-client-testing/fixtures/read-auto-inject-empty.json @@ -0,0 +1,10 @@ +{ + "gated": false, + "known_as_of": 2, + "rows": [], + "state": { + "kind": "available" + }, + "tip": 2, + "truncated": false +} diff --git a/packages/plugin/src/shared/kernel-client-testing/fixtures/read-cross-project-empty.json b/packages/plugin/src/shared/kernel-client-testing/fixtures/read-cross-project-empty.json new file mode 100644 index 000000000..6aa6816d2 --- /dev/null +++ b/packages/plugin/src/shared/kernel-client-testing/fixtures/read-cross-project-empty.json @@ -0,0 +1,10 @@ +{ + "gated": false, + "known_as_of": 2, + "rows": [], + "state": { + "kind": "available" + }, + "tip": 2, + "truncated": false +} diff --git a/packages/plugin/src/shared/kernel-client-testing/fixtures/read-explicit-search-labeled.json b/packages/plugin/src/shared/kernel-client-testing/fixtures/read-explicit-search-labeled.json new file mode 100644 index 000000000..fb0d4254b --- /dev/null +++ b/packages/plugin/src/shared/kernel-client-testing/fixtures/read-explicit-search-labeled.json @@ -0,0 +1,39 @@ +{ + "gated": false, + "known_as_of": 2, + "rows": [ + { + "decision": { + "decision_kind": "memory", + "payload": { + "rationale": "because 1", + "summary": "decision 1" + } + }, + "labeled": true, + "object": { + "created_commit_seq": 2, + "domain_id": "domain", + "invalidated_commit_seq": null, + "object_id": "decision-object-1", + "object_kind": "decision", + "sensitivity": "normal", + "source_id": "memory-lineage", + "source_kind": "assistant", + "source_revision": 1, + "superseded_by": null + }, + "scope_id": "project:", + "token": { + "known_as_of": 2, + "object_id": "decision-object-1" + }, + "visibility": "labeled" + } + ], + "state": { + "kind": "available" + }, + "tip": 2, + "truncated": false +} diff --git a/packages/plugin/src/shared/kernel-client-testing/module-graph.ts b/packages/plugin/src/shared/kernel-client-testing/module-graph.ts new file mode 100644 index 000000000..074fc96da --- /dev/null +++ b/packages/plugin/src/shared/kernel-client-testing/module-graph.ts @@ -0,0 +1,64 @@ +/** + * Reachability is read from the bundler's metafile rather than from the + * emitted text, so a claim about what an entry imports is not fooled by a + * string that happens to appear in a comment or a log line. + */ + +/** The Pi `build` script's externals; the graph stops at these package boundaries. */ +export const BUNDLE_EXTERNALS = [ + "@cortexkit/mc-shm-native", + "@earendil-works/pi-coding-agent", + "@earendil-works/pi-tui", + "@huggingface/transformers", + "node:sqlite", +] as const; + +export interface ModuleGraph { + /** Every source module in the bundle, as the bundler names it relative to the working directory. */ + inputs: string[]; + /** Every specifier an input imports that stays external, since those never appear in `inputs`. */ + externals: string[]; + /** The bundled text, for claims about emitted bytes such as which externals stay external. */ + text: string; +} + +interface MetafileInput { + imports?: { path?: string; external?: boolean }[]; +} + +export async function bundleModuleGraph(entry: string): Promise { + const result = await Bun.build({ + entrypoints: [entry], + target: "node", + format: "esm", + external: [...BUNDLE_EXTERNALS], + metafile: true, + }); + if (!result.success) { + throw new Error(`bundle of ${entry} failed: ${result.logs.map(String).join("\n")}`); + } + if (result.outputs.length === 0) throw new Error(`bundle of ${entry} produced no output`); + // The runtime hands the metafile back as an object; the type declares a JSON string. + const raw: unknown = result.metafile; + const metafile = (typeof raw === "string" ? JSON.parse(raw) : raw) as + | { inputs?: Record } + | undefined; + if (!metafile?.inputs) throw new Error(`bundle of ${entry} produced no metafile`); + const texts = await Promise.all(result.outputs.map((output) => output.text())); + const externals = new Set(); + for (const input of Object.values(metafile.inputs)) { + for (const edge of input.imports ?? []) { + if (edge.external === true && typeof edge.path === "string") externals.add(edge.path); + } + } + return { + inputs: Object.keys(metafile.inputs), + externals: [...externals], + text: texts.join("\n"), + }; +} + +/** Module and external-specifier paths matching `pattern`. */ +export function reachableModules(graph: ModuleGraph, pattern: RegExp): string[] { + return [...graph.inputs, ...graph.externals].filter((path) => pattern.test(path)); +} diff --git a/packages/plugin/src/shared/kernel-client/client.test.ts b/packages/plugin/src/shared/kernel-client/client.test.ts index fb32d49c3..709d4faa0 100644 --- a/packages/plugin/src/shared/kernel-client/client.test.ts +++ b/packages/plugin/src/shared/kernel-client/client.test.ts @@ -227,6 +227,13 @@ describe("KernelClient transport mapping", () => { expect(JSON.stringify(bodies[0])).toBe(JSON.stringify(bodies[1])); }); + test("an already-expired deadline is cancelled before any transport call", async () => { + const transport = new FakeTransport().queue(commitReply(5, false, "decision-object-1")); + const result = await client(transport).create(spec, { ...intent, deadlineMs: 0 }); + expect(result.state).toEqual({ kind: "cancelled" }); + expect(transport.calls).toHaveLength(0); + }); + test("a second outcome_unknown on the same write stays ambiguous", async () => { const transport = new FakeTransport().queue( new McHostCallError("outcome_unknown", "deadline"), diff --git a/packages/plugin/src/tools/ctx-memory/constants.ts b/packages/plugin/src/tools/ctx-memory/constants.ts index fd3f7ba92..5405895f6 100644 --- a/packages/plugin/src/tools/ctx-memory/constants.ts +++ b/packages/plugin/src/tools/ctx-memory/constants.ts @@ -1,4 +1,6 @@ -import type { ImitatedArgRule } from "../unwrap-imitated-reduced-args"; +import { WRITABLE_MEMORY_CATEGORIES } from "../../features/magic-context/memory/constants"; +import type { ImitatedArgRule, ImitatedArgsSchema } from "../unwrap-imitated-reduced-args"; +import { CTX_MEMORY_DREAMER_ACTIONS } from "./types"; export const CTX_MEMORY_TOOL_NAME = "ctx_memory"; export const CTX_MEMORY_DESCRIPTION = `Durable project memories shared across sessions, served by the memory daemon. @@ -41,3 +43,15 @@ export const CTX_MEMORY_ANTI_MEMORY_RULE: ImitatedArgRule = { expiresAt: "number", }, }; + +/** How each `ctx_memory` argument is recovered from a reduced-args wrapper; every host registers the tool against this one table. */ +export const CTX_MEMORY_UNWRAP_RULES: ImitatedArgsSchema = { + action: { type: "enum", values: CTX_MEMORY_DREAMER_ACTIONS }, + content: "string", + category: { type: "enum", values: WRITABLE_MEMORY_CATEGORIES }, + antiMemory: CTX_MEMORY_ANTI_MEMORY_RULE, + objectId: "string", + objectIds: { type: "array", items: "string", maxItems: 20 }, + limit: "number", + reason: "string", +}; diff --git a/packages/plugin/src/tools/ctx-memory/tools.test.ts b/packages/plugin/src/tools/ctx-memory/tools.test.ts index cb74119fa..3b46160fe 100644 --- a/packages/plugin/src/tools/ctx-memory/tools.test.ts +++ b/packages/plugin/src/tools/ctx-memory/tools.test.ts @@ -1,9 +1,13 @@ import { describe, expect, setSystemTime, test } from "bun:test"; import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; +import { relative, resolve } from "node:path"; import { DREAMER_AGENT } from "../../agents/dreamer"; import { KernelClient, TokenCache } from "../../shared/kernel-client"; import { FakeKernel, FakeKernelTransport } from "../../shared/kernel-client-testing/fake-kernel"; +import { + bundleModuleGraph, + reachableModules, +} from "../../shared/kernel-client-testing/module-graph"; import { createCtxMemoryTools } from "./tools"; const PROJECT = "git:kernel-opencode"; @@ -77,6 +81,13 @@ describe("ctx_memory without a daemon", () => { expect(tool.transport.calls).toHaveLength(0); }); + test("the tool holds no database handle and its module graph reaches no claim storage", async () => { + const graph = await bundleModuleGraph(resolve(import.meta.dir, "tools.ts")); + expect(graph.inputs.length).toBeGreaterThan(0); + expect(reachableModules(graph, /storage-claim/)).toEqual([]); + expect(reachableModules(graph, /memory\/storage-memory/)).toEqual([]); + }); + test("a disabled client answers with the disabled text", async () => { const tool = harness(new FakeKernel(), false); expect(await tool.execute(createArgs("x"), "call-disabled")).toBe( @@ -1185,38 +1196,42 @@ describe("ctx_memory human authority", () => { }); }); -/** Every OpenCode file on the memory path; the text bans below scan this list. */ -export const OPENCODE_MEMORY_PATH_FILES = [ - "tools/ctx-memory/tools.ts", - "tools/ctx-memory/types.ts", - "tools/ctx-memory/constants.ts", - "tools/ctx-memory/write-shape.ts", - "tools/ctx-search/tools.ts", - "hooks/magic-context/kernel-transport.ts", - "hooks/magic-context/kernel-memory-render.ts", - "hooks/magic-context/inject-compartments.ts", - "hooks/magic-context/transform.ts", - "hooks/magic-context/transform-compartment-phase.ts", - "hooks/magic-context/m0-token-breakdown.ts", - "hooks/magic-context/auto-search-runner.ts", - "plugin/rpc-handlers.ts", - "plugin/tool-registry.ts", -]; +const PLUGIN_ROOT = resolve(import.meta.dir, "../../.."); +const PI_ROOT = resolve(PLUGIN_ROOT, "../pi-plugin"); + +interface BiomeOverride { + includes?: string[]; + linter?: { rules?: { style?: { noRestrictedImports?: { options?: unknown } } } }; +} + +/** Expands the claim-import ban's `biome.json` globs so this scan and the lint rule cover the same files. */ +function memoryPathFiles(packageRoot: string): string[] { + const biome = JSON.parse(readFileSync(resolve(packageRoot, "biome.json"), "utf8")) as { + overrides?: BiomeOverride[]; + }; + const override = (biome.overrides ?? []).find((candidate) => + JSON.stringify(candidate.linter?.rules?.style?.noRestrictedImports?.options ?? {}).includes( + "storage-claim", + ), + ); + if (!override?.includes) + throw new Error(`${packageRoot}/biome.json has no memory-path override`); + const files = new Set(); + for (const pattern of override.includes) { + if (pattern.startsWith("!")) continue; + for (const match of new Bun.Glob(pattern).scanSync({ cwd: packageRoot })) { + if (match.endsWith(".test.ts")) continue; + files.add(relative(resolve(packageRoot, "src"), resolve(packageRoot, match))); + } + } + return [...files].sort(); +} + +/** Every OpenCode file on the memory path, relative to `packages/plugin/src`; the text bans below scan this list. */ +export const OPENCODE_MEMORY_PATH_FILES = memoryPathFiles(PLUGIN_ROOT); /** Every Pi file on the memory path, relative to `packages/pi-plugin/src`; the same bans scan it. */ -export const PI_MEMORY_PATH_FILES = [ - "tools/ctx-memory.ts", - "tools/ctx-search.ts", - "tools/index.ts", - "kernel-client-pi.ts", - "inject-compartments-pi.ts", - "context-handler.ts", - "auto-search-pi.ts", - "dialogs/status-dialog.ts", - "commands/ctx-status.ts", - "clone-inheritance.ts", - "pi-historian-runner.ts", -]; +export const PI_MEMORY_PATH_FILES = memoryPathFiles(PI_ROOT); const TEXT_BANS = ["claim.intent", "authorityState", "rustToolBackends.memory"]; @@ -1240,6 +1255,36 @@ function scanForBans( } describe("ctx_memory memory path text bans", () => { + test("the biome overrides name the tool, transport, renderer, and injector on both harnesses", () => { + expect(OPENCODE_MEMORY_PATH_FILES).toEqual( + expect.arrayContaining([ + "tools/ctx-memory/tools.ts", + "tools/ctx-memory/execute.ts", + "tools/ctx-search/tools.ts", + "features/magic-context/search.ts", + "hooks/magic-context/kernel-transport.ts", + "hooks/magic-context/kernel-memory-render.ts", + "hooks/magic-context/kernel-claim-usage.ts", + "hooks/magic-context/inject-compartments.ts", + "hooks/magic-context/transform.ts", + "hooks/magic-context/compartment-runner-incremental.ts", + "plugin/rpc-handlers.ts", + "plugin/tool-registry.ts", + ]), + ); + expect(OPENCODE_MEMORY_PATH_FILES).not.toContain("tools/ctx-memory/tools.test.ts"); + expect(PI_MEMORY_PATH_FILES).toEqual( + expect.arrayContaining([ + "tools/ctx-memory.ts", + "tools/ctx-search.ts", + "kernel-client-pi.ts", + "inject-compartments-pi.ts", + "context-handler.ts", + "pi-historian-runner.ts", + ]), + ); + }); + test("no memory-path file names the claim lane, authority state, or the Rust memory backend", () => { const sources = new Map(); for (const file of OPENCODE_MEMORY_PATH_FILES) { diff --git a/packages/plugin/src/tools/ctx-memory/tools.ts b/packages/plugin/src/tools/ctx-memory/tools.ts index a66059db4..fbccb2105 100644 --- a/packages/plugin/src/tools/ctx-memory/tools.ts +++ b/packages/plugin/src/tools/ctx-memory/tools.ts @@ -7,11 +7,7 @@ import { getProjectEmbeddingSnapshot } from "../../features/magic-context/memory import { resolveProjectRootDirectory } from "../../features/magic-context/memory/project-identity"; import { toolCallIdFromContext } from "../../plugin/rust-tool-backends"; import { unwrapImitatedReducedArgs } from "../unwrap-imitated-reduced-args"; -import { - CTX_MEMORY_ANTI_MEMORY_RULE, - CTX_MEMORY_DESCRIPTION, - CTX_MEMORY_TOOL_NAME, -} from "./constants"; +import { CTX_MEMORY_DESCRIPTION, CTX_MEMORY_TOOL_NAME, CTX_MEMORY_UNWRAP_RULES } from "./constants"; import { CTX_MEMORY_ACTOR, CTX_MEMORY_DREAMER_ACTOR, executeCtxMemory } from "./execute"; import { CTX_MEMORY_ACTIONS, @@ -19,6 +15,7 @@ import { type CtxMemoryAction, type CtxMemoryArgs, type CtxMemoryToolDeps, + isCtxMemoryMutation, } from "./types"; import { assertCtxMemoryWriteShape } from "./write-shape"; @@ -86,16 +83,7 @@ function createCtxMemoryTool(deps: CtxMemoryToolDeps): ToolDefinition { try { const parsed = ctxMemoryArgsSchema.safeParse(rawArgs); let args = (parsed.success ? parsed.data : rawArgs) as CtxMemoryArgs; - args = unwrapImitatedReducedArgs(args, ["action"], { - action: { type: "enum", values: CTX_MEMORY_DREAMER_ACTIONS }, - content: "string", - category: { type: "enum", values: WRITABLE_MEMORY_CATEGORIES }, - antiMemory: CTX_MEMORY_ANTI_MEMORY_RULE, - objectId: "string", - objectIds: { type: "array", items: "string", maxItems: 20 }, - limit: "number", - reason: "string", - }); + args = unwrapImitatedReducedArgs(args, ["action"], CTX_MEMORY_UNWRAP_RULES); const rawAction = (args as { action?: unknown }).action; if (rawAction === "approve" || rawAction === "enforce") { return "Error: approve and enforce are human-host-owned commands, not agent actions."; @@ -125,8 +113,7 @@ function createCtxMemoryTool(deps: CtxMemoryToolDeps): ToolDefinition { return "Cross-session memory is disabled for this project."; } const toolCallId = toolCallIdFromContext(toolContext); - const mutation = !["get", "list"].includes(action); - if (!toolCallId && mutation) { + if (!toolCallId && isCtxMemoryMutation(action)) { return "Error: ctx_memory mutation requires a stable tool-call identity."; } const client = deps.kernelClient({ diff --git a/packages/plugin/src/tools/ctx-memory/types.ts b/packages/plugin/src/tools/ctx-memory/types.ts index 0fd81b9c7..bf403d18f 100644 --- a/packages/plugin/src/tools/ctx-memory/types.ts +++ b/packages/plugin/src/tools/ctx-memory/types.ts @@ -10,6 +10,13 @@ export const CTX_MEMORY_DREAMER_ACTIONS = [...CTX_MEMORY_ACTIONS, "list"] as con export type CtxMemoryAction = (typeof CTX_MEMORY_DREAMER_ACTIONS)[number]; +const CTX_MEMORY_READ_ACTIONS: ReadonlySet = new Set(["get", "list"]); + +/** Whether `action` commits through the kernel rather than reading from it. */ +export function isCtxMemoryMutation(action: CtxMemoryAction): boolean { + return !CTX_MEMORY_READ_ACTIONS.has(action); +} + export interface CtxMemoryArgs extends ImitatedReducedArgs { action?: CtxMemoryAction; content?: string;