mirror of
https://github.com/openai/codex.git
synced 2026-11-06 20:20:34 +00:00
Merge branch 'main' into aibrahim/collab-completion-metadata-fix
This commit is contained in:
Generated
+14
-1
@@ -1619,6 +1619,7 @@ version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"codex-connectors",
|
||||
"codex-core",
|
||||
"codex-git",
|
||||
"codex-utils-cargo-bin",
|
||||
@@ -1628,7 +1629,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"urlencoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1790,6 +1790,18 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-connectors"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"codex-app-server-protocol",
|
||||
"pretty_assertions",
|
||||
"serde",
|
||||
"tokio",
|
||||
"urlencoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-core"
|
||||
version = "0.0.0"
|
||||
@@ -1814,6 +1826,7 @@ dependencies = [
|
||||
"codex-async-utils",
|
||||
"codex-client",
|
||||
"codex-config",
|
||||
"codex-connectors",
|
||||
"codex-execpolicy",
|
||||
"codex-file-search",
|
||||
"codex-git",
|
||||
|
||||
@@ -16,6 +16,7 @@ members = [
|
||||
"cloud-tasks",
|
||||
"cloud-tasks-client",
|
||||
"cli",
|
||||
"connectors",
|
||||
"config",
|
||||
"shell-command",
|
||||
"shell-escalation",
|
||||
@@ -98,6 +99,7 @@ codex-chatgpt = { path = "chatgpt" }
|
||||
codex-cli = { path = "cli" }
|
||||
codex-client = { path = "codex-client" }
|
||||
codex-cloud-requirements = { path = "cloud-requirements" }
|
||||
codex-connectors = { path = "connectors" }
|
||||
codex-config = { path = "config" }
|
||||
codex-core = { path = "core" }
|
||||
codex-exec = { path = "exec" }
|
||||
|
||||
@@ -12815,18 +12815,12 @@
|
||||
"type": "array"
|
||||
},
|
||||
"authPolicy": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/PluginAuthPolicy"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
"$ref": "#/definitions/v2/PluginAuthPolicy"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"appsNeedingAuth"
|
||||
"appsNeedingAuth",
|
||||
"authPolicy"
|
||||
],
|
||||
"title": "PluginInstallResponse",
|
||||
"type": "object"
|
||||
@@ -13024,14 +13018,7 @@
|
||||
"PluginSummary": {
|
||||
"properties": {
|
||||
"authPolicy": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/PluginAuthPolicy"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
"$ref": "#/definitions/v2/PluginAuthPolicy"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
@@ -13040,14 +13027,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"installPolicy": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/PluginInstallPolicy"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
"$ref": "#/definitions/v2/PluginInstallPolicy"
|
||||
},
|
||||
"installed": {
|
||||
"type": "boolean"
|
||||
@@ -13070,8 +13050,10 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"authPolicy",
|
||||
"enabled",
|
||||
"id",
|
||||
"installPolicy",
|
||||
"installed",
|
||||
"name",
|
||||
"source"
|
||||
|
||||
@@ -9163,18 +9163,12 @@
|
||||
"type": "array"
|
||||
},
|
||||
"authPolicy": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/PluginAuthPolicy"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
"$ref": "#/definitions/PluginAuthPolicy"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"appsNeedingAuth"
|
||||
"appsNeedingAuth",
|
||||
"authPolicy"
|
||||
],
|
||||
"title": "PluginInstallResponse",
|
||||
"type": "object"
|
||||
@@ -9372,14 +9366,7 @@
|
||||
"PluginSummary": {
|
||||
"properties": {
|
||||
"authPolicy": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/PluginAuthPolicy"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
"$ref": "#/definitions/PluginAuthPolicy"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
@@ -9388,14 +9375,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"installPolicy": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/PluginInstallPolicy"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
"$ref": "#/definitions/PluginInstallPolicy"
|
||||
},
|
||||
"installed": {
|
||||
"type": "boolean"
|
||||
@@ -9418,8 +9398,10 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"authPolicy",
|
||||
"enabled",
|
||||
"id",
|
||||
"installPolicy",
|
||||
"installed",
|
||||
"name",
|
||||
"source"
|
||||
|
||||
@@ -45,18 +45,12 @@
|
||||
"type": "array"
|
||||
},
|
||||
"authPolicy": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/PluginAuthPolicy"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
"$ref": "#/definitions/PluginAuthPolicy"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"appsNeedingAuth"
|
||||
"appsNeedingAuth",
|
||||
"authPolicy"
|
||||
],
|
||||
"title": "PluginInstallResponse",
|
||||
"type": "object"
|
||||
|
||||
@@ -170,14 +170,7 @@
|
||||
"PluginSummary": {
|
||||
"properties": {
|
||||
"authPolicy": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/PluginAuthPolicy"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
"$ref": "#/definitions/PluginAuthPolicy"
|
||||
},
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
@@ -186,14 +179,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"installPolicy": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/PluginInstallPolicy"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
"$ref": "#/definitions/PluginInstallPolicy"
|
||||
},
|
||||
"installed": {
|
||||
"type": "boolean"
|
||||
@@ -216,8 +202,10 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"authPolicy",
|
||||
"enabled",
|
||||
"id",
|
||||
"installPolicy",
|
||||
"installed",
|
||||
"name",
|
||||
"source"
|
||||
|
||||
@@ -4,4 +4,4 @@
|
||||
import type { AppSummary } from "./AppSummary";
|
||||
import type { PluginAuthPolicy } from "./PluginAuthPolicy";
|
||||
|
||||
export type PluginInstallResponse = { authPolicy: PluginAuthPolicy | null, appsNeedingAuth: Array<AppSummary>, };
|
||||
export type PluginInstallResponse = { authPolicy: PluginAuthPolicy, appsNeedingAuth: Array<AppSummary>, };
|
||||
|
||||
@@ -6,4 +6,4 @@ import type { PluginInstallPolicy } from "./PluginInstallPolicy";
|
||||
import type { PluginInterface } from "./PluginInterface";
|
||||
import type { PluginSource } from "./PluginSource";
|
||||
|
||||
export type PluginSummary = { id: string, name: string, source: PluginSource, installed: boolean, enabled: boolean, installPolicy: PluginInstallPolicy | null, authPolicy: PluginAuthPolicy | null, interface: PluginInterface | null, };
|
||||
export type PluginSummary = { id: string, name: string, source: PluginSource, installed: boolean, enabled: boolean, installPolicy: PluginInstallPolicy, authPolicy: PluginAuthPolicy, interface: PluginInterface | null, };
|
||||
|
||||
@@ -3087,8 +3087,8 @@ pub struct PluginSummary {
|
||||
pub source: PluginSource,
|
||||
pub installed: bool,
|
||||
pub enabled: bool,
|
||||
pub install_policy: Option<PluginInstallPolicy>,
|
||||
pub auth_policy: Option<PluginAuthPolicy>,
|
||||
pub install_policy: PluginInstallPolicy,
|
||||
pub auth_policy: PluginAuthPolicy,
|
||||
pub interface: Option<PluginInterface>,
|
||||
}
|
||||
|
||||
@@ -3149,7 +3149,7 @@ pub struct PluginInstallParams {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct PluginInstallResponse {
|
||||
pub auth_policy: Option<PluginAuthPolicy>,
|
||||
pub auth_policy: PluginAuthPolicy,
|
||||
pub apps_needing_auth: Vec<AppSummary>,
|
||||
}
|
||||
|
||||
|
||||
@@ -157,13 +157,13 @@ Example with notification opt-out:
|
||||
- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`.
|
||||
- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly.
|
||||
- `skills/list` — list skills for one or more `cwd` values (optional `forceReload`).
|
||||
- `plugin/list` — list discovered plugin marketplaces and plugin state, including marketplace install/auth policy metadata. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category. Pass `forceRemoteSync: true` to refresh curated plugin state before listing (**under development; do not call from production clients yet**).
|
||||
- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category. Pass `forceRemoteSync: true` to refresh curated plugin state before listing (**under development; do not call from production clients yet**).
|
||||
- `skills/changed` — notification emitted when watched local skill files change.
|
||||
- `skills/remote/list` — list public remote skills (**under development; do not call from production clients yet**).
|
||||
- `skills/remote/export` — download a remote skill by `hazelnutId` into `skills` under `codex_home` (**under development; do not call from production clients yet**).
|
||||
- `app/list` — list available apps.
|
||||
- `skills/config/write` — write user-level skill config by path.
|
||||
- `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, and return the plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**).
|
||||
- `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, and return the effective plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**).
|
||||
- `plugin/uninstall` — uninstall a plugin by id by removing its cached files and clearing its user-level config entry (**under development; do not call from production clients yet**).
|
||||
- `mcpServer/oauth/login` — start an OAuth login for a configured MCP server; returns an `authorization_url` and later emits `mcpServer/oauthLogin/completed` once the browser flow finishes.
|
||||
- `tool/requestUserInput` — prompt the user with 1–3 short questions for a tool call and return their answers (experimental).
|
||||
|
||||
@@ -1723,7 +1723,7 @@ impl CodexMessageProcessor {
|
||||
let outgoing = self.outgoing.clone();
|
||||
let request_for_task = request.clone();
|
||||
let started_network_proxy_for_task = started_network_proxy;
|
||||
let use_linux_sandbox_bwrap = self.config.features.enabled(Feature::UseLinuxSandboxBwrap);
|
||||
let use_legacy_landlock = self.config.features.use_legacy_landlock();
|
||||
let size = match size.map(crate::command_exec::terminal_size_from_protocol) {
|
||||
Some(Ok(size)) => Some(size),
|
||||
Some(Err(error)) => {
|
||||
@@ -1740,7 +1740,7 @@ impl CodexMessageProcessor {
|
||||
effective_network_sandbox_policy,
|
||||
sandbox_cwd.as_path(),
|
||||
&codex_linux_sandbox_exe,
|
||||
use_linux_sandbox_bwrap,
|
||||
use_legacy_landlock,
|
||||
) {
|
||||
Ok(exec_request) => {
|
||||
if let Err(error) = self
|
||||
@@ -5465,8 +5465,8 @@ impl CodexMessageProcessor {
|
||||
PluginSource::Local { path }
|
||||
}
|
||||
},
|
||||
install_policy: plugin.install_policy.map(Into::into),
|
||||
auth_policy: plugin.auth_policy.map(Into::into),
|
||||
install_policy: plugin.install_policy.into(),
|
||||
auth_policy: plugin.auth_policy.into(),
|
||||
interface: plugin.interface.map(|interface| PluginInterface {
|
||||
display_name: interface.display_name,
|
||||
short_description: interface.short_description,
|
||||
@@ -5719,7 +5719,7 @@ impl CodexMessageProcessor {
|
||||
.send_response(
|
||||
request_id,
|
||||
PluginInstallResponse {
|
||||
auth_policy: result.auth_policy.map(Into::into),
|
||||
auth_policy: result.auth_policy.into(),
|
||||
apps_needing_auth,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -710,6 +710,12 @@ async fn command_exec_process_ids_are_connection_scoped_and_disconnect_terminate
|
||||
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(codex_home.path(), &server.uri(), "never")?;
|
||||
let marker = format!(
|
||||
"codex-command-exec-marker-{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)?
|
||||
.as_nanos()
|
||||
);
|
||||
|
||||
let (mut process, bind_addr) = spawn_websocket_server(codex_home.path()).await?;
|
||||
|
||||
@@ -726,7 +732,12 @@ async fn command_exec_process_ids_are_connection_scoped_and_disconnect_terminate
|
||||
"command/exec",
|
||||
101,
|
||||
Some(serde_json::json!({
|
||||
"command": ["sh", "-lc", "printf 'ready\\n%s\\n' $$; sleep 30"],
|
||||
"command": [
|
||||
"python3",
|
||||
"-c",
|
||||
"import time; print('ready', flush=True); time.sleep(30)",
|
||||
marker,
|
||||
],
|
||||
"processId": "shared-process",
|
||||
"streamStdoutStderr": true,
|
||||
})),
|
||||
@@ -737,12 +748,8 @@ async fn command_exec_process_ids_are_connection_scoped_and_disconnect_terminate
|
||||
assert_eq!(delta.process_id, "shared-process");
|
||||
assert_eq!(delta.stream, CommandExecOutputStream::Stdout);
|
||||
let delta_text = String::from_utf8(STANDARD.decode(&delta.delta_base64)?)?;
|
||||
let pid = delta_text
|
||||
.lines()
|
||||
.last()
|
||||
.context("delta should include shell pid")?
|
||||
.parse::<u32>()
|
||||
.context("parse shell pid")?;
|
||||
assert!(delta_text.contains("ready"));
|
||||
wait_for_process_marker(&marker, true).await?;
|
||||
|
||||
send_request(
|
||||
&mut ws2,
|
||||
@@ -766,12 +773,12 @@ async fn command_exec_process_ids_are_connection_scoped_and_disconnect_terminate
|
||||
terminate_error.error.message,
|
||||
"no active command/exec for process id \"shared-process\""
|
||||
);
|
||||
assert!(process_is_alive(pid)?);
|
||||
wait_for_process_marker(&marker, true).await?;
|
||||
|
||||
assert_no_message(&mut ws2, Duration::from_millis(250)).await?;
|
||||
ws1.close(None).await?;
|
||||
|
||||
wait_for_process_exit(pid).await?;
|
||||
wait_for_process_marker(&marker, false).await?;
|
||||
|
||||
process
|
||||
.kill()
|
||||
@@ -855,24 +862,25 @@ async fn read_initialize_response(
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_process_exit(pid: u32) -> Result<()> {
|
||||
async fn wait_for_process_marker(marker: &str, should_exist: bool) -> Result<()> {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
if !process_is_alive(pid)? {
|
||||
if process_with_marker_exists(marker)? == should_exist {
|
||||
return Ok(());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
anyhow::bail!("process {pid} was still alive after websocket disconnect");
|
||||
let expectation = if should_exist { "appear" } else { "exit" };
|
||||
anyhow::bail!("process marker {marker:?} did not {expectation} before timeout");
|
||||
}
|
||||
sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn process_is_alive(pid: u32) -> Result<bool> {
|
||||
let status = std::process::Command::new("kill")
|
||||
.arg("-0")
|
||||
.arg(pid.to_string())
|
||||
.status()
|
||||
.context("spawn kill -0")?;
|
||||
Ok(status.success())
|
||||
fn process_with_marker_exists(marker: &str) -> Result<bool> {
|
||||
let output = std::process::Command::new("ps")
|
||||
.args(["-axo", "command"])
|
||||
.output()
|
||||
.context("spawn ps -axo command")?;
|
||||
let stdout = String::from_utf8(output.stdout).context("decode ps output")?;
|
||||
Ok(stdout.lines().any(|line| line.contains(marker)))
|
||||
}
|
||||
|
||||
@@ -191,7 +191,7 @@ async fn plugin_install_returns_apps_needing_auth() -> Result<()> {
|
||||
"sample-plugin",
|
||||
"./sample-plugin",
|
||||
None,
|
||||
Some("ON_INSTALL"),
|
||||
None,
|
||||
)?;
|
||||
write_plugin_source(repo_root.path(), "sample-plugin", &["alpha", "beta"])?;
|
||||
let marketplace_path =
|
||||
@@ -217,7 +217,7 @@ async fn plugin_install_returns_apps_needing_auth() -> Result<()> {
|
||||
assert_eq!(
|
||||
response,
|
||||
PluginInstallResponse {
|
||||
auth_policy: Some(PluginAuthPolicy::OnInstall),
|
||||
auth_policy: PluginAuthPolicy::OnInstall,
|
||||
apps_needing_auth: vec![AppSummary {
|
||||
id: "alpha".to_string(),
|
||||
name: "Alpha".to_string(),
|
||||
@@ -299,7 +299,7 @@ async fn plugin_install_filters_disallowed_apps_needing_auth() -> Result<()> {
|
||||
assert_eq!(
|
||||
response,
|
||||
PluginInstallResponse {
|
||||
auth_policy: Some(PluginAuthPolicy::OnUse),
|
||||
auth_policy: PluginAuthPolicy::OnUse,
|
||||
apps_needing_auth: vec![AppSummary {
|
||||
id: "alpha".to_string(),
|
||||
name: "Alpha".to_string(),
|
||||
|
||||
@@ -26,6 +26,7 @@ use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567";
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_list_returns_invalid_request_for_invalid_marketplace_file() -> Result<()> {
|
||||
@@ -224,10 +225,26 @@ enabled = false
|
||||
assert_eq!(marketplace.plugins[0].name, "enabled-plugin");
|
||||
assert_eq!(marketplace.plugins[0].installed, true);
|
||||
assert_eq!(marketplace.plugins[0].enabled, true);
|
||||
assert_eq!(
|
||||
marketplace.plugins[0].install_policy,
|
||||
PluginInstallPolicy::Available
|
||||
);
|
||||
assert_eq!(
|
||||
marketplace.plugins[0].auth_policy,
|
||||
PluginAuthPolicy::OnInstall
|
||||
);
|
||||
assert_eq!(marketplace.plugins[1].id, "disabled-plugin@codex-curated");
|
||||
assert_eq!(marketplace.plugins[1].name, "disabled-plugin");
|
||||
assert_eq!(marketplace.plugins[1].installed, true);
|
||||
assert_eq!(marketplace.plugins[1].enabled, false);
|
||||
assert_eq!(
|
||||
marketplace.plugins[1].install_policy,
|
||||
PluginInstallPolicy::Available
|
||||
);
|
||||
assert_eq!(
|
||||
marketplace.plugins[1].auth_policy,
|
||||
PluginAuthPolicy::OnInstall
|
||||
);
|
||||
assert_eq!(
|
||||
marketplace.plugins[2].id,
|
||||
"uninstalled-plugin@codex-curated"
|
||||
@@ -235,6 +252,14 @@ enabled = false
|
||||
assert_eq!(marketplace.plugins[2].name, "uninstalled-plugin");
|
||||
assert_eq!(marketplace.plugins[2].installed, false);
|
||||
assert_eq!(marketplace.plugins[2].enabled, false);
|
||||
assert_eq!(
|
||||
marketplace.plugins[2].install_policy,
|
||||
PluginInstallPolicy::Available
|
||||
);
|
||||
assert_eq!(
|
||||
marketplace.plugins[2].auth_policy,
|
||||
PluginAuthPolicy::OnInstall
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -418,8 +443,8 @@ async fn plugin_list_returns_plugin_interface_with_absolute_asset_paths() -> Res
|
||||
assert_eq!(plugin.id, "demo-plugin@codex-curated");
|
||||
assert_eq!(plugin.installed, false);
|
||||
assert_eq!(plugin.enabled, false);
|
||||
assert_eq!(plugin.install_policy, Some(PluginInstallPolicy::Available));
|
||||
assert_eq!(plugin.auth_policy, Some(PluginAuthPolicy::OnInstall));
|
||||
assert_eq!(plugin.install_policy, PluginInstallPolicy::Available);
|
||||
assert_eq!(plugin.auth_policy, PluginAuthPolicy::OnInstall);
|
||||
let interface = plugin
|
||||
.interface
|
||||
.as_ref()
|
||||
@@ -589,7 +614,9 @@ async fn plugin_list_force_remote_sync_reconciles_curated_plugin_state() -> Resu
|
||||
assert!(
|
||||
codex_home
|
||||
.path()
|
||||
.join("plugins/cache/openai-curated/gmail/local")
|
||||
.join(format!(
|
||||
"plugins/cache/openai-curated/gmail/{TEST_CURATED_PLUGIN_SHA}"
|
||||
))
|
||||
.is_dir()
|
||||
);
|
||||
assert!(
|
||||
@@ -682,5 +709,10 @@ fn write_openai_curated_marketplace(
|
||||
format!(r#"{{"name":"{plugin_name}"}}"#),
|
||||
)?;
|
||||
}
|
||||
std::fs::create_dir_all(codex_home.join(".tmp"))?;
|
||||
std::fs::write(
|
||||
codex_home.join(".tmp/plugins.sha"),
|
||||
format!("{TEST_CURATED_PLUGIN_SHA}\n"),
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ workspace = true
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
clap = { workspace = true, features = ["derive"] }
|
||||
codex-connectors = { workspace = true }
|
||||
codex-core = { workspace = true }
|
||||
codex-utils-cli = { workspace = true }
|
||||
codex-utils-cargo-bin = { workspace = true }
|
||||
@@ -17,7 +18,6 @@ serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
codex-git = { workspace = true }
|
||||
urlencoding = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
|
||||
@@ -1,25 +1,18 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::LazyLock;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
|
||||
use codex_core::AuthManager;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::token_data::TokenData;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashSet;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::chatgpt_client::chatgpt_get_request_with_timeout;
|
||||
use crate::chatgpt_token::get_chatgpt_token_data;
|
||||
use crate::chatgpt_token::init_chatgpt_token_from_auth;
|
||||
|
||||
use codex_core::connectors::AppBranding;
|
||||
use codex_connectors::AllConnectorsCacheKey;
|
||||
use codex_connectors::DirectoryListResponse;
|
||||
|
||||
pub use codex_core::connectors::AppInfo;
|
||||
use codex_core::connectors::AppMetadata;
|
||||
use codex_core::connectors::CONNECTORS_CACHE_TTL;
|
||||
pub use codex_core::connectors::connector_display_label;
|
||||
use codex_core::connectors::connector_install_url;
|
||||
use codex_core::connectors::filter_disallowed_connectors;
|
||||
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools;
|
||||
pub use codex_core::connectors::list_accessible_connectors_from_mcp_tools_with_options;
|
||||
@@ -30,51 +23,8 @@ use codex_core::connectors::merge_plugin_apps;
|
||||
pub use codex_core::connectors::with_app_enabled_state;
|
||||
use codex_core::plugins::PluginsManager;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DirectoryListResponse {
|
||||
apps: Vec<DirectoryApp>,
|
||||
#[serde(alias = "nextToken")]
|
||||
next_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
struct DirectoryApp {
|
||||
id: String,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
#[serde(alias = "appMetadata")]
|
||||
app_metadata: Option<AppMetadata>,
|
||||
branding: Option<AppBranding>,
|
||||
labels: Option<HashMap<String, String>>,
|
||||
#[serde(alias = "logoUrl")]
|
||||
logo_url: Option<String>,
|
||||
#[serde(alias = "logoUrlDark")]
|
||||
logo_url_dark: Option<String>,
|
||||
#[serde(alias = "distributionChannel")]
|
||||
distribution_channel: Option<String>,
|
||||
visibility: Option<String>,
|
||||
}
|
||||
|
||||
const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
struct AllConnectorsCacheKey {
|
||||
chatgpt_base_url: String,
|
||||
account_id: Option<String>,
|
||||
chatgpt_user_id: Option<String>,
|
||||
is_workspace_account: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CachedAllConnectors {
|
||||
key: AllConnectorsCacheKey,
|
||||
expires_at: Instant,
|
||||
connectors: Vec<AppInfo>,
|
||||
}
|
||||
|
||||
static ALL_CONNECTORS_CACHE: LazyLock<StdMutex<Option<CachedAllConnectors>>> =
|
||||
LazyLock::new(|| StdMutex::new(None));
|
||||
|
||||
async fn apps_enabled(config: &Config) -> bool {
|
||||
let auth_manager = AuthManager::shared(
|
||||
config.codex_home.clone(),
|
||||
@@ -83,7 +33,6 @@ async fn apps_enabled(config: &Config) -> bool {
|
||||
);
|
||||
config.features.apps_enabled(Some(&auth_manager)).await
|
||||
}
|
||||
|
||||
pub async fn list_connectors(config: &Config) -> anyhow::Result<Vec<AppInfo>> {
|
||||
if !apps_enabled(config).await {
|
||||
return Ok(Vec::new());
|
||||
@@ -117,7 +66,7 @@ pub async fn list_cached_all_connectors(config: &Config) -> Option<Vec<AppInfo>>
|
||||
}
|
||||
let token_data = get_chatgpt_token_data()?;
|
||||
let cache_key = all_connectors_cache_key(config, &token_data);
|
||||
read_cached_all_connectors(&cache_key).map(|connectors| {
|
||||
codex_connectors::cached_all_connectors(&cache_key).map(|connectors| {
|
||||
let connectors = merge_plugin_apps(connectors, plugin_apps_for_config(config));
|
||||
filter_disallowed_connectors(connectors)
|
||||
})
|
||||
@@ -136,76 +85,31 @@ pub async fn list_all_connectors_with_options(
|
||||
let token_data =
|
||||
get_chatgpt_token_data().ok_or_else(|| anyhow::anyhow!("ChatGPT token not available"))?;
|
||||
let cache_key = all_connectors_cache_key(config, &token_data);
|
||||
if !force_refetch && let Some(cached_connectors) = read_cached_all_connectors(&cache_key) {
|
||||
let connectors = merge_plugin_apps(cached_connectors, plugin_apps_for_config(config));
|
||||
return Ok(filter_disallowed_connectors(connectors));
|
||||
}
|
||||
|
||||
let mut apps = list_directory_connectors(config).await?;
|
||||
if token_data.id_token.is_workspace_account() {
|
||||
apps.extend(list_workspace_connectors(config).await?);
|
||||
}
|
||||
let mut connectors = merge_directory_apps(apps)
|
||||
.into_iter()
|
||||
.map(directory_app_to_app_info)
|
||||
.collect::<Vec<_>>();
|
||||
for connector in &mut connectors {
|
||||
let install_url = match connector.install_url.take() {
|
||||
Some(install_url) => install_url,
|
||||
None => connector_install_url(&connector.name, &connector.id),
|
||||
};
|
||||
connector.name = normalize_connector_name(&connector.name, &connector.id);
|
||||
connector.description = normalize_connector_value(connector.description.as_deref());
|
||||
connector.install_url = Some(install_url);
|
||||
connector.is_accessible = false;
|
||||
}
|
||||
connectors.sort_by(|left, right| {
|
||||
left.name
|
||||
.cmp(&right.name)
|
||||
.then_with(|| left.id.cmp(&right.id))
|
||||
});
|
||||
let connectors = filter_disallowed_connectors(connectors);
|
||||
write_cached_all_connectors(cache_key, &connectors);
|
||||
let connectors = codex_connectors::list_all_connectors_with_options(
|
||||
cache_key,
|
||||
token_data.id_token.is_workspace_account(),
|
||||
force_refetch,
|
||||
|path| async move {
|
||||
chatgpt_get_request_with_timeout::<DirectoryListResponse>(
|
||||
config,
|
||||
path,
|
||||
Some(DIRECTORY_CONNECTORS_TIMEOUT),
|
||||
)
|
||||
.await
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
let connectors = merge_plugin_apps(connectors, plugin_apps_for_config(config));
|
||||
Ok(filter_disallowed_connectors(connectors))
|
||||
}
|
||||
|
||||
fn all_connectors_cache_key(config: &Config, token_data: &TokenData) -> AllConnectorsCacheKey {
|
||||
AllConnectorsCacheKey {
|
||||
chatgpt_base_url: config.chatgpt_base_url.clone(),
|
||||
account_id: token_data.account_id.clone(),
|
||||
chatgpt_user_id: token_data.id_token.chatgpt_user_id.clone(),
|
||||
is_workspace_account: token_data.id_token.is_workspace_account(),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_cached_all_connectors(cache_key: &AllConnectorsCacheKey) -> Option<Vec<AppInfo>> {
|
||||
let mut cache_guard = ALL_CONNECTORS_CACHE
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let now = Instant::now();
|
||||
|
||||
if let Some(cached) = cache_guard.as_ref() {
|
||||
if now < cached.expires_at && cached.key == *cache_key {
|
||||
return Some(cached.connectors.clone());
|
||||
}
|
||||
if now >= cached.expires_at {
|
||||
*cache_guard = None;
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn write_cached_all_connectors(cache_key: AllConnectorsCacheKey, connectors: &[AppInfo]) {
|
||||
let mut cache_guard = ALL_CONNECTORS_CACHE
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
*cache_guard = Some(CachedAllConnectors {
|
||||
key: cache_key,
|
||||
expires_at: Instant::now() + CONNECTORS_CACHE_TTL,
|
||||
connectors: connectors.to_vec(),
|
||||
});
|
||||
AllConnectorsCacheKey::new(
|
||||
config.chatgpt_base_url.clone(),
|
||||
token_data.account_id.clone(),
|
||||
token_data.id_token.chatgpt_user_id.clone(),
|
||||
token_data.id_token.is_workspace_account(),
|
||||
)
|
||||
}
|
||||
|
||||
fn plugin_apps_for_config(config: &Config) -> Vec<codex_core::plugins::AppConnectorId> {
|
||||
@@ -235,248 +139,10 @@ pub fn merge_connectors_with_accessible(
|
||||
filter_disallowed_connectors(merged)
|
||||
}
|
||||
|
||||
async fn list_directory_connectors(config: &Config) -> anyhow::Result<Vec<DirectoryApp>> {
|
||||
let mut apps = Vec::new();
|
||||
let mut next_token: Option<String> = None;
|
||||
loop {
|
||||
let path = match next_token.as_deref() {
|
||||
Some(token) => {
|
||||
let encoded_token = urlencoding::encode(token);
|
||||
format!(
|
||||
"/connectors/directory/list?tier=categorized&token={encoded_token}&external_logos=true"
|
||||
)
|
||||
}
|
||||
None => "/connectors/directory/list?tier=categorized&external_logos=true".to_string(),
|
||||
};
|
||||
let response: DirectoryListResponse =
|
||||
chatgpt_get_request_with_timeout(config, path, Some(DIRECTORY_CONNECTORS_TIMEOUT))
|
||||
.await?;
|
||||
apps.extend(
|
||||
response
|
||||
.apps
|
||||
.into_iter()
|
||||
.filter(|app| !is_hidden_directory_app(app)),
|
||||
);
|
||||
next_token = response
|
||||
.next_token
|
||||
.map(|token| token.trim().to_string())
|
||||
.filter(|token| !token.is_empty());
|
||||
if next_token.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(apps)
|
||||
}
|
||||
|
||||
async fn list_workspace_connectors(config: &Config) -> anyhow::Result<Vec<DirectoryApp>> {
|
||||
let response: anyhow::Result<DirectoryListResponse> = chatgpt_get_request_with_timeout(
|
||||
config,
|
||||
"/connectors/directory/list_workspace?external_logos=true".to_string(),
|
||||
Some(DIRECTORY_CONNECTORS_TIMEOUT),
|
||||
)
|
||||
.await;
|
||||
match response {
|
||||
Ok(response) => Ok(response
|
||||
.apps
|
||||
.into_iter()
|
||||
.filter(|app| !is_hidden_directory_app(app))
|
||||
.collect()),
|
||||
Err(_) => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_directory_apps(apps: Vec<DirectoryApp>) -> Vec<DirectoryApp> {
|
||||
let mut merged: HashMap<String, DirectoryApp> = HashMap::new();
|
||||
for app in apps {
|
||||
if let Some(existing) = merged.get_mut(&app.id) {
|
||||
merge_directory_app(existing, app);
|
||||
} else {
|
||||
merged.insert(app.id.clone(), app);
|
||||
}
|
||||
}
|
||||
merged.into_values().collect()
|
||||
}
|
||||
|
||||
fn merge_directory_app(existing: &mut DirectoryApp, incoming: DirectoryApp) {
|
||||
let DirectoryApp {
|
||||
id: _,
|
||||
name,
|
||||
description,
|
||||
app_metadata,
|
||||
branding,
|
||||
labels,
|
||||
logo_url,
|
||||
logo_url_dark,
|
||||
distribution_channel,
|
||||
visibility: _,
|
||||
} = incoming;
|
||||
|
||||
let incoming_name_is_empty = name.trim().is_empty();
|
||||
if existing.name.trim().is_empty() && !incoming_name_is_empty {
|
||||
existing.name = name;
|
||||
}
|
||||
|
||||
let incoming_description_present = description
|
||||
.as_deref()
|
||||
.map(|value| !value.trim().is_empty())
|
||||
.unwrap_or(false);
|
||||
if incoming_description_present {
|
||||
existing.description = description;
|
||||
}
|
||||
|
||||
if existing.logo_url.is_none() && logo_url.is_some() {
|
||||
existing.logo_url = logo_url;
|
||||
}
|
||||
if existing.logo_url_dark.is_none() && logo_url_dark.is_some() {
|
||||
existing.logo_url_dark = logo_url_dark;
|
||||
}
|
||||
if existing.distribution_channel.is_none() && distribution_channel.is_some() {
|
||||
existing.distribution_channel = distribution_channel;
|
||||
}
|
||||
|
||||
if let Some(incoming_branding) = branding {
|
||||
if let Some(existing_branding) = existing.branding.as_mut() {
|
||||
if existing_branding.category.is_none() && incoming_branding.category.is_some() {
|
||||
existing_branding.category = incoming_branding.category;
|
||||
}
|
||||
if existing_branding.developer.is_none() && incoming_branding.developer.is_some() {
|
||||
existing_branding.developer = incoming_branding.developer;
|
||||
}
|
||||
if existing_branding.website.is_none() && incoming_branding.website.is_some() {
|
||||
existing_branding.website = incoming_branding.website;
|
||||
}
|
||||
if existing_branding.privacy_policy.is_none()
|
||||
&& incoming_branding.privacy_policy.is_some()
|
||||
{
|
||||
existing_branding.privacy_policy = incoming_branding.privacy_policy;
|
||||
}
|
||||
if existing_branding.terms_of_service.is_none()
|
||||
&& incoming_branding.terms_of_service.is_some()
|
||||
{
|
||||
existing_branding.terms_of_service = incoming_branding.terms_of_service;
|
||||
}
|
||||
if !existing_branding.is_discoverable_app && incoming_branding.is_discoverable_app {
|
||||
existing_branding.is_discoverable_app = true;
|
||||
}
|
||||
} else {
|
||||
existing.branding = Some(incoming_branding);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(incoming_app_metadata) = app_metadata {
|
||||
if let Some(existing_app_metadata) = existing.app_metadata.as_mut() {
|
||||
if existing_app_metadata.review.is_none() && incoming_app_metadata.review.is_some() {
|
||||
existing_app_metadata.review = incoming_app_metadata.review;
|
||||
}
|
||||
if existing_app_metadata.categories.is_none()
|
||||
&& incoming_app_metadata.categories.is_some()
|
||||
{
|
||||
existing_app_metadata.categories = incoming_app_metadata.categories;
|
||||
}
|
||||
if existing_app_metadata.sub_categories.is_none()
|
||||
&& incoming_app_metadata.sub_categories.is_some()
|
||||
{
|
||||
existing_app_metadata.sub_categories = incoming_app_metadata.sub_categories;
|
||||
}
|
||||
if existing_app_metadata.seo_description.is_none()
|
||||
&& incoming_app_metadata.seo_description.is_some()
|
||||
{
|
||||
existing_app_metadata.seo_description = incoming_app_metadata.seo_description;
|
||||
}
|
||||
if existing_app_metadata.screenshots.is_none()
|
||||
&& incoming_app_metadata.screenshots.is_some()
|
||||
{
|
||||
existing_app_metadata.screenshots = incoming_app_metadata.screenshots;
|
||||
}
|
||||
if existing_app_metadata.developer.is_none()
|
||||
&& incoming_app_metadata.developer.is_some()
|
||||
{
|
||||
existing_app_metadata.developer = incoming_app_metadata.developer;
|
||||
}
|
||||
if existing_app_metadata.version.is_none() && incoming_app_metadata.version.is_some() {
|
||||
existing_app_metadata.version = incoming_app_metadata.version;
|
||||
}
|
||||
if existing_app_metadata.version_id.is_none()
|
||||
&& incoming_app_metadata.version_id.is_some()
|
||||
{
|
||||
existing_app_metadata.version_id = incoming_app_metadata.version_id;
|
||||
}
|
||||
if existing_app_metadata.version_notes.is_none()
|
||||
&& incoming_app_metadata.version_notes.is_some()
|
||||
{
|
||||
existing_app_metadata.version_notes = incoming_app_metadata.version_notes;
|
||||
}
|
||||
if existing_app_metadata.first_party_type.is_none()
|
||||
&& incoming_app_metadata.first_party_type.is_some()
|
||||
{
|
||||
existing_app_metadata.first_party_type = incoming_app_metadata.first_party_type;
|
||||
}
|
||||
if existing_app_metadata.first_party_requires_install.is_none()
|
||||
&& incoming_app_metadata.first_party_requires_install.is_some()
|
||||
{
|
||||
existing_app_metadata.first_party_requires_install =
|
||||
incoming_app_metadata.first_party_requires_install;
|
||||
}
|
||||
if existing_app_metadata
|
||||
.show_in_composer_when_unlinked
|
||||
.is_none()
|
||||
&& incoming_app_metadata
|
||||
.show_in_composer_when_unlinked
|
||||
.is_some()
|
||||
{
|
||||
existing_app_metadata.show_in_composer_when_unlinked =
|
||||
incoming_app_metadata.show_in_composer_when_unlinked;
|
||||
}
|
||||
} else {
|
||||
existing.app_metadata = Some(incoming_app_metadata);
|
||||
}
|
||||
}
|
||||
|
||||
if existing.labels.is_none() && labels.is_some() {
|
||||
existing.labels = labels;
|
||||
}
|
||||
}
|
||||
|
||||
fn is_hidden_directory_app(app: &DirectoryApp) -> bool {
|
||||
matches!(app.visibility.as_deref(), Some("HIDDEN"))
|
||||
}
|
||||
|
||||
fn directory_app_to_app_info(app: DirectoryApp) -> AppInfo {
|
||||
AppInfo {
|
||||
id: app.id,
|
||||
name: app.name,
|
||||
description: app.description,
|
||||
logo_url: app.logo_url,
|
||||
logo_url_dark: app.logo_url_dark,
|
||||
distribution_channel: app.distribution_channel,
|
||||
branding: app.branding,
|
||||
app_metadata: app.app_metadata,
|
||||
labels: app.labels,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_connector_name(name: &str, connector_id: &str) -> String {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
connector_id.to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_connector_value(value: Option<&str>) -> Option<String> {
|
||||
value
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_core::connectors::connector_install_url;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn app(id: &str) -> AppInfo {
|
||||
|
||||
@@ -250,19 +250,18 @@ async fn run_command_under_sandbox(
|
||||
.await?
|
||||
}
|
||||
SandboxType::Landlock => {
|
||||
use codex_core::features::Feature;
|
||||
#[expect(clippy::expect_used)]
|
||||
let codex_linux_sandbox_exe = config
|
||||
.codex_linux_sandbox_exe
|
||||
.expect("codex-linux-sandbox executable not found");
|
||||
let use_bwrap_sandbox = config.features.enabled(Feature::UseLinuxSandboxBwrap);
|
||||
let use_legacy_landlock = config.features.use_legacy_landlock();
|
||||
spawn_command_under_linux_sandbox(
|
||||
codex_linux_sandbox_exe,
|
||||
command,
|
||||
cwd,
|
||||
config.permissions.sandbox_policy.get(),
|
||||
sandbox_policy_cwd.as_path(),
|
||||
use_bwrap_sandbox,
|
||||
use_legacy_landlock,
|
||||
stdio_policy,
|
||||
network.as_ref(),
|
||||
env,
|
||||
|
||||
@@ -32,7 +32,7 @@ pub struct LandlockCommand {
|
||||
#[clap(skip)]
|
||||
pub config_overrides: CliConfigOverrides,
|
||||
|
||||
/// Full command args to run under landlock.
|
||||
/// Full command args to run under the Linux sandbox.
|
||||
#[arg(trailing_var_arg = true)]
|
||||
pub command: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -239,7 +239,7 @@ enum SandboxCommand {
|
||||
#[clap(visible_alias = "seatbelt")]
|
||||
Macos(SeatbeltCommand),
|
||||
|
||||
/// Run a command under Landlock+seccomp (Linux only).
|
||||
/// Run a command under the Linux sandbox (bubblewrap by default).
|
||||
#[clap(visible_alias = "landlock")]
|
||||
Linux(LandlockCommand),
|
||||
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
load("//:defs.bzl", "codex_rust_crate")
|
||||
|
||||
codex_rust_crate(
|
||||
name = "connectors",
|
||||
crate_name = "codex_connectors",
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "codex-connectors"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
codex-app-server-protocol = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
urlencoding = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
|
||||
@@ -0,0 +1,534 @@
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::sync::LazyLock;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use codex_app_server_protocol::AppBranding;
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use codex_app_server_protocol::AppMetadata;
|
||||
use serde::Deserialize;
|
||||
|
||||
pub const CONNECTORS_CACHE_TTL: Duration = Duration::from_secs(3600);
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AllConnectorsCacheKey {
|
||||
chatgpt_base_url: String,
|
||||
account_id: Option<String>,
|
||||
chatgpt_user_id: Option<String>,
|
||||
is_workspace_account: bool,
|
||||
}
|
||||
|
||||
impl AllConnectorsCacheKey {
|
||||
pub fn new(
|
||||
chatgpt_base_url: String,
|
||||
account_id: Option<String>,
|
||||
chatgpt_user_id: Option<String>,
|
||||
is_workspace_account: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
chatgpt_base_url,
|
||||
account_id,
|
||||
chatgpt_user_id,
|
||||
is_workspace_account,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CachedAllConnectors {
|
||||
key: AllConnectorsCacheKey,
|
||||
expires_at: Instant,
|
||||
connectors: Vec<AppInfo>,
|
||||
}
|
||||
|
||||
static ALL_CONNECTORS_CACHE: LazyLock<StdMutex<Option<CachedAllConnectors>>> =
|
||||
LazyLock::new(|| StdMutex::new(None));
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DirectoryListResponse {
|
||||
apps: Vec<DirectoryApp>,
|
||||
#[serde(alias = "nextToken")]
|
||||
next_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct DirectoryApp {
|
||||
id: String,
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
#[serde(alias = "appMetadata")]
|
||||
app_metadata: Option<AppMetadata>,
|
||||
branding: Option<AppBranding>,
|
||||
labels: Option<HashMap<String, String>>,
|
||||
#[serde(alias = "logoUrl")]
|
||||
logo_url: Option<String>,
|
||||
#[serde(alias = "logoUrlDark")]
|
||||
logo_url_dark: Option<String>,
|
||||
#[serde(alias = "distributionChannel")]
|
||||
distribution_channel: Option<String>,
|
||||
visibility: Option<String>,
|
||||
}
|
||||
|
||||
pub fn cached_all_connectors(cache_key: &AllConnectorsCacheKey) -> Option<Vec<AppInfo>> {
|
||||
let mut cache_guard = ALL_CONNECTORS_CACHE
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let now = Instant::now();
|
||||
|
||||
if let Some(cached) = cache_guard.as_ref() {
|
||||
if now < cached.expires_at && cached.key == *cache_key {
|
||||
return Some(cached.connectors.clone());
|
||||
}
|
||||
if now >= cached.expires_at {
|
||||
*cache_guard = None;
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn list_all_connectors_with_options<F, Fut>(
|
||||
cache_key: AllConnectorsCacheKey,
|
||||
is_workspace_account: bool,
|
||||
force_refetch: bool,
|
||||
mut fetch_page: F,
|
||||
) -> anyhow::Result<Vec<AppInfo>>
|
||||
where
|
||||
F: FnMut(String) -> Fut,
|
||||
Fut: Future<Output = anyhow::Result<DirectoryListResponse>>,
|
||||
{
|
||||
if !force_refetch && let Some(cached_connectors) = cached_all_connectors(&cache_key) {
|
||||
return Ok(cached_connectors);
|
||||
}
|
||||
|
||||
let mut apps = list_directory_connectors(&mut fetch_page).await?;
|
||||
if is_workspace_account {
|
||||
apps.extend(list_workspace_connectors(&mut fetch_page).await?);
|
||||
}
|
||||
|
||||
let mut connectors = merge_directory_apps(apps)
|
||||
.into_iter()
|
||||
.map(directory_app_to_app_info)
|
||||
.collect::<Vec<_>>();
|
||||
for connector in &mut connectors {
|
||||
let install_url = match connector.install_url.take() {
|
||||
Some(install_url) => install_url,
|
||||
None => connector_install_url(&connector.name, &connector.id),
|
||||
};
|
||||
connector.name = normalize_connector_name(&connector.name, &connector.id);
|
||||
connector.description = normalize_connector_value(connector.description.as_deref());
|
||||
connector.install_url = Some(install_url);
|
||||
connector.is_accessible = false;
|
||||
}
|
||||
connectors.sort_by(|left, right| {
|
||||
left.name
|
||||
.cmp(&right.name)
|
||||
.then_with(|| left.id.cmp(&right.id))
|
||||
});
|
||||
write_cached_all_connectors(cache_key, &connectors);
|
||||
Ok(connectors)
|
||||
}
|
||||
|
||||
fn write_cached_all_connectors(cache_key: AllConnectorsCacheKey, connectors: &[AppInfo]) {
|
||||
let mut cache_guard = ALL_CONNECTORS_CACHE
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
*cache_guard = Some(CachedAllConnectors {
|
||||
key: cache_key,
|
||||
expires_at: Instant::now() + CONNECTORS_CACHE_TTL,
|
||||
connectors: connectors.to_vec(),
|
||||
});
|
||||
}
|
||||
|
||||
async fn list_directory_connectors<F, Fut>(fetch_page: &mut F) -> anyhow::Result<Vec<DirectoryApp>>
|
||||
where
|
||||
F: FnMut(String) -> Fut,
|
||||
Fut: Future<Output = anyhow::Result<DirectoryListResponse>>,
|
||||
{
|
||||
let mut apps = Vec::new();
|
||||
let mut next_token: Option<String> = None;
|
||||
loop {
|
||||
let path = match next_token.as_deref() {
|
||||
Some(token) => {
|
||||
let encoded_token = urlencoding::encode(token);
|
||||
format!(
|
||||
"/connectors/directory/list?tier=categorized&token={encoded_token}&external_logos=true"
|
||||
)
|
||||
}
|
||||
None => "/connectors/directory/list?tier=categorized&external_logos=true".to_string(),
|
||||
};
|
||||
let response = fetch_page(path).await?;
|
||||
apps.extend(
|
||||
response
|
||||
.apps
|
||||
.into_iter()
|
||||
.filter(|app| !is_hidden_directory_app(app)),
|
||||
);
|
||||
next_token = response
|
||||
.next_token
|
||||
.map(|token| token.trim().to_string())
|
||||
.filter(|token| !token.is_empty());
|
||||
if next_token.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(apps)
|
||||
}
|
||||
|
||||
async fn list_workspace_connectors<F, Fut>(fetch_page: &mut F) -> anyhow::Result<Vec<DirectoryApp>>
|
||||
where
|
||||
F: FnMut(String) -> Fut,
|
||||
Fut: Future<Output = anyhow::Result<DirectoryListResponse>>,
|
||||
{
|
||||
let response =
|
||||
fetch_page("/connectors/directory/list_workspace?external_logos=true".to_string()).await;
|
||||
match response {
|
||||
Ok(response) => Ok(response
|
||||
.apps
|
||||
.into_iter()
|
||||
.filter(|app| !is_hidden_directory_app(app))
|
||||
.collect()),
|
||||
Err(_) => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_directory_apps(apps: Vec<DirectoryApp>) -> Vec<DirectoryApp> {
|
||||
let mut merged: HashMap<String, DirectoryApp> = HashMap::new();
|
||||
for app in apps {
|
||||
if let Some(existing) = merged.get_mut(&app.id) {
|
||||
merge_directory_app(existing, app);
|
||||
} else {
|
||||
merged.insert(app.id.clone(), app);
|
||||
}
|
||||
}
|
||||
merged.into_values().collect()
|
||||
}
|
||||
|
||||
fn merge_directory_app(existing: &mut DirectoryApp, incoming: DirectoryApp) {
|
||||
let DirectoryApp {
|
||||
id: _,
|
||||
name,
|
||||
description,
|
||||
app_metadata,
|
||||
branding,
|
||||
labels,
|
||||
logo_url,
|
||||
logo_url_dark,
|
||||
distribution_channel,
|
||||
visibility: _,
|
||||
} = incoming;
|
||||
|
||||
let incoming_name_is_empty = name.trim().is_empty();
|
||||
if existing.name.trim().is_empty() && !incoming_name_is_empty {
|
||||
existing.name = name;
|
||||
}
|
||||
|
||||
let incoming_description_present = description
|
||||
.as_deref()
|
||||
.map(|value| !value.trim().is_empty())
|
||||
.unwrap_or(false);
|
||||
if incoming_description_present {
|
||||
existing.description = description;
|
||||
}
|
||||
|
||||
if existing.logo_url.is_none() && logo_url.is_some() {
|
||||
existing.logo_url = logo_url;
|
||||
}
|
||||
if existing.logo_url_dark.is_none() && logo_url_dark.is_some() {
|
||||
existing.logo_url_dark = logo_url_dark;
|
||||
}
|
||||
if existing.distribution_channel.is_none() && distribution_channel.is_some() {
|
||||
existing.distribution_channel = distribution_channel;
|
||||
}
|
||||
|
||||
if let Some(incoming_branding) = branding {
|
||||
if let Some(existing_branding) = existing.branding.as_mut() {
|
||||
if existing_branding.category.is_none() && incoming_branding.category.is_some() {
|
||||
existing_branding.category = incoming_branding.category;
|
||||
}
|
||||
if existing_branding.developer.is_none() && incoming_branding.developer.is_some() {
|
||||
existing_branding.developer = incoming_branding.developer;
|
||||
}
|
||||
if existing_branding.website.is_none() && incoming_branding.website.is_some() {
|
||||
existing_branding.website = incoming_branding.website;
|
||||
}
|
||||
if existing_branding.privacy_policy.is_none()
|
||||
&& incoming_branding.privacy_policy.is_some()
|
||||
{
|
||||
existing_branding.privacy_policy = incoming_branding.privacy_policy;
|
||||
}
|
||||
if existing_branding.terms_of_service.is_none()
|
||||
&& incoming_branding.terms_of_service.is_some()
|
||||
{
|
||||
existing_branding.terms_of_service = incoming_branding.terms_of_service;
|
||||
}
|
||||
if !existing_branding.is_discoverable_app && incoming_branding.is_discoverable_app {
|
||||
existing_branding.is_discoverable_app = true;
|
||||
}
|
||||
} else {
|
||||
existing.branding = Some(incoming_branding);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(incoming_app_metadata) = app_metadata {
|
||||
if let Some(existing_app_metadata) = existing.app_metadata.as_mut() {
|
||||
if existing_app_metadata.review.is_none() && incoming_app_metadata.review.is_some() {
|
||||
existing_app_metadata.review = incoming_app_metadata.review;
|
||||
}
|
||||
if existing_app_metadata.categories.is_none()
|
||||
&& incoming_app_metadata.categories.is_some()
|
||||
{
|
||||
existing_app_metadata.categories = incoming_app_metadata.categories;
|
||||
}
|
||||
if existing_app_metadata.sub_categories.is_none()
|
||||
&& incoming_app_metadata.sub_categories.is_some()
|
||||
{
|
||||
existing_app_metadata.sub_categories = incoming_app_metadata.sub_categories;
|
||||
}
|
||||
if existing_app_metadata.seo_description.is_none()
|
||||
&& incoming_app_metadata.seo_description.is_some()
|
||||
{
|
||||
existing_app_metadata.seo_description = incoming_app_metadata.seo_description;
|
||||
}
|
||||
if existing_app_metadata.screenshots.is_none()
|
||||
&& incoming_app_metadata.screenshots.is_some()
|
||||
{
|
||||
existing_app_metadata.screenshots = incoming_app_metadata.screenshots;
|
||||
}
|
||||
if existing_app_metadata.developer.is_none()
|
||||
&& incoming_app_metadata.developer.is_some()
|
||||
{
|
||||
existing_app_metadata.developer = incoming_app_metadata.developer;
|
||||
}
|
||||
if existing_app_metadata.version.is_none() && incoming_app_metadata.version.is_some() {
|
||||
existing_app_metadata.version = incoming_app_metadata.version;
|
||||
}
|
||||
if existing_app_metadata.version_id.is_none()
|
||||
&& incoming_app_metadata.version_id.is_some()
|
||||
{
|
||||
existing_app_metadata.version_id = incoming_app_metadata.version_id;
|
||||
}
|
||||
if existing_app_metadata.version_notes.is_none()
|
||||
&& incoming_app_metadata.version_notes.is_some()
|
||||
{
|
||||
existing_app_metadata.version_notes = incoming_app_metadata.version_notes;
|
||||
}
|
||||
if existing_app_metadata.first_party_type.is_none()
|
||||
&& incoming_app_metadata.first_party_type.is_some()
|
||||
{
|
||||
existing_app_metadata.first_party_type = incoming_app_metadata.first_party_type;
|
||||
}
|
||||
if existing_app_metadata.first_party_requires_install.is_none()
|
||||
&& incoming_app_metadata.first_party_requires_install.is_some()
|
||||
{
|
||||
existing_app_metadata.first_party_requires_install =
|
||||
incoming_app_metadata.first_party_requires_install;
|
||||
}
|
||||
if existing_app_metadata
|
||||
.show_in_composer_when_unlinked
|
||||
.is_none()
|
||||
&& incoming_app_metadata
|
||||
.show_in_composer_when_unlinked
|
||||
.is_some()
|
||||
{
|
||||
existing_app_metadata.show_in_composer_when_unlinked =
|
||||
incoming_app_metadata.show_in_composer_when_unlinked;
|
||||
}
|
||||
} else {
|
||||
existing.app_metadata = Some(incoming_app_metadata);
|
||||
}
|
||||
}
|
||||
|
||||
if existing.labels.is_none() && labels.is_some() {
|
||||
existing.labels = labels;
|
||||
}
|
||||
}
|
||||
|
||||
fn is_hidden_directory_app(app: &DirectoryApp) -> bool {
|
||||
matches!(app.visibility.as_deref(), Some("HIDDEN"))
|
||||
}
|
||||
|
||||
fn directory_app_to_app_info(app: DirectoryApp) -> AppInfo {
|
||||
AppInfo {
|
||||
id: app.id,
|
||||
name: app.name,
|
||||
description: app.description,
|
||||
logo_url: app.logo_url,
|
||||
logo_url_dark: app.logo_url_dark,
|
||||
distribution_channel: app.distribution_channel,
|
||||
branding: app.branding,
|
||||
app_metadata: app.app_metadata,
|
||||
labels: app.labels,
|
||||
install_url: None,
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn connector_install_url(name: &str, connector_id: &str) -> String {
|
||||
let slug = connector_name_slug(name);
|
||||
format!("https://chatgpt.com/apps/{slug}/{connector_id}")
|
||||
}
|
||||
|
||||
fn connector_name_slug(name: &str) -> String {
|
||||
let mut normalized = String::with_capacity(name.len());
|
||||
for character in name.chars() {
|
||||
if character.is_ascii_alphanumeric() {
|
||||
normalized.push(character.to_ascii_lowercase());
|
||||
} else {
|
||||
normalized.push('-');
|
||||
}
|
||||
}
|
||||
let normalized = normalized.trim_matches('-');
|
||||
if normalized.is_empty() {
|
||||
"app".to_string()
|
||||
} else {
|
||||
normalized.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_connector_name(name: &str, connector_id: &str) -> String {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
connector_id.to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_connector_value(value: Option<&str>) -> Option<String> {
|
||||
value
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
fn cache_key(id: &str) -> AllConnectorsCacheKey {
|
||||
AllConnectorsCacheKey::new(
|
||||
"https://chatgpt.example".to_string(),
|
||||
Some(format!("account-{id}")),
|
||||
Some(format!("user-{id}")),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
fn app(id: &str, name: &str) -> DirectoryApp {
|
||||
DirectoryApp {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
description: None,
|
||||
app_metadata: None,
|
||||
branding: None,
|
||||
labels: None,
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
visibility: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_all_connectors_uses_shared_cache() -> anyhow::Result<()> {
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let call_counter = Arc::clone(&calls);
|
||||
let key = cache_key("shared");
|
||||
|
||||
let first = list_all_connectors_with_options(key.clone(), false, false, move |_path| {
|
||||
let call_counter = Arc::clone(&call_counter);
|
||||
async move {
|
||||
call_counter.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(DirectoryListResponse {
|
||||
apps: vec![app("alpha", "Alpha")],
|
||||
next_token: None,
|
||||
})
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
|
||||
let second = list_all_connectors_with_options(key, false, false, move |_path| async move {
|
||||
anyhow::bail!("cache should have been used");
|
||||
})
|
||||
.await?;
|
||||
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(first, second);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_all_connectors_merges_and_normalizes_directory_apps() -> anyhow::Result<()> {
|
||||
let key = cache_key("merged");
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let call_counter = Arc::clone(&calls);
|
||||
|
||||
let connectors = list_all_connectors_with_options(key, true, true, move |path| {
|
||||
let call_counter = Arc::clone(&call_counter);
|
||||
async move {
|
||||
call_counter.fetch_add(1, Ordering::SeqCst);
|
||||
if path.starts_with("/connectors/directory/list_workspace") {
|
||||
Ok(DirectoryListResponse {
|
||||
apps: vec![
|
||||
DirectoryApp {
|
||||
description: Some("Merged description".to_string()),
|
||||
branding: Some(AppBranding {
|
||||
category: Some("calendar".to_string()),
|
||||
developer: None,
|
||||
website: None,
|
||||
privacy_policy: None,
|
||||
terms_of_service: None,
|
||||
is_discoverable_app: true,
|
||||
}),
|
||||
..app("alpha", "")
|
||||
},
|
||||
DirectoryApp {
|
||||
visibility: Some("HIDDEN".to_string()),
|
||||
..app("hidden", "Hidden")
|
||||
},
|
||||
],
|
||||
next_token: None,
|
||||
})
|
||||
} else {
|
||||
Ok(DirectoryListResponse {
|
||||
apps: vec![app("alpha", " Alpha "), app("beta", "Beta")],
|
||||
next_token: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 2);
|
||||
assert_eq!(connectors.len(), 2);
|
||||
assert_eq!(connectors[0].id, "alpha");
|
||||
assert_eq!(connectors[0].name, "Alpha");
|
||||
assert_eq!(
|
||||
connectors[0].description.as_deref(),
|
||||
Some("Merged description")
|
||||
);
|
||||
assert_eq!(
|
||||
connectors[0].install_url.as_deref(),
|
||||
Some("https://chatgpt.com/apps/alpha/alpha")
|
||||
);
|
||||
assert_eq!(
|
||||
connectors[0]
|
||||
.branding
|
||||
.as_ref()
|
||||
.and_then(|branding| branding.category.as_deref()),
|
||||
Some("calendar")
|
||||
);
|
||||
assert_eq!(connectors[1].id, "beta");
|
||||
assert_eq!(connectors[1].name, "Beta");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ codex-app-server-protocol = { workspace = true }
|
||||
codex-apply-patch = { workspace = true }
|
||||
codex-async-utils = { workspace = true }
|
||||
codex-client = { workspace = true }
|
||||
codex-connectors = { workspace = true }
|
||||
codex-config = { workspace = true }
|
||||
codex-shell-command = { workspace = true }
|
||||
codex-skills = { workspace = true }
|
||||
|
||||
@@ -471,13 +471,16 @@
|
||||
"tool_call_mcp_elicitation": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"tool_suggest": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"undo": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"unified_exec": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"use_linux_sandbox_bwrap": {
|
||||
"use_legacy_landlock": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"voice_transcription": {
|
||||
@@ -1973,13 +1976,16 @@
|
||||
"tool_call_mcp_elicitation": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"tool_suggest": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"undo": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"unified_exec": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"use_linux_sandbox_bwrap": {
|
||||
"use_legacy_landlock": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"voice_transcription": {
|
||||
|
||||
+59
-23
@@ -287,12 +287,14 @@ use crate::tasks::SessionTask;
|
||||
use crate::tasks::SessionTaskContext;
|
||||
use crate::tools::ToolRouter;
|
||||
use crate::tools::context::SharedTurnDiffTracker;
|
||||
use crate::tools::discoverable::DiscoverableTool;
|
||||
use crate::tools::js_repl::JsReplHandle;
|
||||
use crate::tools::js_repl::resolve_compatible_node;
|
||||
use crate::tools::network_approval::NetworkApprovalService;
|
||||
use crate::tools::network_approval::build_blocked_request_observer;
|
||||
use crate::tools::network_approval::build_network_policy_decider;
|
||||
use crate::tools::parallel::ToolCallRuntime;
|
||||
use crate::tools::router::ToolRouterParams;
|
||||
use crate::tools::sandboxing::ApprovalStore;
|
||||
use crate::tools::spec::ToolsConfig;
|
||||
use crate::tools::spec::ToolsConfigParams;
|
||||
@@ -1288,9 +1290,7 @@ impl Session {
|
||||
cwd.clone(),
|
||||
session_configuration.sandbox_policy.get(),
|
||||
session_configuration.windows_sandbox_level,
|
||||
per_turn_config
|
||||
.features
|
||||
.enabled(Feature::UseLinuxSandboxBwrap),
|
||||
per_turn_config.features.use_legacy_landlock(),
|
||||
));
|
||||
let (current_date, timezone) = local_time_context();
|
||||
TurnContext {
|
||||
@@ -1735,7 +1735,9 @@ impl Session {
|
||||
config.features.enabled(Feature::RuntimeMetrics),
|
||||
Self::build_model_client_beta_features_header(config.as_ref()),
|
||||
),
|
||||
code_mode_store: Default::default(),
|
||||
code_mode_service: crate::tools::code_mode::CodeModeService::new(
|
||||
config.js_repl_node_path.clone(),
|
||||
),
|
||||
};
|
||||
let js_repl = Arc::new(JsReplHandle::with_node_path(
|
||||
config.js_repl_node_path.clone(),
|
||||
@@ -1798,7 +1800,7 @@ impl Session {
|
||||
sandbox_policy: session_configuration.sandbox_policy.get().clone(),
|
||||
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
|
||||
sandbox_cwd: session_configuration.cwd.clone(),
|
||||
use_linux_sandbox_bwrap: config.features.enabled(Feature::UseLinuxSandboxBwrap),
|
||||
use_legacy_landlock: config.features.use_legacy_landlock(),
|
||||
};
|
||||
let mut required_mcp_servers: Vec<String> = mcp_servers
|
||||
.iter()
|
||||
@@ -2271,9 +2273,7 @@ impl Session {
|
||||
sandbox_policy: per_turn_config.permissions.sandbox_policy.get().clone(),
|
||||
codex_linux_sandbox_exe: per_turn_config.codex_linux_sandbox_exe.clone(),
|
||||
sandbox_cwd: per_turn_config.cwd.clone(),
|
||||
use_linux_sandbox_bwrap: per_turn_config
|
||||
.features
|
||||
.enabled(Feature::UseLinuxSandboxBwrap),
|
||||
use_legacy_landlock: per_turn_config.features.use_legacy_landlock(),
|
||||
};
|
||||
if let Err(e) = self
|
||||
.services
|
||||
@@ -3934,7 +3934,7 @@ impl Session {
|
||||
sandbox_policy: turn_context.sandbox_policy.get().clone(),
|
||||
codex_linux_sandbox_exe: turn_context.codex_linux_sandbox_exe.clone(),
|
||||
sandbox_cwd: turn_context.cwd.clone(),
|
||||
use_linux_sandbox_bwrap: turn_context.features.enabled(Feature::UseLinuxSandboxBwrap),
|
||||
use_legacy_landlock: turn_context.features.use_legacy_landlock(),
|
||||
};
|
||||
{
|
||||
let mut guard = self.services.mcp_startup_cancellation_token.lock().await;
|
||||
@@ -5211,9 +5211,7 @@ async fn spawn_review_thread(
|
||||
parent_turn_context.cwd.clone(),
|
||||
parent_turn_context.sandbox_policy.get(),
|
||||
parent_turn_context.windows_sandbox_level,
|
||||
parent_turn_context
|
||||
.features
|
||||
.enabled(Feature::UseLinuxSandboxBwrap),
|
||||
parent_turn_context.features.use_legacy_landlock(),
|
||||
));
|
||||
|
||||
let review_turn_context = TurnContext {
|
||||
@@ -6246,7 +6244,7 @@ async fn run_sampling_request(
|
||||
}
|
||||
}
|
||||
|
||||
async fn built_tools(
|
||||
pub(crate) async fn built_tools(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
input: &[ResponseItem],
|
||||
@@ -6269,10 +6267,17 @@ async fn built_tools(
|
||||
let mut effective_explicitly_enabled_connectors = explicitly_enabled_connectors.clone();
|
||||
effective_explicitly_enabled_connectors.extend(sess.get_connector_selection().await);
|
||||
|
||||
let connectors = if turn_context.apps_enabled() {
|
||||
let apps_enabled = turn_context.apps_enabled();
|
||||
let accessible_connectors =
|
||||
apps_enabled.then(|| connectors::accessible_connectors_from_mcp_tools(&mcp_tools));
|
||||
let accessible_connectors_with_enabled_state =
|
||||
accessible_connectors.as_ref().map(|connectors| {
|
||||
connectors::with_app_enabled_state(connectors.clone(), &turn_context.config)
|
||||
});
|
||||
let connectors = if apps_enabled {
|
||||
let connectors = connectors::merge_plugin_apps_with_accessible(
|
||||
loaded_plugins.effective_apps(),
|
||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
||||
accessible_connectors.clone().unwrap_or_default(),
|
||||
);
|
||||
Some(connectors::with_app_enabled_state(
|
||||
connectors,
|
||||
@@ -6281,6 +6286,34 @@ async fn built_tools(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let auth = sess.services.auth_manager.auth().await;
|
||||
let discoverable_tools = if apps_enabled
|
||||
&& turn_context.tools_config.search_tool
|
||||
&& turn_context.tools_config.tool_suggest
|
||||
{
|
||||
if let Some(accessible_connectors) = accessible_connectors_with_enabled_state.as_ref() {
|
||||
match connectors::list_tool_suggest_discoverable_tools_with_auth(
|
||||
&turn_context.config,
|
||||
auth.as_ref(),
|
||||
accessible_connectors.as_slice(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(connectors) if connectors.is_empty() => None,
|
||||
Ok(connectors) => {
|
||||
Some(connectors.into_iter().map(DiscoverableTool::from).collect())
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("failed to load discoverable tool suggestions: {err:#}");
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Keep the connector-grouped app view around for the router even though
|
||||
// app tools only become prompt-visible after explicit selection/discovery.
|
||||
@@ -6312,14 +6345,17 @@ async fn built_tools(
|
||||
|
||||
Ok(Arc::new(ToolRouter::from_config(
|
||||
&turn_context.tools_config,
|
||||
has_mcp_servers.then(|| {
|
||||
mcp_tools
|
||||
.into_iter()
|
||||
.map(|(name, tool)| (name, tool.tool))
|
||||
.collect()
|
||||
}),
|
||||
app_tools,
|
||||
turn_context.dynamic_tools.as_slice(),
|
||||
ToolRouterParams {
|
||||
mcp_tools: has_mcp_servers.then(|| {
|
||||
mcp_tools
|
||||
.into_iter()
|
||||
.map(|(name, tool)| (name, tool.tool))
|
||||
.collect()
|
||||
}),
|
||||
app_tools,
|
||||
discoverable_tools,
|
||||
dynamic_tools: turn_context.dynamic_tools.as_slice(),
|
||||
},
|
||||
)))
|
||||
}
|
||||
|
||||
|
||||
@@ -165,9 +165,12 @@ fn default_image_save_developer_message_text() -> String {
|
||||
fn test_tool_runtime(session: Arc<Session>, turn_context: Arc<TurnContext>) -> ToolCallRuntime {
|
||||
let router = Arc::new(ToolRouter::from_config(
|
||||
&turn_context.tools_config,
|
||||
None,
|
||||
None,
|
||||
turn_context.dynamic_tools.as_slice(),
|
||||
crate::tools::router::ToolRouterParams {
|
||||
mcp_tools: None,
|
||||
app_tools: None,
|
||||
discoverable_tools: None,
|
||||
dynamic_tools: turn_context.dynamic_tools.as_slice(),
|
||||
},
|
||||
));
|
||||
let tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new()));
|
||||
ToolCallRuntime::new(router, session, turn_context, tracker)
|
||||
@@ -2162,7 +2165,9 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
config.features.enabled(Feature::RuntimeMetrics),
|
||||
Session::build_model_client_beta_features_header(config.as_ref()),
|
||||
),
|
||||
code_mode_store: Default::default(),
|
||||
code_mode_service: crate::tools::code_mode::CodeModeService::new(
|
||||
config.js_repl_node_path.clone(),
|
||||
),
|
||||
};
|
||||
let js_repl = Arc::new(JsReplHandle::with_node_path(
|
||||
config.js_repl_node_path.clone(),
|
||||
@@ -2799,7 +2804,9 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
|
||||
config.features.enabled(Feature::RuntimeMetrics),
|
||||
Session::build_model_client_beta_features_header(config.as_ref()),
|
||||
),
|
||||
code_mode_store: Default::default(),
|
||||
code_mode_service: crate::tools::code_mode::CodeModeService::new(
|
||||
config.js_repl_node_path.clone(),
|
||||
),
|
||||
};
|
||||
let js_repl = Arc::new(JsReplHandle::with_node_path(
|
||||
config.js_repl_node_path.clone(),
|
||||
@@ -3954,14 +3961,17 @@ async fn fatal_tool_error_stops_turn_and_reports_error() {
|
||||
let app_tools = Some(tools.clone());
|
||||
let router = ToolRouter::from_config(
|
||||
&turn_context.tools_config,
|
||||
Some(
|
||||
tools
|
||||
.into_iter()
|
||||
.map(|(name, tool)| (name, tool.tool))
|
||||
.collect(),
|
||||
),
|
||||
app_tools,
|
||||
turn_context.dynamic_tools.as_slice(),
|
||||
crate::tools::router::ToolRouterParams {
|
||||
mcp_tools: Some(
|
||||
tools
|
||||
.into_iter()
|
||||
.map(|(name, tool)| (name, tool.tool))
|
||||
.collect(),
|
||||
),
|
||||
app_tools,
|
||||
discoverable_tools: None,
|
||||
dynamic_tools: turn_context.dynamic_tools.as_slice(),
|
||||
},
|
||||
);
|
||||
let item = ResponseItem::CustomToolCall {
|
||||
id: None,
|
||||
|
||||
+332
-12
@@ -9,13 +9,17 @@ use std::sync::Mutex as StdMutex;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use anyhow::Context;
|
||||
use async_channel::unbounded;
|
||||
pub use codex_app_server_protocol::AppBranding;
|
||||
pub use codex_app_server_protocol::AppInfo;
|
||||
pub use codex_app_server_protocol::AppMetadata;
|
||||
use codex_connectors::AllConnectorsCacheKey;
|
||||
use codex_connectors::DirectoryListResponse;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use rmcp::model::ToolAnnotations;
|
||||
use serde::Deserialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::AuthManager;
|
||||
@@ -24,9 +28,9 @@ use crate::SandboxState;
|
||||
use crate::config::Config;
|
||||
use crate::config::types::AppToolApproval;
|
||||
use crate::config::types::AppsConfigToml;
|
||||
use crate::default_client::create_client;
|
||||
use crate::default_client::is_first_party_chat_originator;
|
||||
use crate::default_client::originator;
|
||||
use crate::features::Feature;
|
||||
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use crate::mcp::McpManager;
|
||||
use crate::mcp::ToolPluginProvenance;
|
||||
@@ -38,8 +42,22 @@ use crate::plugins::AppConnectorId;
|
||||
use crate::plugins::PluginsManager;
|
||||
use crate::token_data::TokenData;
|
||||
|
||||
pub const CONNECTORS_CACHE_TTL: Duration = Duration::from_secs(3600);
|
||||
pub use codex_connectors::CONNECTORS_CACHE_TTL;
|
||||
const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30);
|
||||
const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
const TOOL_SUGGEST_DISCOVERABLE_CONNECTOR_IDS: &[&str] = &[
|
||||
"connector_2128aebfecb84f64a069897515042a44",
|
||||
"connector_68df038e0ba48191908c8434991bbac2",
|
||||
"asdk_app_69a1d78e929881919bba0dbda1f6436d",
|
||||
"connector_4964e3b22e3e427e9b4ae1acf2c1fa34",
|
||||
"connector_9d7cfa34e6654a5f98d3387af34b2e1c",
|
||||
"connector_6f1ec045b8fa4ced8738e32c7f74514b",
|
||||
"connector_947e0d954944416db111db556030eea6",
|
||||
"connector_5f3c8c41a1e54ad7a76272c89e2554fa",
|
||||
"connector_686fad9b54914a35b75be6d06a0f6f31",
|
||||
"connector_76869538009648d5b282a4bb21c3d157",
|
||||
"connector_37316be7febe4224b3d31465bae4dbd7",
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct AppToolPolicy {
|
||||
@@ -90,6 +108,19 @@ pub async fn list_accessible_connectors_from_mcp_tools(
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth(
|
||||
config: &Config,
|
||||
auth: Option<&CodexAuth>,
|
||||
accessible_connectors: &[AppInfo],
|
||||
) -> anyhow::Result<Vec<AppInfo>> {
|
||||
let directory_connectors =
|
||||
list_directory_connectors_for_tool_suggest_with_auth(config, auth).await?;
|
||||
Ok(filter_tool_suggest_discoverable_tools(
|
||||
directory_connectors,
|
||||
accessible_connectors,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn list_cached_accessible_connectors_from_mcp_tools(
|
||||
config: &Config,
|
||||
) -> Option<Vec<AppInfo>> {
|
||||
@@ -102,6 +133,21 @@ pub async fn list_cached_accessible_connectors_from_mcp_tools(
|
||||
read_cached_accessible_connectors(&cache_key).map(filter_disallowed_connectors)
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_accessible_connectors_cache_from_mcp_tools(
|
||||
config: &Config,
|
||||
auth: Option<&CodexAuth>,
|
||||
mcp_tools: &HashMap<String, crate::mcp_connection_manager::ToolInfo>,
|
||||
) {
|
||||
if !config.features.enabled(Feature::Apps) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cache_key = accessible_connectors_cache_key(config, auth);
|
||||
let accessible_connectors =
|
||||
filter_disallowed_connectors(accessible_connectors_from_mcp_tools(mcp_tools));
|
||||
write_cached_accessible_connectors(cache_key, &accessible_connectors);
|
||||
}
|
||||
|
||||
pub async fn list_accessible_connectors_from_mcp_tools_with_options(
|
||||
config: &Config,
|
||||
force_refetch: bool,
|
||||
@@ -156,7 +202,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status(
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
|
||||
sandbox_cwd: env::current_dir().unwrap_or_else(|_| PathBuf::from("/")),
|
||||
use_linux_sandbox_bwrap: config.features.enabled(Feature::UseLinuxSandboxBwrap),
|
||||
use_legacy_landlock: config.features.use_legacy_landlock(),
|
||||
};
|
||||
|
||||
let (mcp_connection_manager, cancel_token) = McpConnectionManager::new(
|
||||
@@ -172,19 +218,33 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status(
|
||||
)
|
||||
.await;
|
||||
|
||||
if force_refetch
|
||||
&& let Err(err) = mcp_connection_manager
|
||||
let refreshed_tools = if force_refetch {
|
||||
match mcp_connection_manager
|
||||
.hard_refresh_codex_apps_tools_cache()
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
"failed to force-refresh tools for MCP server '{CODEX_APPS_MCP_SERVER_NAME}', using cached/startup tools: {err:#}"
|
||||
);
|
||||
}
|
||||
{
|
||||
Ok(tools) => Some(tools),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"failed to force-refresh tools for MCP server '{CODEX_APPS_MCP_SERVER_NAME}', using cached/startup tools: {err:#}"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let refreshed_tools_succeeded = refreshed_tools.is_some();
|
||||
|
||||
let mut tools = mcp_connection_manager.list_all_tools().await;
|
||||
let mut tools = if let Some(tools) = refreshed_tools {
|
||||
tools
|
||||
} else {
|
||||
mcp_connection_manager.list_all_tools().await
|
||||
};
|
||||
let mut should_reload_tools = false;
|
||||
let codex_apps_ready = if let Some(cfg) = mcp_servers.get(CODEX_APPS_MCP_SERVER_NAME) {
|
||||
let codex_apps_ready = if refreshed_tools_succeeded {
|
||||
true
|
||||
} else if let Some(cfg) = mcp_servers.get(CODEX_APPS_MCP_SERVER_NAME) {
|
||||
let immediate_ready = mcp_connection_manager
|
||||
.wait_for_server_ready(CODEX_APPS_MCP_SERVER_NAME, Duration::ZERO)
|
||||
.await;
|
||||
@@ -281,6 +341,119 @@ fn write_cached_accessible_connectors(
|
||||
});
|
||||
}
|
||||
|
||||
fn filter_tool_suggest_discoverable_tools(
|
||||
directory_connectors: Vec<AppInfo>,
|
||||
accessible_connectors: &[AppInfo],
|
||||
) -> Vec<AppInfo> {
|
||||
let accessible_connector_ids: HashSet<&str> = accessible_connectors
|
||||
.iter()
|
||||
.filter(|connector| connector.is_accessible && connector.is_enabled)
|
||||
.map(|connector| connector.id.as_str())
|
||||
.collect();
|
||||
let allowed_connector_ids: HashSet<&str> = TOOL_SUGGEST_DISCOVERABLE_CONNECTOR_IDS
|
||||
.iter()
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
let mut connectors = filter_disallowed_connectors(directory_connectors)
|
||||
.into_iter()
|
||||
.filter(|connector| !accessible_connector_ids.contains(connector.id.as_str()))
|
||||
.filter(|connector| allowed_connector_ids.contains(connector.id.as_str()))
|
||||
.collect::<Vec<_>>();
|
||||
connectors.sort_by(|left, right| {
|
||||
left.name
|
||||
.cmp(&right.name)
|
||||
.then_with(|| left.id.cmp(&right.id))
|
||||
});
|
||||
connectors
|
||||
}
|
||||
|
||||
async fn list_directory_connectors_for_tool_suggest_with_auth(
|
||||
config: &Config,
|
||||
auth: Option<&CodexAuth>,
|
||||
) -> anyhow::Result<Vec<AppInfo>> {
|
||||
if !config.features.enabled(Feature::Apps) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let token_data = if let Some(auth) = auth {
|
||||
auth.get_token_data().ok()
|
||||
} else {
|
||||
let auth_manager = auth_manager_from_config(config);
|
||||
auth_manager
|
||||
.auth()
|
||||
.await
|
||||
.and_then(|auth| auth.get_token_data().ok())
|
||||
};
|
||||
let Some(token_data) = token_data else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
|
||||
let account_id = match token_data.account_id.as_deref() {
|
||||
Some(account_id) if !account_id.is_empty() => account_id,
|
||||
_ => return Ok(Vec::new()),
|
||||
};
|
||||
let access_token = token_data.access_token.clone();
|
||||
let account_id = account_id.to_string();
|
||||
let is_workspace_account = token_data.id_token.is_workspace_account();
|
||||
let cache_key = AllConnectorsCacheKey::new(
|
||||
config.chatgpt_base_url.clone(),
|
||||
Some(account_id.clone()),
|
||||
token_data.id_token.chatgpt_user_id.clone(),
|
||||
is_workspace_account,
|
||||
);
|
||||
|
||||
codex_connectors::list_all_connectors_with_options(
|
||||
cache_key,
|
||||
is_workspace_account,
|
||||
false,
|
||||
|path| {
|
||||
let access_token = access_token.clone();
|
||||
let account_id = account_id.clone();
|
||||
async move {
|
||||
chatgpt_get_request_with_token::<DirectoryListResponse>(
|
||||
config,
|
||||
path,
|
||||
access_token.as_str(),
|
||||
account_id.as_str(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn chatgpt_get_request_with_token<T: DeserializeOwned>(
|
||||
config: &Config,
|
||||
path: String,
|
||||
access_token: &str,
|
||||
account_id: &str,
|
||||
) -> anyhow::Result<T> {
|
||||
let client = create_client();
|
||||
let url = format!("{}{}", config.chatgpt_base_url, path);
|
||||
let response = client
|
||||
.get(&url)
|
||||
.bearer_auth(access_token)
|
||||
.header("chatgpt-account-id", account_id)
|
||||
.header("Content-Type", "application/json")
|
||||
.timeout(DIRECTORY_CONNECTORS_TIMEOUT)
|
||||
.send()
|
||||
.await
|
||||
.context("failed to send request")?;
|
||||
|
||||
if response.status().is_success() {
|
||||
response
|
||||
.json()
|
||||
.await
|
||||
.context("failed to parse JSON response")
|
||||
} else {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
anyhow::bail!("request failed with status {status}: {body}");
|
||||
}
|
||||
}
|
||||
|
||||
fn auth_manager_from_config(config: &Config) -> std::sync::Arc<AuthManager> {
|
||||
AuthManager::shared(
|
||||
config.codex_home.clone(),
|
||||
@@ -719,10 +892,12 @@ fn format_connector_label(name: &str, _id: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::config::types::AppConfig;
|
||||
use crate::config::types::AppToolConfig;
|
||||
use crate::config::types::AppToolsConfig;
|
||||
use crate::config::types::AppsDefaultConfig;
|
||||
use crate::features::Feature;
|
||||
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use crate::mcp_connection_manager::ToolInfo;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -730,6 +905,7 @@ mod tests {
|
||||
use rmcp::model::Tool;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn annotations(
|
||||
destructive_hint: Option<bool>,
|
||||
@@ -762,6 +938,15 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn named_app(id: &str, name: &str) -> AppInfo {
|
||||
AppInfo {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
install_url: Some(connector_install_url(name, id)),
|
||||
..app(id)
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_names(names: &[&str]) -> Vec<String> {
|
||||
names.iter().map(ToString::to_string).collect()
|
||||
}
|
||||
@@ -821,6 +1006,21 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn with_accessible_connectors_cache_cleared<R>(f: impl FnOnce() -> R) -> R {
|
||||
let previous = {
|
||||
let mut cache_guard = ACCESSIBLE_CONNECTORS_CACHE
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
cache_guard.take()
|
||||
};
|
||||
let result = f();
|
||||
let mut cache_guard = ACCESSIBLE_CONNECTORS_CACHE
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
*cache_guard = previous;
|
||||
result
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_connectors_replaces_plugin_placeholder_name_with_accessible_name() {
|
||||
let plugin = plugin_app_to_app_info(AppConnectorId("calendar".to_string()));
|
||||
@@ -907,6 +1107,62 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_accessible_connectors_cache_from_mcp_tools_writes_latest_installed_apps() {
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
let mut config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.build()
|
||||
.await
|
||||
.expect("config should load");
|
||||
let _ = config.features.set_enabled(Feature::Apps, true);
|
||||
let cache_key = accessible_connectors_cache_key(&config, None);
|
||||
let tools = HashMap::from([
|
||||
(
|
||||
"mcp__codex_apps__calendar_list_events".to_string(),
|
||||
codex_app_tool(
|
||||
"calendar_list_events",
|
||||
"calendar",
|
||||
Some("Google Calendar"),
|
||||
&["calendar-plugin"],
|
||||
),
|
||||
),
|
||||
(
|
||||
"mcp__codex_apps__openai_hidden".to_string(),
|
||||
codex_app_tool(
|
||||
"openai_hidden",
|
||||
"connector_openai_hidden",
|
||||
Some("Hidden"),
|
||||
&[],
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
let cached = with_accessible_connectors_cache_cleared(|| {
|
||||
refresh_accessible_connectors_cache_from_mcp_tools(&config, None, &tools);
|
||||
read_cached_accessible_connectors(&cache_key).expect("cache should be populated")
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
cached,
|
||||
vec![AppInfo {
|
||||
id: "calendar".to_string(),
|
||||
name: "Google Calendar".to_string(),
|
||||
description: None,
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
install_url: Some(connector_install_url("Google Calendar", "calendar")),
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
plugin_display_names: plugin_names(&["calendar-plugin"]),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_connectors_unions_and_dedupes_plugin_display_names() {
|
||||
let mut plugin = plugin_app_to_app_info(AppConnectorId("calendar".to_string()));
|
||||
@@ -1344,4 +1600,68 @@ mod tests {
|
||||
vec![app("asdk_app_6938a94a61d881918ef32cb999ff937c")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_tool_suggest_discoverable_tools_keeps_only_allowlisted_uninstalled_apps() {
|
||||
let filtered = filter_tool_suggest_discoverable_tools(
|
||||
vec![
|
||||
named_app(
|
||||
"connector_2128aebfecb84f64a069897515042a44",
|
||||
"Google Calendar",
|
||||
),
|
||||
named_app("connector_68df038e0ba48191908c8434991bbac2", "Gmail"),
|
||||
named_app("connector_other", "Other"),
|
||||
],
|
||||
&[AppInfo {
|
||||
is_accessible: true,
|
||||
..named_app(
|
||||
"connector_2128aebfecb84f64a069897515042a44",
|
||||
"Google Calendar",
|
||||
)
|
||||
}],
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
filtered,
|
||||
vec![named_app(
|
||||
"connector_68df038e0ba48191908c8434991bbac2",
|
||||
"Gmail",
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_tool_suggest_discoverable_tools_keeps_disabled_accessible_apps() {
|
||||
let filtered = filter_tool_suggest_discoverable_tools(
|
||||
vec![
|
||||
named_app(
|
||||
"connector_2128aebfecb84f64a069897515042a44",
|
||||
"Google Calendar",
|
||||
),
|
||||
named_app("connector_68df038e0ba48191908c8434991bbac2", "Gmail"),
|
||||
],
|
||||
&[
|
||||
AppInfo {
|
||||
is_accessible: true,
|
||||
..named_app(
|
||||
"connector_2128aebfecb84f64a069897515042a44",
|
||||
"Google Calendar",
|
||||
)
|
||||
},
|
||||
AppInfo {
|
||||
is_accessible: true,
|
||||
is_enabled: false,
|
||||
..named_app("connector_68df038e0ba48191908c8434991bbac2", "Gmail")
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
filtered,
|
||||
vec![named_app(
|
||||
"connector_68df038e0ba48191908c8434991bbac2",
|
||||
"Gmail"
|
||||
)]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,7 @@ pub async fn process_exec_tool_call(
|
||||
network_sandbox_policy: NetworkSandboxPolicy,
|
||||
sandbox_cwd: &Path,
|
||||
codex_linux_sandbox_exe: &Option<PathBuf>,
|
||||
use_linux_sandbox_bwrap: bool,
|
||||
use_legacy_landlock: bool,
|
||||
stdout_stream: Option<StdoutStream>,
|
||||
) -> Result<ExecToolCallOutput> {
|
||||
let exec_req = build_exec_request(
|
||||
@@ -195,7 +195,7 @@ pub async fn process_exec_tool_call(
|
||||
network_sandbox_policy,
|
||||
sandbox_cwd,
|
||||
codex_linux_sandbox_exe,
|
||||
use_linux_sandbox_bwrap,
|
||||
use_legacy_landlock,
|
||||
)?;
|
||||
|
||||
// Route through the sandboxing module for a single, unified execution path.
|
||||
@@ -211,7 +211,7 @@ pub fn build_exec_request(
|
||||
network_sandbox_policy: NetworkSandboxPolicy,
|
||||
sandbox_cwd: &Path,
|
||||
codex_linux_sandbox_exe: &Option<PathBuf>,
|
||||
use_linux_sandbox_bwrap: bool,
|
||||
use_legacy_landlock: bool,
|
||||
) -> Result<ExecRequest> {
|
||||
let windows_sandbox_level = params.windows_sandbox_level;
|
||||
let enforce_managed_network = params.network.is_some();
|
||||
@@ -269,7 +269,7 @@ pub fn build_exec_request(
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_seatbelt_profile_extensions: None,
|
||||
codex_linux_sandbox_exe: codex_linux_sandbox_exe.as_ref(),
|
||||
use_linux_sandbox_bwrap,
|
||||
use_legacy_landlock,
|
||||
windows_sandbox_level,
|
||||
})
|
||||
.map_err(CodexErr::from)?;
|
||||
|
||||
@@ -108,8 +108,9 @@ pub enum Feature {
|
||||
WebSearchCached,
|
||||
/// Legacy search-tool feature flag kept for backward compatibility.
|
||||
SearchTool,
|
||||
/// Use the bubblewrap-based Linux sandbox pipeline.
|
||||
UseLinuxSandboxBwrap,
|
||||
/// Use the legacy Landlock Linux sandbox fallback instead of the default
|
||||
/// bubblewrap pipeline.
|
||||
UseLegacyLandlock,
|
||||
/// Allow the model to request approval and propose exec rules.
|
||||
RequestRule,
|
||||
/// Enable Windows sandbox (restricted token) on Windows.
|
||||
@@ -142,6 +143,8 @@ pub enum Feature {
|
||||
SpawnCsv,
|
||||
/// Enable apps.
|
||||
Apps,
|
||||
/// Enable discoverable tool suggestions for apps.
|
||||
ToolSuggest,
|
||||
/// Enable plugins.
|
||||
Plugins,
|
||||
/// Allow the model to invoke the built-in image generation tool.
|
||||
@@ -282,6 +285,10 @@ impl Features {
|
||||
self.enabled(Feature::Apps) && auth.is_some_and(CodexAuth::is_chatgpt_auth)
|
||||
}
|
||||
|
||||
pub fn use_legacy_landlock(&self) -> bool {
|
||||
self.enabled(Feature::UseLegacyLandlock)
|
||||
}
|
||||
|
||||
pub fn enable(&mut self, f: Feature) -> &mut Self {
|
||||
self.enabled.insert(f);
|
||||
self
|
||||
@@ -634,16 +641,9 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::UseLinuxSandboxBwrap,
|
||||
key: "use_linux_sandbox_bwrap",
|
||||
#[cfg(target_os = "linux")]
|
||||
stage: Stage::Experimental {
|
||||
name: "Bubblewrap sandbox",
|
||||
menu_description: "Try the new linux sandbox based on bubblewrap.",
|
||||
announcement: "NEW: Linux bubblewrap sandbox offers stronger filesystem and network controls than Landlock alone, including keeping .git and .codex read-only inside writable workspaces. Enable it in /experimental and restart Codex to try it.",
|
||||
},
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
stage: Stage::UnderDevelopment,
|
||||
id: Feature::UseLegacyLandlock,
|
||||
key: "use_legacy_landlock",
|
||||
stage: Stage::Stable,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
@@ -714,6 +714,12 @@ pub const FEATURES: &[FeatureSpec] = &[
|
||||
},
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::ToolSuggest,
|
||||
key: "tool_suggest",
|
||||
stage: Stage::UnderDevelopment,
|
||||
default_enabled: false,
|
||||
},
|
||||
FeatureSpec {
|
||||
id: Feature::Plugins,
|
||||
key: "plugins",
|
||||
@@ -924,24 +930,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn use_linux_sandbox_bwrap_is_experimental_on_linux() {
|
||||
assert!(matches!(
|
||||
Feature::UseLinuxSandboxBwrap.stage(),
|
||||
Stage::Experimental { .. }
|
||||
));
|
||||
assert_eq!(Feature::UseLinuxSandboxBwrap.default_enabled(), false);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
#[test]
|
||||
fn use_linux_sandbox_bwrap_is_under_development_off_linux() {
|
||||
assert_eq!(
|
||||
Feature::UseLinuxSandboxBwrap.stage(),
|
||||
Stage::UnderDevelopment
|
||||
);
|
||||
assert_eq!(Feature::UseLinuxSandboxBwrap.default_enabled(), false);
|
||||
fn use_legacy_landlock_is_stable_and_disabled_by_default() {
|
||||
assert_eq!(Feature::UseLegacyLandlock.stage(), Stage::Stable);
|
||||
assert_eq!(Feature::UseLegacyLandlock.default_enabled(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -996,6 +988,12 @@ mod tests {
|
||||
assert_eq!(Feature::RequestPermissionsTool.default_enabled(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_suggest_is_under_development() {
|
||||
assert_eq!(Feature::ToolSuggest.stage(), Stage::UnderDevelopment);
|
||||
assert_eq!(Feature::ToolSuggest.default_enabled(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_generation_is_under_development() {
|
||||
assert_eq!(Feature::ImageGeneration.stage(), Stage::UnderDevelopment);
|
||||
|
||||
@@ -11,7 +11,7 @@ use std::path::PathBuf;
|
||||
use tokio::process::Child;
|
||||
|
||||
/// Spawn a shell tool command under the Linux sandbox helper
|
||||
/// (codex-linux-sandbox), which currently uses bubblewrap for filesystem
|
||||
/// (codex-linux-sandbox), which defaults to bubblewrap for filesystem
|
||||
/// isolation plus seccomp for network restrictions.
|
||||
///
|
||||
/// Unlike macOS Seatbelt where we directly embed the policy text, the Linux
|
||||
@@ -25,7 +25,7 @@ pub async fn spawn_command_under_linux_sandbox<P>(
|
||||
command_cwd: PathBuf,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
sandbox_policy_cwd: &Path,
|
||||
use_bwrap_sandbox: bool,
|
||||
use_legacy_landlock: bool,
|
||||
stdio_policy: StdioPolicy,
|
||||
network: Option<&NetworkProxy>,
|
||||
env: HashMap<String, String>,
|
||||
@@ -42,7 +42,7 @@ where
|
||||
&file_system_sandbox_policy,
|
||||
network_sandbox_policy,
|
||||
sandbox_policy_cwd,
|
||||
use_bwrap_sandbox,
|
||||
use_legacy_landlock,
|
||||
allow_network_for_proxy(false),
|
||||
);
|
||||
let arg0 = Some("codex-linux-sandbox");
|
||||
@@ -69,7 +69,7 @@ pub(crate) fn allow_network_for_proxy(enforce_managed_network: bool) -> bool {
|
||||
/// Converts the sandbox policies into the CLI invocation for
|
||||
/// `codex-linux-sandbox`.
|
||||
///
|
||||
/// The helper performs the actual sandboxing (bubblewrap + seccomp) after
|
||||
/// The helper performs the actual sandboxing (bubblewrap by default + seccomp) after
|
||||
/// parsing these arguments. Policy JSON flags are emitted before helper feature
|
||||
/// flags so the argv order matches the helper's CLI shape. See
|
||||
/// `docs/linux_sandbox.md` for the Linux semantics.
|
||||
@@ -80,7 +80,7 @@ pub(crate) fn create_linux_sandbox_command_args_for_policies(
|
||||
file_system_sandbox_policy: &FileSystemSandboxPolicy,
|
||||
network_sandbox_policy: NetworkSandboxPolicy,
|
||||
sandbox_policy_cwd: &Path,
|
||||
use_bwrap_sandbox: bool,
|
||||
use_legacy_landlock: bool,
|
||||
allow_network_for_proxy: bool,
|
||||
) -> Vec<String> {
|
||||
let sandbox_policy_json = serde_json::to_string(sandbox_policy)
|
||||
@@ -104,8 +104,8 @@ pub(crate) fn create_linux_sandbox_command_args_for_policies(
|
||||
"--network-sandbox-policy".to_string(),
|
||||
network_policy_json,
|
||||
];
|
||||
if use_bwrap_sandbox {
|
||||
linux_cmd.push("--use-bwrap-sandbox".to_string());
|
||||
if use_legacy_landlock {
|
||||
linux_cmd.push("--use-legacy-landlock".to_string());
|
||||
}
|
||||
if allow_network_for_proxy {
|
||||
linux_cmd.push("--allow-network-for-proxy".to_string());
|
||||
@@ -121,7 +121,7 @@ pub(crate) fn create_linux_sandbox_command_args_for_policies(
|
||||
pub(crate) fn create_linux_sandbox_command_args(
|
||||
command: Vec<String>,
|
||||
sandbox_policy_cwd: &Path,
|
||||
use_bwrap_sandbox: bool,
|
||||
use_legacy_landlock: bool,
|
||||
allow_network_for_proxy: bool,
|
||||
) -> Vec<String> {
|
||||
let sandbox_policy_cwd = sandbox_policy_cwd
|
||||
@@ -130,8 +130,8 @@ pub(crate) fn create_linux_sandbox_command_args(
|
||||
.to_string();
|
||||
|
||||
let mut linux_cmd: Vec<String> = vec!["--sandbox-policy-cwd".to_string(), sandbox_policy_cwd];
|
||||
if use_bwrap_sandbox {
|
||||
linux_cmd.push("--use-bwrap-sandbox".to_string());
|
||||
if use_legacy_landlock {
|
||||
linux_cmd.push("--use-legacy-landlock".to_string());
|
||||
}
|
||||
if allow_network_for_proxy {
|
||||
linux_cmd.push("--allow-network-for-proxy".to_string());
|
||||
@@ -153,20 +153,20 @@ mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn bwrap_flags_are_feature_gated() {
|
||||
fn legacy_landlock_flag_is_included_when_requested() {
|
||||
let command = vec!["/bin/true".to_string()];
|
||||
let cwd = Path::new("/tmp");
|
||||
|
||||
let with_bwrap = create_linux_sandbox_command_args(command.clone(), cwd, true, false);
|
||||
let default_bwrap = create_linux_sandbox_command_args(command.clone(), cwd, false, false);
|
||||
assert_eq!(
|
||||
with_bwrap.contains(&"--use-bwrap-sandbox".to_string()),
|
||||
true
|
||||
default_bwrap.contains(&"--use-legacy-landlock".to_string()),
|
||||
false
|
||||
);
|
||||
|
||||
let without_bwrap = create_linux_sandbox_command_args(command, cwd, false, false);
|
||||
let legacy_landlock = create_linux_sandbox_command_args(command, cwd, true, false);
|
||||
assert_eq!(
|
||||
without_bwrap.contains(&"--use-bwrap-sandbox".to_string()),
|
||||
false
|
||||
legacy_landlock.contains(&"--use-legacy-landlock".to_string()),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -304,7 +304,7 @@ pub async fn collect_mcp_snapshot(config: &Config) -> McpListToolsResponseEvent
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
|
||||
sandbox_cwd: env::current_dir().unwrap_or_else(|_| PathBuf::from("/")),
|
||||
use_linux_sandbox_bwrap: config.features.enabled(Feature::UseLinuxSandboxBwrap),
|
||||
use_legacy_landlock: config.features.use_legacy_landlock(),
|
||||
};
|
||||
|
||||
let (mcp_connection_manager, cancel_token) = McpConnectionManager::new(
|
||||
|
||||
@@ -591,7 +591,7 @@ pub struct SandboxState {
|
||||
pub codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
pub sandbox_cwd: PathBuf,
|
||||
#[serde(default)]
|
||||
pub use_linux_sandbox_bwrap: bool,
|
||||
pub use_legacy_landlock: bool,
|
||||
}
|
||||
|
||||
/// A thin wrapper around a set of running [`RmcpClient`] instances.
|
||||
@@ -827,9 +827,10 @@ impl McpConnectionManager {
|
||||
|
||||
/// Force-refresh codex apps tools by bypassing the in-process cache.
|
||||
///
|
||||
/// On success, the refreshed tools replace the cache contents. On failure,
|
||||
/// the existing cache remains unchanged.
|
||||
pub async fn hard_refresh_codex_apps_tools_cache(&self) -> Result<()> {
|
||||
/// On success, the refreshed tools replace the cache contents and the
|
||||
/// latest filtered tool map is returned directly to the caller. On
|
||||
/// failure, the existing cache remains unchanged.
|
||||
pub async fn hard_refresh_codex_apps_tools_cache(&self) -> Result<HashMap<String, ToolInfo>> {
|
||||
let managed_client = self
|
||||
.clients
|
||||
.get(CODEX_APPS_MCP_SERVER_NAME)
|
||||
@@ -865,7 +866,10 @@ impl McpConnectionManager {
|
||||
list_start.elapsed(),
|
||||
&[("cache", "miss")],
|
||||
);
|
||||
Ok(())
|
||||
Ok(qualify_tools(filter_tools(
|
||||
tools,
|
||||
&managed_client.tool_filter,
|
||||
)))
|
||||
}
|
||||
|
||||
/// Returns a single map that contains all resources. Each key is the
|
||||
|
||||
@@ -1,30 +1,67 @@
|
||||
use crate::default_client::build_reqwest_client;
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use std::fs;
|
||||
use std::io::Cursor;
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::process::Output;
|
||||
use std::process::Stdio;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
use zip::ZipArchive;
|
||||
|
||||
const OPENAI_PLUGINS_REPO_URL: &str = "https://github.com/openai/plugins.git";
|
||||
const GITHUB_API_BASE_URL: &str = "https://api.github.com";
|
||||
const GITHUB_API_ACCEPT_HEADER: &str = "application/vnd.github+json";
|
||||
const GITHUB_API_VERSION_HEADER: &str = "2022-11-28";
|
||||
const OPENAI_PLUGINS_OWNER: &str = "openai";
|
||||
const OPENAI_PLUGINS_REPO: &str = "plugins";
|
||||
const CURATED_PLUGINS_RELATIVE_DIR: &str = ".tmp/plugins";
|
||||
const CURATED_PLUGINS_SHA_FILE: &str = ".tmp/plugins.sha";
|
||||
const CURATED_PLUGINS_GIT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const CURATED_PLUGINS_HTTP_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GitHubRepositorySummary {
|
||||
default_branch: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GitHubGitRefSummary {
|
||||
object: GitHubGitRefObject,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GitHubGitRefObject {
|
||||
sha: String,
|
||||
}
|
||||
|
||||
pub(crate) fn curated_plugins_repo_path(codex_home: &Path) -> PathBuf {
|
||||
codex_home.join(CURATED_PLUGINS_RELATIVE_DIR)
|
||||
}
|
||||
|
||||
pub(crate) fn sync_openai_plugins_repo(codex_home: &Path) -> Result<(), String> {
|
||||
pub(crate) fn read_curated_plugins_sha(codex_home: &Path) -> Option<String> {
|
||||
read_sha_file(codex_home.join(CURATED_PLUGINS_SHA_FILE).as_path())
|
||||
}
|
||||
|
||||
pub(crate) fn sync_openai_plugins_repo(codex_home: &Path) -> Result<String, String> {
|
||||
sync_openai_plugins_repo_with_api_base_url(codex_home, GITHUB_API_BASE_URL)
|
||||
}
|
||||
|
||||
fn sync_openai_plugins_repo_with_api_base_url(
|
||||
codex_home: &Path,
|
||||
api_base_url: &str,
|
||||
) -> Result<String, String> {
|
||||
let repo_path = curated_plugins_repo_path(codex_home);
|
||||
let sha_path = codex_home.join(CURATED_PLUGINS_SHA_FILE);
|
||||
let remote_sha = git_ls_remote_head_sha()?;
|
||||
let local_sha = read_local_sha(&repo_path, &sha_path);
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|err| format!("failed to create curated plugins sync runtime: {err}"))?;
|
||||
let remote_sha = runtime.block_on(fetch_curated_repo_remote_sha(api_base_url))?;
|
||||
let local_sha = read_sha_file(&sha_path);
|
||||
|
||||
if local_sha.as_deref() == Some(remote_sha.as_str()) && repo_path.join(".git").is_dir() {
|
||||
return Ok(());
|
||||
if local_sha.as_deref() == Some(remote_sha.as_str()) && repo_path.is_dir() {
|
||||
return Ok(remote_sha);
|
||||
}
|
||||
|
||||
let Some(parent) = repo_path.parent() else {
|
||||
@@ -50,23 +87,18 @@ pub(crate) fn sync_openai_plugins_repo(codex_home: &Path) -> Result<(), String>
|
||||
)
|
||||
})?;
|
||||
let cloned_repo_path = clone_dir.path().join("repo");
|
||||
let clone_output = run_git_command_with_timeout(
|
||||
Command::new("git")
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.arg("clone")
|
||||
.arg("--depth")
|
||||
.arg("1")
|
||||
.arg(OPENAI_PLUGINS_REPO_URL)
|
||||
.arg(&cloned_repo_path),
|
||||
"git clone curated plugins repo",
|
||||
CURATED_PLUGINS_GIT_TIMEOUT,
|
||||
)?;
|
||||
ensure_git_success(&clone_output, "git clone curated plugins repo")?;
|
||||
let zipball_bytes = runtime.block_on(fetch_curated_repo_zipball(api_base_url, &remote_sha))?;
|
||||
extract_zipball_to_dir(&zipball_bytes, &cloned_repo_path)?;
|
||||
|
||||
let cloned_sha = git_head_sha(&cloned_repo_path)?;
|
||||
if cloned_sha != remote_sha {
|
||||
if !cloned_repo_path
|
||||
.join(".agents/plugins/marketplace.json")
|
||||
.is_file()
|
||||
{
|
||||
return Err(format!(
|
||||
"curated plugins clone HEAD mismatch: expected {remote_sha}, got {cloned_sha}"
|
||||
"curated plugins archive missing marketplace manifest at {}",
|
||||
cloned_repo_path
|
||||
.join(".agents/plugins/marketplace.json")
|
||||
.display()
|
||||
));
|
||||
}
|
||||
|
||||
@@ -123,156 +155,215 @@ pub(crate) fn sync_openai_plugins_repo(codex_home: &Path) -> Result<(), String>
|
||||
)
|
||||
})?;
|
||||
}
|
||||
fs::write(&sha_path, format!("{cloned_sha}\n")).map_err(|err| {
|
||||
fs::write(&sha_path, format!("{remote_sha}\n")).map_err(|err| {
|
||||
format!(
|
||||
"failed to write curated plugins sha file {}: {err}",
|
||||
sha_path.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
Ok(remote_sha)
|
||||
}
|
||||
|
||||
fn read_local_sha(repo_path: &Path, sha_path: &Path) -> Option<String> {
|
||||
if repo_path.join(".git").is_dir()
|
||||
&& let Ok(sha) = git_head_sha(repo_path)
|
||||
{
|
||||
return Some(sha);
|
||||
async fn fetch_curated_repo_remote_sha(api_base_url: &str) -> Result<String, String> {
|
||||
let api_base_url = api_base_url.trim_end_matches('/');
|
||||
let repo_url = format!("{api_base_url}/repos/{OPENAI_PLUGINS_OWNER}/{OPENAI_PLUGINS_REPO}");
|
||||
let client = build_reqwest_client();
|
||||
let repo_body = fetch_github_text(&client, &repo_url, "get curated plugins repository").await?;
|
||||
let repo_summary: GitHubRepositorySummary =
|
||||
serde_json::from_str(&repo_body).map_err(|err| {
|
||||
format!("failed to parse curated plugins repository response from {repo_url}: {err}")
|
||||
})?;
|
||||
if repo_summary.default_branch.is_empty() {
|
||||
return Err(format!(
|
||||
"curated plugins repository response from {repo_url} did not include a default branch"
|
||||
));
|
||||
}
|
||||
|
||||
let git_ref_url = format!("{repo_url}/git/ref/heads/{}", repo_summary.default_branch);
|
||||
let git_ref_body =
|
||||
fetch_github_text(&client, &git_ref_url, "get curated plugins HEAD ref").await?;
|
||||
let git_ref: GitHubGitRefSummary = serde_json::from_str(&git_ref_body).map_err(|err| {
|
||||
format!("failed to parse curated plugins ref response from {git_ref_url}: {err}")
|
||||
})?;
|
||||
if git_ref.object.sha.is_empty() {
|
||||
return Err(format!(
|
||||
"curated plugins ref response from {git_ref_url} did not include a HEAD sha"
|
||||
));
|
||||
}
|
||||
|
||||
Ok(git_ref.object.sha)
|
||||
}
|
||||
|
||||
async fn fetch_curated_repo_zipball(
|
||||
api_base_url: &str,
|
||||
remote_sha: &str,
|
||||
) -> Result<Vec<u8>, String> {
|
||||
let api_base_url = api_base_url.trim_end_matches('/');
|
||||
let repo_url = format!("{api_base_url}/repos/{OPENAI_PLUGINS_OWNER}/{OPENAI_PLUGINS_REPO}");
|
||||
let zipball_url = format!("{repo_url}/zipball/{remote_sha}");
|
||||
let client = build_reqwest_client();
|
||||
fetch_github_bytes(&client, &zipball_url, "download curated plugins archive").await
|
||||
}
|
||||
|
||||
async fn fetch_github_text(client: &Client, url: &str, context: &str) -> Result<String, String> {
|
||||
let response = github_request(client, url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("failed to {context} from {url}: {err}"))?;
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
return Err(format!(
|
||||
"{context} from {url} failed with status {status}: {body}"
|
||||
));
|
||||
}
|
||||
Ok(body)
|
||||
}
|
||||
|
||||
async fn fetch_github_bytes(client: &Client, url: &str, context: &str) -> Result<Vec<u8>, String> {
|
||||
let response = github_request(client, url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|err| format!("failed to {context} from {url}: {err}"))?;
|
||||
let status = response.status();
|
||||
let body = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|err| format!("failed to read {context} response from {url}: {err}"))?;
|
||||
if !status.is_success() {
|
||||
let body_text = String::from_utf8_lossy(&body);
|
||||
return Err(format!(
|
||||
"{context} from {url} failed with status {status}: {body_text}"
|
||||
));
|
||||
}
|
||||
Ok(body.to_vec())
|
||||
}
|
||||
|
||||
fn github_request(client: &Client, url: &str) -> reqwest::RequestBuilder {
|
||||
client
|
||||
.get(url)
|
||||
.timeout(CURATED_PLUGINS_HTTP_TIMEOUT)
|
||||
.header("accept", GITHUB_API_ACCEPT_HEADER)
|
||||
.header("x-github-api-version", GITHUB_API_VERSION_HEADER)
|
||||
}
|
||||
|
||||
fn read_sha_file(sha_path: &Path) -> Option<String> {
|
||||
fs::read_to_string(sha_path)
|
||||
.ok()
|
||||
.map(|sha| sha.trim().to_string())
|
||||
.filter(|sha| !sha.is_empty())
|
||||
}
|
||||
|
||||
fn git_ls_remote_head_sha() -> Result<String, String> {
|
||||
let output = run_git_command_with_timeout(
|
||||
Command::new("git")
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.arg("ls-remote")
|
||||
.arg(OPENAI_PLUGINS_REPO_URL)
|
||||
.arg("HEAD"),
|
||||
"git ls-remote curated plugins repo",
|
||||
CURATED_PLUGINS_GIT_TIMEOUT,
|
||||
)?;
|
||||
ensure_git_success(&output, "git ls-remote curated plugins repo")?;
|
||||
fn extract_zipball_to_dir(bytes: &[u8], destination: &Path) -> Result<(), String> {
|
||||
fs::create_dir_all(destination).map_err(|err| {
|
||||
format!(
|
||||
"failed to create curated plugins extraction directory {}: {err}",
|
||||
destination.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let Some(first_line) = stdout.lines().next() else {
|
||||
return Err("git ls-remote returned empty output for curated plugins repo".to_string());
|
||||
};
|
||||
let Some((sha, _)) = first_line.split_once('\t') else {
|
||||
return Err(format!(
|
||||
"unexpected git ls-remote output for curated plugins repo: {first_line}"
|
||||
));
|
||||
};
|
||||
if sha.is_empty() {
|
||||
return Err("git ls-remote returned empty sha for curated plugins repo".to_string());
|
||||
}
|
||||
Ok(sha.to_string())
|
||||
}
|
||||
let cursor = Cursor::new(bytes);
|
||||
let mut archive = ZipArchive::new(cursor)
|
||||
.map_err(|err| format!("failed to open curated plugins zip archive: {err}"))?;
|
||||
|
||||
fn git_head_sha(repo_path: &Path) -> Result<String, String> {
|
||||
let output = Command::new("git")
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.arg("-C")
|
||||
.arg(repo_path)
|
||||
.arg("rev-parse")
|
||||
.arg("HEAD")
|
||||
.output()
|
||||
.map_err(|err| {
|
||||
for index in 0..archive.len() {
|
||||
let mut entry = archive
|
||||
.by_index(index)
|
||||
.map_err(|err| format!("failed to read curated plugins zip entry: {err}"))?;
|
||||
let Some(relative_path) = entry.enclosed_name() else {
|
||||
return Err(format!(
|
||||
"curated plugins zip entry `{}` escapes extraction root",
|
||||
entry.name()
|
||||
));
|
||||
};
|
||||
|
||||
let mut components = relative_path.components();
|
||||
let Some(Component::Normal(_)) = components.next() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let output_relative = components.fold(PathBuf::new(), |mut path, component| {
|
||||
if let Component::Normal(segment) = component {
|
||||
path.push(segment);
|
||||
}
|
||||
path
|
||||
});
|
||||
if output_relative.as_os_str().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let output_path = destination.join(&output_relative);
|
||||
if entry.is_dir() {
|
||||
fs::create_dir_all(&output_path).map_err(|err| {
|
||||
format!(
|
||||
"failed to create curated plugins directory {}: {err}",
|
||||
output_path.display()
|
||||
)
|
||||
})?;
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(parent) = output_path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|err| {
|
||||
format!(
|
||||
"failed to create curated plugins directory {}: {err}",
|
||||
parent.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
let mut output = fs::File::create(&output_path).map_err(|err| {
|
||||
format!(
|
||||
"failed to run git rev-parse HEAD in {}: {err}",
|
||||
repo_path.display()
|
||||
"failed to create curated plugins file {}: {err}",
|
||||
output_path.display()
|
||||
)
|
||||
})?;
|
||||
ensure_git_success(&output, "git rev-parse HEAD")?;
|
||||
|
||||
let sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if sha.is_empty() {
|
||||
return Err(format!(
|
||||
"git rev-parse HEAD returned empty output in {}",
|
||||
repo_path.display()
|
||||
));
|
||||
std::io::copy(&mut entry, &mut output).map_err(|err| {
|
||||
format!(
|
||||
"failed to write curated plugins file {}: {err}",
|
||||
output_path.display()
|
||||
)
|
||||
})?;
|
||||
apply_zip_permissions(&entry, &output_path)?;
|
||||
}
|
||||
Ok(sha)
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_git_command_with_timeout(
|
||||
command: &mut Command,
|
||||
context: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<Output, String> {
|
||||
let mut child = command
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.map_err(|err| format!("failed to run {context}: {err}"))?;
|
||||
|
||||
let start = Instant::now();
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => {
|
||||
return child
|
||||
.wait_with_output()
|
||||
.map_err(|err| format!("failed to wait for {context}: {err}"));
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(err) => return Err(format!("failed to poll {context}: {err}")),
|
||||
}
|
||||
|
||||
if start.elapsed() >= timeout {
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => {
|
||||
return child
|
||||
.wait_with_output()
|
||||
.map_err(|err| format!("failed to wait for {context}: {err}"));
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(err) => return Err(format!("failed to poll {context}: {err}")),
|
||||
}
|
||||
|
||||
let _ = child.kill();
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.map_err(|err| format!("failed to wait for {context} after timeout: {err}"))?;
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
return if stderr.is_empty() {
|
||||
Err(format!("{context} timed out after {}s", timeout.as_secs()))
|
||||
} else {
|
||||
Err(format!(
|
||||
"{context} timed out after {}s: {stderr}",
|
||||
timeout.as_secs()
|
||||
))
|
||||
};
|
||||
}
|
||||
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_git_success(output: &Output, context: &str) -> Result<(), String> {
|
||||
if output.status.success() {
|
||||
#[cfg(unix)]
|
||||
fn apply_zip_permissions(entry: &zip::read::ZipFile<'_>, output_path: &Path) -> Result<(), String> {
|
||||
let Some(mode) = entry.unix_mode() else {
|
||||
return Ok(());
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
if stderr.is_empty() {
|
||||
Err(format!("{context} failed with status {}", output.status))
|
||||
} else {
|
||||
Err(format!(
|
||||
"{context} failed with status {}: {stderr}",
|
||||
output.status
|
||||
))
|
||||
}
|
||||
};
|
||||
fs::set_permissions(output_path, fs::Permissions::from_mode(mode)).map_err(|err| {
|
||||
format!(
|
||||
"failed to set permissions on curated plugins file {}: {err}",
|
||||
output_path.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn apply_zip_permissions(
|
||||
_entry: &zip::read::ZipFile<'_>,
|
||||
_output_path: &Path,
|
||||
) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::io::Write;
|
||||
use tempfile::tempdir;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
use zip::ZipWriter;
|
||||
use zip::write::SimpleFileOptions;
|
||||
|
||||
#[test]
|
||||
fn curated_plugins_repo_path_uses_codex_home_tmp_dir() {
|
||||
@@ -284,70 +375,145 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_local_sha_prefers_repo_head_when_available() {
|
||||
fn read_curated_plugins_sha_reads_trimmed_sha_file() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let repo_path = tmp.path().join("repo");
|
||||
let sha_path = tmp.path().join("plugins.sha");
|
||||
fs::create_dir_all(tmp.path().join(".tmp")).expect("create tmp");
|
||||
fs::write(tmp.path().join(".tmp/plugins.sha"), "abc123\n").expect("write sha");
|
||||
|
||||
fs::create_dir_all(&repo_path).expect("create repo dir");
|
||||
fs::write(&sha_path, "abc123\n").expect("write sha");
|
||||
let init_output = Command::new("git")
|
||||
.arg("init")
|
||||
.arg(&repo_path)
|
||||
.output()
|
||||
.expect("git init should run");
|
||||
ensure_git_success(&init_output, "git init").expect("git init should succeed");
|
||||
let config_name_output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&repo_path)
|
||||
.arg("config")
|
||||
.arg("user.name")
|
||||
.arg("Codex")
|
||||
.output()
|
||||
.expect("git config user.name should run");
|
||||
ensure_git_success(&config_name_output, "git config user.name")
|
||||
.expect("git config user.name should succeed");
|
||||
let config_email_output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&repo_path)
|
||||
.arg("config")
|
||||
.arg("user.email")
|
||||
.arg("codex@example.com")
|
||||
.output()
|
||||
.expect("git config user.email should run");
|
||||
ensure_git_success(&config_email_output, "git config user.email")
|
||||
.expect("git config user.email should succeed");
|
||||
fs::write(repo_path.join("README.md"), "demo\n").expect("write file");
|
||||
let add_output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&repo_path)
|
||||
.arg("add")
|
||||
.arg(".")
|
||||
.output()
|
||||
.expect("git add should run");
|
||||
ensure_git_success(&add_output, "git add").expect("git add should succeed");
|
||||
let commit_output = Command::new("git")
|
||||
.arg("-C")
|
||||
.arg(&repo_path)
|
||||
.arg("commit")
|
||||
.arg("-m")
|
||||
.arg("init")
|
||||
.output()
|
||||
.expect("git commit should run");
|
||||
ensure_git_success(&commit_output, "git commit").expect("git commit should succeed");
|
||||
|
||||
let sha = read_local_sha(&repo_path, &sha_path);
|
||||
assert_eq!(sha, Some(git_head_sha(&repo_path).expect("repo head sha")));
|
||||
assert_eq!(
|
||||
read_curated_plugins_sha(tmp.path()).as_deref(),
|
||||
Some("abc123")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_local_sha_falls_back_to_sha_file() {
|
||||
#[tokio::test]
|
||||
async fn sync_openai_plugins_repo_downloads_zipball_and_records_sha() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let repo_path = tmp.path().join("repo");
|
||||
let sha_path = tmp.path().join("plugins.sha");
|
||||
fs::write(&sha_path, "abc123\n").expect("write sha");
|
||||
let server = MockServer::start().await;
|
||||
let sha = "0123456789abcdef0123456789abcdef01234567";
|
||||
|
||||
let sha = read_local_sha(&repo_path, &sha_path);
|
||||
assert_eq!(sha.as_deref(), Some("abc123"));
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/openai/plugins"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_string(r#"{"default_branch":"main"}"#),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/openai/plugins/git/ref/heads/main"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(format!(r#"{{"object":{{"sha":"{sha}"}}}}"#)),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/repos/openai/plugins/zipball/{sha}")))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.insert_header("content-type", "application/zip")
|
||||
.set_body_bytes(curated_repo_zipball_bytes(sha)),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let server_uri = server.uri();
|
||||
let tmp_path = tmp.path().to_path_buf();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
sync_openai_plugins_repo_with_api_base_url(tmp_path.as_path(), &server_uri)
|
||||
})
|
||||
.await
|
||||
.expect("sync task should join")
|
||||
.expect("sync should succeed");
|
||||
|
||||
let repo_path = curated_plugins_repo_path(tmp.path());
|
||||
assert!(repo_path.join(".agents/plugins/marketplace.json").is_file());
|
||||
assert!(
|
||||
repo_path
|
||||
.join("plugins/gmail/.codex-plugin/plugin.json")
|
||||
.is_file()
|
||||
);
|
||||
assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_openai_plugins_repo_skips_archive_download_when_sha_matches() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let repo_path = curated_plugins_repo_path(tmp.path());
|
||||
fs::create_dir_all(repo_path.join(".agents/plugins")).expect("create repo");
|
||||
fs::write(
|
||||
repo_path.join(".agents/plugins/marketplace.json"),
|
||||
r#"{"name":"openai-curated","plugins":[]}"#,
|
||||
)
|
||||
.expect("write marketplace");
|
||||
fs::create_dir_all(tmp.path().join(".tmp")).expect("create tmp");
|
||||
let sha = "fedcba9876543210fedcba9876543210fedcba98";
|
||||
fs::write(tmp.path().join(".tmp/plugins.sha"), format!("{sha}\n")).expect("write sha");
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/openai/plugins"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).set_body_string(r#"{"default_branch":"main"}"#),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/openai/plugins/git/ref/heads/main"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(format!(r#"{{"object":{{"sha":"{sha}"}}}}"#)),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let server_uri = server.uri();
|
||||
let tmp_path = tmp.path().to_path_buf();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
sync_openai_plugins_repo_with_api_base_url(tmp_path.as_path(), &server_uri)
|
||||
})
|
||||
.await
|
||||
.expect("sync task should join")
|
||||
.expect("sync should succeed");
|
||||
|
||||
assert_eq!(read_curated_plugins_sha(tmp.path()).as_deref(), Some(sha));
|
||||
assert!(repo_path.join(".agents/plugins/marketplace.json").is_file());
|
||||
}
|
||||
|
||||
fn curated_repo_zipball_bytes(sha: &str) -> Vec<u8> {
|
||||
let cursor = Cursor::new(Vec::new());
|
||||
let mut writer = ZipWriter::new(cursor);
|
||||
let options = SimpleFileOptions::default();
|
||||
let root = format!("openai-plugins-{sha}");
|
||||
writer
|
||||
.start_file(format!("{root}/.agents/plugins/marketplace.json"), options)
|
||||
.expect("start marketplace entry");
|
||||
writer
|
||||
.write_all(
|
||||
br#"{
|
||||
"name": "openai-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "gmail",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/gmail"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.expect("write marketplace");
|
||||
writer
|
||||
.start_file(
|
||||
format!("{root}/plugins/gmail/.codex-plugin/plugin.json"),
|
||||
options,
|
||||
)
|
||||
.expect("start plugin manifest entry");
|
||||
writer
|
||||
.write_all(br#"{"name":"gmail"}"#)
|
||||
.expect("write plugin manifest");
|
||||
|
||||
writer.finish().expect("finish zip writer").into_inner()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use super::marketplace::load_marketplace_summary;
|
||||
use super::marketplace::resolve_marketplace_plugin;
|
||||
use super::plugin_manifest_name;
|
||||
use super::plugin_manifest_paths;
|
||||
use super::read_curated_plugins_sha;
|
||||
use super::store::DEFAULT_PLUGIN_VERSION;
|
||||
use super::store::PluginId;
|
||||
use super::store::PluginIdError;
|
||||
@@ -45,6 +46,7 @@ use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::RwLock;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
@@ -56,7 +58,6 @@ use tracing::warn;
|
||||
const DEFAULT_SKILLS_DIR_NAME: &str = "skills";
|
||||
const DEFAULT_MCP_CONFIG_FILE: &str = ".mcp.json";
|
||||
const DEFAULT_APP_CONFIG_FILE: &str = ".app.json";
|
||||
const DISABLE_CURATED_PLUGIN_SYNC_ENV_VAR: &str = "CODEX_DISABLE_CURATED_PLUGIN_SYNC";
|
||||
const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated";
|
||||
const REMOTE_PLUGIN_SYNC_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
static CURATED_REPO_SYNC_STARTED: AtomicBool = AtomicBool::new(false);
|
||||
@@ -75,7 +76,7 @@ pub struct PluginInstallOutcome {
|
||||
pub plugin_id: PluginId,
|
||||
pub plugin_version: String,
|
||||
pub installed_path: AbsolutePathBuf,
|
||||
pub auth_policy: Option<MarketplacePluginAuthPolicy>,
|
||||
pub auth_policy: MarketplacePluginAuthPolicy,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -90,8 +91,8 @@ pub struct ConfiguredMarketplacePluginSummary {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub source: MarketplacePluginSourceSummary,
|
||||
pub install_policy: Option<MarketplacePluginInstallPolicy>,
|
||||
pub auth_policy: Option<MarketplacePluginAuthPolicy>,
|
||||
pub install_policy: MarketplacePluginInstallPolicy,
|
||||
pub auth_policy: MarketplacePluginAuthPolicy,
|
||||
pub interface: Option<PluginManifestInterfaceSummary>,
|
||||
pub installed: bool,
|
||||
pub enabled: bool,
|
||||
@@ -395,9 +396,25 @@ impl PluginsManager {
|
||||
) -> Result<PluginInstallOutcome, PluginInstallError> {
|
||||
let resolved = resolve_marketplace_plugin(&request.marketplace_path, &request.plugin_name)?;
|
||||
let auth_policy = resolved.auth_policy;
|
||||
let plugin_version =
|
||||
if resolved.plugin_id.marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME {
|
||||
Some(
|
||||
read_curated_plugins_sha(self.codex_home.as_path()).ok_or_else(|| {
|
||||
PluginStoreError::Invalid(
|
||||
"local curated marketplace sha is not available".to_string(),
|
||||
)
|
||||
})?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let store = self.store.clone();
|
||||
let result: StorePluginInstallResult = tokio::task::spawn_blocking(move || {
|
||||
store.install(resolved.source_path, resolved.plugin_id)
|
||||
if let Some(plugin_version) = plugin_version {
|
||||
store.install_with_version(resolved.source_path, resolved.plugin_id, plugin_version)
|
||||
} else {
|
||||
store.install(resolved.source_path, resolved.plugin_id)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(PluginInstallError::join)??;
|
||||
@@ -464,8 +481,19 @@ impl PluginsManager {
|
||||
};
|
||||
|
||||
let marketplace_name = curated_marketplace.name.clone();
|
||||
let mut local_plugins =
|
||||
Vec::<(String, PluginId, AbsolutePathBuf, Option<bool>, bool)>::new();
|
||||
let curated_plugin_version = read_curated_plugins_sha(self.codex_home.as_path())
|
||||
.ok_or_else(|| {
|
||||
PluginStoreError::Invalid(
|
||||
"local curated marketplace sha is not available".to_string(),
|
||||
)
|
||||
})?;
|
||||
let mut local_plugins = Vec::<(
|
||||
String,
|
||||
PluginId,
|
||||
AbsolutePathBuf,
|
||||
Option<bool>,
|
||||
Option<String>,
|
||||
)>::new();
|
||||
let mut local_plugin_names = HashSet::new();
|
||||
for plugin in curated_marketplace.plugins {
|
||||
let plugin_name = plugin.name;
|
||||
@@ -486,13 +514,13 @@ impl PluginsManager {
|
||||
let current_enabled = configured_plugins
|
||||
.get(&plugin_key)
|
||||
.map(|plugin| plugin.enabled);
|
||||
let is_installed = self.store.is_installed(&plugin_id);
|
||||
let installed_version = self.store.active_plugin_version(&plugin_id);
|
||||
local_plugins.push((
|
||||
plugin_name,
|
||||
plugin_id,
|
||||
source_path,
|
||||
current_enabled,
|
||||
is_installed,
|
||||
installed_version,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -528,11 +556,20 @@ impl PluginsManager {
|
||||
let remote_plugin_count = remote_enabled_by_name.len();
|
||||
let local_plugin_count = local_plugins.len();
|
||||
|
||||
for (plugin_name, plugin_id, source_path, current_enabled, is_installed) in local_plugins {
|
||||
for (plugin_name, plugin_id, source_path, current_enabled, installed_version) in
|
||||
local_plugins
|
||||
{
|
||||
let plugin_key = plugin_id.as_key();
|
||||
let is_installed = installed_version.is_some();
|
||||
if let Some(enabled) = remote_enabled_by_name.get(&plugin_name).copied() {
|
||||
if !is_installed {
|
||||
installs.push((source_path, plugin_id.clone()));
|
||||
installs.push((
|
||||
source_path,
|
||||
plugin_id.clone(),
|
||||
curated_plugin_version.clone(),
|
||||
));
|
||||
}
|
||||
if !is_installed {
|
||||
result.installed_plugin_ids.push(plugin_key.clone());
|
||||
}
|
||||
|
||||
@@ -565,8 +602,8 @@ impl PluginsManager {
|
||||
|
||||
let store = self.store.clone();
|
||||
let store_result = tokio::task::spawn_blocking(move || {
|
||||
for (source_path, plugin_id) in installs {
|
||||
store.install(source_path, plugin_id)?;
|
||||
for (source_path, plugin_id, plugin_version) in installs {
|
||||
store.install_with_version(source_path, plugin_id, plugin_version)?;
|
||||
}
|
||||
for plugin_id in uninstalls {
|
||||
store.uninstall(&plugin_id)?;
|
||||
@@ -668,28 +705,67 @@ impl PluginsManager {
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn maybe_start_curated_repo_sync_for_config(&self, config: &Config) {
|
||||
pub fn maybe_start_curated_repo_sync_for_config(self: &Arc<Self>, config: &Config) {
|
||||
if plugins_feature_enabled_from_stack(&config.config_layer_stack) {
|
||||
self.start_curated_repo_sync();
|
||||
let mut configured_curated_plugin_ids =
|
||||
configured_plugins_from_stack(&config.config_layer_stack)
|
||||
.into_keys()
|
||||
.filter_map(|plugin_key| match PluginId::parse(&plugin_key) {
|
||||
Ok(plugin_id)
|
||||
if plugin_id.marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME =>
|
||||
{
|
||||
Some(plugin_id)
|
||||
}
|
||||
Ok(_) => None,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
plugin_key,
|
||||
error = %err,
|
||||
"ignoring invalid configured plugin key during curated sync setup"
|
||||
);
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
configured_curated_plugin_ids.sort_unstable_by_key(super::store::PluginId::as_key);
|
||||
self.start_curated_repo_sync(configured_curated_plugin_ids);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_curated_repo_sync(&self) {
|
||||
if std::env::var_os(DISABLE_CURATED_PLUGIN_SYNC_ENV_VAR).is_some() {
|
||||
return;
|
||||
}
|
||||
fn start_curated_repo_sync(self: &Arc<Self>, configured_curated_plugin_ids: Vec<PluginId>) {
|
||||
if CURATED_REPO_SYNC_STARTED.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
let manager = Arc::clone(self);
|
||||
let codex_home = self.codex_home.clone();
|
||||
if let Err(err) = std::thread::Builder::new()
|
||||
.name("plugins-curated-repo-sync".to_string())
|
||||
.spawn(move || {
|
||||
if let Err(err) = sync_openai_plugins_repo(codex_home.as_path()) {
|
||||
CURATED_REPO_SYNC_STARTED.store(false, Ordering::SeqCst);
|
||||
warn!("failed to sync curated plugins repo: {err}");
|
||||
}
|
||||
})
|
||||
.spawn(
|
||||
move || match sync_openai_plugins_repo(codex_home.as_path()) {
|
||||
Ok(curated_plugin_version) => {
|
||||
match refresh_curated_plugin_cache(
|
||||
codex_home.as_path(),
|
||||
&curated_plugin_version,
|
||||
&configured_curated_plugin_ids,
|
||||
) {
|
||||
Ok(cache_refreshed) => {
|
||||
if cache_refreshed {
|
||||
manager.clear_cache();
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
manager.clear_cache();
|
||||
CURATED_REPO_SYNC_STARTED.store(false, Ordering::SeqCst);
|
||||
warn!("failed to refresh curated plugin cache after sync: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
CURATED_REPO_SYNC_STARTED.store(false, Ordering::SeqCst);
|
||||
warn!("failed to sync curated plugins repo: {err}");
|
||||
}
|
||||
},
|
||||
)
|
||||
{
|
||||
CURATED_REPO_SYNC_STARTED.store(false, Ordering::SeqCst);
|
||||
warn!("failed to start curated plugins repo sync task: {err}");
|
||||
@@ -906,6 +982,65 @@ pub(crate) fn plugin_namespace_for_skill_path(path: &Path) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
fn refresh_curated_plugin_cache(
|
||||
codex_home: &Path,
|
||||
plugin_version: &str,
|
||||
configured_curated_plugin_ids: &[PluginId],
|
||||
) -> Result<bool, String> {
|
||||
let store = PluginStore::new(codex_home.to_path_buf());
|
||||
let curated_marketplace_path = AbsolutePathBuf::try_from(
|
||||
curated_plugins_repo_path(codex_home).join(".agents/plugins/marketplace.json"),
|
||||
)
|
||||
.map_err(|_| "local curated marketplace is not available".to_string())?;
|
||||
let curated_marketplace = load_marketplace_summary(&curated_marketplace_path)
|
||||
.map_err(|err| format!("failed to load curated marketplace for cache refresh: {err}"))?;
|
||||
|
||||
let mut plugin_sources = HashMap::<String, AbsolutePathBuf>::new();
|
||||
for plugin in curated_marketplace.plugins {
|
||||
let plugin_name = plugin.name;
|
||||
if plugin_sources.contains_key(&plugin_name) {
|
||||
warn!(
|
||||
plugin = plugin_name,
|
||||
marketplace = OPENAI_CURATED_MARKETPLACE_NAME,
|
||||
"ignoring duplicate curated plugin entry during cache refresh"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let source_path = match plugin.source {
|
||||
MarketplacePluginSourceSummary::Local { path } => path,
|
||||
};
|
||||
plugin_sources.insert(plugin_name, source_path);
|
||||
}
|
||||
|
||||
let mut cache_refreshed = false;
|
||||
for plugin_id in configured_curated_plugin_ids {
|
||||
if store.active_plugin_version(plugin_id).as_deref() == Some(plugin_version) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(source_path) = plugin_sources.get(&plugin_id.plugin_name).cloned() else {
|
||||
warn!(
|
||||
plugin = plugin_id.plugin_name,
|
||||
marketplace = OPENAI_CURATED_MARKETPLACE_NAME,
|
||||
"configured curated plugin no longer exists in curated marketplace during cache refresh"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
store
|
||||
.install_with_version(source_path, plugin_id.clone(), plugin_version.to_string())
|
||||
.map_err(|err| {
|
||||
format!(
|
||||
"failed to refresh curated plugin cache for {}: {err}",
|
||||
plugin_id.as_key()
|
||||
)
|
||||
})?;
|
||||
cache_refreshed = true;
|
||||
}
|
||||
|
||||
Ok(cache_refreshed)
|
||||
}
|
||||
|
||||
fn configured_plugins_from_stack(
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
) -> HashMap<String, PluginConfig> {
|
||||
@@ -926,9 +1061,11 @@ fn configured_plugins_from_stack(
|
||||
}
|
||||
|
||||
fn load_plugin(config_name: String, plugin: &PluginConfig, store: &PluginStore) -> LoadedPlugin {
|
||||
let plugin_version = DEFAULT_PLUGIN_VERSION.to_string();
|
||||
let plugin_root = PluginId::parse(&config_name)
|
||||
.map(|plugin_id| store.plugin_root(&plugin_id, &plugin_version));
|
||||
let plugin_root = PluginId::parse(&config_name).map(|plugin_id| {
|
||||
store
|
||||
.active_plugin_root(&plugin_id)
|
||||
.unwrap_or_else(|| store.plugin_root(&plugin_id, DEFAULT_PLUGIN_VERSION))
|
||||
});
|
||||
let root = match &plugin_root {
|
||||
Ok(plugin_root) => plugin_root.clone(),
|
||||
Err(_) => store.root().clone(),
|
||||
@@ -1227,6 +1364,8 @@ mod tests {
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567";
|
||||
|
||||
fn write_file(path: &Path, contents: &str) {
|
||||
fs::create_dir_all(path.parent().expect("file should have a parent")).unwrap();
|
||||
fs::write(path, contents).unwrap();
|
||||
@@ -1246,7 +1385,6 @@ mod tests {
|
||||
}
|
||||
|
||||
fn write_openai_curated_marketplace(root: &Path, plugin_names: &[&str]) {
|
||||
fs::create_dir_all(root.join(".git")).unwrap();
|
||||
fs::create_dir_all(root.join(".agents/plugins")).unwrap();
|
||||
let plugins = plugin_names
|
||||
.iter()
|
||||
@@ -1280,6 +1418,10 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn write_curated_plugin_sha(codex_home: &Path, sha: &str) {
|
||||
write_file(&codex_home.join(".tmp/plugins.sha"), &format!("{sha}\n"));
|
||||
}
|
||||
|
||||
fn plugin_config_toml(enabled: bool, plugins_feature_enabled: bool) -> String {
|
||||
let mut root = toml::map::Map::new();
|
||||
|
||||
@@ -1972,7 +2114,7 @@ mod tests {
|
||||
plugin_id: PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap(),
|
||||
plugin_version: "local".to_string(),
|
||||
installed_path: AbsolutePathBuf::try_from(installed_path).unwrap(),
|
||||
auth_policy: Some(MarketplacePluginAuthPolicy::OnUse),
|
||||
auth_policy: MarketplacePluginAuthPolicy::OnUse,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -2102,8 +2244,8 @@ enabled = false
|
||||
path: AbsolutePathBuf::try_from(tmp.path().join("repo/enabled-plugin"))
|
||||
.unwrap(),
|
||||
},
|
||||
install_policy: None,
|
||||
auth_policy: None,
|
||||
install_policy: MarketplacePluginInstallPolicy::Available,
|
||||
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
||||
interface: None,
|
||||
installed: true,
|
||||
enabled: true,
|
||||
@@ -2117,8 +2259,8 @@ enabled = false
|
||||
)
|
||||
.unwrap(),
|
||||
},
|
||||
install_policy: None,
|
||||
auth_policy: None,
|
||||
install_policy: MarketplacePluginInstallPolicy::Available,
|
||||
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
||||
interface: None,
|
||||
installed: true,
|
||||
enabled: false,
|
||||
@@ -2134,7 +2276,6 @@ enabled = false
|
||||
let curated_root = curated_plugins_repo_path(tmp.path());
|
||||
let plugin_root = curated_root.join("plugins/linear");
|
||||
|
||||
fs::create_dir_all(curated_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(curated_root.join(".agents/plugins")).unwrap();
|
||||
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
|
||||
fs::write(
|
||||
@@ -2184,8 +2325,8 @@ enabled = false
|
||||
path: AbsolutePathBuf::try_from(curated_root.join("plugins/linear"))
|
||||
.unwrap(),
|
||||
},
|
||||
install_policy: None,
|
||||
auth_policy: None,
|
||||
install_policy: MarketplacePluginInstallPolicy::Available,
|
||||
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
||||
interface: None,
|
||||
installed: false,
|
||||
enabled: false,
|
||||
@@ -2284,8 +2425,8 @@ enabled = false
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
path: AbsolutePathBuf::try_from(tmp.path().join("repo-a/from-a")).unwrap(),
|
||||
},
|
||||
install_policy: None,
|
||||
auth_policy: None,
|
||||
install_policy: MarketplacePluginInstallPolicy::Available,
|
||||
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
||||
interface: None,
|
||||
installed: false,
|
||||
enabled: true,
|
||||
@@ -2310,8 +2451,8 @@ enabled = false
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
path: AbsolutePathBuf::try_from(tmp.path().join("repo-b/from-b-only")).unwrap(),
|
||||
},
|
||||
install_policy: None,
|
||||
auth_policy: None,
|
||||
install_policy: MarketplacePluginInstallPolicy::Available,
|
||||
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
||||
interface: None,
|
||||
installed: false,
|
||||
enabled: false,
|
||||
@@ -2389,8 +2530,8 @@ enabled = true
|
||||
path: AbsolutePathBuf::try_from(tmp.path().join("repo/sample-plugin"))
|
||||
.unwrap(),
|
||||
},
|
||||
install_policy: None,
|
||||
auth_policy: None,
|
||||
install_policy: MarketplacePluginInstallPolicy::Available,
|
||||
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
||||
interface: None,
|
||||
installed: false,
|
||||
enabled: true,
|
||||
@@ -2404,6 +2545,7 @@ enabled = true
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let curated_root = curated_plugins_repo_path(tmp.path());
|
||||
write_openai_curated_marketplace(&curated_root, &["linear", "gmail", "calendar"]);
|
||||
write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA);
|
||||
write_plugin(
|
||||
&tmp.path().join("plugins/cache/openai-curated"),
|
||||
"linear/local",
|
||||
@@ -2469,7 +2611,9 @@ enabled = true
|
||||
);
|
||||
assert!(
|
||||
tmp.path()
|
||||
.join("plugins/cache/openai-curated/gmail/local")
|
||||
.join(format!(
|
||||
"plugins/cache/openai-curated/gmail/{TEST_CURATED_PLUGIN_SHA}"
|
||||
))
|
||||
.is_dir()
|
||||
);
|
||||
assert!(
|
||||
@@ -2511,6 +2655,7 @@ enabled = true
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let curated_root = curated_plugins_repo_path(tmp.path());
|
||||
write_openai_curated_marketplace(&curated_root, &["linear"]);
|
||||
write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA);
|
||||
write_file(
|
||||
&tmp.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
@@ -2566,6 +2711,7 @@ enabled = false
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let curated_root = curated_plugins_repo_path(tmp.path());
|
||||
write_openai_curated_marketplace(&curated_root, &["linear", "gmail"]);
|
||||
write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA);
|
||||
fs::remove_dir_all(curated_root.join("plugins/gmail")).unwrap();
|
||||
write_plugin(
|
||||
&tmp.path().join("plugins/cache/openai-curated"),
|
||||
@@ -2630,7 +2776,7 @@ enabled = false
|
||||
async fn sync_plugins_from_remote_uses_first_duplicate_local_plugin_entry() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let curated_root = curated_plugins_repo_path(tmp.path());
|
||||
fs::create_dir_all(curated_root.join(".git")).unwrap();
|
||||
write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA);
|
||||
fs::create_dir_all(curated_root.join(".agents/plugins")).unwrap();
|
||||
fs::write(
|
||||
curated_root.join(".agents/plugins/marketplace.json"),
|
||||
@@ -2702,15 +2848,98 @@ plugins = true
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_to_string(
|
||||
tmp.path()
|
||||
.join("plugins/cache/openai-curated/gmail/local/marker.txt")
|
||||
)
|
||||
fs::read_to_string(tmp.path().join(format!(
|
||||
"plugins/cache/openai-curated/gmail/{TEST_CURATED_PLUGIN_SHA}/marker.txt"
|
||||
)))
|
||||
.unwrap(),
|
||||
"first"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_curated_plugin_cache_replaces_existing_local_version_with_sha() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let curated_root = curated_plugins_repo_path(tmp.path());
|
||||
write_openai_curated_marketplace(&curated_root, &["slack"]);
|
||||
write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA);
|
||||
let plugin_id = PluginId::new(
|
||||
"slack".to_string(),
|
||||
OPENAI_CURATED_MARKETPLACE_NAME.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
write_plugin(
|
||||
&tmp.path().join("plugins/cache/openai-curated"),
|
||||
"slack/local",
|
||||
"slack",
|
||||
);
|
||||
|
||||
assert!(
|
||||
refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id])
|
||||
.expect("cache refresh should succeed")
|
||||
);
|
||||
|
||||
assert!(
|
||||
!tmp.path()
|
||||
.join("plugins/cache/openai-curated/slack/local")
|
||||
.exists()
|
||||
);
|
||||
assert!(
|
||||
tmp.path()
|
||||
.join(format!(
|
||||
"plugins/cache/openai-curated/slack/{TEST_CURATED_PLUGIN_SHA}"
|
||||
))
|
||||
.is_dir()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_curated_plugin_cache_reinstalls_missing_configured_plugin_with_current_sha() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let curated_root = curated_plugins_repo_path(tmp.path());
|
||||
write_openai_curated_marketplace(&curated_root, &["slack"]);
|
||||
write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA);
|
||||
let plugin_id = PluginId::new(
|
||||
"slack".to_string(),
|
||||
OPENAI_CURATED_MARKETPLACE_NAME.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(
|
||||
refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id])
|
||||
.expect("cache refresh should recreate missing configured plugin")
|
||||
);
|
||||
|
||||
assert!(
|
||||
tmp.path()
|
||||
.join(format!(
|
||||
"plugins/cache/openai-curated/slack/{TEST_CURATED_PLUGIN_SHA}"
|
||||
))
|
||||
.is_dir()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_curated_plugin_cache_returns_false_when_configured_plugins_are_current() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let curated_root = curated_plugins_repo_path(tmp.path());
|
||||
write_openai_curated_marketplace(&curated_root, &["slack"]);
|
||||
let plugin_id = PluginId::new(
|
||||
"slack".to_string(),
|
||||
OPENAI_CURATED_MARKETPLACE_NAME.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
write_plugin(
|
||||
&tmp.path().join("plugins/cache/openai-curated"),
|
||||
&format!("slack/{TEST_CURATED_PLUGIN_SHA}"),
|
||||
"slack",
|
||||
);
|
||||
|
||||
assert!(
|
||||
!refresh_curated_plugin_cache(tmp.path(), TEST_CURATED_PLUGIN_SHA, &[plugin_id])
|
||||
.expect("cache refresh should be a no-op when configured plugins are current")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_plugins_ignores_project_config_files() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
|
||||
@@ -21,7 +21,7 @@ const MARKETPLACE_RELATIVE_PATH: &str = ".agents/plugins/marketplace.json";
|
||||
pub struct ResolvedMarketplacePlugin {
|
||||
pub plugin_id: PluginId,
|
||||
pub source_path: AbsolutePathBuf,
|
||||
pub auth_policy: Option<MarketplacePluginAuthPolicy>,
|
||||
pub auth_policy: MarketplacePluginAuthPolicy,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -35,8 +35,8 @@ pub struct MarketplaceSummary {
|
||||
pub struct MarketplacePluginSummary {
|
||||
pub name: String,
|
||||
pub source: MarketplacePluginSourceSummary,
|
||||
pub install_policy: Option<MarketplacePluginInstallPolicy>,
|
||||
pub auth_policy: Option<MarketplacePluginAuthPolicy>,
|
||||
pub install_policy: MarketplacePluginInstallPolicy,
|
||||
pub auth_policy: MarketplacePluginAuthPolicy,
|
||||
pub interface: Option<PluginManifestInterfaceSummary>,
|
||||
}
|
||||
|
||||
@@ -45,18 +45,20 @@ pub enum MarketplacePluginSourceSummary {
|
||||
Local { path: AbsolutePathBuf },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
|
||||
pub enum MarketplacePluginInstallPolicy {
|
||||
#[serde(rename = "NOT_AVAILABLE")]
|
||||
NotAvailable,
|
||||
#[default]
|
||||
#[serde(rename = "AVAILABLE")]
|
||||
Available,
|
||||
#[serde(rename = "INSTALLED_BY_DEFAULT")]
|
||||
InstalledByDefault,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
|
||||
pub enum MarketplacePluginAuthPolicy {
|
||||
#[default]
|
||||
#[serde(rename = "ON_INSTALL")]
|
||||
OnInstall,
|
||||
#[serde(rename = "ON_USE")]
|
||||
@@ -148,7 +150,7 @@ pub fn resolve_marketplace_plugin(
|
||||
auth_policy,
|
||||
..
|
||||
} = plugin;
|
||||
if install_policy == Some(MarketplacePluginInstallPolicy::NotAvailable) {
|
||||
if install_policy == MarketplacePluginInstallPolicy::NotAvailable {
|
||||
return Err(MarketplaceError::PluginNotAvailable {
|
||||
plugin_name: name,
|
||||
marketplace_name,
|
||||
@@ -243,6 +245,15 @@ fn discover_marketplace_paths_from_roots(
|
||||
}
|
||||
|
||||
for root in additional_roots {
|
||||
// Curated marketplaces can now come from an HTTP-downloaded directory that is not a git
|
||||
// checkout, so check the root directly before falling back to repo-root discovery.
|
||||
if let Ok(path) = root.join(MARKETPLACE_RELATIVE_PATH)
|
||||
&& path.as_path().is_file()
|
||||
&& !paths.contains(&path)
|
||||
{
|
||||
paths.push(path);
|
||||
continue;
|
||||
}
|
||||
if let Some(repo_root) = get_git_repo_root(root.as_path())
|
||||
&& let Ok(repo_root) = AbsolutePathBuf::try_from(repo_root)
|
||||
&& let Ok(path) = repo_root.join(MARKETPLACE_RELATIVE_PATH)
|
||||
@@ -365,9 +376,9 @@ struct MarketplacePlugin {
|
||||
name: String,
|
||||
source: MarketplacePluginSource,
|
||||
#[serde(default)]
|
||||
install_policy: Option<MarketplacePluginInstallPolicy>,
|
||||
install_policy: MarketplacePluginInstallPolicy,
|
||||
#[serde(default)]
|
||||
auth_policy: Option<MarketplacePluginAuthPolicy>,
|
||||
auth_policy: MarketplacePluginAuthPolicy,
|
||||
#[serde(default)]
|
||||
category: Option<String>,
|
||||
}
|
||||
@@ -420,7 +431,7 @@ mod tests {
|
||||
plugin_id: PluginId::new("local-plugin".to_string(), "codex-curated".to_string())
|
||||
.unwrap(),
|
||||
source_path: AbsolutePathBuf::try_from(repo_root.join("plugin-1")).unwrap(),
|
||||
auth_policy: None,
|
||||
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -527,8 +538,8 @@ mod tests {
|
||||
path: AbsolutePathBuf::try_from(home_root.join("home-shared"))
|
||||
.unwrap(),
|
||||
},
|
||||
install_policy: None,
|
||||
auth_policy: None,
|
||||
install_policy: MarketplacePluginInstallPolicy::Available,
|
||||
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
||||
interface: None,
|
||||
},
|
||||
MarketplacePluginSummary {
|
||||
@@ -537,8 +548,8 @@ mod tests {
|
||||
path: AbsolutePathBuf::try_from(home_root.join("home-only"))
|
||||
.unwrap(),
|
||||
},
|
||||
install_policy: None,
|
||||
auth_policy: None,
|
||||
install_policy: MarketplacePluginInstallPolicy::Available,
|
||||
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
||||
interface: None,
|
||||
},
|
||||
],
|
||||
@@ -556,8 +567,8 @@ mod tests {
|
||||
path: AbsolutePathBuf::try_from(repo_root.join("repo-shared"))
|
||||
.unwrap(),
|
||||
},
|
||||
install_policy: None,
|
||||
auth_policy: None,
|
||||
install_policy: MarketplacePluginInstallPolicy::Available,
|
||||
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
||||
interface: None,
|
||||
},
|
||||
MarketplacePluginSummary {
|
||||
@@ -566,8 +577,8 @@ mod tests {
|
||||
path: AbsolutePathBuf::try_from(repo_root.join("repo-only"))
|
||||
.unwrap(),
|
||||
},
|
||||
install_policy: None,
|
||||
auth_policy: None,
|
||||
install_policy: MarketplacePluginInstallPolicy::Available,
|
||||
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
||||
interface: None,
|
||||
},
|
||||
],
|
||||
@@ -638,8 +649,8 @@ mod tests {
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
path: AbsolutePathBuf::try_from(home_root.join("home-plugin")).unwrap(),
|
||||
},
|
||||
install_policy: None,
|
||||
auth_policy: None,
|
||||
install_policy: MarketplacePluginInstallPolicy::Available,
|
||||
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
||||
interface: None,
|
||||
}],
|
||||
},
|
||||
@@ -651,8 +662,8 @@ mod tests {
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
path: AbsolutePathBuf::try_from(repo_root.join("repo-plugin")).unwrap(),
|
||||
},
|
||||
install_policy: None,
|
||||
auth_policy: None,
|
||||
install_policy: MarketplacePluginInstallPolicy::Available,
|
||||
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
||||
interface: None,
|
||||
}],
|
||||
},
|
||||
@@ -717,8 +728,8 @@ mod tests {
|
||||
source: MarketplacePluginSourceSummary::Local {
|
||||
path: AbsolutePathBuf::try_from(repo_root.join("plugin")).unwrap(),
|
||||
},
|
||||
install_policy: None,
|
||||
auth_policy: None,
|
||||
install_policy: MarketplacePluginInstallPolicy::Available,
|
||||
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
||||
interface: None,
|
||||
}],
|
||||
}]
|
||||
@@ -774,11 +785,11 @@ mod tests {
|
||||
|
||||
assert_eq!(
|
||||
marketplaces[0].plugins[0].install_policy,
|
||||
Some(MarketplacePluginInstallPolicy::Available)
|
||||
MarketplacePluginInstallPolicy::Available
|
||||
);
|
||||
assert_eq!(
|
||||
marketplaces[0].plugins[0].auth_policy,
|
||||
Some(MarketplacePluginAuthPolicy::OnInstall)
|
||||
MarketplacePluginAuthPolicy::OnInstall
|
||||
);
|
||||
assert_eq!(
|
||||
marketplaces[0].plugins[0].interface,
|
||||
@@ -868,8 +879,14 @@ mod tests {
|
||||
screenshots: Vec::new(),
|
||||
})
|
||||
);
|
||||
assert_eq!(marketplaces[0].plugins[0].install_policy, None);
|
||||
assert_eq!(marketplaces[0].plugins[0].auth_policy, None);
|
||||
assert_eq!(
|
||||
marketplaces[0].plugins[0].install_policy,
|
||||
MarketplacePluginInstallPolicy::Available
|
||||
);
|
||||
assert_eq!(
|
||||
marketplaces[0].plugins[0].auth_policy,
|
||||
MarketplacePluginAuthPolicy::OnInstall
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -7,6 +7,7 @@ mod render;
|
||||
mod store;
|
||||
|
||||
pub(crate) use curated_repo::curated_plugins_repo_path;
|
||||
pub(crate) use curated_repo::read_curated_plugins_sha;
|
||||
pub(crate) use curated_repo::sync_openai_plugins_repo;
|
||||
pub(crate) use injection::build_plugin_injections;
|
||||
pub use manager::AppConnectorId;
|
||||
|
||||
@@ -81,27 +81,65 @@ impl PluginStore {
|
||||
&self.root
|
||||
}
|
||||
|
||||
pub fn plugin_root(&self, plugin_id: &PluginId, plugin_version: &str) -> AbsolutePathBuf {
|
||||
pub fn plugin_base_root(&self, plugin_id: &PluginId) -> AbsolutePathBuf {
|
||||
AbsolutePathBuf::try_from(
|
||||
self.root
|
||||
.as_path()
|
||||
.join(&plugin_id.marketplace_name)
|
||||
.join(&plugin_id.plugin_name)
|
||||
.join(&plugin_id.plugin_name),
|
||||
)
|
||||
.unwrap_or_else(|err| panic!("plugin cache path should resolve to an absolute path: {err}"))
|
||||
}
|
||||
|
||||
pub fn plugin_root(&self, plugin_id: &PluginId, plugin_version: &str) -> AbsolutePathBuf {
|
||||
AbsolutePathBuf::try_from(
|
||||
self.plugin_base_root(plugin_id)
|
||||
.as_path()
|
||||
.join(plugin_version),
|
||||
)
|
||||
.unwrap_or_else(|err| panic!("plugin cache path should resolve to an absolute path: {err}"))
|
||||
}
|
||||
|
||||
pub fn active_plugin_version(&self, plugin_id: &PluginId) -> Option<String> {
|
||||
let mut discovered_versions = fs::read_dir(self.plugin_base_root(plugin_id).as_path())
|
||||
.ok()?
|
||||
.filter_map(Result::ok)
|
||||
.filter_map(|entry| {
|
||||
entry.file_type().ok().filter(std::fs::FileType::is_dir)?;
|
||||
entry.file_name().into_string().ok()
|
||||
})
|
||||
.filter(|version| validate_plugin_segment(version, "plugin version").is_ok())
|
||||
.collect::<Vec<_>>();
|
||||
discovered_versions.sort_unstable();
|
||||
if discovered_versions.len() == 1 {
|
||||
discovered_versions.pop()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn active_plugin_root(&self, plugin_id: &PluginId) -> Option<AbsolutePathBuf> {
|
||||
self.active_plugin_version(plugin_id)
|
||||
.map(|plugin_version| self.plugin_root(plugin_id, &plugin_version))
|
||||
}
|
||||
|
||||
pub fn is_installed(&self, plugin_id: &PluginId) -> bool {
|
||||
self.plugin_root(plugin_id, DEFAULT_PLUGIN_VERSION)
|
||||
.as_path()
|
||||
.is_dir()
|
||||
self.active_plugin_version(plugin_id).is_some()
|
||||
}
|
||||
|
||||
pub fn install(
|
||||
&self,
|
||||
source_path: AbsolutePathBuf,
|
||||
plugin_id: PluginId,
|
||||
) -> Result<PluginInstallResult, PluginStoreError> {
|
||||
self.install_with_version(source_path, plugin_id, DEFAULT_PLUGIN_VERSION.to_string())
|
||||
}
|
||||
|
||||
pub fn install_with_version(
|
||||
&self,
|
||||
source_path: AbsolutePathBuf,
|
||||
plugin_id: PluginId,
|
||||
plugin_version: String,
|
||||
) -> Result<PluginInstallResult, PluginStoreError> {
|
||||
if !source_path.as_path().is_dir() {
|
||||
return Err(PluginStoreError::Invalid(format!(
|
||||
@@ -117,17 +155,14 @@ impl PluginStore {
|
||||
plugin_id.plugin_name
|
||||
)));
|
||||
}
|
||||
let plugin_version = DEFAULT_PLUGIN_VERSION.to_string();
|
||||
validate_plugin_segment(&plugin_version, "plugin version")
|
||||
.map_err(PluginStoreError::Invalid)?;
|
||||
let installed_path = self.plugin_root(&plugin_id, &plugin_version);
|
||||
|
||||
if let Some(parent) = installed_path.parent() {
|
||||
fs::create_dir_all(parent.as_path()).map_err(|err| {
|
||||
PluginStoreError::io("failed to create plugin cache directory", err)
|
||||
})?;
|
||||
}
|
||||
|
||||
remove_existing_target(installed_path.as_path())?;
|
||||
copy_dir_recursive(source_path.as_path(), installed_path.as_path())?;
|
||||
replace_plugin_root_atomically(
|
||||
source_path.as_path(),
|
||||
self.plugin_base_root(&plugin_id).as_path(),
|
||||
&plugin_version,
|
||||
)?;
|
||||
|
||||
Ok(PluginInstallResult {
|
||||
plugin_id,
|
||||
@@ -137,12 +172,7 @@ impl PluginStore {
|
||||
}
|
||||
|
||||
pub fn uninstall(&self, plugin_id: &PluginId) -> Result<(), PluginStoreError> {
|
||||
let plugin_path = self
|
||||
.root
|
||||
.as_path()
|
||||
.join(&plugin_id.marketplace_name)
|
||||
.join(&plugin_id.plugin_name);
|
||||
remove_existing_target(&plugin_path)
|
||||
remove_existing_target(self.plugin_base_root(plugin_id).as_path())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,6 +248,73 @@ fn remove_existing_target(path: &Path) -> Result<(), PluginStoreError> {
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_plugin_root_atomically(
|
||||
source: &Path,
|
||||
target_root: &Path,
|
||||
plugin_version: &str,
|
||||
) -> Result<(), PluginStoreError> {
|
||||
let Some(parent) = target_root.parent() else {
|
||||
return Err(PluginStoreError::Invalid(format!(
|
||||
"plugin cache path has no parent: {}",
|
||||
target_root.display()
|
||||
)));
|
||||
};
|
||||
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|err| PluginStoreError::io("failed to create plugin cache directory", err))?;
|
||||
|
||||
let Some(plugin_dir_name) = target_root.file_name() else {
|
||||
return Err(PluginStoreError::Invalid(format!(
|
||||
"plugin cache path has no directory name: {}",
|
||||
target_root.display()
|
||||
)));
|
||||
};
|
||||
let staged_dir = tempfile::Builder::new()
|
||||
.prefix("plugin-install-")
|
||||
.tempdir_in(parent)
|
||||
.map_err(|err| {
|
||||
PluginStoreError::io("failed to create temporary plugin cache directory", err)
|
||||
})?;
|
||||
let staged_root = staged_dir.path().join(plugin_dir_name);
|
||||
let staged_version_root = staged_root.join(plugin_version);
|
||||
copy_dir_recursive(source, &staged_version_root)?;
|
||||
|
||||
if target_root.exists() {
|
||||
let backup_dir = tempfile::Builder::new()
|
||||
.prefix("plugin-backup-")
|
||||
.tempdir_in(parent)
|
||||
.map_err(|err| {
|
||||
PluginStoreError::io("failed to create plugin cache backup directory", err)
|
||||
})?;
|
||||
let backup_root = backup_dir.path().join(plugin_dir_name);
|
||||
fs::rename(target_root, &backup_root)
|
||||
.map_err(|err| PluginStoreError::io("failed to back up plugin cache entry", err))?;
|
||||
|
||||
if let Err(err) = fs::rename(&staged_root, target_root) {
|
||||
let rollback_result = fs::rename(&backup_root, target_root);
|
||||
return match rollback_result {
|
||||
Ok(()) => Err(PluginStoreError::io(
|
||||
"failed to activate updated plugin cache entry",
|
||||
err,
|
||||
)),
|
||||
Err(rollback_err) => {
|
||||
let backup_path = backup_dir.keep().join(plugin_dir_name);
|
||||
Err(PluginStoreError::Invalid(format!(
|
||||
"failed to activate updated plugin cache entry at {}: {err}; failed to restore previous cache entry (left at {}): {rollback_err}",
|
||||
target_root.display(),
|
||||
backup_path.display()
|
||||
)))
|
||||
}
|
||||
};
|
||||
}
|
||||
} else {
|
||||
fs::rename(&staged_root, target_root)
|
||||
.map_err(|err| PluginStoreError::io("failed to activate plugin cache entry", err))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_dir_recursive(source: &Path, target: &Path) -> Result<(), PluginStoreError> {
|
||||
fs::create_dir_all(target)
|
||||
.map_err(|err| PluginStoreError::io("failed to create plugin target directory", err))?;
|
||||
@@ -327,6 +424,57 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_with_version_uses_requested_cache_version() {
|
||||
let tmp = tempdir().unwrap();
|
||||
write_plugin(tmp.path(), "sample-plugin", "sample-plugin");
|
||||
let plugin_id =
|
||||
PluginId::new("sample-plugin".to_string(), "openai-curated".to_string()).unwrap();
|
||||
let plugin_version = "0123456789abcdef".to_string();
|
||||
|
||||
let result = PluginStore::new(tmp.path().to_path_buf())
|
||||
.install_with_version(
|
||||
AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(),
|
||||
plugin_id.clone(),
|
||||
plugin_version.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let installed_path = tmp.path().join(format!(
|
||||
"plugins/cache/openai-curated/sample-plugin/{plugin_version}"
|
||||
));
|
||||
assert_eq!(
|
||||
result,
|
||||
PluginInstallResult {
|
||||
plugin_id,
|
||||
plugin_version,
|
||||
installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(),
|
||||
}
|
||||
);
|
||||
assert!(installed_path.join(".codex-plugin/plugin.json").is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_plugin_version_reads_version_directory_name() {
|
||||
let tmp = tempdir().unwrap();
|
||||
write_plugin(
|
||||
&tmp.path().join("plugins/cache/debug"),
|
||||
"sample-plugin/local",
|
||||
"sample-plugin",
|
||||
);
|
||||
let store = PluginStore::new(tmp.path().to_path_buf());
|
||||
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
store.active_plugin_version(&plugin_id),
|
||||
Some("local".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
store.active_plugin_root(&plugin_id).unwrap().as_path(),
|
||||
tmp.path().join("plugins/cache/debug/sample-plugin/local")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_root_rejects_path_separators_in_key_segments() {
|
||||
let err = PluginId::parse("../../etc@debug").unwrap_err();
|
||||
|
||||
@@ -6,7 +6,7 @@ use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
pub(crate) fn sandbox_tag(
|
||||
policy: &SandboxPolicy,
|
||||
windows_sandbox_level: WindowsSandboxLevel,
|
||||
use_linux_sandbox_bwrap: bool,
|
||||
use_legacy_landlock: bool,
|
||||
) -> &'static str {
|
||||
if matches!(policy, SandboxPolicy::DangerFullAccess) {
|
||||
return "none";
|
||||
@@ -18,7 +18,7 @@ pub(crate) fn sandbox_tag(
|
||||
{
|
||||
return "windows_elevated";
|
||||
}
|
||||
if cfg!(target_os = "linux") && use_linux_sandbox_bwrap {
|
||||
if cfg!(target_os = "linux") && !use_legacy_landlock {
|
||||
return "linux_bubblewrap";
|
||||
}
|
||||
|
||||
@@ -38,33 +38,33 @@ mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn danger_full_access_is_untagged_even_when_bubblewrap_is_enabled() {
|
||||
fn danger_full_access_is_untagged_even_when_bubblewrap_is_default() {
|
||||
let actual = sandbox_tag(
|
||||
&SandboxPolicy::DangerFullAccess,
|
||||
WindowsSandboxLevel::Disabled,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
assert_eq!(actual, "none");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_sandbox_keeps_external_tag_when_bubblewrap_is_enabled() {
|
||||
fn external_sandbox_keeps_external_tag_when_bubblewrap_is_default() {
|
||||
let actual = sandbox_tag(
|
||||
&SandboxPolicy::ExternalSandbox {
|
||||
network_access: NetworkAccess::Enabled,
|
||||
},
|
||||
WindowsSandboxLevel::Disabled,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
assert_eq!(actual, "external");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bubblewrap_feature_sets_distinct_linux_tag() {
|
||||
fn bubblewrap_default_sets_distinct_linux_tag() {
|
||||
let actual = sandbox_tag(
|
||||
&SandboxPolicy::new_read_only_policy(),
|
||||
WindowsSandboxLevel::Disabled,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
let expected = if cfg!(target_os = "linux") {
|
||||
"linux_bubblewrap"
|
||||
|
||||
@@ -94,7 +94,7 @@ pub(crate) struct SandboxTransformRequest<'a> {
|
||||
#[cfg(target_os = "macos")]
|
||||
pub macos_seatbelt_profile_extensions: Option<&'a MacOsSeatbeltProfileExtensions>,
|
||||
pub codex_linux_sandbox_exe: Option<&'a PathBuf>,
|
||||
pub use_linux_sandbox_bwrap: bool,
|
||||
pub use_legacy_landlock: bool,
|
||||
pub windows_sandbox_level: WindowsSandboxLevel,
|
||||
}
|
||||
|
||||
@@ -571,7 +571,7 @@ impl SandboxManager {
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_seatbelt_profile_extensions,
|
||||
codex_linux_sandbox_exe,
|
||||
use_linux_sandbox_bwrap,
|
||||
use_legacy_landlock,
|
||||
windows_sandbox_level,
|
||||
} = request;
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
@@ -653,7 +653,7 @@ impl SandboxManager {
|
||||
&effective_file_system_policy,
|
||||
effective_network_policy,
|
||||
sandbox_policy_cwd,
|
||||
use_linux_sandbox_bwrap,
|
||||
use_legacy_landlock,
|
||||
allow_proxy_network,
|
||||
);
|
||||
let mut full_command = Vec::with_capacity(1 + args.len());
|
||||
@@ -886,7 +886,7 @@ mod tests {
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_seatbelt_profile_extensions: None,
|
||||
codex_linux_sandbox_exe: None,
|
||||
use_linux_sandbox_bwrap: false,
|
||||
use_legacy_landlock: false,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
})
|
||||
.expect("transform");
|
||||
@@ -1219,7 +1219,7 @@ mod tests {
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_seatbelt_profile_extensions: None,
|
||||
codex_linux_sandbox_exe: None,
|
||||
use_linux_sandbox_bwrap: false,
|
||||
use_legacy_landlock: false,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
})
|
||||
.expect("transform");
|
||||
@@ -1291,7 +1291,7 @@ mod tests {
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_seatbelt_profile_extensions: None,
|
||||
codex_linux_sandbox_exe: None,
|
||||
use_linux_sandbox_bwrap: false,
|
||||
use_legacy_landlock: false,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
})
|
||||
.expect("transform");
|
||||
|
||||
@@ -15,6 +15,7 @@ use crate::models_manager::manager::ModelsManager;
|
||||
use crate::plugins::PluginsManager;
|
||||
use crate::skills::SkillsManager;
|
||||
use crate::state_db::StateDbHandle;
|
||||
use crate::tools::code_mode::CodeModeService;
|
||||
use crate::tools::network_approval::NetworkApprovalService;
|
||||
use crate::tools::runtimes::ExecveSessionApproval;
|
||||
use crate::tools::sandboxing::ApprovalStore;
|
||||
@@ -22,35 +23,12 @@ use crate::unified_exec::UnifiedExecProcessManager;
|
||||
use codex_hooks::Hooks;
|
||||
use codex_otel::SessionTelemetry;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::path::PathBuf;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::RwLock;
|
||||
use tokio::sync::watch;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
pub(crate) struct CodeModeStoreService {
|
||||
stored_values: Mutex<HashMap<String, JsonValue>>,
|
||||
}
|
||||
|
||||
impl Default for CodeModeStoreService {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
stored_values: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CodeModeStoreService {
|
||||
pub(crate) async fn stored_values(&self) -> HashMap<String, JsonValue> {
|
||||
self.stored_values.lock().await.clone()
|
||||
}
|
||||
|
||||
pub(crate) async fn replace_stored_values(&self, values: HashMap<String, JsonValue>) {
|
||||
*self.stored_values.lock().await = values;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct SessionServices {
|
||||
pub(crate) mcp_connection_manager: Arc<RwLock<McpConnectionManager>>,
|
||||
pub(crate) mcp_startup_cancellation_token: Mutex<CancellationToken>,
|
||||
@@ -82,5 +60,5 @@ pub(crate) struct SessionServices {
|
||||
pub(crate) state_db: Option<StateDbHandle>,
|
||||
/// Session-scoped model client shared across turns.
|
||||
pub(crate) model_client: ModelClient,
|
||||
pub(crate) code_mode_store: CodeModeStoreService,
|
||||
pub(crate) code_mode_service: CodeModeService,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::VecDeque;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
@@ -6,7 +8,6 @@ use crate::client_common::tools::ToolSpec;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::config::Config;
|
||||
use crate::exec_env::create_env;
|
||||
use crate::features::Feature;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::tools::ToolRouter;
|
||||
@@ -18,6 +19,7 @@ use crate::tools::context::ToolPayload;
|
||||
use crate::tools::js_repl::resolve_compatible_node;
|
||||
use crate::tools::router::ToolCall;
|
||||
use crate::tools::router::ToolCallSource;
|
||||
use crate::tools::router::ToolRouterParams;
|
||||
use crate::truncate::TruncationPolicy;
|
||||
use crate::truncate::formatted_truncate_text_content_items_with_policy;
|
||||
use crate::truncate::truncate_function_output_items_with_policy;
|
||||
@@ -30,10 +32,15 @@ use tokio::io::AsyncBufReadExt;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::io::BufReader;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::warn;
|
||||
|
||||
const CODE_MODE_RUNNER_SOURCE: &str = include_str!("code_mode_runner.cjs");
|
||||
const CODE_MODE_BRIDGE_SOURCE: &str = include_str!("code_mode_bridge.js");
|
||||
pub(crate) const PUBLIC_TOOL_NAME: &str = "exec";
|
||||
pub(crate) const WAIT_TOOL_NAME: &str = "exec_wait";
|
||||
pub(crate) const DEFAULT_WAIT_YIELD_TIME_MS: u64 = 10_000;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ExecContext {
|
||||
@@ -42,6 +49,133 @@ struct ExecContext {
|
||||
tracker: SharedTurnDiffTracker,
|
||||
}
|
||||
|
||||
pub(crate) struct CodeModeProcess {
|
||||
child: tokio::process::Child,
|
||||
stdin: tokio::process::ChildStdin,
|
||||
stdout_lines: tokio::io::Lines<BufReader<tokio::process::ChildStdout>>,
|
||||
stderr_task: Option<JoinHandle<()>>,
|
||||
pending_messages: HashMap<i32, VecDeque<NodeToHostMessage>>,
|
||||
}
|
||||
|
||||
impl CodeModeProcess {
|
||||
async fn write(&mut self, message: &HostToNodeMessage) -> Result<(), std::io::Error> {
|
||||
let line = serde_json::to_string(message).map_err(std::io::Error::other)?;
|
||||
self.stdin.write_all(line.as_bytes()).await?;
|
||||
self.stdin.write_all(b"\n").await?;
|
||||
self.stdin.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read(&mut self, session_id: i32) -> Result<NodeToHostMessage, std::io::Error> {
|
||||
if let Some(message) = self
|
||||
.pending_messages
|
||||
.get_mut(&session_id)
|
||||
.and_then(VecDeque::pop_front)
|
||||
{
|
||||
return Ok(message);
|
||||
}
|
||||
|
||||
loop {
|
||||
let Some(line) = self.stdout_lines.next_line().await? else {
|
||||
match self.wait_for_exit().await {
|
||||
Ok(status) => {
|
||||
self.join_stderr_task().await;
|
||||
return Err(std::io::Error::other(format!(
|
||||
"{PUBLIC_TOOL_NAME} runner exited without returning a result (status {status})"
|
||||
)));
|
||||
}
|
||||
Err(err) => return Err(std::io::Error::other(err)),
|
||||
}
|
||||
};
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let message: NodeToHostMessage =
|
||||
serde_json::from_str(&line).map_err(std::io::Error::other)?;
|
||||
let message_session_id = message_session_id(&message);
|
||||
if message_session_id == session_id {
|
||||
return Ok(message);
|
||||
}
|
||||
self.pending_messages
|
||||
.entry(message_session_id)
|
||||
.or_default()
|
||||
.push_back(message);
|
||||
}
|
||||
}
|
||||
|
||||
fn has_exited(&mut self) -> Result<bool, String> {
|
||||
self.child
|
||||
.try_wait()
|
||||
.map(|status| status.is_some())
|
||||
.map_err(|err| format!("failed to inspect {PUBLIC_TOOL_NAME} runner: {err}"))
|
||||
}
|
||||
|
||||
async fn wait_for_exit(&mut self) -> Result<std::process::ExitStatus, String> {
|
||||
self.child
|
||||
.wait()
|
||||
.await
|
||||
.map_err(|err| format!("failed to wait for {PUBLIC_TOOL_NAME} runner: {err}"))
|
||||
}
|
||||
|
||||
async fn join_stderr_task(&mut self) {
|
||||
let Some(stderr_task) = self.stderr_task.take() else {
|
||||
return;
|
||||
};
|
||||
if let Err(err) = stderr_task.await {
|
||||
warn!("failed to join {PUBLIC_TOOL_NAME} stderr task: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct CodeModeService {
|
||||
js_repl_node_path: Option<PathBuf>,
|
||||
stored_values: Mutex<HashMap<String, JsonValue>>,
|
||||
process: Arc<Mutex<Option<CodeModeProcess>>>,
|
||||
next_session_id: Mutex<i32>,
|
||||
}
|
||||
|
||||
impl CodeModeService {
|
||||
pub(crate) fn new(js_repl_node_path: Option<PathBuf>) -> Self {
|
||||
Self {
|
||||
js_repl_node_path,
|
||||
stored_values: Mutex::new(HashMap::new()),
|
||||
process: Arc::new(Mutex::new(None)),
|
||||
next_session_id: Mutex::new(1),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn stored_values(&self) -> HashMap<String, JsonValue> {
|
||||
self.stored_values.lock().await.clone()
|
||||
}
|
||||
|
||||
pub(crate) async fn replace_stored_values(&self, values: HashMap<String, JsonValue>) {
|
||||
*self.stored_values.lock().await = values;
|
||||
}
|
||||
|
||||
async fn ensure_started(
|
||||
&self,
|
||||
) -> Result<tokio::sync::OwnedMutexGuard<Option<CodeModeProcess>>, String> {
|
||||
let mut process_slot = self.process.lock().await;
|
||||
let needs_spawn = match process_slot.as_mut() {
|
||||
Some(process) => !matches!(process.has_exited(), Ok(false)),
|
||||
None => true,
|
||||
};
|
||||
if needs_spawn {
|
||||
let node_path = resolve_compatible_node(self.js_repl_node_path.as_deref()).await?;
|
||||
*process_slot = Some(spawn_code_mode_process(&node_path).await?);
|
||||
}
|
||||
drop(process_slot);
|
||||
Ok(self.process.clone().lock_owned().await)
|
||||
}
|
||||
|
||||
pub(crate) async fn allocate_session_id(&self) -> i32 {
|
||||
let mut next_session_id = self.next_session_id.lock().await;
|
||||
let session_id = *next_session_id;
|
||||
*next_session_id = next_session_id.saturating_add(1);
|
||||
session_id
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
enum CodeModeToolKind {
|
||||
@@ -63,12 +197,21 @@ struct EnabledTool {
|
||||
#[derive(Serialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum HostToNodeMessage {
|
||||
Init {
|
||||
Start {
|
||||
session_id: i32,
|
||||
enabled_tools: Vec<EnabledTool>,
|
||||
stored_values: HashMap<String, JsonValue>,
|
||||
source: String,
|
||||
},
|
||||
Poll {
|
||||
session_id: i32,
|
||||
yield_time_ms: u64,
|
||||
},
|
||||
Terminate {
|
||||
session_id: i32,
|
||||
},
|
||||
Response {
|
||||
session_id: i32,
|
||||
id: String,
|
||||
code_mode_result: JsonValue,
|
||||
},
|
||||
@@ -78,12 +221,22 @@ enum HostToNodeMessage {
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
enum NodeToHostMessage {
|
||||
ToolCall {
|
||||
session_id: i32,
|
||||
id: String,
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
input: Option<JsonValue>,
|
||||
},
|
||||
Yielded {
|
||||
session_id: i32,
|
||||
content_items: Vec<JsonValue>,
|
||||
},
|
||||
Terminated {
|
||||
session_id: i32,
|
||||
content_items: Vec<JsonValue>,
|
||||
},
|
||||
Result {
|
||||
session_id: i32,
|
||||
content_items: Vec<JsonValue>,
|
||||
stored_values: HashMap<String, JsonValue>,
|
||||
#[serde(default)]
|
||||
@@ -93,6 +246,18 @@ enum NodeToHostMessage {
|
||||
},
|
||||
}
|
||||
|
||||
enum CodeModeSessionProgress {
|
||||
Finished(FunctionToolOutput),
|
||||
Yielded { output: FunctionToolOutput },
|
||||
}
|
||||
|
||||
enum CodeModeExecutionStatus {
|
||||
Completed,
|
||||
Failed,
|
||||
Running(i32),
|
||||
Terminated,
|
||||
}
|
||||
|
||||
pub(crate) fn instructions(config: &Config) -> Option<String> {
|
||||
if !config.features.enabled(Feature::CodeMode) {
|
||||
return None;
|
||||
@@ -113,7 +278,10 @@ pub(crate) fn instructions(config: &Config) -> Option<String> {
|
||||
));
|
||||
section.push_str("- Import nested tools from `tools.js`, for example `import { exec_command } from \"tools.js\"` or `import { ALL_TOOLS } from \"tools.js\"` to inspect the available `{ module, name, description }` entries. Namespaced tools are also available from `tools/<namespace...>.js`; MCP tools use `tools/mcp/<server>.js`, for example `import { append_notebook_logs_chart } from \"tools/mcp/ologs.js\"`. Nested tool calls resolve to their code-mode result values.\n");
|
||||
section.push_str(&format!(
|
||||
"- Import `{{ output_text, output_image, set_max_output_tokens_per_exec_call, store, load }}` from `@openai/code_mode` (or `\"openai/code_mode\"`). `output_text(value)` surfaces text back to the model and stringifies non-string objects with `JSON.stringify(...)` when possible. `output_image(imageUrl)` appends an `input_image` content item for `http(s)` or `data:` URLs. `store(key, value)` persists JSON-serializable values across `{PUBLIC_TOOL_NAME}` calls in the current session, and `load(key)` returns a cloned stored value or `undefined`. `set_max_output_tokens_per_exec_call(value)` sets the token budget used to truncate the final Rust-side result of the current `{PUBLIC_TOOL_NAME}` execution; the default is `10000`. This guards the overall `{PUBLIC_TOOL_NAME}` output, not individual nested tool invocations. The returned content starts with a separate `Script completed` or `Script failed` text item that includes wall time. When truncation happens, the final text may include `Total output lines:` and the usual `…N tokens truncated…` marker.\n",
|
||||
"- Import `{{ output_text, output_image, set_max_output_tokens_per_exec_call, set_yield_time, store, load }}` from `@openai/code_mode` (or `\"openai/code_mode\"`). `output_text(value)` surfaces text back to the model and stringifies non-string objects with `JSON.stringify(...)` when possible. `output_image(imageUrl)` appends an `input_image` content item for `http(s)` or `data:` URLs. `store(key, value)` persists JSON-serializable values across `{PUBLIC_TOOL_NAME}` calls in the current session, and `load(key)` returns a cloned stored value or `undefined`. `set_max_output_tokens_per_exec_call(value)` sets the token budget used to truncate direct `{PUBLIC_TOOL_NAME}` returns; `{WAIT_TOOL_NAME}` uses its own `max_tokens` argument instead and defaults to `10000`. `set_yield_time(value)` asks `{PUBLIC_TOOL_NAME}` to return early if the script is still running after that many milliseconds so `{WAIT_TOOL_NAME}` can resume it later. The returned content starts with a separate `Script completed`, `Script failed`, or `Script running with session ID …` text item that includes wall time. When truncation happens, the final text may include `Total output lines:` and the usual `…N tokens truncated…` marker.\n",
|
||||
));
|
||||
section.push_str(&format!(
|
||||
"- If `{PUBLIC_TOOL_NAME}` returns `Script running with session ID …`, call `{WAIT_TOOL_NAME}` with that `session_id` to keep waiting for more output, completion, or termination.\n",
|
||||
));
|
||||
section.push_str(
|
||||
"- Function tools require JSON object arguments. Freeform tools require raw strings.\n",
|
||||
@@ -136,30 +304,103 @@ pub(crate) async fn execute(
|
||||
tracker,
|
||||
};
|
||||
let enabled_tools = build_enabled_tools(&exec).await;
|
||||
let stored_values = exec.session.services.code_mode_store.stored_values().await;
|
||||
let service = &exec.session.services.code_mode_service;
|
||||
let stored_values = service.stored_values().await;
|
||||
let source = build_source(&code, &enabled_tools).map_err(FunctionCallError::RespondToModel)?;
|
||||
execute_node(exec, source, enabled_tools, stored_values)
|
||||
let session_id = service.allocate_session_id().await;
|
||||
let process_slot = service
|
||||
.ensure_started()
|
||||
.await
|
||||
.map_err(FunctionCallError::RespondToModel)
|
||||
.map_err(FunctionCallError::RespondToModel)?;
|
||||
let result = {
|
||||
let mut process_slot = process_slot;
|
||||
let Some(process) = process_slot.as_mut() else {
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"{PUBLIC_TOOL_NAME} runner failed to start"
|
||||
)));
|
||||
};
|
||||
drive_code_mode_session(
|
||||
&exec,
|
||||
process,
|
||||
HostToNodeMessage::Start {
|
||||
session_id,
|
||||
enabled_tools,
|
||||
stored_values,
|
||||
source,
|
||||
},
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
};
|
||||
match result {
|
||||
Ok(CodeModeSessionProgress::Finished(output))
|
||||
| Ok(CodeModeSessionProgress::Yielded { output }) => Ok(output),
|
||||
Err(error) => Err(FunctionCallError::RespondToModel(error)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn execute_node(
|
||||
exec: ExecContext,
|
||||
source: String,
|
||||
enabled_tools: Vec<EnabledTool>,
|
||||
stored_values: HashMap<String, JsonValue>,
|
||||
) -> Result<FunctionToolOutput, String> {
|
||||
let node_path = resolve_compatible_node(exec.turn.config.js_repl_node_path.as_deref()).await?;
|
||||
let started_at = std::time::Instant::now();
|
||||
pub(crate) async fn wait(
|
||||
session: Arc<Session>,
|
||||
turn: Arc<TurnContext>,
|
||||
tracker: SharedTurnDiffTracker,
|
||||
session_id: i32,
|
||||
yield_time_ms: u64,
|
||||
max_output_tokens: Option<usize>,
|
||||
terminate: bool,
|
||||
) -> Result<FunctionToolOutput, FunctionCallError> {
|
||||
let exec = ExecContext {
|
||||
session,
|
||||
turn,
|
||||
tracker,
|
||||
};
|
||||
let process_slot = exec
|
||||
.session
|
||||
.services
|
||||
.code_mode_service
|
||||
.ensure_started()
|
||||
.await
|
||||
.map_err(FunctionCallError::RespondToModel)?;
|
||||
let result = {
|
||||
let mut process_slot = process_slot;
|
||||
let Some(process) = process_slot.as_mut() else {
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"{PUBLIC_TOOL_NAME} runner failed to start"
|
||||
)));
|
||||
};
|
||||
if !matches!(process.has_exited(), Ok(false)) {
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"{PUBLIC_TOOL_NAME} runner failed to start"
|
||||
)));
|
||||
}
|
||||
drive_code_mode_session(
|
||||
&exec,
|
||||
process,
|
||||
if terminate {
|
||||
HostToNodeMessage::Terminate { session_id }
|
||||
} else {
|
||||
HostToNodeMessage::Poll {
|
||||
session_id,
|
||||
yield_time_ms,
|
||||
}
|
||||
},
|
||||
Some(max_output_tokens),
|
||||
terminate,
|
||||
)
|
||||
.await
|
||||
};
|
||||
match result {
|
||||
Ok(CodeModeSessionProgress::Finished(output))
|
||||
| Ok(CodeModeSessionProgress::Yielded { output }) => Ok(output),
|
||||
Err(error) => Err(FunctionCallError::RespondToModel(error)),
|
||||
}
|
||||
}
|
||||
|
||||
let env = create_env(&exec.turn.shell_environment_policy, None);
|
||||
let mut cmd = tokio::process::Command::new(&node_path);
|
||||
async fn spawn_code_mode_process(node_path: &std::path::Path) -> Result<CodeModeProcess, String> {
|
||||
let mut cmd = tokio::process::Command::new(node_path);
|
||||
cmd.arg("--experimental-vm-modules");
|
||||
cmd.arg("--eval");
|
||||
cmd.arg(CODE_MODE_RUNNER_SOURCE);
|
||||
cmd.current_dir(&exec.turn.cwd);
|
||||
cmd.env_clear();
|
||||
cmd.envs(env);
|
||||
cmd.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
@@ -176,7 +417,7 @@ async fn execute_node(
|
||||
.stderr
|
||||
.take()
|
||||
.ok_or_else(|| format!("{PUBLIC_TOOL_NAME} runner missing stderr"))?;
|
||||
let mut stdin = child
|
||||
let stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| format!("{PUBLIC_TOOL_NAME} runner missing stdin"))?;
|
||||
@@ -184,138 +425,189 @@ async fn execute_node(
|
||||
let stderr_task = tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(stderr);
|
||||
let mut buf = Vec::new();
|
||||
let _ = reader.read_to_end(&mut buf).await;
|
||||
String::from_utf8_lossy(&buf).trim().to_string()
|
||||
match reader.read_to_end(&mut buf).await {
|
||||
Ok(_) => {
|
||||
let stderr = String::from_utf8_lossy(&buf).trim().to_string();
|
||||
if !stderr.is_empty() {
|
||||
warn!("{PUBLIC_TOOL_NAME} runner stderr: {stderr}");
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("failed to read {PUBLIC_TOOL_NAME} stderr: {err}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
write_message(
|
||||
&mut stdin,
|
||||
&HostToNodeMessage::Init {
|
||||
enabled_tools: enabled_tools.clone(),
|
||||
stored_values,
|
||||
source,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut stdout_lines = BufReader::new(stdout).lines();
|
||||
let mut pending_result = None;
|
||||
while let Some(line) = stdout_lines
|
||||
.next_line()
|
||||
.await
|
||||
.map_err(|err| format!("failed to read {PUBLIC_TOOL_NAME} runner stdout: {err}"))?
|
||||
{
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let message: NodeToHostMessage = serde_json::from_str(&line).map_err(|err| {
|
||||
format!("invalid {PUBLIC_TOOL_NAME} runner message: {err}; line={line}")
|
||||
})?;
|
||||
match message {
|
||||
NodeToHostMessage::ToolCall { id, name, input } => {
|
||||
let response = HostToNodeMessage::Response {
|
||||
id,
|
||||
code_mode_result: call_nested_tool(exec.clone(), name, input).await,
|
||||
};
|
||||
write_message(&mut stdin, &response).await?;
|
||||
}
|
||||
NodeToHostMessage::Result {
|
||||
content_items,
|
||||
stored_values,
|
||||
error_text,
|
||||
max_output_tokens_per_exec_call,
|
||||
} => {
|
||||
exec.session
|
||||
.services
|
||||
.code_mode_store
|
||||
.replace_stored_values(stored_values)
|
||||
.await;
|
||||
pending_result = Some((
|
||||
output_content_items_from_json_values(content_items)?,
|
||||
error_text,
|
||||
max_output_tokens_per_exec_call,
|
||||
));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drop(stdin);
|
||||
|
||||
let status = child
|
||||
.wait()
|
||||
.await
|
||||
.map_err(|err| format!("failed to wait for {PUBLIC_TOOL_NAME} runner: {err}"))?;
|
||||
let stderr = stderr_task
|
||||
.await
|
||||
.map_err(|err| format!("failed to collect {PUBLIC_TOOL_NAME} stderr: {err}"))?;
|
||||
let wall_time = started_at.elapsed();
|
||||
let success = status.success();
|
||||
|
||||
let Some((mut content_items, error_text, max_output_tokens_per_exec_call)) = pending_result
|
||||
else {
|
||||
let message = if stderr.is_empty() {
|
||||
format!("{PUBLIC_TOOL_NAME} runner exited without returning a result (status {status})")
|
||||
} else {
|
||||
stderr
|
||||
};
|
||||
return Err(message);
|
||||
};
|
||||
|
||||
if !success {
|
||||
let error_text = error_text.unwrap_or_else(|| {
|
||||
if stderr.is_empty() {
|
||||
format!("Process exited with status {status}")
|
||||
} else {
|
||||
stderr
|
||||
}
|
||||
});
|
||||
content_items.push(FunctionCallOutputContentItem::InputText {
|
||||
text: format!("Script error:\n{error_text}"),
|
||||
});
|
||||
}
|
||||
|
||||
let mut content_items =
|
||||
truncate_code_mode_result(content_items, max_output_tokens_per_exec_call);
|
||||
prepend_script_status(&mut content_items, success, wall_time);
|
||||
Ok(FunctionToolOutput::from_content(
|
||||
content_items,
|
||||
Some(success),
|
||||
))
|
||||
Ok(CodeModeProcess {
|
||||
child,
|
||||
stdin,
|
||||
stdout_lines: BufReader::new(stdout).lines(),
|
||||
stderr_task: Some(stderr_task),
|
||||
pending_messages: HashMap::new(),
|
||||
})
|
||||
}
|
||||
|
||||
async fn write_message(
|
||||
stdin: &mut tokio::process::ChildStdin,
|
||||
message: &HostToNodeMessage,
|
||||
) -> Result<(), String> {
|
||||
let line = serde_json::to_string(message)
|
||||
.map_err(|err| format!("failed to serialize {PUBLIC_TOOL_NAME} message: {err}"))?;
|
||||
stdin
|
||||
.write_all(line.as_bytes())
|
||||
async fn drive_code_mode_session(
|
||||
exec: &ExecContext,
|
||||
process: &mut CodeModeProcess,
|
||||
message: HostToNodeMessage,
|
||||
poll_max_output_tokens: Option<Option<usize>>,
|
||||
is_terminate: bool,
|
||||
) -> Result<CodeModeSessionProgress, String> {
|
||||
let started_at = std::time::Instant::now();
|
||||
let session_id = match &message {
|
||||
HostToNodeMessage::Start { session_id, .. }
|
||||
| HostToNodeMessage::Poll { session_id, .. }
|
||||
| HostToNodeMessage::Terminate { session_id }
|
||||
| HostToNodeMessage::Response { session_id, .. } => *session_id,
|
||||
};
|
||||
process
|
||||
.write(&message)
|
||||
.await
|
||||
.map_err(|err| format!("failed to write {PUBLIC_TOOL_NAME} message: {err}"))?;
|
||||
stdin
|
||||
.write_all(b"\n")
|
||||
.await
|
||||
.map_err(|err| format!("failed to write {PUBLIC_TOOL_NAME} message newline: {err}"))?;
|
||||
stdin
|
||||
.flush()
|
||||
.await
|
||||
.map_err(|err| format!("failed to flush {PUBLIC_TOOL_NAME} message: {err}"))
|
||||
.map_err(|err| err.to_string())?;
|
||||
|
||||
loop {
|
||||
let message = process
|
||||
.read(session_id)
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
if let Some(progress) = handle_node_message(
|
||||
exec,
|
||||
process,
|
||||
session_id,
|
||||
message,
|
||||
poll_max_output_tokens,
|
||||
started_at,
|
||||
is_terminate,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
return Ok(progress);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_node_message(
|
||||
exec: &ExecContext,
|
||||
process: &mut CodeModeProcess,
|
||||
session_id: i32,
|
||||
message: NodeToHostMessage,
|
||||
poll_max_output_tokens: Option<Option<usize>>,
|
||||
started_at: std::time::Instant,
|
||||
is_terminate: bool,
|
||||
) -> Result<Option<CodeModeSessionProgress>, String> {
|
||||
match message {
|
||||
NodeToHostMessage::ToolCall {
|
||||
session_id: message_session_id,
|
||||
id,
|
||||
name,
|
||||
input,
|
||||
} => {
|
||||
if is_terminate {
|
||||
return Ok(None);
|
||||
}
|
||||
let response = HostToNodeMessage::Response {
|
||||
session_id: message_session_id,
|
||||
id,
|
||||
code_mode_result: call_nested_tool(exec.clone(), name, input).await,
|
||||
};
|
||||
process
|
||||
.write(&response)
|
||||
.await
|
||||
.map_err(|err| err.to_string())?;
|
||||
Ok(None)
|
||||
}
|
||||
NodeToHostMessage::Yielded { content_items, .. } => {
|
||||
if is_terminate {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut delta_items = output_content_items_from_json_values(content_items)?;
|
||||
delta_items = truncate_code_mode_result(delta_items, poll_max_output_tokens.flatten());
|
||||
prepend_script_status(
|
||||
&mut delta_items,
|
||||
CodeModeExecutionStatus::Running(session_id),
|
||||
started_at.elapsed(),
|
||||
);
|
||||
Ok(Some(CodeModeSessionProgress::Yielded {
|
||||
output: FunctionToolOutput::from_content(delta_items, Some(true)),
|
||||
}))
|
||||
}
|
||||
NodeToHostMessage::Terminated { content_items, .. } => {
|
||||
let mut delta_items = output_content_items_from_json_values(content_items)?;
|
||||
delta_items = truncate_code_mode_result(delta_items, poll_max_output_tokens.flatten());
|
||||
prepend_script_status(
|
||||
&mut delta_items,
|
||||
CodeModeExecutionStatus::Terminated,
|
||||
started_at.elapsed(),
|
||||
);
|
||||
Ok(Some(CodeModeSessionProgress::Finished(
|
||||
FunctionToolOutput::from_content(delta_items, Some(true)),
|
||||
)))
|
||||
}
|
||||
NodeToHostMessage::Result {
|
||||
content_items,
|
||||
stored_values,
|
||||
error_text,
|
||||
max_output_tokens_per_exec_call,
|
||||
..
|
||||
} => {
|
||||
exec.session
|
||||
.services
|
||||
.code_mode_service
|
||||
.replace_stored_values(stored_values)
|
||||
.await;
|
||||
let mut delta_items = output_content_items_from_json_values(content_items)?;
|
||||
let success = error_text.is_none();
|
||||
if let Some(error_text) = error_text {
|
||||
delta_items.push(FunctionCallOutputContentItem::InputText {
|
||||
text: format!("Script error:\n{error_text}"),
|
||||
});
|
||||
}
|
||||
|
||||
let mut delta_items = truncate_code_mode_result(
|
||||
delta_items,
|
||||
poll_max_output_tokens.unwrap_or(max_output_tokens_per_exec_call),
|
||||
);
|
||||
prepend_script_status(
|
||||
&mut delta_items,
|
||||
if success {
|
||||
CodeModeExecutionStatus::Completed
|
||||
} else {
|
||||
CodeModeExecutionStatus::Failed
|
||||
},
|
||||
started_at.elapsed(),
|
||||
);
|
||||
Ok(Some(CodeModeSessionProgress::Finished(
|
||||
FunctionToolOutput::from_content(delta_items, Some(success)),
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn message_session_id(message: &NodeToHostMessage) -> i32 {
|
||||
match message {
|
||||
NodeToHostMessage::ToolCall { session_id, .. }
|
||||
| NodeToHostMessage::Yielded { session_id, .. }
|
||||
| NodeToHostMessage::Terminated { session_id, .. }
|
||||
| NodeToHostMessage::Result { session_id, .. } => *session_id,
|
||||
}
|
||||
}
|
||||
|
||||
fn prepend_script_status(
|
||||
content_items: &mut Vec<FunctionCallOutputContentItem>,
|
||||
success: bool,
|
||||
status: CodeModeExecutionStatus,
|
||||
wall_time: Duration,
|
||||
) {
|
||||
let wall_time_seconds = ((wall_time.as_secs_f32()) * 10.0).round() / 10.0;
|
||||
let header = format!(
|
||||
"{}\nWall time {wall_time_seconds:.1} seconds\nOutput:\n",
|
||||
if success {
|
||||
"Script completed"
|
||||
} else {
|
||||
"Script failed"
|
||||
match status {
|
||||
CodeModeExecutionStatus::Completed => "Script completed".to_string(),
|
||||
CodeModeExecutionStatus::Failed => "Script failed".to_string(),
|
||||
CodeModeExecutionStatus::Running(session_id) => {
|
||||
format!("Script running with session ID {session_id}")
|
||||
}
|
||||
CodeModeExecutionStatus::Terminated => "Script terminated".to_string(),
|
||||
}
|
||||
);
|
||||
content_items.insert(0, FunctionCallOutputContentItem::InputText { text: header });
|
||||
@@ -365,7 +657,7 @@ async fn build_enabled_tools(exec: &ExecContext) -> Vec<EnabledTool> {
|
||||
|
||||
fn enabled_tool_from_spec(spec: ToolSpec) -> Option<EnabledTool> {
|
||||
let tool_name = spec.name().to_string();
|
||||
if tool_name == PUBLIC_TOOL_NAME {
|
||||
if tool_name == PUBLIC_TOOL_NAME || tool_name == WAIT_TOOL_NAME {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -408,9 +700,12 @@ async fn build_nested_router(exec: &ExecContext) -> ToolRouter {
|
||||
|
||||
ToolRouter::from_config(
|
||||
&nested_tools_config,
|
||||
Some(mcp_tools),
|
||||
None,
|
||||
exec.turn.dynamic_tools.as_slice(),
|
||||
ToolRouterParams {
|
||||
mcp_tools: Some(mcp_tools),
|
||||
app_tools: None,
|
||||
discoverable_tools: None,
|
||||
dynamic_tools: exec.turn.dynamic_tools.as_slice(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,111 @@
|
||||
use crate::plugins::PluginCapabilitySummary;
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum DiscoverableToolType {
|
||||
Connector,
|
||||
Plugin,
|
||||
}
|
||||
|
||||
impl DiscoverableToolType {
|
||||
pub(crate) fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Connector => "connector",
|
||||
Self::Plugin => "plugin",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub(crate) enum DiscoverableToolAction {
|
||||
Install,
|
||||
Enable,
|
||||
}
|
||||
|
||||
impl DiscoverableToolAction {
|
||||
pub(crate) fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Install => "install",
|
||||
Self::Enable => "enable",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum DiscoverableTool {
|
||||
Connector(Box<AppInfo>),
|
||||
Plugin(Box<DiscoverablePluginInfo>),
|
||||
}
|
||||
|
||||
impl DiscoverableTool {
|
||||
pub(crate) fn tool_type(&self) -> DiscoverableToolType {
|
||||
match self {
|
||||
Self::Connector(_) => DiscoverableToolType::Connector,
|
||||
Self::Plugin(_) => DiscoverableToolType::Plugin,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn id(&self) -> &str {
|
||||
match self {
|
||||
Self::Connector(connector) => connector.id.as_str(),
|
||||
Self::Plugin(plugin) => plugin.id.as_str(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn name(&self) -> &str {
|
||||
match self {
|
||||
Self::Connector(connector) => connector.name.as_str(),
|
||||
Self::Plugin(plugin) => plugin.name.as_str(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn description(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Connector(connector) => connector.description.as_deref(),
|
||||
Self::Plugin(plugin) => plugin.description.as_deref(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<AppInfo> for DiscoverableTool {
|
||||
fn from(value: AppInfo) -> Self {
|
||||
Self::Connector(Box::new(value))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<DiscoverablePluginInfo> for DiscoverableTool {
|
||||
fn from(value: DiscoverablePluginInfo) -> Self {
|
||||
Self::Plugin(Box::new(value))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct DiscoverablePluginInfo {
|
||||
pub(crate) id: String,
|
||||
pub(crate) name: String,
|
||||
pub(crate) description: Option<String>,
|
||||
pub(crate) has_skills: bool,
|
||||
pub(crate) mcp_server_names: Vec<String>,
|
||||
pub(crate) app_connector_ids: Vec<String>,
|
||||
}
|
||||
|
||||
impl From<PluginCapabilitySummary> for DiscoverablePluginInfo {
|
||||
fn from(value: PluginCapabilitySummary) -> Self {
|
||||
Self {
|
||||
id: value.config_name,
|
||||
name: value.display_name,
|
||||
description: value.description,
|
||||
has_skills: value.has_skills,
|
||||
mcp_server_names: value.mcp_server_names,
|
||||
app_connector_ids: value
|
||||
.app_connector_ids
|
||||
.into_iter()
|
||||
.map(|connector_id| connector_id.0)
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,35 @@
|
||||
use async_trait::async_trait;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::features::Feature;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::tools::code_mode;
|
||||
use crate::tools::code_mode::DEFAULT_WAIT_YIELD_TIME_MS;
|
||||
use crate::tools::code_mode::PUBLIC_TOOL_NAME;
|
||||
use crate::tools::code_mode::WAIT_TOOL_NAME;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::handlers::parse_arguments;
|
||||
use crate::tools::registry::ToolHandler;
|
||||
use crate::tools::registry::ToolKind;
|
||||
|
||||
pub struct CodeModeHandler;
|
||||
pub struct CodeModeWaitHandler;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ExecWaitArgs {
|
||||
session_id: i32,
|
||||
#[serde(default = "default_wait_yield_time_ms")]
|
||||
yield_time_ms: u64,
|
||||
#[serde(default)]
|
||||
max_tokens: Option<usize>,
|
||||
#[serde(default)]
|
||||
terminate: bool,
|
||||
}
|
||||
|
||||
fn default_wait_yield_time_ms() -> u64 {
|
||||
DEFAULT_WAIT_YIELD_TIME_MS
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolHandler for CodeModeHandler {
|
||||
@@ -29,25 +48,57 @@ impl ToolHandler for CodeModeHandler {
|
||||
session,
|
||||
turn,
|
||||
tracker,
|
||||
tool_name,
|
||||
payload,
|
||||
..
|
||||
} = invocation;
|
||||
|
||||
if !session.features().enabled(Feature::CodeMode) {
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"{PUBLIC_TOOL_NAME} is disabled by feature flag"
|
||||
)));
|
||||
}
|
||||
|
||||
let code = match payload {
|
||||
ToolPayload::Custom { input } => input,
|
||||
_ => {
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"{PUBLIC_TOOL_NAME} expects raw JavaScript source text"
|
||||
)));
|
||||
match payload {
|
||||
ToolPayload::Custom { input } if tool_name == PUBLIC_TOOL_NAME => {
|
||||
code_mode::execute(session, turn, tracker, input).await
|
||||
}
|
||||
};
|
||||
|
||||
code_mode::execute(session, turn, tracker, code).await
|
||||
_ => Err(FunctionCallError::RespondToModel(format!(
|
||||
"{PUBLIC_TOOL_NAME} expects raw JavaScript source text"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolHandler for CodeModeWaitHandler {
|
||||
type Output = FunctionToolOutput;
|
||||
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
|
||||
let ToolInvocation {
|
||||
session,
|
||||
turn,
|
||||
tracker,
|
||||
tool_name,
|
||||
payload,
|
||||
..
|
||||
} = invocation;
|
||||
|
||||
match payload {
|
||||
ToolPayload::Function { arguments } if tool_name == WAIT_TOOL_NAME => {
|
||||
let args: ExecWaitArgs = parse_arguments(&arguments)?;
|
||||
code_mode::wait(
|
||||
session,
|
||||
turn,
|
||||
tracker,
|
||||
args.session_id,
|
||||
args.yield_time_ms,
|
||||
args.max_tokens,
|
||||
args.terminate,
|
||||
)
|
||||
.await
|
||||
}
|
||||
_ => Err(FunctionCallError::RespondToModel(format!(
|
||||
"{WAIT_TOOL_NAME} expects JSON arguments"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ mod request_user_input;
|
||||
mod shell;
|
||||
mod test_sync;
|
||||
mod tool_search;
|
||||
mod tool_suggest;
|
||||
pub(crate) mod unified_exec;
|
||||
mod view_image;
|
||||
|
||||
@@ -34,6 +35,7 @@ use crate::sandboxing::normalize_additional_permissions;
|
||||
pub use apply_patch::ApplyPatchHandler;
|
||||
pub use artifacts::ArtifactsHandler;
|
||||
pub use code_mode::CodeModeHandler;
|
||||
pub use code_mode::CodeModeWaitHandler;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
pub use dynamic::DynamicToolHandler;
|
||||
@@ -56,6 +58,8 @@ pub use test_sync::TestSyncHandler;
|
||||
pub(crate) use tool_search::DEFAULT_LIMIT as TOOL_SEARCH_DEFAULT_LIMIT;
|
||||
pub(crate) use tool_search::TOOL_SEARCH_TOOL_NAME;
|
||||
pub use tool_search::ToolSearchHandler;
|
||||
pub(crate) use tool_suggest::TOOL_SUGGEST_TOOL_NAME;
|
||||
pub use tool_suggest::ToolSuggestHandler;
|
||||
pub use unified_exec::UnifiedExecHandler;
|
||||
pub use view_image::ViewImageHandler;
|
||||
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use codex_app_server_protocol::McpElicitationObjectType;
|
||||
use codex_app_server_protocol::McpElicitationSchema;
|
||||
use codex_app_server_protocol::McpServerElicitationRequest;
|
||||
use codex_app_server_protocol::McpServerElicitationRequestParams;
|
||||
use codex_rmcp_client::ElicitationAction;
|
||||
use rmcp::model::RequestId;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::connectors;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::discoverable::DiscoverableTool;
|
||||
use crate::tools::discoverable::DiscoverableToolAction;
|
||||
use crate::tools::discoverable::DiscoverableToolType;
|
||||
use crate::tools::handlers::parse_arguments;
|
||||
use crate::tools::registry::ToolHandler;
|
||||
use crate::tools::registry::ToolKind;
|
||||
|
||||
pub struct ToolSuggestHandler;
|
||||
|
||||
pub(crate) const TOOL_SUGGEST_TOOL_NAME: &str = "tool_suggest";
|
||||
const TOOL_SUGGEST_APPROVAL_KIND_VALUE: &str = "tool_suggestion";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ToolSuggestArgs {
|
||||
tool_type: DiscoverableToolType,
|
||||
action_type: DiscoverableToolAction,
|
||||
tool_id: String,
|
||||
suggest_reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq, Eq)]
|
||||
struct ToolSuggestResult {
|
||||
completed: bool,
|
||||
user_confirmed: bool,
|
||||
tool_type: DiscoverableToolType,
|
||||
action_type: DiscoverableToolAction,
|
||||
tool_id: String,
|
||||
tool_name: String,
|
||||
suggest_reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, PartialEq, Eq)]
|
||||
struct ToolSuggestMeta<'a> {
|
||||
codex_approval_kind: &'static str,
|
||||
tool_type: DiscoverableToolType,
|
||||
suggest_type: DiscoverableToolAction,
|
||||
suggest_reason: &'a str,
|
||||
tool_id: &'a str,
|
||||
tool_name: &'a str,
|
||||
install_url: &'a str,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ToolHandler for ToolSuggestHandler {
|
||||
type Output = FunctionToolOutput;
|
||||
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
|
||||
let ToolInvocation {
|
||||
payload,
|
||||
session,
|
||||
turn,
|
||||
call_id,
|
||||
..
|
||||
} = invocation;
|
||||
|
||||
let arguments = match payload {
|
||||
ToolPayload::Function { arguments } => arguments,
|
||||
_ => {
|
||||
return Err(FunctionCallError::Fatal(format!(
|
||||
"{TOOL_SUGGEST_TOOL_NAME} handler received unsupported payload"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let args: ToolSuggestArgs = parse_arguments(&arguments)?;
|
||||
let suggest_reason = args.suggest_reason.trim();
|
||||
if suggest_reason.is_empty() {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"suggest_reason must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
if args.tool_type == DiscoverableToolType::Plugin {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"plugin tool suggestions are not currently available".to_string(),
|
||||
));
|
||||
}
|
||||
if args.action_type != DiscoverableToolAction::Install {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
"connector tool suggestions currently support only action_type=\"install\""
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let auth = session.services.auth_manager.auth().await;
|
||||
let manager = session.services.mcp_connection_manager.read().await;
|
||||
let mcp_tools = manager.list_all_tools().await;
|
||||
drop(manager);
|
||||
let accessible_connectors = connectors::with_app_enabled_state(
|
||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
||||
&turn.config,
|
||||
);
|
||||
let discoverable_tools = connectors::list_tool_suggest_discoverable_tools_with_auth(
|
||||
&turn.config,
|
||||
auth.as_ref(),
|
||||
&accessible_connectors,
|
||||
)
|
||||
.await
|
||||
.map(|connectors| {
|
||||
connectors
|
||||
.into_iter()
|
||||
.map(DiscoverableTool::from)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"tool suggestions are unavailable right now: {err}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let connector = discoverable_tools
|
||||
.into_iter()
|
||||
.find_map(|tool| match tool {
|
||||
DiscoverableTool::Connector(connector) if connector.id == args.tool_id => {
|
||||
Some(*connector)
|
||||
}
|
||||
DiscoverableTool::Connector(_) | DiscoverableTool::Plugin(_) => None,
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
FunctionCallError::RespondToModel(format!(
|
||||
"tool_id must match one of the discoverable tools exposed by {TOOL_SUGGEST_TOOL_NAME}"
|
||||
))
|
||||
})?;
|
||||
|
||||
let request_id = RequestId::String(format!("tool_suggestion_{call_id}").into());
|
||||
let params = build_tool_suggestion_elicitation_request(
|
||||
session.conversation_id.to_string(),
|
||||
turn.sub_id.clone(),
|
||||
&args,
|
||||
suggest_reason,
|
||||
&connector,
|
||||
);
|
||||
let response = session
|
||||
.request_mcp_server_elicitation(turn.as_ref(), request_id, params)
|
||||
.await;
|
||||
let user_confirmed = response
|
||||
.as_ref()
|
||||
.is_some_and(|response| response.action == ElicitationAction::Accept);
|
||||
|
||||
let completed = if user_confirmed {
|
||||
let manager = session.services.mcp_connection_manager.read().await;
|
||||
match manager.hard_refresh_codex_apps_tools_cache().await {
|
||||
Ok(mcp_tools) => {
|
||||
let accessible_connectors = connectors::with_app_enabled_state(
|
||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
||||
&turn.config,
|
||||
);
|
||||
connectors::refresh_accessible_connectors_cache_from_mcp_tools(
|
||||
&turn.config,
|
||||
auth.as_ref(),
|
||||
&mcp_tools,
|
||||
);
|
||||
verified_connector_suggestion_completed(
|
||||
args.action_type,
|
||||
connector.id.as_str(),
|
||||
&accessible_connectors,
|
||||
)
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"failed to refresh codex apps tools cache after tool suggestion for {}: {err:#}",
|
||||
connector.id
|
||||
);
|
||||
false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if completed {
|
||||
session
|
||||
.merge_connector_selection(HashSet::from([connector.id.clone()]))
|
||||
.await;
|
||||
}
|
||||
|
||||
let content = serde_json::to_string(&ToolSuggestResult {
|
||||
completed,
|
||||
user_confirmed,
|
||||
tool_type: args.tool_type,
|
||||
action_type: args.action_type,
|
||||
tool_id: connector.id,
|
||||
tool_name: connector.name,
|
||||
suggest_reason: suggest_reason.to_string(),
|
||||
})
|
||||
.map_err(|err| {
|
||||
FunctionCallError::Fatal(format!(
|
||||
"failed to serialize {TOOL_SUGGEST_TOOL_NAME} response: {err}"
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(FunctionToolOutput::from_text(content, Some(true)))
|
||||
}
|
||||
}
|
||||
|
||||
fn build_tool_suggestion_elicitation_request(
|
||||
thread_id: String,
|
||||
turn_id: String,
|
||||
args: &ToolSuggestArgs,
|
||||
suggest_reason: &str,
|
||||
connector: &AppInfo,
|
||||
) -> McpServerElicitationRequestParams {
|
||||
let tool_name = connector.name.clone();
|
||||
let install_url = connector
|
||||
.install_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| connectors::connector_install_url(&tool_name, &connector.id));
|
||||
|
||||
let message = format!(
|
||||
"{tool_name} could help with this request.\n\n{suggest_reason}\n\nOpen ChatGPT to {} it, then confirm here if you finish.",
|
||||
args.action_type.as_str()
|
||||
);
|
||||
|
||||
McpServerElicitationRequestParams {
|
||||
thread_id,
|
||||
turn_id: Some(turn_id),
|
||||
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
request: McpServerElicitationRequest::Form {
|
||||
meta: Some(json!(build_tool_suggestion_meta(
|
||||
args.tool_type,
|
||||
args.action_type,
|
||||
suggest_reason,
|
||||
connector.id.as_str(),
|
||||
tool_name.as_str(),
|
||||
install_url.as_str(),
|
||||
))),
|
||||
message,
|
||||
requested_schema: McpElicitationSchema {
|
||||
schema_uri: None,
|
||||
type_: McpElicitationObjectType::Object,
|
||||
properties: BTreeMap::new(),
|
||||
required: None,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn build_tool_suggestion_meta<'a>(
|
||||
tool_type: DiscoverableToolType,
|
||||
action_type: DiscoverableToolAction,
|
||||
suggest_reason: &'a str,
|
||||
tool_id: &'a str,
|
||||
tool_name: &'a str,
|
||||
install_url: &'a str,
|
||||
) -> ToolSuggestMeta<'a> {
|
||||
ToolSuggestMeta {
|
||||
codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE,
|
||||
tool_type,
|
||||
suggest_type: action_type,
|
||||
suggest_reason,
|
||||
tool_id,
|
||||
tool_name,
|
||||
install_url,
|
||||
}
|
||||
}
|
||||
|
||||
fn verified_connector_suggestion_completed(
|
||||
action_type: DiscoverableToolAction,
|
||||
tool_id: &str,
|
||||
accessible_connectors: &[AppInfo],
|
||||
) -> bool {
|
||||
accessible_connectors
|
||||
.iter()
|
||||
.find(|connector| connector.id == tool_id)
|
||||
.is_some_and(|connector| match action_type {
|
||||
DiscoverableToolAction::Install => connector.is_accessible,
|
||||
DiscoverableToolAction::Enable => connector.is_accessible && connector.is_enabled,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn build_tool_suggestion_elicitation_request_uses_expected_shape() {
|
||||
let args = ToolSuggestArgs {
|
||||
tool_type: DiscoverableToolType::Connector,
|
||||
action_type: DiscoverableToolAction::Install,
|
||||
tool_id: "connector_2128aebfecb84f64a069897515042a44".to_string(),
|
||||
suggest_reason: "Plan and reference events from your calendar".to_string(),
|
||||
};
|
||||
let connector = AppInfo {
|
||||
id: "connector_2128aebfecb84f64a069897515042a44".to_string(),
|
||||
name: "Google Calendar".to_string(),
|
||||
description: Some("Plan events and schedules.".to_string()),
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: Some(
|
||||
"https://chatgpt.com/apps/google-calendar/connector_2128aebfecb84f64a069897515042a44"
|
||||
.to_string(),
|
||||
),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
};
|
||||
|
||||
let request = build_tool_suggestion_elicitation_request(
|
||||
"thread-1".to_string(),
|
||||
"turn-1".to_string(),
|
||||
&args,
|
||||
"Plan and reference events from your calendar",
|
||||
&connector,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
request,
|
||||
McpServerElicitationRequestParams {
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
request: McpServerElicitationRequest::Form {
|
||||
meta: Some(json!(ToolSuggestMeta {
|
||||
codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE,
|
||||
tool_type: DiscoverableToolType::Connector,
|
||||
suggest_type: DiscoverableToolAction::Install,
|
||||
suggest_reason: "Plan and reference events from your calendar",
|
||||
tool_id: "connector_2128aebfecb84f64a069897515042a44",
|
||||
tool_name: "Google Calendar",
|
||||
install_url: "https://chatgpt.com/apps/google-calendar/connector_2128aebfecb84f64a069897515042a44",
|
||||
})),
|
||||
message: "Google Calendar could help with this request.\n\nPlan and reference events from your calendar\n\nOpen ChatGPT to install it, then confirm here if you finish.".to_string(),
|
||||
requested_schema: McpElicitationSchema {
|
||||
schema_uri: None,
|
||||
type_: McpElicitationObjectType::Object,
|
||||
properties: BTreeMap::new(),
|
||||
required: None,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_tool_suggestion_meta_uses_expected_shape() {
|
||||
let meta = build_tool_suggestion_meta(
|
||||
DiscoverableToolType::Connector,
|
||||
DiscoverableToolAction::Install,
|
||||
"Find and reference emails from your inbox",
|
||||
"connector_68df038e0ba48191908c8434991bbac2",
|
||||
"Gmail",
|
||||
"https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
meta,
|
||||
ToolSuggestMeta {
|
||||
codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE,
|
||||
tool_type: DiscoverableToolType::Connector,
|
||||
suggest_type: DiscoverableToolAction::Install,
|
||||
suggest_reason: "Find and reference emails from your inbox",
|
||||
tool_id: "connector_68df038e0ba48191908c8434991bbac2",
|
||||
tool_name: "Gmail",
|
||||
install_url: "https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verified_connector_suggestion_completed_requires_installed_connector() {
|
||||
let accessible_connectors = vec![AppInfo {
|
||||
id: "calendar".to_string(),
|
||||
name: "Google Calendar".to_string(),
|
||||
description: None,
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: None,
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}];
|
||||
|
||||
assert!(verified_connector_suggestion_completed(
|
||||
DiscoverableToolAction::Install,
|
||||
"calendar",
|
||||
&accessible_connectors,
|
||||
));
|
||||
assert!(!verified_connector_suggestion_completed(
|
||||
DiscoverableToolAction::Install,
|
||||
"gmail",
|
||||
&accessible_connectors,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verified_connector_suggestion_completed_requires_enabled_connector_for_enable() {
|
||||
let accessible_connectors = vec![
|
||||
AppInfo {
|
||||
id: "calendar".to_string(),
|
||||
name: "Google Calendar".to_string(),
|
||||
description: None,
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: None,
|
||||
is_accessible: true,
|
||||
is_enabled: false,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
AppInfo {
|
||||
id: "gmail".to_string(),
|
||||
name: "Gmail".to_string(),
|
||||
description: None,
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: None,
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
];
|
||||
|
||||
assert!(!verified_connector_suggestion_completed(
|
||||
DiscoverableToolAction::Enable,
|
||||
"calendar",
|
||||
&accessible_connectors,
|
||||
));
|
||||
assert!(verified_connector_suggestion_completed(
|
||||
DiscoverableToolAction::Enable,
|
||||
"gmail",
|
||||
&accessible_connectors,
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -884,9 +884,7 @@ impl JsReplManager {
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_seatbelt_profile_extensions: None,
|
||||
codex_linux_sandbox_exe: turn.codex_linux_sandbox_exe.as_ref(),
|
||||
use_linux_sandbox_bwrap: turn
|
||||
.features
|
||||
.enabled(crate::features::Feature::UseLinuxSandboxBwrap),
|
||||
use_legacy_landlock: turn.features.use_legacy_landlock(),
|
||||
windows_sandbox_level: turn.windows_sandbox_level,
|
||||
})
|
||||
.map_err(|err| format!("failed to configure sandbox for js_repl: {err}"))?;
|
||||
@@ -1360,14 +1358,17 @@ impl JsReplManager {
|
||||
|
||||
let router = ToolRouter::from_config(
|
||||
&exec.turn.tools_config,
|
||||
Some(
|
||||
mcp_tools
|
||||
.into_iter()
|
||||
.map(|(name, tool)| (name, tool.tool))
|
||||
.collect(),
|
||||
),
|
||||
None,
|
||||
exec.turn.dynamic_tools.as_slice(),
|
||||
crate::tools::router::ToolRouterParams {
|
||||
mcp_tools: Some(
|
||||
mcp_tools
|
||||
.into_iter()
|
||||
.map(|(name, tool)| (name, tool.tool))
|
||||
.collect(),
|
||||
),
|
||||
app_tools: None,
|
||||
discoverable_tools: None,
|
||||
dynamic_tools: exec.turn.dynamic_tools.as_slice(),
|
||||
},
|
||||
);
|
||||
|
||||
let payload = if let Some((server, tool)) = exec
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod code_mode;
|
||||
pub(crate) mod code_mode_description;
|
||||
pub mod context;
|
||||
pub(crate) mod discoverable;
|
||||
pub mod events;
|
||||
pub(crate) mod handlers;
|
||||
pub mod js_repl;
|
||||
|
||||
@@ -9,7 +9,6 @@ caching).
|
||||
use crate::error::CodexErr;
|
||||
use crate::error::SandboxErr;
|
||||
use crate::exec::ExecToolCallOutput;
|
||||
use crate::features::Feature;
|
||||
use crate::guardian::GUARDIAN_REJECTION_MESSAGE;
|
||||
use crate::guardian::routes_approval_to_guardian;
|
||||
use crate::network_policy_decision::network_approval_context_from_payload;
|
||||
@@ -186,7 +185,7 @@ impl ToolOrchestrator {
|
||||
|
||||
// Platform-specific flag gating is handled by SandboxManager::select_initial
|
||||
// via crate::safety::get_platform_sandbox(..).
|
||||
let use_linux_sandbox_bwrap = turn_ctx.features.enabled(Feature::UseLinuxSandboxBwrap);
|
||||
let use_legacy_landlock = turn_ctx.features.use_legacy_landlock();
|
||||
let initial_attempt = SandboxAttempt {
|
||||
sandbox: initial_sandbox,
|
||||
policy: &turn_ctx.sandbox_policy,
|
||||
@@ -196,7 +195,7 @@ impl ToolOrchestrator {
|
||||
manager: &self.sandbox,
|
||||
sandbox_cwd: &turn_ctx.cwd,
|
||||
codex_linux_sandbox_exe: turn_ctx.codex_linux_sandbox_exe.as_ref(),
|
||||
use_linux_sandbox_bwrap,
|
||||
use_legacy_landlock,
|
||||
windows_sandbox_level: turn_ctx.windows_sandbox_level,
|
||||
};
|
||||
|
||||
@@ -318,7 +317,7 @@ impl ToolOrchestrator {
|
||||
manager: &self.sandbox,
|
||||
sandbox_cwd: &turn_ctx.cwd,
|
||||
codex_linux_sandbox_exe: None,
|
||||
use_linux_sandbox_bwrap,
|
||||
use_legacy_landlock,
|
||||
windows_sandbox_level: turn_ctx.windows_sandbox_level,
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::client_common::tools::ToolSpec;
|
||||
use crate::features::Feature;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::memories::usage::emit_metric_for_tool_read;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
@@ -174,10 +173,7 @@ impl ToolRegistry {
|
||||
sandbox_tag(
|
||||
&invocation.turn.sandbox_policy,
|
||||
invocation.turn.windows_sandbox_level,
|
||||
invocation
|
||||
.turn
|
||||
.features
|
||||
.enabled(Feature::UseLinuxSandboxBwrap),
|
||||
invocation.turn.features.use_legacy_landlock(),
|
||||
),
|
||||
),
|
||||
(
|
||||
@@ -505,7 +501,7 @@ async fn dispatch_after_tool_use_hook(
|
||||
sandbox: sandbox_tag(
|
||||
&turn.sandbox_policy,
|
||||
turn.windows_sandbox_level,
|
||||
turn.features.enabled(Feature::UseLinuxSandboxBwrap),
|
||||
turn.features.use_legacy_landlock(),
|
||||
)
|
||||
.to_string(),
|
||||
sandbox_policy: sandbox_policy_tag(&turn.sandbox_policy).to_string(),
|
||||
|
||||
@@ -9,11 +9,12 @@ use crate::tools::context::SharedTurnDiffTracker;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::context::ToolSearchOutput;
|
||||
use crate::tools::discoverable::DiscoverableTool;
|
||||
use crate::tools::registry::AnyToolResult;
|
||||
use crate::tools::registry::ConfiguredToolSpec;
|
||||
use crate::tools::registry::ToolRegistry;
|
||||
use crate::tools::spec::ToolsConfig;
|
||||
use crate::tools::spec::build_specs;
|
||||
use crate::tools::spec::build_specs_with_discoverable_tools;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_protocol::models::LocalShellAction;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
@@ -40,14 +41,28 @@ pub struct ToolRouter {
|
||||
specs: Vec<ConfiguredToolSpec>,
|
||||
}
|
||||
|
||||
pub(crate) struct ToolRouterParams<'a> {
|
||||
pub(crate) mcp_tools: Option<HashMap<String, Tool>>,
|
||||
pub(crate) app_tools: Option<HashMap<String, ToolInfo>>,
|
||||
pub(crate) discoverable_tools: Option<Vec<DiscoverableTool>>,
|
||||
pub(crate) dynamic_tools: &'a [DynamicToolSpec],
|
||||
}
|
||||
|
||||
impl ToolRouter {
|
||||
pub fn from_config(
|
||||
config: &ToolsConfig,
|
||||
mcp_tools: Option<HashMap<String, Tool>>,
|
||||
app_tools: Option<HashMap<String, ToolInfo>>,
|
||||
dynamic_tools: &[DynamicToolSpec],
|
||||
) -> Self {
|
||||
let builder = build_specs(config, mcp_tools, app_tools, dynamic_tools);
|
||||
pub fn from_config(config: &ToolsConfig, params: ToolRouterParams<'_>) -> Self {
|
||||
let ToolRouterParams {
|
||||
mcp_tools,
|
||||
app_tools,
|
||||
discoverable_tools,
|
||||
dynamic_tools,
|
||||
} = params;
|
||||
let builder = build_specs_with_discoverable_tools(
|
||||
config,
|
||||
mcp_tools,
|
||||
app_tools,
|
||||
discoverable_tools,
|
||||
dynamic_tools,
|
||||
);
|
||||
let (specs, registry) = builder.build();
|
||||
|
||||
Self { registry, specs }
|
||||
@@ -287,6 +302,7 @@ mod tests {
|
||||
use super::ToolCall;
|
||||
use super::ToolCallSource;
|
||||
use super::ToolRouter;
|
||||
use super::ToolRouterParams;
|
||||
|
||||
#[tokio::test]
|
||||
async fn js_repl_tools_only_blocks_direct_tool_calls() -> anyhow::Result<()> {
|
||||
@@ -305,14 +321,17 @@ mod tests {
|
||||
let app_tools = Some(mcp_tools.clone());
|
||||
let router = ToolRouter::from_config(
|
||||
&turn.tools_config,
|
||||
Some(
|
||||
mcp_tools
|
||||
.into_iter()
|
||||
.map(|(name, tool)| (name, tool.tool))
|
||||
.collect(),
|
||||
),
|
||||
app_tools,
|
||||
turn.dynamic_tools.as_slice(),
|
||||
ToolRouterParams {
|
||||
mcp_tools: Some(
|
||||
mcp_tools
|
||||
.into_iter()
|
||||
.map(|(name, tool)| (name, tool.tool))
|
||||
.collect(),
|
||||
),
|
||||
app_tools,
|
||||
discoverable_tools: None,
|
||||
dynamic_tools: turn.dynamic_tools.as_slice(),
|
||||
},
|
||||
);
|
||||
|
||||
let call = ToolCall {
|
||||
@@ -359,14 +378,17 @@ mod tests {
|
||||
let app_tools = Some(mcp_tools.clone());
|
||||
let router = ToolRouter::from_config(
|
||||
&turn.tools_config,
|
||||
Some(
|
||||
mcp_tools
|
||||
.into_iter()
|
||||
.map(|(name, tool)| (name, tool.tool))
|
||||
.collect(),
|
||||
),
|
||||
app_tools,
|
||||
turn.dynamic_tools.as_slice(),
|
||||
ToolRouterParams {
|
||||
mcp_tools: Some(
|
||||
mcp_tools
|
||||
.into_iter()
|
||||
.map(|(name, tool)| (name, tool.tool))
|
||||
.collect(),
|
||||
),
|
||||
app_tools,
|
||||
discoverable_tools: None,
|
||||
dynamic_tools: turn.dynamic_tools.as_slice(),
|
||||
},
|
||||
);
|
||||
|
||||
let call = ToolCall {
|
||||
|
||||
@@ -146,7 +146,7 @@ pub(super) async fn try_run_zsh_fork(
|
||||
.macos_seatbelt_profile_extensions
|
||||
.clone(),
|
||||
codex_linux_sandbox_exe: ctx.turn.codex_linux_sandbox_exe.clone(),
|
||||
use_linux_sandbox_bwrap: ctx.turn.features.enabled(Feature::UseLinuxSandboxBwrap),
|
||||
use_legacy_landlock: ctx.turn.features.use_legacy_landlock(),
|
||||
};
|
||||
let main_execve_wrapper_exe = ctx
|
||||
.session
|
||||
@@ -258,7 +258,7 @@ pub(crate) async fn prepare_unified_exec_zsh_fork(
|
||||
.macos_seatbelt_profile_extensions
|
||||
.clone(),
|
||||
codex_linux_sandbox_exe: ctx.turn.codex_linux_sandbox_exe.clone(),
|
||||
use_linux_sandbox_bwrap: ctx.turn.features.enabled(Feature::UseLinuxSandboxBwrap),
|
||||
use_legacy_landlock: ctx.turn.features.use_legacy_landlock(),
|
||||
};
|
||||
let main_execve_wrapper_exe = ctx
|
||||
.session
|
||||
@@ -855,7 +855,7 @@ struct CoreShellCommandExecutor {
|
||||
#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
macos_seatbelt_profile_extensions: Option<MacOsSeatbeltProfileExtensions>,
|
||||
codex_linux_sandbox_exe: Option<PathBuf>,
|
||||
use_linux_sandbox_bwrap: bool,
|
||||
use_legacy_landlock: bool,
|
||||
}
|
||||
|
||||
struct PrepareSandboxedExecParams<'a> {
|
||||
@@ -1052,7 +1052,7 @@ impl CoreShellCommandExecutor {
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_seatbelt_profile_extensions,
|
||||
codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.as_ref(),
|
||||
use_linux_sandbox_bwrap: self.use_linux_sandbox_bwrap,
|
||||
use_legacy_landlock: self.use_legacy_landlock,
|
||||
windows_sandbox_level: self.windows_sandbox_level,
|
||||
})?;
|
||||
if let Some(network) = exec_request.network.as_ref() {
|
||||
|
||||
@@ -611,7 +611,7 @@ async fn prepare_escalated_exec_turn_default_preserves_macos_seatbelt_extensions
|
||||
..Default::default()
|
||||
}),
|
||||
codex_linux_sandbox_exe: None,
|
||||
use_linux_sandbox_bwrap: false,
|
||||
use_legacy_landlock: false,
|
||||
};
|
||||
|
||||
let prepared = executor
|
||||
@@ -660,7 +660,7 @@ async fn prepare_escalated_exec_permissions_preserve_macos_seatbelt_extensions()
|
||||
sandbox_policy_cwd: cwd.to_path_buf(),
|
||||
macos_seatbelt_profile_extensions: None,
|
||||
codex_linux_sandbox_exe: None,
|
||||
use_linux_sandbox_bwrap: false,
|
||||
use_legacy_landlock: false,
|
||||
};
|
||||
|
||||
let permissions = Permissions {
|
||||
@@ -737,7 +737,7 @@ async fn prepare_escalated_exec_permission_profile_unions_turn_and_requested_mac
|
||||
..Default::default()
|
||||
}),
|
||||
codex_linux_sandbox_exe: None,
|
||||
use_linux_sandbox_bwrap: false,
|
||||
use_legacy_landlock: false,
|
||||
};
|
||||
|
||||
let prepared = executor
|
||||
|
||||
@@ -330,7 +330,7 @@ pub(crate) struct SandboxAttempt<'a> {
|
||||
pub(crate) manager: &'a SandboxManager,
|
||||
pub(crate) sandbox_cwd: &'a Path,
|
||||
pub codex_linux_sandbox_exe: Option<&'a std::path::PathBuf>,
|
||||
pub use_linux_sandbox_bwrap: bool,
|
||||
pub use_legacy_landlock: bool,
|
||||
pub windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel,
|
||||
}
|
||||
|
||||
@@ -353,7 +353,7 @@ impl<'a> SandboxAttempt<'a> {
|
||||
#[cfg(target_os = "macos")]
|
||||
macos_seatbelt_profile_extensions: None,
|
||||
codex_linux_sandbox_exe: self.codex_linux_sandbox_exe,
|
||||
use_linux_sandbox_bwrap: self.use_linux_sandbox_bwrap,
|
||||
use_legacy_landlock: self.use_legacy_landlock,
|
||||
windows_sandbox_level: self.windows_sandbox_level,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,11 +8,18 @@ use crate::features::Features;
|
||||
use crate::mcp_connection_manager::ToolInfo;
|
||||
use crate::models_manager::collaboration_mode_presets::CollaborationModesConfig;
|
||||
use crate::original_image_detail::can_request_original_image_detail;
|
||||
use crate::tools::code_mode::DEFAULT_WAIT_YIELD_TIME_MS;
|
||||
use crate::tools::code_mode::PUBLIC_TOOL_NAME;
|
||||
use crate::tools::code_mode::WAIT_TOOL_NAME;
|
||||
use crate::tools::code_mode_description::augment_tool_spec_for_code_mode;
|
||||
use crate::tools::discoverable::DiscoverablePluginInfo;
|
||||
use crate::tools::discoverable::DiscoverableTool;
|
||||
use crate::tools::discoverable::DiscoverableToolAction;
|
||||
use crate::tools::discoverable::DiscoverableToolType;
|
||||
use crate::tools::handlers::PLAN_TOOL;
|
||||
use crate::tools::handlers::TOOL_SEARCH_DEFAULT_LIMIT;
|
||||
use crate::tools::handlers::TOOL_SEARCH_TOOL_NAME;
|
||||
use crate::tools::handlers::TOOL_SUGGEST_TOOL_NAME;
|
||||
use crate::tools::handlers::agent_jobs::BatchJobHandler;
|
||||
use crate::tools::handlers::apply_patch::create_apply_patch_freeform_tool;
|
||||
use crate::tools::handlers::apply_patch::create_apply_patch_json_tool;
|
||||
@@ -44,6 +51,8 @@ use std::collections::HashMap;
|
||||
|
||||
const TOOL_SEARCH_DESCRIPTION_TEMPLATE: &str =
|
||||
include_str!("../../templates/search_tool/tool_description.md");
|
||||
const TOOL_SUGGEST_DESCRIPTION_TEMPLATE: &str =
|
||||
include_str!("../../templates/search_tool/tool_suggest_description.md");
|
||||
const WEB_SEARCH_CONTENT_TYPES: [&str; 2] = ["text", "image"];
|
||||
|
||||
fn unified_exec_output_schema() -> JsonValue {
|
||||
@@ -105,6 +114,7 @@ pub(crate) struct ToolsConfig {
|
||||
pub image_gen_tool: bool,
|
||||
pub agent_roles: BTreeMap<String, AgentRoleConfig>,
|
||||
pub search_tool: bool,
|
||||
pub tool_suggest: bool,
|
||||
pub request_permission_enabled: bool,
|
||||
pub request_permissions_tool_enabled: bool,
|
||||
pub code_mode_enabled: bool,
|
||||
@@ -148,6 +158,7 @@ impl ToolsConfig {
|
||||
let include_default_mode_request_user_input =
|
||||
include_request_user_input && features.enabled(Feature::DefaultModeRequestUserInput);
|
||||
let include_search_tool = features.enabled(Feature::Apps);
|
||||
let include_tool_suggest = include_search_tool && features.enabled(Feature::ToolSuggest);
|
||||
let include_original_image_detail = can_request_original_image_detail(features, model_info);
|
||||
let include_artifact_tools =
|
||||
features.enabled(Feature::Artifact) && codex_artifacts::can_manage_artifact_runtime();
|
||||
@@ -215,6 +226,7 @@ impl ToolsConfig {
|
||||
image_gen_tool: include_image_gen_tool,
|
||||
agent_roles: BTreeMap::new(),
|
||||
search_tool: include_search_tool,
|
||||
tool_suggest: include_tool_suggest,
|
||||
request_permission_enabled,
|
||||
request_permissions_tool_enabled,
|
||||
code_mode_enabled: include_code_mode,
|
||||
@@ -579,6 +591,55 @@ fn create_write_stdin_tool() -> ToolSpec {
|
||||
})
|
||||
}
|
||||
|
||||
fn create_exec_wait_tool() -> ToolSpec {
|
||||
let properties = BTreeMap::from([
|
||||
(
|
||||
"session_id".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some("Identifier of the running exec session.".to_string()),
|
||||
},
|
||||
),
|
||||
(
|
||||
"yield_time_ms".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some(
|
||||
"How long to wait (in milliseconds) for more output before yielding again."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"max_tokens".to_string(),
|
||||
JsonSchema::Number {
|
||||
description: Some(
|
||||
"Maximum number of output tokens to return for this wait call.".to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"terminate".to_string(),
|
||||
JsonSchema::Boolean {
|
||||
description: Some("Whether to terminate the running exec session.".to_string()),
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: WAIT_TOOL_NAME.to_string(),
|
||||
description: format!(
|
||||
"Waits on a yielded `{PUBLIC_TOOL_NAME}` session and returns new output or completion."
|
||||
),
|
||||
strict: false,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec!["session_id".to_string()]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
defer_loading: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn create_shell_tool(request_permission_enabled: bool) -> ToolSpec {
|
||||
let mut properties = BTreeMap::from([
|
||||
(
|
||||
@@ -843,7 +904,10 @@ fn create_spawn_agent_tool(config: &ToolsConfig) -> ToolSpec {
|
||||
name: "spawn_agent".to_string(),
|
||||
description: format!(
|
||||
r#"
|
||||
Only use `spawn_agent` if and only if the user explicitly asked for sub-agents or parallel agent work. Spawn a sub-agent for a well-scoped task. Returns the agent id (and user-facing nickname when available) to use to communicate with this agent. This spawn_agent tool provides you access to smaller but more efficient sub-agents. A mini model can solve many tasks faster than the main model. You should follow the rules and guidelines below to use this tool.
|
||||
Only use `spawn_agent` if and only if the user explicitly asks for sub-agents, delegation, or parallel agent work.
|
||||
Requests for depth, thoroughness, research, investigation, or detailed codebase analysis do not count as permission to spawn.
|
||||
Agent-role guidance below only helps choose which agent to use after spawning is already authorized; it never authorizes spawning by itself.
|
||||
Spawn a sub-agent for a well-scoped task. Returns the agent id (and user-facing nickname when available) to use to communicate with this agent. This spawn_agent tool provides you access to smaller but more efficient sub-agents. A mini model can solve many tasks faster than the main model. You should follow the rules and guidelines below to use this tool.
|
||||
|
||||
{available_models_description}
|
||||
### When to delegate vs. do the subtask yourself
|
||||
@@ -1451,6 +1515,133 @@ fn create_tool_search_tool(app_tools: &HashMap<String, ToolInfo>) -> ToolSpec {
|
||||
}
|
||||
}
|
||||
|
||||
fn create_tool_suggest_tool(discoverable_tools: &[DiscoverableTool]) -> ToolSpec {
|
||||
let discoverable_tool_ids = discoverable_tools
|
||||
.iter()
|
||||
.map(DiscoverableTool::id)
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let properties = BTreeMap::from([
|
||||
(
|
||||
"tool_type".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Type of discoverable tool to suggest. Use \"connector\" or \"plugin\"."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"action_type".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Suggested action for the tool. Use \"install\" or \"enable\".".to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
(
|
||||
"tool_id".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(format!(
|
||||
"Connector or plugin id to suggest. Must be one of: {discoverable_tool_ids}."
|
||||
)),
|
||||
},
|
||||
),
|
||||
(
|
||||
"suggest_reason".to_string(),
|
||||
JsonSchema::String {
|
||||
description: Some(
|
||||
"Concise one-line user-facing reason why this tool can help with the current request."
|
||||
.to_string(),
|
||||
),
|
||||
},
|
||||
),
|
||||
]);
|
||||
let description = TOOL_SUGGEST_DESCRIPTION_TEMPLATE.replace(
|
||||
"{{discoverable_tools}}",
|
||||
format_discoverable_tools(discoverable_tools).as_str(),
|
||||
);
|
||||
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: TOOL_SUGGEST_TOOL_NAME.to_string(),
|
||||
description,
|
||||
strict: false,
|
||||
defer_loading: None,
|
||||
parameters: JsonSchema::Object {
|
||||
properties,
|
||||
required: Some(vec![
|
||||
"tool_type".to_string(),
|
||||
"action_type".to_string(),
|
||||
"tool_id".to_string(),
|
||||
"suggest_reason".to_string(),
|
||||
]),
|
||||
additional_properties: Some(false.into()),
|
||||
},
|
||||
output_schema: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn format_discoverable_tools(discoverable_tools: &[DiscoverableTool]) -> String {
|
||||
let mut discoverable_tools = discoverable_tools.to_vec();
|
||||
discoverable_tools.sort_by(|left, right| {
|
||||
left.name()
|
||||
.cmp(right.name())
|
||||
.then_with(|| left.id().cmp(right.id()))
|
||||
});
|
||||
|
||||
discoverable_tools
|
||||
.into_iter()
|
||||
.map(|tool| {
|
||||
let description = tool
|
||||
.description()
|
||||
.filter(|description| !description.trim().is_empty())
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| match &tool {
|
||||
DiscoverableTool::Connector(_) => "No description provided.".to_string(),
|
||||
DiscoverableTool::Plugin(plugin) => format_plugin_summary(plugin.as_ref()),
|
||||
});
|
||||
let default_action = match tool.tool_type() {
|
||||
DiscoverableToolType::Connector => DiscoverableToolAction::Install,
|
||||
DiscoverableToolType::Plugin => DiscoverableToolAction::Enable,
|
||||
};
|
||||
format!(
|
||||
"- {} (id: `{}`, type: {}, action: {}): {}",
|
||||
tool.name(),
|
||||
tool.id(),
|
||||
tool.tool_type().as_str(),
|
||||
default_action.as_str(),
|
||||
description
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn format_plugin_summary(plugin: &DiscoverablePluginInfo) -> String {
|
||||
let mut details = Vec::new();
|
||||
if plugin.has_skills {
|
||||
details.push("skills".to_string());
|
||||
}
|
||||
if !plugin.mcp_server_names.is_empty() {
|
||||
details.push(format!(
|
||||
"MCP servers: {}",
|
||||
plugin.mcp_server_names.join(", ")
|
||||
));
|
||||
}
|
||||
if !plugin.app_connector_ids.is_empty() {
|
||||
details.push(format!(
|
||||
"app connectors: {}",
|
||||
plugin.app_connector_ids.join(", ")
|
||||
));
|
||||
}
|
||||
|
||||
if details.is_empty() {
|
||||
"No description provided.".to_string()
|
||||
} else {
|
||||
details.join("; ")
|
||||
}
|
||||
}
|
||||
|
||||
fn create_read_file_tool() -> ToolSpec {
|
||||
let indentation_properties = BTreeMap::from([
|
||||
(
|
||||
@@ -1692,7 +1883,7 @@ source: /[\s\S]+/
|
||||
enabled_tool_names.join(", ")
|
||||
};
|
||||
let description = format!(
|
||||
"Runs JavaScript in a Node-backed `node:vm` context. This is a freeform tool: send raw JavaScript source text (no JSON/quotes/markdown fences). Direct tool calls remain available while `{PUBLIC_TOOL_NAME}` is enabled. Inside JavaScript, import nested tools from `tools.js`, for example `import {{ exec_command }} from \"tools.js\"` or `import {{ ALL_TOOLS }} from \"tools.js\"` to inspect the available `{{ module, name, description }}` entries. Namespaced tools are also available from `tools/<namespace...>.js`; MCP tools use `tools/mcp/<server>.js`, for example `import {{ append_notebook_logs_chart }} from \"tools/mcp/ologs.js\"`. Nested tool calls resolve to their code-mode result values. Import `{{ output_text, output_image, set_max_output_tokens_per_exec_call, store, load }}` from `\"@openai/code_mode\"` (or `\"openai/code_mode\"`); `output_text(value)` surfaces text back to the model and stringifies non-string objects when possible, `output_image(imageUrl)` appends an `input_image` content item for `http(s)` or `data:` URLs, `store(key, value)` persists JSON-serializable values across `{PUBLIC_TOOL_NAME}` calls in the current session, `load(key)` returns a cloned stored value or `undefined`, and `set_max_output_tokens_per_exec_call(value)` sets the token budget used to truncate the final Rust-side result of the current `{PUBLIC_TOOL_NAME}` execution. The default is `10000`. This guards the overall `{PUBLIC_TOOL_NAME}` output, not individual nested tool invocations. The returned content starts with a separate `Script completed` or `Script failed` text item that includes wall time. When truncation happens, the final text may include `Total output lines:` and the usual `…N tokens truncated…` marker. Function tools require JSON object arguments. Freeform tools require raw strings. `add_content(value)` remains available for compatibility with a content item, content-item array, or string. Structured nested-tool results should be converted to text first, for example with `JSON.stringify(...)`. Only content passed to `output_text(...)`, `output_image(...)`, or `add_content(value)` is surfaced back to the model. Enabled nested tools: {enabled_list}."
|
||||
"Runs JavaScript in a Node-backed `node:vm` context. This is a freeform tool: send raw JavaScript source text (no JSON/quotes/markdown fences). Direct tool calls remain available while `{PUBLIC_TOOL_NAME}` is enabled. Inside JavaScript, import nested tools from `tools.js`, for example `import {{ exec_command }} from \"tools.js\"` or `import {{ ALL_TOOLS }} from \"tools.js\"` to inspect the available `{{ module, name, description }}` entries. Namespaced tools are also available from `tools/<namespace...>.js`; MCP tools use `tools/mcp/<server>.js`, for example `import {{ append_notebook_logs_chart }} from \"tools/mcp/ologs.js\"`. Nested tool calls resolve to their code-mode result values. Import `{{ output_text, output_image, set_max_output_tokens_per_exec_call, set_yield_time, store, load }}` from `\"@openai/code_mode\"` (or `\"openai/code_mode\"`); `output_text(value)` surfaces text back to the model and stringifies non-string objects when possible, `output_image(imageUrl)` appends an `input_image` content item for `http(s)` or `data:` URLs, `store(key, value)` persists JSON-serializable values across `{PUBLIC_TOOL_NAME}` calls in the current session, `load(key)` returns a cloned stored value or `undefined`, `set_max_output_tokens_per_exec_call(value)` sets the token budget used to truncate direct `{PUBLIC_TOOL_NAME}` returns, and `{WAIT_TOOL_NAME}` uses its own `max_tokens` argument with a default of `10000`. `set_yield_time(value)` asks `{PUBLIC_TOOL_NAME}` to return early if the script is still running after that many milliseconds so `{WAIT_TOOL_NAME}` can resume it later. The default wait timeout for `{WAIT_TOOL_NAME}` is {DEFAULT_WAIT_YIELD_TIME_MS}. The returned content starts with a separate `Script completed`, `Script failed`, or `Script running with session ID …` text item that includes wall time. When truncation happens, the final text may include `Total output lines:` and the usual `…N tokens truncated…` marker. Function tools require JSON object arguments. Freeform tools require raw strings. `add_content(value)` remains available for compatibility with a content item, content-item array, or string. Structured nested-tool results should be converted to text first, for example with `JSON.stringify(...)`. Only content passed to `output_text(...)`, `output_image(...)`, or `add_content(value)` is surfaced back to the model. Enabled nested tools: {enabled_list}."
|
||||
);
|
||||
|
||||
ToolSpec::Freeform(FreeformTool {
|
||||
@@ -1707,7 +1898,9 @@ source: /[\s\S]+/
|
||||
}
|
||||
|
||||
fn is_code_mode_nested_tool(spec: &ToolSpec) -> bool {
|
||||
spec.name() != PUBLIC_TOOL_NAME && matches!(spec, ToolSpec::Function(_) | ToolSpec::Freeform(_))
|
||||
spec.name() != PUBLIC_TOOL_NAME
|
||||
&& spec.name() != WAIT_TOOL_NAME
|
||||
&& matches!(spec, ToolSpec::Function(_) | ToolSpec::Freeform(_))
|
||||
}
|
||||
|
||||
fn create_list_mcp_resources_tool() -> ToolSpec {
|
||||
@@ -2083,15 +2276,27 @@ fn sanitize_json_schema(value: &mut JsonValue) {
|
||||
}
|
||||
|
||||
/// Builds the tool registry builder while collecting tool specs for later serialization.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn build_specs(
|
||||
config: &ToolsConfig,
|
||||
mcp_tools: Option<HashMap<String, rmcp::model::Tool>>,
|
||||
app_tools: Option<HashMap<String, ToolInfo>>,
|
||||
dynamic_tools: &[DynamicToolSpec],
|
||||
) -> ToolRegistryBuilder {
|
||||
build_specs_with_discoverable_tools(config, mcp_tools, app_tools, None, dynamic_tools)
|
||||
}
|
||||
|
||||
pub(crate) fn build_specs_with_discoverable_tools(
|
||||
config: &ToolsConfig,
|
||||
mcp_tools: Option<HashMap<String, rmcp::model::Tool>>,
|
||||
app_tools: Option<HashMap<String, ToolInfo>>,
|
||||
discoverable_tools: Option<Vec<DiscoverableTool>>,
|
||||
dynamic_tools: &[DynamicToolSpec],
|
||||
) -> ToolRegistryBuilder {
|
||||
use crate::tools::handlers::ApplyPatchHandler;
|
||||
use crate::tools::handlers::ArtifactsHandler;
|
||||
use crate::tools::handlers::CodeModeHandler;
|
||||
use crate::tools::handlers::CodeModeWaitHandler;
|
||||
use crate::tools::handlers::DynamicToolHandler;
|
||||
use crate::tools::handlers::GrepFilesHandler;
|
||||
use crate::tools::handlers::JsReplHandler;
|
||||
@@ -2108,6 +2313,7 @@ pub(crate) fn build_specs(
|
||||
use crate::tools::handlers::ShellHandler;
|
||||
use crate::tools::handlers::TestSyncHandler;
|
||||
use crate::tools::handlers::ToolSearchHandler;
|
||||
use crate::tools::handlers::ToolSuggestHandler;
|
||||
use crate::tools::handlers::UnifiedExecHandler;
|
||||
use crate::tools::handlers::ViewImageHandler;
|
||||
use std::sync::Arc;
|
||||
@@ -2127,7 +2333,9 @@ pub(crate) fn build_specs(
|
||||
let request_user_input_handler = Arc::new(RequestUserInputHandler {
|
||||
default_mode_request_user_input: config.default_mode_request_user_input,
|
||||
});
|
||||
let tool_suggest_handler = Arc::new(ToolSuggestHandler);
|
||||
let code_mode_handler = Arc::new(CodeModeHandler);
|
||||
let code_mode_wait_handler = Arc::new(CodeModeWaitHandler);
|
||||
let js_repl_handler = Arc::new(JsReplHandler);
|
||||
let js_repl_reset_handler = Arc::new(JsReplResetHandler);
|
||||
let artifacts_handler = Arc::new(ArtifactsHandler);
|
||||
@@ -2135,10 +2343,11 @@ pub(crate) fn build_specs(
|
||||
|
||||
if config.code_mode_enabled {
|
||||
let nested_config = config.for_code_mode_nested_tools();
|
||||
let (nested_specs, _) = build_specs(
|
||||
let (nested_specs, _) = build_specs_with_discoverable_tools(
|
||||
&nested_config,
|
||||
mcp_tools.clone(),
|
||||
app_tools.clone(),
|
||||
None,
|
||||
dynamic_tools,
|
||||
)
|
||||
.build();
|
||||
@@ -2157,6 +2366,13 @@ pub(crate) fn build_specs(
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
builder.register_handler(PUBLIC_TOOL_NAME, code_mode_handler);
|
||||
push_tool_spec(
|
||||
&mut builder,
|
||||
create_exec_wait_tool(),
|
||||
false,
|
||||
config.code_mode_enabled,
|
||||
);
|
||||
builder.register_handler(WAIT_TOOL_NAME, code_mode_wait_handler);
|
||||
}
|
||||
|
||||
match &config.shell_type {
|
||||
@@ -2304,6 +2520,15 @@ pub(crate) fn build_specs(
|
||||
}
|
||||
}
|
||||
|
||||
if config.tool_suggest
|
||||
&& let Some(discoverable_tools) = discoverable_tools
|
||||
.as_ref()
|
||||
.filter(|tools| !tools.is_empty())
|
||||
{
|
||||
builder.push_spec_with_parallel_support(create_tool_suggest_tool(discoverable_tools), true);
|
||||
builder.register_handler(TOOL_SUGGEST_TOOL_NAME, tool_suggest_handler);
|
||||
}
|
||||
|
||||
if let Some(apply_patch_tool_type) = &config.apply_patch_tool_type {
|
||||
match apply_patch_tool_type {
|
||||
ApplyPatchToolType::Freeform => {
|
||||
@@ -2565,6 +2790,7 @@ mod tests {
|
||||
use crate::models_manager::manager::ModelsManager;
|
||||
use crate::models_manager::model_info::with_config_overrides;
|
||||
use crate::tools::registry::ConfiguredToolSpec;
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use codex_protocol::openai_models::InputModality;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
@@ -2590,6 +2816,25 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn discoverable_connector(id: &str, name: &str, description: &str) -> DiscoverableTool {
|
||||
let slug = name.replace(' ', "-").to_lowercase();
|
||||
DiscoverableTool::Connector(Box::new(AppInfo {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
description: Some(description.to_string()),
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: Some(format!("https://chatgpt.com/apps/{slug}/{id}")),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_tool_to_openai_tool_inserts_empty_properties() {
|
||||
let mut schema = rmcp::model::JsonObject::new();
|
||||
@@ -4147,7 +4392,6 @@ mod tests {
|
||||
});
|
||||
let (tools, _) = build_specs(&tools_config, None, app_tools.clone(), &[]).build();
|
||||
assert_lacks_tool_name(&tools, TOOL_SEARCH_TOOL_NAME);
|
||||
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::Apps);
|
||||
let available_models = Vec::new();
|
||||
@@ -4162,6 +4406,41 @@ mod tests {
|
||||
assert_contains_tool_names(&tools, &[TOOL_SEARCH_TOOL_NAME]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_suggest_is_not_registered_without_feature_flag() {
|
||||
let config = test_config();
|
||||
let model_info =
|
||||
ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::Apps);
|
||||
let available_models = Vec::new();
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
available_models: &available_models,
|
||||
features: &features,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
session_source: SessionSource::Cli,
|
||||
});
|
||||
let (tools, _) = build_specs_with_discoverable_tools(
|
||||
&tools_config,
|
||||
None,
|
||||
None,
|
||||
Some(vec![discoverable_connector(
|
||||
"connector_2128aebfecb84f64a069897515042a44",
|
||||
"Google Calendar",
|
||||
"Plan events and schedules.",
|
||||
)]),
|
||||
&[],
|
||||
)
|
||||
.build();
|
||||
|
||||
assert!(
|
||||
!tools
|
||||
.iter()
|
||||
.any(|tool| tool_name(&tool.spec) == TOOL_SUGGEST_TOOL_NAME)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_tool_description_handles_no_enabled_apps() {
|
||||
let config = test_config();
|
||||
@@ -4253,6 +4532,89 @@ mod tests {
|
||||
assert!(registry.has_handler(alias.as_str(), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_suggest_description_lists_discoverable_tools() {
|
||||
let config = test_config();
|
||||
let model_info =
|
||||
ModelsManager::construct_model_info_offline_for_tests("gpt-5-codex", &config);
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::Apps);
|
||||
features.enable(Feature::ToolSuggest);
|
||||
let available_models = Vec::new();
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
available_models: &available_models,
|
||||
features: &features,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
session_source: SessionSource::Cli,
|
||||
});
|
||||
|
||||
let discoverable_tools = vec![
|
||||
discoverable_connector(
|
||||
"connector_2128aebfecb84f64a069897515042a44",
|
||||
"Google Calendar",
|
||||
"Plan events and schedules.",
|
||||
),
|
||||
discoverable_connector(
|
||||
"connector_68df038e0ba48191908c8434991bbac2",
|
||||
"Gmail",
|
||||
"Find and summarize email threads.",
|
||||
),
|
||||
DiscoverableTool::Plugin(Box::new(DiscoverablePluginInfo {
|
||||
id: "sample@test".to_string(),
|
||||
name: "Sample Plugin".to_string(),
|
||||
description: None,
|
||||
has_skills: true,
|
||||
mcp_server_names: vec!["sample-docs".to_string()],
|
||||
app_connector_ids: vec!["connector_sample".to_string()],
|
||||
})),
|
||||
];
|
||||
|
||||
let (tools, _) = build_specs_with_discoverable_tools(
|
||||
&tools_config,
|
||||
None,
|
||||
None,
|
||||
Some(discoverable_tools),
|
||||
&[],
|
||||
)
|
||||
.build();
|
||||
|
||||
let tool_suggest = find_tool(&tools, TOOL_SUGGEST_TOOL_NAME);
|
||||
let ToolSpec::Function(ResponsesApiTool {
|
||||
description,
|
||||
parameters,
|
||||
..
|
||||
}) = &tool_suggest.spec
|
||||
else {
|
||||
panic!("expected function tool");
|
||||
};
|
||||
assert!(description.contains("Google Calendar"));
|
||||
assert!(description.contains("Gmail"));
|
||||
assert!(description.contains("Sample Plugin"));
|
||||
assert!(description.contains("Plan events and schedules."));
|
||||
assert!(description.contains("Find and summarize email threads."));
|
||||
assert!(description.contains("id: `sample@test`, type: plugin, action: enable"));
|
||||
assert!(
|
||||
description
|
||||
.contains("skills; MCP servers: sample-docs; app connectors: connector_sample")
|
||||
);
|
||||
assert!(
|
||||
description.contains("DO NOT explore or recommend tools that are not on this list.")
|
||||
);
|
||||
let JsonSchema::Object { required, .. } = parameters else {
|
||||
panic!("expected object parameters");
|
||||
};
|
||||
assert_eq!(
|
||||
required.as_ref(),
|
||||
Some(&vec![
|
||||
"tool_type".to_string(),
|
||||
"action_type".to_string(),
|
||||
"tool_id".to_string(),
|
||||
"suggest_reason".to_string(),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mcp_tool_property_missing_type_defaults_to_string() {
|
||||
let config = test_config();
|
||||
|
||||
@@ -132,16 +132,11 @@ impl TurnMetadataState {
|
||||
cwd: PathBuf,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
windows_sandbox_level: WindowsSandboxLevel,
|
||||
use_linux_sandbox_bwrap: bool,
|
||||
use_legacy_landlock: bool,
|
||||
) -> Self {
|
||||
let repo_root = get_git_repo_root(&cwd).map(|root| root.to_string_lossy().into_owned());
|
||||
let sandbox = Some(
|
||||
sandbox_tag(
|
||||
sandbox_policy,
|
||||
windows_sandbox_level,
|
||||
use_linux_sandbox_bwrap,
|
||||
)
|
||||
.to_string(),
|
||||
sandbox_tag(sandbox_policy, windows_sandbox_level, use_legacy_landlock).to_string(),
|
||||
);
|
||||
let base_metadata = build_turn_metadata_bag(Some(turn_id), sandbox, None, None);
|
||||
let base_header = base_metadata
|
||||
@@ -300,19 +295,19 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_metadata_state_respects_linux_bubblewrap_toggle() {
|
||||
fn turn_metadata_state_respects_legacy_landlock_flag() {
|
||||
let temp_dir = TempDir::new().expect("temp dir");
|
||||
let cwd = temp_dir.path().to_path_buf();
|
||||
let sandbox_policy = SandboxPolicy::new_read_only_policy();
|
||||
|
||||
let without_bubblewrap = TurnMetadataState::new(
|
||||
let default_bubblewrap = TurnMetadataState::new(
|
||||
"turn-a".to_string(),
|
||||
cwd.clone(),
|
||||
&sandbox_policy,
|
||||
WindowsSandboxLevel::Disabled,
|
||||
false,
|
||||
);
|
||||
let with_bubblewrap = TurnMetadataState::new(
|
||||
let legacy_landlock = TurnMetadataState::new(
|
||||
"turn-b".to_string(),
|
||||
cwd,
|
||||
&sandbox_policy,
|
||||
@@ -320,30 +315,33 @@ mod tests {
|
||||
true,
|
||||
);
|
||||
|
||||
let without_bubblewrap_header = without_bubblewrap
|
||||
let default_bubblewrap_header = default_bubblewrap
|
||||
.current_header_value()
|
||||
.expect("without_bubblewrap_header");
|
||||
let with_bubblewrap_header = with_bubblewrap
|
||||
.expect("default_bubblewrap_header");
|
||||
let legacy_landlock_header = legacy_landlock
|
||||
.current_header_value()
|
||||
.expect("with_bubblewrap_header");
|
||||
.expect("legacy_landlock_header");
|
||||
|
||||
let without_bubblewrap_json: Value =
|
||||
serde_json::from_str(&without_bubblewrap_header).expect("without_bubblewrap_json");
|
||||
let with_bubblewrap_json: Value =
|
||||
serde_json::from_str(&with_bubblewrap_header).expect("with_bubblewrap_json");
|
||||
let default_bubblewrap_json: Value =
|
||||
serde_json::from_str(&default_bubblewrap_header).expect("default_bubblewrap_json");
|
||||
let legacy_landlock_json: Value =
|
||||
serde_json::from_str(&legacy_landlock_header).expect("legacy_landlock_json");
|
||||
|
||||
let without_bubblewrap_sandbox = without_bubblewrap_json
|
||||
let default_bubblewrap_sandbox = default_bubblewrap_json
|
||||
.get("sandbox")
|
||||
.and_then(Value::as_str);
|
||||
let with_bubblewrap_sandbox = with_bubblewrap_json.get("sandbox").and_then(Value::as_str);
|
||||
let legacy_landlock_sandbox = legacy_landlock_json.get("sandbox").and_then(Value::as_str);
|
||||
|
||||
let expected_with_bubblewrap =
|
||||
sandbox_tag(&sandbox_policy, WindowsSandboxLevel::Disabled, true);
|
||||
assert_eq!(with_bubblewrap_sandbox, Some(expected_with_bubblewrap));
|
||||
let expected_default_bubblewrap =
|
||||
sandbox_tag(&sandbox_policy, WindowsSandboxLevel::Disabled, false);
|
||||
assert_eq!(
|
||||
default_bubblewrap_sandbox,
|
||||
Some(expected_default_bubblewrap)
|
||||
);
|
||||
|
||||
if cfg!(target_os = "linux") {
|
||||
assert_eq!(with_bubblewrap_sandbox, Some("linux_bubblewrap"));
|
||||
assert_ne!(with_bubblewrap_sandbox, without_bubblewrap_sandbox);
|
||||
assert_eq!(default_bubblewrap_sandbox, Some("linux_bubblewrap"));
|
||||
assert_ne!(default_bubblewrap_sandbox, legacy_landlock_sandbox);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Tool suggestion discovery
|
||||
|
||||
Suggests a discoverable connector or plugin when the user clearly wants a capability that is not currently available in the active `tools` list.
|
||||
|
||||
Use this ONLY when:
|
||||
- There's no available tool to handle the user's request
|
||||
- And tool_search fails to find a good match
|
||||
- AND the user's request strongly matches one of the discoverable tools listed below.
|
||||
|
||||
Tool suggestions should only use the discoverable tools listed here. DO NOT explore or recommend tools that are not on this list.
|
||||
|
||||
Discoverable tools:
|
||||
{{discoverable_tools}}
|
||||
|
||||
Workflow:
|
||||
|
||||
1. Match the user's request against the discoverable tools list above.
|
||||
2. If one tool clearly fits, call `tool_suggest` with:
|
||||
- `tool_type`: `connector` or `plugin`
|
||||
- `action_type`: `install` or `enable`
|
||||
- `tool_id`: exact id from the discoverable tools list above
|
||||
- `suggest_reason`: concise one-line user-facing reason this tool can help with the current request
|
||||
3. After the suggestion flow completes:
|
||||
- if the user finished the install or enable flow, continue by searching again or using the newly available tool
|
||||
- if the user did not finish, continue without that tool, and don't suggest that tool again unless the user explicitly asks you to.
|
||||
@@ -12,6 +12,8 @@ use wiremock::matchers::path_regex;
|
||||
|
||||
const CONNECTOR_ID: &str = "calendar";
|
||||
const CONNECTOR_NAME: &str = "Calendar";
|
||||
const DISCOVERABLE_CALENDAR_ID: &str = "connector_2128aebfecb84f64a069897515042a44";
|
||||
const DISCOVERABLE_GMAIL_ID: &str = "connector_68df038e0ba48191908c8434991bbac2";
|
||||
const CONNECTOR_DESCRIPTION: &str = "Plan events and manage your calendar.";
|
||||
const PROTOCOL_VERSION: &str = "2025-11-25";
|
||||
const SERVER_NAME: &str = "codex-apps-test";
|
||||
@@ -32,6 +34,7 @@ impl AppsTestServer {
|
||||
connector_name: &str,
|
||||
) -> Result<Self> {
|
||||
mount_oauth_metadata(server).await;
|
||||
mount_connectors_directory(server).await;
|
||||
mount_streamable_http_json_rpc(
|
||||
server,
|
||||
connector_name.to_string(),
|
||||
@@ -56,6 +59,37 @@ async fn mount_oauth_metadata(server: &MockServer) {
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn mount_connectors_directory(server: &MockServer) {
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/connectors/directory/list"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"apps": [
|
||||
{
|
||||
"id": DISCOVERABLE_CALENDAR_ID,
|
||||
"name": "Google Calendar",
|
||||
"description": "Plan events and schedules.",
|
||||
},
|
||||
{
|
||||
"id": DISCOVERABLE_GMAIL_ID,
|
||||
"name": "Gmail",
|
||||
"description": "Find and summarize email threads.",
|
||||
}
|
||||
],
|
||||
"nextToken": null
|
||||
})))
|
||||
.mount(server)
|
||||
.await;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/connectors/directory/list_workspace"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
|
||||
"apps": [],
|
||||
"nextToken": null
|
||||
})))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn mount_streamable_http_json_rpc(
|
||||
server: &MockServer,
|
||||
connector_name: String,
|
||||
|
||||
@@ -1321,7 +1321,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
expectation: Expectation::FileNotCreated {
|
||||
target: TargetPath::Workspace("ro_never.txt"),
|
||||
message_contains: if cfg!(target_os = "linux") {
|
||||
&["Permission denied"]
|
||||
&["Permission denied|Read-only file system"]
|
||||
} else {
|
||||
&[
|
||||
"Permission denied|Operation not permitted|operation not permitted|\
|
||||
@@ -1468,7 +1468,7 @@ fn scenarios() -> Vec<ScenarioSpec> {
|
||||
expectation: Expectation::FileNotCreated {
|
||||
target: TargetPath::OutsideWorkspace("ww_never.txt"),
|
||||
message_contains: if cfg!(target_os = "linux") {
|
||||
&["Permission denied"]
|
||||
&["Permission denied|Read-only file system"]
|
||||
} else {
|
||||
&[
|
||||
"Permission denied|Operation not permitted|operation not permitted|\
|
||||
@@ -2290,20 +2290,17 @@ allow_local_binding = true
|
||||
test.config.permissions.network.is_some(),
|
||||
"expected managed network proxy config to be present"
|
||||
);
|
||||
let runtime_proxy = test
|
||||
.session_configured
|
||||
test.session_configured
|
||||
.network_proxy
|
||||
.as_ref()
|
||||
.expect("expected runtime managed network proxy addresses");
|
||||
let proxy_addr = runtime_proxy.http_addr.as_str();
|
||||
|
||||
let call_id_first = "allow-network-first";
|
||||
// Use the same urllib-based pattern as the other network integration tests,
|
||||
// but point it at the runtime proxy directly so the blocked host reliably
|
||||
// produces a network approval request without relying on curl.
|
||||
let fetch_command = format!(
|
||||
"python3 -c \"import urllib.request; proxy = urllib.request.ProxyHandler({{'http': 'http://{proxy_addr}'}}); opener = urllib.request.build_opener(proxy); print('OK:' + opener.open('http://codex-network-test.invalid', timeout=30).read().decode(errors='replace'))\""
|
||||
);
|
||||
// Use urllib without overriding proxy settings so managed-network sessions
|
||||
// continue to exercise the env-based proxy routing path under bubblewrap.
|
||||
let fetch_command =
|
||||
"python3 -c \"import urllib.request; opener = urllib.request.build_opener(urllib.request.ProxyHandler()); print('OK:' + opener.open('http://codex-network-test.invalid', timeout=30).read().decode(errors='replace'))\""
|
||||
.to_string();
|
||||
let first_event = shell_event(
|
||||
call_id_first,
|
||||
&fetch_command,
|
||||
|
||||
@@ -21,6 +21,7 @@ use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use wiremock::MockServer;
|
||||
|
||||
@@ -32,6 +33,16 @@ fn custom_tool_output_items(req: &ResponsesRequest, call_id: &str) -> Vec<Value>
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn function_tool_output_items(req: &ResponsesRequest, call_id: &str) -> Vec<Value> {
|
||||
match req.function_call_output(call_id).get("output") {
|
||||
Some(Value::Array(items)) => items.clone(),
|
||||
Some(Value::String(text)) => {
|
||||
vec![serde_json::json!({ "type": "input_text", "text": text })]
|
||||
}
|
||||
_ => panic!("function tool output should be serialized as text or content items"),
|
||||
}
|
||||
}
|
||||
|
||||
fn text_item(items: &[Value], index: usize) -> &str {
|
||||
items[index]
|
||||
.get("text")
|
||||
@@ -39,6 +50,23 @@ fn text_item(items: &[Value], index: usize) -> &str {
|
||||
.expect("content item should be input_text")
|
||||
}
|
||||
|
||||
fn extract_running_session_id(text: &str) -> i32 {
|
||||
text.strip_prefix("Script running with session ID ")
|
||||
.and_then(|rest| rest.split('\n').next())
|
||||
.expect("running header should contain a session ID")
|
||||
.parse()
|
||||
.expect("session ID should parse as i32")
|
||||
}
|
||||
|
||||
fn wait_for_file_source(path: &Path) -> Result<String> {
|
||||
let quoted_path = shlex::try_join([path.to_string_lossy().as_ref()])?;
|
||||
let command = format!("if [ -f {quoted_path} ]; then printf ready; fi");
|
||||
Ok(format!(
|
||||
r#"while ((await exec_command({{ cmd: {command:?} }})).output !== "ready") {{
|
||||
}}"#
|
||||
))
|
||||
}
|
||||
|
||||
fn custom_tool_output_body_and_success(
|
||||
req: &ResponsesRequest,
|
||||
call_id: &str,
|
||||
@@ -289,6 +317,799 @@ Error:\ boom\n
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(windows, ignore = "no exec_command on Windows")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn code_mode_can_yield_and_resume_with_exec_wait() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
let _ = config.features.enable(Feature::CodeMode);
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
let phase_2_gate = test.workspace_path("code-mode-phase-2.ready");
|
||||
let phase_3_gate = test.workspace_path("code-mode-phase-3.ready");
|
||||
let phase_2_wait = wait_for_file_source(&phase_2_gate)?;
|
||||
let phase_3_wait = wait_for_file_source(&phase_3_gate)?;
|
||||
|
||||
let code = format!(
|
||||
r#"
|
||||
import {{ output_text, set_yield_time }} from "@openai/code_mode";
|
||||
import {{ exec_command }} from "tools.js";
|
||||
|
||||
output_text("phase 1");
|
||||
set_yield_time(10);
|
||||
{phase_2_wait}
|
||||
output_text("phase 2");
|
||||
{phase_3_wait}
|
||||
output_text("phase 3");
|
||||
"#
|
||||
);
|
||||
|
||||
responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_custom_tool_call("call-1", "exec", &code),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let first_completion = responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "waiting"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
test.submit_turn("start the long exec").await?;
|
||||
|
||||
let first_request = first_completion.single_request();
|
||||
let first_items = custom_tool_output_items(&first_request, "call-1");
|
||||
assert_eq!(first_items.len(), 2);
|
||||
assert_regex_match(
|
||||
concat!(
|
||||
r"(?s)\A",
|
||||
r"Script running with session ID \d+\nWall time \d+\.\d seconds\nOutput:\n\z"
|
||||
),
|
||||
text_item(&first_items, 0),
|
||||
);
|
||||
assert_eq!(text_item(&first_items, 1), "phase 1");
|
||||
let session_id = extract_running_session_id(text_item(&first_items, 0));
|
||||
|
||||
responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-3"),
|
||||
responses::ev_function_call(
|
||||
"call-2",
|
||||
"exec_wait",
|
||||
&serde_json::to_string(&serde_json::json!({
|
||||
"session_id": session_id,
|
||||
"yield_time_ms": 1_000,
|
||||
}))?,
|
||||
),
|
||||
ev_completed("resp-3"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let second_completion = responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-2", "still waiting"),
|
||||
ev_completed("resp-4"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
fs::write(&phase_2_gate, "ready")?;
|
||||
test.submit_turn("wait again").await?;
|
||||
|
||||
let second_request = second_completion.single_request();
|
||||
let second_items = function_tool_output_items(&second_request, "call-2");
|
||||
assert_eq!(second_items.len(), 2);
|
||||
assert_regex_match(
|
||||
concat!(
|
||||
r"(?s)\A",
|
||||
r"Script running with session ID \d+\nWall time \d+\.\d seconds\nOutput:\n\z"
|
||||
),
|
||||
text_item(&second_items, 0),
|
||||
);
|
||||
assert_eq!(
|
||||
extract_running_session_id(text_item(&second_items, 0)),
|
||||
session_id
|
||||
);
|
||||
assert_eq!(text_item(&second_items, 1), "phase 2");
|
||||
|
||||
responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-5"),
|
||||
responses::ev_function_call(
|
||||
"call-3",
|
||||
"exec_wait",
|
||||
&serde_json::to_string(&serde_json::json!({
|
||||
"session_id": session_id,
|
||||
"yield_time_ms": 1_000,
|
||||
}))?,
|
||||
),
|
||||
ev_completed("resp-5"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let third_completion = responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-3", "done"),
|
||||
ev_completed("resp-6"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
fs::write(&phase_3_gate, "ready")?;
|
||||
test.submit_turn("wait for completion").await?;
|
||||
|
||||
let third_request = third_completion.single_request();
|
||||
let third_items = function_tool_output_items(&third_request, "call-3");
|
||||
assert_eq!(third_items.len(), 2);
|
||||
assert_regex_match(
|
||||
concat!(
|
||||
r"(?s)\A",
|
||||
r"Script completed\nWall time \d+\.\d seconds\nOutput:\n\z"
|
||||
),
|
||||
text_item(&third_items, 0),
|
||||
);
|
||||
assert_eq!(text_item(&third_items, 1), "phase 3");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(windows, ignore = "no exec_command on Windows")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn code_mode_can_run_multiple_yielded_sessions() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
let _ = config.features.enable(Feature::CodeMode);
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
let session_a_gate = test.workspace_path("code-mode-session-a.ready");
|
||||
let session_b_gate = test.workspace_path("code-mode-session-b.ready");
|
||||
let session_a_wait = wait_for_file_source(&session_a_gate)?;
|
||||
let session_b_wait = wait_for_file_source(&session_b_gate)?;
|
||||
|
||||
let session_a_code = format!(
|
||||
r#"
|
||||
import {{ output_text, set_yield_time }} from "@openai/code_mode";
|
||||
import {{ exec_command }} from "tools.js";
|
||||
|
||||
output_text("session a start");
|
||||
set_yield_time(10);
|
||||
{session_a_wait}
|
||||
output_text("session a done");
|
||||
"#
|
||||
);
|
||||
let session_b_code = format!(
|
||||
r#"
|
||||
import {{ output_text, set_yield_time }} from "@openai/code_mode";
|
||||
import {{ exec_command }} from "tools.js";
|
||||
|
||||
output_text("session b start");
|
||||
set_yield_time(10);
|
||||
{session_b_wait}
|
||||
output_text("session b done");
|
||||
"#
|
||||
);
|
||||
|
||||
responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_custom_tool_call("call-1", "exec", &session_a_code),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let first_completion = responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "session a waiting"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
test.submit_turn("start session a").await?;
|
||||
|
||||
let first_request = first_completion.single_request();
|
||||
let first_items = custom_tool_output_items(&first_request, "call-1");
|
||||
assert_eq!(first_items.len(), 2);
|
||||
let session_a_id = extract_running_session_id(text_item(&first_items, 0));
|
||||
assert_eq!(text_item(&first_items, 1), "session a start");
|
||||
|
||||
responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-3"),
|
||||
ev_custom_tool_call("call-2", "exec", &session_b_code),
|
||||
ev_completed("resp-3"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let second_completion = responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-2", "session b waiting"),
|
||||
ev_completed("resp-4"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
test.submit_turn("start session b").await?;
|
||||
|
||||
let second_request = second_completion.single_request();
|
||||
let second_items = custom_tool_output_items(&second_request, "call-2");
|
||||
assert_eq!(second_items.len(), 2);
|
||||
let session_b_id = extract_running_session_id(text_item(&second_items, 0));
|
||||
assert_eq!(text_item(&second_items, 1), "session b start");
|
||||
assert_ne!(session_a_id, session_b_id);
|
||||
|
||||
fs::write(&session_a_gate, "ready")?;
|
||||
responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-5"),
|
||||
responses::ev_function_call(
|
||||
"call-3",
|
||||
"exec_wait",
|
||||
&serde_json::to_string(&serde_json::json!({
|
||||
"session_id": session_a_id,
|
||||
"yield_time_ms": 1_000,
|
||||
}))?,
|
||||
),
|
||||
ev_completed("resp-5"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let third_completion = responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-3", "session a done"),
|
||||
ev_completed("resp-6"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
test.submit_turn("wait session a").await?;
|
||||
|
||||
let third_request = third_completion.single_request();
|
||||
let third_items = function_tool_output_items(&third_request, "call-3");
|
||||
assert_eq!(third_items.len(), 2);
|
||||
assert_regex_match(
|
||||
concat!(
|
||||
r"(?s)\A",
|
||||
r"Script completed\nWall time \d+\.\d seconds\nOutput:\n\z"
|
||||
),
|
||||
text_item(&third_items, 0),
|
||||
);
|
||||
assert_eq!(text_item(&third_items, 1), "session a done");
|
||||
|
||||
fs::write(&session_b_gate, "ready")?;
|
||||
responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-7"),
|
||||
responses::ev_function_call(
|
||||
"call-4",
|
||||
"exec_wait",
|
||||
&serde_json::to_string(&serde_json::json!({
|
||||
"session_id": session_b_id,
|
||||
"yield_time_ms": 1_000,
|
||||
}))?,
|
||||
),
|
||||
ev_completed("resp-7"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let fourth_completion = responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-4", "session b done"),
|
||||
ev_completed("resp-8"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
test.submit_turn("wait session b").await?;
|
||||
|
||||
let fourth_request = fourth_completion.single_request();
|
||||
let fourth_items = function_tool_output_items(&fourth_request, "call-4");
|
||||
assert_eq!(fourth_items.len(), 2);
|
||||
assert_regex_match(
|
||||
concat!(
|
||||
r"(?s)\A",
|
||||
r"Script completed\nWall time \d+\.\d seconds\nOutput:\n\z"
|
||||
),
|
||||
text_item(&fourth_items, 0),
|
||||
);
|
||||
assert_eq!(text_item(&fourth_items, 1), "session b done");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(windows, ignore = "no exec_command on Windows")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn code_mode_exec_wait_can_terminate_and_continue() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
let _ = config.features.enable(Feature::CodeMode);
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
let termination_gate = test.workspace_path("code-mode-terminate.ready");
|
||||
let termination_wait = wait_for_file_source(&termination_gate)?;
|
||||
|
||||
let code = format!(
|
||||
r#"
|
||||
import {{ output_text, set_yield_time }} from "@openai/code_mode";
|
||||
import {{ exec_command }} from "tools.js";
|
||||
|
||||
output_text("phase 1");
|
||||
set_yield_time(10);
|
||||
{termination_wait}
|
||||
output_text("phase 2");
|
||||
"#
|
||||
);
|
||||
|
||||
responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_custom_tool_call("call-1", "exec", &code),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let first_completion = responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "waiting"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
test.submit_turn("start the long exec").await?;
|
||||
|
||||
let first_request = first_completion.single_request();
|
||||
let first_items = custom_tool_output_items(&first_request, "call-1");
|
||||
assert_eq!(first_items.len(), 2);
|
||||
let session_id = extract_running_session_id(text_item(&first_items, 0));
|
||||
assert_eq!(text_item(&first_items, 1), "phase 1");
|
||||
|
||||
responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-3"),
|
||||
responses::ev_function_call(
|
||||
"call-2",
|
||||
"exec_wait",
|
||||
&serde_json::to_string(&serde_json::json!({
|
||||
"session_id": session_id,
|
||||
"terminate": true,
|
||||
}))?,
|
||||
),
|
||||
ev_completed("resp-3"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let second_completion = responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-2", "terminated"),
|
||||
ev_completed("resp-4"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
test.submit_turn("terminate it").await?;
|
||||
|
||||
let second_request = second_completion.single_request();
|
||||
let second_items = function_tool_output_items(&second_request, "call-2");
|
||||
assert_eq!(second_items.len(), 1);
|
||||
assert_regex_match(
|
||||
concat!(
|
||||
r"(?s)\A",
|
||||
r"Script terminated\nWall time \d+\.\d seconds\nOutput:\n\z"
|
||||
),
|
||||
text_item(&second_items, 0),
|
||||
);
|
||||
|
||||
responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-5"),
|
||||
ev_custom_tool_call(
|
||||
"call-3",
|
||||
"exec",
|
||||
r#"
|
||||
import { output_text } from "@openai/code_mode";
|
||||
|
||||
output_text("after terminate");
|
||||
"#,
|
||||
),
|
||||
ev_completed("resp-5"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let third_completion = responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-3", "done"),
|
||||
ev_completed("resp-6"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
test.submit_turn("run another exec").await?;
|
||||
|
||||
let third_request = third_completion.single_request();
|
||||
let third_items = custom_tool_output_items(&third_request, "call-3");
|
||||
assert_eq!(third_items.len(), 2);
|
||||
assert_regex_match(
|
||||
concat!(
|
||||
r"(?s)\A",
|
||||
r"Script completed\nWall time \d+\.\d seconds\nOutput:\n\z"
|
||||
),
|
||||
text_item(&third_items, 0),
|
||||
);
|
||||
assert_eq!(text_item(&third_items, 1), "after terminate");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn code_mode_exec_wait_returns_error_for_unknown_session() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
let _ = config.features.enable(Feature::CodeMode);
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
|
||||
responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
responses::ev_function_call(
|
||||
"call-1",
|
||||
"exec_wait",
|
||||
&serde_json::to_string(&serde_json::json!({
|
||||
"session_id": 999_999,
|
||||
"yield_time_ms": 1_000,
|
||||
}))?,
|
||||
),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let completion = responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "done"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
test.submit_turn("wait on an unknown exec session").await?;
|
||||
|
||||
let request = completion.single_request();
|
||||
let (_, success) = request
|
||||
.function_call_output_content_and_success("call-1")
|
||||
.expect("function tool output should be present");
|
||||
assert_ne!(success, Some(true));
|
||||
|
||||
let items = function_tool_output_items(&request, "call-1");
|
||||
assert_eq!(items.len(), 2);
|
||||
assert_regex_match(
|
||||
concat!(
|
||||
r"(?s)\A",
|
||||
r"Script failed\nWall time \d+\.\d seconds\nOutput:\n\z"
|
||||
),
|
||||
text_item(&items, 0),
|
||||
);
|
||||
assert_eq!(
|
||||
text_item(&items, 1),
|
||||
"Script error:\nexec session 999999 not found"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(windows, ignore = "no exec_command on Windows")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn code_mode_exec_wait_terminate_returns_completed_session_if_it_finished_in_background()
|
||||
-> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
let _ = config.features.enable(Feature::CodeMode);
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
let session_a_gate = test.workspace_path("code-mode-session-a-finished.ready");
|
||||
let session_b_gate = test.workspace_path("code-mode-session-b-blocked.ready");
|
||||
let session_a_wait = wait_for_file_source(&session_a_gate)?;
|
||||
let session_b_wait = wait_for_file_source(&session_b_gate)?;
|
||||
|
||||
let session_a_code = format!(
|
||||
r#"
|
||||
import {{ output_text, set_yield_time }} from "@openai/code_mode";
|
||||
import {{ exec_command }} from "tools.js";
|
||||
|
||||
output_text("session a start");
|
||||
set_yield_time(10);
|
||||
{session_a_wait}
|
||||
output_text("session a done");
|
||||
"#
|
||||
);
|
||||
let session_b_code = format!(
|
||||
r#"
|
||||
import {{ output_text, set_yield_time }} from "@openai/code_mode";
|
||||
import {{ exec_command }} from "tools.js";
|
||||
|
||||
output_text("session b start");
|
||||
set_yield_time(10);
|
||||
{session_b_wait}
|
||||
output_text("session b done");
|
||||
"#
|
||||
);
|
||||
|
||||
responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_custom_tool_call("call-1", "exec", &session_a_code),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let first_completion = responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "session a waiting"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
test.submit_turn("start session a").await?;
|
||||
|
||||
let first_request = first_completion.single_request();
|
||||
let first_items = custom_tool_output_items(&first_request, "call-1");
|
||||
assert_eq!(first_items.len(), 2);
|
||||
let session_a_id = extract_running_session_id(text_item(&first_items, 0));
|
||||
assert_eq!(text_item(&first_items, 1), "session a start");
|
||||
|
||||
responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-3"),
|
||||
ev_custom_tool_call("call-2", "exec", &session_b_code),
|
||||
ev_completed("resp-3"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let second_completion = responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-2", "session b waiting"),
|
||||
ev_completed("resp-4"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
test.submit_turn("start session b").await?;
|
||||
|
||||
let second_request = second_completion.single_request();
|
||||
let second_items = custom_tool_output_items(&second_request, "call-2");
|
||||
assert_eq!(second_items.len(), 2);
|
||||
let session_b_id = extract_running_session_id(text_item(&second_items, 0));
|
||||
assert_eq!(text_item(&second_items, 1), "session b start");
|
||||
|
||||
fs::write(&session_a_gate, "ready")?;
|
||||
responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-5"),
|
||||
responses::ev_function_call(
|
||||
"call-3",
|
||||
"exec_wait",
|
||||
&serde_json::to_string(&serde_json::json!({
|
||||
"session_id": session_b_id,
|
||||
"yield_time_ms": 1_000,
|
||||
}))?,
|
||||
),
|
||||
ev_completed("resp-5"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let third_completion = responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-3", "session b still waiting"),
|
||||
ev_completed("resp-6"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
test.submit_turn("wait session b").await?;
|
||||
|
||||
let third_request = third_completion.single_request();
|
||||
let third_items = function_tool_output_items(&third_request, "call-3");
|
||||
assert_eq!(third_items.len(), 1);
|
||||
assert_regex_match(
|
||||
concat!(
|
||||
r"(?s)\A",
|
||||
r"Script running with session ID \d+\nWall time \d+\.\d seconds\nOutput:\n\z"
|
||||
),
|
||||
text_item(&third_items, 0),
|
||||
);
|
||||
assert_eq!(
|
||||
extract_running_session_id(text_item(&third_items, 0)),
|
||||
session_b_id
|
||||
);
|
||||
|
||||
responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-7"),
|
||||
responses::ev_function_call(
|
||||
"call-4",
|
||||
"exec_wait",
|
||||
&serde_json::to_string(&serde_json::json!({
|
||||
"session_id": session_a_id,
|
||||
"terminate": true,
|
||||
}))?,
|
||||
),
|
||||
ev_completed("resp-7"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let fourth_completion = responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-4", "session a already done"),
|
||||
ev_completed("resp-8"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
test.submit_turn("terminate session a").await?;
|
||||
|
||||
let fourth_request = fourth_completion.single_request();
|
||||
let fourth_items = function_tool_output_items(&fourth_request, "call-4");
|
||||
assert_eq!(fourth_items.len(), 1);
|
||||
assert_regex_match(
|
||||
concat!(
|
||||
r"(?s)\A",
|
||||
r"Script terminated\nWall time \d+\.\d seconds\nOutput:\n\z"
|
||||
),
|
||||
text_item(&fourth_items, 0),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(windows, ignore = "no exec_command on Windows")]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn code_mode_exec_wait_uses_its_own_max_tokens_budget() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
let server = responses::start_mock_server().await;
|
||||
let mut builder = test_codex().with_config(move |config| {
|
||||
let _ = config.features.enable(Feature::CodeMode);
|
||||
});
|
||||
let test = builder.build(&server).await?;
|
||||
let completion_gate = test.workspace_path("code-mode-max-tokens.ready");
|
||||
let completion_wait = wait_for_file_source(&completion_gate)?;
|
||||
|
||||
let code = format!(
|
||||
r#"
|
||||
import {{ output_text, set_max_output_tokens_per_exec_call, set_yield_time }} from "@openai/code_mode";
|
||||
import {{ exec_command }} from "tools.js";
|
||||
|
||||
output_text("phase 1");
|
||||
set_max_output_tokens_per_exec_call(100);
|
||||
set_yield_time(10);
|
||||
{completion_wait}
|
||||
output_text("token one token two token three token four token five token six token seven");
|
||||
"#
|
||||
);
|
||||
|
||||
responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-1"),
|
||||
ev_custom_tool_call("call-1", "exec", &code),
|
||||
ev_completed("resp-1"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let first_completion = responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-1", "waiting"),
|
||||
ev_completed("resp-2"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
test.submit_turn("start the long exec").await?;
|
||||
|
||||
let first_request = first_completion.single_request();
|
||||
let first_items = custom_tool_output_items(&first_request, "call-1");
|
||||
assert_eq!(first_items.len(), 2);
|
||||
assert_eq!(text_item(&first_items, 1), "phase 1");
|
||||
let session_id = extract_running_session_id(text_item(&first_items, 0));
|
||||
|
||||
fs::write(&completion_gate, "ready")?;
|
||||
responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_response_created("resp-3"),
|
||||
responses::ev_function_call(
|
||||
"call-2",
|
||||
"exec_wait",
|
||||
&serde_json::to_string(&serde_json::json!({
|
||||
"session_id": session_id,
|
||||
"yield_time_ms": 1_000,
|
||||
"max_tokens": 6,
|
||||
}))?,
|
||||
),
|
||||
ev_completed("resp-3"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
let second_completion = responses::mount_sse_once(
|
||||
&server,
|
||||
sse(vec![
|
||||
ev_assistant_message("msg-2", "done"),
|
||||
ev_completed("resp-4"),
|
||||
]),
|
||||
)
|
||||
.await;
|
||||
|
||||
test.submit_turn("wait for completion").await?;
|
||||
|
||||
let second_request = second_completion.single_request();
|
||||
let second_items = function_tool_output_items(&second_request, "call-2");
|
||||
assert_eq!(second_items.len(), 2);
|
||||
assert_regex_match(
|
||||
concat!(
|
||||
r"(?s)\A",
|
||||
r"Script completed\nWall time \d+\.\d seconds\nOutput:\n\z"
|
||||
),
|
||||
text_item(&second_items, 0),
|
||||
);
|
||||
let expected_pattern = r#"(?sx)
|
||||
\A
|
||||
Total\ output\ lines:\ 1\n
|
||||
\n
|
||||
.*…\d+\ tokens\ truncated….*
|
||||
\z
|
||||
"#;
|
||||
assert_regex_match(expected_pattern, text_item(&second_items, 1));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn code_mode_can_output_serialized_text_via_openai_code_mode_module() -> Result<()> {
|
||||
skip_if_no_network!(Ok(()));
|
||||
|
||||
@@ -180,6 +180,24 @@ async fn spawn_agent_description_lists_visible_models_and_reasoning_efforts() ->
|
||||
!description.contains("Hidden Model"),
|
||||
"hidden picker model should be omitted from spawn_agent description: {description:?}"
|
||||
);
|
||||
assert!(
|
||||
description.contains(
|
||||
"Only use `spawn_agent` if and only if the user explicitly asks for sub-agents, delegation, or parallel agent work."
|
||||
),
|
||||
"expected explicit authorization rule in spawn_agent description: {description:?}"
|
||||
);
|
||||
assert!(
|
||||
description.contains(
|
||||
"Requests for depth, thoroughness, research, investigation, or detailed codebase analysis do not count as permission to spawn."
|
||||
),
|
||||
"expected non-authorization clarification in spawn_agent description: {description:?}"
|
||||
);
|
||||
assert!(
|
||||
description.contains(
|
||||
"Agent-role guidance below only helps choose which agent to use after spawning is already authorized; it never authorizes spawning by itself."
|
||||
),
|
||||
"expected agent-role clarification in spawn_agent description: {description:?}"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -12,29 +12,28 @@ into this binary.
|
||||
|
||||
**Current Behavior**
|
||||
- Legacy Landlock + mount protections remain available as the legacy pipeline.
|
||||
- The bubblewrap pipeline is standardized on the vendored path.
|
||||
- During rollout, the bubblewrap pipeline is gated by the temporary feature
|
||||
flag `use_linux_sandbox_bwrap` (CLI `-c` alias for
|
||||
`features.use_linux_sandbox_bwrap`; legacy remains default when off).
|
||||
- When enabled, the bubblewrap pipeline applies `PR_SET_NO_NEW_PRIVS` and a
|
||||
- The default Linux sandbox pipeline is bubblewrap on the vendored path.
|
||||
- Set `features.use_legacy_landlock = true` (or CLI `-c use_legacy_landlock=true`)
|
||||
to force the legacy Landlock fallback.
|
||||
- When the default bubblewrap pipeline is active, it applies `PR_SET_NO_NEW_PRIVS` and a
|
||||
seccomp network filter in-process.
|
||||
- When enabled, the filesystem is read-only by default via `--ro-bind / /`.
|
||||
- When enabled, writable roots are layered with `--bind <root> <root>`.
|
||||
- When enabled, protected subpaths under writable roots (for example `.git`,
|
||||
- When the default bubblewrap pipeline is active, the filesystem is read-only by default via `--ro-bind / /`.
|
||||
- When the default bubblewrap pipeline is active, writable roots are layered with `--bind <root> <root>`.
|
||||
- When the default bubblewrap pipeline is active, protected subpaths under writable roots (for example `.git`,
|
||||
resolved `gitdir:`, and `.codex`) are re-applied as read-only via `--ro-bind`.
|
||||
- When enabled, symlink-in-path and non-existent protected paths inside
|
||||
- When the default bubblewrap pipeline is active, symlink-in-path and non-existent protected paths inside
|
||||
writable roots are blocked by mounting `/dev/null` on the symlink or first
|
||||
missing component.
|
||||
- When enabled, the helper explicitly isolates the user namespace via
|
||||
- When the default bubblewrap pipeline is active, the helper explicitly isolates the user namespace via
|
||||
`--unshare-user` and the PID namespace via `--unshare-pid`.
|
||||
- When enabled and network is restricted without proxy routing, the helper also
|
||||
- When the default bubblewrap pipeline is active and network is restricted without proxy routing, the helper also
|
||||
isolates the network namespace via `--unshare-net`.
|
||||
- In managed proxy mode, the helper uses `--unshare-net` plus an internal
|
||||
TCP->UDS->TCP routing bridge so tool traffic reaches only configured proxy
|
||||
endpoints.
|
||||
- In managed proxy mode, after the bridge is live, seccomp blocks new
|
||||
AF_UNIX/socketpair creation for the user command.
|
||||
- When enabled, it mounts a fresh `/proc` via `--proc /proc` by default, but
|
||||
- When the default bubblewrap pipeline is active, it mounts a fresh `/proc` via `--proc /proc` by default, but
|
||||
you can skip this in restrictive container environments with `--no-proc`.
|
||||
|
||||
**Notes**
|
||||
|
||||
@@ -22,7 +22,8 @@ use codex_protocol::protocol::SandboxPolicy;
|
||||
/// CLI surface for the Linux sandbox helper.
|
||||
///
|
||||
/// The type name remains `LandlockCommand` for compatibility with existing
|
||||
/// wiring, but the filesystem sandbox now uses bubblewrap.
|
||||
/// wiring, but bubblewrap is now the default filesystem sandbox and Landlock
|
||||
/// is the legacy fallback.
|
||||
pub struct LandlockCommand {
|
||||
/// It is possible that the cwd used in the context of the sandbox policy
|
||||
/// is different from the cwd of the process to spawn.
|
||||
@@ -42,11 +43,11 @@ pub struct LandlockCommand {
|
||||
#[arg(long = "network-sandbox-policy", hide = true)]
|
||||
pub network_sandbox_policy: Option<NetworkSandboxPolicy>,
|
||||
|
||||
/// Opt-in: use the bubblewrap-based Linux sandbox pipeline.
|
||||
/// Opt-in: use the legacy Landlock Linux sandbox fallback.
|
||||
///
|
||||
/// When not set, we fall back to the legacy Landlock + mount pipeline.
|
||||
#[arg(long = "use-bwrap-sandbox", hide = true, default_value_t = false)]
|
||||
pub use_bwrap_sandbox: bool,
|
||||
/// When not set, the helper uses the default bubblewrap pipeline.
|
||||
#[arg(long = "use-legacy-landlock", hide = true, default_value_t = false)]
|
||||
pub use_legacy_landlock: bool,
|
||||
|
||||
/// Internal: apply seccomp and `no_new_privs` in the already-sandboxed
|
||||
/// process, then exec the user command.
|
||||
@@ -92,7 +93,7 @@ pub fn run_main() -> ! {
|
||||
sandbox_policy,
|
||||
file_system_sandbox_policy,
|
||||
network_sandbox_policy,
|
||||
use_bwrap_sandbox,
|
||||
use_legacy_landlock,
|
||||
apply_seccomp_then_exec,
|
||||
allow_network_for_proxy,
|
||||
proxy_route_spec,
|
||||
@@ -103,7 +104,7 @@ pub fn run_main() -> ! {
|
||||
if command.is_empty() {
|
||||
panic!("No command specified to execute.");
|
||||
}
|
||||
ensure_inner_stage_mode_is_valid(apply_seccomp_then_exec, use_bwrap_sandbox);
|
||||
ensure_inner_stage_mode_is_valid(apply_seccomp_then_exec, use_legacy_landlock);
|
||||
let EffectiveSandboxPolicies {
|
||||
sandbox_policy,
|
||||
file_system_sandbox_policy,
|
||||
@@ -154,7 +155,7 @@ pub fn run_main() -> ! {
|
||||
exec_or_panic(command);
|
||||
}
|
||||
|
||||
if use_bwrap_sandbox {
|
||||
if !use_legacy_landlock {
|
||||
// Outer stage: bubblewrap first, then re-enter this binary in the
|
||||
// sandboxed environment to apply seccomp. This path never falls back
|
||||
// to legacy Landlock on failure.
|
||||
@@ -171,7 +172,6 @@ pub fn run_main() -> ! {
|
||||
sandbox_policy: &sandbox_policy,
|
||||
file_system_sandbox_policy: &file_system_sandbox_policy,
|
||||
network_sandbox_policy,
|
||||
use_bwrap_sandbox,
|
||||
allow_network_for_proxy,
|
||||
proxy_route_spec,
|
||||
command,
|
||||
@@ -256,9 +256,9 @@ fn resolve_sandbox_policies(
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_inner_stage_mode_is_valid(apply_seccomp_then_exec: bool, use_bwrap_sandbox: bool) {
|
||||
if apply_seccomp_then_exec && !use_bwrap_sandbox {
|
||||
panic!("--apply-seccomp-then-exec requires --use-bwrap-sandbox");
|
||||
fn ensure_inner_stage_mode_is_valid(apply_seccomp_then_exec: bool, use_legacy_landlock: bool) {
|
||||
if apply_seccomp_then_exec && use_legacy_landlock {
|
||||
panic!("--apply-seccomp-then-exec is incompatible with --use-legacy-landlock");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,7 +280,8 @@ fn run_bwrap_with_proc_fallback(
|
||||
network_mode,
|
||||
)
|
||||
{
|
||||
eprintln!("codex-linux-sandbox: bwrap could not mount /proc; retrying with --no-proc");
|
||||
// Keep the retry silent so sandbox-internal diagnostics do not leak into the
|
||||
// child process stderr stream.
|
||||
mount_proc = false;
|
||||
}
|
||||
|
||||
@@ -470,7 +471,6 @@ struct InnerSeccompCommandArgs<'a> {
|
||||
sandbox_policy: &'a SandboxPolicy,
|
||||
file_system_sandbox_policy: &'a FileSystemSandboxPolicy,
|
||||
network_sandbox_policy: NetworkSandboxPolicy,
|
||||
use_bwrap_sandbox: bool,
|
||||
allow_network_for_proxy: bool,
|
||||
proxy_route_spec: Option<String>,
|
||||
command: Vec<String>,
|
||||
@@ -483,7 +483,6 @@ fn build_inner_seccomp_command(args: InnerSeccompCommandArgs<'_>) -> Vec<String>
|
||||
sandbox_policy,
|
||||
file_system_sandbox_policy,
|
||||
network_sandbox_policy,
|
||||
use_bwrap_sandbox,
|
||||
allow_network_for_proxy,
|
||||
proxy_route_spec,
|
||||
command,
|
||||
@@ -516,10 +515,7 @@ fn build_inner_seccomp_command(args: InnerSeccompCommandArgs<'_>) -> Vec<String>
|
||||
"--network-sandbox-policy".to_string(),
|
||||
network_policy_json,
|
||||
];
|
||||
if use_bwrap_sandbox {
|
||||
inner.push("--use-bwrap-sandbox".to_string());
|
||||
inner.push("--apply-seccomp-then-exec".to_string());
|
||||
}
|
||||
inner.push("--apply-seccomp-then-exec".to_string());
|
||||
if allow_network_for_proxy {
|
||||
inner.push("--allow-network-for-proxy".to_string());
|
||||
let proxy_route_spec = proxy_route_spec
|
||||
|
||||
@@ -127,7 +127,6 @@ fn managed_proxy_inner_command_includes_route_spec() {
|
||||
sandbox_policy: &sandbox_policy,
|
||||
file_system_sandbox_policy: &FileSystemSandboxPolicy::from(&sandbox_policy),
|
||||
network_sandbox_policy: NetworkSandboxPolicy::Restricted,
|
||||
use_bwrap_sandbox: true,
|
||||
allow_network_for_proxy: true,
|
||||
proxy_route_spec: Some("{\"routes\":[]}".to_string()),
|
||||
command: vec!["/bin/true".to_string()],
|
||||
@@ -145,7 +144,6 @@ fn inner_command_includes_split_policy_flags() {
|
||||
sandbox_policy: &sandbox_policy,
|
||||
file_system_sandbox_policy: &FileSystemSandboxPolicy::from(&sandbox_policy),
|
||||
network_sandbox_policy: NetworkSandboxPolicy::Restricted,
|
||||
use_bwrap_sandbox: true,
|
||||
allow_network_for_proxy: false,
|
||||
proxy_route_spec: None,
|
||||
command: vec!["/bin/true".to_string()],
|
||||
@@ -163,7 +161,6 @@ fn non_managed_inner_command_omits_route_spec() {
|
||||
sandbox_policy: &sandbox_policy,
|
||||
file_system_sandbox_policy: &FileSystemSandboxPolicy::from(&sandbox_policy),
|
||||
network_sandbox_policy: NetworkSandboxPolicy::Restricted,
|
||||
use_bwrap_sandbox: true,
|
||||
allow_network_for_proxy: false,
|
||||
proxy_route_spec: None,
|
||||
command: vec!["/bin/true".to_string()],
|
||||
@@ -181,7 +178,6 @@ fn managed_proxy_inner_command_requires_route_spec() {
|
||||
sandbox_policy: &sandbox_policy,
|
||||
file_system_sandbox_policy: &FileSystemSandboxPolicy::from(&sandbox_policy),
|
||||
network_sandbox_policy: NetworkSandboxPolicy::Restricted,
|
||||
use_bwrap_sandbox: true,
|
||||
allow_network_for_proxy: true,
|
||||
proxy_route_spec: None,
|
||||
command: vec!["/bin/true".to_string()],
|
||||
@@ -244,8 +240,8 @@ fn resolve_sandbox_policies_rejects_partial_split_policies() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_seccomp_then_exec_without_bwrap_panics() {
|
||||
let result = std::panic::catch_unwind(|| ensure_inner_stage_mode_is_valid(true, false));
|
||||
fn apply_seccomp_then_exec_with_legacy_landlock_panics() {
|
||||
let result = std::panic::catch_unwind(|| ensure_inner_stage_mode_is_valid(true, true));
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
@@ -253,5 +249,5 @@ fn apply_seccomp_then_exec_without_bwrap_panics() {
|
||||
fn valid_inner_stage_modes_do_not_panic() {
|
||||
ensure_inner_stage_mode_is_valid(false, false);
|
||||
ensure_inner_stage_mode_is_valid(false, true);
|
||||
ensure_inner_stage_mode_is_valid(true, true);
|
||||
ensure_inner_stage_mode_is_valid(true, false);
|
||||
}
|
||||
|
||||
@@ -72,7 +72,7 @@ async fn run_cmd_result_with_writable_roots(
|
||||
cmd: &[&str],
|
||||
writable_roots: &[PathBuf],
|
||||
timeout_ms: u64,
|
||||
use_bwrap_sandbox: bool,
|
||||
use_legacy_landlock: bool,
|
||||
network_access: bool,
|
||||
) -> Result<codex_core::exec::ExecToolCallOutput> {
|
||||
let sandbox_policy = SandboxPolicy::WorkspaceWrite {
|
||||
@@ -96,7 +96,7 @@ async fn run_cmd_result_with_writable_roots(
|
||||
file_system_sandbox_policy,
|
||||
network_sandbox_policy,
|
||||
timeout_ms,
|
||||
use_bwrap_sandbox,
|
||||
use_legacy_landlock,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -108,7 +108,7 @@ async fn run_cmd_result_with_policies(
|
||||
file_system_sandbox_policy: FileSystemSandboxPolicy,
|
||||
network_sandbox_policy: NetworkSandboxPolicy,
|
||||
timeout_ms: u64,
|
||||
use_bwrap_sandbox: bool,
|
||||
use_legacy_landlock: bool,
|
||||
) -> Result<codex_core::exec::ExecToolCallOutput> {
|
||||
let cwd = std::env::current_dir().expect("cwd should exist");
|
||||
let sandbox_cwd = cwd.clone();
|
||||
@@ -133,7 +133,7 @@ async fn run_cmd_result_with_policies(
|
||||
network_sandbox_policy,
|
||||
sandbox_cwd.as_path(),
|
||||
&codex_linux_sandbox_exe,
|
||||
use_bwrap_sandbox,
|
||||
use_legacy_landlock,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
@@ -155,7 +155,7 @@ async fn should_skip_bwrap_tests() -> bool {
|
||||
&["bash", "-lc", "true"],
|
||||
&[],
|
||||
NETWORK_TIMEOUT_MS,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
@@ -216,7 +216,7 @@ async fn test_dev_null_write() {
|
||||
// We have seen timeouts when running this test in CI on GitHub,
|
||||
// so we are using a generous timeout until we can diagnose further.
|
||||
LONG_TIMEOUT_MS,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
@@ -240,7 +240,7 @@ async fn bwrap_populates_minimal_dev_nodes() {
|
||||
],
|
||||
&[],
|
||||
LONG_TIMEOUT_MS,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
@@ -278,7 +278,7 @@ async fn bwrap_preserves_writable_dev_shm_bind_mount() {
|
||||
],
|
||||
&[PathBuf::from("/dev/shm")],
|
||||
LONG_TIMEOUT_MS,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
@@ -442,7 +442,7 @@ async fn sandbox_blocks_git_and_codex_writes_inside_writable_root() {
|
||||
],
|
||||
&[tmpdir.path().to_path_buf()],
|
||||
LONG_TIMEOUT_MS,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
)
|
||||
.await,
|
||||
@@ -458,7 +458,7 @@ async fn sandbox_blocks_git_and_codex_writes_inside_writable_root() {
|
||||
],
|
||||
&[tmpdir.path().to_path_buf()],
|
||||
LONG_TIMEOUT_MS,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
)
|
||||
.await,
|
||||
@@ -495,7 +495,7 @@ async fn sandbox_blocks_codex_symlink_replacement_attack() {
|
||||
],
|
||||
&[tmpdir.path().to_path_buf()],
|
||||
LONG_TIMEOUT_MS,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
)
|
||||
.await,
|
||||
@@ -548,7 +548,7 @@ async fn sandbox_blocks_explicit_split_policy_carveouts_under_bwrap() {
|
||||
file_system_sandbox_policy,
|
||||
NetworkSandboxPolicy::Enabled,
|
||||
LONG_TIMEOUT_MS,
|
||||
true,
|
||||
false,
|
||||
)
|
||||
.await,
|
||||
"explicit split-policy carveout should be denied under bubblewrap",
|
||||
@@ -599,7 +599,7 @@ async fn sandbox_blocks_root_read_carveouts_under_bwrap() {
|
||||
file_system_sandbox_policy,
|
||||
NetworkSandboxPolicy::Enabled,
|
||||
LONG_TIMEOUT_MS,
|
||||
true,
|
||||
false,
|
||||
)
|
||||
.await,
|
||||
"root-read carveout should be denied under bubblewrap",
|
||||
|
||||
@@ -133,7 +133,6 @@ async fn run_linux_sandbox_direct(
|
||||
cwd.to_string_lossy().to_string(),
|
||||
"--sandbox-policy".to_string(),
|
||||
policy_json,
|
||||
"--use-bwrap-sandbox".to_string(),
|
||||
];
|
||||
if allow_network_for_proxy {
|
||||
args.push("--allow-network-for-proxy".to_string());
|
||||
|
||||
@@ -2422,6 +2422,9 @@ impl App {
|
||||
url,
|
||||
is_installed,
|
||||
is_enabled,
|
||||
suggest_reason: None,
|
||||
suggestion_type: None,
|
||||
elicitation_target: None,
|
||||
});
|
||||
}
|
||||
AppEvent::OpenUrlInBrowser { url } => {
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::approvals::ElicitationAction;
|
||||
use codex_protocol::mcp::RequestId as McpRequestId;
|
||||
use codex_protocol::protocol::Op;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
@@ -34,6 +38,19 @@ enum AppLinkScreen {
|
||||
InstallConfirmation,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum AppLinkSuggestionType {
|
||||
Install,
|
||||
Enable,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct AppLinkElicitationTarget {
|
||||
pub(crate) thread_id: ThreadId,
|
||||
pub(crate) server_name: String,
|
||||
pub(crate) request_id: McpRequestId,
|
||||
}
|
||||
|
||||
pub(crate) struct AppLinkViewParams {
|
||||
pub(crate) app_id: String,
|
||||
pub(crate) title: String,
|
||||
@@ -42,6 +59,9 @@ pub(crate) struct AppLinkViewParams {
|
||||
pub(crate) url: String,
|
||||
pub(crate) is_installed: bool,
|
||||
pub(crate) is_enabled: bool,
|
||||
pub(crate) suggest_reason: Option<String>,
|
||||
pub(crate) suggestion_type: Option<AppLinkSuggestionType>,
|
||||
pub(crate) elicitation_target: Option<AppLinkElicitationTarget>,
|
||||
}
|
||||
|
||||
pub(crate) struct AppLinkView {
|
||||
@@ -52,6 +72,9 @@ pub(crate) struct AppLinkView {
|
||||
url: String,
|
||||
is_installed: bool,
|
||||
is_enabled: bool,
|
||||
suggest_reason: Option<String>,
|
||||
suggestion_type: Option<AppLinkSuggestionType>,
|
||||
elicitation_target: Option<AppLinkElicitationTarget>,
|
||||
app_event_tx: AppEventSender,
|
||||
screen: AppLinkScreen,
|
||||
selected_action: usize,
|
||||
@@ -68,6 +91,9 @@ impl AppLinkView {
|
||||
url,
|
||||
is_installed,
|
||||
is_enabled,
|
||||
suggest_reason,
|
||||
suggestion_type,
|
||||
elicitation_target,
|
||||
} = params;
|
||||
Self {
|
||||
app_id,
|
||||
@@ -77,6 +103,9 @@ impl AppLinkView {
|
||||
url,
|
||||
is_installed,
|
||||
is_enabled,
|
||||
suggest_reason,
|
||||
suggestion_type,
|
||||
elicitation_target,
|
||||
app_event_tx,
|
||||
screen: AppLinkScreen::Link,
|
||||
selected_action: 0,
|
||||
@@ -113,6 +142,31 @@ impl AppLinkView {
|
||||
self.selected_action = (self.selected_action + 1).min(self.action_labels().len() - 1);
|
||||
}
|
||||
|
||||
fn is_tool_suggestion(&self) -> bool {
|
||||
self.elicitation_target.is_some()
|
||||
}
|
||||
|
||||
fn resolve_elicitation(&self, decision: ElicitationAction) {
|
||||
let Some(target) = self.elicitation_target.as_ref() else {
|
||||
return;
|
||||
};
|
||||
self.app_event_tx.send(AppEvent::SubmitThreadOp {
|
||||
thread_id: target.thread_id,
|
||||
op: Op::ResolveElicitation {
|
||||
server_name: target.server_name.clone(),
|
||||
request_id: target.request_id.clone(),
|
||||
decision,
|
||||
content: None,
|
||||
meta: None,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
fn decline_tool_suggestion(&mut self) {
|
||||
self.resolve_elicitation(ElicitationAction::Decline);
|
||||
self.complete = true;
|
||||
}
|
||||
|
||||
fn open_chatgpt_link(&mut self) {
|
||||
self.app_event_tx.send(AppEvent::OpenUrlInBrowser {
|
||||
url: self.url.clone(),
|
||||
@@ -127,6 +181,9 @@ impl AppLinkView {
|
||||
self.app_event_tx.send(AppEvent::RefreshConnectors {
|
||||
force_refetch: true,
|
||||
});
|
||||
if self.is_tool_suggestion() {
|
||||
self.resolve_elicitation(ElicitationAction::Accept);
|
||||
}
|
||||
self.complete = true;
|
||||
}
|
||||
|
||||
@@ -141,9 +198,40 @@ impl AppLinkView {
|
||||
id: self.app_id.clone(),
|
||||
enabled: self.is_enabled,
|
||||
});
|
||||
if self.is_tool_suggestion() {
|
||||
self.resolve_elicitation(ElicitationAction::Accept);
|
||||
self.complete = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn activate_selected_action(&mut self) {
|
||||
if self.is_tool_suggestion() {
|
||||
match self.suggestion_type {
|
||||
Some(AppLinkSuggestionType::Enable) => match self.screen {
|
||||
AppLinkScreen::Link => match self.selected_action {
|
||||
0 => self.open_chatgpt_link(),
|
||||
1 if self.is_installed => self.toggle_enabled(),
|
||||
_ => self.decline_tool_suggestion(),
|
||||
},
|
||||
AppLinkScreen::InstallConfirmation => match self.selected_action {
|
||||
0 => self.refresh_connectors_and_close(),
|
||||
_ => self.decline_tool_suggestion(),
|
||||
},
|
||||
},
|
||||
Some(AppLinkSuggestionType::Install) | None => match self.screen {
|
||||
AppLinkScreen::Link => match self.selected_action {
|
||||
0 => self.open_chatgpt_link(),
|
||||
_ => self.decline_tool_suggestion(),
|
||||
},
|
||||
AppLinkScreen::InstallConfirmation => match self.selected_action {
|
||||
0 => self.refresh_connectors_and_close(),
|
||||
_ => self.decline_tool_suggestion(),
|
||||
},
|
||||
},
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
match self.screen {
|
||||
AppLinkScreen::Link => match self.selected_action {
|
||||
0 => self.open_chatgpt_link(),
|
||||
@@ -181,6 +269,17 @@ impl AppLinkView {
|
||||
}
|
||||
|
||||
lines.push(Line::from(""));
|
||||
if let Some(suggest_reason) = self
|
||||
.suggest_reason
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|suggest_reason| !suggest_reason.is_empty())
|
||||
{
|
||||
for line in wrap(suggest_reason, usable_width) {
|
||||
lines.push(Line::from(line.into_owned().italic()));
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
if self.is_installed {
|
||||
for line in wrap("Use $ to insert this app into the prompt.", usable_width) {
|
||||
lines.push(Line::from(line.into_owned()));
|
||||
@@ -366,6 +465,9 @@ impl BottomPaneView for AppLinkView {
|
||||
}
|
||||
|
||||
fn on_ctrl_c(&mut self) -> CancellationEvent {
|
||||
if self.is_tool_suggestion() {
|
||||
self.resolve_elicitation(ElicitationAction::Decline);
|
||||
}
|
||||
self.complete = true;
|
||||
CancellationEvent::Handled
|
||||
}
|
||||
@@ -447,8 +549,40 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::render::renderable::Renderable;
|
||||
use insta::assert_snapshot;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
fn suggestion_target() -> AppLinkElicitationTarget {
|
||||
AppLinkElicitationTarget {
|
||||
thread_id: ThreadId::try_from("00000000-0000-0000-0000-000000000001")
|
||||
.expect("valid thread id"),
|
||||
server_name: "codex_apps".to_string(),
|
||||
request_id: McpRequestId::String("request-1".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn render_snapshot(view: &AppLinkView, area: Rect) -> String {
|
||||
let mut buf = Buffer::empty(area);
|
||||
view.render(area, &mut buf);
|
||||
(0..area.height)
|
||||
.map(|y| {
|
||||
(0..area.width)
|
||||
.map(|x| {
|
||||
let symbol = buf[(x, y)].symbol();
|
||||
if symbol.is_empty() {
|
||||
' '
|
||||
} else {
|
||||
symbol.chars().next().unwrap_or(' ')
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
.trim_end()
|
||||
.to_string()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn installed_app_has_toggle_action() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
@@ -462,6 +596,9 @@ mod tests {
|
||||
url: "https://example.test/notion".to_string(),
|
||||
is_installed: true,
|
||||
is_enabled: true,
|
||||
suggest_reason: None,
|
||||
suggestion_type: None,
|
||||
elicitation_target: None,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
@@ -485,6 +622,9 @@ mod tests {
|
||||
url: "https://example.test/notion".to_string(),
|
||||
is_installed: true,
|
||||
is_enabled: true,
|
||||
suggest_reason: None,
|
||||
suggestion_type: None,
|
||||
elicitation_target: None,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
@@ -521,6 +661,9 @@ mod tests {
|
||||
url: url_like.to_string(),
|
||||
is_installed: true,
|
||||
is_enabled: true,
|
||||
suggest_reason: None,
|
||||
suggestion_type: None,
|
||||
elicitation_target: None,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
@@ -561,6 +704,9 @@ mod tests {
|
||||
url: url.to_string(),
|
||||
is_installed: true,
|
||||
is_enabled: true,
|
||||
suggest_reason: None,
|
||||
suggestion_type: None,
|
||||
elicitation_target: None,
|
||||
},
|
||||
tx,
|
||||
);
|
||||
@@ -593,4 +739,206 @@ mod tests {
|
||||
"expected wrapped setup URL tail to remain visible in narrow pane, got:\n{rendered_blob}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_tool_suggestion_resolves_elicitation_after_confirmation() {
|
||||
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut view = AppLinkView::new(
|
||||
AppLinkViewParams {
|
||||
app_id: "connector_google_calendar".to_string(),
|
||||
title: "Google Calendar".to_string(),
|
||||
description: Some("Plan events and schedules.".to_string()),
|
||||
instructions: "Install this app in your browser, then return here.".to_string(),
|
||||
url: "https://example.test/google-calendar".to_string(),
|
||||
is_installed: false,
|
||||
is_enabled: false,
|
||||
suggest_reason: Some("Plan and reference events from your calendar".to_string()),
|
||||
suggestion_type: Some(AppLinkSuggestionType::Install),
|
||||
elicitation_target: Some(suggestion_target()),
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
match rx.try_recv() {
|
||||
Ok(AppEvent::OpenUrlInBrowser { url }) => {
|
||||
assert_eq!(url, "https://example.test/google-calendar".to_string());
|
||||
}
|
||||
Ok(other) => panic!("unexpected app event: {other:?}"),
|
||||
Err(err) => panic!("missing app event: {err}"),
|
||||
}
|
||||
assert_eq!(view.screen, AppLinkScreen::InstallConfirmation);
|
||||
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
match rx.try_recv() {
|
||||
Ok(AppEvent::RefreshConnectors { force_refetch }) => {
|
||||
assert!(force_refetch);
|
||||
}
|
||||
Ok(other) => panic!("unexpected app event: {other:?}"),
|
||||
Err(err) => panic!("missing app event: {err}"),
|
||||
}
|
||||
match rx.try_recv() {
|
||||
Ok(AppEvent::SubmitThreadOp { thread_id, op }) => {
|
||||
assert_eq!(thread_id, suggestion_target().thread_id);
|
||||
assert_eq!(
|
||||
op,
|
||||
Op::ResolveElicitation {
|
||||
server_name: "codex_apps".to_string(),
|
||||
request_id: McpRequestId::String("request-1".to_string()),
|
||||
decision: ElicitationAction::Accept,
|
||||
content: None,
|
||||
meta: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
Ok(other) => panic!("unexpected app event: {other:?}"),
|
||||
Err(err) => panic!("missing app event: {err}"),
|
||||
}
|
||||
assert!(view.is_complete());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declined_tool_suggestion_resolves_elicitation_decline() {
|
||||
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut view = AppLinkView::new(
|
||||
AppLinkViewParams {
|
||||
app_id: "connector_google_calendar".to_string(),
|
||||
title: "Google Calendar".to_string(),
|
||||
description: None,
|
||||
instructions: "Install this app in your browser, then return here.".to_string(),
|
||||
url: "https://example.test/google-calendar".to_string(),
|
||||
is_installed: false,
|
||||
is_enabled: false,
|
||||
suggest_reason: Some("Plan and reference events from your calendar".to_string()),
|
||||
suggestion_type: Some(AppLinkSuggestionType::Install),
|
||||
elicitation_target: Some(suggestion_target()),
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE));
|
||||
|
||||
match rx.try_recv() {
|
||||
Ok(AppEvent::SubmitThreadOp { thread_id, op }) => {
|
||||
assert_eq!(thread_id, suggestion_target().thread_id);
|
||||
assert_eq!(
|
||||
op,
|
||||
Op::ResolveElicitation {
|
||||
server_name: "codex_apps".to_string(),
|
||||
request_id: McpRequestId::String("request-1".to_string()),
|
||||
decision: ElicitationAction::Decline,
|
||||
content: None,
|
||||
meta: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
Ok(other) => panic!("unexpected app event: {other:?}"),
|
||||
Err(err) => panic!("missing app event: {err}"),
|
||||
}
|
||||
assert!(view.is_complete());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enable_tool_suggestion_resolves_elicitation_after_enable() {
|
||||
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let mut view = AppLinkView::new(
|
||||
AppLinkViewParams {
|
||||
app_id: "connector_google_calendar".to_string(),
|
||||
title: "Google Calendar".to_string(),
|
||||
description: Some("Plan events and schedules.".to_string()),
|
||||
instructions: "Enable this app to use it for the current request.".to_string(),
|
||||
url: "https://example.test/google-calendar".to_string(),
|
||||
is_installed: true,
|
||||
is_enabled: false,
|
||||
suggest_reason: Some("Plan and reference events from your calendar".to_string()),
|
||||
suggestion_type: Some(AppLinkSuggestionType::Enable),
|
||||
elicitation_target: Some(suggestion_target()),
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
view.handle_key_event(KeyEvent::new(KeyCode::Char('2'), KeyModifiers::NONE));
|
||||
|
||||
match rx.try_recv() {
|
||||
Ok(AppEvent::SetAppEnabled { id, enabled }) => {
|
||||
assert_eq!(id, "connector_google_calendar");
|
||||
assert!(enabled);
|
||||
}
|
||||
Ok(other) => panic!("unexpected app event: {other:?}"),
|
||||
Err(err) => panic!("missing app event: {err}"),
|
||||
}
|
||||
match rx.try_recv() {
|
||||
Ok(AppEvent::SubmitThreadOp { thread_id, op }) => {
|
||||
assert_eq!(thread_id, suggestion_target().thread_id);
|
||||
assert_eq!(
|
||||
op,
|
||||
Op::ResolveElicitation {
|
||||
server_name: "codex_apps".to_string(),
|
||||
request_id: McpRequestId::String("request-1".to_string()),
|
||||
decision: ElicitationAction::Accept,
|
||||
content: None,
|
||||
meta: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
Ok(other) => panic!("unexpected app event: {other:?}"),
|
||||
Err(err) => panic!("missing app event: {err}"),
|
||||
}
|
||||
assert!(view.is_complete());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_suggestion_with_reason_snapshot() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let view = AppLinkView::new(
|
||||
AppLinkViewParams {
|
||||
app_id: "connector_google_calendar".to_string(),
|
||||
title: "Google Calendar".to_string(),
|
||||
description: Some("Plan events and schedules.".to_string()),
|
||||
instructions: "Install this app in your browser, then return here.".to_string(),
|
||||
url: "https://example.test/google-calendar".to_string(),
|
||||
is_installed: false,
|
||||
is_enabled: false,
|
||||
suggest_reason: Some("Plan and reference events from your calendar".to_string()),
|
||||
suggestion_type: Some(AppLinkSuggestionType::Install),
|
||||
elicitation_target: Some(suggestion_target()),
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
assert_snapshot!(
|
||||
"app_link_view_install_suggestion_with_reason",
|
||||
render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enable_suggestion_with_reason_snapshot() {
|
||||
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
|
||||
let tx = AppEventSender::new(tx_raw);
|
||||
let view = AppLinkView::new(
|
||||
AppLinkViewParams {
|
||||
app_id: "connector_google_calendar".to_string(),
|
||||
title: "Google Calendar".to_string(),
|
||||
description: Some("Plan events and schedules.".to_string()),
|
||||
instructions: "Enable this app to use it for the current request.".to_string(),
|
||||
url: "https://example.test/google-calendar".to_string(),
|
||||
is_installed: true,
|
||||
is_enabled: false,
|
||||
suggest_reason: Some("Plan and reference events from your calendar".to_string()),
|
||||
suggestion_type: Some(AppLinkSuggestionType::Enable),
|
||||
elicitation_target: Some(suggestion_target()),
|
||||
},
|
||||
tx,
|
||||
);
|
||||
|
||||
assert_snapshot!(
|
||||
"app_link_view_enable_suggestion_with_reason",
|
||||
render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,9 +54,16 @@ const APPROVAL_DECLINE_VALUE: &str = "decline";
|
||||
const APPROVAL_CANCEL_VALUE: &str = "cancel";
|
||||
const APPROVAL_META_KIND_KEY: &str = "codex_approval_kind";
|
||||
const APPROVAL_META_KIND_MCP_TOOL_CALL: &str = "mcp_tool_call";
|
||||
const APPROVAL_META_KIND_TOOL_SUGGESTION: &str = "tool_suggestion";
|
||||
const APPROVAL_PERSIST_KEY: &str = "persist";
|
||||
const APPROVAL_PERSIST_SESSION_VALUE: &str = "session";
|
||||
const APPROVAL_PERSIST_ALWAYS_VALUE: &str = "always";
|
||||
const TOOL_TYPE_KEY: &str = "tool_type";
|
||||
const TOOL_ID_KEY: &str = "tool_id";
|
||||
const TOOL_NAME_KEY: &str = "tool_name";
|
||||
const TOOL_SUGGEST_SUGGEST_TYPE_KEY: &str = "suggest_type";
|
||||
const TOOL_SUGGEST_REASON_KEY: &str = "suggest_reason";
|
||||
const TOOL_SUGGEST_INSTALL_URL_KEY: &str = "install_url";
|
||||
|
||||
#[derive(Clone, PartialEq, Default)]
|
||||
struct ComposerDraft {
|
||||
@@ -117,6 +124,28 @@ enum McpServerElicitationResponseMode {
|
||||
ApprovalAction,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ToolSuggestionToolType {
|
||||
Connector,
|
||||
Plugin,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ToolSuggestionType {
|
||||
Install,
|
||||
Enable,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct ToolSuggestionRequest {
|
||||
pub(crate) tool_type: ToolSuggestionToolType,
|
||||
pub(crate) suggest_type: ToolSuggestionType,
|
||||
pub(crate) suggest_reason: String,
|
||||
pub(crate) tool_id: String,
|
||||
pub(crate) tool_name: String,
|
||||
pub(crate) install_url: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct McpServerElicitationFormRequest {
|
||||
thread_id: ThreadId,
|
||||
@@ -125,6 +154,7 @@ pub(crate) struct McpServerElicitationFormRequest {
|
||||
message: String,
|
||||
response_mode: McpServerElicitationResponseMode,
|
||||
fields: Vec<McpServerElicitationField>,
|
||||
tool_suggestion: Option<ToolSuggestionRequest>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -170,6 +200,7 @@ impl McpServerElicitationFormRequest {
|
||||
return None;
|
||||
};
|
||||
|
||||
let tool_suggestion = parse_tool_suggestion_request(meta.as_ref());
|
||||
let is_tool_approval = meta
|
||||
.as_ref()
|
||||
.and_then(Value::as_object)
|
||||
@@ -186,9 +217,11 @@ impl McpServerElicitationFormRequest {
|
||||
let is_tool_approval_action =
|
||||
is_tool_approval && (requested_schema.is_null() || is_empty_object_schema);
|
||||
|
||||
let (response_mode, fields) = if requested_schema.is_null()
|
||||
|| (is_tool_approval && is_empty_object_schema)
|
||||
let (response_mode, fields) = if tool_suggestion.is_some()
|
||||
&& (requested_schema.is_null() || is_empty_object_schema)
|
||||
{
|
||||
(McpServerElicitationResponseMode::FormContent, Vec::new())
|
||||
} else if requested_schema.is_null() || (is_tool_approval && is_empty_object_schema) {
|
||||
let mut options = vec![McpServerElicitationOption {
|
||||
label: "Allow".to_string(),
|
||||
description: Some("Run the tool and continue.".to_string()),
|
||||
@@ -266,8 +299,63 @@ impl McpServerElicitationFormRequest {
|
||||
message,
|
||||
response_mode,
|
||||
fields,
|
||||
tool_suggestion,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn tool_suggestion(&self) -> Option<&ToolSuggestionRequest> {
|
||||
self.tool_suggestion.as_ref()
|
||||
}
|
||||
|
||||
pub(crate) fn thread_id(&self) -> ThreadId {
|
||||
self.thread_id
|
||||
}
|
||||
|
||||
pub(crate) fn server_name(&self) -> &str {
|
||||
self.server_name.as_str()
|
||||
}
|
||||
|
||||
pub(crate) fn request_id(&self) -> &McpRequestId {
|
||||
&self.request_id
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_tool_suggestion_request(meta: Option<&Value>) -> Option<ToolSuggestionRequest> {
|
||||
let meta = meta?.as_object()?;
|
||||
if meta.get(APPROVAL_META_KIND_KEY).and_then(Value::as_str)
|
||||
!= Some(APPROVAL_META_KIND_TOOL_SUGGESTION)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let tool_type = match meta.get(TOOL_TYPE_KEY).and_then(Value::as_str) {
|
||||
Some("connector") => ToolSuggestionToolType::Connector,
|
||||
Some("plugin") => ToolSuggestionToolType::Plugin,
|
||||
_ => return None,
|
||||
};
|
||||
let suggest_type = match meta
|
||||
.get(TOOL_SUGGEST_SUGGEST_TYPE_KEY)
|
||||
.and_then(Value::as_str)
|
||||
{
|
||||
Some("install") => ToolSuggestionType::Install,
|
||||
Some("enable") => ToolSuggestionType::Enable,
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some(ToolSuggestionRequest {
|
||||
tool_type,
|
||||
suggest_type,
|
||||
suggest_reason: meta
|
||||
.get(TOOL_SUGGEST_REASON_KEY)
|
||||
.and_then(Value::as_str)?
|
||||
.to_string(),
|
||||
tool_id: meta.get(TOOL_ID_KEY).and_then(Value::as_str)?.to_string(),
|
||||
tool_name: meta.get(TOOL_NAME_KEY).and_then(Value::as_str)?.to_string(),
|
||||
install_url: meta
|
||||
.get(TOOL_SUGGEST_INSTALL_URL_KEY)
|
||||
.and_then(Value::as_str)?
|
||||
.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn tool_approval_supports_persist_mode(meta: Option<&Value>, expected_mode: &str) -> bool {
|
||||
@@ -1550,6 +1638,7 @@ mod tests {
|
||||
default_idx: None,
|
||||
},
|
||||
}],
|
||||
tool_suggestion: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1621,6 +1710,7 @@ mod tests {
|
||||
default_idx: Some(0),
|
||||
},
|
||||
}],
|
||||
tool_suggestion: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1667,10 +1757,44 @@ mod tests {
|
||||
default_idx: Some(0),
|
||||
},
|
||||
}],
|
||||
tool_suggestion: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_suggestion_meta_is_parsed_into_request_payload() {
|
||||
let request = McpServerElicitationFormRequest::from_event(
|
||||
ThreadId::default(),
|
||||
form_request(
|
||||
"Suggest Google Calendar",
|
||||
empty_object_schema(),
|
||||
Some(serde_json::json!({
|
||||
"codex_approval_kind": "tool_suggestion",
|
||||
"tool_type": "connector",
|
||||
"suggest_type": "install",
|
||||
"suggest_reason": "Plan and reference events from your calendar",
|
||||
"tool_id": "connector_2128aebfecb84f64a069897515042a44",
|
||||
"tool_name": "Google Calendar",
|
||||
"install_url": "https://example.test/google-calendar",
|
||||
})),
|
||||
),
|
||||
)
|
||||
.expect("expected tool suggestion form");
|
||||
|
||||
assert_eq!(
|
||||
request.tool_suggestion(),
|
||||
Some(&ToolSuggestionRequest {
|
||||
tool_type: ToolSuggestionToolType::Connector,
|
||||
suggest_type: ToolSuggestionType::Install,
|
||||
suggest_reason: "Plan and reference events from your calendar".to_string(),
|
||||
tool_id: "connector_2128aebfecb84f64a069897515042a44".to_string(),
|
||||
tool_name: "Google Calendar".to_string(),
|
||||
install_url: "https://example.test/google-calendar".to_string(),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_unmarked_schema_falls_back() {
|
||||
let request = McpServerElicitationFormRequest::from_event(
|
||||
|
||||
@@ -47,6 +47,8 @@ mod mcp_server_elicitation;
|
||||
mod multi_select_picker;
|
||||
mod request_user_input;
|
||||
mod status_line_setup;
|
||||
pub(crate) use app_link_view::AppLinkElicitationTarget;
|
||||
pub(crate) use app_link_view::AppLinkSuggestionType;
|
||||
pub(crate) use app_link_view::AppLinkView;
|
||||
pub(crate) use app_link_view::AppLinkViewParams;
|
||||
pub(crate) use approval_overlay::ApprovalOverlay;
|
||||
@@ -963,6 +965,52 @@ impl BottomPane {
|
||||
request
|
||||
};
|
||||
|
||||
if let Some(tool_suggestion) = request.tool_suggestion() {
|
||||
let suggestion_type = match tool_suggestion.suggest_type {
|
||||
mcp_server_elicitation::ToolSuggestionType::Install => {
|
||||
AppLinkSuggestionType::Install
|
||||
}
|
||||
mcp_server_elicitation::ToolSuggestionType::Enable => AppLinkSuggestionType::Enable,
|
||||
};
|
||||
let is_installed = matches!(
|
||||
tool_suggestion.suggest_type,
|
||||
mcp_server_elicitation::ToolSuggestionType::Enable
|
||||
);
|
||||
let view = AppLinkView::new(
|
||||
AppLinkViewParams {
|
||||
app_id: tool_suggestion.tool_id.clone(),
|
||||
title: tool_suggestion.tool_name.clone(),
|
||||
description: None,
|
||||
instructions: match suggestion_type {
|
||||
AppLinkSuggestionType::Install => {
|
||||
"Install this app in your browser, then return here.".to_string()
|
||||
}
|
||||
AppLinkSuggestionType::Enable => {
|
||||
"Enable this app to use it for the current request.".to_string()
|
||||
}
|
||||
},
|
||||
url: tool_suggestion.install_url.clone(),
|
||||
is_installed,
|
||||
is_enabled: false,
|
||||
suggest_reason: Some(tool_suggestion.suggest_reason.clone()),
|
||||
suggestion_type: Some(suggestion_type),
|
||||
elicitation_target: Some(AppLinkElicitationTarget {
|
||||
thread_id: request.thread_id(),
|
||||
server_name: request.server_name().to_string(),
|
||||
request_id: request.request_id().clone(),
|
||||
}),
|
||||
},
|
||||
self.app_event_tx.clone(),
|
||||
);
|
||||
self.pause_status_timer_for_modal();
|
||||
self.set_composer_input_enabled(
|
||||
false,
|
||||
Some("Respond to the tool suggestion to continue.".to_string()),
|
||||
);
|
||||
self.push_view(Box::new(view));
|
||||
return;
|
||||
}
|
||||
|
||||
let modal = McpServerElicitationOverlay::new(
|
||||
request,
|
||||
self.app_event_tx.clone(),
|
||||
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/app_link_view.rs
|
||||
expression: "render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))"
|
||||
---
|
||||
|
||||
Google Calendar
|
||||
Plan events and schedules.
|
||||
|
||||
Plan and reference events from your calendar
|
||||
|
||||
Use $ to insert this app into the prompt.
|
||||
|
||||
Enable this app to use it for the current request.
|
||||
Newly installed apps can take a few minutes to appear in /apps.
|
||||
|
||||
|
||||
› 1. Manage on ChatGPT
|
||||
2. Enable app
|
||||
3. Back
|
||||
Use tab / ↑ ↓ to move, enter to select, esc to close
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/app_link_view.rs
|
||||
expression: "render_snapshot(&view, Rect::new(0, 0, 72, view.desired_height(72)))"
|
||||
---
|
||||
|
||||
Google Calendar
|
||||
Plan events and schedules.
|
||||
|
||||
Plan and reference events from your calendar
|
||||
|
||||
Install this app in your browser, then return here.
|
||||
Newly installed apps can take a few minutes to appear in /apps.
|
||||
After installed, use $ to insert this app into the prompt.
|
||||
|
||||
|
||||
› 1. Install on ChatGPT
|
||||
2. Back
|
||||
Use tab / ↑ ↓ to move, enter to select, esc to close
|
||||
@@ -89,8 +89,8 @@ impl CliConfigOverrides {
|
||||
}
|
||||
|
||||
fn canonicalize_override_key(key: &str) -> String {
|
||||
if key == "use_linux_sandbox_bwrap" {
|
||||
"features.use_linux_sandbox_bwrap".to_string()
|
||||
if key == "use_legacy_landlock" {
|
||||
"features.use_legacy_landlock".to_string()
|
||||
} else {
|
||||
key.to_string()
|
||||
}
|
||||
@@ -181,12 +181,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonicalizes_use_linux_sandbox_bwrap_alias() {
|
||||
fn canonicalizes_use_legacy_landlock_alias() {
|
||||
let overrides = CliConfigOverrides {
|
||||
raw_overrides: vec!["use_linux_sandbox_bwrap=true".to_string()],
|
||||
raw_overrides: vec!["use_legacy_landlock=true".to_string()],
|
||||
};
|
||||
let parsed = overrides.parse_overrides().expect("parse_overrides");
|
||||
assert_eq!(parsed[0].0.as_str(), "features.use_linux_sandbox_bwrap");
|
||||
assert_eq!(parsed[0].0.as_str(), "features.use_legacy_landlock");
|
||||
assert_eq!(parsed[0].1.as_bool(), Some(true));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user