mirror of
https://github.com/openai/codex.git
synced 2026-05-29 23:40:29 +00:00
## Summary This PR moves Codex backend request authentication from direct bearer-token handling to `AuthProvider`. The new `codex-auth-provider` crate defines the shared request-auth trait. `CodexAuth::provider()` returns a provider that can apply all headers needed for the selected auth mode. This lets ChatGPT token auth and AgentIdentity auth share the same callsite path: - ChatGPT token auth applies bearer auth plus account/FedRAMP headers where needed. - AgentIdentity auth applies AgentAssertion plus account/FedRAMP headers where needed. Reference old stack: https://github.com/openai/codex/pull/17387/changes ## Callsite Migration | Area | Change | | --- | --- | | backend-client | accepts an `AuthProvider` instead of a raw token/header | | chatgpt client/connectors | applies auth through `CodexAuth::provider()` | | cloud tasks | keeps Codex-backend gating, applies auth through provider | | cloud requirements | uses Codex-backend auth checks and provider headers | | app-server remote control | applies provider headers for backend calls | | MCP Apps/connectors | gates on `uses_codex_backend()` and keys caches from generic account getters | | model refresh | treats AgentIdentity as Codex-backend auth | | OpenAI file upload path | rejects non-Codex-backend auth before applying headers | | core client setup | keeps model-provider auth flow and allows AgentIdentity through provider-backed OpenAI auth | ## Stack 1. https://github.com/openai/codex/pull/18757: full revert 2. https://github.com/openai/codex/pull/18871: isolated Agent Identity crate 3. https://github.com/openai/codex/pull/18785: explicit AgentIdentity auth mode and startup task allocation 4. This PR: migrate Codex backend auth callsites through AuthProvider 5. https://github.com/openai/codex/pull/18904: accept AgentIdentity JWTs and load `CODEX_AGENT_IDENTITY` ## Testing Tests: targeted Rust checks, cargo-shear, Bazel lock check, and CI.
260 lines
7.8 KiB
Rust
260 lines
7.8 KiB
Rust
use anyhow::Context;
|
|
use anyhow::Result;
|
|
use serde::Deserialize;
|
|
use std::path::Component;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
use std::time::Duration;
|
|
|
|
use codex_login::CodexAuth;
|
|
use codex_login::default_client::build_reqwest_client;
|
|
|
|
const REMOTE_SKILLS_API_TIMEOUT: Duration = Duration::from_secs(30);
|
|
|
|
// Low-level client for the remote skill API. This is intentionally kept around for
|
|
// future wiring, but it is not used yet by any active product surface.
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum RemoteSkillScope {
|
|
WorkspaceShared,
|
|
AllShared,
|
|
Personal,
|
|
Example,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum RemoteSkillProductSurface {
|
|
Chatgpt,
|
|
Codex,
|
|
Api,
|
|
Atlas,
|
|
}
|
|
|
|
fn as_query_scope(scope: RemoteSkillScope) -> Option<&'static str> {
|
|
match scope {
|
|
RemoteSkillScope::WorkspaceShared => Some("workspace-shared"),
|
|
RemoteSkillScope::AllShared => Some("all-shared"),
|
|
RemoteSkillScope::Personal => Some("personal"),
|
|
RemoteSkillScope::Example => Some("example"),
|
|
}
|
|
}
|
|
|
|
fn as_query_product_surface(product_surface: RemoteSkillProductSurface) -> &'static str {
|
|
match product_surface {
|
|
RemoteSkillProductSurface::Chatgpt => "chatgpt",
|
|
RemoteSkillProductSurface::Codex => "codex",
|
|
RemoteSkillProductSurface::Api => "api",
|
|
RemoteSkillProductSurface::Atlas => "atlas",
|
|
}
|
|
}
|
|
|
|
fn ensure_codex_backend_auth(auth: Option<&CodexAuth>) -> Result<&CodexAuth> {
|
|
let Some(auth) = auth else {
|
|
anyhow::bail!("chatgpt authentication required for remote skill scopes");
|
|
};
|
|
if !auth.uses_codex_backend() {
|
|
anyhow::bail!(
|
|
"chatgpt authentication required for remote skill scopes; api key auth is not supported"
|
|
);
|
|
}
|
|
Ok(auth)
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct RemoteSkillSummary {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub description: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct RemoteSkillDownloadResult {
|
|
pub id: String,
|
|
pub path: PathBuf,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct RemoteSkillsResponse {
|
|
#[serde(rename = "hazelnuts")]
|
|
skills: Vec<RemoteSkill>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct RemoteSkill {
|
|
id: String,
|
|
name: String,
|
|
description: String,
|
|
}
|
|
|
|
pub async fn list_remote_skills(
|
|
chatgpt_base_url: String,
|
|
auth: Option<&CodexAuth>,
|
|
scope: RemoteSkillScope,
|
|
product_surface: RemoteSkillProductSurface,
|
|
enabled: Option<bool>,
|
|
) -> Result<Vec<RemoteSkillSummary>> {
|
|
let base_url = chatgpt_base_url.trim_end_matches('/');
|
|
let auth = ensure_codex_backend_auth(auth)?;
|
|
|
|
let url = format!("{base_url}/hazelnuts");
|
|
let product_surface = as_query_product_surface(product_surface);
|
|
let mut query_params = vec![("product_surface", product_surface)];
|
|
if let Some(scope) = as_query_scope(scope) {
|
|
query_params.push(("scope", scope));
|
|
}
|
|
if let Some(enabled) = enabled {
|
|
let enabled = if enabled { "true" } else { "false" };
|
|
query_params.push(("enabled", enabled));
|
|
}
|
|
|
|
let client = build_reqwest_client();
|
|
let request = client
|
|
.get(&url)
|
|
.timeout(REMOTE_SKILLS_API_TIMEOUT)
|
|
.query(&query_params)
|
|
.headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers());
|
|
let response = request
|
|
.send()
|
|
.await
|
|
.with_context(|| format!("Failed to send request to {url}"))?;
|
|
|
|
let status = response.status();
|
|
let body = response.text().await.unwrap_or_default();
|
|
if !status.is_success() {
|
|
anyhow::bail!("Request failed with status {status} from {url}: {body}");
|
|
}
|
|
|
|
let parsed: RemoteSkillsResponse =
|
|
serde_json::from_str(&body).context("Failed to parse skills response")?;
|
|
|
|
Ok(parsed
|
|
.skills
|
|
.into_iter()
|
|
.map(|skill| RemoteSkillSummary {
|
|
id: skill.id,
|
|
name: skill.name,
|
|
description: skill.description,
|
|
})
|
|
.collect())
|
|
}
|
|
|
|
pub async fn export_remote_skill(
|
|
chatgpt_base_url: String,
|
|
codex_home: PathBuf,
|
|
auth: Option<&CodexAuth>,
|
|
skill_id: &str,
|
|
) -> Result<RemoteSkillDownloadResult> {
|
|
let auth = ensure_codex_backend_auth(auth)?;
|
|
|
|
let client = build_reqwest_client();
|
|
let base_url = chatgpt_base_url.trim_end_matches('/');
|
|
let url = format!("{base_url}/hazelnuts/{skill_id}/export");
|
|
let request = client
|
|
.get(&url)
|
|
.timeout(REMOTE_SKILLS_API_TIMEOUT)
|
|
.headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers());
|
|
|
|
let response = request
|
|
.send()
|
|
.await
|
|
.with_context(|| format!("Failed to send download request to {url}"))?;
|
|
|
|
let status = response.status();
|
|
let body = response.bytes().await.context("Failed to read download")?;
|
|
if !status.is_success() {
|
|
let body_text = String::from_utf8_lossy(&body);
|
|
anyhow::bail!("Download failed with status {status} from {url}: {body_text}");
|
|
}
|
|
|
|
if !is_zip_payload(&body) {
|
|
anyhow::bail!("Downloaded remote skill payload is not a zip archive");
|
|
}
|
|
|
|
let output_dir = codex_home.join("skills").join(skill_id);
|
|
tokio::fs::create_dir_all(&output_dir)
|
|
.await
|
|
.context("Failed to create downloaded skills directory")?;
|
|
|
|
let zip_bytes = body.to_vec();
|
|
let output_dir_clone = output_dir.clone();
|
|
let prefix_candidates = vec![skill_id.to_string()];
|
|
tokio::task::spawn_blocking(move || {
|
|
extract_zip_to_dir(zip_bytes, &output_dir_clone, &prefix_candidates)
|
|
})
|
|
.await
|
|
.context("Zip extraction task failed")??;
|
|
|
|
Ok(RemoteSkillDownloadResult {
|
|
id: skill_id.to_string(),
|
|
path: output_dir,
|
|
})
|
|
}
|
|
|
|
fn safe_join(base: &Path, name: &str) -> Result<PathBuf> {
|
|
let path = Path::new(name);
|
|
for component in path.components() {
|
|
match component {
|
|
Component::Normal(_) => {}
|
|
_ => {
|
|
anyhow::bail!("Invalid file path in remote skill payload: {name}");
|
|
}
|
|
}
|
|
}
|
|
Ok(base.join(path))
|
|
}
|
|
|
|
fn is_zip_payload(bytes: &[u8]) -> bool {
|
|
bytes.starts_with(b"PK\x03\x04")
|
|
|| bytes.starts_with(b"PK\x05\x06")
|
|
|| bytes.starts_with(b"PK\x07\x08")
|
|
}
|
|
|
|
fn extract_zip_to_dir(
|
|
bytes: Vec<u8>,
|
|
output_dir: &Path,
|
|
prefix_candidates: &[String],
|
|
) -> Result<()> {
|
|
let cursor = std::io::Cursor::new(bytes);
|
|
let mut archive = zip::ZipArchive::new(cursor).context("Failed to open zip archive")?;
|
|
for i in 0..archive.len() {
|
|
let mut file = archive.by_index(i).context("Failed to read zip entry")?;
|
|
if file.is_dir() {
|
|
continue;
|
|
}
|
|
let raw_name = file.name().to_string();
|
|
let normalized = normalize_zip_name(&raw_name, prefix_candidates);
|
|
let Some(normalized) = normalized else {
|
|
continue;
|
|
};
|
|
let file_path = safe_join(output_dir, &normalized)?;
|
|
if let Some(parent) = file_path.parent() {
|
|
std::fs::create_dir_all(parent)
|
|
.with_context(|| format!("Failed to create parent dir for {normalized}"))?;
|
|
}
|
|
let mut out = std::fs::File::create(&file_path)
|
|
.with_context(|| format!("Failed to create file {normalized}"))?;
|
|
std::io::copy(&mut file, &mut out)
|
|
.with_context(|| format!("Failed to write skill file {normalized}"))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn normalize_zip_name(name: &str, prefix_candidates: &[String]) -> Option<String> {
|
|
let mut trimmed = name.trim_start_matches("./");
|
|
for prefix in prefix_candidates {
|
|
if prefix.is_empty() {
|
|
continue;
|
|
}
|
|
let prefix = format!("{prefix}/");
|
|
if let Some(rest) = trimmed.strip_prefix(&prefix) {
|
|
trimmed = rest;
|
|
break;
|
|
}
|
|
}
|
|
if trimmed.is_empty() {
|
|
None
|
|
} else {
|
|
Some(trimmed.to_string())
|
|
}
|
|
}
|