## Why `PermissionProfile` should describe filesystem roots as absolute paths at the type level. Using `PathBuf` in `FileSystemPermissions` made the shared type too permissive and blurred together three different deserialization cases: - skill metadata in `agents/openai.yaml`, where relative paths should resolve against the skill directory - app-server API payloads, where callers should have to send absolute paths - local tool-call payloads for commands like `shell_command` and `exec_command`, where `additional_permissions.file_system` may legitimately be relative to the command `workdir` This change tightens the shared model without regressing the existing local command flow. ## What Changed - changed `protocol::models::FileSystemPermissions` and the app-server `AdditionalFileSystemPermissions` mirror to use `AbsolutePathBuf` - wrapped skill metadata deserialization in `AbsolutePathBufGuard`, so relative permission roots in `agents/openai.yaml` resolve against the containing skill directory - kept app-server/API deserialization strict, so relative `additionalPermissions.fileSystem.*` paths are rejected at the boundary - restored cwd/workdir-relative deserialization for local tool-call payloads by parsing `shell`, `shell_command`, and `exec_command` arguments under an `AbsolutePathBufGuard` rooted at the resolved command working directory - simplified runtime additional-permission normalization so it only canonicalizes and deduplicates absolute roots instead of trying to recover relative ones later - updated the app-server schema fixtures, `app-server/README.md`, and the affected transport/TUI tests to match the final behavior
1556 lines
54 KiB
Rust
1556 lines
54 KiB
Rust
use std::path::Path;
|
|
|
|
use crate::JSONRPCNotification;
|
|
use crate::JSONRPCRequest;
|
|
use crate::RequestId;
|
|
use crate::export::GeneratedSchema;
|
|
use crate::export::write_json_schema;
|
|
use crate::protocol::v1;
|
|
use crate::protocol::v2;
|
|
use codex_experimental_api_macros::ExperimentalApi;
|
|
use schemars::JsonSchema;
|
|
use serde::Deserialize;
|
|
use serde::Serialize;
|
|
use strum_macros::Display;
|
|
use ts_rs::TS;
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema, TS)]
|
|
#[ts(type = "string")]
|
|
pub struct GitSha(pub String);
|
|
|
|
impl GitSha {
|
|
pub fn new(sha: &str) -> Self {
|
|
Self(sha.to_string())
|
|
}
|
|
}
|
|
|
|
/// Authentication mode for OpenAI-backed providers.
|
|
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum AuthMode {
|
|
/// OpenAI API key provided by the caller and stored by Codex.
|
|
ApiKey,
|
|
/// ChatGPT OAuth managed by Codex (tokens persisted and refreshed by Codex).
|
|
Chatgpt,
|
|
/// [UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE.
|
|
///
|
|
/// ChatGPT auth tokens are supplied by an external host app and are only
|
|
/// stored in memory. Token refresh must be handled by the external host app.
|
|
#[serde(rename = "chatgptAuthTokens")]
|
|
#[ts(rename = "chatgptAuthTokens")]
|
|
#[strum(serialize = "chatgptAuthTokens")]
|
|
ChatgptAuthTokens,
|
|
}
|
|
|
|
macro_rules! experimental_reason_expr {
|
|
// If a request variant is explicitly marked experimental, that reason wins.
|
|
(#[experimental($reason:expr)] $params:ident $(, $inspect_params:tt)?) => {
|
|
Some($reason)
|
|
};
|
|
// `inspect_params: true` is used when a method is mostly stable but needs
|
|
// field-level gating from its params type (for example, ThreadStart).
|
|
($params:ident, true) => {
|
|
crate::experimental_api::ExperimentalApi::experimental_reason($params)
|
|
};
|
|
($params:ident $(, $inspect_params:tt)?) => {
|
|
None
|
|
};
|
|
}
|
|
|
|
macro_rules! experimental_method_entry {
|
|
(#[experimental($reason:expr)] => $wire:literal) => {
|
|
$wire
|
|
};
|
|
(#[experimental($reason:expr)]) => {
|
|
$reason
|
|
};
|
|
($($tt:tt)*) => {
|
|
""
|
|
};
|
|
}
|
|
|
|
macro_rules! experimental_type_entry {
|
|
(#[experimental($reason:expr)] $ty:ty) => {
|
|
stringify!($ty)
|
|
};
|
|
($ty:ty) => {
|
|
""
|
|
};
|
|
}
|
|
|
|
/// Generates an `enum ClientRequest` where each variant is a request that the
|
|
/// client can send to the server. Each variant has associated `params` and
|
|
/// `response` types. Also generates a `export_client_responses()` function to
|
|
/// export all response types to TypeScript.
|
|
macro_rules! client_request_definitions {
|
|
(
|
|
$(
|
|
$(#[experimental($reason:expr)])?
|
|
$(#[doc = $variant_doc:literal])*
|
|
$variant:ident $(=> $wire:literal)? {
|
|
params: $(#[$params_meta:meta])* $params:ty,
|
|
$(inspect_params: $inspect_params:tt,)?
|
|
response: $response:ty,
|
|
}
|
|
),* $(,)?
|
|
) => {
|
|
/// Request from the client to the server.
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
|
#[serde(tag = "method", rename_all = "camelCase")]
|
|
pub enum ClientRequest {
|
|
$(
|
|
$(#[doc = $variant_doc])*
|
|
$(#[serde(rename = $wire)] #[ts(rename = $wire)])?
|
|
$variant {
|
|
#[serde(rename = "id")]
|
|
request_id: RequestId,
|
|
$(#[$params_meta])*
|
|
params: $params,
|
|
},
|
|
)*
|
|
}
|
|
|
|
impl crate::experimental_api::ExperimentalApi for ClientRequest {
|
|
fn experimental_reason(&self) -> Option<&'static str> {
|
|
match self {
|
|
$(
|
|
Self::$variant { params: _params, .. } => {
|
|
experimental_reason_expr!(
|
|
$(#[experimental($reason)])?
|
|
_params
|
|
$(, $inspect_params)?
|
|
)
|
|
}
|
|
)*
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(crate) const EXPERIMENTAL_CLIENT_METHODS: &[&str] = &[
|
|
$(
|
|
experimental_method_entry!($(#[experimental($reason)])? $(=> $wire)?),
|
|
)*
|
|
];
|
|
pub(crate) const EXPERIMENTAL_CLIENT_METHOD_PARAM_TYPES: &[&str] = &[
|
|
$(
|
|
experimental_type_entry!($(#[experimental($reason)])? $params),
|
|
)*
|
|
];
|
|
pub(crate) const EXPERIMENTAL_CLIENT_METHOD_RESPONSE_TYPES: &[&str] = &[
|
|
$(
|
|
experimental_type_entry!($(#[experimental($reason)])? $response),
|
|
)*
|
|
];
|
|
|
|
pub fn export_client_responses(
|
|
out_dir: &::std::path::Path,
|
|
) -> ::std::result::Result<(), ::ts_rs::ExportError> {
|
|
$(
|
|
<$response as ::ts_rs::TS>::export_all_to(out_dir)?;
|
|
)*
|
|
Ok(())
|
|
}
|
|
|
|
#[allow(clippy::vec_init_then_push)]
|
|
pub fn export_client_response_schemas(
|
|
out_dir: &::std::path::Path,
|
|
) -> ::anyhow::Result<Vec<GeneratedSchema>> {
|
|
let mut schemas = Vec::new();
|
|
$(
|
|
schemas.push(write_json_schema::<$response>(out_dir, stringify!($response))?);
|
|
)*
|
|
Ok(schemas)
|
|
}
|
|
|
|
#[allow(clippy::vec_init_then_push)]
|
|
pub fn export_client_param_schemas(
|
|
out_dir: &::std::path::Path,
|
|
) -> ::anyhow::Result<Vec<GeneratedSchema>> {
|
|
let mut schemas = Vec::new();
|
|
$(
|
|
schemas.push(write_json_schema::<$params>(out_dir, stringify!($params))?);
|
|
)*
|
|
Ok(schemas)
|
|
}
|
|
};
|
|
}
|
|
|
|
client_request_definitions! {
|
|
Initialize {
|
|
params: v1::InitializeParams,
|
|
response: v1::InitializeResponse,
|
|
},
|
|
|
|
/// NEW APIs
|
|
// Thread lifecycle
|
|
// Uses `inspect_params` because only some fields are experimental.
|
|
ThreadStart => "thread/start" {
|
|
params: v2::ThreadStartParams,
|
|
inspect_params: true,
|
|
response: v2::ThreadStartResponse,
|
|
},
|
|
ThreadResume => "thread/resume" {
|
|
params: v2::ThreadResumeParams,
|
|
inspect_params: true,
|
|
response: v2::ThreadResumeResponse,
|
|
},
|
|
ThreadFork => "thread/fork" {
|
|
params: v2::ThreadForkParams,
|
|
inspect_params: true,
|
|
response: v2::ThreadForkResponse,
|
|
},
|
|
ThreadArchive => "thread/archive" {
|
|
params: v2::ThreadArchiveParams,
|
|
response: v2::ThreadArchiveResponse,
|
|
},
|
|
ThreadUnsubscribe => "thread/unsubscribe" {
|
|
params: v2::ThreadUnsubscribeParams,
|
|
response: v2::ThreadUnsubscribeResponse,
|
|
},
|
|
ThreadSetName => "thread/name/set" {
|
|
params: v2::ThreadSetNameParams,
|
|
response: v2::ThreadSetNameResponse,
|
|
},
|
|
ThreadUnarchive => "thread/unarchive" {
|
|
params: v2::ThreadUnarchiveParams,
|
|
response: v2::ThreadUnarchiveResponse,
|
|
},
|
|
ThreadCompactStart => "thread/compact/start" {
|
|
params: v2::ThreadCompactStartParams,
|
|
response: v2::ThreadCompactStartResponse,
|
|
},
|
|
#[experimental("thread/backgroundTerminals/clean")]
|
|
ThreadBackgroundTerminalsClean => "thread/backgroundTerminals/clean" {
|
|
params: v2::ThreadBackgroundTerminalsCleanParams,
|
|
response: v2::ThreadBackgroundTerminalsCleanResponse,
|
|
},
|
|
ThreadRollback => "thread/rollback" {
|
|
params: v2::ThreadRollbackParams,
|
|
response: v2::ThreadRollbackResponse,
|
|
},
|
|
ThreadList => "thread/list" {
|
|
params: v2::ThreadListParams,
|
|
response: v2::ThreadListResponse,
|
|
},
|
|
ThreadLoadedList => "thread/loaded/list" {
|
|
params: v2::ThreadLoadedListParams,
|
|
response: v2::ThreadLoadedListResponse,
|
|
},
|
|
ThreadRead => "thread/read" {
|
|
params: v2::ThreadReadParams,
|
|
response: v2::ThreadReadResponse,
|
|
},
|
|
SkillsList => "skills/list" {
|
|
params: v2::SkillsListParams,
|
|
response: v2::SkillsListResponse,
|
|
},
|
|
SkillsRemoteList => "skills/remote/list" {
|
|
params: v2::SkillsRemoteReadParams,
|
|
response: v2::SkillsRemoteReadResponse,
|
|
},
|
|
SkillsRemoteExport => "skills/remote/export" {
|
|
params: v2::SkillsRemoteWriteParams,
|
|
response: v2::SkillsRemoteWriteResponse,
|
|
},
|
|
AppsList => "app/list" {
|
|
params: v2::AppsListParams,
|
|
response: v2::AppsListResponse,
|
|
},
|
|
SkillsConfigWrite => "skills/config/write" {
|
|
params: v2::SkillsConfigWriteParams,
|
|
response: v2::SkillsConfigWriteResponse,
|
|
},
|
|
TurnStart => "turn/start" {
|
|
params: v2::TurnStartParams,
|
|
inspect_params: true,
|
|
response: v2::TurnStartResponse,
|
|
},
|
|
TurnSteer => "turn/steer" {
|
|
params: v2::TurnSteerParams,
|
|
response: v2::TurnSteerResponse,
|
|
},
|
|
TurnInterrupt => "turn/interrupt" {
|
|
params: v2::TurnInterruptParams,
|
|
response: v2::TurnInterruptResponse,
|
|
},
|
|
#[experimental("thread/realtime/start")]
|
|
ThreadRealtimeStart => "thread/realtime/start" {
|
|
params: v2::ThreadRealtimeStartParams,
|
|
response: v2::ThreadRealtimeStartResponse,
|
|
},
|
|
#[experimental("thread/realtime/appendAudio")]
|
|
ThreadRealtimeAppendAudio => "thread/realtime/appendAudio" {
|
|
params: v2::ThreadRealtimeAppendAudioParams,
|
|
response: v2::ThreadRealtimeAppendAudioResponse,
|
|
},
|
|
#[experimental("thread/realtime/appendText")]
|
|
ThreadRealtimeAppendText => "thread/realtime/appendText" {
|
|
params: v2::ThreadRealtimeAppendTextParams,
|
|
response: v2::ThreadRealtimeAppendTextResponse,
|
|
},
|
|
#[experimental("thread/realtime/stop")]
|
|
ThreadRealtimeStop => "thread/realtime/stop" {
|
|
params: v2::ThreadRealtimeStopParams,
|
|
response: v2::ThreadRealtimeStopResponse,
|
|
},
|
|
ReviewStart => "review/start" {
|
|
params: v2::ReviewStartParams,
|
|
response: v2::ReviewStartResponse,
|
|
},
|
|
|
|
ModelList => "model/list" {
|
|
params: v2::ModelListParams,
|
|
response: v2::ModelListResponse,
|
|
},
|
|
ExperimentalFeatureList => "experimentalFeature/list" {
|
|
params: v2::ExperimentalFeatureListParams,
|
|
response: v2::ExperimentalFeatureListResponse,
|
|
},
|
|
#[experimental("collaborationMode/list")]
|
|
/// Lists collaboration mode presets.
|
|
CollaborationModeList => "collaborationMode/list" {
|
|
params: v2::CollaborationModeListParams,
|
|
response: v2::CollaborationModeListResponse,
|
|
},
|
|
#[experimental("mock/experimentalMethod")]
|
|
/// Test-only method used to validate experimental gating.
|
|
MockExperimentalMethod => "mock/experimentalMethod" {
|
|
params: v2::MockExperimentalMethodParams,
|
|
response: v2::MockExperimentalMethodResponse,
|
|
},
|
|
|
|
McpServerOauthLogin => "mcpServer/oauth/login" {
|
|
params: v2::McpServerOauthLoginParams,
|
|
response: v2::McpServerOauthLoginResponse,
|
|
},
|
|
|
|
McpServerRefresh => "config/mcpServer/reload" {
|
|
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
|
|
response: v2::McpServerRefreshResponse,
|
|
},
|
|
|
|
McpServerStatusList => "mcpServerStatus/list" {
|
|
params: v2::ListMcpServerStatusParams,
|
|
response: v2::ListMcpServerStatusResponse,
|
|
},
|
|
|
|
WindowsSandboxSetupStart => "windowsSandbox/setupStart" {
|
|
params: v2::WindowsSandboxSetupStartParams,
|
|
response: v2::WindowsSandboxSetupStartResponse,
|
|
},
|
|
|
|
LoginAccount => "account/login/start" {
|
|
params: v2::LoginAccountParams,
|
|
inspect_params: true,
|
|
response: v2::LoginAccountResponse,
|
|
},
|
|
|
|
CancelLoginAccount => "account/login/cancel" {
|
|
params: v2::CancelLoginAccountParams,
|
|
response: v2::CancelLoginAccountResponse,
|
|
},
|
|
|
|
LogoutAccount => "account/logout" {
|
|
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
|
|
response: v2::LogoutAccountResponse,
|
|
},
|
|
|
|
GetAccountRateLimits => "account/rateLimits/read" {
|
|
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
|
|
response: v2::GetAccountRateLimitsResponse,
|
|
},
|
|
|
|
FeedbackUpload => "feedback/upload" {
|
|
params: v2::FeedbackUploadParams,
|
|
response: v2::FeedbackUploadResponse,
|
|
},
|
|
|
|
/// Execute a command (argv vector) under the server's sandbox.
|
|
OneOffCommandExec => "command/exec" {
|
|
params: v2::CommandExecParams,
|
|
response: v2::CommandExecResponse,
|
|
},
|
|
|
|
ConfigRead => "config/read" {
|
|
params: v2::ConfigReadParams,
|
|
response: v2::ConfigReadResponse,
|
|
},
|
|
ExternalAgentConfigDetect => "externalAgentConfig/detect" {
|
|
params: v2::ExternalAgentConfigDetectParams,
|
|
response: v2::ExternalAgentConfigDetectResponse,
|
|
},
|
|
ExternalAgentConfigImport => "externalAgentConfig/import" {
|
|
params: v2::ExternalAgentConfigImportParams,
|
|
response: v2::ExternalAgentConfigImportResponse,
|
|
},
|
|
ConfigValueWrite => "config/value/write" {
|
|
params: v2::ConfigValueWriteParams,
|
|
response: v2::ConfigWriteResponse,
|
|
},
|
|
ConfigBatchWrite => "config/batchWrite" {
|
|
params: v2::ConfigBatchWriteParams,
|
|
response: v2::ConfigWriteResponse,
|
|
},
|
|
|
|
ConfigRequirementsRead => "configRequirements/read" {
|
|
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
|
|
response: v2::ConfigRequirementsReadResponse,
|
|
},
|
|
|
|
GetAccount => "account/read" {
|
|
params: v2::GetAccountParams,
|
|
response: v2::GetAccountResponse,
|
|
},
|
|
|
|
/// DEPRECATED APIs below
|
|
NewConversation {
|
|
params: v1::NewConversationParams,
|
|
response: v1::NewConversationResponse,
|
|
},
|
|
GetConversationSummary {
|
|
params: v1::GetConversationSummaryParams,
|
|
response: v1::GetConversationSummaryResponse,
|
|
},
|
|
/// List recorded Codex conversations (rollouts) with optional pagination and search.
|
|
ListConversations {
|
|
params: v1::ListConversationsParams,
|
|
response: v1::ListConversationsResponse,
|
|
},
|
|
/// Resume a recorded Codex conversation from a rollout file.
|
|
ResumeConversation {
|
|
params: v1::ResumeConversationParams,
|
|
response: v1::ResumeConversationResponse,
|
|
},
|
|
/// Fork a recorded Codex conversation into a new session.
|
|
ForkConversation {
|
|
params: v1::ForkConversationParams,
|
|
response: v1::ForkConversationResponse,
|
|
},
|
|
ArchiveConversation {
|
|
params: v1::ArchiveConversationParams,
|
|
response: v1::ArchiveConversationResponse,
|
|
},
|
|
SendUserMessage {
|
|
params: v1::SendUserMessageParams,
|
|
response: v1::SendUserMessageResponse,
|
|
},
|
|
SendUserTurn {
|
|
params: v1::SendUserTurnParams,
|
|
response: v1::SendUserTurnResponse,
|
|
},
|
|
InterruptConversation {
|
|
params: v1::InterruptConversationParams,
|
|
response: v1::InterruptConversationResponse,
|
|
},
|
|
AddConversationListener {
|
|
params: v1::AddConversationListenerParams,
|
|
response: v1::AddConversationSubscriptionResponse,
|
|
},
|
|
RemoveConversationListener {
|
|
params: v1::RemoveConversationListenerParams,
|
|
response: v1::RemoveConversationSubscriptionResponse,
|
|
},
|
|
GitDiffToRemote {
|
|
params: v1::GitDiffToRemoteParams,
|
|
response: v1::GitDiffToRemoteResponse,
|
|
},
|
|
LoginApiKey {
|
|
params: v1::LoginApiKeyParams,
|
|
response: v1::LoginApiKeyResponse,
|
|
},
|
|
LoginChatGpt {
|
|
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
|
|
response: v1::LoginChatGptResponse,
|
|
},
|
|
// DEPRECATED in favor of CancelLoginAccount
|
|
CancelLoginChatGpt {
|
|
params: v1::CancelLoginChatGptParams,
|
|
response: v1::CancelLoginChatGptResponse,
|
|
},
|
|
LogoutChatGpt {
|
|
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
|
|
response: v1::LogoutChatGptResponse,
|
|
},
|
|
/// DEPRECATED in favor of GetAccount
|
|
GetAuthStatus {
|
|
params: v1::GetAuthStatusParams,
|
|
response: v1::GetAuthStatusResponse,
|
|
},
|
|
GetUserSavedConfig {
|
|
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
|
|
response: v1::GetUserSavedConfigResponse,
|
|
},
|
|
SetDefaultModel {
|
|
params: v1::SetDefaultModelParams,
|
|
response: v1::SetDefaultModelResponse,
|
|
},
|
|
GetUserAgent {
|
|
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
|
|
response: v1::GetUserAgentResponse,
|
|
},
|
|
UserInfo {
|
|
params: #[ts(type = "undefined")] #[serde(skip_serializing_if = "Option::is_none")] Option<()>,
|
|
response: v1::UserInfoResponse,
|
|
},
|
|
FuzzyFileSearch {
|
|
params: FuzzyFileSearchParams,
|
|
response: FuzzyFileSearchResponse,
|
|
},
|
|
#[experimental("fuzzyFileSearch/sessionStart")]
|
|
FuzzyFileSearchSessionStart => "fuzzyFileSearch/sessionStart" {
|
|
params: FuzzyFileSearchSessionStartParams,
|
|
response: FuzzyFileSearchSessionStartResponse,
|
|
},
|
|
#[experimental("fuzzyFileSearch/sessionUpdate")]
|
|
FuzzyFileSearchSessionUpdate => "fuzzyFileSearch/sessionUpdate" {
|
|
params: FuzzyFileSearchSessionUpdateParams,
|
|
response: FuzzyFileSearchSessionUpdateResponse,
|
|
},
|
|
#[experimental("fuzzyFileSearch/sessionStop")]
|
|
FuzzyFileSearchSessionStop => "fuzzyFileSearch/sessionStop" {
|
|
params: FuzzyFileSearchSessionStopParams,
|
|
response: FuzzyFileSearchSessionStopResponse,
|
|
},
|
|
/// Execute a command (argv vector) under the server's sandbox.
|
|
ExecOneOffCommand {
|
|
params: v1::ExecOneOffCommandParams,
|
|
response: v1::ExecOneOffCommandResponse,
|
|
},
|
|
}
|
|
|
|
/// Generates an `enum ServerRequest` where each variant is a request that the
|
|
/// server can send to the client along with the corresponding params and
|
|
/// response types. It also generates helper types used by the app/server
|
|
/// infrastructure (payload enum, request constructor, and export helpers).
|
|
macro_rules! server_request_definitions {
|
|
(
|
|
$(
|
|
$(#[$variant_meta:meta])*
|
|
$variant:ident $(=> $wire:literal)? {
|
|
params: $params:ty,
|
|
response: $response:ty,
|
|
}
|
|
),* $(,)?
|
|
) => {
|
|
/// Request initiated from the server and sent to the client.
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
|
#[allow(clippy::large_enum_variant)]
|
|
#[serde(tag = "method", rename_all = "camelCase")]
|
|
pub enum ServerRequest {
|
|
$(
|
|
$(#[$variant_meta])*
|
|
$(#[serde(rename = $wire)] #[ts(rename = $wire)])?
|
|
$variant {
|
|
#[serde(rename = "id")]
|
|
request_id: RequestId,
|
|
params: $params,
|
|
},
|
|
)*
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, JsonSchema)]
|
|
#[allow(clippy::large_enum_variant)]
|
|
pub enum ServerRequestPayload {
|
|
$( $variant($params), )*
|
|
}
|
|
|
|
impl ServerRequestPayload {
|
|
pub fn request_with_id(self, request_id: RequestId) -> ServerRequest {
|
|
match self {
|
|
$(Self::$variant(params) => ServerRequest::$variant { request_id, params },)*
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn export_server_responses(
|
|
out_dir: &::std::path::Path,
|
|
) -> ::std::result::Result<(), ::ts_rs::ExportError> {
|
|
$(
|
|
<$response as ::ts_rs::TS>::export_all_to(out_dir)?;
|
|
)*
|
|
Ok(())
|
|
}
|
|
|
|
#[allow(clippy::vec_init_then_push)]
|
|
pub fn export_server_response_schemas(
|
|
out_dir: &Path,
|
|
) -> ::anyhow::Result<Vec<GeneratedSchema>> {
|
|
let mut schemas = Vec::new();
|
|
$(
|
|
schemas.push(crate::export::write_json_schema::<$response>(
|
|
out_dir,
|
|
concat!(stringify!($variant), "Response"),
|
|
)?);
|
|
)*
|
|
Ok(schemas)
|
|
}
|
|
|
|
#[allow(clippy::vec_init_then_push)]
|
|
pub fn export_server_param_schemas(
|
|
out_dir: &Path,
|
|
) -> ::anyhow::Result<Vec<GeneratedSchema>> {
|
|
let mut schemas = Vec::new();
|
|
$(
|
|
schemas.push(crate::export::write_json_schema::<$params>(
|
|
out_dir,
|
|
concat!(stringify!($variant), "Params"),
|
|
)?);
|
|
)*
|
|
Ok(schemas)
|
|
}
|
|
};
|
|
}
|
|
|
|
/// Generates `ServerNotification` enum and helpers, including a JSON Schema
|
|
/// exporter for each notification.
|
|
macro_rules! server_notification_definitions {
|
|
(
|
|
$(
|
|
$(#[$variant_meta:meta])*
|
|
$variant:ident $(=> $wire:literal)? ( $payload:ty )
|
|
),* $(,)?
|
|
) => {
|
|
/// Notification sent from the server to the client.
|
|
#[derive(
|
|
Serialize,
|
|
Deserialize,
|
|
Debug,
|
|
Clone,
|
|
JsonSchema,
|
|
TS,
|
|
Display,
|
|
ExperimentalApi,
|
|
)]
|
|
#[serde(tag = "method", content = "params", rename_all = "camelCase")]
|
|
#[strum(serialize_all = "camelCase")]
|
|
pub enum ServerNotification {
|
|
$(
|
|
$(#[$variant_meta])*
|
|
$(#[serde(rename = $wire)] #[ts(rename = $wire)] #[strum(serialize = $wire)])?
|
|
$variant($payload),
|
|
)*
|
|
}
|
|
|
|
impl ServerNotification {
|
|
pub fn to_params(self) -> Result<serde_json::Value, serde_json::Error> {
|
|
match self {
|
|
$(Self::$variant(params) => serde_json::to_value(params),)*
|
|
}
|
|
}
|
|
}
|
|
|
|
impl TryFrom<JSONRPCNotification> for ServerNotification {
|
|
type Error = serde_json::Error;
|
|
|
|
fn try_from(value: JSONRPCNotification) -> Result<Self, serde_json::Error> {
|
|
serde_json::from_value(serde_json::to_value(value)?)
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::vec_init_then_push)]
|
|
pub fn export_server_notification_schemas(
|
|
out_dir: &::std::path::Path,
|
|
) -> ::anyhow::Result<Vec<GeneratedSchema>> {
|
|
let mut schemas = Vec::new();
|
|
$(schemas.push(crate::export::write_json_schema::<$payload>(out_dir, stringify!($payload))?);)*
|
|
Ok(schemas)
|
|
}
|
|
};
|
|
}
|
|
/// Notifications sent from the client to the server.
|
|
macro_rules! client_notification_definitions {
|
|
(
|
|
$(
|
|
$(#[$variant_meta:meta])*
|
|
$variant:ident $( ( $payload:ty ) )?
|
|
),* $(,)?
|
|
) => {
|
|
#[derive(Serialize, Deserialize, Debug, Clone, JsonSchema, TS, Display)]
|
|
#[serde(tag = "method", content = "params", rename_all = "camelCase")]
|
|
#[strum(serialize_all = "camelCase")]
|
|
pub enum ClientNotification {
|
|
$(
|
|
$(#[$variant_meta])*
|
|
$variant $( ( $payload ) )?,
|
|
)*
|
|
}
|
|
|
|
pub fn export_client_notification_schemas(
|
|
_out_dir: &::std::path::Path,
|
|
) -> ::anyhow::Result<Vec<GeneratedSchema>> {
|
|
let schemas = Vec::new();
|
|
$( $(schemas.push(crate::export::write_json_schema::<$payload>(_out_dir, stringify!($payload))?);)? )*
|
|
Ok(schemas)
|
|
}
|
|
};
|
|
}
|
|
|
|
impl TryFrom<JSONRPCRequest> for ServerRequest {
|
|
type Error = serde_json::Error;
|
|
|
|
fn try_from(value: JSONRPCRequest) -> Result<Self, Self::Error> {
|
|
serde_json::from_value(serde_json::to_value(value)?)
|
|
}
|
|
}
|
|
|
|
server_request_definitions! {
|
|
/// NEW APIs
|
|
/// Sent when approval is requested for a specific command execution.
|
|
/// This request is used for Turns started via turn/start.
|
|
CommandExecutionRequestApproval => "item/commandExecution/requestApproval" {
|
|
params: v2::CommandExecutionRequestApprovalParams,
|
|
response: v2::CommandExecutionRequestApprovalResponse,
|
|
},
|
|
|
|
/// Sent when approval is requested for a specific file change.
|
|
/// This request is used for Turns started via turn/start.
|
|
FileChangeRequestApproval => "item/fileChange/requestApproval" {
|
|
params: v2::FileChangeRequestApprovalParams,
|
|
response: v2::FileChangeRequestApprovalResponse,
|
|
},
|
|
|
|
/// EXPERIMENTAL - Request input from the user for a tool call.
|
|
ToolRequestUserInput => "item/tool/requestUserInput" {
|
|
params: v2::ToolRequestUserInputParams,
|
|
response: v2::ToolRequestUserInputResponse,
|
|
},
|
|
|
|
/// Execute a dynamic tool call on the client.
|
|
DynamicToolCall => "item/tool/call" {
|
|
params: v2::DynamicToolCallParams,
|
|
response: v2::DynamicToolCallResponse,
|
|
},
|
|
|
|
ChatgptAuthTokensRefresh => "account/chatgptAuthTokens/refresh" {
|
|
params: v2::ChatgptAuthTokensRefreshParams,
|
|
response: v2::ChatgptAuthTokensRefreshResponse,
|
|
},
|
|
|
|
/// DEPRECATED APIs below
|
|
/// Request to approve a patch.
|
|
/// This request is used for Turns started via the legacy APIs (i.e. SendUserTurn, SendUserMessage).
|
|
ApplyPatchApproval {
|
|
params: v1::ApplyPatchApprovalParams,
|
|
response: v1::ApplyPatchApprovalResponse,
|
|
},
|
|
/// Request to exec a command.
|
|
/// This request is used for Turns started via the legacy APIs (i.e. SendUserTurn, SendUserMessage).
|
|
ExecCommandApproval {
|
|
params: v1::ExecCommandApprovalParams,
|
|
response: v1::ExecCommandApprovalResponse,
|
|
},
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
|
#[serde(rename_all = "camelCase")]
|
|
#[ts(rename_all = "camelCase")]
|
|
pub struct FuzzyFileSearchParams {
|
|
pub query: String,
|
|
pub roots: Vec<String>,
|
|
// if provided, will cancel any previous request that used the same value
|
|
pub cancellation_token: Option<String>,
|
|
}
|
|
|
|
/// Superset of [`codex_file_search::FileMatch`]
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
|
pub struct FuzzyFileSearchResult {
|
|
pub root: String,
|
|
pub path: String,
|
|
pub file_name: String,
|
|
pub score: u32,
|
|
pub indices: Option<Vec<u32>>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
|
pub struct FuzzyFileSearchResponse {
|
|
pub files: Vec<FuzzyFileSearchResult>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
|
#[serde(rename_all = "camelCase")]
|
|
#[ts(rename_all = "camelCase")]
|
|
pub struct FuzzyFileSearchSessionStartParams {
|
|
pub session_id: String,
|
|
pub roots: Vec<String>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, Default)]
|
|
pub struct FuzzyFileSearchSessionStartResponse {}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
|
#[serde(rename_all = "camelCase")]
|
|
#[ts(rename_all = "camelCase")]
|
|
pub struct FuzzyFileSearchSessionUpdateParams {
|
|
pub session_id: String,
|
|
pub query: String,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, Default)]
|
|
pub struct FuzzyFileSearchSessionUpdateResponse {}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
|
#[serde(rename_all = "camelCase")]
|
|
#[ts(rename_all = "camelCase")]
|
|
pub struct FuzzyFileSearchSessionStopParams {
|
|
pub session_id: String,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS, Default)]
|
|
pub struct FuzzyFileSearchSessionStopResponse {}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
|
#[serde(rename_all = "camelCase")]
|
|
#[ts(rename_all = "camelCase")]
|
|
pub struct FuzzyFileSearchSessionUpdatedNotification {
|
|
pub session_id: String,
|
|
pub query: String,
|
|
pub files: Vec<FuzzyFileSearchResult>,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
|
#[serde(rename_all = "camelCase")]
|
|
#[ts(rename_all = "camelCase")]
|
|
pub struct FuzzyFileSearchSessionCompletedNotification {
|
|
pub session_id: String,
|
|
}
|
|
|
|
server_notification_definitions! {
|
|
/// NEW NOTIFICATIONS
|
|
Error => "error" (v2::ErrorNotification),
|
|
ThreadStarted => "thread/started" (v2::ThreadStartedNotification),
|
|
ThreadStatusChanged => "thread/status/changed" (v2::ThreadStatusChangedNotification),
|
|
ThreadArchived => "thread/archived" (v2::ThreadArchivedNotification),
|
|
ThreadUnarchived => "thread/unarchived" (v2::ThreadUnarchivedNotification),
|
|
ThreadClosed => "thread/closed" (v2::ThreadClosedNotification),
|
|
ThreadNameUpdated => "thread/name/updated" (v2::ThreadNameUpdatedNotification),
|
|
ThreadTokenUsageUpdated => "thread/tokenUsage/updated" (v2::ThreadTokenUsageUpdatedNotification),
|
|
TurnStarted => "turn/started" (v2::TurnStartedNotification),
|
|
TurnCompleted => "turn/completed" (v2::TurnCompletedNotification),
|
|
TurnDiffUpdated => "turn/diff/updated" (v2::TurnDiffUpdatedNotification),
|
|
TurnPlanUpdated => "turn/plan/updated" (v2::TurnPlanUpdatedNotification),
|
|
ItemStarted => "item/started" (v2::ItemStartedNotification),
|
|
ItemCompleted => "item/completed" (v2::ItemCompletedNotification),
|
|
/// This event is internal-only. Used by Codex Cloud.
|
|
RawResponseItemCompleted => "rawResponseItem/completed" (v2::RawResponseItemCompletedNotification),
|
|
AgentMessageDelta => "item/agentMessage/delta" (v2::AgentMessageDeltaNotification),
|
|
/// EXPERIMENTAL - proposed plan streaming deltas for plan items.
|
|
PlanDelta => "item/plan/delta" (v2::PlanDeltaNotification),
|
|
CommandExecutionOutputDelta => "item/commandExecution/outputDelta" (v2::CommandExecutionOutputDeltaNotification),
|
|
TerminalInteraction => "item/commandExecution/terminalInteraction" (v2::TerminalInteractionNotification),
|
|
FileChangeOutputDelta => "item/fileChange/outputDelta" (v2::FileChangeOutputDeltaNotification),
|
|
McpToolCallProgress => "item/mcpToolCall/progress" (v2::McpToolCallProgressNotification),
|
|
McpServerOauthLoginCompleted => "mcpServer/oauthLogin/completed" (v2::McpServerOauthLoginCompletedNotification),
|
|
AccountUpdated => "account/updated" (v2::AccountUpdatedNotification),
|
|
AccountRateLimitsUpdated => "account/rateLimits/updated" (v2::AccountRateLimitsUpdatedNotification),
|
|
AppListUpdated => "app/list/updated" (v2::AppListUpdatedNotification),
|
|
ReasoningSummaryTextDelta => "item/reasoning/summaryTextDelta" (v2::ReasoningSummaryTextDeltaNotification),
|
|
ReasoningSummaryPartAdded => "item/reasoning/summaryPartAdded" (v2::ReasoningSummaryPartAddedNotification),
|
|
ReasoningTextDelta => "item/reasoning/textDelta" (v2::ReasoningTextDeltaNotification),
|
|
/// Deprecated: Use `ContextCompaction` item type instead.
|
|
ContextCompacted => "thread/compacted" (v2::ContextCompactedNotification),
|
|
ModelRerouted => "model/rerouted" (v2::ModelReroutedNotification),
|
|
DeprecationNotice => "deprecationNotice" (v2::DeprecationNoticeNotification),
|
|
ConfigWarning => "configWarning" (v2::ConfigWarningNotification),
|
|
FuzzyFileSearchSessionUpdated => "fuzzyFileSearch/sessionUpdated" (FuzzyFileSearchSessionUpdatedNotification),
|
|
FuzzyFileSearchSessionCompleted => "fuzzyFileSearch/sessionCompleted" (FuzzyFileSearchSessionCompletedNotification),
|
|
#[experimental("thread/realtime/started")]
|
|
ThreadRealtimeStarted => "thread/realtime/started" (v2::ThreadRealtimeStartedNotification),
|
|
#[experimental("thread/realtime/itemAdded")]
|
|
ThreadRealtimeItemAdded => "thread/realtime/itemAdded" (v2::ThreadRealtimeItemAddedNotification),
|
|
#[experimental("thread/realtime/outputAudio/delta")]
|
|
ThreadRealtimeOutputAudioDelta => "thread/realtime/outputAudio/delta" (v2::ThreadRealtimeOutputAudioDeltaNotification),
|
|
#[experimental("thread/realtime/error")]
|
|
ThreadRealtimeError => "thread/realtime/error" (v2::ThreadRealtimeErrorNotification),
|
|
#[experimental("thread/realtime/closed")]
|
|
ThreadRealtimeClosed => "thread/realtime/closed" (v2::ThreadRealtimeClosedNotification),
|
|
|
|
/// Notifies the user of world-writable directories on Windows, which cannot be protected by the sandbox.
|
|
WindowsWorldWritableWarning => "windows/worldWritableWarning" (v2::WindowsWorldWritableWarningNotification),
|
|
WindowsSandboxSetupCompleted => "windowsSandbox/setupCompleted" (v2::WindowsSandboxSetupCompletedNotification),
|
|
|
|
#[serde(rename = "account/login/completed")]
|
|
#[ts(rename = "account/login/completed")]
|
|
#[strum(serialize = "account/login/completed")]
|
|
AccountLoginCompleted(v2::AccountLoginCompletedNotification),
|
|
|
|
/// DEPRECATED NOTIFICATIONS below
|
|
AuthStatusChange(v1::AuthStatusChangeNotification),
|
|
|
|
/// Deprecated: use `account/login/completed` instead.
|
|
LoginChatGptComplete(v1::LoginChatGptCompleteNotification),
|
|
SessionConfigured(v1::SessionConfiguredNotification),
|
|
}
|
|
|
|
client_notification_definitions! {
|
|
Initialized,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use anyhow::Result;
|
|
use codex_protocol::ThreadId;
|
|
use codex_protocol::account::PlanType;
|
|
use codex_protocol::parse_command::ParsedCommand;
|
|
use codex_protocol::protocol::AskForApproval;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::json;
|
|
use std::path::PathBuf;
|
|
|
|
fn absolute_path(path: &str) -> AbsolutePathBuf {
|
|
AbsolutePathBuf::from_absolute_path(path).expect("absolute path")
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_new_conversation() -> Result<()> {
|
|
let request = ClientRequest::NewConversation {
|
|
request_id: RequestId::Integer(42),
|
|
params: v1::NewConversationParams {
|
|
model: Some("gpt-5.1-codex-max".to_string()),
|
|
model_provider: None,
|
|
profile: None,
|
|
cwd: None,
|
|
approval_policy: Some(AskForApproval::OnRequest),
|
|
sandbox: None,
|
|
config: None,
|
|
base_instructions: None,
|
|
developer_instructions: None,
|
|
compact_prompt: None,
|
|
include_apply_patch_tool: None,
|
|
},
|
|
};
|
|
assert_eq!(
|
|
json!({
|
|
"method": "newConversation",
|
|
"id": 42,
|
|
"params": {
|
|
"model": "gpt-5.1-codex-max",
|
|
"modelProvider": null,
|
|
"profile": null,
|
|
"cwd": null,
|
|
"approvalPolicy": "on-request",
|
|
"sandbox": null,
|
|
"config": null,
|
|
"baseInstructions": null,
|
|
"includeApplyPatchTool": null
|
|
}
|
|
}),
|
|
serde_json::to_value(&request)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_initialize_with_opt_out_notification_methods() -> Result<()> {
|
|
let request = ClientRequest::Initialize {
|
|
request_id: RequestId::Integer(42),
|
|
params: v1::InitializeParams {
|
|
client_info: v1::ClientInfo {
|
|
name: "codex_vscode".to_string(),
|
|
title: Some("Codex VS Code Extension".to_string()),
|
|
version: "0.1.0".to_string(),
|
|
},
|
|
capabilities: Some(v1::InitializeCapabilities {
|
|
experimental_api: true,
|
|
opt_out_notification_methods: Some(vec![
|
|
"codex/event/session_configured".to_string(),
|
|
"item/agentMessage/delta".to_string(),
|
|
]),
|
|
}),
|
|
},
|
|
};
|
|
|
|
assert_eq!(
|
|
json!({
|
|
"method": "initialize",
|
|
"id": 42,
|
|
"params": {
|
|
"clientInfo": {
|
|
"name": "codex_vscode",
|
|
"title": "Codex VS Code Extension",
|
|
"version": "0.1.0"
|
|
},
|
|
"capabilities": {
|
|
"experimentalApi": true,
|
|
"optOutNotificationMethods": [
|
|
"codex/event/session_configured",
|
|
"item/agentMessage/delta"
|
|
]
|
|
}
|
|
}
|
|
}),
|
|
serde_json::to_value(&request)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn deserialize_initialize_with_opt_out_notification_methods() -> Result<()> {
|
|
let request: ClientRequest = serde_json::from_value(json!({
|
|
"method": "initialize",
|
|
"id": 42,
|
|
"params": {
|
|
"clientInfo": {
|
|
"name": "codex_vscode",
|
|
"title": "Codex VS Code Extension",
|
|
"version": "0.1.0"
|
|
},
|
|
"capabilities": {
|
|
"experimentalApi": true,
|
|
"optOutNotificationMethods": [
|
|
"codex/event/session_configured",
|
|
"item/agentMessage/delta"
|
|
]
|
|
}
|
|
}
|
|
}))?;
|
|
|
|
assert_eq!(
|
|
request,
|
|
ClientRequest::Initialize {
|
|
request_id: RequestId::Integer(42),
|
|
params: v1::InitializeParams {
|
|
client_info: v1::ClientInfo {
|
|
name: "codex_vscode".to_string(),
|
|
title: Some("Codex VS Code Extension".to_string()),
|
|
version: "0.1.0".to_string(),
|
|
},
|
|
capabilities: Some(v1::InitializeCapabilities {
|
|
experimental_api: true,
|
|
opt_out_notification_methods: Some(vec![
|
|
"codex/event/session_configured".to_string(),
|
|
"item/agentMessage/delta".to_string(),
|
|
]),
|
|
}),
|
|
},
|
|
}
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn conversation_id_serializes_as_plain_string() -> Result<()> {
|
|
let id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?;
|
|
|
|
assert_eq!(
|
|
json!("67e55044-10b1-426f-9247-bb680e5fe0c8"),
|
|
serde_json::to_value(id)?
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn conversation_id_deserializes_from_plain_string() -> Result<()> {
|
|
let id: ThreadId = serde_json::from_value(json!("67e55044-10b1-426f-9247-bb680e5fe0c8"))?;
|
|
|
|
assert_eq!(
|
|
ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?,
|
|
id,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_client_notification() -> Result<()> {
|
|
let notification = ClientNotification::Initialized;
|
|
// Note there is no "params" field for this notification.
|
|
assert_eq!(
|
|
json!({
|
|
"method": "initialized",
|
|
}),
|
|
serde_json::to_value(¬ification)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_server_request() -> Result<()> {
|
|
let conversation_id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?;
|
|
let params = v1::ExecCommandApprovalParams {
|
|
conversation_id,
|
|
call_id: "call-42".to_string(),
|
|
approval_id: Some("approval-42".to_string()),
|
|
command: vec!["echo".to_string(), "hello".to_string()],
|
|
cwd: PathBuf::from("/tmp"),
|
|
reason: Some("because tests".to_string()),
|
|
parsed_cmd: vec![ParsedCommand::Unknown {
|
|
cmd: "echo hello".to_string(),
|
|
}],
|
|
};
|
|
let request = ServerRequest::ExecCommandApproval {
|
|
request_id: RequestId::Integer(7),
|
|
params: params.clone(),
|
|
};
|
|
|
|
assert_eq!(
|
|
json!({
|
|
"method": "execCommandApproval",
|
|
"id": 7,
|
|
"params": {
|
|
"conversationId": "67e55044-10b1-426f-9247-bb680e5fe0c8",
|
|
"callId": "call-42",
|
|
"approvalId": "approval-42",
|
|
"command": ["echo", "hello"],
|
|
"cwd": "/tmp",
|
|
"reason": "because tests",
|
|
"parsedCmd": [
|
|
{
|
|
"type": "unknown",
|
|
"cmd": "echo hello"
|
|
}
|
|
]
|
|
}
|
|
}),
|
|
serde_json::to_value(&request)?,
|
|
);
|
|
|
|
let payload = ServerRequestPayload::ExecCommandApproval(params);
|
|
assert_eq!(payload.request_with_id(RequestId::Integer(7)), request);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_chatgpt_auth_tokens_refresh_request() -> Result<()> {
|
|
let request = ServerRequest::ChatgptAuthTokensRefresh {
|
|
request_id: RequestId::Integer(8),
|
|
params: v2::ChatgptAuthTokensRefreshParams {
|
|
reason: v2::ChatgptAuthTokensRefreshReason::Unauthorized,
|
|
previous_account_id: Some("org-123".to_string()),
|
|
},
|
|
};
|
|
assert_eq!(
|
|
json!({
|
|
"method": "account/chatgptAuthTokens/refresh",
|
|
"id": 8,
|
|
"params": {
|
|
"reason": "unauthorized",
|
|
"previousAccountId": "org-123"
|
|
}
|
|
}),
|
|
serde_json::to_value(&request)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_get_account_rate_limits() -> Result<()> {
|
|
let request = ClientRequest::GetAccountRateLimits {
|
|
request_id: RequestId::Integer(1),
|
|
params: None,
|
|
};
|
|
assert_eq!(
|
|
json!({
|
|
"method": "account/rateLimits/read",
|
|
"id": 1,
|
|
}),
|
|
serde_json::to_value(&request)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_config_requirements_read() -> Result<()> {
|
|
let request = ClientRequest::ConfigRequirementsRead {
|
|
request_id: RequestId::Integer(1),
|
|
params: None,
|
|
};
|
|
assert_eq!(
|
|
json!({
|
|
"method": "configRequirements/read",
|
|
"id": 1,
|
|
}),
|
|
serde_json::to_value(&request)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_account_login_api_key() -> Result<()> {
|
|
let request = ClientRequest::LoginAccount {
|
|
request_id: RequestId::Integer(2),
|
|
params: v2::LoginAccountParams::ApiKey {
|
|
api_key: "secret".to_string(),
|
|
},
|
|
};
|
|
assert_eq!(
|
|
json!({
|
|
"method": "account/login/start",
|
|
"id": 2,
|
|
"params": {
|
|
"type": "apiKey",
|
|
"apiKey": "secret"
|
|
}
|
|
}),
|
|
serde_json::to_value(&request)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_account_login_chatgpt() -> Result<()> {
|
|
let request = ClientRequest::LoginAccount {
|
|
request_id: RequestId::Integer(3),
|
|
params: v2::LoginAccountParams::Chatgpt,
|
|
};
|
|
assert_eq!(
|
|
json!({
|
|
"method": "account/login/start",
|
|
"id": 3,
|
|
"params": {
|
|
"type": "chatgpt"
|
|
}
|
|
}),
|
|
serde_json::to_value(&request)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_account_logout() -> Result<()> {
|
|
let request = ClientRequest::LogoutAccount {
|
|
request_id: RequestId::Integer(4),
|
|
params: None,
|
|
};
|
|
assert_eq!(
|
|
json!({
|
|
"method": "account/logout",
|
|
"id": 4,
|
|
}),
|
|
serde_json::to_value(&request)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_account_login_chatgpt_auth_tokens() -> Result<()> {
|
|
let request = ClientRequest::LoginAccount {
|
|
request_id: RequestId::Integer(5),
|
|
params: v2::LoginAccountParams::ChatgptAuthTokens {
|
|
access_token: "access-token".to_string(),
|
|
chatgpt_account_id: "org-123".to_string(),
|
|
chatgpt_plan_type: Some("business".to_string()),
|
|
},
|
|
};
|
|
assert_eq!(
|
|
json!({
|
|
"method": "account/login/start",
|
|
"id": 5,
|
|
"params": {
|
|
"type": "chatgptAuthTokens",
|
|
"accessToken": "access-token",
|
|
"chatgptAccountId": "org-123",
|
|
"chatgptPlanType": "business"
|
|
}
|
|
}),
|
|
serde_json::to_value(&request)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_get_account() -> Result<()> {
|
|
let request = ClientRequest::GetAccount {
|
|
request_id: RequestId::Integer(6),
|
|
params: v2::GetAccountParams {
|
|
refresh_token: false,
|
|
},
|
|
};
|
|
assert_eq!(
|
|
json!({
|
|
"method": "account/read",
|
|
"id": 6,
|
|
"params": {
|
|
"refreshToken": false
|
|
}
|
|
}),
|
|
serde_json::to_value(&request)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn account_serializes_fields_in_camel_case() -> Result<()> {
|
|
let api_key = v2::Account::ApiKey {};
|
|
assert_eq!(
|
|
json!({
|
|
"type": "apiKey",
|
|
}),
|
|
serde_json::to_value(&api_key)?,
|
|
);
|
|
|
|
let chatgpt = v2::Account::Chatgpt {
|
|
email: "user@example.com".to_string(),
|
|
plan_type: PlanType::Plus,
|
|
};
|
|
assert_eq!(
|
|
json!({
|
|
"type": "chatgpt",
|
|
"email": "user@example.com",
|
|
"planType": "plus",
|
|
}),
|
|
serde_json::to_value(&chatgpt)?,
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_list_models() -> Result<()> {
|
|
let request = ClientRequest::ModelList {
|
|
request_id: RequestId::Integer(6),
|
|
params: v2::ModelListParams::default(),
|
|
};
|
|
assert_eq!(
|
|
json!({
|
|
"method": "model/list",
|
|
"id": 6,
|
|
"params": {
|
|
"limit": null,
|
|
"cursor": null,
|
|
"includeHidden": null
|
|
}
|
|
}),
|
|
serde_json::to_value(&request)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_list_collaboration_modes() -> Result<()> {
|
|
let request = ClientRequest::CollaborationModeList {
|
|
request_id: RequestId::Integer(7),
|
|
params: v2::CollaborationModeListParams::default(),
|
|
};
|
|
assert_eq!(
|
|
json!({
|
|
"method": "collaborationMode/list",
|
|
"id": 7,
|
|
"params": {}
|
|
}),
|
|
serde_json::to_value(&request)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_list_apps() -> Result<()> {
|
|
let request = ClientRequest::AppsList {
|
|
request_id: RequestId::Integer(8),
|
|
params: v2::AppsListParams::default(),
|
|
};
|
|
assert_eq!(
|
|
json!({
|
|
"method": "app/list",
|
|
"id": 8,
|
|
"params": {
|
|
"cursor": null,
|
|
"limit": null,
|
|
"threadId": null
|
|
}
|
|
}),
|
|
serde_json::to_value(&request)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_list_experimental_features() -> Result<()> {
|
|
let request = ClientRequest::ExperimentalFeatureList {
|
|
request_id: RequestId::Integer(8),
|
|
params: v2::ExperimentalFeatureListParams::default(),
|
|
};
|
|
assert_eq!(
|
|
json!({
|
|
"method": "experimentalFeature/list",
|
|
"id": 8,
|
|
"params": {
|
|
"cursor": null,
|
|
"limit": null
|
|
}
|
|
}),
|
|
serde_json::to_value(&request)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_thread_background_terminals_clean() -> Result<()> {
|
|
let request = ClientRequest::ThreadBackgroundTerminalsClean {
|
|
request_id: RequestId::Integer(8),
|
|
params: v2::ThreadBackgroundTerminalsCleanParams {
|
|
thread_id: "thr_123".to_string(),
|
|
},
|
|
};
|
|
assert_eq!(
|
|
json!({
|
|
"method": "thread/backgroundTerminals/clean",
|
|
"id": 8,
|
|
"params": {
|
|
"threadId": "thr_123"
|
|
}
|
|
}),
|
|
serde_json::to_value(&request)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_thread_realtime_start() -> Result<()> {
|
|
let request = ClientRequest::ThreadRealtimeStart {
|
|
request_id: RequestId::Integer(9),
|
|
params: v2::ThreadRealtimeStartParams {
|
|
thread_id: "thr_123".to_string(),
|
|
prompt: "You are on a call".to_string(),
|
|
session_id: Some("sess_456".to_string()),
|
|
},
|
|
};
|
|
assert_eq!(
|
|
json!({
|
|
"method": "thread/realtime/start",
|
|
"id": 9,
|
|
"params": {
|
|
"threadId": "thr_123",
|
|
"prompt": "You are on a call",
|
|
"sessionId": "sess_456"
|
|
}
|
|
}),
|
|
serde_json::to_value(&request)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_thread_status_changed_notification() -> Result<()> {
|
|
let notification =
|
|
ServerNotification::ThreadStatusChanged(v2::ThreadStatusChangedNotification {
|
|
thread_id: "thr_123".to_string(),
|
|
status: v2::ThreadStatus::Idle,
|
|
});
|
|
assert_eq!(
|
|
json!({
|
|
"method": "thread/status/changed",
|
|
"params": {
|
|
"threadId": "thr_123",
|
|
"status": {
|
|
"type": "idle"
|
|
},
|
|
}
|
|
}),
|
|
serde_json::to_value(¬ification)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn serialize_thread_realtime_output_audio_delta_notification() -> Result<()> {
|
|
let notification = ServerNotification::ThreadRealtimeOutputAudioDelta(
|
|
v2::ThreadRealtimeOutputAudioDeltaNotification {
|
|
thread_id: "thr_123".to_string(),
|
|
audio: v2::ThreadRealtimeAudioChunk {
|
|
data: "AQID".to_string(),
|
|
sample_rate: 24_000,
|
|
num_channels: 1,
|
|
samples_per_channel: Some(512),
|
|
},
|
|
},
|
|
);
|
|
assert_eq!(
|
|
json!({
|
|
"method": "thread/realtime/outputAudio/delta",
|
|
"params": {
|
|
"threadId": "thr_123",
|
|
"audio": {
|
|
"data": "AQID",
|
|
"sampleRate": 24000,
|
|
"numChannels": 1,
|
|
"samplesPerChannel": 512
|
|
}
|
|
}
|
|
}),
|
|
serde_json::to_value(¬ification)?,
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn mock_experimental_method_is_marked_experimental() {
|
|
let request = ClientRequest::MockExperimentalMethod {
|
|
request_id: RequestId::Integer(1),
|
|
params: v2::MockExperimentalMethodParams::default(),
|
|
};
|
|
let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&request);
|
|
assert_eq!(reason, Some("mock/experimentalMethod"));
|
|
}
|
|
#[test]
|
|
fn thread_realtime_start_is_marked_experimental() {
|
|
let request = ClientRequest::ThreadRealtimeStart {
|
|
request_id: RequestId::Integer(1),
|
|
params: v2::ThreadRealtimeStartParams {
|
|
thread_id: "thr_123".to_string(),
|
|
prompt: "You are on a call".to_string(),
|
|
session_id: None,
|
|
},
|
|
};
|
|
let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&request);
|
|
assert_eq!(reason, Some("thread/realtime/start"));
|
|
}
|
|
#[test]
|
|
fn thread_realtime_started_notification_is_marked_experimental() {
|
|
let notification =
|
|
ServerNotification::ThreadRealtimeStarted(v2::ThreadRealtimeStartedNotification {
|
|
thread_id: "thr_123".to_string(),
|
|
session_id: Some("sess_456".to_string()),
|
|
});
|
|
let reason = crate::experimental_api::ExperimentalApi::experimental_reason(¬ification);
|
|
assert_eq!(reason, Some("thread/realtime/started"));
|
|
}
|
|
|
|
#[test]
|
|
fn thread_realtime_output_audio_delta_notification_is_marked_experimental() {
|
|
let notification = ServerNotification::ThreadRealtimeOutputAudioDelta(
|
|
v2::ThreadRealtimeOutputAudioDeltaNotification {
|
|
thread_id: "thr_123".to_string(),
|
|
audio: v2::ThreadRealtimeAudioChunk {
|
|
data: "AQID".to_string(),
|
|
sample_rate: 24_000,
|
|
num_channels: 1,
|
|
samples_per_channel: Some(512),
|
|
},
|
|
},
|
|
);
|
|
let reason = crate::experimental_api::ExperimentalApi::experimental_reason(¬ification);
|
|
assert_eq!(reason, Some("thread/realtime/outputAudio/delta"));
|
|
}
|
|
|
|
#[test]
|
|
fn command_execution_request_approval_additional_permissions_is_marked_experimental() {
|
|
let params = v2::CommandExecutionRequestApprovalParams {
|
|
thread_id: "thr_123".to_string(),
|
|
turn_id: "turn_123".to_string(),
|
|
item_id: "call_123".to_string(),
|
|
approval_id: None,
|
|
reason: None,
|
|
network_approval_context: None,
|
|
command: Some("cat file".to_string()),
|
|
cwd: None,
|
|
command_actions: None,
|
|
additional_permissions: Some(v2::AdditionalPermissionProfile {
|
|
network: None,
|
|
file_system: Some(v2::AdditionalFileSystemPermissions {
|
|
read: Some(vec![absolute_path("/tmp/allowed")]),
|
|
write: None,
|
|
}),
|
|
macos: None,
|
|
}),
|
|
proposed_execpolicy_amendment: None,
|
|
proposed_network_policy_amendments: None,
|
|
available_decisions: None,
|
|
};
|
|
let reason = crate::experimental_api::ExperimentalApi::experimental_reason(¶ms);
|
|
assert_eq!(
|
|
reason,
|
|
Some("item/commandExecution/requestApproval.additionalPermissions")
|
|
);
|
|
}
|
|
}
|