This release represents a comprehensive transformation of the codebase from Codex to LLMX, enhanced with LiteLLM integration to support 100+ LLM providers through a unified API. ## Major Changes ### Phase 1: Repository & Infrastructure Setup - Established new repository structure and branching strategy - Created comprehensive project documentation (CLAUDE.md, LITELLM-SETUP.md) - Set up development environment and tooling configuration ### Phase 2: Rust Workspace Transformation - Renamed all Rust crates from `codex-*` to `llmx-*` (30+ crates) - Updated package names, binary names, and workspace members - Renamed core modules: codex.rs → llmx.rs, codex_delegate.rs → llmx_delegate.rs - Updated all internal references, imports, and type names - Renamed directories: codex-rs/ → llmx-rs/, codex-backend-openapi-models/ → llmx-backend-openapi-models/ - Fixed all Rust compilation errors after mass rename ### Phase 3: LiteLLM Integration - Integrated LiteLLM for multi-provider LLM support (Anthropic, OpenAI, Azure, Google AI, AWS Bedrock, etc.) - Implemented OpenAI-compatible Chat Completions API support - Added model family detection and provider-specific handling - Updated authentication to support LiteLLM API keys - Renamed environment variables: OPENAI_BASE_URL → LLMX_BASE_URL - Added LLMX_API_KEY for unified authentication - Enhanced error handling for Chat Completions API responses - Implemented fallback mechanisms between Responses API and Chat Completions API ### Phase 4: TypeScript/Node.js Components - Renamed npm package: @codex/codex-cli → @valknar/llmx - Updated TypeScript SDK to use new LLMX APIs and endpoints - Fixed all TypeScript compilation and linting errors - Updated SDK tests to support both API backends - Enhanced mock server to handle multiple API formats - Updated build scripts for cross-platform packaging ### Phase 5: Configuration & Documentation - Updated all configuration files to use LLMX naming - Rewrote README and documentation for LLMX branding - Updated config paths: ~/.codex/ → ~/.llmx/ - Added comprehensive LiteLLM setup guide - Updated all user-facing strings and help text - Created release plan and migration documentation ### Phase 6: Testing & Validation - Fixed all Rust tests for new naming scheme - Updated snapshot tests in TUI (36 frame files) - Fixed authentication storage tests - Updated Chat Completions payload and SSE tests - Fixed SDK tests for new API endpoints - Ensured compatibility with Claude Sonnet 4.5 model - Fixed test environment variables (LLMX_API_KEY, LLMX_BASE_URL) ### Phase 7: Build & Release Pipeline - Updated GitHub Actions workflows for LLMX binary names - Fixed rust-release.yml to reference llmx-rs/ instead of codex-rs/ - Updated CI/CD pipelines for new package names - Made Apple code signing optional in release workflow - Enhanced npm packaging resilience for partial platform builds - Added Windows sandbox support to workspace - Updated dotslash configuration for new binary names ### Phase 8: Final Polish - Renamed all assets (.github images, labels, templates) - Updated VSCode and DevContainer configurations - Fixed all clippy warnings and formatting issues - Applied cargo fmt and prettier formatting across codebase - Updated issue templates and pull request templates - Fixed all remaining UI text references ## Technical Details **Breaking Changes:** - Binary name changed from `codex` to `llmx` - Config directory changed from `~/.codex/` to `~/.llmx/` - Environment variables renamed (CODEX_* → LLMX_*) - npm package renamed to `@valknar/llmx` **New Features:** - Support for 100+ LLM providers via LiteLLM - Unified authentication with LLMX_API_KEY - Enhanced model provider detection and handling - Improved error handling and fallback mechanisms **Files Changed:** - 578 files modified across Rust, TypeScript, and documentation - 30+ Rust crates renamed and updated - Complete rebrand of UI, CLI, and documentation - All tests updated and passing **Dependencies:** - Updated Cargo.lock with new package names - Updated npm dependencies in llmx-cli - Enhanced OpenAPI models for LLMX backend This release establishes LLMX as a standalone project with comprehensive LiteLLM integration, maintaining full backward compatibility with existing functionality while opening support for a wide ecosystem of LLM providers. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> Co-Authored-By: Sebastian Krüger <support@pivoine.art>
323 lines
9.8 KiB
Rust
323 lines
9.8 KiB
Rust
#![cfg(unix)]
|
||
use llmx_core::protocol::SandboxPolicy;
|
||
use llmx_core::spawn::StdioPolicy;
|
||
use std::collections::HashMap;
|
||
use std::future::Future;
|
||
use std::io;
|
||
use std::path::Path;
|
||
use std::path::PathBuf;
|
||
use std::process::ExitStatus;
|
||
use tokio::fs::create_dir_all;
|
||
use tokio::process::Child;
|
||
|
||
#[cfg(target_os = "macos")]
|
||
async fn spawn_command_under_sandbox(
|
||
command: Vec<String>,
|
||
command_cwd: PathBuf,
|
||
sandbox_policy: &SandboxPolicy,
|
||
sandbox_cwd: &Path,
|
||
stdio_policy: StdioPolicy,
|
||
env: HashMap<String, String>,
|
||
) -> std::io::Result<Child> {
|
||
use llmx_core::seatbelt::spawn_command_under_seatbelt;
|
||
spawn_command_under_seatbelt(
|
||
command,
|
||
command_cwd,
|
||
sandbox_policy,
|
||
sandbox_cwd,
|
||
stdio_policy,
|
||
env,
|
||
)
|
||
.await
|
||
}
|
||
|
||
#[cfg(target_os = "linux")]
|
||
async fn spawn_command_under_sandbox(
|
||
command: Vec<String>,
|
||
command_cwd: PathBuf,
|
||
sandbox_policy: &SandboxPolicy,
|
||
sandbox_cwd: &Path,
|
||
stdio_policy: StdioPolicy,
|
||
env: HashMap<String, String>,
|
||
) -> std::io::Result<Child> {
|
||
use llmx_core::landlock::spawn_command_under_linux_sandbox;
|
||
let llmx_linux_sandbox_exe = assert_cmd::cargo::cargo_bin("llmx-exec");
|
||
spawn_command_under_linux_sandbox(
|
||
llmx_linux_sandbox_exe,
|
||
command,
|
||
command_cwd,
|
||
sandbox_policy,
|
||
sandbox_cwd,
|
||
stdio_policy,
|
||
env,
|
||
)
|
||
.await
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn python_multiprocessing_lock_works_under_sandbox() {
|
||
core_test_support::skip_if_sandbox!();
|
||
#[cfg(target_os = "macos")]
|
||
let writable_roots = Vec::<PathBuf>::new();
|
||
|
||
// From https://man7.org/linux/man-pages/man7/sem_overview.7.html
|
||
//
|
||
// > On Linux, named semaphores are created in a virtual filesystem,
|
||
// > normally mounted under /dev/shm.
|
||
#[cfg(target_os = "linux")]
|
||
let writable_roots = vec![PathBuf::from("/dev/shm")];
|
||
|
||
let policy = SandboxPolicy::WorkspaceWrite {
|
||
writable_roots,
|
||
network_access: false,
|
||
exclude_tmpdir_env_var: false,
|
||
exclude_slash_tmp: false,
|
||
};
|
||
|
||
let python_code = r#"import multiprocessing
|
||
from multiprocessing import Lock, Process
|
||
|
||
def f(lock):
|
||
with lock:
|
||
print("Lock acquired in child process")
|
||
|
||
if __name__ == '__main__':
|
||
lock = Lock()
|
||
p = Process(target=f, args=(lock,))
|
||
p.start()
|
||
p.join()
|
||
"#;
|
||
|
||
let command_cwd = std::env::current_dir().expect("should be able to get current dir");
|
||
let sandbox_cwd = command_cwd.clone();
|
||
let mut child = spawn_command_under_sandbox(
|
||
vec![
|
||
"python3".to_string(),
|
||
"-c".to_string(),
|
||
python_code.to_string(),
|
||
],
|
||
command_cwd,
|
||
&policy,
|
||
sandbox_cwd.as_path(),
|
||
StdioPolicy::Inherit,
|
||
HashMap::new(),
|
||
)
|
||
.await
|
||
.expect("should be able to spawn python under sandbox");
|
||
|
||
let status = child.wait().await.expect("should wait for child process");
|
||
assert!(status.success(), "python exited with {status:?}");
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn sandbox_distinguishes_command_and_policy_cwds() {
|
||
core_test_support::skip_if_sandbox!();
|
||
let temp = tempfile::tempdir().expect("should be able to create temp dir");
|
||
let sandbox_root = temp.path().join("sandbox");
|
||
let command_root = temp.path().join("command");
|
||
create_dir_all(&sandbox_root).await.expect("mkdir");
|
||
create_dir_all(&command_root).await.expect("mkdir");
|
||
let canonical_sandbox_root = tokio::fs::canonicalize(&sandbox_root)
|
||
.await
|
||
.expect("canonicalize sandbox root");
|
||
let canonical_allowed_path = canonical_sandbox_root.join("allowed.txt");
|
||
|
||
let disallowed_path = command_root.join("forbidden.txt");
|
||
|
||
// Note writable_roots is empty: verify that `canonical_allowed_path` is
|
||
// writable only because it is under the sandbox policy cwd, not because it
|
||
// is under a writable root.
|
||
let policy = SandboxPolicy::WorkspaceWrite {
|
||
writable_roots: vec![],
|
||
network_access: false,
|
||
exclude_tmpdir_env_var: true,
|
||
exclude_slash_tmp: true,
|
||
};
|
||
|
||
// Attempt to write inside the command cwd, which is outside of the sandbox policy cwd.
|
||
let mut child = spawn_command_under_sandbox(
|
||
vec![
|
||
"bash".to_string(),
|
||
"-lc".to_string(),
|
||
"echo forbidden > forbidden.txt".to_string(),
|
||
],
|
||
command_root.clone(),
|
||
&policy,
|
||
canonical_sandbox_root.as_path(),
|
||
StdioPolicy::Inherit,
|
||
HashMap::new(),
|
||
)
|
||
.await
|
||
.expect("should spawn command writing to forbidden path");
|
||
|
||
let status = child
|
||
.wait()
|
||
.await
|
||
.expect("should wait for forbidden command");
|
||
assert!(
|
||
!status.success(),
|
||
"sandbox unexpectedly allowed writing to command cwd: {status:?}"
|
||
);
|
||
let forbidden_exists = tokio::fs::try_exists(&disallowed_path)
|
||
.await
|
||
.expect("try_exists failed");
|
||
assert!(
|
||
!forbidden_exists,
|
||
"forbidden path should not have been created"
|
||
);
|
||
|
||
// Writing to the sandbox policy cwd after changing directories into it should succeed.
|
||
let mut child = spawn_command_under_sandbox(
|
||
vec![
|
||
"/usr/bin/touch".to_string(),
|
||
canonical_allowed_path.to_string_lossy().into_owned(),
|
||
],
|
||
command_root,
|
||
&policy,
|
||
canonical_sandbox_root.as_path(),
|
||
StdioPolicy::Inherit,
|
||
HashMap::new(),
|
||
)
|
||
.await
|
||
.expect("should spawn command writing to sandbox root");
|
||
|
||
let status = child.wait().await.expect("should wait for allowed command");
|
||
assert!(
|
||
status.success(),
|
||
"sandbox blocked allowed write: {status:?}"
|
||
);
|
||
let allowed_exists = tokio::fs::try_exists(&canonical_allowed_path)
|
||
.await
|
||
.expect("try_exists allowed failed");
|
||
assert!(allowed_exists, "allowed path should exist");
|
||
}
|
||
|
||
fn unix_sock_body() {
|
||
unsafe {
|
||
let mut fds = [0i32; 2];
|
||
let r = libc::socketpair(libc::AF_UNIX, libc::SOCK_DGRAM, 0, fds.as_mut_ptr());
|
||
assert_eq!(
|
||
r,
|
||
0,
|
||
"socketpair(AF_UNIX, SOCK_DGRAM) failed: {}",
|
||
io::Error::last_os_error()
|
||
);
|
||
|
||
let msg = b"hello_unix";
|
||
// write() from one end (generic write is allowed)
|
||
let sent = libc::write(fds[0], msg.as_ptr() as *const libc::c_void, msg.len());
|
||
assert!(sent >= 0, "write() failed: {}", io::Error::last_os_error());
|
||
|
||
// recvfrom() on the other end. We don’t need the address for socketpair,
|
||
// so we pass null pointers for src address.
|
||
let mut buf = [0u8; 64];
|
||
let recvd = libc::recvfrom(
|
||
fds[1],
|
||
buf.as_mut_ptr() as *mut libc::c_void,
|
||
buf.len(),
|
||
0,
|
||
std::ptr::null_mut(),
|
||
std::ptr::null_mut(),
|
||
);
|
||
assert!(
|
||
recvd >= 0,
|
||
"recvfrom() failed: {}",
|
||
io::Error::last_os_error()
|
||
);
|
||
|
||
let recvd_slice = &buf[..(recvd as usize)];
|
||
assert_eq!(
|
||
recvd_slice,
|
||
&msg[..],
|
||
"payload mismatch: sent {} bytes, got {} bytes",
|
||
msg.len(),
|
||
recvd
|
||
);
|
||
|
||
// Also exercise AF_UNIX stream socketpair quickly to ensure AF_UNIX in general works.
|
||
let mut sfds = [0i32; 2];
|
||
let sr = libc::socketpair(libc::AF_UNIX, libc::SOCK_STREAM, 0, sfds.as_mut_ptr());
|
||
assert_eq!(
|
||
sr,
|
||
0,
|
||
"socketpair(AF_UNIX, SOCK_STREAM) failed: {}",
|
||
io::Error::last_os_error()
|
||
);
|
||
let snt2 = libc::write(sfds[0], msg.as_ptr() as *const libc::c_void, msg.len());
|
||
assert!(
|
||
snt2 >= 0,
|
||
"write(stream) failed: {}",
|
||
io::Error::last_os_error()
|
||
);
|
||
let mut b2 = [0u8; 64];
|
||
let rcv2 = libc::recv(sfds[1], b2.as_mut_ptr() as *mut libc::c_void, b2.len(), 0);
|
||
assert!(
|
||
rcv2 >= 0,
|
||
"recv(stream) failed: {}",
|
||
io::Error::last_os_error()
|
||
);
|
||
|
||
// Clean up
|
||
let _ = libc::close(sfds[0]);
|
||
let _ = libc::close(sfds[1]);
|
||
let _ = libc::close(fds[0]);
|
||
let _ = libc::close(fds[1]);
|
||
}
|
||
}
|
||
|
||
#[tokio::test]
|
||
async fn allow_unix_socketpair_recvfrom() {
|
||
run_code_under_sandbox(
|
||
"allow_unix_socketpair_recvfrom",
|
||
&SandboxPolicy::ReadOnly,
|
||
|| async { unix_sock_body() },
|
||
)
|
||
.await
|
||
.expect("should be able to reexec");
|
||
}
|
||
|
||
const IN_SANDBOX_ENV_VAR: &str = "IN_SANDBOX";
|
||
|
||
#[expect(clippy::expect_used)]
|
||
pub async fn run_code_under_sandbox<F, Fut>(
|
||
test_selector: &str,
|
||
policy: &SandboxPolicy,
|
||
child_body: F,
|
||
) -> io::Result<Option<ExitStatus>>
|
||
where
|
||
F: FnOnce() -> Fut + Send + 'static,
|
||
Fut: Future<Output = ()> + Send + 'static,
|
||
{
|
||
if std::env::var(IN_SANDBOX_ENV_VAR).is_err() {
|
||
let exe = std::env::current_exe()?;
|
||
let mut cmds = vec![exe.to_string_lossy().into_owned(), "--exact".into()];
|
||
let mut stdio_policy = StdioPolicy::RedirectForShellTool;
|
||
// Allow for us to pass forward --nocapture / use the right stdio policy.
|
||
if std::env::args().any(|a| a == "--nocapture") {
|
||
cmds.push("--nocapture".into());
|
||
stdio_policy = StdioPolicy::Inherit;
|
||
}
|
||
cmds.push(test_selector.into());
|
||
|
||
// Your existing launcher:
|
||
let command_cwd = std::env::current_dir().expect("should be able to get current dir");
|
||
let sandbox_cwd = command_cwd.clone();
|
||
let mut child = spawn_command_under_sandbox(
|
||
cmds,
|
||
command_cwd,
|
||
policy,
|
||
sandbox_cwd.as_path(),
|
||
stdio_policy,
|
||
HashMap::from([("IN_SANDBOX".into(), "1".into())]),
|
||
)
|
||
.await?;
|
||
|
||
let status = child.wait().await?;
|
||
Ok(Some(status))
|
||
} else {
|
||
// Child branch: run the provided body.
|
||
child_body().await;
|
||
Ok(None)
|
||
}
|
||
}
|