mirror of
https://github.com/openai/codex.git
synced 2026-05-07 06:41:36 +03:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7be5328f34 |
19
codex-rs/Cargo.lock
generated
19
codex-rs/Cargo.lock
generated
@@ -2188,6 +2188,7 @@ dependencies = [
|
||||
"codex-utils-cargo-bin",
|
||||
"codex-utils-cli",
|
||||
"codex-utils-path",
|
||||
"codex-web-server",
|
||||
"codex-windows-sandbox",
|
||||
"libc",
|
||||
"owo-colors",
|
||||
@@ -3936,6 +3937,24 @@ dependencies = [
|
||||
"v8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-web-server"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
"clap",
|
||||
"codex-utils-pty",
|
||||
"futures",
|
||||
"include_dir",
|
||||
"pretty_assertions",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tracing",
|
||||
"url",
|
||||
"webbrowser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-windows-sandbox"
|
||||
version = "0.0.0"
|
||||
|
||||
@@ -104,6 +104,7 @@ members = [
|
||||
"thread-store",
|
||||
"uds",
|
||||
"codex-experimental-api-macros",
|
||||
"web-server",
|
||||
"plugin",
|
||||
"model-provider",
|
||||
]
|
||||
@@ -223,6 +224,7 @@ codex-utils-stream-parser = { path = "utils/stream-parser" }
|
||||
codex-utils-string = { path = "utils/string" }
|
||||
codex-utils-template = { path = "utils/template" }
|
||||
codex-v8-poc = { path = "v8-poc" }
|
||||
codex-web-server = { path = "web-server" }
|
||||
codex-windows-sandbox = { path = "windows-sandbox-rs" }
|
||||
core_test_support = { path = "core/tests/common" }
|
||||
mcp_test_support = { path = "mcp-server/tests/common" }
|
||||
|
||||
@@ -50,6 +50,7 @@ codex-terminal-detection = { workspace = true }
|
||||
codex-tui = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
codex-utils-path = { workspace = true }
|
||||
codex-web-server = { workspace = true }
|
||||
libc = { workspace = true }
|
||||
owo-colors = { workspace = true }
|
||||
regex-lite = { workspace = true }
|
||||
|
||||
@@ -126,6 +126,9 @@ enum Subcommand {
|
||||
/// [experimental] Run the app server or related tooling.
|
||||
AppServer(AppServerCommand),
|
||||
|
||||
/// Serve Codex in a browser-backed terminal.
|
||||
Web(codex_web_server::WebCommand),
|
||||
|
||||
/// Launch the Codex desktop app (opens the app installer if missing).
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
App(app_cmd::AppCommand),
|
||||
@@ -888,6 +891,14 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Subcommand::Web(web_cli)) => {
|
||||
reject_remote_mode_for_subcommand(
|
||||
root_remote.as_deref(),
|
||||
root_remote_auth_token_env.as_deref(),
|
||||
"web",
|
||||
)?;
|
||||
codex_web_server::run(web_cli, root_config_overrides.raw_overrides.clone()).await?;
|
||||
}
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
Some(Subcommand::App(app_cli)) => {
|
||||
reject_remote_mode_for_subcommand(
|
||||
@@ -2449,6 +2460,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn web_command_parses_forwarded_args() {
|
||||
let cli = MultitoolCli::try_parse_from([
|
||||
"codex",
|
||||
"web",
|
||||
"--listen",
|
||||
"127.0.0.1:4321",
|
||||
"--",
|
||||
"--model",
|
||||
"gpt-test",
|
||||
])
|
||||
.expect("parse");
|
||||
let Some(Subcommand::Web(web)) = cli.subcommand else {
|
||||
panic!("expected web subcommand");
|
||||
};
|
||||
assert_eq!(
|
||||
web.listen,
|
||||
"127.0.0.1:4321"
|
||||
.parse::<std::net::SocketAddr>()
|
||||
.expect("socket address should parse")
|
||||
);
|
||||
assert_eq!(web.codex_args, vec!["--model", "gpt-test"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_remote_auth_token_env_for_app_server_proxy() {
|
||||
let subcommand = AppServerSubcommand::Proxy(AppServerProxyCommand { socket_path: None });
|
||||
|
||||
7
codex-rs/web-server/BUILD.bazel
Normal file
7
codex-rs/web-server/BUILD.bazel
Normal file
@@ -0,0 +1,7 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "web-server",
|
||||
crate_name = "codex_web_server",
|
||||
compile_data = glob(["assets/**"]),
|
||||
)
|
||||
36
codex-rs/web-server/Cargo.toml
Normal file
36
codex-rs/web-server/Cargo.toml
Normal file
@@ -0,0 +1,36 @@
|
||||
[package]
|
||||
name = "codex-web-server"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lib]
|
||||
name = "codex_web_server"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
axum = { workspace = true, default-features = false, features = [
|
||||
"http1",
|
||||
"tokio",
|
||||
"ws",
|
||||
] }
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
codex-utils-pty = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
include_dir = { workspace = true }
|
||||
tokio = { workspace = true, features = [
|
||||
"macros",
|
||||
"net",
|
||||
"rt-multi-thread",
|
||||
] }
|
||||
tracing = { workspace = true, features = ["log"] }
|
||||
url = { workspace = true }
|
||||
webbrowser = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
tokio-tungstenite = { workspace = true }
|
||||
BIN
codex-rs/web-server/assets/assets/ghostty-vt-BG4Ub6dk.wasm
Normal file
BIN
codex-rs/web-server/assets/assets/ghostty-vt-BG4Ub6dk.wasm
Normal file
Binary file not shown.
2
codex-rs/web-server/assets/assets/index-DZyd-Z17.css
Normal file
2
codex-rs/web-server/assets/assets/index-DZyd-Z17.css
Normal file
File diff suppressed because one or more lines are too long
1
codex-rs/web-server/assets/assets/index-DcK3rtbT.js
Normal file
1
codex-rs/web-server/assets/assets/index-DcK3rtbT.js
Normal file
File diff suppressed because one or more lines are too long
20
codex-rs/web-server/assets/index.html
Normal file
20
codex-rs/web-server/assets/index.html
Normal file
@@ -0,0 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Codex Web Terminal</title>
|
||||
<script type="module" crossorigin src="/assets/index-DcK3rtbT.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DZyd-Z17.css">
|
||||
</head>
|
||||
<body>
|
||||
<main id="app" class="h-dvh bg-neutral-950 text-neutral-100">
|
||||
<div id="terminal" class="h-full min-h-0"></div>
|
||||
<div
|
||||
id="status"
|
||||
class="pointer-events-none fixed bottom-3 left-3 z-10 hidden max-w-[calc(100vw-1.5rem)] rounded border border-red-500/40 bg-neutral-950 px-3 py-2 text-sm text-red-200 shadow"
|
||||
role="status"
|
||||
></div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
557
codex-rs/web-server/src/lib.rs
Normal file
557
codex-rs/web-server/src/lib.rs
Normal file
@@ -0,0 +1,557 @@
|
||||
use anyhow::Context;
|
||||
use axum::Router;
|
||||
use axum::body::Body;
|
||||
use axum::extract::ConnectInfo;
|
||||
use axum::extract::State;
|
||||
use axum::extract::ws::Message;
|
||||
use axum::extract::ws::WebSocket;
|
||||
use axum::extract::ws::WebSocketUpgrade;
|
||||
use axum::http::HeaderMap;
|
||||
use axum::http::StatusCode;
|
||||
use axum::http::header;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::response::Response;
|
||||
use axum::routing::get;
|
||||
use clap::Args;
|
||||
use codex_utils_pty::ProcessHandle;
|
||||
use codex_utils_pty::SpawnedProcess;
|
||||
use codex_utils_pty::TerminalSize;
|
||||
use codex_utils_pty::spawn_pty_process;
|
||||
use futures::SinkExt;
|
||||
use futures::StreamExt;
|
||||
use include_dir::Dir;
|
||||
use include_dir::include_dir;
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::debug;
|
||||
use tracing::warn;
|
||||
use url::Position;
|
||||
use url::Url;
|
||||
|
||||
const INPUT_FRAME: u8 = 0x00;
|
||||
const RESIZE_FRAME: u8 = 0x01;
|
||||
const DEFAULT_TERMINAL_SIZE: TerminalSize = TerminalSize { rows: 24, cols: 80 };
|
||||
static WEBUI_ASSETS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/assets");
|
||||
|
||||
/// Run Codex in a browser-backed terminal served by the current Codex binary.
|
||||
#[derive(Clone, Debug, Args)]
|
||||
pub struct WebCommand {
|
||||
/// Address to bind. Only loopback addresses are accepted.
|
||||
#[arg(long, default_value = "127.0.0.1:0", value_name = "ADDR")]
|
||||
pub listen: SocketAddr,
|
||||
|
||||
/// Working directory for the Codex session.
|
||||
#[arg(long, value_name = "DIR")]
|
||||
pub cwd: Option<PathBuf>,
|
||||
|
||||
/// Open the served URL in the default browser.
|
||||
#[arg(long)]
|
||||
pub open: bool,
|
||||
|
||||
/// Internal test hook: command to spawn instead of this Codex executable.
|
||||
#[arg(long, hide = true, value_name = "PATH")]
|
||||
pub command: Option<PathBuf>,
|
||||
|
||||
/// Internal test hook: argument for --command. May be repeated.
|
||||
#[arg(long = "command-arg", hide = true, value_name = "ARG")]
|
||||
pub command_args: Vec<String>,
|
||||
|
||||
/// Arguments forwarded to the inner Codex TUI. Pass them after `--`.
|
||||
#[arg(last = true, value_name = "CODEX_ARGS")]
|
||||
pub codex_args: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ServerConfig {
|
||||
listen: SocketAddr,
|
||||
open: bool,
|
||||
command: PathBuf,
|
||||
args: Vec<String>,
|
||||
cwd: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ServerState {
|
||||
config: Arc<ServerConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum ClientFrame<'a> {
|
||||
Input(&'a [u8]),
|
||||
Resize { cols: u16, rows: u16 },
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum FrameDecodeError {
|
||||
Empty,
|
||||
MalformedResize,
|
||||
ZeroResize,
|
||||
UnknownFrameType(u8),
|
||||
}
|
||||
|
||||
pub struct StaticAsset {
|
||||
pub path: &'static str,
|
||||
pub content_type: &'static str,
|
||||
pub cache_control: &'static str,
|
||||
pub bytes: &'static [u8],
|
||||
}
|
||||
|
||||
impl WebCommand {
|
||||
pub fn into_server_config(
|
||||
self,
|
||||
inherited_config_overrides: Vec<String>,
|
||||
) -> anyhow::Result<ServerConfig> {
|
||||
if !self.listen.ip().is_loopback() {
|
||||
anyhow::bail!("codex web only accepts loopback --listen addresses");
|
||||
}
|
||||
|
||||
let cwd = match self.cwd {
|
||||
Some(cwd) => cwd,
|
||||
None => std::env::current_dir().context("failed to read current directory")?,
|
||||
};
|
||||
let (command, args) = match self.command {
|
||||
Some(command) => (command, self.command_args),
|
||||
None => {
|
||||
let command = std::env::current_exe().context("failed to resolve current exe")?;
|
||||
let mut args = Vec::new();
|
||||
for config_override in inherited_config_overrides {
|
||||
args.push("-c".to_string());
|
||||
args.push(config_override);
|
||||
}
|
||||
args.extend(self.codex_args);
|
||||
(command, args)
|
||||
}
|
||||
};
|
||||
|
||||
Ok(ServerConfig {
|
||||
listen: self.listen,
|
||||
open: self.open,
|
||||
command,
|
||||
args,
|
||||
cwd,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(
|
||||
command: WebCommand,
|
||||
inherited_config_overrides: Vec<String>,
|
||||
) -> anyhow::Result<()> {
|
||||
let config = command.into_server_config(inherited_config_overrides)?;
|
||||
let listener = TcpListener::bind(config.listen)
|
||||
.await
|
||||
.with_context(|| format!("failed to bind codex web listener on {}", config.listen))?;
|
||||
let url = http_url_for_addr(listener.local_addr()?);
|
||||
println!("Codex web listening on {url}");
|
||||
if config.open
|
||||
&& let Err(err) = webbrowser::open(&url)
|
||||
{
|
||||
eprintln!("Failed to open browser for {url}: {err}");
|
||||
}
|
||||
serve_listener(listener, config).await
|
||||
}
|
||||
|
||||
pub async fn serve_listener(listener: TcpListener, config: ServerConfig) -> anyhow::Result<()> {
|
||||
let state = ServerState {
|
||||
config: Arc::new(config),
|
||||
};
|
||||
let router = Router::new()
|
||||
.route("/healthz", get(healthz))
|
||||
.route("/api/pty", get(pty_websocket))
|
||||
.fallback(get(static_handler))
|
||||
.with_state(state);
|
||||
|
||||
axum::serve(
|
||||
listener,
|
||||
router.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
)
|
||||
.await
|
||||
.context("codex web server failed")
|
||||
}
|
||||
|
||||
async fn healthz() -> StatusCode {
|
||||
StatusCode::OK
|
||||
}
|
||||
|
||||
async fn pty_websocket(
|
||||
websocket: WebSocketUpgrade,
|
||||
ConnectInfo(peer_addr): ConnectInfo<SocketAddr>,
|
||||
State(state): State<ServerState>,
|
||||
headers: HeaderMap,
|
||||
) -> Response {
|
||||
if !origin_is_allowed(&headers) {
|
||||
warn!(%peer_addr, "rejecting codex web websocket due to Origin mismatch");
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
|
||||
websocket
|
||||
.on_upgrade(move |socket| handle_pty_socket(socket, state.config))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn static_handler(request: axum::http::Request<Body>) -> Response {
|
||||
let path = request.uri().path();
|
||||
if path.starts_with("/api/") {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
|
||||
match static_asset_for_path(path) {
|
||||
Some(asset) => (
|
||||
[
|
||||
(header::CONTENT_TYPE, asset.content_type),
|
||||
(header::CACHE_CONTROL, asset.cache_control),
|
||||
],
|
||||
asset.bytes,
|
||||
)
|
||||
.into_response(),
|
||||
None => StatusCode::NOT_FOUND.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn static_asset_for_path(request_path: &str) -> Option<StaticAsset> {
|
||||
let asset_path = normalize_asset_path(request_path)?;
|
||||
let file = WEBUI_ASSETS
|
||||
.get_file(&asset_path)
|
||||
.or_else(|| WEBUI_ASSETS.get_file("index.html"))?;
|
||||
let path = file.path().to_str()?;
|
||||
Some(StaticAsset {
|
||||
path,
|
||||
content_type: content_type_for_path(path),
|
||||
cache_control: if path == "index.html" {
|
||||
"no-store"
|
||||
} else {
|
||||
"public, max-age=31536000, immutable"
|
||||
},
|
||||
bytes: file.contents(),
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_asset_path(request_path: &str) -> Option<String> {
|
||||
let trimmed = request_path.strip_prefix('/').unwrap_or(request_path);
|
||||
let path = if trimmed.is_empty() {
|
||||
"index.html"
|
||||
} else {
|
||||
trimmed
|
||||
};
|
||||
if path
|
||||
.split('/')
|
||||
.any(|component| component.is_empty() || component == "." || component == "..")
|
||||
|| path.contains('\\')
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(path.to_string())
|
||||
}
|
||||
|
||||
fn content_type_for_path(path: &str) -> &'static str {
|
||||
match path.rsplit_once('.').map(|(_, extension)| extension) {
|
||||
Some("css") => "text/css; charset=utf-8",
|
||||
Some("html") => "text/html; charset=utf-8",
|
||||
Some("js") => "text/javascript; charset=utf-8",
|
||||
Some("json") | Some("map") => "application/json; charset=utf-8",
|
||||
Some("svg") => "image/svg+xml",
|
||||
Some("wasm") => "application/wasm",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn decode_client_frame(bytes: &[u8]) -> Result<ClientFrame<'_>, FrameDecodeError> {
|
||||
let Some(kind) = bytes.first().copied() else {
|
||||
return Err(FrameDecodeError::Empty);
|
||||
};
|
||||
match kind {
|
||||
INPUT_FRAME => Ok(ClientFrame::Input(&bytes[1..])),
|
||||
RESIZE_FRAME => {
|
||||
if bytes.len() != 5 {
|
||||
return Err(FrameDecodeError::MalformedResize);
|
||||
}
|
||||
let cols = u16::from_be_bytes([bytes[1], bytes[2]]);
|
||||
let rows = u16::from_be_bytes([bytes[3], bytes[4]]);
|
||||
if cols == 0 || rows == 0 {
|
||||
return Err(FrameDecodeError::ZeroResize);
|
||||
}
|
||||
Ok(ClientFrame::Resize { cols, rows })
|
||||
}
|
||||
other => Err(FrameDecodeError::UnknownFrameType(other)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_pty_socket(socket: WebSocket, config: Arc<ServerConfig>) {
|
||||
let spawned = match spawn_codex_pty(config.as_ref()).await {
|
||||
Ok(spawned) => spawned,
|
||||
Err(err) => {
|
||||
let mut socket = socket;
|
||||
let message = format!("Failed to start PTY: {err}\r\n");
|
||||
let _ = socket
|
||||
.send(Message::Binary(message.into_bytes().into()))
|
||||
.await;
|
||||
let _ = socket.close().await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
bridge_socket_to_pty(socket, spawned).await;
|
||||
}
|
||||
|
||||
async fn spawn_codex_pty(config: &ServerConfig) -> anyhow::Result<SpawnedProcess> {
|
||||
let command = config
|
||||
.command
|
||||
.to_str()
|
||||
.ok_or_else(|| anyhow::anyhow!("codex web command path is not valid UTF-8"))?;
|
||||
spawn_pty_process(
|
||||
command,
|
||||
&config.args,
|
||||
&config.cwd,
|
||||
&child_environment(),
|
||||
/*arg0*/ &None,
|
||||
DEFAULT_TERMINAL_SIZE,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("failed to spawn {}", config.command.display()))
|
||||
}
|
||||
|
||||
async fn bridge_socket_to_pty(socket: WebSocket, spawned: SpawnedProcess) {
|
||||
let SpawnedProcess {
|
||||
session,
|
||||
mut stdout_rx,
|
||||
stderr_rx: _,
|
||||
mut exit_rx,
|
||||
} = spawned;
|
||||
let session = Arc::new(session);
|
||||
let writer = session.writer_sender();
|
||||
let resize_session = Arc::clone(&session);
|
||||
let (mut websocket_writer, mut websocket_reader) = socket.split();
|
||||
|
||||
let mut outbound_task: JoinHandle<()> = tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::select! {
|
||||
output = stdout_rx.recv() => {
|
||||
let Some(output) = output else {
|
||||
break;
|
||||
};
|
||||
if websocket_writer.send(Message::Binary(output.into())).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
exit = &mut exit_rx => {
|
||||
let code = exit.unwrap_or(-1);
|
||||
let message = format!("\r\n[process exited: {code}]\r\n");
|
||||
let _ = websocket_writer
|
||||
.send(Message::Binary(message.into_bytes().into()))
|
||||
.await;
|
||||
let _ = websocket_writer.close().await;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mut inbound_task: JoinHandle<()> = tokio::spawn(async move {
|
||||
while let Some(message) = websocket_reader.next().await {
|
||||
match message {
|
||||
Ok(Message::Binary(bytes)) => match decode_client_frame(&bytes) {
|
||||
Ok(ClientFrame::Input(input)) => {
|
||||
if writer.send(input.to_vec()).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(ClientFrame::Resize { cols, rows }) => {
|
||||
if let Err(err) = resize_session.resize(TerminalSize { rows, cols }) {
|
||||
debug!("failed to resize codex web PTY: {err}");
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
debug!("ignoring malformed codex web frame: {err:?}");
|
||||
}
|
||||
},
|
||||
Ok(Message::Close(_)) | Err(_) => break,
|
||||
Ok(Message::Text(_) | Message::Ping(_) | Message::Pong(_)) => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tokio::select! {
|
||||
_ = &mut outbound_task => {
|
||||
inbound_task.abort();
|
||||
}
|
||||
_ = &mut inbound_task => {
|
||||
outbound_task.abort();
|
||||
}
|
||||
}
|
||||
terminate_process(&session);
|
||||
}
|
||||
|
||||
fn terminate_process(session: &ProcessHandle) {
|
||||
session.terminate();
|
||||
}
|
||||
|
||||
fn child_environment() -> HashMap<String, String> {
|
||||
let mut env: HashMap<String, String> = std::env::vars().collect();
|
||||
env.insert("TERM".to_string(), "xterm-256color".to_string());
|
||||
env.insert("COLORTERM".to_string(), "truecolor".to_string());
|
||||
env.insert("TERM_PROGRAM".to_string(), "wterm".to_string());
|
||||
env.insert(
|
||||
"CODEX_TUI_DISABLE_KEYBOARD_ENHANCEMENT".to_string(),
|
||||
"1".to_string(),
|
||||
);
|
||||
env
|
||||
}
|
||||
|
||||
fn origin_is_allowed(headers: &HeaderMap) -> bool {
|
||||
let Some(origin) = headers.get(header::ORIGIN) else {
|
||||
return true;
|
||||
};
|
||||
let Ok(origin) = origin.to_str() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(origin) = Url::parse(origin) else {
|
||||
return false;
|
||||
};
|
||||
let Some(host) = headers.get(header::HOST) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(host) = host.to_str() else {
|
||||
return false;
|
||||
};
|
||||
origin[Position::BeforeHost..Position::AfterPort].eq_ignore_ascii_case(host)
|
||||
}
|
||||
|
||||
fn http_url_for_addr(addr: SocketAddr) -> String {
|
||||
match addr.ip() {
|
||||
IpAddr::V4(ip) => format!("http://{ip}:{}", addr.port()),
|
||||
IpAddr::V6(ip) => format!("http://[{ip}]:{}", addr.port()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn spawn_for_test(
|
||||
command: PathBuf,
|
||||
args: Vec<String>,
|
||||
) -> anyhow::Result<(String, oneshot::Sender<()>, JoinHandle<anyhow::Result<()>>)> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let url = http_url_for_addr(listener.local_addr()?);
|
||||
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
|
||||
let config = ServerConfig {
|
||||
listen: listener.local_addr()?,
|
||||
open: false,
|
||||
command,
|
||||
args,
|
||||
cwd: std::env::current_dir()?,
|
||||
};
|
||||
let handle = tokio::spawn(async move {
|
||||
let state = ServerState {
|
||||
config: Arc::new(config),
|
||||
};
|
||||
let router = Router::new()
|
||||
.route("/healthz", get(healthz))
|
||||
.route("/api/pty", get(pty_websocket))
|
||||
.fallback(get(static_handler))
|
||||
.with_state(state);
|
||||
axum::serve(
|
||||
listener,
|
||||
router.into_make_service_with_connect_info::<SocketAddr>(),
|
||||
)
|
||||
.with_graceful_shutdown(async move {
|
||||
let _ = shutdown_rx.await;
|
||||
})
|
||||
.await
|
||||
.context("codex web test server failed")
|
||||
});
|
||||
Ok((url, shutdown_tx, handle))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn decodes_input_frames() {
|
||||
let frame = [INPUT_FRAME, b'h', b'i'];
|
||||
assert_eq!(decode_client_frame(&frame), Ok(ClientFrame::Input(b"hi")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decodes_resize_frames() {
|
||||
let frame = [RESIZE_FRAME, 0, 120, 0, 40];
|
||||
assert_eq!(
|
||||
decode_client_frame(&frame),
|
||||
Ok(ClientFrame::Resize {
|
||||
cols: 120,
|
||||
rows: 40
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_frames() {
|
||||
assert_eq!(decode_client_frame(&[]), Err(FrameDecodeError::Empty));
|
||||
assert_eq!(
|
||||
decode_client_frame(&[RESIZE_FRAME, 0, 80]),
|
||||
Err(FrameDecodeError::MalformedResize)
|
||||
);
|
||||
assert_eq!(
|
||||
decode_client_frame(&[RESIZE_FRAME, 0, 0, 0, 24]),
|
||||
Err(FrameDecodeError::ZeroResize)
|
||||
);
|
||||
assert_eq!(
|
||||
decode_client_frame(&[9]),
|
||||
Err(FrameDecodeError::UnknownFrameType(9))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serves_index_for_root_and_spa_routes() {
|
||||
let root = static_asset_for_path("/").expect("root asset");
|
||||
let route = static_asset_for_path("/thread/123").expect("route asset");
|
||||
|
||||
assert_eq!(root.path, "index.html");
|
||||
assert_eq!(route.path, "index.html");
|
||||
assert_eq!(root.content_type, "text/html; charset=utf-8");
|
||||
assert_eq!(root.cache_control, "no-store");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_path_traversal() {
|
||||
assert!(static_asset_for_path("/../Cargo.toml").is_none());
|
||||
assert!(static_asset_for_path("/assets\\index.js").is_none());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn websocket_bridges_pty_output() -> anyhow::Result<()> {
|
||||
let (url, shutdown, handle) = spawn_for_test(
|
||||
PathBuf::from("/bin/sh"),
|
||||
vec!["-c".to_string(), "printf READY; cat".to_string()],
|
||||
)
|
||||
.await?;
|
||||
let ws_url = format!("{url}/api/pty").replace("http://", "ws://");
|
||||
let (mut socket, _) = tokio_tungstenite::connect_async(&ws_url).await?;
|
||||
let mut saw_ready = false;
|
||||
|
||||
for _ in 0..8 {
|
||||
if let Some(message) = socket.next().await {
|
||||
let message = message?;
|
||||
if message
|
||||
.into_data()
|
||||
.windows("READY".len())
|
||||
.any(|w| w == b"READY")
|
||||
{
|
||||
saw_ready = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = socket.close(None).await;
|
||||
let _ = shutdown.send(());
|
||||
let _ = handle.await?;
|
||||
assert!(saw_ready);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
19
codex-webui/index.html
Normal file
19
codex-webui/index.html
Normal file
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Codex Web Terminal</title>
|
||||
</head>
|
||||
<body>
|
||||
<main id="app" class="h-dvh bg-neutral-950 text-neutral-100">
|
||||
<div id="terminal" class="h-full min-h-0"></div>
|
||||
<div
|
||||
id="status"
|
||||
class="pointer-events-none fixed bottom-3 left-3 z-10 hidden max-w-[calc(100vw-1.5rem)] rounded border border-red-500/40 bg-neutral-950 px-3 py-2 text-sm text-red-200 shadow"
|
||||
role="status"
|
||||
></div>
|
||||
</main>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
30
codex-webui/package.json
Normal file
30
codex-webui/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@openai/codex-webui",
|
||||
"version": "0.0.0-dev",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "node server.mjs --dev",
|
||||
"start": "node server.mjs",
|
||||
"build": "vite build",
|
||||
"build:rust-assets": "pnpm build && node scripts/sync-rust-assets.mjs",
|
||||
"check:rust-assets": "pnpm build && node scripts/sync-rust-assets.mjs --check",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test tests/*.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "4.2.4",
|
||||
"@wterm/dom": "0.3.0",
|
||||
"@wterm/ghostty": "0.3.0",
|
||||
"node-pty": "1.2.0-beta.12",
|
||||
"tailwindcss": "4.2.4",
|
||||
"vite": "8.0.10",
|
||||
"ws": "8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "6.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
}
|
||||
28
codex-webui/protocol.mjs
Normal file
28
codex-webui/protocol.mjs
Normal file
@@ -0,0 +1,28 @@
|
||||
export const INPUT_FRAME = 0x00;
|
||||
export const RESIZE_FRAME = 0x01;
|
||||
|
||||
export function decodeClientFrame(frame) {
|
||||
const data = Buffer.isBuffer(frame) ? frame : Buffer.from(frame);
|
||||
if (data.length === 0) {
|
||||
return { type: "invalid", reason: "empty frame" };
|
||||
}
|
||||
|
||||
const kind = data.readUInt8(0);
|
||||
if (kind === INPUT_FRAME) {
|
||||
return { type: "input", data: data.subarray(1).toString("utf8") };
|
||||
}
|
||||
|
||||
if (kind === RESIZE_FRAME) {
|
||||
if (data.length !== 5) {
|
||||
return { type: "invalid", reason: "malformed resize frame" };
|
||||
}
|
||||
const cols = data.readUInt16BE(1);
|
||||
const rows = data.readUInt16BE(3);
|
||||
if (cols === 0 || rows === 0) {
|
||||
return { type: "invalid", reason: "resize dimensions must be positive" };
|
||||
}
|
||||
return { type: "resize", cols, rows };
|
||||
}
|
||||
|
||||
return { type: "invalid", reason: `unknown frame type ${kind}` };
|
||||
}
|
||||
85
codex-webui/scripts/sync-rust-assets.mjs
Normal file
85
codex-webui/scripts/sync-rust-assets.mjs
Normal file
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env node
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
} from "node:fs";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const packageRoot = resolve(dirname(__filename), "..");
|
||||
const repoRoot = resolve(packageRoot, "..");
|
||||
const distRoot = join(packageRoot, "dist");
|
||||
const rustAssetsRoot = join(repoRoot, "codex-rs", "web-server", "assets");
|
||||
const check = process.argv.includes("--check");
|
||||
|
||||
if (!existsSync(join(distRoot, "index.html"))) {
|
||||
throw new Error("Missing dist/index.html. Run the codex-webui build first.");
|
||||
}
|
||||
|
||||
if (check) {
|
||||
const diff = diffTrees(distRoot, rustAssetsRoot);
|
||||
if (diff.length > 0) {
|
||||
throw new Error(
|
||||
`codex web assets are out of date:\n${diff.map((item) => ` ${item}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
console.log("codex web Rust assets are up to date.");
|
||||
} else {
|
||||
rmSync(rustAssetsRoot, { force: true, recursive: true });
|
||||
mkdirSync(rustAssetsRoot, { recursive: true });
|
||||
cpSync(distRoot, rustAssetsRoot, { recursive: true });
|
||||
console.log(
|
||||
`Synced ${relative(repoRoot, distRoot)} to ${relative(repoRoot, rustAssetsRoot)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
function diffTrees(leftRoot, rightRoot) {
|
||||
const leftFiles = listFiles(leftRoot);
|
||||
const rightFiles = listFiles(rightRoot);
|
||||
const allFiles = new Set([...leftFiles.keys(), ...rightFiles.keys()]);
|
||||
const diff = [];
|
||||
|
||||
for (const file of [...allFiles].sort()) {
|
||||
const left = leftFiles.get(file);
|
||||
const right = rightFiles.get(file);
|
||||
if (!left) {
|
||||
diff.push(`unexpected ${file}`);
|
||||
} else if (!right) {
|
||||
diff.push(`missing ${file}`);
|
||||
} else if (left !== right) {
|
||||
diff.push(`changed ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
return diff;
|
||||
}
|
||||
|
||||
function listFiles(root) {
|
||||
const files = new Map();
|
||||
if (!existsSync(root)) {
|
||||
return files;
|
||||
}
|
||||
walk(root, root, files);
|
||||
return files;
|
||||
}
|
||||
|
||||
function walk(root, dir, files) {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(root, path, files);
|
||||
} else if (entry.isFile()) {
|
||||
files.set(relative(root, path), sha256(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sha256(path) {
|
||||
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
||||
}
|
||||
309
codex-webui/server.mjs
Normal file
309
codex-webui/server.mjs
Normal file
@@ -0,0 +1,309 @@
|
||||
#!/usr/bin/env node
|
||||
import { createServer } from "node:http";
|
||||
import { createReadStream, existsSync } from "node:fs";
|
||||
import { dirname, extname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import pty from "node-pty";
|
||||
import { WebSocketServer } from "ws";
|
||||
import { decodeClientFrame } from "./protocol.mjs";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const packageRoot = dirname(__filename);
|
||||
const repoRoot = resolve(packageRoot, "..");
|
||||
|
||||
const MIME_TYPES = {
|
||||
".css": "text/css; charset=utf-8",
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".map": "application/json; charset=utf-8",
|
||||
".svg": "image/svg+xml",
|
||||
".wasm": "application/wasm",
|
||||
};
|
||||
|
||||
const SERVER_FLAGS = new Set([
|
||||
"--dev",
|
||||
"--shell",
|
||||
"--host",
|
||||
"--port",
|
||||
"--cwd",
|
||||
"--codex-bin",
|
||||
]);
|
||||
|
||||
export function parseArgs(argv) {
|
||||
const options = {
|
||||
dev: false,
|
||||
host: "127.0.0.1",
|
||||
port: 4321,
|
||||
cwd: repoRoot,
|
||||
codexBin: null,
|
||||
shell: false,
|
||||
codexArgs: [],
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--") {
|
||||
if (SERVER_FLAGS.has(argv[index + 1])) {
|
||||
continue;
|
||||
}
|
||||
options.codexArgs = argv.slice(index + 1);
|
||||
break;
|
||||
}
|
||||
if (arg === "--dev") {
|
||||
options.dev = true;
|
||||
} else if (arg === "--shell") {
|
||||
options.shell = true;
|
||||
} else if (arg === "--host") {
|
||||
options.host = requireValue(argv, (index += 1), arg);
|
||||
} else if (arg === "--port") {
|
||||
options.port = Number.parseInt(requireValue(argv, (index += 1), arg), 10);
|
||||
if (!Number.isFinite(options.port) || options.port <= 0) {
|
||||
throw new Error("--port must be a positive integer");
|
||||
}
|
||||
} else if (arg === "--cwd") {
|
||||
options.cwd = resolve(requireValue(argv, (index += 1), arg));
|
||||
} else if (arg === "--codex-bin") {
|
||||
options.codexBin = resolve(requireValue(argv, (index += 1), arg));
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function requireValue(argv, index, flag) {
|
||||
const value = argv[index];
|
||||
if (!value) {
|
||||
throw new Error(`${flag} requires a value`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function commandFor(options) {
|
||||
if (options.shell) {
|
||||
return {
|
||||
command: process.env.SHELL || "/bin/sh",
|
||||
args: ["-l"],
|
||||
};
|
||||
}
|
||||
|
||||
if (options.codexBin) {
|
||||
return {
|
||||
command: options.codexBin,
|
||||
args: options.codexArgs,
|
||||
};
|
||||
}
|
||||
|
||||
const debugCodex = join(
|
||||
repoRoot,
|
||||
"codex-rs",
|
||||
"target",
|
||||
"debug",
|
||||
process.platform === "win32" ? "codex.exe" : "codex",
|
||||
);
|
||||
if (existsSync(debugCodex)) {
|
||||
return {
|
||||
command: debugCodex,
|
||||
args: options.codexArgs,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
command: "cargo",
|
||||
args: [
|
||||
"run",
|
||||
"--manifest-path",
|
||||
join(repoRoot, "codex-rs", "Cargo.toml"),
|
||||
"--bin",
|
||||
"codex",
|
||||
"--",
|
||||
...options.codexArgs,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function createPty(options) {
|
||||
const { command, args } = commandFor(options);
|
||||
return pty.spawn(command, args, {
|
||||
name: "xterm-256color",
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: options.cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: "xterm-256color",
|
||||
COLORTERM: "truecolor",
|
||||
TERM_PROGRAM: "wterm",
|
||||
CODEX_TUI_DISABLE_KEYBOARD_ENHANCEMENT: "1",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function isAllowedOrigin(request) {
|
||||
const origin = request.headers.origin;
|
||||
if (!origin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(origin).host === request.headers.host;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function sendHttp(response, status, body, headers = {}) {
|
||||
response.writeHead(status, {
|
||||
"content-type": "text/plain; charset=utf-8",
|
||||
"cache-control": "no-store",
|
||||
...headers,
|
||||
});
|
||||
response.end(body);
|
||||
}
|
||||
|
||||
async function serveStatic(request, response) {
|
||||
const distRoot = join(packageRoot, "dist");
|
||||
const url = new URL(request.url ?? "/", "http://localhost");
|
||||
const pathname = decodeURIComponent(url.pathname);
|
||||
const relativePath = pathname === "/" ? "index.html" : pathname.slice(1);
|
||||
const resolvedPath = resolve(distRoot, relativePath);
|
||||
|
||||
if (!resolvedPath.startsWith(`${distRoot}/`) && resolvedPath !== distRoot) {
|
||||
sendHttp(response, 403, "Forbidden");
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = existsSync(resolvedPath)
|
||||
? resolvedPath
|
||||
: join(distRoot, "index.html");
|
||||
const contentType =
|
||||
MIME_TYPES[extname(filePath)] || "application/octet-stream";
|
||||
response.writeHead(200, {
|
||||
"content-type": contentType,
|
||||
"cache-control": filePath.endsWith("index.html")
|
||||
? "no-store"
|
||||
: "public, max-age=31536000, immutable",
|
||||
});
|
||||
createReadStream(filePath).pipe(response);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const server = createServer();
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
|
||||
if (options.dev) {
|
||||
const { createServer: createViteServer } = await import("vite");
|
||||
const vite = await createViteServer({
|
||||
root: packageRoot,
|
||||
server: { middlewareMode: true },
|
||||
appType: "spa",
|
||||
});
|
||||
server.on("request", (request, response) => {
|
||||
vite.middlewares(request, response, () => {
|
||||
sendHttp(response, 404, "Not found");
|
||||
});
|
||||
});
|
||||
} else {
|
||||
server.on("request", (request, response) => {
|
||||
if (request.url === "/healthz") {
|
||||
sendHttp(response, 200, "ok");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!existsSync(join(packageRoot, "dist", "index.html"))) {
|
||||
sendHttp(
|
||||
response,
|
||||
500,
|
||||
"Missing dist/. Run pnpm --filter @openai/codex-webui build first.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
void serveStatic(request, response).catch((error) => {
|
||||
sendHttp(
|
||||
response,
|
||||
500,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
server.on("upgrade", (request, socket, head) => {
|
||||
const url = new URL(request.url ?? "/", "http://localhost");
|
||||
if (url.pathname !== "/api/pty" || !isAllowedOrigin(request)) {
|
||||
socket.write("HTTP/1.1 403 Forbidden\r\n\r\n");
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
wss.handleUpgrade(request, socket, head, (websocket) => {
|
||||
wss.emit("connection", websocket, request);
|
||||
});
|
||||
});
|
||||
|
||||
wss.on("connection", (websocket) => {
|
||||
let child;
|
||||
try {
|
||||
child = createPty(options);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (websocket.readyState === websocket.OPEN) {
|
||||
websocket.send(Buffer.from(`Failed to start PTY: ${message}\r\n`));
|
||||
websocket.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const output = child.onData((data) => {
|
||||
if (websocket.readyState === websocket.OPEN) {
|
||||
websocket.send(Buffer.from(data, "utf8"));
|
||||
}
|
||||
});
|
||||
|
||||
child.onExit(({ exitCode, signal }) => {
|
||||
if (websocket.readyState === websocket.OPEN) {
|
||||
websocket.send(
|
||||
Buffer.from(
|
||||
`\r\n[process exited: ${signal ?? exitCode}]\r\n`,
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
websocket.close();
|
||||
}
|
||||
});
|
||||
|
||||
websocket.on("message", (data) => {
|
||||
const frame = decodeClientFrame(data);
|
||||
if (frame.type === "input") {
|
||||
child.write(frame.data);
|
||||
} else if (frame.type === "resize") {
|
||||
child.resize(frame.cols, frame.rows);
|
||||
}
|
||||
});
|
||||
|
||||
websocket.on("close", () => {
|
||||
output.dispose();
|
||||
child.kill();
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise((resolveListen) => {
|
||||
server.listen(options.port, options.host, resolveListen);
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
const port =
|
||||
typeof address === "object" && address ? address.port : options.port;
|
||||
console.log(`Codex web terminal listening on http://${options.host}:${port}`);
|
||||
}
|
||||
|
||||
if (process.argv[1] === __filename) {
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
138
codex-webui/src/main.ts
Normal file
138
codex-webui/src/main.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { WTerm } from "@wterm/dom";
|
||||
import { GhosttyCore } from "@wterm/ghostty";
|
||||
import "./styles.css";
|
||||
|
||||
const INPUT_FRAME = 0x00;
|
||||
const RESIZE_FRAME = 0x01;
|
||||
|
||||
function requireElement(id: string): HTMLElement {
|
||||
const element = document.getElementById(id);
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
throw new Error(`Missing ${id} element`);
|
||||
}
|
||||
return element;
|
||||
}
|
||||
|
||||
const terminalElement = requireElement("terminal");
|
||||
const statusElement = requireElement("status");
|
||||
const encoder = new TextEncoder();
|
||||
let socket: WebSocket | null = null;
|
||||
const pendingFrames: Uint8Array[] = [];
|
||||
|
||||
function showStatus(message: string): void {
|
||||
statusElement.textContent = message;
|
||||
statusElement.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function clearStatus(): void {
|
||||
statusElement.textContent = "";
|
||||
statusElement.classList.add("hidden");
|
||||
}
|
||||
|
||||
function encodeInput(data: string): Uint8Array {
|
||||
const encoded = encoder.encode(data);
|
||||
const frame = new Uint8Array(1 + encoded.length);
|
||||
frame[0] = INPUT_FRAME;
|
||||
frame.set(encoded, 1);
|
||||
return frame;
|
||||
}
|
||||
|
||||
function encodeResize(cols: number, rows: number): Uint8Array {
|
||||
const frame = new Uint8Array(5);
|
||||
const view = new DataView(frame.buffer);
|
||||
view.setUint8(0, RESIZE_FRAME);
|
||||
view.setUint16(1, cols, false);
|
||||
view.setUint16(3, rows, false);
|
||||
return frame;
|
||||
}
|
||||
|
||||
function sendFrame(frame: Uint8Array): void {
|
||||
const payload = frame.buffer.slice(
|
||||
frame.byteOffset,
|
||||
frame.byteOffset + frame.byteLength,
|
||||
) as ArrayBuffer;
|
||||
if (socket?.readyState === WebSocket.OPEN) {
|
||||
socket.send(payload);
|
||||
return;
|
||||
}
|
||||
pendingFrames.push(new Uint8Array(payload));
|
||||
}
|
||||
|
||||
function flushPendingFrames(): void {
|
||||
while (socket?.readyState === WebSocket.OPEN && pendingFrames.length > 0) {
|
||||
const frame = pendingFrames.shift();
|
||||
if (frame) {
|
||||
const payload = frame.buffer.slice(
|
||||
frame.byteOffset,
|
||||
frame.byteOffset + frame.byteLength,
|
||||
) as ArrayBuffer;
|
||||
socket.send(payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function websocketUrl(): string {
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
return `${protocol}//${window.location.host}/api/pty`;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const core = await GhosttyCore.load({ scrollbackLimit: 10000 });
|
||||
const term = new WTerm(terminalElement, {
|
||||
core,
|
||||
autoResize: true,
|
||||
cursorBlink: false,
|
||||
onData(data) {
|
||||
sendFrame(encodeInput(data));
|
||||
},
|
||||
onResize(cols, rows) {
|
||||
sendFrame(encodeResize(cols, rows));
|
||||
},
|
||||
onTitle(title) {
|
||||
document.title = title
|
||||
? `${title} - Codex Web Terminal`
|
||||
: "Codex Web Terminal";
|
||||
},
|
||||
});
|
||||
|
||||
socket = new WebSocket(websocketUrl());
|
||||
socket.binaryType = "arraybuffer";
|
||||
|
||||
socket.addEventListener("open", () => {
|
||||
clearStatus();
|
||||
flushPendingFrames();
|
||||
term.focus();
|
||||
});
|
||||
|
||||
socket.addEventListener("message", (event) => {
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
term.write(new Uint8Array(event.data));
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.data instanceof Blob) {
|
||||
void event.data.arrayBuffer().then((buffer) => {
|
||||
term.write(new Uint8Array(buffer));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
term.write(String(event.data));
|
||||
});
|
||||
|
||||
socket.addEventListener("close", () => {
|
||||
showStatus("Terminal session disconnected.");
|
||||
});
|
||||
|
||||
socket.addEventListener("error", () => {
|
||||
showStatus("Terminal connection failed.");
|
||||
});
|
||||
|
||||
await term.init();
|
||||
term.focus();
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
showStatus(`Failed to start terminal: ${message}`);
|
||||
});
|
||||
19
codex-webui/src/styles.css
Normal file
19
codex-webui/src/styles.css
Normal file
@@ -0,0 +1,19 @@
|
||||
@import "tailwindcss";
|
||||
@import "@wterm/dom/css";
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#terminal {
|
||||
padding: max(env(safe-area-inset-top), 0.5rem)
|
||||
max(env(safe-area-inset-right), 0.5rem)
|
||||
max(env(safe-area-inset-bottom), 0.5rem)
|
||||
max(env(safe-area-inset-left), 0.5rem);
|
||||
}
|
||||
1
codex-webui/src/vite-env.d.ts
vendored
Normal file
1
codex-webui/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
43
codex-webui/tests/protocol.test.mjs
Normal file
43
codex-webui/tests/protocol.test.mjs
Normal file
@@ -0,0 +1,43 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { decodeClientFrame, INPUT_FRAME, RESIZE_FRAME } from "../protocol.mjs";
|
||||
|
||||
test("decodes input frames", () => {
|
||||
const frame = Buffer.concat([
|
||||
Buffer.from([INPUT_FRAME]),
|
||||
Buffer.from("hello"),
|
||||
]);
|
||||
assert.deepEqual(decodeClientFrame(frame), { type: "input", data: "hello" });
|
||||
});
|
||||
|
||||
test("decodes resize frames", () => {
|
||||
const frame = Buffer.alloc(5);
|
||||
frame.writeUInt8(RESIZE_FRAME, 0);
|
||||
frame.writeUInt16BE(120, 1);
|
||||
frame.writeUInt16BE(40, 3);
|
||||
|
||||
assert.deepEqual(decodeClientFrame(frame), {
|
||||
type: "resize",
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects malformed resize frames", () => {
|
||||
assert.deepEqual(decodeClientFrame(Buffer.from([RESIZE_FRAME, 0, 80])), {
|
||||
type: "invalid",
|
||||
reason: "malformed resize frame",
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects zero resize dimensions", () => {
|
||||
const frame = Buffer.alloc(5);
|
||||
frame.writeUInt8(RESIZE_FRAME, 0);
|
||||
frame.writeUInt16BE(0, 1);
|
||||
frame.writeUInt16BE(24, 3);
|
||||
|
||||
assert.deepEqual(decodeClientFrame(frame), {
|
||||
type: "invalid",
|
||||
reason: "resize dimensions must be positive",
|
||||
});
|
||||
});
|
||||
16
codex-webui/tests/server.test.mjs
Normal file
16
codex-webui/tests/server.test.mjs
Normal file
@@ -0,0 +1,16 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { parseArgs } from "../server.mjs";
|
||||
|
||||
test("parses pnpm script argument separator before server flags", () => {
|
||||
const options = parseArgs(["--dev", "--", "--port", "4322", "--shell"]);
|
||||
assert.equal(options.dev, true);
|
||||
assert.equal(options.port, 4322);
|
||||
assert.equal(options.shell, true);
|
||||
assert.deepEqual(options.codexArgs, []);
|
||||
});
|
||||
|
||||
test("uses separator before non-server flags as codex args", () => {
|
||||
const options = parseArgs(["--dev", "--", "--model", "gpt-test"]);
|
||||
assert.deepEqual(options.codexArgs, ["--model", "gpt-test"]);
|
||||
});
|
||||
16
codex-webui/tsconfig.json
Normal file
16
codex-webui/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": false,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
6
codex-webui/vite.config.ts
Normal file
6
codex-webui/vite.config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss()],
|
||||
});
|
||||
799
pnpm-lock.yaml
generated
799
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
packages:
|
||||
- codex-cli
|
||||
- codex-webui
|
||||
- codex-rs/responses-api-proxy/npm
|
||||
- sdk/typescript
|
||||
|
||||
@@ -7,11 +8,15 @@ ignoredBuiltDependencies:
|
||||
- esbuild
|
||||
|
||||
minimumReleaseAge: 10080
|
||||
minimumReleaseAgeExclude: []
|
||||
minimumReleaseAgeExclude:
|
||||
- '@wterm/core@0.3.0'
|
||||
- '@wterm/dom@0.3.0'
|
||||
- '@wterm/ghostty@0.3.0'
|
||||
|
||||
blockExoticSubdeps: true
|
||||
strictDepBuilds: true
|
||||
trustPolicy: no-downgrade
|
||||
trustPolicyIgnoreAfter: 10080
|
||||
trustPolicyExclude: []
|
||||
allowBuilds: {}
|
||||
allowBuilds:
|
||||
node-pty: true
|
||||
|
||||
Reference in New Issue
Block a user