mirror of
https://github.com/openai/codex.git
synced 2026-09-11 20:36:49 +00:00
## What changed - Add the macOS-only `allow_symlinked_codex_home` user setting. When enabled in the execution host's `$CODEX_HOME/config.toml`, writable roots at or beneath that home may traverse symlinks. - Keep the exception disabled by default and prevent project config, command-line overrides, and ignored user config from enabling it. Other writable roots remain subject to symlink checks. - Propagate the resolved setting through local, interactive, and exec-server sandbox paths, including `CODEX_HOME` aliases. - Explain the opt-out in symlink rejection errors, including that it trusts targets outside `CODEX_HOME` and targets that change between commands. ## Testing - Add macOS coverage for shell commands, patches, filesystem helpers, process execution, and interactive startup with enabled, disabled, aliased, and out-of-scope homes. GitOrigin-RevId: 99fcdf611200c9e1b7713cf06f7fdea5bfa7f089
181 lines
7.7 KiB
Rust
181 lines
7.7 KiB
Rust
//! Drives automatic reconnect through the real binary and terminal event loop.
|
|
|
|
use super::focus_palette::PtyCodex;
|
|
use super::focus_palette::write_test_config;
|
|
use anyhow::Result;
|
|
use anyhow::ensure;
|
|
use codex_app_server_protocol::JSONRPCMessage;
|
|
use futures::SinkExt;
|
|
use futures::StreamExt;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::json;
|
|
use std::time::Duration;
|
|
use std::time::Instant;
|
|
use tokio::net::UnixListener;
|
|
use tokio_tungstenite::tungstenite::Message;
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn automatic_reconnect_restores_draft_and_routes_new_notifications() -> Result<()> {
|
|
let repo_root = codex_utils_cargo_bin::repo_root()?;
|
|
// macOS's default temporary directory leaves too little room for the control socket path.
|
|
let codex_home = tempfile::tempdir_in("/tmp")?;
|
|
write_test_config(codex_home.path(), &repo_root)?;
|
|
let socket = codex_app_server_client::app_server_control_socket_path(codex_home.path())?;
|
|
std::fs::create_dir_all(socket.parent().unwrap())?;
|
|
let listener = UnixListener::bind(socket.as_path())?;
|
|
let (disconnect_tx, mut disconnect_rx) = tokio::sync::oneshot::channel();
|
|
let (restore_tx, restore_rx) = tokio::sync::oneshot::channel();
|
|
let server_cwd = repo_root.clone();
|
|
let server = tokio::spawn(async move {
|
|
let mut methods = Vec::new();
|
|
let mut restore_rx = Some(restore_rx);
|
|
let id = "00000000-0000-0000-0000-000000000001";
|
|
let thread = json!({
|
|
"id": id, "sessionId": id, "preview": "", "ephemeral": false,
|
|
"modelProvider": "openai", "createdAt": 1, "updatedAt": 2,
|
|
"status": {"type": "active", "activeFlags": []}, "cwd": server_cwd,
|
|
"cliVersion": "0.0.0", "source": "cli", "turns": [{"id": "running", "items": [], "status": "inProgress", "error": null}]
|
|
});
|
|
for connection in 0..3 {
|
|
let mut socket = loop {
|
|
let (stream, _) = listener.accept().await?;
|
|
// Startup probes the default daemon socket before opening its WebSocket.
|
|
if let Ok(socket) = tokio_tungstenite::accept_async(stream).await {
|
|
break socket;
|
|
}
|
|
};
|
|
loop {
|
|
let frame = tokio::select! {
|
|
_ = &mut disconnect_rx, if connection == 0 => {
|
|
socket.close(/*msg*/ None).await?;
|
|
break;
|
|
}
|
|
frame = socket.next() => frame,
|
|
};
|
|
let Some(Ok(Message::Text(text))) = frame else {
|
|
break;
|
|
};
|
|
let JSONRPCMessage::Request(request) = serde_json::from_str(&text)? else {
|
|
continue;
|
|
};
|
|
methods.push(request.method.clone());
|
|
if connection == 1 && request.method == "initialize" {
|
|
restore_rx.take().unwrap().await?;
|
|
}
|
|
if connection == 1 && request.method == "thread/resume" {
|
|
socket
|
|
.send(Message::Text(
|
|
json!({"id": request.id, "error": {
|
|
"code": -32600,
|
|
"message": format!("thread {id} is closing; retry after the thread is closed")
|
|
}})
|
|
.to_string()
|
|
.into(),
|
|
))
|
|
.await?;
|
|
continue;
|
|
}
|
|
let result = match request.method.as_str() {
|
|
"initialize" => json!({"userAgent": "reconnect-pty"}),
|
|
"account/read" => {
|
|
json!({"account": {"type": "apiKey"}, "requiresOpenaiAuth": false})
|
|
}
|
|
"model/list" => json!({"data": [], "nextCursor": null}),
|
|
"configRequirements/read" => json!({"requirements": null}),
|
|
"thread/start" | "thread/resume" => {
|
|
json!({"thread": thread, "model": "gpt-5.6-terra", "modelProvider": "openai",
|
|
"cwd": server_cwd, "approvalPolicy": "never", "approvalsReviewer": "user",
|
|
"sandbox": {"type": "readOnly"}, "reasoningEffort": null})
|
|
}
|
|
"thread/read" => json!({"thread": thread}),
|
|
"thread/goal/get" => json!({"goal": null}),
|
|
"skills/list" => json!({"data": []}),
|
|
_ => {
|
|
socket
|
|
.send(Message::Text(
|
|
json!({"id": request.id, "error": {
|
|
"code": -32601, "message": "method not found"
|
|
}})
|
|
.to_string()
|
|
.into(),
|
|
))
|
|
.await?;
|
|
continue;
|
|
}
|
|
};
|
|
socket
|
|
.send(Message::Text(
|
|
json!({"id": request.id, "result": result})
|
|
.to_string()
|
|
.into(),
|
|
))
|
|
.await?;
|
|
if connection == 2 && request.method == "thread/resume" {
|
|
// Keep the recovered turn running: its output must appear without waiting
|
|
// for turn/completed or rebuilding the transcript.
|
|
socket
|
|
.send(Message::Text(
|
|
json!({
|
|
"method": "item/agentMessage/delta", "params": {
|
|
"threadId": id, "turnId": "running", "itemId": "live-item",
|
|
"delta": "fresh-notification-after-reconnect\n"
|
|
}
|
|
})
|
|
.to_string()
|
|
.into(),
|
|
))
|
|
.await?;
|
|
}
|
|
}
|
|
}
|
|
Ok::<_, anyhow::Error>(methods)
|
|
});
|
|
let mut terminal = PtyCodex::start(&repo_root, codex_home, &[])?;
|
|
terminal.wait_for_startup()?;
|
|
let mut disconnect_tx = Some(disconnect_tx);
|
|
let mut restore_tx = Some(restore_tx);
|
|
for expected in [
|
|
"gpt-5.6-terra",
|
|
"preserved-draft",
|
|
"Reconnecting",
|
|
"preserved-draft!",
|
|
"fresh-notification-after-reconnect",
|
|
] {
|
|
let deadline = Instant::now() + Duration::from_secs(/*secs*/ 30);
|
|
while !terminal.screen_contains(expected) && Instant::now() < deadline {
|
|
terminal.read_output(Duration::from_millis(/*millis*/ 20))?;
|
|
}
|
|
ensure!(
|
|
terminal.screen_contains(expected),
|
|
"missing {expected}; screen:\n{}",
|
|
terminal.screen_contents()
|
|
);
|
|
match expected {
|
|
"gpt-5.6-terra" => terminal.write_input(b"preserved-draft")?,
|
|
"preserved-draft" => {
|
|
disconnect_tx.take().unwrap().send(()).unwrap();
|
|
}
|
|
"Reconnecting" => terminal.write_input(b"!")?,
|
|
"preserved-draft!" => {
|
|
restore_tx.take().unwrap().send(()).unwrap();
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
ensure!(
|
|
terminal.screen_contains("preserved-draft!"),
|
|
"draft was lost after recovery"
|
|
);
|
|
drop(terminal);
|
|
let methods = tokio::time::timeout(Duration::from_secs(/*secs*/ 5), server).await???;
|
|
assert_eq!(
|
|
methods
|
|
.iter()
|
|
.filter(|method| *method == "thread/resume")
|
|
.count(),
|
|
2
|
|
);
|
|
assert!(!methods.iter().any(|method| method == "turn/start"));
|
|
Ok(())
|
|
}
|