Skip to content
Open
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
94 changes: 94 additions & 0 deletions .ase-project.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
{
"name": "claude-code-proxy",
"version": "0.1.48",
"description": "Anthropic-compatible proxy bridging Claude Code to Codex, Kimi, Grok, OpenCode Go, and Cursor Agent backends.",
"validation_timeout_ms": 300000,
"tech_stack": [
"Rust 2024",
"axum 0.8",
"tokio (rt-multi-thread)",
"reqwest",
"prost",
"ratatui",
"tokio-tungstenite"
],
"conventions": [
"Strict type safety and error-fidelity matching Anthropic Messages API specs",
"No blocking operations on Tokio async runtime worker threads",
"Security-first: constant-time token comparison, Origin/Host validation, atomic file permissions",
"On-demand daemon architecture with clean shutdown"
],
"architecture_patterns": [
"Provider abstraction with translation layers and streaming reducers",
"Shared session management with composite lineage-aware state keys",
"SSE state machine and WebSocket protocol bridging"
],
"file_structure": {
"src/app": "Next.js App Router pages and layouts",
"src/features": "Feature modules with co-located components, hooks, and types",
"src/lib": "Shared utilities, database client, auth helpers",
"src/components/ui": "Reusable UI primitives (Button, Modal, Input)",
"tests": "Test files mirroring src/ structure"
},
"testing_strategy": {
"framework": "Vitest + React Testing Library",
"coverage_target": "80%",
"patterns": [
"Unit tests for all utility functions",
"Integration tests for API routes",
"Component tests for interactive UI elements"
]
},
"deployment": {
"platform": "Vercel",
"branch_strategy": "main → production, dev → preview",
"environments": [
"development",
"staging",
"production"
]
},
"sprint_focus": "Building the user onboarding flow with email verification and profile setup.",
"sprint_skills": [
"security-and-hardening",
"api-and-interface-design",
"test-driven-development"
],
"never_do": [
"Never use useEffect for data fetching — use Server Components or SWR",
"Never store secrets in .env files committed to git",
"Never use inline styles — always use Tailwind classes",
"Never use `any` TypeScript type — always type explicitly",
"Never mix database queries into React components",
"Never output verbose unsolicited recaps, exhaustive changelogs, or repetitive summaries that waste tokens — mention ONLY the most critical points unless explicitly asked",
"Never use purple gradients, pill buttons, fake reviews/metrics/counters, vague hero text, emoji icons, em dashes, crazy scroll/cursor animations, or AI slop photos/copy on websites",
"Never let an AI grade its own work without independent gate verification",
"Never leave completed work uncommitted or unpushed to GitHub",
"Never run unsolicited background daemons, update pollers, or indexing services on Arch Linux"
],
"preferred_patterns": [
"Practice extreme token economy: keep responses concise, high-signal, and directly answer user requests without unprompted changelogs or filler",
"Use Zod for all schema validation",
"Use Drizzle ORM for all database access",
"Use server actions for mutations",
"Use Next.js Image component for all images",
"Always mandate production web essentials: custom domain, custom favicon suite, remove AI badges, /privacy page, and /terms page",
"Always follow the 6-step Second Brain closed loop: Load Conventions -> Select Model -> Autonomous Loop -> Validate Output -> Continuous Learning -> Automated Git Push",
"Always enforce Arch Linux On-Demand System Governance (run interactive tools on-demand, terminate completely on exit)"
],
"agent_skills": {
"directory": "/home/thor/skiils/skills",
"default_skills": [
"using-agent-skills",
"context-engineering",
"anti-vibe-design"
],
"mandatory_skills": {
"api": "api-and-interface-design",
"auth": "security-and-hardening",
"frontend": "anti-vibe-design",
"ui": "anti-vibe-design"
},
"session_reporting_preference": true
}
}
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ sha2 = "0.10"
hostname = "0.4"
once_cell = "1.20"
regex-lite = "0.1"
subtle = "2.6"
libc = "0.2"
tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "rustls-tls-native-roots"] }
tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12"] }
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
Expand Down
114 changes: 114 additions & 0 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -328,13 +328,127 @@ pub fn delete_auth_file(primary: &std::path::Path, legacy: &std::path::Path) ->
Ok(())
}

pub struct FileLock {
#[cfg(unix)]
fd: std::os::unix::io::RawFd,
#[cfg(unix)]
_file: Option<File>,
}

impl FileLock {
pub fn acquire(lock_path: &std::path::Path) -> io::Result<Self> {
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
use std::os::unix::io::AsRawFd;
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.open(lock_path)?;
let fd = file.as_raw_fd();
let ret = unsafe { libc::flock(fd, libc::LOCK_EX) };
if ret != 0 {
return Err(io::Error::last_os_error());
}
Ok(Self { fd, _file: Some(file) })
}
#[cfg(not(unix))]
{
Ok(Self {})
}
}

pub fn try_acquire(lock_path: &std::path::Path) -> io::Result<Option<Self>> {
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
use std::os::unix::io::AsRawFd;
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.mode(0o600)
.open(lock_path)?;
let fd = file.as_raw_fd();
let ret = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
if ret != 0 {
let err = io::Error::last_os_error();
if err.raw_os_error() == Some(libc::EWOULDBLOCK) {
return Ok(None);
}
return Err(err);
}
Ok(Some(Self { fd, _file: Some(file) }))
}
#[cfg(not(unix))]
{
Ok(Some(Self {}))
}
}
}

#[cfg(unix)]
impl Drop for FileLock {
fn drop(&mut self) {
if self.fd >= 0 {
unsafe {
libc::flock(self.fd, libc::LOCK_UN);
}
}
}
}

pub fn acquire_lock(path: &str) -> io::Result<FileLock> {
if path == "memory" || path.is_empty() {
return Ok(FileLock {
#[cfg(unix)]
fd: -1,
#[cfg(unix)]
_file: None,
});
}
let lock_path = format!("{path}.lock");
FileLock::acquire(std::path::Path::new(&lock_path))
}

pub async fn acquire_lock_async(path: &str) -> io::Result<FileLock> {
if path == "memory" || path.is_empty() {
return Ok(FileLock {
#[cfg(unix)]
fd: -1,
#[cfg(unix)]
_file: None,
});
}
let lock_path = format!("{path}.lock");
let path_buf = std::path::PathBuf::from(lock_path);
// Non-blocking poll loop with async sleep so Tokio worker thread is never parked
for _ in 0..100 {
if let Some(lock) = FileLock::try_acquire(&path_buf)? {
return Ok(lock);
}
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
}
// Fallback to spawn_blocking if lock is held across a longer window
tokio::task::spawn_blocking(move || FileLock::acquire(&path_buf))
.await
.map_err(io::Error::other)?
}

pub fn write_atomically<T: Serialize>(path: &str, value: &T) -> Result<()> {
let dir = std::path::Path::new(path)
.parent()
.ok_or_else(|| anyhow::anyhow!("invalid auth path"))?;
fs::create_dir_all(dir)?;
set_mode(dir, 0o700);

let lock_path = format!("{path}.lock");
let _lock = FileLock::acquire(std::path::Path::new(&lock_path))?;

let tmp = format!("{path}.tmp-{}", uuid::Uuid::new_v4());
#[cfg(unix)]
let mut out = {
Expand Down
68 changes: 68 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ struct FileConfig {
pub alias_provider: Option<String>,
#[serde(rename = "autoReviewModel")]
pub auto_review_model: Option<String>,
#[serde(rename = "authToken")]
pub auth_token: Option<String>,
#[serde(rename = "requireAuth")]
pub require_auth: Option<bool>,
pub log: Option<FileLog>,
pub kimi: Option<KimiConfig>,
pub codex: Option<CodexConfig>,
Expand Down Expand Up @@ -244,6 +248,70 @@ pub fn log_stderr() -> bool {
load_config().log_stderr
}

pub fn local_auth_token() -> Option<String> {
let env: HashMap<_, _> = std::env::vars().collect();
if let Some(raw) = env.get("CCP_LOCAL_AUTH_TOKEN").or_else(|| env.get("CCP_AUTH_TOKEN")) {
let trimmed = raw.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
let config_dir = paths::config_dir();
if let Some(file) = read_file_config(&config_dir)
&& let Some(token) = file.auth_token
{
let trimmed = token.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
let state_file = paths::state_dir().join("local_auth_token");
if let Ok(token) = fs::read_to_string(state_file) {
let trimmed = token.trim();
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
None
}

pub fn require_local_auth() -> bool {
let env: HashMap<_, _> = std::env::vars().collect();
if let Some(raw) = env.get("CCP_REQUIRE_AUTH") {
return matches!(raw.to_ascii_lowercase().as_str(), "1" | "true" | "yes");
}
let config_dir = paths::config_dir();
if let Some(file) = read_file_config(&config_dir)
&& let Some(enabled) = file.require_auth
{
return enabled;
}
false
}

pub fn ensure_local_auth_token() -> Option<String> {
if let Some(token) = local_auth_token() {
return Some(token);
}
if !require_local_auth() {
return None;
}
let state_dir = paths::state_dir();
let _ = fs::create_dir_all(&state_dir);
let token = format!("ccp_{}", uuid::Uuid::new_v4().to_string().replace('-', ""));
let state_file = state_dir.join("local_auth_token");
if let Ok(()) = fs::write(&state_file, &token) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(&state_file, fs::Permissions::from_mode(0o600));
}
Some(token)
} else {
None
}
}

pub fn config_override_summary_lines(cfg: &LoadedConfig) -> Vec<String> {
let file = read_file_config(&cfg.config_dir);
let env: HashMap<_, _> = std::env::vars().collect();
Expand Down
17 changes: 15 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,16 @@ async fn run_service(config: ServerConfig) -> Result<()> {
let _ = stopped.await;
});
tokio::pin!(server);
tokio::select! {

let debounce_task = tokio::spawn(async {
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(5));
loop {
interval.tick().await;
claude_code_proxy::providers::codex::persist_compaction_states_if_dirty();
}
});

let outcome = tokio::select! {
result = &mut server => result,
signal = signals.recv() => {
signal?;
Expand All @@ -209,11 +218,15 @@ async fn run_service(config: ServerConfig) -> Result<()> {
result = &mut server => result,
signal = signals.recv() => {
signal?;
claude_code_proxy::providers::codex::persist_compaction_states();
std::process::exit(130);
}
}
}
}
};
debounce_task.abort();
claude_code_proxy::providers::codex::persist_compaction_states();
outcome
}

#[cfg(unix)]
Expand Down
7 changes: 5 additions & 2 deletions src/providers/codex/auth/manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,8 +86,11 @@ impl<S: AuthStorage<StoredAuth>> CodexAuthManager<S> {
) -> Result<StoredAuth, anyhow::Error> {
let _refresh_guard = self.refresh_lock.lock().await;

// Reload from durable storage after acquiring the single-flight lock.
// Another request may have rotated and persisted the token while this
let auth_path = self.store.auth_path();
let _file_lock = crate::auth::acquire_lock_async(&auth_path).await.ok();

// Reload from durable storage after acquiring the single-flight cross-process lock.
// Another process or request may have rotated and persisted the token while this
// caller was waiting.
let current = self
.load_auth()?
Expand Down
Loading