mirror of
https://github.com/openai/codex.git
synced 2026-05-17 01:32:32 +00:00
Compare commits
5 Commits
fix/plugin
...
codex/file
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c03932e66a | ||
|
|
b204ac18ae | ||
|
|
f1815041e4 | ||
|
|
25c7e752f8 | ||
|
|
5b0a195e48 |
@@ -30,6 +30,7 @@ const EXEC_DESCRIPTION_TEMPLATE: &str = r#"Run JavaScript code to orchestrate/co
|
||||
- `store(key: string, value: any)`: stores a serializable value under a string key for later `exec` calls in the same session.
|
||||
- `load(key: string)`: returns the stored value for a string key, or `undefined` if it is missing.
|
||||
- `notify(value: string | number | boolean | undefined | null)`: immediately injects an extra `custom_tool_call_output` for the current `exec` call. Values are stringified like `text(...)`.
|
||||
- `io.write(value: string | number[] | TextContent | BlobResourceContents, destination: "env://current/<relative-path>")`: writes text or bytes to the current environment filesystem and returns `{ uri, path, bytes_written }`.
|
||||
- `setTimeout(callback: () => void, delayMs?: number)`: schedules a callback to run later and returns a timeout id. Pending timeouts do not keep `exec` alive by themselves; await an explicit promise if you need to wait for one.
|
||||
- `clearTimeout(timeoutId?: number)`: cancels a timeout created by `setTimeout`.
|
||||
- `ALL_TOOLS`: metadata for the enabled nested tools as `{ name, description }` entries.
|
||||
|
||||
@@ -18,6 +18,7 @@ pub use description::render_json_schema_to_typescript;
|
||||
pub use response::DEFAULT_IMAGE_DETAIL;
|
||||
pub use response::FunctionCallOutputContentItem;
|
||||
pub use response::ImageDetail;
|
||||
pub use runtime::CodeModeIoWrite;
|
||||
pub use runtime::CodeModeNestedToolCall;
|
||||
pub use runtime::DEFAULT_EXEC_YIELD_TIME_MS;
|
||||
pub use runtime::DEFAULT_MAX_OUTPUT_TOKENS_PER_EXEC_CALL;
|
||||
|
||||
@@ -9,6 +9,7 @@ use super::value::normalize_output_image;
|
||||
use super::value::serialize_output_text;
|
||||
use super::value::throw_type_error;
|
||||
use super::value::v8_value_to_json;
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
pub(super) fn tool_callback(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
@@ -74,6 +75,59 @@ pub(super) fn tool_callback(
|
||||
retval.set(promise.into());
|
||||
}
|
||||
|
||||
pub(super) fn io_write_callback(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
args: v8::FunctionCallbackArguments,
|
||||
mut retval: v8::ReturnValue<v8::Value>,
|
||||
) {
|
||||
if args.length() < 2 {
|
||||
throw_type_error(scope, "io.write expects a value and destination");
|
||||
return;
|
||||
}
|
||||
|
||||
let value = match v8_value_to_json(scope, args.get(0)) {
|
||||
Ok(Some(value)) => value,
|
||||
Ok(None) => JsonValue::Null,
|
||||
Err(error_text) => {
|
||||
throw_type_error(scope, &error_text);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let destination = match args.get(1).to_string(scope) {
|
||||
Some(destination) => destination.to_rust_string_lossy(scope),
|
||||
None => {
|
||||
throw_type_error(scope, "io.write destination must be a string");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if destination.trim().is_empty() {
|
||||
throw_type_error(scope, "io.write destination must be non-empty");
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(resolver) = v8::PromiseResolver::new(scope) else {
|
||||
throw_type_error(scope, "failed to create io.write promise");
|
||||
return;
|
||||
};
|
||||
let promise = resolver.get_promise(scope);
|
||||
let resolver = v8::Global::new(scope, resolver);
|
||||
|
||||
let Some(state) = scope.get_slot_mut::<RuntimeState>() else {
|
||||
throw_type_error(scope, "runtime state unavailable");
|
||||
return;
|
||||
};
|
||||
let id = format!("io-write-{}", state.next_tool_call_id);
|
||||
state.next_tool_call_id = state.next_tool_call_id.saturating_add(1);
|
||||
let event_tx = state.event_tx.clone();
|
||||
state.pending_tool_calls.insert(id.clone(), resolver);
|
||||
let _ = event_tx.send(RuntimeEvent::IoWrite {
|
||||
id,
|
||||
value,
|
||||
destination,
|
||||
});
|
||||
retval.set(promise.into());
|
||||
}
|
||||
|
||||
pub(super) fn text_callback(
|
||||
scope: &mut v8::PinScope<'_, '_>,
|
||||
args: v8::FunctionCallbackArguments,
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::RuntimeState;
|
||||
use super::callbacks::clear_timeout_callback;
|
||||
use super::callbacks::exit_callback;
|
||||
use super::callbacks::image_callback;
|
||||
use super::callbacks::io_write_callback;
|
||||
use super::callbacks::load_callback;
|
||||
use super::callbacks::notify_callback;
|
||||
use super::callbacks::set_timeout_callback;
|
||||
@@ -19,6 +20,7 @@ pub(super) fn install_globals(scope: &mut v8::PinScope<'_, '_>) -> Result<(), St
|
||||
|
||||
let tools = build_tools_object(scope)?;
|
||||
let all_tools = build_all_tools_value(scope)?;
|
||||
let io = build_io_object(scope)?;
|
||||
let clear_timeout = helper_function(scope, "clearTimeout", clear_timeout_callback)?;
|
||||
let set_timeout = helper_function(scope, "setTimeout", set_timeout_callback)?;
|
||||
let text = helper_function(scope, "text", text_callback)?;
|
||||
@@ -30,6 +32,7 @@ pub(super) fn install_globals(scope: &mut v8::PinScope<'_, '_>) -> Result<(), St
|
||||
let exit = helper_function(scope, "exit", exit_callback)?;
|
||||
|
||||
set_global(scope, global, "tools", tools.into())?;
|
||||
set_global(scope, global, "io", io.into())?;
|
||||
set_global(scope, global, "ALL_TOOLS", all_tools)?;
|
||||
set_global(scope, global, "clearTimeout", clear_timeout.into())?;
|
||||
set_global(scope, global, "setTimeout", set_timeout.into())?;
|
||||
@@ -43,6 +46,20 @@ pub(super) fn install_globals(scope: &mut v8::PinScope<'_, '_>) -> Result<(), St
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_io_object<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
) -> Result<v8::Local<'s, v8::Object>, String> {
|
||||
let io = v8::Object::new(scope);
|
||||
let write_key = v8::String::new(scope, "write")
|
||||
.ok_or_else(|| "failed to allocate io.write key".to_string())?;
|
||||
let write = helper_function(scope, "io.write", io_write_callback)?;
|
||||
if io.set(scope, write_key.into(), write.into()) == Some(true) {
|
||||
Ok(io)
|
||||
} else {
|
||||
Err("failed to set io.write".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn build_tools_object<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
) -> Result<v8::Local<'s, v8::Object>, String> {
|
||||
|
||||
@@ -100,9 +100,18 @@ pub struct CodeModeNestedToolCall {
|
||||
pub input: Option<JsonValue>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct CodeModeIoWrite {
|
||||
pub cell_id: String,
|
||||
pub runtime_write_id: String,
|
||||
pub value: JsonValue,
|
||||
pub destination: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum TurnMessage {
|
||||
ToolCall(CodeModeNestedToolCall),
|
||||
IoWrite(CodeModeIoWrite),
|
||||
Notify {
|
||||
cell_id: String,
|
||||
call_id: String,
|
||||
@@ -128,6 +137,11 @@ pub(crate) enum RuntimeEvent {
|
||||
name: ToolName,
|
||||
input: Option<JsonValue>,
|
||||
},
|
||||
IoWrite {
|
||||
id: String,
|
||||
value: JsonValue,
|
||||
destination: String,
|
||||
},
|
||||
Notify {
|
||||
call_id: String,
|
||||
text: String,
|
||||
|
||||
@@ -13,6 +13,7 @@ use tokio_util::sync::CancellationToken;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::FunctionCallOutputContentItem;
|
||||
use crate::runtime::CodeModeIoWrite;
|
||||
use crate::runtime::CodeModeNestedToolCall;
|
||||
use crate::runtime::DEFAULT_EXEC_YIELD_TIME_MS;
|
||||
use crate::runtime::ExecuteRequest;
|
||||
@@ -33,6 +34,12 @@ pub trait CodeModeTurnHost: Send + Sync {
|
||||
) -> Result<JsonValue, String>;
|
||||
|
||||
async fn notify(&self, call_id: String, cell_id: String, text: String) -> Result<(), String>;
|
||||
|
||||
async fn write_file(
|
||||
&self,
|
||||
request: CodeModeIoWrite,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> Result<JsonValue, String>;
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -222,6 +229,35 @@ impl CodeModeService {
|
||||
let _ = runtime_tx.send(command);
|
||||
});
|
||||
}
|
||||
TurnMessage::IoWrite(request) => {
|
||||
let host = Arc::clone(&host);
|
||||
let inner = Arc::clone(&inner);
|
||||
tokio::spawn(async move {
|
||||
let cell_id = request.cell_id.clone();
|
||||
let runtime_write_id = request.runtime_write_id.clone();
|
||||
let response = host.write_file(request, CancellationToken::new()).await;
|
||||
let runtime_tx = inner
|
||||
.sessions
|
||||
.lock()
|
||||
.await
|
||||
.get(&cell_id)
|
||||
.map(|handle| handle.runtime_tx.clone());
|
||||
let Some(runtime_tx) = runtime_tx else {
|
||||
return;
|
||||
};
|
||||
let command = match response {
|
||||
Ok(result) => RuntimeCommand::ToolResponse {
|
||||
id: runtime_write_id,
|
||||
result,
|
||||
},
|
||||
Err(error_text) => RuntimeCommand::ToolError {
|
||||
id: runtime_write_id,
|
||||
error_text,
|
||||
},
|
||||
};
|
||||
let _ = runtime_tx.send(command);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -397,6 +433,19 @@ async fn run_session_control(
|
||||
.send(TurnMessage::ToolCall(tool_call))
|
||||
.await;
|
||||
}
|
||||
RuntimeEvent::IoWrite {
|
||||
id,
|
||||
value,
|
||||
destination,
|
||||
} => {
|
||||
let request = CodeModeIoWrite {
|
||||
cell_id: cell_id.clone(),
|
||||
runtime_write_id: id,
|
||||
value,
|
||||
destination,
|
||||
};
|
||||
let _ = inner.turn_message_tx.send(TurnMessage::IoWrite(request)).await;
|
||||
}
|
||||
RuntimeEvent::Result {
|
||||
stored_values,
|
||||
error_text,
|
||||
|
||||
@@ -275,7 +275,7 @@ fn mask_input_property_schema(schema: &mut JsonValue) {
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_default();
|
||||
let guidance = "This parameter expects an absolute local file path. If you want to upload a file, provide the absolute path to that file here.";
|
||||
let guidance = "This parameter expects an absolute local file path or an env://current/<relative-path> file reference. If you want to upload a file, provide the path or file reference here.";
|
||||
if description.is_empty() {
|
||||
description = guidance.to_string();
|
||||
} else if !description.contains(guidance) {
|
||||
|
||||
@@ -12,9 +12,35 @@
|
||||
|
||||
use crate::session::session::Session;
|
||||
use crate::session::turn_context::TurnContext;
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use codex_api::upload_local_file;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::path::Path;
|
||||
|
||||
const CURRENT_ENV_URI_PREFIX: &str = "env://current/";
|
||||
|
||||
// rmcp 0.15 does not expose SEP-2356 `Tool.inputFiles` yet. Keep the encoder
|
||||
// close to the broker so wiring the field later does not introduce a new path.
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct Sep2356FileInput {
|
||||
pub(crate) data_uri: String,
|
||||
pub(crate) file_name: String,
|
||||
pub(crate) mime_type: String,
|
||||
pub(crate) file_size_bytes: u64,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) async fn build_sep2356_file_input_for_mcp(
|
||||
turn_context: &TurnContext,
|
||||
file_ref: &str,
|
||||
) -> Result<Sep2356FileInput, String> {
|
||||
let file_input = FileBrokerInput::from_model_argument(turn_context, file_ref)?;
|
||||
file_input.to_sep2356_file_input().await
|
||||
}
|
||||
|
||||
pub(crate) async fn rewrite_mcp_tool_arguments_for_openai_files(
|
||||
sess: &Session,
|
||||
@@ -26,75 +52,260 @@ pub(crate) async fn rewrite_mcp_tool_arguments_for_openai_files(
|
||||
return Ok(arguments_value);
|
||||
};
|
||||
|
||||
let Some(arguments_value) = arguments_value else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(arguments) = arguments_value.as_object() else {
|
||||
return Ok(Some(arguments_value));
|
||||
};
|
||||
let auth = sess.services.auth_manager.auth().await;
|
||||
let mut rewritten_arguments = arguments.clone();
|
||||
|
||||
for field_name in openai_file_input_params {
|
||||
let Some(value) = arguments.get(field_name) else {
|
||||
continue;
|
||||
};
|
||||
let Some(uploaded_value) =
|
||||
rewrite_argument_value_for_openai_files(turn_context, auth.as_ref(), field_name, value)
|
||||
.await?
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
rewritten_arguments.insert(field_name.clone(), uploaded_value);
|
||||
}
|
||||
|
||||
if rewritten_arguments == *arguments {
|
||||
return Ok(Some(arguments_value));
|
||||
}
|
||||
|
||||
Ok(Some(JsonValue::Object(rewritten_arguments)))
|
||||
OpenAiFileBroker::new(turn_context, auth.as_ref())
|
||||
.rewrite_tool_arguments(arguments_value, openai_file_input_params)
|
||||
.await
|
||||
}
|
||||
|
||||
struct OpenAiFileBroker<'a> {
|
||||
turn_context: &'a TurnContext,
|
||||
auth: Option<&'a CodexAuth>,
|
||||
}
|
||||
|
||||
impl<'a> OpenAiFileBroker<'a> {
|
||||
fn new(turn_context: &'a TurnContext, auth: Option<&'a CodexAuth>) -> Self {
|
||||
Self { turn_context, auth }
|
||||
}
|
||||
|
||||
async fn rewrite_tool_arguments(
|
||||
&self,
|
||||
arguments_value: Option<JsonValue>,
|
||||
openai_file_input_params: &[String],
|
||||
) -> Result<Option<JsonValue>, String> {
|
||||
let Some(arguments_value) = arguments_value else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(arguments) = arguments_value.as_object() else {
|
||||
return Ok(Some(arguments_value));
|
||||
};
|
||||
let mut rewritten_arguments = arguments.clone();
|
||||
|
||||
for field_name in openai_file_input_params {
|
||||
let Some(value) = arguments.get(field_name) else {
|
||||
continue;
|
||||
};
|
||||
let Some(uploaded_value) = self.rewrite_argument_value(field_name, value).await? else {
|
||||
continue;
|
||||
};
|
||||
rewritten_arguments.insert(field_name.clone(), uploaded_value);
|
||||
}
|
||||
|
||||
if rewritten_arguments == *arguments {
|
||||
return Ok(Some(arguments_value));
|
||||
}
|
||||
|
||||
Ok(Some(JsonValue::Object(rewritten_arguments)))
|
||||
}
|
||||
|
||||
async fn rewrite_argument_value(
|
||||
&self,
|
||||
field_name: &str,
|
||||
value: &JsonValue,
|
||||
) -> Result<Option<JsonValue>, String> {
|
||||
match value {
|
||||
JsonValue::String(path_or_file_ref) => {
|
||||
let rewritten = self
|
||||
.build_uploaded_argument_value(
|
||||
field_name,
|
||||
/*index*/ None,
|
||||
path_or_file_ref,
|
||||
)
|
||||
.await?;
|
||||
Ok(Some(rewritten))
|
||||
}
|
||||
JsonValue::Array(values) => {
|
||||
let mut rewritten_values = Vec::with_capacity(values.len());
|
||||
for (index, item) in values.iter().enumerate() {
|
||||
let Some(path_or_file_ref) = item.as_str() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let rewritten = self
|
||||
.build_uploaded_argument_value(field_name, Some(index), path_or_file_ref)
|
||||
.await?;
|
||||
rewritten_values.push(rewritten);
|
||||
}
|
||||
Ok(Some(JsonValue::Array(rewritten_values)))
|
||||
}
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_uploaded_argument_value(
|
||||
&self,
|
||||
field_name: &str,
|
||||
index: Option<usize>,
|
||||
file_path: &str,
|
||||
) -> Result<JsonValue, String> {
|
||||
let file_input = FileBrokerInput::from_model_argument(self.turn_context, file_path)?;
|
||||
let Some(auth) = self.auth else {
|
||||
return Err(
|
||||
"ChatGPT auth is required to upload local files for Codex Apps tools".to_string(),
|
||||
);
|
||||
};
|
||||
if !auth.uses_codex_backend() {
|
||||
return Err(
|
||||
"ChatGPT auth is required to upload local files for Codex Apps tools".to_string(),
|
||||
);
|
||||
}
|
||||
let upload_auth = codex_model_provider::auth_provider_from_auth(auth);
|
||||
let uploaded = upload_local_file(
|
||||
self.turn_context
|
||||
.config
|
||||
.chatgpt_base_url
|
||||
.trim_end_matches('/'),
|
||||
upload_auth.as_ref(),
|
||||
&file_input.resolved_path,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| match index {
|
||||
Some(index) => {
|
||||
format!(
|
||||
"failed to upload `{}` for `{field_name}[{index}]`: {error}",
|
||||
file_input.original
|
||||
)
|
||||
}
|
||||
None => format!(
|
||||
"failed to upload `{}` for `{field_name}`: {error}",
|
||||
file_input.original
|
||||
),
|
||||
})?;
|
||||
Ok(serde_json::json!({
|
||||
"download_url": uploaded.download_url,
|
||||
"file_id": uploaded.file_id,
|
||||
"mime_type": uploaded.mime_type,
|
||||
"file_name": uploaded.file_name,
|
||||
"uri": uploaded.uri,
|
||||
"file_size_bytes": uploaded.file_size_bytes,
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
struct FileBrokerInput<'a> {
|
||||
original: &'a str,
|
||||
resolved_path: AbsolutePathBuf,
|
||||
}
|
||||
|
||||
impl<'a> FileBrokerInput<'a> {
|
||||
fn from_model_argument(turn_context: &TurnContext, value: &'a str) -> Result<Self, String> {
|
||||
if let Some(relative_path) = value.strip_prefix(CURRENT_ENV_URI_PREFIX) {
|
||||
return Self::current_env_path(turn_context, value, relative_path);
|
||||
}
|
||||
if value.starts_with("env://") {
|
||||
return Err(format!("unsupported file environment reference `{value}`"));
|
||||
}
|
||||
Ok(Self {
|
||||
original: value,
|
||||
resolved_path: turn_context.resolve_path(Some(value.to_string())),
|
||||
})
|
||||
}
|
||||
|
||||
fn current_env_path(
|
||||
turn_context: &TurnContext,
|
||||
original: &'a str,
|
||||
relative_path: &str,
|
||||
) -> Result<Self, String> {
|
||||
if relative_path.trim().is_empty() {
|
||||
return Err("file environment reference path must be non-empty".to_string());
|
||||
}
|
||||
if Path::new(relative_path).is_absolute() {
|
||||
return Err("file environment reference path must be relative".to_string());
|
||||
}
|
||||
let resolved_path = turn_context.cwd.join(relative_path);
|
||||
if !resolved_path
|
||||
.as_path()
|
||||
.starts_with(turn_context.cwd.as_path())
|
||||
{
|
||||
return Err(format!(
|
||||
"file environment reference `{original}` escapes the current environment"
|
||||
));
|
||||
}
|
||||
Ok(Self {
|
||||
original,
|
||||
resolved_path,
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
async fn to_sep2356_file_input(&self) -> Result<Sep2356FileInput, String> {
|
||||
let bytes = tokio::fs::read(self.resolved_path.as_path())
|
||||
.await
|
||||
.map_err(|error| {
|
||||
format!(
|
||||
"failed to read `{}` for SEP-2356 file input: {error}",
|
||||
self.original
|
||||
)
|
||||
})?;
|
||||
let file_name = self
|
||||
.resolved_path
|
||||
.as_path()
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.filter(|name| !name.is_empty())
|
||||
.ok_or_else(|| format!("failed to determine file name for `{}`", self.original))?
|
||||
.to_string();
|
||||
let mime_type = mime_type_for_sep2356_path(self.resolved_path.as_path()).to_string();
|
||||
let encoded_name = percent_encode_rfc2397_parameter(&file_name);
|
||||
let data_uri = format!(
|
||||
"data:{mime_type};name={encoded_name};base64,{}",
|
||||
BASE64_STANDARD.encode(&bytes)
|
||||
);
|
||||
Ok(Sep2356FileInput {
|
||||
data_uri,
|
||||
file_name,
|
||||
mime_type,
|
||||
file_size_bytes: bytes.len() as u64,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn mime_type_for_sep2356_path(path: &Path) -> &'static str {
|
||||
match path
|
||||
.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.map(str::to_ascii_lowercase)
|
||||
.as_deref()
|
||||
{
|
||||
Some("csv") => "text/csv",
|
||||
Some("gif") => "image/gif",
|
||||
Some("htm" | "html") => "text/html",
|
||||
Some("jpg" | "jpeg") => "image/jpeg",
|
||||
Some("json") => "application/json",
|
||||
Some("pdf") => "application/pdf",
|
||||
Some("png") => "image/png",
|
||||
Some("txt") => "text/plain",
|
||||
Some("webp") => "image/webp",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn percent_encode_rfc2397_parameter(value: &str) -> String {
|
||||
let mut encoded = String::with_capacity(value.len());
|
||||
for byte in value.as_bytes() {
|
||||
if byte.is_ascii_alphanumeric() || matches!(*byte, b'-' | b'.' | b'_' | b'~') {
|
||||
encoded.push(*byte as char);
|
||||
} else {
|
||||
encoded.push_str(&format!("%{byte:02X}"));
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn rewrite_argument_value_for_openai_files(
|
||||
turn_context: &TurnContext,
|
||||
auth: Option<&CodexAuth>,
|
||||
field_name: &str,
|
||||
value: &JsonValue,
|
||||
) -> Result<Option<JsonValue>, String> {
|
||||
match value {
|
||||
JsonValue::String(path_or_file_ref) => {
|
||||
let rewritten = build_uploaded_local_argument_value(
|
||||
turn_context,
|
||||
auth,
|
||||
field_name,
|
||||
/*index*/ None,
|
||||
path_or_file_ref,
|
||||
)
|
||||
.await?;
|
||||
Ok(Some(rewritten))
|
||||
}
|
||||
JsonValue::Array(values) => {
|
||||
let mut rewritten_values = Vec::with_capacity(values.len());
|
||||
for (index, item) in values.iter().enumerate() {
|
||||
let Some(path_or_file_ref) = item.as_str() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let rewritten = build_uploaded_local_argument_value(
|
||||
turn_context,
|
||||
auth,
|
||||
field_name,
|
||||
Some(index),
|
||||
path_or_file_ref,
|
||||
)
|
||||
.await?;
|
||||
rewritten_values.push(rewritten);
|
||||
}
|
||||
Ok(Some(JsonValue::Array(rewritten_values)))
|
||||
}
|
||||
_ => Ok(None),
|
||||
}
|
||||
OpenAiFileBroker::new(turn_context, auth)
|
||||
.rewrite_argument_value(field_name, value)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
async fn build_uploaded_local_argument_value(
|
||||
turn_context: &TurnContext,
|
||||
auth: Option<&CodexAuth>,
|
||||
@@ -102,38 +313,9 @@ async fn build_uploaded_local_argument_value(
|
||||
index: Option<usize>,
|
||||
file_path: &str,
|
||||
) -> Result<JsonValue, String> {
|
||||
let resolved_path = turn_context.resolve_path(Some(file_path.to_string()));
|
||||
let Some(auth) = auth else {
|
||||
return Err(
|
||||
"ChatGPT auth is required to upload local files for Codex Apps tools".to_string(),
|
||||
);
|
||||
};
|
||||
if !auth.uses_codex_backend() {
|
||||
return Err(
|
||||
"ChatGPT auth is required to upload local files for Codex Apps tools".to_string(),
|
||||
);
|
||||
}
|
||||
let upload_auth = codex_model_provider::auth_provider_from_auth(auth);
|
||||
let uploaded = upload_local_file(
|
||||
turn_context.config.chatgpt_base_url.trim_end_matches('/'),
|
||||
upload_auth.as_ref(),
|
||||
&resolved_path,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| match index {
|
||||
Some(index) => {
|
||||
format!("failed to upload `{file_path}` for `{field_name}[{index}]`: {error}")
|
||||
}
|
||||
None => format!("failed to upload `{file_path}` for `{field_name}`: {error}"),
|
||||
})?;
|
||||
Ok(serde_json::json!({
|
||||
"download_url": uploaded.download_url,
|
||||
"file_id": uploaded.file_id,
|
||||
"mime_type": uploaded.mime_type,
|
||||
"file_name": uploaded.file_name,
|
||||
"uri": uploaded.uri,
|
||||
"file_size_bytes": uploaded.file_size_bytes,
|
||||
}))
|
||||
OpenAiFileBroker::new(turn_context, auth)
|
||||
.build_uploaded_argument_value(field_name, index, file_path)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -245,6 +427,144 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_uploaded_local_argument_value_uploads_current_env_file_ref() {
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::body_json;
|
||||
use wiremock::matchers::header;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/backend-api/files"))
|
||||
.and(header("chatgpt-account-id", "account_id"))
|
||||
.and(body_json(serde_json::json!({
|
||||
"file_name": "file_report.csv",
|
||||
"file_size": 5,
|
||||
"use_case": "codex",
|
||||
})))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"file_id": "file_123",
|
||||
"upload_url": format!("{}/upload/file_123", server.uri()),
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("PUT"))
|
||||
.and(path("/upload/file_123"))
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/backend-api/files/file_123/uploaded"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"status": "success",
|
||||
"download_url": format!("{}/download/file_123", server.uri()),
|
||||
"file_name": "file_report.csv",
|
||||
"mime_type": "text/csv",
|
||||
"file_size_bytes": 5,
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let (_, mut turn_context) = make_session_and_context().await;
|
||||
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
|
||||
let dir = tempdir().expect("temp dir");
|
||||
tokio::fs::create_dir_all(dir.path().join("nested"))
|
||||
.await
|
||||
.expect("create nested dir");
|
||||
tokio::fs::write(dir.path().join("nested/file_report.csv"), b"hello")
|
||||
.await
|
||||
.expect("write env file ref target");
|
||||
turn_context.cwd = AbsolutePathBuf::try_from(dir.path()).expect("absolute path");
|
||||
|
||||
let mut config = (*turn_context.config).clone();
|
||||
config.chatgpt_base_url = format!("{}/backend-api", server.uri());
|
||||
turn_context.config = Arc::new(config);
|
||||
|
||||
let rewritten = build_uploaded_local_argument_value(
|
||||
&turn_context,
|
||||
Some(&auth),
|
||||
"file",
|
||||
/*index*/ None,
|
||||
"env://current/nested/file_report.csv",
|
||||
)
|
||||
.await
|
||||
.expect("rewrite should upload the env file ref");
|
||||
|
||||
assert_eq!(
|
||||
rewritten,
|
||||
serde_json::json!({
|
||||
"download_url": format!("{}/download/file_123", server.uri()),
|
||||
"file_id": "file_123",
|
||||
"mime_type": "text/csv",
|
||||
"file_name": "file_report.csv",
|
||||
"uri": "sediment://file_123",
|
||||
"file_size_bytes": 5,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_uploaded_local_argument_value_rejects_current_env_escape() {
|
||||
let (_, mut turn_context) = make_session_and_context().await;
|
||||
let dir = tempdir().expect("temp dir");
|
||||
turn_context.cwd = AbsolutePathBuf::try_from(dir.path()).expect("absolute path");
|
||||
|
||||
let error = build_uploaded_local_argument_value(
|
||||
&turn_context,
|
||||
Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()),
|
||||
"file",
|
||||
/*index*/ None,
|
||||
"env://current/../outside.csv",
|
||||
)
|
||||
.await
|
||||
.expect_err("env file refs should not escape cwd");
|
||||
|
||||
assert!(error.contains("escapes the current environment"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_sep2356_file_input_encodes_current_env_file_ref_as_data_uri() {
|
||||
let (_, mut turn_context) = make_session_and_context().await;
|
||||
let dir = tempdir().expect("temp dir");
|
||||
tokio::fs::write(dir.path().join("report 1.csv"), b"hello")
|
||||
.await
|
||||
.expect("write sep file");
|
||||
turn_context.cwd = AbsolutePathBuf::try_from(dir.path()).expect("absolute path");
|
||||
|
||||
let file_input =
|
||||
build_sep2356_file_input_for_mcp(&turn_context, "env://current/report 1.csv")
|
||||
.await
|
||||
.expect("sep file input should be encoded");
|
||||
|
||||
assert_eq!(file_input.file_name, "report 1.csv");
|
||||
assert_eq!(file_input.mime_type, "text/csv");
|
||||
assert_eq!(file_input.file_size_bytes, 5);
|
||||
assert_eq!(
|
||||
file_input.data_uri,
|
||||
"data:text/csv;name=report%201.csv;base64,aGVsbG8="
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_sep2356_file_input_rejects_current_env_escape() {
|
||||
let (_, mut turn_context) = make_session_and_context().await;
|
||||
let dir = tempdir().expect("temp dir");
|
||||
turn_context.cwd = AbsolutePathBuf::try_from(dir.path()).expect("absolute path");
|
||||
|
||||
let error = build_sep2356_file_input_for_mcp(&turn_context, "env://current/../outside.csv")
|
||||
.await
|
||||
.expect_err("sep file refs should not escape cwd");
|
||||
|
||||
assert!(error.contains("escapes the current environment"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rewrite_argument_value_for_openai_files_rewrites_scalar_path() {
|
||||
use wiremock::Mock;
|
||||
|
||||
@@ -4,9 +4,13 @@ mod response_adapter;
|
||||
mod wait_handler;
|
||||
pub(crate) mod wait_spec;
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use codex_code_mode::CodeModeIoWrite;
|
||||
use codex_code_mode::CodeModeNestedToolCall;
|
||||
use codex_code_mode::CodeModeTurnHost;
|
||||
use codex_code_mode::RuntimeResponse;
|
||||
@@ -158,6 +162,104 @@ impl CodeModeTurnHost for CoreTurnHost {
|
||||
format!("failed to inject exec notify message for cell {cell_id}: no active turn")
|
||||
})
|
||||
}
|
||||
|
||||
async fn write_file(
|
||||
&self,
|
||||
request: CodeModeIoWrite,
|
||||
_cancellation_token: CancellationToken,
|
||||
) -> Result<JsonValue, String> {
|
||||
write_code_mode_io_value(self.exec.turn.as_ref(), request).await
|
||||
}
|
||||
}
|
||||
|
||||
const CURRENT_ENV_URI_PREFIX: &str = "env://current/";
|
||||
|
||||
async fn write_code_mode_io_value(
|
||||
turn: &TurnContext,
|
||||
request: CodeModeIoWrite,
|
||||
) -> Result<JsonValue, String> {
|
||||
let destination = resolve_code_mode_io_destination(turn, &request.destination)?;
|
||||
let bytes = code_mode_io_value_bytes(&request.value)?;
|
||||
if let Some(parent) = destination.parent() {
|
||||
tokio::fs::create_dir_all(parent.as_path())
|
||||
.await
|
||||
.map_err(|error| format!("failed to create parent directory for io.write: {error}"))?;
|
||||
}
|
||||
tokio::fs::write(destination.as_path(), &bytes)
|
||||
.await
|
||||
.map_err(|error| format!("failed to write `{}`: {error}", request.destination))?;
|
||||
Ok(serde_json::json!({
|
||||
"uri": request.destination,
|
||||
"path": destination.to_string_lossy(),
|
||||
"bytes_written": bytes.len(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn resolve_code_mode_io_destination(
|
||||
turn: &TurnContext,
|
||||
destination: &str,
|
||||
) -> Result<codex_utils_absolute_path::AbsolutePathBuf, String> {
|
||||
let relative_path = destination
|
||||
.strip_prefix(CURRENT_ENV_URI_PREFIX)
|
||||
.ok_or_else(|| {
|
||||
format!("io.write destination must start with `{CURRENT_ENV_URI_PREFIX}`")
|
||||
})?;
|
||||
if relative_path.trim().is_empty() {
|
||||
return Err("io.write destination path must be non-empty".to_string());
|
||||
}
|
||||
if Path::new(relative_path).is_absolute() {
|
||||
return Err("io.write destination path must be relative".to_string());
|
||||
}
|
||||
let resolved = turn.cwd.join(relative_path);
|
||||
if !resolved.as_path().starts_with(turn.cwd.as_path()) {
|
||||
return Err(format!(
|
||||
"io.write destination `{destination}` escapes the current environment"
|
||||
));
|
||||
}
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
fn code_mode_io_value_bytes(value: &JsonValue) -> Result<Vec<u8>, String> {
|
||||
match value {
|
||||
JsonValue::String(text) => Ok(text.as_bytes().to_vec()),
|
||||
JsonValue::Array(items) if items.iter().all(JsonValue::is_number) => items
|
||||
.iter()
|
||||
.map(|item| {
|
||||
let Some(byte) = item.as_u64().filter(|byte| *byte <= u8::MAX as u64) else {
|
||||
return Err(
|
||||
"io.write byte arrays must contain integers from 0 to 255".to_string()
|
||||
);
|
||||
};
|
||||
Ok(byte as u8)
|
||||
})
|
||||
.collect(),
|
||||
JsonValue::Object(object) => {
|
||||
if let Some(text) = object.get("text").and_then(JsonValue::as_str) {
|
||||
return Ok(text.as_bytes().to_vec());
|
||||
}
|
||||
if let Some(resource) = object.get("resource") {
|
||||
return code_mode_io_value_bytes(resource);
|
||||
}
|
||||
if let Some(encoded) = object
|
||||
.get("blob")
|
||||
.or_else(|| object.get("data_base64"))
|
||||
.or_else(|| object.get("base64"))
|
||||
.and_then(JsonValue::as_str)
|
||||
{
|
||||
return BASE64_STANDARD
|
||||
.decode(encoded)
|
||||
.map_err(|error| format!("io.write received invalid base64 data: {error}"));
|
||||
}
|
||||
Err(
|
||||
"io.write value must be a string, byte array, MCP text block, or base64 blob block"
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
_ => Err(
|
||||
"io.write value must be a string, byte array, MCP text block, or base64 blob block"
|
||||
.to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn handle_runtime_response(
|
||||
|
||||
@@ -2170,6 +2170,43 @@ async fn code_mode_can_apply_patch_via_nested_tool() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn code_mode_io_write_writes_text_to_current_env() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let code = r#"
|
||||
const result = await io.write("hello from io", "env://current/nested/io-output.txt");
|
||||
text(JSON.stringify(result));
|
||||
"#;
|
||||
|
||||
let (test, second_mock) =
|
||||
run_code_mode_turn(&server, "use io.write to write a file", code, false).await?;
|
||||
|
||||
let req = second_mock.single_request();
|
||||
let (output, success) = custom_tool_output_body_and_success(&req, "call-1");
|
||||
assert_ne!(
|
||||
success,
|
||||
Some(false),
|
||||
"exec io.write failed unexpectedly: {output}"
|
||||
);
|
||||
let parsed: Value = serde_json::from_str(&output)?;
|
||||
assert_eq!(
|
||||
parsed.get("uri").and_then(Value::as_str),
|
||||
Some("env://current/nested/io-output.txt")
|
||||
);
|
||||
assert_eq!(
|
||||
parsed.get("bytes_written").and_then(Value::as_u64),
|
||||
Some(13)
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(test.cwd_path().join("nested/io-output.txt"))?,
|
||||
"hello from io"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn code_mode_can_print_structured_mcp_tool_result_fields() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -15,11 +15,13 @@ use core_test_support::apps_test_server::DOCUMENT_EXTRACT_TEXT_RESOURCE_URI;
|
||||
use core_test_support::hooks::trust_discovered_hooks;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
use core_test_support::responses::ev_custom_tool_call;
|
||||
use core_test_support::responses::ev_function_call_with_namespace;
|
||||
use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::mount_sse_sequence;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
@@ -188,7 +190,7 @@ async fn codex_apps_file_params_upload_local_paths_before_mcp_tool_call() -> Res
|
||||
extract_tool.pointer("/parameters/properties/file"),
|
||||
Some(&json!({
|
||||
"type": "string",
|
||||
"description": "Document file payload. This parameter expects an absolute local file path. If you want to upload a file, provide the absolute path to that file here."
|
||||
"description": "Document file payload. This parameter expects an absolute local file path or an env://current/<relative-path> file reference. If you want to upload a file, provide the path or file reference here."
|
||||
}))
|
||||
);
|
||||
|
||||
@@ -245,3 +247,139 @@ async fn codex_apps_file_params_upload_local_paths_before_mcp_tool_call() -> Res
|
||||
server.verify().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn code_mode_nested_mcp_file_params_upload_local_paths_before_tool_call() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = start_mock_server().await;
|
||||
let apps_server = AppsTestServer::mount(&server).await?;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/files"))
|
||||
.and(header("chatgpt-account-id", "account_id"))
|
||||
.and(body_json(json!({
|
||||
"file_name": "report.txt",
|
||||
"file_size": 11,
|
||||
"use_case": "codex",
|
||||
})))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"file_id": "file_123",
|
||||
"upload_url": format!("{}/upload/file_123", server.uri()),
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("PUT"))
|
||||
.and(path("/upload/file_123"))
|
||||
.and(header("content-length", "11"))
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/files/file_123/uploaded"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"status": "success",
|
||||
"download_url": format!("{}/download/file_123", server.uri()),
|
||||
"file_name": "report.txt",
|
||||
"mime_type": "text/plain",
|
||||
"file_size_bytes": 11,
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let code = r#"
|
||||
const result = await tools.mcp__codex_apps__calendar_extract_text({
|
||||
file: "report.txt",
|
||||
});
|
||||
text(result.content?.[0]?.text ?? "missing result");
|
||||
"#;
|
||||
mount_sse_sequence(
|
||||
&server,
|
||||
vec![
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_custom_tool_call("call-1", "exec", code),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
sse(vec![
|
||||
ev_response_created("resp-2"),
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut builder = test_codex()
|
||||
.with_auth(CodexAuth::create_dummy_chatgpt_auth_for_testing())
|
||||
.with_config(move |config| {
|
||||
configure_apps(config, apps_server.chatgpt_base_url.as_str());
|
||||
config
|
||||
.features
|
||||
.enable(Feature::CodeMode)
|
||||
.expect("test config should allow feature update");
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
tokio::fs::write(test.cwd.path().join("report.txt"), b"hello world").await?;
|
||||
|
||||
test.submit_turn_with_approval_and_permission_profile(
|
||||
"Use code mode to extract the report text with the app tool.",
|
||||
AskForApproval::Never,
|
||||
PermissionProfile::Disabled,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let apps_tool_call = server
|
||||
.received_requests()
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.find_map(|request| {
|
||||
let body: Value = serde_json::from_slice(&request.body).ok()?;
|
||||
(request.url.path() == "/api/codex/apps"
|
||||
&& body.get("method").and_then(Value::as_str) == Some("tools/call")
|
||||
&& body.pointer("/params/name").and_then(Value::as_str)
|
||||
== Some("calendar_extract_text"))
|
||||
.then_some(body)
|
||||
})
|
||||
.expect("apps calendar_extract_text tools/call request should be recorded");
|
||||
|
||||
assert_eq!(
|
||||
apps_tool_call.pointer("/params/arguments/file"),
|
||||
Some(&json!({
|
||||
"download_url": format!("{}/download/file_123", server.uri()),
|
||||
"file_id": "file_123",
|
||||
"mime_type": "text/plain",
|
||||
"file_name": "report.txt",
|
||||
"uri": "sediment://file_123",
|
||||
"file_size_bytes": 11,
|
||||
}))
|
||||
);
|
||||
let codex_apps_meta = apps_tool_call
|
||||
.pointer("/params/_meta/_codex_apps")
|
||||
.expect("codex apps metadata should be forwarded");
|
||||
assert!(
|
||||
codex_apps_meta
|
||||
.pointer("/call_id")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|call_id| call_id.starts_with("exec-"))
|
||||
);
|
||||
assert_eq!(
|
||||
codex_apps_meta.pointer("/resource_uri"),
|
||||
Some(&json!(DOCUMENT_EXTRACT_TEXT_RESOURCE_URI))
|
||||
);
|
||||
assert_eq!(
|
||||
codex_apps_meta.pointer("/contains_mcp_source"),
|
||||
Some(&json!(true))
|
||||
);
|
||||
assert_eq!(
|
||||
codex_apps_meta.pointer("/connector_id"),
|
||||
Some(&json!("calendar"))
|
||||
);
|
||||
|
||||
server.verify().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user