diff --git a/AGENTS.md b/AGENTS.md index 2b6608b..00424b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,12 @@ repair the store. Consumers own native requests, provider lowering, prompts, schemas, tool execution, retry between passes, interpretation, decisions, and state admission. +Keep the feature boundary exact. `--no-default-features` is the contract-only +surface used where a Mind seals provider request bytes. The default `client` +surface adds only authenticated framing and socket transport. `daemon` adds the +service authorities. Contract-only consumers must not compile client crypto or +socket physiology by accident. + Do not link upstream Codex crates into this package. Idunn freezes the official Codex binary as an exact package input. The child receives auth RPC only, never consumer identity, prompts, tools, provider requests, or model output. diff --git a/Cargo.toml b/Cargo.toml index ba6a00e..dea5c3f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,8 +17,14 @@ path = "src/main.rs" required-features = ["daemon"] [features] -default = [] +default = ["client"] +client = [ + "dep:aes-gcm", + "dep:rand_core", + "dep:zeroize", +] daemon = [ + "client", "dep:anyhow", "dep:chrono", "dep:cultcache-rs", @@ -30,14 +36,14 @@ daemon = [ ] [dependencies] -aes-gcm = "0.10" +aes-gcm = { version = "0.10", optional = true } anyhow = { version = "1", optional = true } base64 = "0.23" chrono = { version = "0.4", features = ["serde"], optional = true } cultcache-rs = { git = "https://github.com/GameCult/cultcache-rs.git", rev = "fdbf3bf0a4b3f6b617c224d7439feb8e69d4474c", optional = true } cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "75c180782aeba7cfd22d6412877397708a4ed28f", optional = true } ed25519-dalek = { version = "2.2", optional = true } -rand_core = { version = "0.6", features = ["getrandom"] } +rand_core = { version = "0.6", features = ["getrandom"], optional = true } rmp-serde = "1" serde = { version = "1", features = ["derive"] } serde_bytes = { version = "0.11", optional = true } @@ -46,4 +52,4 @@ sha2 = "0.10" thiserror = "2" ureq = { version = "3.4", default-features = false, features = ["rustls"], optional = true } uuid = { version = "1", features = ["v4"], optional = true } -zeroize = "1" +zeroize = { version = "1", optional = true } diff --git a/README.md b/README.md index 7410ed3..f1738e5 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,13 @@ one credential store. The public connector reads the credential only after the child's private auth RPC completes, then performs the exact Responses request. It owns no prompt policy or decision authority. -The default library is the shared consumer ABI: v2 typed contracts, exact -provider lowering, encryption, bounded direct-pipe framing, one-request socket -transport, and exact result validation. It does not compile CultCache, HTTP, -TLS, provider auth, replay, or daemon code. Epiphany and Ghostlight consume this -surface instead of copying wire law. +The library has three explicit compile surfaces. With no features it exposes +only the v2 provider/result contracts and their deterministic validation. The +default `client` feature adds encryption, bounded direct-pipe framing, and the +one-request socket client. It still compiles no CultCache, HTTP, TLS, provider +auth, replay, or daemon code. The `daemon` feature includes the client surface +and the service. Epiphany and Ghostlight can therefore type and seal their exact +provider requests without importing transport physiology into their Minds. Connection establishment is bounded independently. Response read/write timeout is optional so a consumer's typed outer pass may remain the sole deadline owner. @@ -45,9 +47,10 @@ No upstream Codex crate is linked into the package. The official binary is a pinned deployment input, keeping Codex's application build graph outside both consumers and this small daemon. -Focused verification uses the two real build surfaces: +Focused verification proves each real build surface independently: ```text +cargo test --lib --no-default-features cargo test --lib cargo test --lib --features daemon cargo check --bin codex-connector --features daemon diff --git a/src/lib.rs b/src/lib.rs index 5cd7b68..3769b5b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,9 @@ use std::collections::HashSet; +#[cfg(feature = "client")] use std::io::{Read, Write}; +#[cfg(feature = "client")] use std::net::{SocketAddr, TcpStream}; +#[cfg(feature = "client")] use std::time::Duration; #[cfg(feature = "daemon")] @@ -8,14 +11,18 @@ use std::collections::HashMap; #[cfg(feature = "daemon")] use std::path::Path; +#[cfg(feature = "client")] use aes_gcm::aead::{Aead, Payload}; +#[cfg(feature = "client")] use aes_gcm::{Aes256Gcm, KeyInit, Nonce}; #[cfg(feature = "daemon")] use cultcache_rs::{CultCache, DatabaseEntry, OwnedRedbMessagePackBackingStore}; +#[cfg(feature = "client")] use rand_core::{OsRng, RngCore}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use thiserror::Error; +#[cfg(feature = "client")] use zeroize::{Zeroize, Zeroizing}; #[cfg(feature = "daemon")] @@ -59,9 +66,11 @@ pub const RESULT_SCHEMA_ID: &str = "gamecult.codex.transport_result.v2"; pub const RECEIPT_SCHEMA_ID: &str = "gamecult.codex.transport_receipt.v2"; pub const ENVELOPE_SCHEMA_ID: &str = "gamecult.codex.transport_envelope.v2"; +#[cfg(feature = "client")] #[derive(Clone, PartialEq, Eq)] pub struct CodexTransportKey([u8; 32]); +#[cfg(feature = "client")] impl CodexTransportKey { pub fn from_connection_secret(secret: &str) -> Result { if secret.trim().is_empty() || secret.trim() != secret { @@ -71,6 +80,7 @@ impl CodexTransportKey { } } +#[cfg(feature = "client")] impl Drop for CodexTransportKey { fn drop(&mut self) { self.0.zeroize(); @@ -634,12 +644,14 @@ pub fn canonical_invocation_bytes( rmp_serde::to_vec(invocation).map_err(|_| ContractError::Encoding) } +#[cfg(feature = "client")] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum CodexEnvelopeKind { Invocation, Result, } +#[cfg(feature = "client")] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CodexTransportEnvelope { pub schema_id: String, @@ -650,6 +662,7 @@ pub struct CodexTransportEnvelope { pub ciphertext: Vec, } +#[cfg(feature = "client")] pub fn encrypt_invocation( invocation: &CodexTransportInvocation, security: &CodexTransportKey, @@ -664,6 +677,7 @@ pub fn encrypt_invocation( ) } +#[cfg(feature = "client")] pub fn decrypt_invocation( envelope: &CodexTransportEnvelope, security: &CodexTransportKey, @@ -680,6 +694,7 @@ pub fn decrypt_invocation( Ok(invocation) } +#[cfg(feature = "client")] pub fn encrypt_result( result: &CodexTransportResult, security: &CodexTransportKey, @@ -694,6 +709,7 @@ pub fn encrypt_result( ) } +#[cfg(feature = "client")] pub fn decrypt_result( envelope: &CodexTransportEnvelope, security: &CodexTransportKey, @@ -712,6 +728,7 @@ pub fn decrypt_result( Ok(result) } +#[cfg(feature = "client")] #[derive(Clone)] pub struct CodexConnectorClient { endpoint: SocketAddr, @@ -720,6 +737,7 @@ pub struct CodexConnectorClient { response_timeout: Option, } +#[cfg(feature = "client")] impl CodexConnectorClient { pub fn new( endpoint: SocketAddr, @@ -770,6 +788,7 @@ impl CodexConnectorClient { } } +#[cfg(feature = "client")] #[derive(Debug, Error)] pub enum CodexConnectorClientError { #[error("invalid connector client configuration")] @@ -784,12 +803,14 @@ pub enum CodexConnectorClientError { Transport(#[from] ServiceError), } +#[cfg(feature = "client")] #[derive(Debug)] enum TransportFrameError { Connection(std::io::Error), Size, } +#[cfg(feature = "client")] fn client_frame_error(error: TransportFrameError) -> CodexConnectorClientError { match error { TransportFrameError::Connection(error) => CodexConnectorClientError::Connection(error), @@ -797,6 +818,7 @@ fn client_frame_error(error: TransportFrameError) -> CodexConnectorClientError { } } +#[cfg(feature = "client")] fn read_transport_frame( reader: &mut impl Read, max_frame_bytes: usize, @@ -816,6 +838,7 @@ fn read_transport_frame( Ok(payload) } +#[cfg(feature = "client")] fn write_transport_frame( writer: &mut impl Write, payload: &[u8], @@ -831,6 +854,7 @@ fn write_transport_frame( .map_err(TransportFrameError::Connection) } +#[cfg(feature = "client")] fn encrypt_message( caller_runtime_id: &str, request_id: &str, @@ -868,6 +892,7 @@ fn encrypt_message( }) } +#[cfg(feature = "client")] fn decrypt_message( envelope: &CodexTransportEnvelope, security: &CodexTransportKey, @@ -890,6 +915,7 @@ fn decrypt_message( .map_err(|_| ServiceError::Encryption) } +#[cfg(feature = "client")] fn envelope_aad( schema_id: &str, caller_runtime_id: &str, @@ -900,6 +926,7 @@ fn envelope_aad( .map_err(|_| ServiceError::Encoding) } +#[cfg(feature = "client")] fn validate_envelope( envelope: &CodexTransportEnvelope, expected_kind: CodexEnvelopeKind, @@ -1643,6 +1670,7 @@ mod tests { .unwrap() } + #[cfg(feature = "client")] fn security(key: &str) -> CodexTransportKey { CodexTransportKey::from_connection_secret(key).unwrap() } @@ -1654,6 +1682,7 @@ mod tests { CodexTransportInvocation::new(caller_runtime_id, 2_000, [7; 32], request).unwrap() } + #[cfg(feature = "client")] #[test] fn encrypted_invocation_hides_content_and_binds_outer_identity() { let invocation = invocation(); @@ -1682,6 +1711,7 @@ mod tests { ); } + #[cfg(feature = "client")] #[test] fn shared_client_transports_one_exact_encrypted_request_and_result() { let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); diff --git a/src/provider_backend.rs b/src/provider_backend.rs index 91c4870..6776e86 100644 --- a/src/provider_backend.rs +++ b/src/provider_backend.rs @@ -500,7 +500,7 @@ fn parse_responses_sse(reader: impl Read) -> Result Result &'static str { +fn provider_sse_read_error(error: &io::Error) -> &'static str { + if let Some(ureq_error) = error + .get_ref() + .and_then(|source| source.downcast_ref::()) + { + return match ureq_error { + ureq::Error::Timeout(_) => "provider SSE read timed out", + ureq::Error::Protocol(_) => "provider SSE protocol failed", + ureq::Error::Tls(_) | ureq::Error::Rustls(_) => "provider SSE secure transport failed", + ureq::Error::BodyStalled => "provider SSE body stalled", + ureq::Error::Io(inner) => provider_sse_io_error(inner.kind()), + _ => "provider SSE read failed", + }; + } + provider_sse_io_error(error.kind()) +} + +fn provider_sse_io_error(kind: io::ErrorKind) -> &'static str { match kind { io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock => "provider SSE read timed out", io::ErrorKind::ConnectionReset @@ -1057,6 +1074,14 @@ mod tests { } } + struct WrappedFailingReader(Option); + + impl Read for WrappedFailingReader { + fn read(&mut self, _buf: &mut [u8]) -> io::Result { + Err(io::Error::other(self.0.take().unwrap())) + } + } + fn jwt(payload: Value) -> String { let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap()); format!("e30.{payload}.signature") @@ -1257,6 +1282,10 @@ mod tests { parse_responses_sse(FailingReader(io::ErrorKind::Other)).err(), Some("provider SSE read failed") ); + assert_eq!( + parse_responses_sse(WrappedFailingReader(Some(ureq::Error::BodyStalled))).err(), + Some("provider SSE body stalled") + ); } #[test]