Compare commits

...
Sign in to create a new pull request.
Author SHA1 Message Date
Meta Vens
6945061a0b Preserve safe SSE failure classes 2026-08-25 04:02:38 +02:00
Meta Vens
8c57be4b12 Document contract-only connector surface 2026-08-24 23:46:52 +02:00
Meta Vens
6dc80f6d26 Separate provider contract from connector client 2026-08-24 23:45:44 +02:00
5 changed files with 86 additions and 12 deletions

View file

@ -16,6 +16,12 @@ repair the store. Consumers own native requests, provider lowering, prompts,
schemas, tool execution, retry between passes, interpretation, decisions, and schemas, tool execution, retry between passes, interpretation, decisions, and
state admission. 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 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 Codex binary as an exact package input. The child receives auth RPC only, never
consumer identity, prompts, tools, provider requests, or model output. consumer identity, prompts, tools, provider requests, or model output.

View file

@ -17,8 +17,14 @@ path = "src/main.rs"
required-features = ["daemon"] required-features = ["daemon"]
[features] [features]
default = [] default = ["client"]
client = [
"dep:aes-gcm",
"dep:rand_core",
"dep:zeroize",
]
daemon = [ daemon = [
"client",
"dep:anyhow", "dep:anyhow",
"dep:chrono", "dep:chrono",
"dep:cultcache-rs", "dep:cultcache-rs",
@ -30,14 +36,14 @@ daemon = [
] ]
[dependencies] [dependencies]
aes-gcm = "0.10" aes-gcm = { version = "0.10", optional = true }
anyhow = { version = "1", optional = true } anyhow = { version = "1", optional = true }
base64 = "0.23" base64 = "0.23"
chrono = { version = "0.4", features = ["serde"], optional = true } chrono = { version = "0.4", features = ["serde"], optional = true }
cultcache-rs = { git = "https://github.com/GameCult/cultcache-rs.git", rev = "fdbf3bf0a4b3f6b617c224d7439feb8e69d4474c", 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 } cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "75c180782aeba7cfd22d6412877397708a4ed28f", optional = true }
ed25519-dalek = { version = "2.2", 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" rmp-serde = "1"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_bytes = { version = "0.11", optional = true } serde_bytes = { version = "0.11", optional = true }
@ -46,4 +52,4 @@ sha2 = "0.10"
thiserror = "2" thiserror = "2"
ureq = { version = "3.4", default-features = false, features = ["rustls"], optional = true } ureq = { version = "3.4", default-features = false, features = ["rustls"], optional = true }
uuid = { version = "1", features = ["v4"], optional = true } uuid = { version = "1", features = ["v4"], optional = true }
zeroize = "1" zeroize = { version = "1", optional = true }

View file

@ -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. child's private auth RPC completes, then performs the exact Responses request.
It owns no prompt policy or decision authority. It owns no prompt policy or decision authority.
The default library is the shared consumer ABI: v2 typed contracts, exact The library has three explicit compile surfaces. With no features it exposes
provider lowering, encryption, bounded direct-pipe framing, one-request socket only the v2 provider/result contracts and their deterministic validation. The
transport, and exact result validation. It does not compile CultCache, HTTP, default `client` feature adds encryption, bounded direct-pipe framing, and the
TLS, provider auth, replay, or daemon code. Epiphany and Ghostlight consume this one-request socket client. It still compiles no CultCache, HTTP, TLS, provider
surface instead of copying wire law. 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 Connection establishment is bounded independently. Response read/write timeout
is optional so a consumer's typed outer pass may remain the sole deadline owner. 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 pinned deployment input, keeping Codex's application build graph outside both
consumers and this small daemon. consumers and this small daemon.
Focused verification uses the two real build surfaces: Focused verification proves each real build surface independently:
```text ```text
cargo test --lib --no-default-features
cargo test --lib cargo test --lib
cargo test --lib --features daemon cargo test --lib --features daemon
cargo check --bin codex-connector --features daemon cargo check --bin codex-connector --features daemon

View file

@ -1,6 +1,9 @@
use std::collections::HashSet; use std::collections::HashSet;
#[cfg(feature = "client")]
use std::io::{Read, Write}; use std::io::{Read, Write};
#[cfg(feature = "client")]
use std::net::{SocketAddr, TcpStream}; use std::net::{SocketAddr, TcpStream};
#[cfg(feature = "client")]
use std::time::Duration; use std::time::Duration;
#[cfg(feature = "daemon")] #[cfg(feature = "daemon")]
@ -8,14 +11,18 @@ use std::collections::HashMap;
#[cfg(feature = "daemon")] #[cfg(feature = "daemon")]
use std::path::Path; use std::path::Path;
#[cfg(feature = "client")]
use aes_gcm::aead::{Aead, Payload}; use aes_gcm::aead::{Aead, Payload};
#[cfg(feature = "client")]
use aes_gcm::{Aes256Gcm, KeyInit, Nonce}; use aes_gcm::{Aes256Gcm, KeyInit, Nonce};
#[cfg(feature = "daemon")] #[cfg(feature = "daemon")]
use cultcache_rs::{CultCache, DatabaseEntry, OwnedRedbMessagePackBackingStore}; use cultcache_rs::{CultCache, DatabaseEntry, OwnedRedbMessagePackBackingStore};
#[cfg(feature = "client")]
use rand_core::{OsRng, RngCore}; use rand_core::{OsRng, RngCore};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use thiserror::Error; use thiserror::Error;
#[cfg(feature = "client")]
use zeroize::{Zeroize, Zeroizing}; use zeroize::{Zeroize, Zeroizing};
#[cfg(feature = "daemon")] #[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 RECEIPT_SCHEMA_ID: &str = "gamecult.codex.transport_receipt.v2";
pub const ENVELOPE_SCHEMA_ID: &str = "gamecult.codex.transport_envelope.v2"; pub const ENVELOPE_SCHEMA_ID: &str = "gamecult.codex.transport_envelope.v2";
#[cfg(feature = "client")]
#[derive(Clone, PartialEq, Eq)] #[derive(Clone, PartialEq, Eq)]
pub struct CodexTransportKey([u8; 32]); pub struct CodexTransportKey([u8; 32]);
#[cfg(feature = "client")]
impl CodexTransportKey { impl CodexTransportKey {
pub fn from_connection_secret(secret: &str) -> Result<Self, ServiceError> { pub fn from_connection_secret(secret: &str) -> Result<Self, ServiceError> {
if secret.trim().is_empty() || secret.trim() != secret { if secret.trim().is_empty() || secret.trim() != secret {
@ -71,6 +80,7 @@ impl CodexTransportKey {
} }
} }
#[cfg(feature = "client")]
impl Drop for CodexTransportKey { impl Drop for CodexTransportKey {
fn drop(&mut self) { fn drop(&mut self) {
self.0.zeroize(); self.0.zeroize();
@ -634,12 +644,14 @@ pub fn canonical_invocation_bytes(
rmp_serde::to_vec(invocation).map_err(|_| ContractError::Encoding) rmp_serde::to_vec(invocation).map_err(|_| ContractError::Encoding)
} }
#[cfg(feature = "client")]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum CodexEnvelopeKind { pub enum CodexEnvelopeKind {
Invocation, Invocation,
Result, Result,
} }
#[cfg(feature = "client")]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CodexTransportEnvelope { pub struct CodexTransportEnvelope {
pub schema_id: String, pub schema_id: String,
@ -650,6 +662,7 @@ pub struct CodexTransportEnvelope {
pub ciphertext: Vec<u8>, pub ciphertext: Vec<u8>,
} }
#[cfg(feature = "client")]
pub fn encrypt_invocation( pub fn encrypt_invocation(
invocation: &CodexTransportInvocation, invocation: &CodexTransportInvocation,
security: &CodexTransportKey, security: &CodexTransportKey,
@ -664,6 +677,7 @@ pub fn encrypt_invocation(
) )
} }
#[cfg(feature = "client")]
pub fn decrypt_invocation( pub fn decrypt_invocation(
envelope: &CodexTransportEnvelope, envelope: &CodexTransportEnvelope,
security: &CodexTransportKey, security: &CodexTransportKey,
@ -680,6 +694,7 @@ pub fn decrypt_invocation(
Ok(invocation) Ok(invocation)
} }
#[cfg(feature = "client")]
pub fn encrypt_result( pub fn encrypt_result(
result: &CodexTransportResult, result: &CodexTransportResult,
security: &CodexTransportKey, security: &CodexTransportKey,
@ -694,6 +709,7 @@ pub fn encrypt_result(
) )
} }
#[cfg(feature = "client")]
pub fn decrypt_result( pub fn decrypt_result(
envelope: &CodexTransportEnvelope, envelope: &CodexTransportEnvelope,
security: &CodexTransportKey, security: &CodexTransportKey,
@ -712,6 +728,7 @@ pub fn decrypt_result(
Ok(result) Ok(result)
} }
#[cfg(feature = "client")]
#[derive(Clone)] #[derive(Clone)]
pub struct CodexConnectorClient { pub struct CodexConnectorClient {
endpoint: SocketAddr, endpoint: SocketAddr,
@ -720,6 +737,7 @@ pub struct CodexConnectorClient {
response_timeout: Option<Duration>, response_timeout: Option<Duration>,
} }
#[cfg(feature = "client")]
impl CodexConnectorClient { impl CodexConnectorClient {
pub fn new( pub fn new(
endpoint: SocketAddr, endpoint: SocketAddr,
@ -770,6 +788,7 @@ impl CodexConnectorClient {
} }
} }
#[cfg(feature = "client")]
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum CodexConnectorClientError { pub enum CodexConnectorClientError {
#[error("invalid connector client configuration")] #[error("invalid connector client configuration")]
@ -784,12 +803,14 @@ pub enum CodexConnectorClientError {
Transport(#[from] ServiceError), Transport(#[from] ServiceError),
} }
#[cfg(feature = "client")]
#[derive(Debug)] #[derive(Debug)]
enum TransportFrameError { enum TransportFrameError {
Connection(std::io::Error), Connection(std::io::Error),
Size, Size,
} }
#[cfg(feature = "client")]
fn client_frame_error(error: TransportFrameError) -> CodexConnectorClientError { fn client_frame_error(error: TransportFrameError) -> CodexConnectorClientError {
match error { match error {
TransportFrameError::Connection(error) => CodexConnectorClientError::Connection(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( fn read_transport_frame(
reader: &mut impl Read, reader: &mut impl Read,
max_frame_bytes: usize, max_frame_bytes: usize,
@ -816,6 +838,7 @@ fn read_transport_frame(
Ok(payload) Ok(payload)
} }
#[cfg(feature = "client")]
fn write_transport_frame( fn write_transport_frame(
writer: &mut impl Write, writer: &mut impl Write,
payload: &[u8], payload: &[u8],
@ -831,6 +854,7 @@ fn write_transport_frame(
.map_err(TransportFrameError::Connection) .map_err(TransportFrameError::Connection)
} }
#[cfg(feature = "client")]
fn encrypt_message( fn encrypt_message(
caller_runtime_id: &str, caller_runtime_id: &str,
request_id: &str, request_id: &str,
@ -868,6 +892,7 @@ fn encrypt_message(
}) })
} }
#[cfg(feature = "client")]
fn decrypt_message( fn decrypt_message(
envelope: &CodexTransportEnvelope, envelope: &CodexTransportEnvelope,
security: &CodexTransportKey, security: &CodexTransportKey,
@ -890,6 +915,7 @@ fn decrypt_message(
.map_err(|_| ServiceError::Encryption) .map_err(|_| ServiceError::Encryption)
} }
#[cfg(feature = "client")]
fn envelope_aad( fn envelope_aad(
schema_id: &str, schema_id: &str,
caller_runtime_id: &str, caller_runtime_id: &str,
@ -900,6 +926,7 @@ fn envelope_aad(
.map_err(|_| ServiceError::Encoding) .map_err(|_| ServiceError::Encoding)
} }
#[cfg(feature = "client")]
fn validate_envelope( fn validate_envelope(
envelope: &CodexTransportEnvelope, envelope: &CodexTransportEnvelope,
expected_kind: CodexEnvelopeKind, expected_kind: CodexEnvelopeKind,
@ -1643,6 +1670,7 @@ mod tests {
.unwrap() .unwrap()
} }
#[cfg(feature = "client")]
fn security(key: &str) -> CodexTransportKey { fn security(key: &str) -> CodexTransportKey {
CodexTransportKey::from_connection_secret(key).unwrap() CodexTransportKey::from_connection_secret(key).unwrap()
} }
@ -1654,6 +1682,7 @@ mod tests {
CodexTransportInvocation::new(caller_runtime_id, 2_000, [7; 32], request).unwrap() CodexTransportInvocation::new(caller_runtime_id, 2_000, [7; 32], request).unwrap()
} }
#[cfg(feature = "client")]
#[test] #[test]
fn encrypted_invocation_hides_content_and_binds_outer_identity() { fn encrypted_invocation_hides_content_and_binds_outer_identity() {
let invocation = invocation(); let invocation = invocation();
@ -1682,6 +1711,7 @@ mod tests {
); );
} }
#[cfg(feature = "client")]
#[test] #[test]
fn shared_client_transports_one_exact_encrypted_request_and_result() { fn shared_client_transports_one_exact_encrypted_request_and_result() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();

View file

@ -500,7 +500,7 @@ fn parse_responses_sse(reader: impl Read) -> Result<ParsedProviderStream, &'stat
.by_ref() .by_ref()
.take((MAX_SSE_FRAME_BYTES + 1) as u64) .take((MAX_SSE_FRAME_BYTES + 1) as u64)
.read_until(b'\n', &mut line) .read_until(b'\n', &mut line)
.map_err(|error| provider_sse_read_error(error.kind()))?; .map_err(|error| provider_sse_read_error(&error))?;
if read == 0 { if read == 0 {
if !data.is_empty() { if !data.is_empty() {
state.accept_frame(&data)?; state.accept_frame(&data)?;
@ -544,7 +544,24 @@ fn parse_responses_sse(reader: impl Read) -> Result<ParsedProviderStream, &'stat
}) })
} }
fn provider_sse_read_error(kind: io::ErrorKind) -> &'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::<ureq::Error>())
{
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 { match kind {
io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock => "provider SSE read timed out", io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock => "provider SSE read timed out",
io::ErrorKind::ConnectionReset io::ErrorKind::ConnectionReset
@ -1057,6 +1074,14 @@ mod tests {
} }
} }
struct WrappedFailingReader(Option<ureq::Error>);
impl Read for WrappedFailingReader {
fn read(&mut self, _buf: &mut [u8]) -> io::Result<usize> {
Err(io::Error::other(self.0.take().unwrap()))
}
}
fn jwt(payload: Value) -> String { fn jwt(payload: Value) -> String {
let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap()); let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap());
format!("e30.{payload}.signature") format!("e30.{payload}.signature")
@ -1257,6 +1282,10 @@ mod tests {
parse_responses_sse(FailingReader(io::ErrorKind::Other)).err(), parse_responses_sse(FailingReader(io::ErrorKind::Other)).err(),
Some("provider SSE read failed") Some("provider SSE read failed")
); );
assert_eq!(
parse_responses_sse(WrappedFailingReader(Some(ureq::Error::BodyStalled))).err(),
Some("provider SSE body stalled")
);
} }
#[test] #[test]