diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index 3f9ba7bfdf..3695aed834 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -317,6 +317,16 @@ client_request_definitions! { params: v2::ThreadReadParams, response: v2::ThreadReadResponse, }, + #[experimental("thread/share")] + ThreadShare => "thread/share" { + params: v2::ThreadShareParams, + response: v2::ThreadShareResponse, + }, + #[experimental("share/revoke")] + ShareRevoke => "share/revoke" { + params: v2::ShareRevokeParams, + response: v2::ShareRevokeResponse, + }, SkillsList => "skills/list" { params: v2::SkillsListParams, response: v2::SkillsListResponse, diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 4ce7e9c0bb..65ddcf540b 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3245,6 +3245,35 @@ pub struct ThreadReadResponse { pub thread: Thread, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadShareParams { + pub thread_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ThreadShareResponse { + pub share_id: String, + pub share_url: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ShareRevokeParams { + pub share_id: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ShareRevokeResponse { + pub revoked: bool, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server-test-client/src/lib.rs b/codex-rs/app-server-test-client/src/lib.rs index dd548012a0..27dda29ce7 100644 --- a/codex-rs/app-server-test-client/src/lib.rs +++ b/codex-rs/app-server-test-client/src/lib.rs @@ -53,6 +53,8 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ShareRevokeParams; +use codex_app_server_protocol::ShareRevokeResponse; use codex_app_server_protocol::ThreadDecrementElicitationParams; use codex_app_server_protocol::ThreadDecrementElicitationResponse; use codex_app_server_protocol::ThreadIncrementElicitationParams; @@ -62,6 +64,8 @@ use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadListResponse; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; +use codex_app_server_protocol::ThreadShareParams; +use codex_app_server_protocol::ThreadShareResponse; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::TurnStartParams; @@ -272,6 +276,18 @@ enum CliCommand { #[arg(long, default_value_t = 15)] hold_seconds: u64, }, + /// Create a shareable snapshot link for a persisted thread. + #[command(name = "thread-share")] + ThreadShare { + /// Existing thread id to share. + thread_id: String, + }, + /// Revoke a previously created share link. + #[command(name = "share-revoke")] + ShareRevoke { + /// Share id returned by thread-share. + share_id: String, + }, } pub async fn run() -> Result<()> { @@ -423,6 +439,16 @@ pub async fn run() -> Result<()> { hold_seconds, ) } + CliCommand::ThreadShare { thread_id } => { + ensure_dynamic_tools_unused(&dynamic_tools, "thread-share")?; + let endpoint = resolve_endpoint(codex_bin, url)?; + thread_share(&endpoint, &config_overrides, thread_id).await + } + CliCommand::ShareRevoke { share_id } => { + ensure_dynamic_tools_unused(&dynamic_tools, "share-revoke")?; + let endpoint = resolve_endpoint(codex_bin, url)?; + share_revoke(&endpoint, &config_overrides, share_id).await + } } } @@ -1137,6 +1163,40 @@ async fn thread_list(endpoint: &Endpoint, config_overrides: &[String], limit: u3 .await } +async fn thread_share( + endpoint: &Endpoint, + config_overrides: &[String], + thread_id: String, +) -> Result<()> { + with_client("thread-share", endpoint, config_overrides, |client| { + let initialize = client.initialize_with_experimental_api(true)?; + println!("< initialize response: {initialize:?}"); + + let response = client.thread_share(ThreadShareParams { thread_id })?; + println!("< thread/share response: {response:?}"); + + Ok(()) + }) + .await +} + +async fn share_revoke( + endpoint: &Endpoint, + config_overrides: &[String], + share_id: String, +) -> Result<()> { + with_client("share-revoke", endpoint, config_overrides, |client| { + let initialize = client.initialize_with_experimental_api(true)?; + println!("< initialize response: {initialize:?}"); + + let response = client.share_revoke(ShareRevokeParams { share_id })?; + println!("< share/revoke response: {response:?}"); + + Ok(()) + }) + .await +} + async fn with_client( command_name: &'static str, endpoint: &Endpoint, @@ -1681,6 +1741,26 @@ impl CodexClient { self.send_request(request, request_id, "thread/decrement_elicitation") } + fn thread_share(&mut self, params: ThreadShareParams) -> Result { + let request_id = self.request_id(); + let request = ClientRequest::ThreadShare { + request_id: request_id.clone(), + params, + }; + + self.send_request(request, request_id, "thread/share") + } + + fn share_revoke(&mut self, params: ShareRevokeParams) -> Result { + let request_id = self.request_id(); + let request = ClientRequest::ShareRevoke { + request_id: request_id.clone(), + params, + }; + + self.send_request(request, request_id, "share/revoke") + } + fn wait_for_account_login_completion( &mut self, expected_login_id: &str, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 94f86c7ebb..77975496e2 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -367,6 +367,25 @@ Use `thread/read` to fetch a stored thread by id without resuming it. Pass `incl } } ``` +### Example: Create a shareable snapshot link (experimental) + +`thread/share` creates a markdown snapshot of a persisted thread, publishes it through the Codex backend, and returns a share URL. This method rejects threads that have not materialized a rollout yet and requires ChatGPT authentication. + +```json +{ "method": "thread/share", "id": 24, "params": { "threadId": "thr_123" } } +{ "id": 24, "result": { + "shareId": "019cbc6b-bf5e-74e4-86c5-8e2b0d3ad2a1", + "shareUrl": "https://chatgpt.com/s/019cbc6b-bf5e-74e4-86c5-8e2b0d3ad2a1" +} } +``` + +`share/revoke` revokes a share previously created by the authenticated user. + +```json +{ "method": "share/revoke", "id": 25, "params": { "shareId": "019cbc6b-bf5e-74e4-86c5-8e2b0d3ad2a1" } } +{ "id": 25, "result": { "revoked": true } } +``` + ### Example: Update stored thread metadata Use `thread/metadata/update` to patch sqlite-backed metadata for a thread without resuming it. Today this supports persisted `gitInfo`; omitted fields are left unchanged, while explicit `null` clears a stored value. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 53f4a876c2..753a0db782 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -18,6 +18,7 @@ use crate::outgoing_message::RequestContext; use crate::outgoing_message::ThreadScopedOutgoingMessageSender; use crate::thread_status::ThreadWatchManager; use crate::thread_status::resolve_thread_status; +use anyhow::Context; use chrono::DateTime; use chrono::SecondsFormat; use chrono::Utc; @@ -112,6 +113,8 @@ use codex_app_server_protocol::ReviewTarget as ApiReviewTarget; use codex_app_server_protocol::SandboxMode; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequestResolvedNotification; +use codex_app_server_protocol::ShareRevokeParams; +use codex_app_server_protocol::ShareRevokeResponse; use codex_app_server_protocol::SkillSummary; use codex_app_server_protocol::SkillsConfigWriteParams; use codex_app_server_protocol::SkillsConfigWriteResponse; @@ -159,6 +162,7 @@ use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadRollbackParams; use codex_app_server_protocol::ThreadSetNameParams; use codex_app_server_protocol::ThreadSetNameResponse; +use codex_app_server_protocol::ThreadShareParams; use codex_app_server_protocol::ThreadShellCommandParams; use codex_app_server_protocol::ThreadShellCommandResponse; use codex_app_server_protocol::ThreadSortKey; @@ -189,6 +193,7 @@ use codex_app_server_protocol::WindowsSandboxSetupStartResponse; use codex_app_server_protocol::build_turns_from_rollout_items; use codex_arg0::Arg0DispatchPaths; use codex_backend_client::Client as BackendClient; +use codex_backend_client::CreateThreadShareRequest; use codex_chatgpt::connectors; use codex_cloud_requirements::cloud_requirements_loader; use codex_config::types::McpServerTransportConfig; @@ -197,7 +202,6 @@ use codex_core::Cursor as RolloutCursor; use codex_core::ForkSnapshot; use codex_core::NewThread; use codex_core::RolloutRecorder; -use codex_core::SessionMeta; use codex_core::SteerInputError; use codex_core::ThreadConfigSnapshot; use codex_core::ThreadManager; @@ -293,6 +297,7 @@ use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::ReviewTarget as CoreReviewTarget; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionConfiguredEvent; +use codex_protocol::protocol::SessionMeta; use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::ThreadNameUpdatedEvent; use codex_protocol::protocol::USER_MESSAGE_BEGIN; @@ -789,6 +794,14 @@ impl CodexMessageProcessor { self.thread_shell_command(to_connection_request_id(request_id), params) .await; } + ClientRequest::ThreadShare { request_id, params } => { + self.thread_share(to_connection_request_id(request_id), params) + .await; + } + ClientRequest::ShareRevoke { request_id, params } => { + self.share_revoke(to_connection_request_id(request_id), params) + .await; + } ClientRequest::SkillsList { request_id, params } => { self.skills_list(to_connection_request_id(request_id), params) .await; @@ -3714,6 +3727,212 @@ impl CodexMessageProcessor { self.outgoing.send_response(request_id, response).await; } + async fn thread_share(&self, request_id: ConnectionRequestId, params: ThreadShareParams) { + let thread_uuid = match ThreadId::from_string(¶ms.thread_id) { + Ok(id) => id, + Err(err) => { + self.send_invalid_request_error(request_id, format!("invalid thread id: {err}")) + .await; + return; + } + }; + + let thread_id = thread_uuid.to_string(); + let rollout_path = match find_thread_path_by_id_str(&self.config.codex_home, &thread_id) + .await + { + Ok(Some(path)) => path, + Ok(None) => { + match find_archived_thread_path_by_id_str(&self.config.codex_home, &thread_id).await + { + Ok(Some(path)) => path, + Ok(None) => { + self.outgoing + .send_error( + request_id, + JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!( + "thread {thread_uuid} is not materialized yet; thread/share is only available for persisted conversations" + ), + data: Some(serde_json::json!({ + "reason": "thread_not_persisted" + })), + }, + ) + .await; + return; + } + Err(err) => { + self.send_internal_error( + request_id, + format!("failed to locate archived thread {thread_uuid}: {err}"), + ) + .await; + return; + } + } + } + Err(err) => { + self.send_internal_error( + request_id, + format!("failed to locate thread {thread_uuid}: {err}"), + ) + .await; + return; + } + }; + + let mut thread = match load_thread_summary_for_rollout( + &self.config, + thread_uuid, + rollout_path.as_path(), + self.config.model_provider_id.as_str(), + /*persisted_metadata*/ None, + ) + .await + { + Ok(thread) => thread, + Err(err) => { + self.send_internal_error( + request_id, + format!("failed to snapshot thread {thread_uuid} for sharing: {err}"), + ) + .await; + return; + } + }; + if let Err(message) = populate_thread_turns( + &mut thread, + ThreadTurnSource::RolloutPath(rollout_path.as_path()), + /*active_turn*/ None, + ) + .await + { + self.send_internal_error(request_id, message).await; + return; + } + + let auth = match self.auth_manager.auth().await { + Some(auth) if auth.is_chatgpt_auth() => auth, + Some(_) | None => { + self.send_invalid_request_error( + request_id, + "chatgpt authentication required to create share link".to_string(), + ) + .await; + return; + } + }; + + let title = thread + .name + .clone() + .filter(|name| !name.trim().is_empty()) + .unwrap_or_else(|| { + if thread.preview.trim().is_empty() { + format!("Codex thread {}", thread.id) + } else { + thread.preview.clone() + } + }); + let markdown = match serde_json::to_string_pretty(&thread) + .map(|snapshot| format!("# {title}\n\n```json\n{snapshot}\n```\n")) + .context("serialize thread snapshot") + { + Ok(markdown) => markdown, + Err(err) => { + self.send_internal_error( + request_id, + format!("failed to render thread {thread_uuid} for sharing: {err}"), + ) + .await; + return; + } + }; + let client = match BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth) { + Ok(client) => client, + Err(err) => { + self.send_internal_error( + request_id, + format!("failed to construct backend client: {err}"), + ) + .await; + return; + } + }; + let request = CreateThreadShareRequest { title, markdown }; + match client.create_thread_share(&request).await { + Ok(response) => { + self.outgoing + .send_response( + request_id, + codex_app_server_protocol::ThreadShareResponse { + share_id: response.share_id, + share_url: response.share_url, + }, + ) + .await; + } + Err(err) => { + self.send_internal_error( + request_id, + format!("failed to create backend share link for thread {thread_uuid}: {err}"), + ) + .await; + } + } + } + + async fn share_revoke(&self, request_id: ConnectionRequestId, params: ShareRevokeParams) { + let share_id = params.share_id.trim(); + let has_path_chars = share_id.chars().any(|ch| matches!(ch, '/' | '?' | '#')); + if share_id.is_empty() || has_path_chars { + self.send_invalid_request_error(request_id, "shareId is invalid".to_string()) + .await; + return; + } + + let auth = match self.auth_manager.auth().await { + Some(auth) if auth.is_chatgpt_auth() => auth, + Some(_) | None => { + self.send_invalid_request_error( + request_id, + "chatgpt authentication required to revoke share link".to_string(), + ) + .await; + return; + } + }; + let client = match BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth) { + Ok(client) => client, + Err(err) => { + self.send_internal_error( + request_id, + format!("failed to construct backend client: {err}"), + ) + .await; + return; + } + }; + match client.revoke_thread_share(share_id).await { + Ok(response) => { + self.outgoing + .send_response( + request_id, + ShareRevokeResponse { + revoked: response.revoked, + }, + ) + .await; + } + Err(err) => { + self.send_internal_error(request_id, format!("failed to revoke share link: {err}")) + .await; + } + } + } + pub(crate) fn thread_created_receiver(&self) -> broadcast::Receiver { self.thread_manager.subscribe_thread_created() } @@ -9253,11 +9472,14 @@ mod tests { use crate::outgoing_message::OutgoingEnvelope; use crate::outgoing_message::OutgoingMessage; use anyhow::Result; + use chrono::Utc; use codex_app_server_protocol::ServerRequestPayload; use codex_app_server_protocol::ToolRequestUserInputParams; + use codex_core::SessionMeta; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; + use codex_protocol::protocol::USER_MESSAGE_BEGIN; use pretty_assertions::assert_eq; use serde_json::json; use std::path::PathBuf; diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index 76bb7bff43..f0d4aa94d7 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -58,6 +58,7 @@ use codex_app_server_protocol::PluginUninstallParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ReviewStartParams; use codex_app_server_protocol::ServerRequest; +use codex_app_server_protocol::ShareRevokeParams; use codex_app_server_protocol::SkillsListParams; use codex_app_server_protocol::ThreadArchiveParams; use codex_app_server_protocol::ThreadCompactStartParams; @@ -74,6 +75,7 @@ use codex_app_server_protocol::ThreadRealtimeStopParams; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadRollbackParams; use codex_app_server_protocol::ThreadSetNameParams; +use codex_app_server_protocol::ThreadShareParams; use codex_app_server_protocol::ThreadShellCommandParams; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadUnarchiveParams; @@ -451,6 +453,24 @@ impl McpProcess { self.send_request("thread/read", params).await } + /// Send a `thread/share` JSON-RPC request. + pub async fn send_thread_share_request( + &mut self, + params: ThreadShareParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("thread/share", params).await + } + + /// Send a `share/revoke` JSON-RPC request. + pub async fn send_share_revoke_request( + &mut self, + params: ShareRevokeParams, + ) -> anyhow::Result { + let params = Some(serde_json::to_value(params)?); + self.send_request("share/revoke", params).await + } + /// Send a `model/list` JSON-RPC request. pub async fn send_list_models_request( &mut self, diff --git a/codex-rs/app-server/tests/suite/v2/mod.rs b/codex-rs/app-server/tests/suite/v2/mod.rs index db82c1368f..40e4ad832a 100644 --- a/codex-rs/app-server/tests/suite/v2/mod.rs +++ b/codex-rs/app-server/tests/suite/v2/mod.rs @@ -42,6 +42,7 @@ mod thread_name_websocket; mod thread_read; mod thread_resume; mod thread_rollback; +mod thread_share; mod thread_shell_command; mod thread_start; mod thread_status; diff --git a/codex-rs/app-server/tests/suite/v2/thread_share.rs b/codex-rs/app-server/tests/suite/v2/thread_share.rs new file mode 100644 index 0000000000..57adb90867 --- /dev/null +++ b/codex-rs/app-server/tests/suite/v2/thread_share.rs @@ -0,0 +1,154 @@ +use anyhow::Result; +use app_test_support::McpProcess; +use app_test_support::create_fake_rollout; +use app_test_support::to_response; +use codex_app_server_protocol::JSONRPCError; +use codex_app_server_protocol::JSONRPCResponse; +use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ShareRevokeParams; +use codex_app_server_protocol::ThreadShareParams; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::path::Path; +use tempfile::TempDir; +use tokio::time::timeout; + +const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + +#[tokio::test] +async fn thread_share_requires_chatgpt_auth_for_persisted_threads() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let filename_ts = "2025-01-05T12-00-00"; + let thread_id = create_fake_rollout( + codex_home.path(), + filename_ts, + "2025-01-05T12:00:00Z", + "Saved user message", + Some("mock_provider"), + None, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let share_request_id = mcp + .send_thread_share_request(ThreadShareParams { + thread_id: thread_id.clone(), + }) + .await?; + let share_error: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(share_request_id)), + ) + .await??; + + assert!( + share_error + .error + .message + .contains("chatgpt authentication required to create share link"), + "unexpected error: {}", + share_error.error.message + ); + + Ok(()) +} + +#[tokio::test] +async fn thread_share_rejects_unmaterialized_threads() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_request_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let start_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_request_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_response)?; + assert!( + !thread.path.as_ref().expect("thread path").exists(), + "fresh thread rollout should not be materialized yet" + ); + + let share_request_id = mcp + .send_thread_share_request(ThreadShareParams { + thread_id: thread.id.clone(), + }) + .await?; + let share_error: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(share_request_id)), + ) + .await??; + + assert!( + share_error + .error + .message + .contains("thread/share is only available for persisted conversations"), + "unexpected error: {}", + share_error.error.message + ); + assert_eq!( + share_error.error.data, + Some(json!({"reason": "thread_not_persisted"})) + ); + + Ok(()) +} + +#[tokio::test] +async fn share_revoke_requires_chatgpt_auth() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let revoke_request_id = mcp + .send_share_revoke_request(ShareRevokeParams { + share_id: "share_123".to_string(), + }) + .await?; + let revoke_error: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(revoke_request_id)), + ) + .await??; + + assert!( + revoke_error + .error + .message + .contains("chatgpt authentication required to revoke share link"), + "unexpected error: {}", + revoke_error.error.message + ); + + Ok(()) +} + +fn create_minimal_config(codex_home: &Path) -> std::io::Result<()> { + let config_toml = codex_home.join("config.toml"); + std::fs::write( + config_toml, + r#" +model = "mock-model" +approval_policy = "never" +"#, + )?; + Ok(()) +} diff --git a/codex-rs/backend-client/src/client.rs b/codex-rs/backend-client/src/client.rs index 20baa1cfea..97178c067b 100644 --- a/codex-rs/backend-client/src/client.rs +++ b/codex-rs/backend-client/src/client.rs @@ -1,7 +1,10 @@ use crate::types::CodeTaskDetailsResponse; use crate::types::ConfigFileResponse; +use crate::types::CreateThreadShareRequest; +use crate::types::CreateThreadShareResponse; use crate::types::PaginatedListTaskListItem; use crate::types::RateLimitStatusPayload; +use crate::types::RevokeThreadShareResponse; use crate::types::TurnAttemptsSiblingTurnsResponse; use anyhow::Result; use codex_client::build_reqwest_client_with_custom_ca; @@ -357,6 +360,34 @@ impl Client { .map_err(RequestError::from) } + pub async fn create_thread_share( + &self, + request: &CreateThreadShareRequest, + ) -> Result { + let url = match self.path_style { + PathStyle::CodexApi => format!("{}/api/codex/thread-shares", self.base_url), + PathStyle::ChatGptApi => format!("{}/wham/thread-shares", self.base_url), + }; + let req = self + .http + .post(&url) + .headers(self.headers()) + .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) + .json(request); + let (body, ct) = self.exec_request(req, "POST", &url).await?; + self.decode_json::(&url, &ct, &body) + } + + pub async fn revoke_thread_share(&self, share_id: &str) -> Result { + let url = match self.path_style { + PathStyle::CodexApi => format!("{}/api/codex/thread-shares/{share_id}", self.base_url), + PathStyle::ChatGptApi => format!("{}/wham/thread-shares/{share_id}", self.base_url), + }; + let req = self.http.delete(&url).headers(self.headers()); + let (body, ct) = self.exec_request(req, "DELETE", &url).await?; + self.decode_json::(&url, &ct, &body) + } + /// Create a new task (user turn) by POSTing to the appropriate backend path /// based on `path_style`. Returns the created task id. pub async fn create_task(&self, request_body: serde_json::Value) -> Result { diff --git a/codex-rs/backend-client/src/lib.rs b/codex-rs/backend-client/src/lib.rs index 397c2a2cd4..dd6cb480a2 100644 --- a/codex-rs/backend-client/src/lib.rs +++ b/codex-rs/backend-client/src/lib.rs @@ -6,6 +6,9 @@ pub use client::RequestError; pub use types::CodeTaskDetailsResponse; pub use types::CodeTaskDetailsResponseExt; pub use types::ConfigFileResponse; +pub use types::CreateThreadShareRequest; +pub use types::CreateThreadShareResponse; pub use types::PaginatedListTaskListItem; +pub use types::RevokeThreadShareResponse; pub use types::TaskListItem; pub use types::TurnAttemptsSiblingTurnsResponse; diff --git a/codex-rs/backend-client/src/types.rs b/codex-rs/backend-client/src/types.rs index 22e0984cda..17971591dd 100644 --- a/codex-rs/backend-client/src/types.rs +++ b/codex-rs/backend-client/src/types.rs @@ -8,10 +8,31 @@ pub use codex_backend_openapi_models::models::RateLimitWindowSnapshot; pub use codex_backend_openapi_models::models::TaskListItem; use serde::Deserialize; +use serde::Serialize; use serde::de::Deserializer; use serde_json::Value; use std::collections::HashMap; +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateThreadShareRequest { + pub title: String, + pub markdown: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateThreadShareResponse { + pub share_id: String, + pub share_url: String, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RevokeThreadShareResponse { + pub revoked: bool, +} + /// Hand-rolled models for the Cloud Tasks task-details response. /// The generated OpenAPI models are pretty bad. This is a half-step /// towards hand-rolling them.