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
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.

View file

@ -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 }

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.
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

View file

@ -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<Self, ServiceError> {
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<u8>,
}
#[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<Duration>,
}
#[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();

View file

@ -500,7 +500,7 @@ fn parse_responses_sse(reader: impl Read) -> Result<ParsedProviderStream, &'stat
.by_ref()
.take((MAX_SSE_FRAME_BYTES + 1) as u64)
.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 !data.is_empty() {
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 {
io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock => "provider SSE read timed out",
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 {
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]