Compare commits

...

1 Commits
main ... webui

Author SHA1 Message Date
Felipe Coury
7be5328f34 feat: add codex web terminal 2026-05-02 17:16:20 -03:00
25 changed files with 2147 additions and 51 deletions

19
codex-rs/Cargo.lock generated
View File

@@ -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"

View File

@@ -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" }

View File

@@ -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 }

View File

@@ -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 });

View 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/**"]),
)

View 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 }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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>

View 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
View 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
View 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
View 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}` };
}

View 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
View 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
View 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}`);
});

View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View 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",
});
});

View 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
View 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"]
}

View File

@@ -0,0 +1,6 @@
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [tailwindcss()],
});

799
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -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