Codex Handbook
core-skills/src/remote.rs 259 lines
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())    }}