Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ RUN mkdir -p server/src clients/rust/src cxtx/src && \
COPY server/ ./server/
COPY clients/ ./clients/
COPY cxtx/ ./cxtx/
RUN touch server/src/main.rs && \
RUN find server/src clients/rust/src cxtx/src -type f -exec touch {} + && \
cargo build --release --manifest-path server/Cargo.toml

# ============================================
Expand Down
3 changes: 2 additions & 1 deletion cxtx/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ description = "CLI wrapper that captures claude/codex provider traffic and uploa
[dependencies]
anyhow = "1.0"
async-stream = "0.3"
axum = { version = "0.7", features = ["macros"] }
axum = { version = "0.7", features = ["macros", "ws"] }
base64 = "0.22"
bytes = "1.10"
chrono = { version = "0.4", features = ["clock", "serde"] }
Expand All @@ -20,6 +20,7 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1", features = ["fs", "io-util", "macros", "net", "process", "rt-multi-thread", "signal", "sync", "time"] }
tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] }
url = "2.5"
uuid = { version = "1.18", features = ["v4"] }

Expand Down
15 changes: 12 additions & 3 deletions cxtx/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,24 @@ cargo build --release -p cxtx

```bash
# Wrap Codex and send captured turns to the local CXDB HTTP endpoint
./target/release/cxtx codex -- --model gpt-5
./target/release/cxtx --local codex -- --model gpt-5

# Wrap Claude and send captured turns to the local CXDB HTTP endpoint
./target/release/cxtx --local claude -- --print stream

# Wrap Claude against a specific CXDB server
./target/release/cxtx --url http://127.0.0.1:9010 claude -- --print stream
```

`cxtx` preserves child stdin, stdout, stderr, and exit status. On successful execution it does not write wrapper-authored stdout. If CXDB is unavailable, it still launches the child, enters queued-delivery mode, and records delivery state in the local ledger until delivery recovers or shutdown drain completes.

For `codex`, `cxtx` now hardens the launch contract so interactive traffic stays on the local proxy path that OSS capture depends on. It injects both `OPENAI_*` and `CXTX_OPENAI_*` proxy base-url env vars, removes inherited upstream base-url overrides before spawning the child, and prepends websocket-disabling flags unless the caller already supplied explicit overrides.

## Resulting Artifacts

- CXDB receives canonical `system`, `user_input`, `assistant_turn`, and tool-related items for the wrapped session.
- Interactive `codex` Responses traffic no longer leaks wrapper/bootstrap scaffolding into uploaded `user_input` turns.
- Websocket-backed provider traffic is captured through the local proxy instead of bypassing ledger and CXDB upload paths.
- The first uploaded turn carries `ContextMetadata` and `Provenance`, so the context is queryable in CXDB listings.
- `cxtx` publishes the bundled canonical `cxdb.ConversationItem` registry descriptor automatically before the first append when the server does not already have it.
- Local evidence is written under `.scratch/cxtx/sessions/<stable-session-id>/`:
Expand All @@ -42,9 +49,11 @@ cargo build --release -p cxtx
- `failed to launch codex` or `failed to launch claude`:
- The child binary is missing from `PATH` or is not executable.
- `cxtx: CXDB ingest unavailable, entering queued-delivery mode`:
- The wrapper could not reach the configured `--url`. Check the CXDB server, but the child session is still running and the ledger will show queue state.
- The wrapper could not reach the configured default URL, `--local`, or `--url`. Check the CXDB server, but the child session is still running and the ledger will show queue state.
- No captured turns appear in CXDB:
- Confirm the child CLI honors the injected provider base URL variables. `cxtx` depends on those environment overrides for transparent capture.
- Confirm the child CLI honors the injected provider base URL variables. `codex` should see both the legacy `OPENAI_*` variables and the `CXTX_OPENAI_*` aliases, and `claude` should see the Anthropic/Claude base-url overrides.
- `codex` still reaches the public OpenAI endpoint directly:
- Check whether the caller explicitly re-enabled websocket features or overrode the proxy base URL in child args. `cxtx` only injects its websocket-disable defaults when the caller did not already make an explicit choice.

## Verification

Expand Down
51 changes: 49 additions & 2 deletions cxtx/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,29 @@ use clap::{Parser, Subcommand};

use crate::provider::ProviderKind;

pub const DEFAULT_LOCAL_CXDB_URL: &str = "http://127.0.0.1:9010";

#[derive(Debug, Clone, Parser)]
#[command(
name = "cxtx",
about = "Wrap claude or codex, capture provider traffic, and upload canonical conversation context to CXDB",
after_help = "Examples:\n cxtx codex -- --model gpt-5\n cxtx --url http://127.0.0.1:9010 claude -- --print stream"
after_help = "Examples:\n cxtx codex -- --model gpt-5\n cxtx --local claude -- --print stream\n cxtx --url http://127.0.0.1:9010 claude -- --print stream"
)]
pub struct Cli {
#[arg(
long,
default_value = "http://127.0.0.1:9010",
default_value = DEFAULT_LOCAL_CXDB_URL,
help = "CXDB HTTP base URL used for registry publication, context creation, and turn append"
)]
pub url: String,

#[arg(
long,
conflicts_with = "url",
help = "Use the local CXDB HTTP endpoint at http://127.0.0.1:9010"
)]
pub local: bool,

#[command(subcommand)]
pub command: Command,
}
Expand Down Expand Up @@ -50,14 +59,52 @@ impl Command {
}

impl Cli {
pub fn effective_url(&self) -> &str {
if self.local {
DEFAULT_LOCAL_CXDB_URL
} else {
&self.url
}
}

pub fn for_tests(provider: ProviderKind, args: Vec<String>, url: &str) -> Self {
let command = match provider {
ProviderKind::Claude => Command::Claude { args },
ProviderKind::Codex => Command::Codex { args },
};
Self {
url: url.to_string(),
local: false,
command,
}
}
}

#[cfg(test)]
mod tests {
use super::{Cli, Command, DEFAULT_LOCAL_CXDB_URL};
use clap::Parser;

#[test]
fn defaults_to_local_cxdb_url() {
let cli = Cli::parse_from(["cxtx", "codex"]);
assert_eq!(cli.effective_url(), DEFAULT_LOCAL_CXDB_URL);
assert!(!cli.local);
assert!(matches!(cli.command, Command::Codex { .. }));
}

#[test]
fn local_flag_switches_to_local_cxdb_url() {
let cli = Cli::parse_from(["cxtx", "--local", "claude"]);
assert_eq!(cli.effective_url(), DEFAULT_LOCAL_CXDB_URL);
assert!(cli.local);
assert!(matches!(cli.command, Command::Claude { .. }));
}

#[test]
fn local_flag_conflicts_with_explicit_url() {
let result =
Cli::try_parse_from(["cxtx", "--local", "--url", "http://example.test", "codex"]);
assert!(result.is_err());
}
}
28 changes: 21 additions & 7 deletions cxtx/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,22 +19,33 @@ use tokio::process::Command;

pub async fn run(cli: Cli) -> Result<i32> {
let provider = cli.command.provider();
let args = cli.command.args().to_vec();
let effective_url = cli.effective_url();
let cxdb_url = cli
.url
.effective_url()
.parse()
.with_context(|| format!("invalid CXDB URL: {}", cli.url))?;
.with_context(|| format!("invalid CXDB URL: {effective_url}"))?;

let upstream = provider
.resolve_upstream_base()
.context("failed to resolve provider upstream base URL")?;
let (listener, proxy_base_url) = ProxyServer::bind(provider, &upstream)
.await
.context("failed to reserve local reverse proxy listener")?;
let args = provider.child_args_for_proxy(cli.command.args(), Some(&proxy_base_url));
let allowlisted_env = provider.capture_env_allowlist();
let session = SessionRuntime::new(provider, args.clone(), allowlisted_env)?;
let ledger = SessionLedgerWriter::create(&session).await?;

let proxy = ProxyServer::start(provider, upstream, session.clone(), ledger.clone())
.await
.context("failed to start local reverse proxy")?;
let proxy = ProxyServer::start_with_listener(
provider,
upstream,
session.clone(),
ledger.clone(),
listener,
proxy_base_url.clone(),
)
.await
.context("failed to start local reverse proxy")?;
let delivery = DeliveryHandle::start(
cxdb_url,
session.clone(),
Expand All @@ -49,7 +60,10 @@ pub async fn run(cli: Cli) -> Result<i32> {
command.stdin(Stdio::inherit());
command.stdout(Stdio::inherit());
command.stderr(Stdio::inherit());
command.envs(provider.injected_env(&proxy.proxy_base_url()));
for name in provider.upstream_base_env_names() {
command.env_remove(name);
}
command.envs(provider.injected_env(&proxy_base_url));

let mut child = match command.spawn() {
Ok(child) => child,
Expand Down
46 changes: 45 additions & 1 deletion cxtx/src/provider/anthropic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,12 @@ fn parse_user_blocks(content: &Value) -> Result<Vec<HistoryItem>, String> {
Value::Array(blocks) => {
let mut history = Vec::new();
let mut text_buffer = String::new();
for block in blocks {
let content_start = blocks
.iter()
.enumerate()
.find_map(|(index, block)| (!is_bootstrap_user_block(block)).then_some(index))
.unwrap_or(blocks.len());
for block in blocks.iter().skip(content_start) {
match block.get("type").and_then(Value::as_str) {
Some("text") => {
let text = block
Expand Down Expand Up @@ -406,6 +411,23 @@ fn parse_user_blocks(content: &Value) -> Result<Vec<HistoryItem>, String> {
}
}

fn is_bootstrap_user_block(block: &Value) -> bool {
if block.get("type").and_then(Value::as_str) != Some("text") {
return false;
}

let text = block
.get("text")
.and_then(Value::as_str)
.unwrap_or_default()
.trim_start();

text.starts_with("<system-reminder>")
&& (text.contains("SessionStart hook additional context")
|| text.contains("The following skills are available for use with the Skill tool:")
|| text.contains("As you answer the user's questions, you can use the following context:"))
}

fn parse_assistant_content(
content: &Value,
model: Option<&str>,
Expand Down Expand Up @@ -540,6 +562,22 @@ mod tests {
}),
expected_kinds: vec!["user_input", "assistant_turn"],
},
Case {
name: "leading bootstrap reminders are skipped before the real prompt",
payload: json!({
"model": "claude-sonnet",
"messages": [
{"role": "user", "content": [
{"type": "text", "text": "<system-reminder>\nSessionStart hook additional context: ..."},
{"type": "text", "text": "<system-reminder>\nThe following skills are available for use with the Skill tool:\n..."},
{"type": "text", "text": "<system-reminder>\nAs you answer the user's questions, you can use the following context:\n..."},
{"type": "text", "text": "real prompt"}
]},
{"role": "assistant", "content": [{"type": "text", "text": "done"}]}
]
}),
expected_kinds: vec!["user_input", "assistant_turn"],
},
];

for case in cases {
Expand All @@ -553,6 +591,12 @@ mod tests {
})
.collect::<Vec<_>>();
assert_eq!(kinds, case.expected_kinds, "case {}", case.name);
if case.name == "leading bootstrap reminders are skipped before the real prompt" {
match &history[0] {
HistoryItem::UserInput { text, .. } => assert_eq!(text, "real prompt"),
other => panic!("expected user input, got {other:?}"),
}
}
}
}

Expand Down
Loading
Loading