Merge 429537c2a2 into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2026-06-25 15:36:45 -07:00
committed by GitHub
17 changed files with 1217 additions and 58 deletions
+32
View File
@@ -0,0 +1,32 @@
{
"outputs": {
"codex-bash": {
"platforms": {
"macos-aarch64": {
"name": "codex-bash-aarch64-apple-darwin.tar.gz",
"format": "tar.gz",
"hash": "sha256",
"path": "codex-bash/bin/bash"
},
"macos-x86_64": {
"name": "codex-bash-x86_64-apple-darwin.tar.gz",
"format": "tar.gz",
"hash": "sha256",
"path": "codex-bash/bin/bash"
},
"linux-x86_64": {
"name": "codex-bash-x86_64-unknown-linux-musl.tar.gz",
"format": "tar.gz",
"hash": "sha256",
"path": "codex-bash/bin/bash"
},
"linux-aarch64": {
"name": "codex-bash-aarch64-unknown-linux-musl.tar.gz",
"format": "tar.gz",
"hash": "sha256",
"path": "codex-bash/bin/bash"
}
}
}
}
}
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ "$#" -ne 1 ]]; then
echo "usage: $0 <archive-path>" >&2
exit 1
fi
archive_path="$1"
workspace="${GITHUB_WORKSPACE:?missing GITHUB_WORKSPACE}"
bash_commit="${BASH_COMMIT:?missing BASH_COMMIT}"
bash_patch="${BASH_PATCH:?missing BASH_PATCH}"
temp_root="${RUNNER_TEMP:-/tmp}"
work_root="$(mktemp -d "${temp_root%/}/codex-bash-release.XXXXXX")"
trap 'rm -rf "$work_root"' EXIT
source_root="${work_root}/bash"
package_root="${work_root}/codex-bash"
wrapper_path="${work_root}/exec-wrapper"
stdout_path="${work_root}/stdout.txt"
wrapper_log_path="${work_root}/wrapper.log"
socket_probe_path="${work_root}/socket-probe.txt"
git clone https://git.savannah.gnu.org/git/bash "$source_root"
cd "$source_root"
git checkout "$bash_commit"
git apply "${workspace}/${bash_patch}"
./configure --without-bash-malloc
cores="$(command -v nproc >/dev/null 2>&1 && nproc || getconf _NPROCESSORS_ONLN)"
make -j"${cores}"
cat > "$wrapper_path" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
: "${CODEX_WRAPPER_LOG:?missing CODEX_WRAPPER_LOG}"
: "${EXEC_WRAPPER:?missing EXEC_WRAPPER}"
: "${CODEX_ESCALATE_SOCKET:?missing CODEX_ESCALATE_SOCKET}"
printf 'socket-open\n' >&"${CODEX_ESCALATE_SOCKET}"
printf '%s\n' "$@" >> "$CODEX_WRAPPER_LOG"
file="$1"
shift
if [[ "$#" -eq 0 ]]; then
exec "$file"
fi
arg0="$1"
shift
exec -a "$arg0" "$file" "$@"
EOF
chmod +x "$wrapper_path"
CODEX_WRAPPER_LOG="$wrapper_log_path" \
CODEX_ESCALATE_SOCKET=9 \
EXEC_WRAPPER="$wrapper_path" \
"${source_root}/bash" \
-c "\"${source_root}/bash\" -c '/bin/echo smoke-bash'" \
> "$stdout_path" \
9> "$socket_probe_path"
grep -Fx "smoke-bash" "$stdout_path"
grep -Fx "${source_root}/bash" "$wrapper_log_path"
grep -Fx "/bin/echo" "$wrapper_log_path"
[[ "$(grep -Fxc "socket-open" "$socket_probe_path")" -eq 2 ]]
mkdir -p "$package_root/bin" "$(dirname "${workspace}/${archive_path}")"
cp "${source_root}/bash" "$package_root/bin/bash"
chmod +x "$package_root/bin/bash"
(cd "$work_root" && tar -czf "${workspace}/${archive_path}" codex-bash)
+12 -2
View File
@@ -20,6 +20,7 @@ package_root="${work_root}/codex-zsh"
wrapper_path="${work_root}/exec-wrapper"
stdout_path="${work_root}/stdout.txt"
wrapper_log_path="${work_root}/wrapper.log"
socket_probe_path="${work_root}/socket-probe.txt"
git clone https://git.code.sf.net/p/zsh/code "$source_root"
cd "$source_root"
@@ -35,7 +36,10 @@ cat > "$wrapper_path" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
: "${CODEX_WRAPPER_LOG:?missing CODEX_WRAPPER_LOG}"
printf '%s\n' "$@" > "$CODEX_WRAPPER_LOG"
: "${EXEC_WRAPPER:?missing EXEC_WRAPPER}"
: "${CODEX_ESCALATE_SOCKET:?missing CODEX_ESCALATE_SOCKET}"
printf 'socket-open\n' >&"${CODEX_ESCALATE_SOCKET}"
printf '%s\n' "$@" >> "$CODEX_WRAPPER_LOG"
file="$1"
shift
if [[ "$#" -eq 0 ]]; then
@@ -48,11 +52,17 @@ EOF
chmod +x "$wrapper_path"
CODEX_WRAPPER_LOG="$wrapper_log_path" \
CODEX_ESCALATE_SOCKET=9 \
EXEC_WRAPPER="$wrapper_path" \
"${source_root}/Src/zsh" -fc '/bin/echo smoke-zsh' > "$stdout_path"
"${source_root}/Src/zsh" \
-fc "\"${source_root}/Src/zsh\" -fc '/bin/echo smoke-zsh'" \
> "$stdout_path" \
9> "$socket_probe_path"
grep -Fx "smoke-zsh" "$stdout_path"
grep -Fx "${source_root}/Src/zsh" "$wrapper_log_path"
grep -Fx "/bin/echo" "$wrapper_log_path"
[[ "$(grep -Fxc "socket-open" "$socket_probe_path")" -eq 2 ]]
mkdir -p "$package_root/bin" "$(dirname "${workspace}/${archive_path}")"
cp "${source_root}/Src/zsh" "$package_root/bin/zsh"
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ "$#" -ne 2 ]]; then
echo "usage: $0 <bash-path> <zsh-path>" >&2
exit 1
fi
bash_path="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")"
zsh_path="$(cd "$(dirname "$2")" && pwd)/$(basename "$2")"
temp_root="${RUNNER_TEMP:-/tmp}"
work_root="$(mktemp -d "${temp_root%/}/codex-shell-chain.XXXXXX")"
trap 'rm -rf "$work_root"' EXIT
wrapper_path="${work_root}/exec-wrapper"
cat > "$wrapper_path" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
: "${CODEX_WRAPPER_LOG:?missing CODEX_WRAPPER_LOG}"
: "${EXEC_WRAPPER:?missing EXEC_WRAPPER}"
: "${CODEX_ESCALATE_SOCKET:?missing CODEX_ESCALATE_SOCKET}"
printf 'socket-open\n' >&"${CODEX_ESCALATE_SOCKET}"
printf '%s\n' "$@" >> "$CODEX_WRAPPER_LOG"
file="$1"
shift
if [[ "$#" -eq 0 ]]; then
exec "$file"
fi
arg0="$1"
shift
exec -a "$arg0" "$file" "$@"
EOF
chmod +x "$wrapper_path"
run_chain() {
local outer_shell="$1"
local outer_flag="$2"
local inner_shell="$3"
local inner_flag="$4"
local marker="$5"
local wrapper_log="${work_root}/${marker}-wrapper.log"
local socket_probe="${work_root}/${marker}-socket.log"
local stdout="${work_root}/${marker}-stdout.txt"
local command
command="\"${inner_shell}\" ${inner_flag} '/bin/echo ${marker}'"
CODEX_WRAPPER_LOG="$wrapper_log" \
CODEX_ESCALATE_SOCKET=9 \
EXEC_WRAPPER="$wrapper_path" \
"$outer_shell" "$outer_flag" "$command" > "$stdout" 9> "$socket_probe"
grep -Fx "$marker" "$stdout"
grep -Fx "$inner_shell" "$wrapper_log"
grep -Fx "/bin/echo" "$wrapper_log"
[[ "$(grep -Fxc "socket-open" "$socket_probe")" -eq 2 ]]
}
run_chain "$bash_path" -c "$zsh_path" -fc bash-zsh-chain
run_chain "$zsh_path" -fc "$bash_path" -c zsh-bash-chain
+223
View File
@@ -0,0 +1,223 @@
name: rust-release-bash
on:
push:
tags:
- "codex-bash-v*.*.*"
env:
BASH_COMMIT: a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b
BASH_PATCH: codex-rs/shell-escalation/patches/bash-exec-wrapper.patch
ZSH_FORK_RELEASE_TAG: codex-zsh-v0.1.0
concurrency:
group: ${{ github.workflow }}::${{ github.ref_name }}
cancel-in-progress: false
jobs:
metadata:
runs-on: ubuntu-latest
outputs:
release_tag: ${{ steps.release_tag.outputs.release_tag }}
steps:
- name: Validate release tag
id: release_tag
env:
RELEASE_TAG: ${{ github.ref_name }}
shell: bash
run: |
set -euo pipefail
if [[ ! "${RELEASE_TAG}" =~ ^codex-bash-v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Tag ${RELEASE_TAG} does not match codex-bash-vX.Y.Z." >&2
exit 1
fi
echo "release_tag=${RELEASE_TAG}" >> "${GITHUB_OUTPUT}"
- name: Ensure release does not exist
env:
GH_TOKEN: ${{ github.token }}
RELEASE_TAG: ${{ steps.release_tag.outputs.release_tag }}
shell: bash
run: |
set -euo pipefail
if gh release view "${RELEASE_TAG}" --repo "${GITHUB_REPOSITORY}" > /dev/null 2>&1; then
echo "Release ${RELEASE_TAG} already exists; publish changed artifacts under a new tag." >&2
exit 1
fi
linux:
name: Build bash (Linux) - ${{ matrix.variant }} - ${{ matrix.target }}
needs: metadata
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
container:
image: ${{ matrix.image }}
strategy:
fail-fast: false
matrix:
include:
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
variant: ubuntu-24.04
image: ubuntu:24.04
archive_name: codex-bash-x86_64-unknown-linux-musl.tar.gz
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
variant: ubuntu-24.04
image: arm64v8/ubuntu:24.04
archive_name: codex-bash-aarch64-unknown-linux-musl.tar.gz
steps:
- name: Install build prerequisites
shell: bash
run: |
set -euo pipefail
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y \
autoconf \
bison \
build-essential \
ca-certificates \
gettext \
git \
libncursesw5-dev
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Build, smoke-test, and stage bash artifact
shell: bash
run: |
"${GITHUB_WORKSPACE}/.github/scripts/build-bash-release-artifact.sh" \
"dist/bash/${{ matrix.target }}/${{ matrix.archive_name }}"
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: codex-bash-${{ matrix.target }}
path: dist/bash/${{ matrix.target }}/*
if-no-files-found: error
darwin:
name: Build bash (macOS) - ${{ matrix.variant }} - ${{ matrix.target }}
needs: metadata
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- runner: macos-15-large
target: x86_64-apple-darwin
variant: macos-15
archive_name: codex-bash-x86_64-apple-darwin.tar.gz
- runner: macos-15-xlarge
target: aarch64-apple-darwin
variant: macos-15
archive_name: codex-bash-aarch64-apple-darwin.tar.gz
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Build, smoke-test, and stage bash artifact
shell: bash
run: |
"${GITHUB_WORKSPACE}/.github/scripts/build-bash-release-artifact.sh" \
"dist/bash/${{ matrix.target }}/${{ matrix.archive_name }}"
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with:
name: codex-bash-${{ matrix.target }}
path: dist/bash/${{ matrix.target }}/*
if-no-files-found: error
cross-shell-smoke:
name: Smoke test bash and zsh fork chain
needs:
- linux
runs-on: ubuntu-24.04
permissions:
contents: read
actions: read
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Download bash artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: codex-bash-x86_64-unknown-linux-musl
path: dist/bash
- name: Download zsh fork artifact
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
set -euo pipefail
mkdir -p dist/zsh
gh release download "${ZSH_FORK_RELEASE_TAG}" \
--repo "${GITHUB_REPOSITORY}" \
--pattern codex-zsh-x86_64-unknown-linux-musl.tar.gz \
--dir dist/zsh
- name: Test nested shell forks
shell: bash
run: |
set -euo pipefail
mkdir -p dist/extracted/bash dist/extracted/zsh
tar -xzf dist/bash/codex-bash-x86_64-unknown-linux-musl.tar.gz \
-C dist/extracted/bash
tar -xzf dist/zsh/codex-zsh-x86_64-unknown-linux-musl.tar.gz \
-C dist/extracted/zsh
.github/scripts/test-shell-exec-wrapper-chain.sh \
dist/extracted/bash/codex-bash/bin/bash \
dist/extracted/zsh/codex-zsh/bin/zsh
publish-release:
needs:
- metadata
- linux
- darwin
- cross-shell-smoke
runs-on: ubuntu-latest
permissions:
contents: write
actions: read
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: dist
- name: Create GitHub Release
uses: softprops/action-gh-release@153bb8e04406b158c6c84fc1615b65b24149a1fe # v2.6.1
with:
tag_name: ${{ needs.metadata.outputs.release_tag }}
name: ${{ needs.metadata.outputs.release_tag }}
files: dist/**
# Keep bash artifact releases out of Codex's normal "latest release" channel.
prerelease: true
- name: Publish DotSlash manifest
uses: facebook/dotslash-publish-release@9c9ec027515c34db9282a09a25a9cab5880b2c52 # v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag: ${{ needs.metadata.outputs.release_tag }}
config: .github/dotslash-bash-config.json
+1
View File
@@ -2557,6 +2557,7 @@ dependencies = [
"multimap",
"pretty_assertions",
"prost 0.14.3",
"regex-lite",
"schemars 0.8.22",
"serde",
"serde_ignored",
+1
View File
@@ -30,6 +30,7 @@ gethostname = { workspace = true }
indexmap = { workspace = true, features = ["serde"] }
multimap = { workspace = true }
prost = "0.14.3"
regex-lite = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_ignored = { workspace = true }
+181 -16
View File
@@ -19,6 +19,7 @@ use super::requirements_exec_policy::RequirementsExecPolicyToml;
use crate::Constrained;
use crate::ConstraintError;
use crate::ManagedHooksRequirementsToml;
use crate::mcp_requirements::McpServerRequirement;
use crate::mcp_types::AppToolApproval;
use crate::permissions_toml::PermissionProfileToml;
use crate::types::WindowsSandboxModeToml;
@@ -217,18 +218,6 @@ impl ConfigRequirements {
}
}
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(untagged)]
pub enum McpServerIdentity {
Command { command: String },
Url { url: String },
}
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct McpServerRequirement {
pub identity: McpServerIdentity,
}
#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)]
pub struct PluginRequirementsToml {
pub mcp_servers: Option<BTreeMap<String, McpServerRequirement>>,
@@ -1201,6 +1190,25 @@ impl ConfigRequirementsToml {
}
}
fn validate_mcp_server_requirements(
requirements: &BTreeMap<String, McpServerRequirement>,
source: &RequirementSource,
plugin_name: Option<&str>,
) -> Result<(), ConstraintError> {
for (server_name, requirement) in requirements {
requirement
.validate()
.map_err(|reason| ConstraintError::McpServerRequirementParse {
server_name: plugin_name
.map(|plugin_name| format!("{plugin_name}/{server_name}"))
.unwrap_or_else(|| server_name.clone()),
requirement_source: source.clone(),
reason,
})?;
}
Ok(())
}
impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
type Error = ConstraintError;
@@ -1233,6 +1241,25 @@ impl TryFrom<ConfigRequirementsWithSources> for ConfigRequirements {
guardian_policy_config,
} = toml;
if let Some(requirements) = &mcp_servers {
validate_mcp_server_requirements(
&requirements.value,
&requirements.source,
/*plugin_name*/ None,
)?;
}
if let Some(plugin_requirements) = &plugins {
for (plugin_name, plugin) in &plugin_requirements.value {
if let Some(requirements) = &plugin.mcp_servers {
validate_mcp_server_requirements(
requirements,
&plugin_requirements.source,
Some(plugin_name),
)?;
}
}
}
let approval_policy = match allowed_approval_policies {
Some(Sourced {
value: policies,
@@ -1542,6 +1569,9 @@ pub fn sandbox_mode_requirement_for_permission_profile(
mod tests {
use super::*;
use crate::HookEventsToml;
use crate::McpServerCommandMatcher;
use crate::McpServerIdentity;
use crate::McpServerValueMatcher;
use anyhow::Result;
use codex_execpolicy::Decision;
use codex_execpolicy::Evaluation;
@@ -3454,6 +3484,9 @@ command = "python3 /enterprise/hooks/pre.py"
#[test]
fn deserialize_mcp_server_requirements() -> Result<()> {
let toml_str = r#"
[mcp_servers.docs]
description = "ignored legacy field"
[mcp_servers.docs.identity]
command = "codex-mcp"
@@ -3469,7 +3502,7 @@ command = "python3 /enterprise/hooks/pre.py"
BTreeMap::from([
(
"docs".to_string(),
McpServerRequirement {
McpServerRequirement::Identity {
identity: McpServerIdentity::Command {
command: "codex-mcp".to_string(),
},
@@ -3477,7 +3510,7 @@ command = "python3 /enterprise/hooks/pre.py"
),
(
"remote".to_string(),
McpServerRequirement {
McpServerRequirement::Identity {
identity: McpServerIdentity::Url {
url: "https://example.com/mcp".to_string(),
},
@@ -3490,6 +3523,74 @@ command = "python3 /enterprise/hooks/pre.py"
Ok(())
}
#[test]
fn deserialize_mcp_server_matcher_requirements() -> Result<()> {
let toml_str = r#"
[mcp_servers.internal_mcp_proxy.identity]
command = { executable = "company-cli", args = [
{ match = "exact", value = "mcp" },
{ match = "exact", value = "proxy" },
{ match = "exact", value = "--server" },
{ match = "regex", expression = '^https://[A-Za-z0-9-]+\.mcp\.internal\.example\.com(?::443)?(?:/.*)?$' },
] }
"#;
let requirements: ConfigRequirements =
with_unknown_source(from_str(toml_str)?).try_into()?;
assert_eq!(
requirements.mcp_servers,
Some(Sourced::new(
BTreeMap::from([(
"internal_mcp_proxy".to_string(),
McpServerRequirement::Command(McpServerCommandMatcher {
executable: "company-cli".to_string(),
args: vec![
McpServerValueMatcher::Exact {
value: "mcp".to_string(),
},
McpServerValueMatcher::Exact {
value: "proxy".to_string(),
},
McpServerValueMatcher::Exact {
value: "--server".to_string(),
},
McpServerValueMatcher::Regex {
expression: r"^https://[A-Za-z0-9-]+\.mcp\.internal\.example\.com(?::443)?(?:/.*)?$"
.to_string(),
},
],
}),
)]),
RequirementSource::Unknown,
))
);
Ok(())
}
#[test]
fn invalid_mcp_server_requirement_regex_reports_the_server_name_and_source() -> Result<()> {
let toml_str = r#"
[mcp_servers.broken_rule.identity]
url = { match = "regex", expression = "[" }
"#;
let err = ConfigRequirements::try_from(with_unknown_source(from_str(toml_str)?))
.expect_err("invalid matcher regex should fail requirements normalization");
let ConstraintError::McpServerRequirementParse {
server_name,
requirement_source,
reason,
} = err
else {
panic!("unexpected error: {err:?}");
};
assert_eq!(server_name, "broken_rule");
assert_eq!(requirement_source, RequirementSource::Unknown);
assert!(reason.contains("invalid regex `[`"), "{reason}");
Ok(())
}
#[test]
fn deserialize_plugin_mcp_server_requirements() -> Result<()> {
let toml_str = r#"
@@ -3511,7 +3612,7 @@ command = "python3 /enterprise/hooks/pre.py"
PluginRequirementsToml {
mcp_servers: Some(BTreeMap::from([(
"remote".to_string(),
McpServerRequirement {
McpServerRequirement::Identity {
identity: McpServerIdentity::Url {
url: "https://example.com/mcp".to_string(),
},
@@ -3524,7 +3625,7 @@ command = "python3 /enterprise/hooks/pre.py"
PluginRequirementsToml {
mcp_servers: Some(BTreeMap::from([(
"sample".to_string(),
McpServerRequirement {
McpServerRequirement::Identity {
identity: McpServerIdentity::Command {
command: "sample-mcp".to_string(),
},
@@ -3539,6 +3640,70 @@ command = "python3 /enterprise/hooks/pre.py"
Ok(())
}
#[test]
fn deserialize_plugin_mcp_server_matcher_requirement() -> Result<()> {
let toml_str = r#"
[plugins."sample@test".mcp_servers.internal_proxy.identity]
command = { executable = "company-cli", args = [
{ match = "exact", value = "mcp" },
{ match = "regex", expression = '^https://[a-z]+\.example\.com$' },
] }
"#;
let requirements: ConfigRequirements =
with_unknown_source(from_str(toml_str)?).try_into()?;
assert_eq!(
requirements.plugins,
Some(Sourced::new(
BTreeMap::from([(
"sample@test".to_string(),
PluginRequirementsToml {
mcp_servers: Some(BTreeMap::from([(
"internal_proxy".to_string(),
McpServerRequirement::Command(McpServerCommandMatcher {
executable: "company-cli".to_string(),
args: vec![
McpServerValueMatcher::Exact {
value: "mcp".to_string(),
},
McpServerValueMatcher::Regex {
expression: r"^https://[a-z]+\.example\.com$".to_string(),
},
],
}),
)])),
},
)]),
RequirementSource::Unknown,
))
);
Ok(())
}
#[test]
fn invalid_plugin_mcp_server_regex_reports_plugin_and_server_name() -> Result<()> {
let toml_str = r#"
[plugins."sample@test".mcp_servers.broken_rule.identity]
url = { match = "regex", expression = "[" }
"#;
let err = ConfigRequirements::try_from(with_unknown_source(from_str(toml_str)?))
.expect_err("invalid plugin MCP regex should fail requirements normalization");
let ConstraintError::McpServerRequirementParse {
server_name,
requirement_source,
reason,
} = err
else {
panic!("unexpected error: {err:?}");
};
assert_eq!(server_name, "sample@test/broken_rule");
assert_eq!(requirement_source, RequirementSource::Unknown);
assert!(reason.contains("invalid regex `[`"), "{reason}");
Ok(())
}
#[test]
fn deserialize_exec_policy_requirements() -> Result<()> {
let toml_str = r#"
+9
View File
@@ -24,6 +24,15 @@ pub enum ConstraintError {
requirement_source: RequirementSource,
reason: String,
},
#[error(
"invalid requirement for MCP server `{server_name}` (set by {requirement_source}): {reason}"
)]
McpServerRequirementParse {
server_name: String,
requirement_source: RequirementSource,
reason: String,
},
}
impl ConstraintError {
+5 -2
View File
@@ -12,6 +12,7 @@ mod key_aliases;
pub mod loader;
mod marketplace_edit;
mod mcp_edit;
mod mcp_requirements;
mod mcp_types;
mod merge;
mod overrides;
@@ -66,8 +67,6 @@ pub use config_requirements::FilesystemDenyReadPattern;
pub use config_requirements::MarketplaceAllowedSourceKind;
pub use config_requirements::MarketplaceAllowedSourceToml;
pub use config_requirements::MarketplaceRequirementsToml;
pub use config_requirements::McpServerIdentity;
pub use config_requirements::McpServerRequirement;
pub use config_requirements::NetworkConstraints;
pub use config_requirements::NetworkDomainPermissionToml;
pub use config_requirements::NetworkDomainPermissionsToml;
@@ -113,6 +112,10 @@ pub use marketplace_edit::remove_user_marketplace;
pub use marketplace_edit::remove_user_marketplace_config;
pub use mcp_edit::ConfigEditsBuilder;
pub use mcp_edit::load_global_mcp_servers;
pub use mcp_requirements::McpServerCommandMatcher;
pub use mcp_requirements::McpServerIdentity;
pub use mcp_requirements::McpServerRequirement;
pub use mcp_requirements::McpServerValueMatcher;
pub use mcp_types::AppToolApproval;
pub use mcp_types::DEFAULT_MCP_SERVER_ENVIRONMENT_ID;
pub use mcp_types::McpServerAuth;
+163
View File
@@ -0,0 +1,163 @@
use crate::mcp_types::McpServerConfig;
use crate::mcp_types::McpServerTransportConfig;
use regex_lite::Regex;
use serde::Deserialize;
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(untagged)]
pub enum McpServerIdentity {
Command { command: String },
Url { url: String },
}
/// String matching operations available to managed MCP server matchers.
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(tag = "match", rename_all = "snake_case", deny_unknown_fields)]
pub enum McpServerValueMatcher {
Exact { value: String },
Prefix { value: String },
Regex { expression: String },
}
impl McpServerValueMatcher {
fn compile_full_regex(expression: &str) -> Result<Regex, String> {
Regex::new(&format!(r"\A(?:{expression})\z")).map_err(|err| {
format!("regex `{expression}` cannot be used for full-value matching: {err}")
})
}
fn validate(&self) -> Result<(), String> {
let Self::Regex { expression } = self else {
return Ok(());
};
Regex::new(expression).map_err(|err| format!("invalid regex `{expression}`: {err}"))?;
Self::compile_full_regex(expression).map(|_| ())
}
fn matches(&self, candidate: &str) -> bool {
match self {
Self::Exact { value } => candidate == value,
Self::Prefix { value } => candidate.starts_with(value),
Self::Regex { expression } => Self::compile_full_regex(expression)
.ok()
.is_some_and(|regex| regex.is_match(candidate)),
}
}
}
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct McpServerCommandMatcher {
pub executable: String,
pub args: Vec<McpServerValueMatcher>,
}
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
struct RawMcpServerCommandIdentity {
command: McpServerCommandMatcher,
}
#[derive(Deserialize, Debug, Clone, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
struct RawMcpServerUrlIdentity {
url: McpServerValueMatcher,
}
/// A requirement for one named MCP server.
///
/// The `Identity` variant preserves the released exact-match contract. The
/// command and URL variants are the normalized matcher-based forms accepted
/// under the `identity` key.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum McpServerRequirement {
Identity { identity: McpServerIdentity },
Command(McpServerCommandMatcher),
Url(McpServerValueMatcher),
}
#[derive(Deserialize)]
struct RawMcpServerRequirement {
identity: RawMcpServerIdentity,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum RawMcpServerIdentity {
Exact(McpServerIdentity),
Command(RawMcpServerCommandIdentity),
Url(RawMcpServerUrlIdentity),
}
impl<'de> Deserialize<'de> for McpServerRequirement {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let RawMcpServerRequirement { identity } =
RawMcpServerRequirement::deserialize(deserializer)?;
match identity {
RawMcpServerIdentity::Exact(identity) => Ok(Self::Identity { identity }),
RawMcpServerIdentity::Command(matcher) => Ok(Self::Command(matcher.command)),
RawMcpServerIdentity::Url(matcher) => Ok(Self::Url(matcher.url)),
}
}
}
impl McpServerRequirement {
pub(crate) fn validate(&self) -> Result<(), String> {
match self {
Self::Identity { .. } => Ok(()),
Self::Command(matcher) => {
for (index, arg) in matcher.args.iter().enumerate() {
arg.validate().map_err(|err| {
format!("invalid argument matcher at index {index}: {err}")
})?;
}
Ok(())
}
Self::Url(matcher) => matcher.validate(),
}
}
pub fn matches(&self, server: &McpServerConfig) -> bool {
match (self, &server.transport) {
(
Self::Identity {
identity:
McpServerIdentity::Command {
command: want_command,
},
},
McpServerTransportConfig::Stdio {
command: got_command,
..
},
) => got_command == want_command,
(
Self::Identity {
identity: McpServerIdentity::Url { url: want_url },
},
McpServerTransportConfig::StreamableHttp { url: got_url, .. },
) => got_url == want_url,
(Self::Command(matcher), McpServerTransportConfig::Stdio { command, args, .. }) => {
matcher.executable == *command
&& matcher.args.len() == args.len()
&& matcher
.args
.iter()
.zip(args)
.all(|(matcher, arg)| matcher.matches(arg))
}
(Self::Url(matcher), McpServerTransportConfig::StreamableHttp { url, .. }) => {
matcher.matches(url)
}
_ => false,
}
}
}
#[cfg(test)]
#[path = "mcp_requirements_tests.rs"]
mod tests;
@@ -0,0 +1,217 @@
use super::*;
use crate::mcp_types::McpServerConfig;
use pretty_assertions::assert_eq;
use std::collections::HashMap;
fn stdio_server(command: &str, args: &[&str]) -> McpServerConfig {
McpServerConfig {
transport: McpServerTransportConfig::Stdio {
command: command.to_string(),
args: args.iter().map(ToString::to_string).collect(),
env: None,
env_vars: Vec::new(),
cwd: None,
},
environment_id: crate::DEFAULT_MCP_SERVER_ENVIRONMENT_ID.to_string(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
default_tools_approval_mode: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth: None,
oauth_resource: None,
tools: HashMap::new(),
}
}
#[test]
fn command_matcher_matches_exact_positional_arguments() {
let requirement = McpServerRequirement::Command(McpServerCommandMatcher {
executable: "company-cli".to_string(),
args: vec![
McpServerValueMatcher::Exact {
value: "mcp".to_string(),
},
McpServerValueMatcher::Regex {
expression: r"https://[a-z]+\.example\.com".to_string(),
},
],
});
assert!(requirement.matches(&stdio_server(
"company-cli",
&["mcp", "https://pricing.example.com"]
)));
assert!(!requirement.matches(&stdio_server(
"company-cli",
&["https://pricing.example.com", "mcp"]
)));
assert!(!requirement.matches(&stdio_server(
"company-cli",
&["mcp", "https://pricing.example.com", "--verbose"]
)));
assert!(!requirement.matches(&stdio_server(
"/usr/local/bin/company-cli",
&["mcp", "https://pricing.example.com"]
)));
}
#[test]
fn regex_matcher_requires_a_full_value_match() {
let matcher = McpServerValueMatcher::Regex {
expression: "mcp".to_string(),
};
assert!(matcher.matches("mcp"));
assert!(!matcher.matches("mcp-proxy"));
assert!(!matcher.matches("prefix-mcp"));
}
#[test]
fn regex_matcher_allows_a_later_alternative_to_match_the_full_value() {
let matcher = McpServerValueMatcher::Regex {
expression: r"https://api\.example\.com|https://api\.example\.com/mcp".to_string(),
};
assert!(matcher.matches("https://api.example.com/mcp"));
}
#[test]
fn regex_matcher_validation_rejects_expression_that_cannot_be_wrapped() {
let matcher = McpServerValueMatcher::Regex {
expression: "(?x)mcp # trailing comment".to_string(),
};
let err = matcher
.validate()
.expect_err("expression should not be valid for full-value matching");
assert!(
err.contains("cannot be used for full-value matching"),
"{err}"
);
}
#[test]
fn legacy_command_identity_keeps_ignoring_arguments() {
let requirement: McpServerRequirement = toml::from_str(
r#"
[identity]
command = "company-cli"
"#,
)
.expect("legacy command identity");
assert!(requirement.matches(&stdio_server(
"company-cli",
&["any", "arguments", "remain", "allowed"]
)));
assert!(!requirement.matches(&stdio_server("different-cli", &[])));
}
#[test]
fn requirement_deserializes_command_and_url_matcher_shapes() {
let command: McpServerRequirement = toml::from_str(
r#"
[identity]
command = { executable = "company-cli", args = [
{ match = "exact", value = "mcp" },
{ match = "regex", expression = '^https://[a-z]+\.example\.com$' },
] }
"#,
)
.expect("command matcher");
let url: McpServerRequirement = toml::from_str(
r#"
[identity]
url = { match = "prefix", value = "https://mcp.example.com/" }
"#,
)
.expect("URL matcher");
assert_eq!(
command,
McpServerRequirement::Command(McpServerCommandMatcher {
executable: "company-cli".to_string(),
args: vec![
McpServerValueMatcher::Exact {
value: "mcp".to_string(),
},
McpServerValueMatcher::Regex {
expression: r"^https://[a-z]+\.example\.com$".to_string(),
},
],
})
);
assert_eq!(
url,
McpServerRequirement::Url(McpServerValueMatcher::Prefix {
value: "https://mcp.example.com/".to_string(),
})
);
}
#[test]
fn requirement_rejects_matchers_outside_identity() {
for contents in [
r#"
command = "company-cli"
"#,
r#"
command = { executable = "company-cli", args = [] }
"#,
r#"
url = { match = "prefix", value = "https://mcp.example.com/" }
"#,
] {
let err = toml::from_str::<McpServerRequirement>(contents)
.expect_err("MCP server requirements should use the identity key");
assert!(
err.to_string().contains("missing field `identity`"),
"{err}"
);
}
}
#[test]
fn matcher_identity_rejects_unknown_fields() {
for contents in [
r#"
[identity]
unknown = "value"
command = { executable = "company-cli", args = [] }
"#,
r#"
[identity]
command = { executable = "company-cli", args = [], unknown = "value" }
"#,
] {
toml::from_str::<McpServerRequirement>(contents)
.expect_err("matcher identities should reject unknown fields");
}
}
#[test]
fn identity_requirement_keeps_ignoring_unrelated_sibling_fields() {
let requirement: McpServerRequirement = toml::from_str(
r#"
unrelated = "ignored"
[identity]
command = "company-cli"
"#,
)
.expect("legacy identity with unrelated sibling field");
assert_eq!(
requirement,
McpServerRequirement::Identity {
identity: McpServerIdentity::Command {
command: "company-cli".to_string(),
},
}
);
}
+190 -12
View File
@@ -6,6 +6,10 @@ use codex_config::CONFIG_TOML_FILE;
use codex_config::ConfigLayerEntry;
use codex_config::ConfigLayerSource;
use codex_config::ConfigLayerStack;
use codex_config::McpServerCommandMatcher;
use codex_config::McpServerIdentity;
use codex_config::McpServerRequirement;
use codex_config::McpServerValueMatcher;
use codex_config::ProfileV2Name;
use codex_config::RequirementSource;
use codex_config::Sourced;
@@ -113,11 +117,15 @@ use std::time::Duration;
use tempfile::TempDir;
fn stdio_mcp(command: &str) -> McpServerConfig {
stdio_mcp_with_args(command, &[])
}
fn stdio_mcp_with_args(command: &str, args: &[&str]) -> McpServerConfig {
McpServerConfig {
auth: Default::default(),
transport: McpServerTransportConfig::Stdio {
command: command.to_string(),
args: Vec::new(),
args: args.iter().map(ToString::to_string).collect(),
env: None,
env_vars: Vec::new(),
cwd: None,
@@ -4128,7 +4136,7 @@ fn filter_mcp_servers_by_allowlist_enforces_identity_rules() {
BTreeMap::from([
(
MISMATCHED_URL_SERVER.to_string(),
McpServerRequirement {
McpServerRequirement::Identity {
identity: McpServerIdentity::Url {
url: "https://example.com/other".to_string(),
},
@@ -4136,7 +4144,7 @@ fn filter_mcp_servers_by_allowlist_enforces_identity_rules() {
),
(
MISMATCHED_COMMAND_SERVER.to_string(),
McpServerRequirement {
McpServerRequirement::Identity {
identity: McpServerIdentity::Command {
command: "other-cmd".to_string(),
},
@@ -4144,7 +4152,7 @@ fn filter_mcp_servers_by_allowlist_enforces_identity_rules() {
),
(
MATCHED_URL_SERVER.to_string(),
McpServerRequirement {
McpServerRequirement::Identity {
identity: McpServerIdentity::Url {
url: GOOD_URL.to_string(),
},
@@ -4152,7 +4160,7 @@ fn filter_mcp_servers_by_allowlist_enforces_identity_rules() {
),
(
MATCHED_COMMAND_SERVER.to_string(),
McpServerRequirement {
McpServerRequirement::Identity {
identity: McpServerIdentity::Command {
command: GOOD_CMD.to_string(),
},
@@ -4209,6 +4217,117 @@ fn filter_mcp_servers_by_allowlist_allows_all_when_unset() {
);
}
#[test]
fn filter_mcp_servers_by_matchers_enforces_command_and_positional_args() {
let mut servers = HashMap::from([
(
"internal_mcp_proxy".to_string(),
stdio_mcp_with_args(
"company-cli",
&[
"mcp",
"proxy",
"--server",
"https://pricing.mcp.internal.example.com",
],
),
),
(
"unlisted".to_string(),
stdio_mcp_with_args(
"company-cli",
&[
"mcp",
"proxy",
"--server",
"https://pricing.mcp.internal.example.com",
],
),
),
(
"wrong-order".to_string(),
stdio_mcp_with_args(
"company-cli",
&[
"proxy",
"mcp",
"--server",
"https://pricing.mcp.internal.example.com",
],
),
),
(
"trailing-arg".to_string(),
stdio_mcp_with_args(
"company-cli",
&[
"mcp",
"proxy",
"--server",
"https://pricing.mcp.internal.example.com",
"--verbose",
],
),
),
(
"wrong-host".to_string(),
stdio_mcp_with_args(
"company-cli",
&["mcp", "proxy", "--server", "https://mcp.example.com"],
),
),
]);
let source = RequirementSource::LegacyManagedConfigTomlFromMdm;
let requirement = McpServerRequirement::Command(McpServerCommandMatcher {
executable: "company-cli".to_string(),
args: vec![
McpServerValueMatcher::Exact {
value: "mcp".to_string(),
},
McpServerValueMatcher::Exact {
value: "proxy".to_string(),
},
McpServerValueMatcher::Exact {
value: "--server".to_string(),
},
McpServerValueMatcher::Regex {
expression:
r"^https://[A-Za-z0-9-]+\.mcp\.internal\.example\.com(?::443)?(?:/.*)?$"
.to_string(),
},
],
});
let requirements = Sourced::new(
BTreeMap::from([
("internal_mcp_proxy".to_string(), requirement.clone()),
("wrong-order".to_string(), requirement.clone()),
("trailing-arg".to_string(), requirement.clone()),
("wrong-host".to_string(), requirement),
]),
source.clone(),
);
filter_mcp_servers_by_requirements(&mut servers, Some(&requirements));
let reason = Some(McpServerDisabledReason::Requirements { source });
assert_eq!(
servers
.iter()
.map(|(name, server)| (
name.clone(),
(server.enabled, server.disabled_reason.clone())
))
.collect::<HashMap<String, (bool, Option<McpServerDisabledReason>)>>(),
HashMap::from([
("internal_mcp_proxy".to_string(), (true, None)),
("unlisted".to_string(), (false, reason.clone())),
("wrong-order".to_string(), (false, reason.clone())),
("trailing-arg".to_string(), (false, reason.clone())),
("wrong-host".to_string(), (false, reason)),
])
);
}
#[test]
fn filter_mcp_servers_by_allowlist_blocks_all_when_empty() {
let mut servers = HashMap::from([
@@ -4259,7 +4378,7 @@ fn filter_plugin_mcp_servers_by_allowlist_enforces_plugin_and_identity_rules() {
mcp_servers: Some(BTreeMap::from([
(
MATCHED_SERVER.to_string(),
McpServerRequirement {
McpServerRequirement::Identity {
identity: McpServerIdentity::Command {
command: GOOD_CMD.to_string(),
},
@@ -4267,7 +4386,7 @@ fn filter_plugin_mcp_servers_by_allowlist_enforces_plugin_and_identity_rules() {
),
(
MISMATCHED_SERVER.to_string(),
McpServerRequirement {
McpServerRequirement::Identity {
identity: McpServerIdentity::Command {
command: GOOD_CMD.to_string(),
},
@@ -4308,7 +4427,7 @@ fn filter_plugin_mcp_servers_by_allowlist_blocks_unlisted_plugin() {
codex_config::PluginRequirementsToml {
mcp_servers: Some(BTreeMap::from([(
"server-a".to_string(),
McpServerRequirement {
McpServerRequirement::Identity {
identity: McpServerIdentity::Command {
command: "cmd-a".to_string(),
},
@@ -4339,6 +4458,65 @@ fn filter_plugin_mcp_servers_by_allowlist_blocks_unlisted_plugin() {
);
}
#[test]
fn filter_plugin_mcp_servers_by_matchers_enforces_name_and_invocation() {
const MATCHED_SERVER: &str = "matched";
const MISMATCHED_SERVER: &str = "mismatched";
const UNLISTED_SERVER: &str = "unlisted";
let mut servers = HashMap::from([
(
MATCHED_SERVER.to_string(),
stdio_mcp_with_args("company-cli", &["approved"]),
),
(
MISMATCHED_SERVER.to_string(),
stdio_mcp_with_args("company-cli", &["rejected"]),
),
(
UNLISTED_SERVER.to_string(),
stdio_mcp_with_args("company-cli", &["approved"]),
),
]);
let source = RequirementSource::LegacyManagedConfigTomlFromMdm;
let requirement = McpServerRequirement::Command(McpServerCommandMatcher {
executable: "company-cli".to_string(),
args: vec![McpServerValueMatcher::Exact {
value: "approved".to_string(),
}],
});
let requirements = Sourced::new(
BTreeMap::from([(
"sample@test".to_string(),
codex_config::PluginRequirementsToml {
mcp_servers: Some(BTreeMap::from([
(MATCHED_SERVER.to_string(), requirement.clone()),
(MISMATCHED_SERVER.to_string(), requirement),
])),
},
)]),
source.clone(),
);
filter_plugin_mcp_servers_by_requirements("sample@test", &mut servers, Some(&requirements));
let reason = Some(McpServerDisabledReason::Requirements { source });
assert_eq!(
servers
.iter()
.map(|(name, server)| (
name.clone(),
(server.enabled, server.disabled_reason.clone())
))
.collect::<HashMap<String, (bool, Option<McpServerDisabledReason>)>>(),
HashMap::from([
(MATCHED_SERVER.to_string(), (true, None)),
(MISMATCHED_SERVER.to_string(), (false, reason.clone())),
(UNLISTED_SERVER.to_string(), (false, reason)),
])
);
}
#[tokio::test]
async fn rebuild_preserving_session_layers_refreshes_requirements() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
@@ -4348,7 +4526,7 @@ async fn rebuild_preserving_session_layers_refreshes_requirements() -> std::io::
let mcp_requirements = BTreeMap::from([
(
"session_overrides_user".to_string(),
McpServerRequirement {
McpServerRequirement::Identity {
identity: McpServerIdentity::Command {
command: "session-command".to_string(),
},
@@ -4356,7 +4534,7 @@ async fn rebuild_preserving_session_layers_refreshes_requirements() -> std::io::
),
(
"managed_overrides_session".to_string(),
McpServerRequirement {
McpServerRequirement::Identity {
identity: McpServerIdentity::Command {
command: "managed-command".to_string(),
},
@@ -4364,7 +4542,7 @@ async fn rebuild_preserving_session_layers_refreshes_requirements() -> std::io::
),
(
"fresh_global".to_string(),
McpServerRequirement {
McpServerRequirement::Identity {
identity: McpServerIdentity::Command {
command: "fresh-global-command".to_string(),
},
@@ -4372,7 +4550,7 @@ async fn rebuild_preserving_session_layers_refreshes_requirements() -> std::io::
),
(
"fresh_project".to_string(),
McpServerRequirement {
McpServerRequirement::Identity {
identity: McpServerIdentity::Command {
command: "fresh-project-command".to_string(),
},
+2 -24
View File
@@ -14,7 +14,6 @@ use codex_config::ConfigRequirements;
use codex_config::ConfigRequirementsToml;
use codex_config::ConstrainedWithSource;
use codex_config::FeatureRequirementsToml;
use codex_config::McpServerIdentity;
use codex_config::McpServerRequirement;
use codex_config::PluginRequirementsToml;
use codex_config::ProfileV2Name;
@@ -40,7 +39,6 @@ use codex_config::types::AuthKeyringBackendKind;
use codex_config::types::History;
use codex_config::types::McpServerConfig;
use codex_config::types::McpServerDisabledReason;
use codex_config::types::McpServerTransportConfig;
use codex_config::types::MemoriesConfig;
use codex_config::types::ModelAvailabilityNuxConfig;
use codex_config::types::Notice;
@@ -1890,7 +1888,7 @@ fn filter_mcp_servers_by_requirements(
let allowed = allowlist
.value
.get(name)
.is_some_and(|requirement| mcp_server_matches_requirement(requirement, server));
.is_some_and(|requirement| requirement.matches(server));
if allowed {
server.disabled_reason = None;
} else {
@@ -1919,7 +1917,7 @@ fn filter_plugin_mcp_servers_by_requirements(
for (name, server) in mcp_servers.iter_mut() {
let allowed = plugin_mcp_requirements
.and_then(|mcp_requirements| mcp_requirements.get(name))
.is_some_and(|requirement| mcp_server_matches_requirement(requirement, server));
.is_some_and(|requirement| requirement.matches(server));
if allowed {
server.disabled_reason = None;
} else {
@@ -1982,26 +1980,6 @@ where
Ok(false)
}
fn mcp_server_matches_requirement(
requirement: &McpServerRequirement,
server: &McpServerConfig,
) -> bool {
match &requirement.identity {
McpServerIdentity::Command {
command: want_command,
} => matches!(
&server.transport,
McpServerTransportConfig::Stdio { command: got_command, .. }
if got_command == want_command
),
McpServerIdentity::Url { url: want_url } => matches!(
&server.transport,
McpServerTransportConfig::StreamableHttp { url: got_url, .. }
if got_url == want_url
),
}
}
pub async fn load_global_mcp_servers(
codex_home: &Path,
) -> std::io::Result<BTreeMap<String, McpServerConfig>> {
+24
View File
@@ -15,6 +15,30 @@ decision to the shell-escalation protocol over a shared file descriptor (specifi
- `Deny`: the server has declared the proposed command to be forbidden, so
`codex-execve-wrapper` prints an error to `stderr` and exits with `1`.
Both patched shells use `EXEC_WRAPPER` to locate `codex-execve-wrapper` and
preserve `CODEX_ESCALATE_SOCKET` as the inherited escalation socket file
descriptor. This shared environment contract lets intercepted commands pass
through trees containing either shell without losing the escalation session.
## Patched bash
We carry a small patch to `execute_cmd.c` (see
`patches/bash-exec-wrapper.patch`) that adds support for `EXEC_WRAPPER`. The
patch applies to `a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b` from
https://git.savannah.gnu.org/git/bash. To rebuild manually:
```bash
git clone https://git.savannah.gnu.org/git/bash
git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b
git apply /path/to/patches/bash-exec-wrapper.patch
./configure --without-bash-malloc
make -j"$(nproc)"
```
Release artifacts are built by `.github/workflows/rust-release-bash.yml` when
a `codex-bash-vX.Y.Z` tag is pushed. When the bash commit or patch changes,
publish the next version tag.
## Patched zsh
We carry a small patch to `Src/exec.c` (see `patches/zsh-exec-wrapper.patch`) that adds support for `EXEC_WRAPPER`. The patch applies to `77045ef899e53b9598bebc5a41db93a548a40ca6` from https://git.code.sf.net/p/zsh/code. To rebuild manually:
@@ -0,0 +1,24 @@
diff --git a/execute_cmd.c b/execute_cmd.c
index 070f5119..d20ad2b9 100644
--- a/execute_cmd.c
+++ b/execute_cmd.c
@@ -6129,6 +6129,19 @@ shell_execve (char *command, char **args, char **env)
char sample[HASH_BANG_BUFSIZ];
size_t larray;
+ char *exec_wrapper = getenv("EXEC_WRAPPER");
+ if (exec_wrapper && *exec_wrapper && !whitespace (*exec_wrapper))
+ {
+ char *orig_command = command;
+
+ larray = strvec_len (args);
+
+ memmove (args + 2, args, (++larray) * sizeof (char *));
+ args[0] = exec_wrapper;
+ args[1] = orig_command;
+ command = exec_wrapper;
+ }
+
SETOSTYPE (0); /* Some systems use for USG/POSIX semantics */
execve (command, args, env);
i = errno; /* error from execve() */
+2 -2
View File
@@ -699,7 +699,7 @@ mod tests {
mcp_servers: Some(Sourced::new(
BTreeMap::from([(
"docs".to_string(),
McpServerRequirement {
McpServerRequirement::Identity {
identity: McpServerIdentity::Command {
command: "codex-mcp".to_string(),
},
@@ -778,7 +778,7 @@ mod tests {
hooks: None,
mcp_servers: Some(BTreeMap::from([(
"docs".to_string(),
McpServerRequirement {
McpServerRequirement::Identity {
identity: McpServerIdentity::Command {
command: "codex-mcp".to_string(),
},