Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 8 additions & 9 deletions assets/inject/renderer-inject.js
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,11 @@
installCodexPlusForceChineseLocale();

const helperBase = window.__CODEX_SESSION_DELETE_HELPER__ || "http://127.0.0.1:57321";
const helperAuthToken = window.__CODEX_PLUS_HELPER_TOKEN__ || "";
const helperJsonHeaders = () => ({
"Content-Type": "application/json",
"X-Codex-Plus-Token": helperAuthToken,
});
const buttonClass = "codex-delete-button";
const exportButtonClass = "codex-export-button";
const projectMoveButtonClass = "codex-project-move-button";
Expand Down Expand Up @@ -3743,15 +3748,9 @@
window.__codexSessionDeleteBridge("/diagnostics/log", payload).catch(() => {});
}
const body = JSON.stringify(payload);
try {
if (navigator.sendBeacon) {
const blob = new Blob([body], { type: "application/json" });
if (navigator.sendBeacon(`${helperBase}/diagnostics/log`, blob)) return;
}
} catch (_) {}
fetch(`${helperBase}/diagnostics/log`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: helperJsonHeaders(),
body,
keepalive: true,
}).catch(() => {});
Expand Down Expand Up @@ -4387,7 +4386,7 @@
try {
const response = await fetch(`${helperBase}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: helperJsonHeaders(),
body: JSON.stringify(payload || {}),
});
return await response.json();
Expand All @@ -4408,7 +4407,7 @@
try {
const response = await fetch(`${helperBase}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
headers: helperJsonHeaders(),
body: JSON.stringify(payload || {}),
});
return await response.json();
Expand Down
26 changes: 25 additions & 1 deletion crates/codex-plus-core/src/assets.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,18 @@ pub fn injection_script(helper_port: u16) -> String {
}

pub fn injection_script_with_settings(helper_port: u16, settings: &BackendSettings) -> String {
injection_script_with_settings_and_token(
helper_port,
settings,
crate::launcher::helper_auth_token(),
)
}

fn injection_script_with_settings_and_token(
helper_port: u16,
settings: &BackendSettings,
helper_auth_token: &str,
) -> String {
let helper_url = format!("http://127.0.0.1:{helper_port}");
let sponsor_images = sponsor_image_data_uris();
let image_overlay = image_overlay_config(helper_port, settings);
Expand All @@ -39,8 +51,9 @@ pub fn injection_script_with_settings(helper_port: u16, settings: &BackendSettin
let force_chinese_locale = force_chinese_locale_config(settings);
let fast_startup = fast_startup_config(settings);
format!(
"window.__CODEX_SESSION_DELETE_HELPER__ = {};\nwindow.__CODEX_PLUS_SPONSOR_IMAGES__ = {};\nwindow.__CODEX_PLUS_VERSION__ = {};\nwindow.__CODEX_PLUS_BUILD__ = {};\nwindow.__CODEX_PLUS_IMAGE_OVERLAY__ = {};\nwindow.__CODEX_PLUS_PLUGIN_MARKETPLACES__ = {};\nwindow.__CODEX_PLUS_PASTE_FIX__ = {};\nwindow.__CODEX_PLUS_FORCE_CHINESE_LOCALE__ = {};\nwindow.__CODEX_PLUS_FAST_STARTUP__ = {};\n{}\n{}",
"window.__CODEX_SESSION_DELETE_HELPER__ = {};\nwindow.__CODEX_PLUS_HELPER_TOKEN__ = {};\nwindow.__CODEX_PLUS_SPONSOR_IMAGES__ = {};\nwindow.__CODEX_PLUS_VERSION__ = {};\nwindow.__CODEX_PLUS_BUILD__ = {};\nwindow.__CODEX_PLUS_IMAGE_OVERLAY__ = {};\nwindow.__CODEX_PLUS_PLUGIN_MARKETPLACES__ = {};\nwindow.__CODEX_PLUS_PASTE_FIX__ = {};\nwindow.__CODEX_PLUS_FORCE_CHINESE_LOCALE__ = {};\nwindow.__CODEX_PLUS_FAST_STARTUP__ = {};\n{}\n{}",
serde_json::to_string(&helper_url).expect("helper URL should serialize"),
serde_json::to_string(helper_auth_token).expect("helper auth token should serialize"),
serde_json::to_string(&sponsor_images).expect("sponsor images should serialize"),
serde_json::to_string(crate::version::VERSION).expect("version should serialize"),
serde_json::to_string(DIAGNOSTIC_BUILD_ID).expect("build id should serialize"),
Expand Down Expand Up @@ -303,6 +316,17 @@ fn image_content_type(path: &Path) -> Option<&'static str> {
mod tests {
use super::*;

#[test]
fn injection_script_embeds_helper_auth_token() {
let script = injection_script_with_settings_and_token(
57321,
&BackendSettings::default(),
"test-helper-token",
);

assert!(script.contains(r#"window.__CODEX_PLUS_HELPER_TOKEN__ = "test-helper-token";"#));
}

#[test]
fn image_overlay_config_includes_fit_mode() {
let settings = BackendSettings {
Expand Down
181 changes: 161 additions & 20 deletions crates/codex-plus-core/src/launcher.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Arc;
use std::sync::{Arc, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};

use anyhow::Context;
Expand All @@ -15,6 +15,15 @@ use tokio::sync::Mutex;
use crate::settings::{BackendSettings, SettingsStore, normalize_codex_extra_args};
use crate::status::{LaunchStatus, StatusStore};

const HELPER_AUTH_HEADER: &str = "x-codex-plus-token";
static HELPER_AUTH_TOKEN: OnceLock<String> = OnceLock::new();

pub fn helper_auth_token() -> &'static str {
HELPER_AUTH_TOKEN
.get_or_init(|| uuid::Uuid::new_v4().simple().to_string())
.as_str()
}

#[cfg(windows)]
const POST_LAUNCH_COMPUTER_USE_GUARD_SECONDS: &[u64] = &[0, 5, 15, 30, 60, 120, 180, 240, 300];
#[cfg_attr(not(windows), allow(dead_code))]
Expand Down Expand Up @@ -939,6 +948,10 @@ async fn handle_helper_connection(
let request_body = http_request_body(&request);
let request_user_agent = header_value_from_request(&request, "user-agent");
let remote_addr_text = remote_addr.map(|addr| addr.to_string());
let is_responses_proxy = crate::protocol_proxy::is_responses_proxy_path(path);
let is_chat_proxy = crate::protocol_proxy::is_chat_completions_proxy_path(path);
let is_models_proxy = crate::protocol_proxy::is_models_proxy_path(path);
let is_protocol_proxy = is_responses_proxy || is_chat_proxy || is_models_proxy;

let _ = crate::diagnostic_log::append_diagnostic_log(
"helper.request",
Expand All @@ -951,7 +964,36 @@ async fn handle_helper_connection(
}),
);

if crate::protocol_proxy::is_responses_proxy_path(path) && method == "POST" {
if is_protocol_proxy && method == "OPTIONS" {
write_http_response(
&mut stream,
"405 Method Not Allowed",
"application/json; charset=utf-8",
br#"{"status":"failed","message":"CORS preflight is not supported"}"#,
)
.await?;
stream.shutdown().await?;
return Ok(());
}
if is_protocol_proxy && !protocol_proxy_request_authorized(&request) {
write_http_response(
&mut stream,
"401 Unauthorized",
"application/json; charset=utf-8",
br#"{"status":"failed","message":"Unauthorized"}"#,
)
.await?;
log_helper_response(
"helper.protocol_proxy_unauthorized",
method,
path,
"401 Unauthorized",
remote_addr_text,
);
stream.shutdown().await?;
return Ok(());
}
if is_responses_proxy && method == "POST" {
return handle_protocol_proxy_connection(
&mut stream,
request_body,
Expand All @@ -962,7 +1004,7 @@ async fn handle_helper_connection(
)
.await;
}
if crate::protocol_proxy::is_chat_completions_proxy_path(path) && method == "POST" {
if is_chat_proxy && method == "POST" {
return handle_chat_completions_proxy_connection(
&mut stream,
request_body,
Expand All @@ -973,7 +1015,7 @@ async fn handle_helper_connection(
)
.await;
}
if crate::protocol_proxy::is_models_proxy_path(path) && matches!(method, "GET" | "OPTIONS") {
if is_models_proxy && method == "GET" {
return handle_models_proxy_connection(
&mut stream,
request_user_agent.as_deref(),
Expand All @@ -983,6 +1025,36 @@ async fn handle_helper_connection(
)
.await;
}
if is_protocol_proxy {
write_http_response(
&mut stream,
"405 Method Not Allowed",
"application/json; charset=utf-8",
br#"{"status":"failed","message":"Method not allowed"}"#,
)
.await?;
stream.shutdown().await?;
return Ok(());
}

let is_renderer_helper_path = matches!(
path,
"/backend/status" | "/diagnostics/log" | "/overlay/image"
);
if is_renderer_helper_path
&& method != "OPTIONS"
&& !renderer_helper_request_authorized(&request)
{
write_cors_http_response(
&mut stream,
"401 Unauthorized",
"application/json; charset=utf-8",
br#"{"status":"failed","message":"Unauthorized"}"#,
)
.await?;
stream.shutdown().await?;
return Ok(());
}

let (status, body, content_type, log_event) = if path == "/backend/status"
&& matches!(method, "GET" | "POST" | "OPTIONS")
Expand Down Expand Up @@ -1057,11 +1129,11 @@ async fn handle_helper_connection(
);
let response = if method == "OPTIONS" {
format!(
"HTTP/1.1 204 No Content\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, POST, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type, Authorization\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
"HTTP/1.1 204 No Content\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, POST, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type, {HELPER_AUTH_HEADER}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
)
} else {
format!(
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, POST, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type, Authorization\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, POST, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type, {HELPER_AUTH_HEADER}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
)
};
Expand Down Expand Up @@ -1131,17 +1203,6 @@ async fn handle_models_proxy_connection(
path: &str,
remote_addr_text: Option<String>,
) -> anyhow::Result<()> {
if method == "OPTIONS" {
write_http_response(
stream,
"204 No Content",
"application/json; charset=utf-8",
&[],
)
.await?;
stream.shutdown().await?;
return Ok(());
}
let upstream = match crate::protocol_proxy::open_models_proxy_request(request_user_agent).await
{
Ok(upstream) => upstream,
Expand Down Expand Up @@ -1438,7 +1499,22 @@ async fn write_http_response(
body: &[u8],
) -> anyhow::Result<()> {
let response = format!(
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, POST, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type, Authorization\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
stream.write_all(response.as_bytes()).await?;
stream.write_all(body).await?;
Ok(())
}

async fn write_cors_http_response(
stream: &mut tokio::net::TcpStream,
status: &str,
content_type: &str,
body: &[u8],
) -> anyhow::Result<()> {
let response = format!(
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, POST, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type, {HELPER_AUTH_HEADER}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
);
stream.write_all(response.as_bytes()).await?;
Expand All @@ -1452,7 +1528,7 @@ async fn write_http_stream_headers(
content_type: &str,
) -> anyhow::Result<()> {
let response = format!(
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nCache-Control: no-cache\r\nAccess-Control-Allow-Origin: *\r\nAccess-Control-Allow-Methods: GET, POST, OPTIONS\r\nAccess-Control-Allow-Headers: Content-Type, Authorization\r\nConnection: close\r\n\r\n"
"HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nCache-Control: no-cache\r\nConnection: close\r\n\r\n"
);
stream.write_all(response.as_bytes()).await?;
Ok(())
Expand All @@ -1478,7 +1554,10 @@ fn log_helper_response(

#[cfg(test)]
mod computer_use_tests {
use super::{header_value_from_request, overlay_image_content_type};
use super::{
bearer_token, header_value_from_request, helper_auth_token, overlay_image_content_type,
protocol_proxy_request_authorized_for_key, renderer_helper_request_authorized,
};
use std::path::Path;

#[test]
Expand Down Expand Up @@ -1507,6 +1586,41 @@ mod computer_use_tests {
Some("Codex/26.614")
);
}

#[test]
fn helper_authorization_requires_exact_runtime_token() {
let token = helper_auth_token();
let authorized =
format!("POST /backend/status HTTP/1.1\r\nX-Codex-Plus-Token: {token}\r\n\r\n");
let unauthorized = "POST /backend/status HTTP/1.1\r\nX-Codex-Plus-Token: wrong\r\n\r\n";

assert!(renderer_helper_request_authorized(&authorized));
assert!(!renderer_helper_request_authorized(unauthorized));
}

#[test]
fn bearer_token_requires_bearer_scheme_and_value() {
assert_eq!(bearer_token("Bearer sk-test"), Some("sk-test"));
assert_eq!(bearer_token("bearer sk-test "), Some("sk-test"));
assert_eq!(bearer_token("Basic sk-test"), None);
assert_eq!(bearer_token("Bearer"), None);
}

#[test]
fn protocol_proxy_authorization_requires_active_relay_key() {
let authorized = "POST /v1/responses HTTP/1.1\r\nAuthorization: Bearer sk-active\r\n\r\n";
let unauthorized = "POST /v1/responses HTTP/1.1\r\nAuthorization: Bearer sk-other\r\n\r\n";

assert!(protocol_proxy_request_authorized_for_key(
authorized,
"sk-active"
));
assert!(!protocol_proxy_request_authorized_for_key(
unauthorized,
"sk-active"
));
assert!(!protocol_proxy_request_authorized_for_key(authorized, ""));
}
}

async fn read_http_request(stream: &mut tokio::net::TcpStream) -> anyhow::Result<Vec<u8>> {
Expand Down Expand Up @@ -1579,6 +1693,33 @@ fn header_value_from_request(request: &str, header_name: &str) -> Option<String>
.filter(|value| !value.is_empty())
}

fn renderer_helper_request_authorized(request: &str) -> bool {
header_value_from_request(request, HELPER_AUTH_HEADER)
.is_some_and(|token| token == helper_auth_token())
}

fn protocol_proxy_request_authorized(request: &str) -> bool {
let Ok(settings) = SettingsStore::default().load() else {
return false;
};
let relay = settings.active_relay_profile();
let expected = crate::relay_config::relay_profile_api_key(&relay);
protocol_proxy_request_authorized_for_key(request, &expected)
}

fn protocol_proxy_request_authorized_for_key(request: &str, expected: &str) -> bool {
!expected.is_empty()
&& header_value_from_request(request, "authorization")
.and_then(|header| bearer_token(&header).map(ToString::to_string))
.is_some_and(|provided| provided == expected)
}

fn bearer_token(header: &str) -> Option<&str> {
let (scheme, token) = header.trim().split_once(' ')?;
let token = token.trim();
(scheme.eq_ignore_ascii_case("bearer") && !token.is_empty()).then_some(token)
}

fn sanitize_diagnostic_event(event: &str) -> String {
let sanitized = event
.chars()
Expand Down
Loading