chore: merge origin/main into PR 14718

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
viyatb-oai
2026-04-13 21:11:19 -07:00
co-authored by Codex
340 changed files with 14223 additions and 4010 deletions
+3
View File
@@ -10,6 +10,7 @@ ENV TZ="$TZ"
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
# Devcontainers run as a non-root user, so enable bubblewrap's setuid mode.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
@@ -37,6 +38,8 @@ RUN apt-get update \
ipset \
iptables \
aggregate \
bubblewrap \
&& chmod u+s /usr/bin/bwrap \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
+4
View File
@@ -14,6 +14,8 @@ Use `devcontainer.json` when you are developing Codex itself. This is the same l
Use `devcontainer.secure.json` when you want a stricter runtime profile for running Codex inside a project container:
- installs the Codex CLI plus common build tools
- installs bubblewrap in setuid mode for Codex's Linux sandbox
- disables Docker's outer seccomp and AppArmor profiles so bubblewrap can construct Codex's inner sandbox
- enables firewall startup with an allowlist-driven outbound policy
- blocks IPv6 by default so the allowlist cannot be bypassed over AAAA routes
- requires `NET_ADMIN` and `NET_RAW` so the firewall can be installed at startup
@@ -43,3 +45,5 @@ Note that `/workspace/target` will contain the binaries built for your host plat
For arm64, specify `--platform=linux/arm64` instead for both `docker build` and `docker run`.
Currently, the contributor `Dockerfile` works for both x64 and arm64 Linux, though you need to run `rustup target add x86_64-unknown-linux-musl` yourself to install the musl toolchain for x64.
The secure profile's capability, seccomp, and AppArmor options are required when you want Codex's bubblewrap sandbox to run inside Docker as the non-root devcontainer user. Without them, Docker's default runtime profile can block bubblewrap's namespace setup before Codex's own seccomp filter is installed. This keeps the Docker relaxation explicit in the profile that is meant to run Codex inside a project container, while the default contributor profile stays lightweight.
+7
View File
@@ -12,6 +12,13 @@
}
},
"runArgs": [
"--cap-add=SYS_ADMIN",
"--cap-add=SYS_CHROOT",
"--cap-add=SETUID",
"--cap-add=SETGID",
"--cap-add=SYS_PTRACE",
"--security-opt=seccomp=unconfined",
"--security-opt=apparmor=unconfined",
"--cap-add=NET_ADMIN",
"--cap-add=NET_RAW"
],
+1 -1
View File
@@ -12,7 +12,7 @@ runs:
using: composite
steps:
- name: Install cosign
uses: sigstore/cosign-installer@v3.7.0
uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da # v3.7.0
- name: Cosign Linux artifacts
shell: bash
+3 -3
View File
@@ -18,7 +18,7 @@ runs:
steps:
- name: Set up Node.js for js_repl tests
if: inputs.install-test-prereqs == 'true'
uses: actions/setup-node@v6
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
with:
node-version-file: codex-rs/node-version.txt
@@ -26,7 +26,7 @@ runs:
# See https://github.com/openai/codex/pull/7617.
- name: Install DotSlash
if: inputs.install-test-prereqs == 'true'
uses: facebook/install-dotslash@v2
uses: facebook/install-dotslash@1e4e7b3e07eaca387acb98f1d4720e0bee8dbb6a # v2
- name: Make DotSlash available in PATH (Unix)
if: inputs.install-test-prereqs == 'true' && runner.os != 'Windows'
@@ -39,7 +39,7 @@ runs:
run: Copy-Item (Get-Command dotslash).Source -Destination "$env:LOCALAPPDATA\Microsoft\WindowsApps\dotslash.exe"
- name: Set up Bazel
uses: bazelbuild/setup-bazelisk@v3
uses: bazelbuild/setup-bazelisk@b39c379c82683a5f25d34f0d062761f62693e0b2 # v3
- name: Configure Bazel repository cache
id: configure_bazel_repository_cache
@@ -0,0 +1,49 @@
name: setup-rusty-v8-musl
description: Download and verify musl rusty_v8 artifacts for Cargo builds.
inputs:
target:
description: Rust musl target triple.
required: true
runs:
using: composite
steps:
- name: Configure musl rusty_v8 artifact overrides and verify checksums
shell: bash
env:
TARGET: ${{ inputs.target }}
run: |
set -euo pipefail
case "${TARGET}" in
x86_64-unknown-linux-musl|aarch64-unknown-linux-musl)
;;
*)
echo "Unsupported musl rusty_v8 target: ${TARGET}" >&2
exit 1
;;
esac
version="$(python3 "${GITHUB_WORKSPACE}/.github/scripts/rusty_v8_bazel.py" resolved-v8-crate-version)"
release_tag="rusty-v8-v${version}"
base_url="https://github.com/openai/codex/releases/download/${release_tag}"
binding_dir="${RUNNER_TEMP}/rusty_v8"
archive_path="${binding_dir}/librusty_v8_release_${TARGET}.a.gz"
binding_path="${binding_dir}/src_binding_release_${TARGET}.rs"
checksums_path="${binding_dir}/rusty_v8_release_${TARGET}.sha256"
checksums_source="${GITHUB_WORKSPACE}/third_party/v8/rusty_v8_${version//./_}.sha256"
mkdir -p "${binding_dir}"
curl -fsSL "${base_url}/librusty_v8_release_${TARGET}.a.gz" -o "${archive_path}"
curl -fsSL "${base_url}/src_binding_release_${TARGET}.rs" -o "${binding_path}"
grep -E " (librusty_v8_release_${TARGET}[.]a[.]gz|src_binding_release_${TARGET}[.]rs)$" \
"${checksums_source}" > "${checksums_path}"
if [[ "$(wc -l < "${checksums_path}")" -ne 2 ]]; then
echo "Expected exactly two checksums for ${TARGET} in ${checksums_source}" >&2
exit 1
fi
(cd "${binding_dir}" && sha256sum -c "${checksums_path}")
echo "RUSTY_V8_ARCHIVE=${archive_path}" >> "${GITHUB_ENV}"
echo "RUSTY_V8_SRC_BINDING_PATH=${binding_path}" >> "${GITHUB_ENV}"
+2 -2
View File
@@ -27,14 +27,14 @@ runs:
using: composite
steps:
- name: Azure login for Trusted Signing (OIDC)
uses: azure/login@v2
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2
with:
client-id: ${{ inputs.client-id }}
tenant-id: ${{ inputs.tenant-id }}
subscription-id: ${{ inputs.subscription-id }}
- name: Sign Windows binaries with Azure Trusted Signing
uses: azure/trusted-signing-action@v0
uses: azure/trusted-signing-action@1d365fec12862c4aa68fcac418143d73f0cea293 # v0
with:
endpoint: ${{ inputs.endpoint }}
trusted-signing-account-name: ${{ inputs.account-name }}
+71
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import argparse
import gzip
import hashlib
import re
import shutil
import subprocess
@@ -12,8 +13,16 @@ import tempfile
import tomllib
from pathlib import Path
from rusty_v8_module_bazel import (
RustyV8ChecksumError,
check_module_bazel,
update_module_bazel,
)
ROOT = Path(__file__).resolve().parents[2]
MODULE_BAZEL = ROOT / "MODULE.bazel"
RUSTY_V8_CHECKSUMS_DIR = ROOT / "third_party" / "v8"
MUSL_RUNTIME_ARCHIVE_LABELS = [
"@llvm//runtimes/libcxx:libcxx.static",
"@llvm//runtimes/libcxx:libcxxabi.static",
@@ -146,6 +155,24 @@ def resolved_v8_crate_version() -> str:
return matches[0]
def rusty_v8_checksum_manifest_path(version: str) -> Path:
return RUSTY_V8_CHECKSUMS_DIR / f"rusty_v8_{version.replace('.', '_')}.sha256"
def command_version(version: str | None) -> str:
if version is not None:
return version
return resolved_v8_crate_version()
def command_manifest_path(manifest: Path | None, version: str) -> Path:
if manifest is None:
return rusty_v8_checksum_manifest_path(version)
if manifest.is_absolute():
return manifest
return ROOT / manifest
def staged_archive_name(target: str, source_path: Path) -> str:
if source_path.suffix == ".lib":
return f"rusty_v8_release_{target}.lib.gz"
@@ -244,8 +271,18 @@ def stage_release_pair(
shutil.copyfile(binding_path, staged_binding)
staged_checksums = output_dir / f"rusty_v8_release_{target}.sha256"
with staged_checksums.open("w", encoding="utf-8") as checksums:
for path in [staged_library, staged_binding]:
digest = hashlib.sha256()
with path.open("rb") as artifact:
for chunk in iter(lambda: artifact.read(1024 * 1024), b""):
digest.update(chunk)
checksums.write(f"{digest.hexdigest()} {path.name}\n")
print(staged_library)
print(staged_binding)
print(staged_checksums)
def parse_args() -> argparse.Namespace:
@@ -264,6 +301,24 @@ def parse_args() -> argparse.Namespace:
subparsers.add_parser("resolved-v8-crate-version")
check_module_bazel_parser = subparsers.add_parser("check-module-bazel")
check_module_bazel_parser.add_argument("--version")
check_module_bazel_parser.add_argument("--manifest", type=Path)
check_module_bazel_parser.add_argument(
"--module-bazel",
type=Path,
default=MODULE_BAZEL,
)
update_module_bazel_parser = subparsers.add_parser("update-module-bazel")
update_module_bazel_parser.add_argument("--version")
update_module_bazel_parser.add_argument("--manifest", type=Path)
update_module_bazel_parser.add_argument(
"--module-bazel",
type=Path,
default=MODULE_BAZEL,
)
return parser.parse_args()
@@ -280,6 +335,22 @@ def main() -> int:
if args.command == "resolved-v8-crate-version":
print(resolved_v8_crate_version())
return 0
if args.command == "check-module-bazel":
version = command_version(args.version)
manifest_path = command_manifest_path(args.manifest, version)
try:
check_module_bazel(args.module_bazel, manifest_path, version)
except RustyV8ChecksumError as exc:
raise SystemExit(str(exc)) from exc
return 0
if args.command == "update-module-bazel":
version = command_version(args.version)
manifest_path = command_manifest_path(args.manifest, version)
try:
update_module_bazel(args.module_bazel, manifest_path, version)
except RustyV8ChecksumError as exc:
raise SystemExit(str(exc)) from exc
return 0
raise SystemExit(f"unsupported command: {args.command}")
+230
View File
@@ -0,0 +1,230 @@
#!/usr/bin/env python3
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
SHA256_RE = re.compile(r"[0-9a-f]{64}")
HTTP_FILE_BLOCK_RE = re.compile(r"(?ms)^http_file\(\n.*?^\)\n?")
class RustyV8ChecksumError(ValueError):
pass
@dataclass(frozen=True)
class RustyV8HttpFile:
start: int
end: int
block: str
name: str
downloaded_file_path: str
sha256: str | None
def parse_checksum_manifest(path: Path) -> dict[str, str]:
try:
lines = path.read_text(encoding="utf-8").splitlines()
except FileNotFoundError as exc:
raise RustyV8ChecksumError(f"missing checksum manifest: {path}") from exc
checksums: dict[str, str] = {}
for line_number, line in enumerate(lines, 1):
if not line.strip():
continue
parts = line.split()
if len(parts) != 2:
raise RustyV8ChecksumError(
f"{path}:{line_number}: expected '<sha256> <filename>'"
)
checksum, filename = parts
if not SHA256_RE.fullmatch(checksum):
raise RustyV8ChecksumError(
f"{path}:{line_number}: invalid SHA-256 digest for {filename}"
)
if not filename or filename in {".", ".."} or "/" in filename:
raise RustyV8ChecksumError(
f"{path}:{line_number}: expected a bare artifact filename"
)
if filename in checksums:
raise RustyV8ChecksumError(
f"{path}:{line_number}: duplicate checksum for {filename}"
)
checksums[filename] = checksum
if not checksums:
raise RustyV8ChecksumError(f"empty checksum manifest: {path}")
return checksums
def string_field(block: str, field: str) -> str | None:
# Matches one-line string fields inside http_file blocks, e.g. `sha256 = "...",`.
match = re.search(rf'^\s*{re.escape(field)}\s*=\s*"([^"]+)",\s*$', block, re.M)
if match:
return match.group(1)
return None
def rusty_v8_http_files(module_bazel: str, version: str) -> list[RustyV8HttpFile]:
version_slug = version.replace(".", "_")
name_prefix = f"rusty_v8_{version_slug}_"
entries = []
for match in HTTP_FILE_BLOCK_RE.finditer(module_bazel):
block = match.group(0)
name = string_field(block, "name")
if not name or not name.startswith(name_prefix):
continue
downloaded_file_path = string_field(block, "downloaded_file_path")
if not downloaded_file_path:
raise RustyV8ChecksumError(
f"MODULE.bazel {name} is missing downloaded_file_path"
)
entries.append(
RustyV8HttpFile(
start=match.start(),
end=match.end(),
block=block,
name=name,
downloaded_file_path=downloaded_file_path,
sha256=string_field(block, "sha256"),
)
)
return entries
def module_entry_set_errors(
entries: list[RustyV8HttpFile],
checksums: dict[str, str],
version: str,
) -> list[str]:
errors = []
if not entries:
errors.append(f"MODULE.bazel has no rusty_v8 http_file entries for {version}")
return errors
module_files: dict[str, RustyV8HttpFile] = {}
duplicate_files = set()
for entry in entries:
if entry.downloaded_file_path in module_files:
duplicate_files.add(entry.downloaded_file_path)
module_files[entry.downloaded_file_path] = entry
for filename in sorted(duplicate_files):
errors.append(f"MODULE.bazel has duplicate http_file entries for {filename}")
for filename in sorted(set(module_files) - set(checksums)):
entry = module_files[filename]
errors.append(f"MODULE.bazel {entry.name} has no checksum in the manifest")
for filename in sorted(set(checksums) - set(module_files)):
errors.append(f"manifest has {filename}, but MODULE.bazel has no http_file")
return errors
def module_checksum_errors(
entries: list[RustyV8HttpFile],
checksums: dict[str, str],
) -> list[str]:
errors = []
for entry in entries:
expected = checksums.get(entry.downloaded_file_path)
if expected is None:
continue
if entry.sha256 is None:
errors.append(f"MODULE.bazel {entry.name} is missing sha256")
elif entry.sha256 != expected:
errors.append(
f"MODULE.bazel {entry.name} has sha256 {entry.sha256}, "
f"expected {expected}"
)
return errors
def raise_checksum_errors(message: str, errors: list[str]) -> None:
if errors:
formatted_errors = "\n".join(f"- {error}" for error in errors)
raise RustyV8ChecksumError(f"{message}:\n{formatted_errors}")
def check_module_bazel_text(
module_bazel: str,
checksums: dict[str, str],
version: str,
) -> None:
entries = rusty_v8_http_files(module_bazel, version)
errors = [
*module_entry_set_errors(entries, checksums, version),
*module_checksum_errors(entries, checksums),
]
raise_checksum_errors("rusty_v8 MODULE.bazel checksum drift", errors)
def block_with_sha256(block: str, checksum: str) -> str:
sha256_line_re = re.compile(r'(?m)^(\s*)sha256\s*=\s*"[0-9a-f]+",\s*$')
if sha256_line_re.search(block):
return sha256_line_re.sub(
lambda match: f'{match.group(1)}sha256 = "{checksum}",',
block,
count=1,
)
downloaded_file_path_match = re.search(
r'(?m)^(\s*)downloaded_file_path\s*=\s*"[^"]+",\n',
block,
)
if not downloaded_file_path_match:
raise RustyV8ChecksumError("http_file block is missing downloaded_file_path")
insert_at = downloaded_file_path_match.end()
indent = downloaded_file_path_match.group(1)
return f'{block[:insert_at]}{indent}sha256 = "{checksum}",\n{block[insert_at:]}'
def update_module_bazel_text(
module_bazel: str,
checksums: dict[str, str],
version: str,
) -> str:
entries = rusty_v8_http_files(module_bazel, version)
errors = module_entry_set_errors(entries, checksums, version)
raise_checksum_errors("cannot update rusty_v8 MODULE.bazel checksums", errors)
updated = []
previous_end = 0
for entry in entries:
updated.append(module_bazel[previous_end : entry.start])
updated.append(
block_with_sha256(entry.block, checksums[entry.downloaded_file_path])
)
previous_end = entry.end
updated.append(module_bazel[previous_end:])
return "".join(updated)
def check_module_bazel(
module_bazel_path: Path,
manifest_path: Path,
version: str,
) -> None:
checksums = parse_checksum_manifest(manifest_path)
module_bazel = module_bazel_path.read_text(encoding="utf-8")
check_module_bazel_text(module_bazel, checksums, version)
print(f"{module_bazel_path} rusty_v8 {version} checksums match {manifest_path}")
def update_module_bazel(
module_bazel_path: Path,
manifest_path: Path,
version: str,
) -> None:
checksums = parse_checksum_manifest(manifest_path)
module_bazel = module_bazel_path.read_text(encoding="utf-8")
updated_module_bazel = update_module_bazel_text(module_bazel, checksums, version)
if updated_module_bazel == module_bazel:
print(f"{module_bazel_path} rusty_v8 {version} checksums are already current")
return
module_bazel_path.write_text(updated_module_bazel, encoding="utf-8")
print(f"updated {module_bazel_path} rusty_v8 {version} checksums")
+126
View File
@@ -0,0 +1,126 @@
#!/usr/bin/env python3
from __future__ import annotations
import textwrap
import unittest
import rusty_v8_module_bazel
class RustyV8BazelTest(unittest.TestCase):
def test_update_module_bazel_replaces_and_inserts_sha256(self) -> None:
module_bazel = textwrap.dedent(
"""\
http_file(
name = "rusty_v8_146_4_0_x86_64_unknown_linux_gnu_archive",
downloaded_file_path = "librusty_v8_release_x86_64-unknown-linux-gnu.a.gz",
sha256 = "0000000000000000000000000000000000000000000000000000000000000000",
urls = [
"https://example.test/librusty_v8_release_x86_64-unknown-linux-gnu.a.gz",
],
)
http_file(
name = "rusty_v8_146_4_0_x86_64_unknown_linux_musl_binding",
downloaded_file_path = "src_binding_release_x86_64-unknown-linux-musl.rs",
urls = [
"https://example.test/src_binding_release_x86_64-unknown-linux-musl.rs",
],
)
http_file(
name = "rusty_v8_145_0_0_x86_64_unknown_linux_gnu_archive",
downloaded_file_path = "librusty_v8_release_x86_64-unknown-linux-gnu.a.gz",
sha256 = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
urls = [
"https://example.test/old.gz",
],
)
"""
)
checksums = {
"librusty_v8_release_x86_64-unknown-linux-gnu.a.gz": (
"1111111111111111111111111111111111111111111111111111111111111111"
),
"src_binding_release_x86_64-unknown-linux-musl.rs": (
"2222222222222222222222222222222222222222222222222222222222222222"
),
}
updated = rusty_v8_module_bazel.update_module_bazel_text(
module_bazel,
checksums,
"146.4.0",
)
self.assertEqual(
textwrap.dedent(
"""\
http_file(
name = "rusty_v8_146_4_0_x86_64_unknown_linux_gnu_archive",
downloaded_file_path = "librusty_v8_release_x86_64-unknown-linux-gnu.a.gz",
sha256 = "1111111111111111111111111111111111111111111111111111111111111111",
urls = [
"https://example.test/librusty_v8_release_x86_64-unknown-linux-gnu.a.gz",
],
)
http_file(
name = "rusty_v8_146_4_0_x86_64_unknown_linux_musl_binding",
downloaded_file_path = "src_binding_release_x86_64-unknown-linux-musl.rs",
sha256 = "2222222222222222222222222222222222222222222222222222222222222222",
urls = [
"https://example.test/src_binding_release_x86_64-unknown-linux-musl.rs",
],
)
http_file(
name = "rusty_v8_145_0_0_x86_64_unknown_linux_gnu_archive",
downloaded_file_path = "librusty_v8_release_x86_64-unknown-linux-gnu.a.gz",
sha256 = "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
urls = [
"https://example.test/old.gz",
],
)
"""
),
updated,
)
rusty_v8_module_bazel.check_module_bazel_text(updated, checksums, "146.4.0")
def test_check_module_bazel_rejects_manifest_drift(self) -> None:
module_bazel = textwrap.dedent(
"""\
http_file(
name = "rusty_v8_146_4_0_x86_64_unknown_linux_gnu_archive",
downloaded_file_path = "librusty_v8_release_x86_64-unknown-linux-gnu.a.gz",
sha256 = "1111111111111111111111111111111111111111111111111111111111111111",
urls = [
"https://example.test/librusty_v8_release_x86_64-unknown-linux-gnu.a.gz",
],
)
"""
)
checksums = {
"librusty_v8_release_x86_64-unknown-linux-gnu.a.gz": (
"1111111111111111111111111111111111111111111111111111111111111111"
),
"orphan.gz": (
"2222222222222222222222222222222222222222222222222222222222222222"
),
}
with self.assertRaisesRegex(
rusty_v8_module_bazel.RustyV8ChecksumError,
"manifest has orphan.gz",
):
rusty_v8_module_bazel.check_module_bazel_text(
module_bazel,
checksums,
"146.4.0",
)
if __name__ == "__main__":
unittest.main()
+9 -2
View File
@@ -51,6 +51,13 @@ jobs:
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Check rusty_v8 MODULE.bazel checksums
if: matrix.os == 'ubuntu-24.04' && matrix.target == 'x86_64-unknown-linux-gnu'
shell: bash
run: |
python3 .github/scripts/rusty_v8_bazel.py check-module-bazel
python3 -m unittest discover -s .github/scripts -p test_rusty_v8_bazel.py
- name: Set up Bazel CI
id: setup_bazel
uses: ./.github/actions/setup-bazel-ci
@@ -65,7 +72,7 @@ jobs:
- name: Restore bazel repository cache
id: cache_bazel_repository_restore
continue-on-error: true
uses: actions/cache/restore@v5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
with:
path: ${{ steps.setup_bazel.outputs.repository-cache-path }}
key: bazel-cache-${{ matrix.target }}-${{ hashFiles('MODULE.bazel', 'codex-rs/Cargo.lock', 'codex-rs/Cargo.toml') }}
@@ -168,7 +175,7 @@ jobs:
- name: Restore bazel repository cache
id: cache_bazel_repository_restore
continue-on-error: true
uses: actions/cache/restore@v5
uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5
with:
path: ${{ steps.setup_bazel.outputs.repository-cache-path }}
key: bazel-cache-${{ matrix.target }}-${{ hashFiles('MODULE.bazel', 'codex-rs/Cargo.lock', 'codex-rs/Cargo.toml') }}
+12 -18
View File
@@ -43,6 +43,9 @@ jobs:
argument_comment_lint_package:
name: Argument comment lint package
runs-on: ubuntu-24.04
env:
CARGO_DYLINT_VERSION: 5.0.0
DYLINT_LINK_VERSION: 5.0.0
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0
@@ -59,10 +62,13 @@ jobs:
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
key: argument-comment-lint-${{ runner.os }}-${{ hashFiles('tools/argument-comment-lint/Cargo.lock', 'tools/argument-comment-lint/rust-toolchain', '.github/workflows/rust-ci.yml', '.github/workflows/rust-ci-full.yml') }}
key: argument-comment-lint-${{ runner.os }}-${{ env.CARGO_DYLINT_VERSION }}-${{ env.DYLINT_LINK_VERSION }}-${{ hashFiles('tools/argument-comment-lint/Cargo.lock', 'tools/argument-comment-lint/rust-toolchain', '.github/workflows/rust-ci.yml', '.github/workflows/rust-ci-full.yml') }}
- name: Install cargo-dylint tooling
if: ${{ steps.cargo_dylint_cache.outputs.cache-hit != 'true' }}
run: cargo install --locked cargo-dylint dylint-link
shell: bash
run: |
cargo install --locked cargo-dylint --version "$CARGO_DYLINT_VERSION"
cargo install --locked dylint-link --version "$DYLINT_LINK_VERSION"
- name: Check Python wrapper syntax
run: python3 -m py_compile tools/argument-comment-lint/wrapper_common.py tools/argument-comment-lint/run.py tools/argument-comment-lint/run-prebuilt-linter.py tools/argument-comment-lint/test_wrapper_common.py
- name: Test Python wrapper helpers
@@ -415,22 +421,10 @@ jobs:
echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV"
- if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl' }}
name: Configure musl rusty_v8 artifact overrides
env:
TARGET: ${{ matrix.target }}
shell: bash
run: |
set -euo pipefail
version="$(python3 "${GITHUB_WORKSPACE}/.github/scripts/rusty_v8_bazel.py" resolved-v8-crate-version)"
release_tag="rusty-v8-v${version}"
base_url="https://github.com/openai/codex/releases/download/${release_tag}"
archive="https://github.com/openai/codex/releases/download/rusty-v8-v${version}/librusty_v8_release_${TARGET}.a.gz"
binding_dir="${RUNNER_TEMP}/rusty_v8"
binding_path="${binding_dir}/src_binding_release_${TARGET}.rs"
mkdir -p "${binding_dir}"
curl -fsSL "${base_url}/src_binding_release_${TARGET}.rs" -o "${binding_path}"
echo "RUSTY_V8_ARCHIVE=${archive}" >> "$GITHUB_ENV"
echo "RUSTY_V8_SRC_BINDING_PATH=${binding_path}" >> "$GITHUB_ENV"
name: Configure musl rusty_v8 artifact overrides and verify checksums
uses: ./.github/actions/setup-rusty-v8-musl
with:
target: ${{ matrix.target }}
- name: Install cargo-chef
if: ${{ matrix.profile == 'release' }}
+8 -2
View File
@@ -90,6 +90,9 @@ jobs:
runs-on: ubuntu-24.04
needs: changed
if: ${{ needs.changed.outputs.argument_comment_lint_package == 'true' }}
env:
CARGO_DYLINT_VERSION: 5.0.0
DYLINT_LINK_VERSION: 5.0.0
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: dtolnay/rust-toolchain@a0b273b48ed29de4470960879e8381ff45632f26 # 1.93.0
@@ -113,10 +116,13 @@ jobs:
~/.cargo/registry/index
~/.cargo/registry/cache
~/.cargo/git/db
key: argument-comment-lint-${{ runner.os }}-${{ hashFiles('tools/argument-comment-lint/Cargo.lock', 'tools/argument-comment-lint/rust-toolchain', '.github/workflows/rust-ci.yml', '.github/workflows/rust-ci-full.yml') }}
key: argument-comment-lint-${{ runner.os }}-${{ env.CARGO_DYLINT_VERSION }}-${{ env.DYLINT_LINK_VERSION }}-${{ hashFiles('tools/argument-comment-lint/Cargo.lock', 'tools/argument-comment-lint/rust-toolchain', '.github/workflows/rust-ci.yml', '.github/workflows/rust-ci-full.yml') }}
- name: Install cargo-dylint tooling
if: ${{ steps.cargo_dylint_cache.outputs.cache-hit != 'true' }}
run: cargo install --locked cargo-dylint dylint-link
shell: bash
run: |
cargo install --locked cargo-dylint --version "$CARGO_DYLINT_VERSION"
cargo install --locked dylint-link --version "$DYLINT_LINK_VERSION"
- name: Check Python wrapper syntax
run: python3 -m py_compile tools/argument-comment-lint/wrapper_common.py tools/argument-comment-lint/run.py tools/argument-comment-lint/run-prebuilt-linter.py tools/argument-comment-lint/test_wrapper_common.py
- name: Test Python wrapper helpers
@@ -19,6 +19,9 @@ jobs:
name: Build - ${{ matrix.runner }} - ${{ matrix.target }}
runs-on: ${{ matrix.runs_on || matrix.runner }}
timeout-minutes: 60
env:
CARGO_DYLINT_VERSION: 5.0.0
DYLINT_LINK_VERSION: 5.0.0
strategy:
fail-fast: false
@@ -65,8 +68,8 @@ jobs:
shell: bash
run: |
install_root="${RUNNER_TEMP}/argument-comment-lint-tools"
cargo install --locked cargo-dylint --root "$install_root"
cargo install --locked dylint-link
cargo install --locked cargo-dylint --version "$CARGO_DYLINT_VERSION" --root "$install_root"
cargo install --locked dylint-link --version "$DYLINT_LINK_VERSION"
echo "INSTALL_ROOT=$install_root" >> "$GITHUB_ENV"
- name: Cargo build
+4 -16
View File
@@ -211,22 +211,10 @@ jobs:
echo "CXXFLAGS=${cxxflags}" >> "$GITHUB_ENV"
- if: ${{ matrix.target == 'x86_64-unknown-linux-musl' || matrix.target == 'aarch64-unknown-linux-musl' }}
name: Configure musl rusty_v8 artifact overrides
env:
TARGET: ${{ matrix.target }}
shell: bash
run: |
set -euo pipefail
version="$(python3 "${GITHUB_WORKSPACE}/.github/scripts/rusty_v8_bazel.py" resolved-v8-crate-version)"
release_tag="rusty-v8-v${version}"
base_url="https://github.com/openai/codex/releases/download/${release_tag}"
archive="https://github.com/openai/codex/releases/download/rusty-v8-v${version}/librusty_v8_release_${TARGET}.a.gz"
binding_dir="${RUNNER_TEMP}/rusty_v8"
binding_path="${binding_dir}/src_binding_release_${TARGET}.rs"
mkdir -p "${binding_dir}"
curl -fsSL "${base_url}/src_binding_release_${TARGET}.rs" -o "${binding_path}"
echo "RUSTY_V8_ARCHIVE=${archive}" >> "$GITHUB_ENV"
echo "RUSTY_V8_SRC_BINDING_PATH=${binding_path}" >> "$GITHUB_ENV"
name: Configure musl rusty_v8 artifact overrides and verify checksums
uses: ./.github/actions/setup-rusty-v8-musl
with:
target: ${{ matrix.target }}
- name: Cargo build
shell: bash
+1 -1
View File
@@ -78,7 +78,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up Bazel
uses: bazelbuild/setup-bazelisk@6ecf4fd8b7d1f9721785f1dd656a689acf9add47 # v3
uses: bazelbuild/setup-bazelisk@b39c379c82683a5f25d34f0d062761f62693e0b2 # v3
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
+1 -1
View File
@@ -75,7 +75,7 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up Bazel
uses: bazelbuild/setup-bazelisk@6ecf4fd8b7d1f9721785f1dd656a689acf9add47 # v3
uses: bazelbuild/setup-bazelisk@b39c379c82683a5f25d34f0d062761f62693e0b2 # v3
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
+10
View File
@@ -423,6 +423,7 @@ http_archive(
http_file(
name = "rusty_v8_146_4_0_aarch64_apple_darwin_archive",
downloaded_file_path = "librusty_v8_release_aarch64-apple-darwin.a.gz",
sha256 = "bfe2c9be32a56c28546f0f965825ee68fbf606405f310cc4e17b448a568cf98a",
urls = [
"https://github.com/denoland/rusty_v8/releases/download/v146.4.0/librusty_v8_release_aarch64-apple-darwin.a.gz",
],
@@ -431,6 +432,7 @@ http_file(
http_file(
name = "rusty_v8_146_4_0_aarch64_unknown_linux_gnu_archive",
downloaded_file_path = "librusty_v8_release_aarch64-unknown-linux-gnu.a.gz",
sha256 = "dbf165b07c81bdb054bc046b43d23e69fcf7bcc1a4c1b5b4776983a71062ecd8",
urls = [
"https://github.com/denoland/rusty_v8/releases/download/v146.4.0/librusty_v8_release_aarch64-unknown-linux-gnu.a.gz",
],
@@ -439,6 +441,7 @@ http_file(
http_file(
name = "rusty_v8_146_4_0_aarch64_pc_windows_msvc_archive",
downloaded_file_path = "rusty_v8_release_aarch64-pc-windows-msvc.lib.gz",
sha256 = "ed13363659c6d08583ac8fdc40493445c5767d8b94955a4d5d7bb8d5a81f6bf8",
urls = [
"https://github.com/denoland/rusty_v8/releases/download/v146.4.0/rusty_v8_release_aarch64-pc-windows-msvc.lib.gz",
],
@@ -447,6 +450,7 @@ http_file(
http_file(
name = "rusty_v8_146_4_0_x86_64_apple_darwin_archive",
downloaded_file_path = "librusty_v8_release_x86_64-apple-darwin.a.gz",
sha256 = "630cd240f1bbecdb071417dc18387ab81cf67c549c1c515a0b4fcf9eba647bb7",
urls = [
"https://github.com/denoland/rusty_v8/releases/download/v146.4.0/librusty_v8_release_x86_64-apple-darwin.a.gz",
],
@@ -455,6 +459,7 @@ http_file(
http_file(
name = "rusty_v8_146_4_0_x86_64_unknown_linux_gnu_archive",
downloaded_file_path = "librusty_v8_release_x86_64-unknown-linux-gnu.a.gz",
sha256 = "e64b4d99e4ae293a2e846244a89b80178ba10382c13fb591c1fa6968f5291153",
urls = [
"https://github.com/denoland/rusty_v8/releases/download/v146.4.0/librusty_v8_release_x86_64-unknown-linux-gnu.a.gz",
],
@@ -463,6 +468,7 @@ http_file(
http_file(
name = "rusty_v8_146_4_0_x86_64_pc_windows_msvc_archive",
downloaded_file_path = "rusty_v8_release_x86_64-pc-windows-msvc.lib.gz",
sha256 = "90a9a2346acd3685a355e98df85c24dbe406cb124367d16259a4b5d522621862",
urls = [
"https://github.com/denoland/rusty_v8/releases/download/v146.4.0/rusty_v8_release_x86_64-pc-windows-msvc.lib.gz",
],
@@ -471,6 +477,7 @@ http_file(
http_file(
name = "rusty_v8_146_4_0_aarch64_unknown_linux_musl_archive",
downloaded_file_path = "librusty_v8_release_aarch64-unknown-linux-musl.a.gz",
sha256 = "27a08ed26c34297bfd93e514692ccc44b85f8b15c6aa39cf34e784f84fb37e8e",
urls = [
"https://github.com/openai/codex/releases/download/rusty-v8-v146.4.0/librusty_v8_release_aarch64-unknown-linux-musl.a.gz",
],
@@ -479,6 +486,7 @@ http_file(
http_file(
name = "rusty_v8_146_4_0_aarch64_unknown_linux_musl_binding",
downloaded_file_path = "src_binding_release_aarch64-unknown-linux-musl.rs",
sha256 = "09f8900ced8297c229246c7a50b2e0ec23c54d0a554f369619cc29863f38dd1a",
urls = [
"https://github.com/openai/codex/releases/download/rusty-v8-v146.4.0/src_binding_release_aarch64-unknown-linux-musl.rs",
],
@@ -487,6 +495,7 @@ http_file(
http_file(
name = "rusty_v8_146_4_0_x86_64_unknown_linux_musl_archive",
downloaded_file_path = "librusty_v8_release_x86_64-unknown-linux-musl.a.gz",
sha256 = "20d8271ad712323d352c1383c36e3c4b755abc41ece35819c49c75ec7134d2f8",
urls = [
"https://github.com/openai/codex/releases/download/rusty-v8-v146.4.0/librusty_v8_release_x86_64-unknown-linux-musl.a.gz",
],
@@ -495,6 +504,7 @@ http_file(
http_file(
name = "rusty_v8_146_4_0_x86_64_unknown_linux_musl_binding",
downloaded_file_path = "src_binding_release_x86_64-unknown-linux-musl.rs",
sha256 = "09f8900ced8297c229246c7a50b2e0ec23c54d0a554f369619cc29863f38dd1a",
urls = [
"https://github.com/openai/codex/releases/download/rusty-v8-v146.4.0/src_binding_release_x86_64-unknown-linux-musl.rs",
],
+2 -2
View File
@@ -903,8 +903,8 @@
"git+https://github.com/juberti-oai/rust-sdks.git?rev=e2d1d1d230c6fc9df171ccb181423f957bb3c1f0#e2d1d1d230c6fc9df171ccb181423f957bb3c1f0_livekit-runtime": "{\"dependencies\":[{\"default_features\":true,\"features\":[],\"name\":\"async-io\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"async-std\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"async-task\",\"optional\":true},{\"name\":\"futures\",\"optional\":true},{\"default_features\":false,\"features\":[\"net\",\"rt\",\"rt-multi-thread\",\"time\"],\"name\":\"tokio\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"tokio-stream\",\"optional\":true}],\"features\":{\"async\":[\"dep:async-std\",\"dep:futures\",\"dep:async-io\"],\"default\":[\"tokio\"],\"dispatcher\":[\"dep:futures\",\"dep:async-io\",\"dep:async-std\",\"dep:async-task\"],\"tokio\":[\"dep:tokio\",\"dep:tokio-stream\"]},\"strip_prefix\":\"livekit-runtime\"}",
"git+https://github.com/juberti-oai/rust-sdks.git?rev=e2d1d1d230c6fc9df171ccb181423f957bb3c1f0#e2d1d1d230c6fc9df171ccb181423f957bb3c1f0_webrtc-sys": "{\"dependencies\":[{\"name\":\"cxx\"},{\"name\":\"log\"},{\"kind\":\"build\",\"name\":\"cc\"},{\"kind\":\"build\",\"name\":\"cxx-build\"},{\"kind\":\"build\",\"name\":\"glob\"},{\"kind\":\"build\",\"name\":\"pkg-config\"},{\"default_features\":true,\"features\":[],\"kind\":\"build\",\"name\":\"webrtc-sys-build\",\"optional\":false}],\"features\":{\"default\":[]},\"strip_prefix\":\"webrtc-sys\"}",
"git+https://github.com/juberti-oai/rust-sdks.git?rev=e2d1d1d230c6fc9df171ccb181423f957bb3c1f0#e2d1d1d230c6fc9df171ccb181423f957bb3c1f0_webrtc-sys-build": "{\"dependencies\":[{\"name\":\"anyhow\"},{\"name\":\"fs2\"},{\"name\":\"regex\"},{\"default_features\":false,\"features\":[\"rustls-tls-native-roots\",\"blocking\"],\"name\":\"reqwest\",\"optional\":false},{\"name\":\"scratch\"},{\"name\":\"semver\"},{\"name\":\"zip\"}],\"features\":{},\"strip_prefix\":\"webrtc-sys/build\"}",
"git+https://github.com/nornagon/crossterm?branch=nornagon%2Fcolor-query#87db8bfa6dc99427fd3b071681b07fc31c6ce995_crossterm": "{\"dependencies\":[{\"default_features\":true,\"features\":[],\"name\":\"bitflags\",\"optional\":false},{\"default_features\":false,\"features\":[],\"name\":\"futures-core\",\"optional\":true},{\"name\":\"parking_lot\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"filedescriptor\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":false,\"features\":[],\"name\":\"libc\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[\"os-poll\"],\"name\":\"mio\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":false,\"features\":[\"std\",\"stdio\",\"termios\"],\"name\":\"rustix\",\"optional\":false,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[],\"name\":\"signal-hook\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[\"support-v1_0\"],\"name\":\"signal-hook-mio\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[],\"name\":\"crossterm_winapi\",\"optional\":true,\"target\":\"cfg(windows)\"},{\"default_features\":true,\"features\":[\"winuser\",\"winerror\"],\"name\":\"winapi\",\"optional\":true,\"target\":\"cfg(windows)\"}],\"features\":{\"bracketed-paste\":[],\"default\":[\"bracketed-paste\",\"windows\",\"events\"],\"event-stream\":[\"dep:futures-core\",\"events\"],\"events\":[\"dep:mio\",\"dep:signal-hook\",\"dep:signal-hook-mio\"],\"serde\":[\"dep:serde\",\"bitflags/serde\"],\"use-dev-tty\":[\"filedescriptor\",\"rustix/process\"],\"windows\":[\"dep:winapi\",\"dep:crossterm_winapi\"]},\"strip_prefix\":\"\"}",
"git+https://github.com/nornagon/ratatui?branch=nornagon-v0.29.0-patch#9b2ad1298408c45918ee9f8241a6f95498cdbed2_ratatui": "{\"dependencies\":[{\"name\":\"bitflags\"},{\"name\":\"cassowary\"},{\"name\":\"compact_str\"},{\"default_features\":true,\"features\":[],\"name\":\"crossterm\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"document-features\",\"optional\":true},{\"name\":\"indoc\"},{\"name\":\"instability\"},{\"name\":\"itertools\"},{\"name\":\"lru\"},{\"default_features\":true,\"features\":[],\"name\":\"palette\",\"optional\":true},{\"name\":\"paste\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"strum\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"termwiz\",\"optional\":true},{\"default_features\":true,\"features\":[\"local-offset\"],\"name\":\"time\",\"optional\":true},{\"name\":\"unicode-segmentation\"},{\"name\":\"unicode-truncate\"},{\"name\":\"unicode-width\"},{\"default_features\":true,\"features\":[],\"name\":\"termion\",\"optional\":true,\"target\":\"cfg(not(windows))\"}],\"features\":{\"all-widgets\":[\"widget-calendar\"],\"crossterm\":[\"dep:crossterm\"],\"default\":[\"crossterm\",\"underline-color\"],\"macros\":[],\"palette\":[\"dep:palette\"],\"scrolling-regions\":[],\"serde\":[\"dep:serde\",\"bitflags/serde\",\"compact_str/serde\"],\"termion\":[\"dep:termion\"],\"termwiz\":[\"dep:termwiz\"],\"underline-color\":[\"dep:crossterm\"],\"unstable\":[\"unstable-rendered-line-info\",\"unstable-widget-ref\",\"unstable-backend-writer\"],\"unstable-backend-writer\":[],\"unstable-rendered-line-info\":[],\"unstable-widget-ref\":[],\"widget-calendar\":[\"dep:time\"]},\"strip_prefix\":\"\"}",
"git+https://github.com/nornagon/crossterm?rev=87db8bfa6dc99427fd3b071681b07fc31c6ce995#87db8bfa6dc99427fd3b071681b07fc31c6ce995_crossterm": "{\"dependencies\":[{\"default_features\":true,\"features\":[],\"name\":\"bitflags\",\"optional\":false},{\"default_features\":false,\"features\":[],\"name\":\"futures-core\",\"optional\":true},{\"name\":\"parking_lot\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"filedescriptor\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":false,\"features\":[],\"name\":\"libc\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[\"os-poll\"],\"name\":\"mio\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":false,\"features\":[\"std\",\"stdio\",\"termios\"],\"name\":\"rustix\",\"optional\":false,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[],\"name\":\"signal-hook\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[\"support-v1_0\"],\"name\":\"signal-hook-mio\",\"optional\":true,\"target\":\"cfg(unix)\"},{\"default_features\":true,\"features\":[],\"name\":\"crossterm_winapi\",\"optional\":true,\"target\":\"cfg(windows)\"},{\"default_features\":true,\"features\":[\"winuser\",\"winerror\"],\"name\":\"winapi\",\"optional\":true,\"target\":\"cfg(windows)\"}],\"features\":{\"bracketed-paste\":[],\"default\":[\"bracketed-paste\",\"windows\",\"events\"],\"event-stream\":[\"dep:futures-core\",\"events\"],\"events\":[\"dep:mio\",\"dep:signal-hook\",\"dep:signal-hook-mio\"],\"serde\":[\"dep:serde\",\"bitflags/serde\"],\"use-dev-tty\":[\"filedescriptor\",\"rustix/process\"],\"windows\":[\"dep:winapi\",\"dep:crossterm_winapi\"]},\"strip_prefix\":\"\"}",
"git+https://github.com/nornagon/ratatui?rev=9b2ad1298408c45918ee9f8241a6f95498cdbed2#9b2ad1298408c45918ee9f8241a6f95498cdbed2_ratatui": "{\"dependencies\":[{\"name\":\"bitflags\"},{\"name\":\"cassowary\"},{\"name\":\"compact_str\"},{\"default_features\":true,\"features\":[],\"name\":\"crossterm\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"document-features\",\"optional\":true},{\"name\":\"indoc\"},{\"name\":\"instability\"},{\"name\":\"itertools\"},{\"name\":\"lru\"},{\"default_features\":true,\"features\":[],\"name\":\"palette\",\"optional\":true},{\"name\":\"paste\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":true},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"strum\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"termwiz\",\"optional\":true},{\"default_features\":true,\"features\":[\"local-offset\"],\"name\":\"time\",\"optional\":true},{\"name\":\"unicode-segmentation\"},{\"name\":\"unicode-truncate\"},{\"name\":\"unicode-width\"},{\"default_features\":true,\"features\":[],\"name\":\"termion\",\"optional\":true,\"target\":\"cfg(not(windows))\"}],\"features\":{\"all-widgets\":[\"widget-calendar\"],\"crossterm\":[\"dep:crossterm\"],\"default\":[\"crossterm\",\"underline-color\"],\"macros\":[],\"palette\":[\"dep:palette\"],\"scrolling-regions\":[],\"serde\":[\"dep:serde\",\"bitflags/serde\",\"compact_str/serde\"],\"termion\":[\"dep:termion\"],\"termwiz\":[\"dep:termwiz\"],\"underline-color\":[\"dep:crossterm\"],\"unstable\":[\"unstable-rendered-line-info\",\"unstable-widget-ref\",\"unstable-backend-writer\"],\"unstable-backend-writer\":[],\"unstable-rendered-line-info\":[],\"unstable-widget-ref\":[],\"widget-calendar\":[\"dep:time\"]},\"strip_prefix\":\"\"}",
"git+https://github.com/openai-oss-forks/tokio-tungstenite?rev=132f5b39c862e3a970f731d709608b3e6276d5f6#132f5b39c862e3a970f731d709608b3e6276d5f6_tokio-tungstenite": "{\"dependencies\":[{\"default_features\":false,\"features\":[\"sink\",\"std\"],\"name\":\"futures-util\",\"optional\":false},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"default_features\":false,\"features\":[],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":false,\"features\":[\"io-util\"],\"name\":\"tokio\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"tokio-native-tls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tokio-rustls\",\"optional\":true},{\"default_features\":false,\"features\":[],\"name\":\"tungstenite\",\"optional\":false},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\",\"tokio-rustls\",\"stream\",\"tungstenite/__rustls-tls\",\"handshake\"],\"connect\":[\"stream\",\"tokio/net\",\"handshake\"],\"default\":[\"connect\",\"handshake\"],\"handshake\":[\"tungstenite/handshake\"],\"native-tls\":[\"native-tls-crate\",\"tokio-native-tls\",\"stream\",\"tungstenite/native-tls\",\"handshake\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\",\"tungstenite/native-tls-vendored\"],\"proxy\":[\"tungstenite/proxy\",\"tokio/net\",\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"stream\":[],\"url\":[\"tungstenite/url\"]},\"strip_prefix\":\"\"}",
"git+https://github.com/openai-oss-forks/tungstenite-rs?rev=9200079d3b54a1ff51072e24d81fd354f085156f#9200079d3b54a1ff51072e24d81fd354f085156f_tungstenite": "{\"dependencies\":[{\"name\":\"bytes\"},{\"default_features\":true,\"features\":[],\"name\":\"data-encoding\",\"optional\":true},{\"default_features\":false,\"features\":[\"zlib\"],\"name\":\"flate2\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"headers\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"http\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"httparse\",\"optional\":true},{\"name\":\"log\"},{\"default_features\":true,\"features\":[],\"name\":\"native-tls-crate\",\"optional\":true,\"package\":\"native-tls\"},{\"name\":\"rand\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"rustls\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-native-certs\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"rustls-pki-types\",\"optional\":true},{\"default_features\":true,\"features\":[],\"name\":\"sha1\",\"optional\":true},{\"name\":\"thiserror\"},{\"default_features\":true,\"features\":[],\"name\":\"url\",\"optional\":true},{\"name\":\"utf-8\"},{\"default_features\":true,\"features\":[],\"name\":\"webpki-roots\",\"optional\":true}],\"features\":{\"__rustls-tls\":[\"rustls\",\"rustls-pki-types\"],\"default\":[\"handshake\"],\"deflate\":[\"headers\",\"flate2\"],\"handshake\":[\"data-encoding\",\"headers\",\"httparse\",\"sha1\"],\"headers\":[\"http\",\"dep:headers\"],\"native-tls\":[\"native-tls-crate\"],\"native-tls-vendored\":[\"native-tls\",\"native-tls-crate/vendored\"],\"proxy\":[\"handshake\"],\"rustls-tls-native-roots\":[\"__rustls-tls\",\"rustls-native-certs\"],\"rustls-tls-webpki-roots\":[\"__rustls-tls\",\"webpki-roots\"],\"url\":[\"dep:url\"]},\"strip_prefix\":\"\"}",
"git+https://github.com/rust-lang/rust-clippy?rev=20ce69b9a63bcd2756cd906fe0964d1e901e042a#20ce69b9a63bcd2756cd906fe0964d1e901e042a_clippy_utils": "{\"dependencies\":[{\"default_features\":false,\"features\":[],\"name\":\"arrayvec\",\"optional\":false},{\"name\":\"itertools\"},{\"name\":\"rustc_apfloat\"},{\"default_features\":true,\"features\":[\"derive\"],\"name\":\"serde\",\"optional\":false}],\"features\":{},\"strip_prefix\":\"clippy_utils\"}",
+3
View File
@@ -1,6 +1,9 @@
[advisories]
# Reviewed 2026-04-11. Keep this list in sync with ../deny.toml.
ignore = [
"RUSTSEC-2024-0388", # derivative 2.2.0 via starlark; upstream crate is unmaintained
"RUSTSEC-2025-0057", # fxhash 0.2.1 via starlark_map; upstream crate is unmaintained
"RUSTSEC-2024-0436", # paste 1.0.15 via starlark/ratatui; upstream crate is unmaintained
"RUSTSEC-2024-0320", # yaml-rust via syntect; remove when syntect drops or updates it
"RUSTSEC-2025-0141", # bincode via syntect; remove when syntect drops or updates it
]
+18 -5
View File
@@ -1902,7 +1902,6 @@ dependencies = [
"codex-api",
"codex-app-server-protocol",
"codex-apply-patch",
"codex-arg0",
"codex-async-utils",
"codex-code-mode",
"codex-config",
@@ -1932,6 +1931,7 @@ dependencies = [
"codex-shell-escalation",
"codex-state",
"codex-terminal-detection",
"codex-test-binary-support",
"codex-tools",
"codex-utils-absolute-path",
"codex-utils-cache",
@@ -2097,16 +2097,19 @@ dependencies = [
"arc-swap",
"async-trait",
"base64 0.22.1",
"clap",
"codex-app-server-protocol",
"codex-config",
"codex-protocol",
"codex-sandboxing",
"codex-test-binary-support",
"codex-utils-absolute-path",
"codex-utils-cargo-bin",
"codex-utils-pty",
"ctor 0.6.3",
"futures",
"pretty_assertions",
"serde",
"serde_json",
"serial_test",
"tempfile",
"test-case",
"thiserror 2.0.18",
@@ -2349,6 +2352,7 @@ dependencies = [
"codex-plugin",
"codex-protocol",
"codex-rmcp-client",
"codex-utils-absolute-path",
"codex-utils-plugins",
"futures",
"pretty_assertions",
@@ -2810,6 +2814,14 @@ dependencies = [
"tracing",
]
[[package]]
name = "codex-test-binary-support"
version = "0.0.0"
dependencies = [
"codex-arg0",
"tempfile",
]
[[package]]
name = "codex-tools"
version = "0.0.0"
@@ -2987,6 +2999,7 @@ version = "0.0.0"
name = "codex-utils-home-dir"
version = "0.0.0"
dependencies = [
"codex-utils-absolute-path",
"dirs",
"pretty_assertions",
"tempfile",
@@ -3523,7 +3536,7 @@ checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crossterm"
version = "0.28.1"
source = "git+https://github.com/nornagon/crossterm?branch=nornagon%2Fcolor-query#87db8bfa6dc99427fd3b071681b07fc31c6ce995"
source = "git+https://github.com/nornagon/crossterm?rev=87db8bfa6dc99427fd3b071681b07fc31c6ce995#87db8bfa6dc99427fd3b071681b07fc31c6ce995"
dependencies = [
"bitflags 2.10.0",
"crossterm_winapi",
@@ -8450,7 +8463,7 @@ dependencies = [
[[package]]
name = "ratatui"
version = "0.29.0"
source = "git+https://github.com/nornagon/ratatui?branch=nornagon-v0.29.0-patch#9b2ad1298408c45918ee9f8241a6f95498cdbed2"
source = "git+https://github.com/nornagon/ratatui?rev=9b2ad1298408c45918ee9f8241a6f95498cdbed2#9b2ad1298408c45918ee9f8241a6f95498cdbed2"
dependencies = [
"bitflags 2.10.0",
"cassowary",
+4 -2
View File
@@ -86,6 +86,7 @@ members = [
"codex-api",
"state",
"terminal-detection",
"test-binary-support",
"codex-experimental-api-macros",
"plugin",
]
@@ -163,6 +164,7 @@ codex-skills = { path = "skills" }
codex-state = { path = "state" }
codex-stdio-to-uds = { path = "stdio-to-uds" }
codex-terminal-detection = { path = "terminal-detection" }
codex-test-binary-support = { path = "test-binary-support" }
codex-tools = { path = "tools" }
codex-tui = { path = "tui" }
codex-utils-absolute-path = { path = "utils/absolute-path" }
@@ -427,8 +429,8 @@ opt-level = 0
[patch.crates-io]
# Uncomment to debug local changes.
# ratatui = { path = "../../ratatui" }
crossterm = { git = "https://github.com/nornagon/crossterm", branch = "nornagon/color-query" }
ratatui = { git = "https://github.com/nornagon/ratatui", branch = "nornagon-v0.29.0-patch" }
crossterm = { git = "https://github.com/nornagon/crossterm", rev = "87db8bfa6dc99427fd3b071681b07fc31c6ce995" }
ratatui = { git = "https://github.com/nornagon/ratatui", rev = "9b2ad1298408c45918ee9f8241a6f95498cdbed2" }
tokio-tungstenite = { git = "https://github.com/openai-oss-forks/tokio-tungstenite", rev = "132f5b39c862e3a970f731d709608b3e6276d5f6" }
tungstenite = { git = "https://github.com/openai-oss-forks/tungstenite-rs", rev = "9200079d3b54a1ff51072e24d81fd354f085156f" }
@@ -105,6 +105,7 @@ fn sample_thread_start_response(thread_id: &str, ephemeral: bool, model: &str) -
model_provider: "openai".to_string(),
service_tier: None,
cwd: PathBuf::from("/tmp"),
instruction_sources: Vec::new(),
approval_policy: AppServerAskForApproval::OnFailure,
approvals_reviewer: AppServerApprovalsReviewer::User,
sandbox: AppServerSandboxPolicy::DangerFullAccess,
@@ -155,6 +156,7 @@ fn sample_thread_resume_response_with_source(
model_provider: "openai".to_string(),
service_tier: None,
cwd: PathBuf::from("/tmp"),
instruction_sources: Vec::new(),
approval_policy: AppServerAskForApproval::OnFailure,
approvals_reviewer: AppServerApprovalsReviewer::User,
sandbox: AppServerSandboxPolicy::DangerFullAccess,
+1
View File
@@ -44,6 +44,7 @@ use codex_core::config::Config;
use codex_core::config_loader::CloudRequirementsLoader;
use codex_core::config_loader::LoaderOverrides;
pub use codex_exec_server::EnvironmentManager;
pub use codex_exec_server::ExecServerRuntimePaths;
use codex_feedback::CodexFeedback;
use codex_protocol::protocol::SessionSource;
use serde::de::DeserializeOwned;
@@ -1233,6 +1233,32 @@
}
]
},
"MarketplaceAddParams": {
"properties": {
"refName": {
"type": [
"string",
"null"
]
},
"source": {
"type": "string"
},
"sparsePaths": {
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
}
},
"required": [
"source"
],
"type": "object"
},
"McpResourceReadParams": {
"properties": {
"server": {
@@ -2715,6 +2741,23 @@
],
"type": "object"
},
"ThreadInjectItemsParams": {
"properties": {
"items": {
"description": "Raw Responses API items to append to the thread's model-visible history.",
"items": true,
"type": "array"
},
"threadId": {
"type": "string"
}
},
"required": [
"items",
"threadId"
],
"type": "object"
},
"ThreadListParams": {
"properties": {
"archived": {
@@ -2809,6 +2852,13 @@
},
"type": "object"
},
"ThreadMemoryMode": {
"enum": [
"enabled",
"disabled"
],
"type": "string"
},
"ThreadMetadataGitInfoUpdateParams": {
"properties": {
"branch": {
@@ -3952,6 +4002,31 @@
"title": "Thread/readRequest",
"type": "object"
},
{
"description": "Append raw Responses API items to the thread history without starting a user turn.",
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"thread/inject_items"
],
"title": "Thread/injectItemsRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadInjectItemsParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/injectItemsRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -3976,6 +4051,30 @@
"title": "Skills/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"marketplace/add"
],
"title": "Marketplace/addRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/MarketplaceAddParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Marketplace/addRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -578,6 +578,31 @@
"title": "Thread/readRequest",
"type": "object"
},
{
"description": "Append raw Responses API items to the thread history without starting a user turn.",
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"thread/inject_items"
],
"title": "Thread/injectItemsRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/ThreadInjectItemsParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/injectItemsRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -602,6 +627,30 @@
"title": "Skills/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"marketplace/add"
],
"title": "Marketplace/addRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/MarketplaceAddParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Marketplace/addRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -7688,11 +7737,15 @@
"type": "integer"
},
"isDirectory": {
"description": "Whether the path currently resolves to a directory.",
"description": "Whether the path resolves to a directory.",
"type": "boolean"
},
"isFile": {
"description": "Whether the path currently resolves to a regular file.",
"description": "Whether the path resolves to a regular file.",
"type": "boolean"
},
"isSymlink": {
"description": "Whether the path itself is a symbolic link.",
"type": "boolean"
},
"modifiedAtMs": {
@@ -7705,6 +7758,7 @@
"createdAtMs",
"isDirectory",
"isFile",
"isSymlink",
"modifiedAtMs"
],
"title": "FsGetMetadataResponse",
@@ -9030,6 +9084,55 @@
"title": "LogoutAccountResponse",
"type": "object"
},
"MarketplaceAddParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"refName": {
"type": [
"string",
"null"
]
},
"source": {
"type": "string"
},
"sparsePaths": {
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
}
},
"required": [
"source"
],
"title": "MarketplaceAddParams",
"type": "object"
},
"MarketplaceAddResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"alreadyAdded": {
"type": "boolean"
},
"installedRoot": {
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"marketplaceName": {
"type": "string"
}
},
"required": [
"alreadyAdded",
"installedRoot",
"marketplaceName"
],
"title": "MarketplaceAddResponse",
"type": "object"
},
"MarketplaceInterface": {
"properties": {
"displayName": {
@@ -12086,7 +12189,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"scope": {
"$ref": "#/definitions/v2/SkillScope"
@@ -12139,7 +12242,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/v2/AbsolutePathBuf"
},
"shortDescription": {
"type": [
@@ -12806,6 +12909,14 @@
"cwd": {
"type": "string"
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"items": {
"type": "string"
},
"type": "array"
},
"model": {
"type": "string"
},
@@ -12854,6 +12965,30 @@
"ThreadId": {
"type": "string"
},
"ThreadInjectItemsParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"items": {
"description": "Raw Responses API items to append to the thread's model-visible history.",
"items": true,
"type": "array"
},
"threadId": {
"type": "string"
}
},
"required": [
"items",
"threadId"
],
"title": "ThreadInjectItemsParams",
"type": "object"
},
"ThreadInjectItemsResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ThreadInjectItemsResponse",
"type": "object"
},
"ThreadItem": {
"oneOf": [
{
@@ -13639,6 +13774,13 @@
"title": "ThreadLoadedListResponse",
"type": "object"
},
"ThreadMemoryMode": {
"enum": [
"enabled",
"disabled"
],
"type": "string"
},
"ThreadMetadataGitInfoUpdateParams": {
"properties": {
"branch": {
@@ -14095,6 +14237,14 @@
"cwd": {
"type": "string"
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"items": {
"type": "string"
},
"type": "array"
},
"model": {
"type": "string"
},
@@ -14386,6 +14536,14 @@
"cwd": {
"type": "string"
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"items": {
"type": "string"
},
"type": "array"
},
"model": {
"type": "string"
},
@@ -1160,6 +1160,31 @@
"title": "Thread/readRequest",
"type": "object"
},
{
"description": "Append raw Responses API items to the thread history without starting a user turn.",
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"thread/inject_items"
],
"title": "Thread/injectItemsRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ThreadInjectItemsParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Thread/injectItemsRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -1184,6 +1209,30 @@
"title": "Skills/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"marketplace/add"
],
"title": "Marketplace/addRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/MarketplaceAddParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Marketplace/addRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -4329,11 +4378,15 @@
"type": "integer"
},
"isDirectory": {
"description": "Whether the path currently resolves to a directory.",
"description": "Whether the path resolves to a directory.",
"type": "boolean"
},
"isFile": {
"description": "Whether the path currently resolves to a regular file.",
"description": "Whether the path resolves to a regular file.",
"type": "boolean"
},
"isSymlink": {
"description": "Whether the path itself is a symbolic link.",
"type": "boolean"
},
"modifiedAtMs": {
@@ -4346,6 +4399,7 @@
"createdAtMs",
"isDirectory",
"isFile",
"isSymlink",
"modifiedAtMs"
],
"title": "FsGetMetadataResponse",
@@ -5826,6 +5880,55 @@
"title": "LogoutAccountResponse",
"type": "object"
},
"MarketplaceAddParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"refName": {
"type": [
"string",
"null"
]
},
"source": {
"type": "string"
},
"sparsePaths": {
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
}
},
"required": [
"source"
],
"title": "MarketplaceAddParams",
"type": "object"
},
"MarketplaceAddResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"alreadyAdded": {
"type": "boolean"
},
"installedRoot": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"marketplaceName": {
"type": "string"
}
},
"required": [
"alreadyAdded",
"installedRoot",
"marketplaceName"
],
"title": "MarketplaceAddResponse",
"type": "object"
},
"MarketplaceInterface": {
"properties": {
"displayName": {
@@ -9934,7 +10037,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"scope": {
"$ref": "#/definitions/SkillScope"
@@ -9987,7 +10090,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"shortDescription": {
"type": [
@@ -10654,6 +10757,14 @@
"cwd": {
"type": "string"
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"items": {
"type": "string"
},
"type": "array"
},
"model": {
"type": "string"
},
@@ -10702,6 +10813,30 @@
"ThreadId": {
"type": "string"
},
"ThreadInjectItemsParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"items": {
"description": "Raw Responses API items to append to the thread's model-visible history.",
"items": true,
"type": "array"
},
"threadId": {
"type": "string"
}
},
"required": [
"items",
"threadId"
],
"title": "ThreadInjectItemsParams",
"type": "object"
},
"ThreadInjectItemsResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ThreadInjectItemsResponse",
"type": "object"
},
"ThreadItem": {
"oneOf": [
{
@@ -11487,6 +11622,13 @@
"title": "ThreadLoadedListResponse",
"type": "object"
},
"ThreadMemoryMode": {
"enum": [
"enabled",
"disabled"
],
"type": "string"
},
"ThreadMetadataGitInfoUpdateParams": {
"properties": {
"branch": {
@@ -11943,6 +12085,14 @@
"cwd": {
"type": "string"
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"items": {
"type": "string"
},
"type": "array"
},
"model": {
"type": "string"
},
@@ -12234,6 +12384,14 @@
"cwd": {
"type": "string"
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"items": {
"type": "string"
},
"type": "array"
},
"model": {
"type": "string"
},
@@ -8,11 +8,15 @@
"type": "integer"
},
"isDirectory": {
"description": "Whether the path currently resolves to a directory.",
"description": "Whether the path resolves to a directory.",
"type": "boolean"
},
"isFile": {
"description": "Whether the path currently resolves to a regular file.",
"description": "Whether the path resolves to a regular file.",
"type": "boolean"
},
"isSymlink": {
"description": "Whether the path itself is a symbolic link.",
"type": "boolean"
},
"modifiedAtMs": {
@@ -25,6 +29,7 @@
"createdAtMs",
"isDirectory",
"isFile",
"isSymlink",
"modifiedAtMs"
],
"title": "FsGetMetadataResponse",
@@ -0,0 +1,28 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"refName": {
"type": [
"string",
"null"
]
},
"source": {
"type": "string"
},
"sparsePaths": {
"items": {
"type": "string"
},
"type": [
"array",
"null"
]
}
},
"required": [
"source"
],
"title": "MarketplaceAddParams",
"type": "object"
}
@@ -0,0 +1,27 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
}
},
"properties": {
"alreadyAdded": {
"type": "boolean"
},
"installedRoot": {
"$ref": "#/definitions/AbsolutePathBuf"
},
"marketplaceName": {
"type": "string"
}
},
"required": [
"alreadyAdded",
"installedRoot",
"marketplaceName"
],
"title": "MarketplaceAddResponse",
"type": "object"
}
@@ -335,7 +335,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"shortDescription": {
"type": [
@@ -1,6 +1,10 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"AbsolutePathBuf": {
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
"type": "string"
},
"SkillDependencies": {
"properties": {
"tools": {
@@ -103,7 +107,7 @@
"type": "string"
},
"path": {
"type": "string"
"$ref": "#/definitions/AbsolutePathBuf"
},
"scope": {
"$ref": "#/definitions/SkillScope"
@@ -2187,6 +2187,14 @@
"cwd": {
"type": "string"
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"items": {
"type": "string"
},
"type": "array"
},
"model": {
"type": "string"
},
@@ -0,0 +1,19 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"items": {
"description": "Raw Responses API items to append to the thread's model-visible history.",
"items": true,
"type": "array"
},
"threadId": {
"type": "string"
}
},
"required": [
"items",
"threadId"
],
"title": "ThreadInjectItemsParams",
"type": "object"
}
@@ -0,0 +1,5 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ThreadInjectItemsResponse",
"type": "object"
}
@@ -2187,6 +2187,14 @@
"cwd": {
"type": "string"
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"items": {
"type": "string"
},
"type": "array"
},
"model": {
"type": "string"
},
@@ -2187,6 +2187,14 @@
"cwd": {
"type": "string"
},
"instructionSources": {
"default": [],
"description": "Instruction source files currently loaded for this thread.",
"items": {
"type": "string"
},
"type": "array"
},
"model": {
"type": "string"
},
File diff suppressed because one or more lines are too long
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ThreadMemoryMode = "enabled" | "disabled";
@@ -68,6 +68,7 @@ export type { SessionSource } from "./SessionSource";
export type { Settings } from "./Settings";
export type { SubAgentSource } from "./SubAgentSource";
export type { ThreadId } from "./ThreadId";
export type { ThreadMemoryMode } from "./ThreadMemoryMode";
export type { Tool } from "./Tool";
export type { Verbosity } from "./Verbosity";
export type { WebSearchAction } from "./WebSearchAction";
@@ -7,13 +7,17 @@
*/
export type FsGetMetadataResponse = {
/**
* Whether the path currently resolves to a directory.
* Whether the path resolves to a directory.
*/
isDirectory: boolean,
/**
* Whether the path currently resolves to a regular file.
* Whether the path resolves to a regular file.
*/
isFile: boolean,
/**
* Whether the path itself is a symbolic link.
*/
isSymlink: boolean,
/**
* File creation time in Unix milliseconds when available, otherwise `0`.
*/
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type MarketplaceAddParams = { source: string, refName?: string | null, sparsePaths?: Array<string> | null, };
@@ -0,0 +1,6 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
export type MarketplaceAddResponse = { marketplaceName: string, installedRoot: AbsolutePathBuf, alreadyAdded: boolean, };
@@ -1,6 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { SkillDependencies } from "./SkillDependencies";
import type { SkillInterface } from "./SkillInterface";
import type { SkillScope } from "./SkillScope";
@@ -9,4 +10,4 @@ export type SkillMetadata = { name: string, description: string,
/**
* Legacy short_description from SKILL.md. Prefer SKILL.json interface.short_description.
*/
shortDescription?: string, interface?: SkillInterface, dependencies?: SkillDependencies, path: string, scope: SkillScope, enabled: boolean, };
shortDescription?: string, interface?: SkillInterface, dependencies?: SkillDependencies, path: AbsolutePathBuf, scope: SkillScope, enabled: boolean, };
@@ -1,6 +1,7 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
import type { SkillInterface } from "./SkillInterface";
export type SkillSummary = { name: string, description: string, shortDescription: string | null, interface: SkillInterface | null, path: string, enabled: boolean, };
export type SkillSummary = { name: string, description: string, shortDescription: string | null, interface: SkillInterface | null, path: AbsolutePathBuf, enabled: boolean, };
@@ -8,7 +8,11 @@ import type { AskForApproval } from "./AskForApproval";
import type { SandboxPolicy } from "./SandboxPolicy";
import type { Thread } from "./Thread";
export type ThreadForkResponse = { thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: string, approvalPolicy: AskForApproval,
export type ThreadForkResponse = { thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: string,
/**
* Instruction source files currently loaded for this thread.
*/
instructionSources: Array<string>, approvalPolicy: AskForApproval,
/**
* Reviewer currently used for approval requests on this thread.
*/
@@ -0,0 +1,10 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { JsonValue } from "../serde_json/JsonValue";
export type ThreadInjectItemsParams = { threadId: string,
/**
* Raw Responses API items to append to the thread's model-visible history.
*/
items: Array<JsonValue>, };
@@ -0,0 +1,5 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type ThreadInjectItemsResponse = Record<string, never>;
@@ -8,7 +8,11 @@ import type { AskForApproval } from "./AskForApproval";
import type { SandboxPolicy } from "./SandboxPolicy";
import type { Thread } from "./Thread";
export type ThreadResumeResponse = { thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: string, approvalPolicy: AskForApproval,
export type ThreadResumeResponse = { thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: string,
/**
* Instruction source files currently loaded for this thread.
*/
instructionSources: Array<string>, approvalPolicy: AskForApproval,
/**
* Reviewer currently used for approval requests on this thread.
*/
@@ -8,7 +8,11 @@ import type { AskForApproval } from "./AskForApproval";
import type { SandboxPolicy } from "./SandboxPolicy";
import type { Thread } from "./Thread";
export type ThreadStartResponse = { thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: string, approvalPolicy: AskForApproval,
export type ThreadStartResponse = { thread: Thread, model: string, modelProvider: string, serviceTier: ServiceTier | null, cwd: string,
/**
* Instruction source files currently loaded for this thread.
*/
instructionSources: Array<string>, approvalPolicy: AskForApproval,
/**
* Reviewer currently used for approval requests on this thread.
*/
@@ -149,6 +149,8 @@ export type { ListMcpServerStatusResponse } from "./ListMcpServerStatusResponse"
export type { LoginAccountParams } from "./LoginAccountParams";
export type { LoginAccountResponse } from "./LoginAccountResponse";
export type { LogoutAccountResponse } from "./LogoutAccountResponse";
export type { MarketplaceAddParams } from "./MarketplaceAddParams";
export type { MarketplaceAddResponse } from "./MarketplaceAddResponse";
export type { MarketplaceInterface } from "./MarketplaceInterface";
export type { MarketplaceLoadErrorInfo } from "./MarketplaceLoadErrorInfo";
export type { McpAuthStatus } from "./McpAuthStatus";
@@ -282,6 +284,8 @@ export type { ThreadCompactStartParams } from "./ThreadCompactStartParams";
export type { ThreadCompactStartResponse } from "./ThreadCompactStartResponse";
export type { ThreadForkParams } from "./ThreadForkParams";
export type { ThreadForkResponse } from "./ThreadForkResponse";
export type { ThreadInjectItemsParams } from "./ThreadInjectItemsParams";
export type { ThreadInjectItemsResponse } from "./ThreadInjectItemsResponse";
export type { ThreadItem } from "./ThreadItem";
export type { ThreadListParams } from "./ThreadListParams";
export type { ThreadListResponse } from "./ThreadListResponse";
@@ -284,6 +284,11 @@ client_request_definitions! {
params: v2::ThreadMetadataUpdateParams,
response: v2::ThreadMetadataUpdateResponse,
},
#[experimental("thread/memoryMode/set")]
ThreadMemoryModeSet => "thread/memoryMode/set" {
params: v2::ThreadMemoryModeSetParams,
response: v2::ThreadMemoryModeSetResponse,
},
ThreadUnarchive => "thread/unarchive" {
params: v2::ThreadUnarchiveParams,
response: v2::ThreadUnarchiveResponse,
@@ -317,10 +322,19 @@ client_request_definitions! {
params: v2::ThreadReadParams,
response: v2::ThreadReadResponse,
},
/// Append raw Responses API items to the thread history without starting a user turn.
ThreadInjectItems => "thread/inject_items" {
params: v2::ThreadInjectItemsParams,
response: v2::ThreadInjectItemsResponse,
},
SkillsList => "skills/list" {
params: v2::SkillsListParams,
response: v2::SkillsListResponse,
},
MarketplaceAdd => "marketplace/add" {
params: v2::MarketplaceAddParams,
response: v2::MarketplaceAddResponse,
},
PluginList => "plugin/list" {
params: v2::PluginListParams,
response: v2::PluginListResponse,
@@ -1405,6 +1419,7 @@ mod tests {
model_provider: "openai".to_string(),
service_tier: None,
cwd: PathBuf::from("/tmp"),
instruction_sources: vec![PathBuf::from("/tmp/AGENTS.md")],
approval_policy: v2::AskForApproval::OnFailure,
approvals_reviewer: v2::ApprovalsReviewer::User,
sandbox: v2::SandboxPolicy::DangerFullAccess,
@@ -1444,6 +1459,7 @@ mod tests {
"modelProvider": "openai",
"serviceTier": null,
"cwd": "/tmp",
"instructionSources": ["/tmp/AGENTS.md"],
"approvalPolicy": "on-failure",
"approvalsReviewer": "user",
"sandbox": {
+163 -4
View File
@@ -2320,10 +2320,12 @@ pub struct FsGetMetadataParams {
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct FsGetMetadataResponse {
/// Whether the path currently resolves to a directory.
/// Whether the path resolves to a directory.
pub is_directory: bool,
/// Whether the path currently resolves to a regular file.
/// Whether the path resolves to a regular file.
pub is_file: bool,
/// Whether the path itself is a symbolic link.
pub is_symlink: bool,
/// File creation time in Unix milliseconds when available, otherwise `0`.
#[ts(type = "number")]
pub created_at_ms: i64,
@@ -2719,6 +2721,9 @@ pub struct ThreadStartResponse {
pub model_provider: String,
pub service_tier: Option<ServiceTier>,
pub cwd: PathBuf,
/// Instruction source files currently loaded for this thread.
#[serde(default)]
pub instruction_sources: Vec<PathBuf>,
#[experimental(nested)]
pub approval_policy: AskForApproval,
/// Reviewer currently used for approval requests on this thread.
@@ -2805,6 +2810,9 @@ pub struct ThreadResumeResponse {
pub model_provider: String,
pub service_tier: Option<ServiceTier>,
pub cwd: PathBuf,
/// Instruction source files currently loaded for this thread.
#[serde(default)]
pub instruction_sources: Vec<PathBuf>,
#[experimental(nested)]
pub approval_policy: AskForApproval,
/// Reviewer currently used for approval requests on this thread.
@@ -2882,6 +2890,9 @@ pub struct ThreadForkResponse {
pub model_provider: String,
pub service_tier: Option<ServiceTier>,
pub cwd: PathBuf,
/// Instruction source files currently loaded for this thread.
#[serde(default)]
pub instruction_sources: Vec<PathBuf>,
#[experimental(nested)]
pub approval_policy: AskForApproval,
/// Reviewer currently used for approval requests on this thread.
@@ -3040,6 +3051,43 @@ pub struct ThreadMetadataUpdateResponse {
pub thread: Thread,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "lowercase")]
#[ts(rename_all = "lowercase")]
pub enum ThreadMemoryMode {
Enabled,
Disabled,
}
impl ThreadMemoryMode {
pub fn as_str(self) -> &'static str {
match self {
Self::Enabled => "enabled",
Self::Disabled => "disabled",
}
}
pub fn to_core(self) -> codex_protocol::protocol::ThreadMemoryMode {
match self {
Self::Enabled => codex_protocol::protocol::ThreadMemoryMode::Enabled,
Self::Disabled => codex_protocol::protocol::ThreadMemoryMode::Disabled,
}
}
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadMemoryModeSetParams {
pub thread_id: String,
pub mode: ThreadMemoryMode,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadMemoryModeSetResponse {}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -3278,6 +3326,26 @@ pub struct SkillsListResponse {
pub data: Vec<SkillsListEntry>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct MarketplaceAddParams {
pub source: String,
#[ts(optional = nullable)]
pub ref_name: Option<String>,
#[ts(optional = nullable)]
pub sparse_paths: Option<Vec<String>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct MarketplaceAddResponse {
pub marketplace_name: String,
pub installed_root: AbsolutePathBuf,
pub already_added: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -3354,7 +3422,7 @@ pub struct SkillMetadata {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub dependencies: Option<SkillDependencies>,
pub path: PathBuf,
pub path: AbsolutePathBuf,
pub scope: SkillScope,
pub enabled: bool,
}
@@ -3500,7 +3568,7 @@ pub struct SkillSummary {
pub description: String,
pub short_description: Option<String>,
pub interface: Option<SkillInterface>,
pub path: PathBuf,
pub path: AbsolutePathBuf,
pub enabled: bool,
}
@@ -4205,6 +4273,20 @@ pub struct TurnStartResponse {
pub turn: Turn,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadInjectItemsParams {
pub thread_id: String,
/// Raw Responses API items to append to the thread's model-visible history.
pub items: Vec<JsonValue>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadInjectItemsResponse {}
#[derive(
Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS, ExperimentalApi,
)]
@@ -6705,6 +6787,7 @@ mod tests {
let response = FsGetMetadataResponse {
is_directory: false,
is_file: true,
is_symlink: false,
created_at_ms: 123,
modified_at_ms: 456,
};
@@ -6715,6 +6798,7 @@ mod tests {
json!({
"isDirectory": false,
"isFile": true,
"isSymlink": false,
"createdAtMs": 123,
"modifiedAtMs": 456,
})
@@ -8288,6 +8372,37 @@ mod tests {
);
}
#[test]
fn marketplace_add_params_serialization_uses_optional_ref_name_and_sparse_paths() {
assert_eq!(
serde_json::to_value(MarketplaceAddParams {
source: "owner/repo".to_string(),
ref_name: None,
sparse_paths: None,
})
.unwrap(),
json!({
"source": "owner/repo",
"refName": null,
"sparsePaths": null,
}),
);
assert_eq!(
serde_json::to_value(MarketplaceAddParams {
source: "owner/repo".to_string(),
ref_name: Some("main".to_string()),
sparse_paths: Some(vec!["plugins/foo".to_string()]),
})
.unwrap(),
json!({
"source": "owner/repo",
"refName": "main",
"sparsePaths": ["plugins/foo"],
}),
);
}
#[test]
fn plugin_install_params_serialization_uses_force_remote_sync() {
let marketplace_path = if cfg!(windows) {
@@ -8506,6 +8621,50 @@ mod tests {
assert_eq!(serialized_without_override.get("serviceTier"), None);
}
#[test]
fn thread_lifecycle_responses_default_missing_instruction_sources() {
let response = json!({
"thread": {
"id": "thread-id",
"forkedFromId": null,
"preview": "",
"ephemeral": false,
"modelProvider": "openai",
"createdAt": 1,
"updatedAt": 1,
"status": { "type": "idle" },
"path": null,
"cwd": "/tmp",
"cliVersion": "0.0.0",
"source": "exec",
"agentNickname": null,
"agentRole": null,
"gitInfo": null,
"name": null,
"turns": []
},
"model": "gpt-5",
"modelProvider": "openai",
"serviceTier": null,
"cwd": "/tmp",
"approvalPolicy": "on-failure",
"approvalsReviewer": "user",
"sandbox": { "type": "dangerFullAccess" },
"reasoningEffort": null
});
let start: ThreadStartResponse =
serde_json::from_value(response.clone()).expect("thread/start response");
let resume: ThreadResumeResponse =
serde_json::from_value(response.clone()).expect("thread/resume response");
let fork: ThreadForkResponse =
serde_json::from_value(response).expect("thread/fork response");
assert_eq!(start.instruction_sources, Vec::<PathBuf>::new());
assert_eq!(resume.instruction_sources, Vec::<PathBuf>::new());
assert_eq!(fork.instruction_sources, Vec::<PathBuf>::new());
}
#[test]
fn turn_start_params_preserve_explicit_null_service_tier() {
let params: TurnStartParams = serde_json::from_value(json!({
+41 -4
View File
@@ -140,9 +140,10 @@ Example with notification opt-out:
- `thread/loaded/list` — list the thread ids currently loaded in memory.
- `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded.
- `thread/metadata/update` — patch stored thread metadata in sqlite; currently supports updating persisted `gitInfo` fields and returns the refreshed `thread`.
- `thread/memoryMode/set` — experimental; set a thread’s persisted memory eligibility to `"enabled"` or `"disabled"` for either a loaded thread or a stored rollout; returns `{}` on success.
- `thread/status/changed` — notification emitted when a loaded thread’s status changes (`threadId` + new `status`).
- `thread/archive` — move a thread’s rollout file into the archived directory; returns `{}` on success and emits `thread/archived`.
- `thread/unsubscribe` — unsubscribe this connection from thread turn/item events. If this was the last subscriber, the server shuts down and unloads the thread, then emits `thread/closed`.
- `thread/unsubscribe` — unsubscribe this connection from thread turn/item events. If this was the last subscriber, the server keeps the thread loaded and unloads it only after it has had no subscribers and no thread activity for 30 minutes, then emits `thread/closed`.
- `thread/name/set` — set or update a thread’s user-facing name for either a loaded thread or a persisted rollout; returns `{}` on success and emits `thread/name/updated` to initialized, opted-in clients. Thread names are not required to be unique; name lookups resolve to the most recently updated thread.
- `thread/unarchive` — move an archived rollout file back into the sessions directory; returns the restored `thread` on success and emits `thread/unarchived`.
- `thread/compact/start` — trigger conversation history compaction for a thread; returns `{}` immediately while progress streams through standard turn/item notifications.
@@ -150,6 +151,7 @@ Example with notification opt-out:
- `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted.
- `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success.
- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode".
- `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a user turn; returns `{}` on success.
- `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. Review and manual compaction turns reject `turn/steer`.
- `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`.
- `thread/realtime/start` — start a thread-scoped realtime session (experimental); returns `{}` and streams `thread/realtime/*` notifications. Omit `transport` for the websocket transport, or pass `{ "type": "webrtc", "sdp": "..." }` to create a WebRTC session from a browser-generated SDP offer; the remote answer SDP is emitted as `thread/realtime/sdp`.
@@ -165,7 +167,7 @@ Example with notification opt-out:
- `fs/readFile` — read an absolute file path and return `{ dataBase64 }`.
- `fs/writeFile` — write an absolute file path from base64-encoded `{ dataBase64 }`; returns `{}`.
- `fs/createDirectory` — create an absolute directory path; `recursive` defaults to `true`.
- `fs/getMetadata` — return metadata for an absolute path: `isDirectory`, `isFile`, `createdAtMs`, and `modifiedAtMs`.
- `fs/getMetadata` — return metadata for an absolute path: `isDirectory`, `isFile`, `isSymlink`, `createdAtMs`, and `modifiedAtMs`.
- `fs/readDirectory` — list direct child entries for an absolute directory path; each entry contains `fileName`, `isDirectory`, and `isFile`, and `fileName` is just the child name, not a path.
- `fs/remove` — remove an absolute file or directory tree; `recursive` and `force` default to `true`.
- `fs/copy` — copy between absolute paths; directory copies require `recursive: true`.
@@ -177,6 +179,7 @@ Example with notification opt-out:
- `experimentalFeature/enablement/set` — patch the in-memory process-wide runtime feature enablement for the currently supported feature keys (`apps`, `plugins`). For each feature, precedence is: cloud requirements > --enable <feature_name> > config.toml > experimentalFeature/enablement/set (new) > code default.
- `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`).
- `marketplace/add` — add a remote plugin marketplace from an HTTP(S) Git URL, SSH Git URL, or GitHub `owner/repo` shorthand, then persist it into the user marketplace config. Returns the installed root path plus whether the marketplace was already present.
- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. `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/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/apps/MCP server names. Returned plugin skills include their current `enabled` state after local config filtering. Plugin app summaries also include `needsAuth` when the server can determine connector accessibility (**under development; do not call from production clients yet**).
- `skills/changed` — notification emitted when watched local skill files change.
@@ -337,11 +340,16 @@ When `nextCursor` is `null`, you’ve reached the final page.
- `notSubscribed` when the connection was not subscribed to that thread.
- `notLoaded` when the thread is not loaded.
If this was the last subscriber, the server unloads the thread and emits `thread/closed` and a `thread/status/changed` transition to `notLoaded`.
If this was the last subscriber, the server does not unload the thread immediately. It unloads the thread after the thread has had no subscribers and no thread activity for 30 minutes, then emits `thread/closed` and a `thread/status/changed` transition to `notLoaded`.
```json
{ "method": "thread/unsubscribe", "id": 22, "params": { "threadId": "thr_123" } }
{ "id": 22, "result": { "status": "unsubscribed" } }
```
Later, after the idle unload timeout:
```json
{ "method": "thread/status/changed", "params": {
"threadId": "thr_123",
"status": { "type": "notLoaded" }
@@ -395,6 +403,16 @@ Use `thread/metadata/update` to patch sqlite-backed metadata for a thread withou
} }
```
Experimental: use `thread/memoryMode/set` to change whether a thread remains eligible for future memory generation.
```json
{ "method": "thread/memoryMode/set", "id": 26, "params": {
"threadId": "thr_123",
"mode": "disabled"
} }
{ "id": 26, "result": {} }
```
### Example: Archive a thread
Use `thread/archive` to move the persisted rollout (stored as a JSONL file on disk) into the archived sessions directory.
@@ -565,6 +583,24 @@ Invoke a plugin by including a UI mention token such as `@sample` in the text in
} } }
```
### Example: Inject raw history items
Use `thread/inject_items` to append prebuilt Responses API items to a loaded thread’s prompt history without starting a user turn. These items are persisted to the rollout and included in subsequent model requests.
```json
{ "method": "thread/inject_items", "id": 36, "params": {
"threadId": "thr_123",
"items": [
{
"type": "message",
"role": "assistant",
"content": [{ "type": "output_text", "text": "Previously computed context." }]
}
]
} }
{ "id": 36, "result": {} }
```
### Example: Start realtime with WebRTC
Use `thread/realtime/start` with `transport.type: "webrtc"` when a browser or webview owns the `RTCPeerConnection` and app-server should create the server-side realtime session. The transport `sdp` must be the offer SDP produced by `RTCPeerConnection.createOffer()`, not a hand-written or minimal SDP string.
@@ -843,6 +879,7 @@ All filesystem paths in this section must be absolute.
{ "id": 42, "result": {
"isDirectory": false,
"isFile": true,
"isSymlink": false,
"createdAtMs": 1730910000000,
"modifiedAtMs": 1730910000000
} }
@@ -854,7 +891,7 @@ All filesystem paths in this section must be absolute.
} }
```
- `fs/getMetadata` returns whether the path currently resolves to a directory or regular file, plus `createdAtMs` and `modifiedAtMs` in Unix milliseconds. If a timestamp is unavailable on the current platform, that field is `0`.
- `fs/getMetadata` returns whether the path resolves to a directory or regular file, whether the path itself is a symlink, plus `createdAtMs` and `modifiedAtMs` in Unix milliseconds. If a timestamp is unavailable on the current platform, that field is `0`.
- `fs/createDirectory` defaults `recursive` to `true` when omitted.
- `fs/remove` defaults both `recursive` and `force` to `true` when omitted.
- `fs/readFile` always returns base64 bytes via `dataBase64`, and `fs/writeFile` always expects base64 bytes in `dataBase64`.
@@ -1303,6 +1303,11 @@ pub(crate) async fn apply_bespoke_event_handling(
.await;
}
EventMsg::ContextCompacted(..) => {
// Core still fans out this deprecated event for legacy clients;
// v2 clients receive the canonical ContextCompaction item instead.
if matches!(api_version, ApiVersion::V2) {
return;
}
let notification = ContextCompactedNotification {
thread_id: conversation_id.to_string(),
turn_id: event_turn_id.clone(),
@@ -1599,6 +1604,17 @@ pub(crate) async fn apply_bespoke_event_handling(
.await;
}
EventMsg::ExecCommandBegin(exec_command_begin_event) => {
if matches!(api_version, ApiVersion::V2)
&& matches!(
exec_command_begin_event.source,
codex_protocol::protocol::ExecCommandSource::UnifiedExecInteraction
)
{
// TerminalInteraction is the v2 surface for unified exec
// stdin/poll events. Suppress the legacy CommandExecution
// item so clients do not render the same wait twice.
return;
}
let item_id = exec_command_begin_event.call_id.clone();
let command_actions = exec_command_begin_event
.parsed_cmd
@@ -1702,6 +1718,17 @@ pub(crate) async fn apply_bespoke_event_handling(
.command_execution_started
.remove(&call_id);
}
if matches!(api_version, ApiVersion::V2)
&& matches!(
exec_command_end_event.source,
codex_protocol::protocol::ExecCommandSource::UnifiedExecInteraction
)
{
// The paired begin event is suppressed above; keep the
// completion out of v2 as well so no orphan legacy item is
// emitted for unified exec interactions.
return;
}
let item = build_command_execution_end_item(&exec_command_end_event);
@@ -3356,7 +3383,7 @@ mod tests {
codex_core::test_support::thread_manager_with_models_provider_and_home(
CodexAuth::create_dummy_chatgpt_auth_for_testing(),
config.model_provider.clone(),
config.codex_home.clone(),
config.codex_home.to_path_buf(),
Arc::new(codex_exec_server::EnvironmentManager::new(
/*exec_server_url*/ None,
)),
File diff suppressed because it is too large Load Diff
+8 -4
View File
@@ -46,7 +46,7 @@ impl FsApi {
) -> Result<FsReadFileResponse, JSONRPCErrorError> {
let bytes = self
.file_system
.read_file(&params.path)
.read_file(&params.path, /*sandbox*/ None)
.await
.map_err(map_fs_error)?;
Ok(FsReadFileResponse {
@@ -64,7 +64,7 @@ impl FsApi {
))
})?;
self.file_system
.write_file(&params.path, bytes)
.write_file(&params.path, bytes, /*sandbox*/ None)
.await
.map_err(map_fs_error)?;
Ok(FsWriteFileResponse {})
@@ -80,6 +80,7 @@ impl FsApi {
CreateDirectoryOptions {
recursive: params.recursive.unwrap_or(true),
},
/*sandbox*/ None,
)
.await
.map_err(map_fs_error)?;
@@ -92,12 +93,13 @@ impl FsApi {
) -> Result<FsGetMetadataResponse, JSONRPCErrorError> {
let metadata = self
.file_system
.get_metadata(&params.path)
.get_metadata(&params.path, /*sandbox*/ None)
.await
.map_err(map_fs_error)?;
Ok(FsGetMetadataResponse {
is_directory: metadata.is_directory,
is_file: metadata.is_file,
is_symlink: metadata.is_symlink,
created_at_ms: metadata.created_at_ms,
modified_at_ms: metadata.modified_at_ms,
})
@@ -109,7 +111,7 @@ impl FsApi {
) -> Result<FsReadDirectoryResponse, JSONRPCErrorError> {
let entries = self
.file_system
.read_directory(&params.path)
.read_directory(&params.path, /*sandbox*/ None)
.await
.map_err(map_fs_error)?;
Ok(FsReadDirectoryResponse {
@@ -135,6 +137,7 @@ impl FsApi {
recursive: params.recursive.unwrap_or(true),
force: params.force.unwrap_or(true),
},
/*sandbox*/ None,
)
.await
.map_err(map_fs_error)?;
@@ -152,6 +155,7 @@ impl FsApi {
CopyOptions {
recursive: params.recursive,
},
/*sandbox*/ None,
)
.await
.map_err(map_fs_error)?;
+8 -2
View File
@@ -44,6 +44,7 @@ use codex_core::check_execpolicy_for_warnings;
use codex_core::config_loader::ConfigLoadError;
use codex_core::config_loader::TextRange as CoreTextRange;
use codex_exec_server::EnvironmentManager;
use codex_exec_server::ExecServerRuntimePaths;
use codex_feedback::CodexFeedback;
use codex_protocol::protocol::SessionSource;
use codex_state::log_db;
@@ -355,7 +356,12 @@ pub async fn run_main_with_transport(
session_source: SessionSource,
auth: AppServerWebsocketAuthSettings,
) -> IoResult<()> {
let environment_manager = Arc::new(EnvironmentManager::from_env());
let environment_manager = Arc::new(EnvironmentManager::from_env_with_runtime_paths(Some(
ExecServerRuntimePaths::from_optional_paths(
arg0_paths.codex_self_exe.clone(),
arg0_paths.codex_linux_sandbox_exe.clone(),
)?,
)));
let (transport_event_tx, mut transport_event_rx) =
mpsc::channel::<TransportEvent>(CHANNEL_CAPACITY);
let (outgoing_tx, mut outgoing_rx) = mpsc::channel::<OutgoingEnvelope>(CHANNEL_CAPACITY);
@@ -399,7 +405,7 @@ pub async fn run_main_with_transport(
cloud_requirements_loader(
auth_manager,
config.chatgpt_base_url,
config.codex_home.clone(),
config.codex_home.to_path_buf(),
)
}
Err(err) => {
+4 -16
View File
@@ -9,7 +9,6 @@ use std::sync::atomic::Ordering;
use crate::codex_message_processor::CodexMessageProcessor;
use crate::codex_message_processor::CodexMessageProcessorArgs;
use crate::config_api::ConfigApi;
use crate::error_code::INTERNAL_ERROR_CODE;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::external_agent_config_api::ExternalAgentConfigApi;
use crate::fs_api::FsApi;
@@ -266,7 +265,7 @@ impl MessageProcessor {
.plugins_manager()
.maybe_start_plugin_startup_tasks_for_config(&config, auth_manager.clone());
let config_api = ConfigApi::new(
config.codex_home.clone(),
config.codex_home.to_path_buf(),
cli_overrides,
runtime_feature_enablement,
loader_overrides,
@@ -274,7 +273,8 @@ impl MessageProcessor {
thread_manager,
analytics_events_client.clone(),
);
let external_agent_config_api = ExternalAgentConfigApi::new(config.codex_home.clone());
let external_agent_config_api =
ExternalAgentConfigApi::new(config.codex_home.to_path_buf());
let fs_api = FsApi::default();
let fs_watch_manager = FsWatchManager::new(outgoing.clone());
@@ -620,21 +620,9 @@ impl MessageProcessor {
}
let user_agent = get_codex_user_agent();
let codex_home = match self.config.codex_home.clone().try_into() {
Ok(codex_home) => codex_home,
Err(err) => {
let error = JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("Invalid CODEX_HOME: {err}"),
data: None,
};
self.outgoing.send_error(connection_request_id, error).await;
return;
}
};
let response = InitializeResponse {
user_agent,
codex_home,
codex_home: self.config.codex_home.clone(),
platform_family: std::env::consts::FAMILY.to_string(),
platform_os: std::env::consts::OS.to_string(),
};
+32 -6
View File
@@ -16,6 +16,7 @@ use std::sync::Weak;
use tokio::sync::Mutex;
use tokio::sync::mpsc;
use tokio::sync::oneshot;
use tokio::sync::watch;
use tracing::error;
type PendingInterruptQueue = Vec<(
@@ -27,6 +28,7 @@ pub(crate) struct PendingThreadResumeRequest {
pub(crate) request_id: ConnectionRequestId,
pub(crate) rollout_path: PathBuf,
pub(crate) config_snapshot: ThreadConfigSnapshot,
pub(crate) instruction_sources: Vec<PathBuf>,
pub(crate) thread_summary: codex_app_server_protocol::Thread,
}
@@ -158,6 +160,7 @@ pub(crate) async fn resolve_server_request_on_thread_listener(
struct ThreadEntry {
state: Arc<Mutex<ThreadState>>,
connection_ids: HashSet<ConnectionId>,
has_connections_watcher: watch::Sender<bool>,
}
impl Default for ThreadEntry {
@@ -165,10 +168,21 @@ impl Default for ThreadEntry {
Self {
state: Arc::new(Mutex::new(ThreadState::default())),
connection_ids: HashSet::new(),
has_connections_watcher: watch::channel(false).0,
}
}
}
impl ThreadEntry {
fn update_has_connections(&self) {
let _ = self.has_connections_watcher.send_if_modified(|current| {
let prev = *current;
*current = !self.connection_ids.is_empty();
prev != *current
});
}
}
#[derive(Default)]
struct ThreadStateManagerInner {
live_connections: HashSet<ConnectionId>,
@@ -285,12 +299,14 @@ impl ThreadStateManager {
}
if let Some(thread_entry) = state.threads.get_mut(&thread_id) {
thread_entry.connection_ids.remove(&connection_id);
thread_entry.update_has_connections();
}
};
true
}
#[cfg(test)]
pub(crate) async fn has_subscribers(&self, thread_id: ThreadId) -> bool {
self.state
.lock()
@@ -318,6 +334,7 @@ impl ThreadStateManager {
.insert(thread_id);
let thread_entry = state.threads.entry(thread_id).or_default();
thread_entry.connection_ids.insert(connection_id);
thread_entry.update_has_connections();
thread_entry.state.clone()
};
{
@@ -343,12 +360,9 @@ impl ThreadStateManager {
.entry(connection_id)
.or_default()
.insert(thread_id);
state
.threads
.entry(thread_id)
.or_default()
.connection_ids
.insert(connection_id);
let thread_entry = state.threads.entry(thread_id).or_default();
thread_entry.connection_ids.insert(connection_id);
thread_entry.update_has_connections();
true
}
@@ -363,6 +377,7 @@ impl ThreadStateManager {
for thread_id in &thread_ids {
if let Some(thread_entry) = state.threads.get_mut(thread_id) {
thread_entry.connection_ids.remove(&connection_id);
thread_entry.update_has_connections();
}
}
thread_ids
@@ -376,4 +391,15 @@ impl ThreadStateManager {
.collect::<Vec<_>>()
}
}
pub(crate) async fn subscribe_to_has_connections(
&self,
thread_id: ThreadId,
) -> Option<watch::Receiver<bool>> {
let state = self.state.lock().await;
state
.threads
.get(&thread_id)
.map(|thread_entry| thread_entry.has_connections_watcher.subscribe())
}
}
+95
View File
@@ -8,6 +8,7 @@ use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadActiveFlag;
use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::ThreadStatusChangedNotification;
use codex_protocol::ThreadId;
use std::collections::HashMap;
#[cfg(test)]
use std::path::PathBuf;
@@ -244,6 +245,13 @@ impl ThreadWatchManager {
}
}
pub(crate) async fn subscribe(
&self,
thread_id: ThreadId,
) -> Option<watch::Receiver<ThreadStatus>> {
Some(self.state.lock().await.subscribe(thread_id.to_string()))
}
async fn note_active_guard_released(
&self,
thread_id: String,
@@ -295,6 +303,7 @@ pub(crate) fn resolve_thread_status(
#[derive(Default)]
struct ThreadWatchState {
runtime_by_thread_id: HashMap<String, RuntimeFacts>,
status_watcher_by_thread_id: HashMap<String, watch::Sender<ThreadStatus>>,
}
impl ThreadWatchState {
@@ -309,6 +318,7 @@ impl ThreadWatchState {
.entry(thread_id.clone())
.or_default();
runtime.is_loaded = true;
self.update_status_watcher_for_thread(&thread_id);
if emit_notification {
self.status_changed_notification(thread_id, previous_status)
} else {
@@ -319,6 +329,7 @@ impl ThreadWatchState {
fn remove_thread(&mut self, thread_id: &str) -> Option<ThreadStatusChangedNotification> {
let previous_status = self.status_for(thread_id);
self.runtime_by_thread_id.remove(thread_id);
self.update_status_watcher(thread_id, &ThreadStatus::NotLoaded);
if previous_status.is_some() && previous_status != Some(ThreadStatus::NotLoaded) {
Some(ThreadStatusChangedNotification {
thread_id: thread_id.to_string(),
@@ -344,6 +355,7 @@ impl ThreadWatchState {
.or_default();
runtime.is_loaded = true;
mutate(runtime);
self.update_status_watcher_for_thread(thread_id);
self.status_changed_notification(thread_id.to_string(), previous_status)
}
@@ -358,6 +370,40 @@ impl ThreadWatchState {
.unwrap_or(ThreadStatus::NotLoaded)
}
fn subscribe(&mut self, thread_id: String) -> watch::Receiver<ThreadStatus> {
let status = self.loaded_status_for_thread(&thread_id);
let sender = self
.status_watcher_by_thread_id
.entry(thread_id)
.or_insert_with(|| watch::channel(status.clone()).0);
sender.subscribe()
}
fn update_status_watcher_for_thread(&mut self, thread_id: &str) {
let status = self.loaded_status_for_thread(thread_id);
self.update_status_watcher(thread_id, &status);
}
fn update_status_watcher(&mut self, thread_id: &str, status: &ThreadStatus) {
let remove_watcher = if let Some(sender) = self.status_watcher_by_thread_id.get(thread_id) {
let status = status.clone();
let _ = sender.send_if_modified(|current| {
if *current == status {
false
} else {
*current = status;
true
}
});
sender.receiver_count() == 0
} else {
false
};
if remove_watcher {
self.status_watcher_by_thread_id.remove(thread_id);
}
}
fn status_changed_notification(
&self,
thread_id: String,
@@ -752,6 +798,55 @@ mod tests {
);
}
#[tokio::test]
async fn status_watchers_receive_only_their_thread_updates() {
let manager = ThreadWatchManager::new();
manager
.upsert_thread(test_thread(
INTERACTIVE_THREAD_ID,
codex_app_server_protocol::SessionSource::Cli,
))
.await;
manager
.upsert_thread(test_thread(
NON_INTERACTIVE_THREAD_ID,
codex_app_server_protocol::SessionSource::AppServer,
))
.await;
let interactive_thread_id = ThreadId::from_string(INTERACTIVE_THREAD_ID)
.expect("interactive thread id should parse");
let non_interactive_thread_id = ThreadId::from_string(NON_INTERACTIVE_THREAD_ID)
.expect("non-interactive thread id should parse");
let mut interactive_rx = manager
.subscribe(interactive_thread_id)
.await
.expect("interactive status watcher should subscribe");
let mut non_interactive_rx = manager
.subscribe(non_interactive_thread_id)
.await
.expect("non-interactive status watcher should subscribe");
manager.note_turn_started(INTERACTIVE_THREAD_ID).await;
timeout(Duration::from_secs(1), interactive_rx.changed())
.await
.expect("timed out waiting for interactive status update")
.expect("interactive status watcher should remain open");
assert_eq!(
*interactive_rx.borrow(),
ThreadStatus::Active {
active_flags: vec![],
},
);
assert!(
timeout(Duration::from_millis(100), non_interactive_rx.changed())
.await
.is_err(),
"unrelated thread watcher should not receive an update"
);
assert_eq!(*non_interactive_rx.borrow(), ThreadStatus::Idle);
}
async fn wait_for_status(
manager: &ThreadWatchManager,
thread_id: &str,
@@ -47,6 +47,7 @@ use codex_app_server_protocol::JSONRPCRequest;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::ListMcpServerStatusParams;
use codex_app_server_protocol::LoginAccountParams;
use codex_app_server_protocol::MarketplaceAddParams;
use codex_app_server_protocol::McpResourceReadParams;
use codex_app_server_protocol::McpServerToolCallParams;
use codex_app_server_protocol::MockExperimentalMethodParams;
@@ -62,8 +63,10 @@ use codex_app_server_protocol::SkillsListParams;
use codex_app_server_protocol::ThreadArchiveParams;
use codex_app_server_protocol::ThreadCompactStartParams;
use codex_app_server_protocol::ThreadForkParams;
use codex_app_server_protocol::ThreadInjectItemsParams;
use codex_app_server_protocol::ThreadListParams;
use codex_app_server_protocol::ThreadLoadedListParams;
use codex_app_server_protocol::ThreadMemoryModeSetParams;
use codex_app_server_protocol::ThreadMetadataUpdateParams;
use codex_app_server_protocol::ThreadReadParams;
use codex_app_server_protocol::ThreadRealtimeAppendAudioParams;
@@ -513,6 +516,15 @@ impl McpProcess {
self.send_request("skills/list", params).await
}
/// Send a `marketplace/add` JSON-RPC request.
pub async fn send_marketplace_add_request(
&mut self,
params: MarketplaceAddParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("marketplace/add", params).await
}
/// Send a `plugin/install` JSON-RPC request.
pub async fn send_plugin_install_request(
&mut self,
@@ -584,6 +596,15 @@ impl McpProcess {
self.send_request("mock/experimentalMethod", params).await
}
/// Send a `thread/memoryMode/set` JSON-RPC request (v2, experimental).
pub async fn send_thread_memory_mode_set_request(
&mut self,
params: ThreadMemoryModeSetParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("thread/memoryMode/set", params).await
}
/// Send a `turn/start` JSON-RPC request (v2).
pub async fn send_turn_start_request(
&mut self,
@@ -593,6 +614,15 @@ impl McpProcess {
self.send_request("turn/start", params).await
}
/// Send a `thread/inject_items` JSON-RPC request (v2).
pub async fn send_thread_inject_items_request(
&mut self,
params: ThreadInjectItemsParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("thread/inject_items", params).await
}
/// Send a `command/exec` JSON-RPC request (v2).
pub async fn send_command_exec_request(
&mut self,
@@ -338,7 +338,8 @@ async fn websocket_transport_allows_unauthenticated_non_loopback_startup_by_defa
}
#[tokio::test]
async fn websocket_disconnect_unloads_last_subscribed_thread() -> Result<()> {
async fn websocket_disconnect_keeps_last_subscribed_thread_loaded_until_idle_timeout() -> Result<()>
{
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")?;
@@ -359,7 +360,7 @@ async fn websocket_disconnect_unloads_last_subscribed_thread() -> Result<()> {
send_initialize_request(&mut ws2, /*id*/ 4, "ws_reconnect_client").await?;
read_response_for_id(&mut ws2, /*id*/ 4).await?;
wait_for_loaded_threads(&mut ws2, /*first_id*/ 5, &[]).await?;
wait_for_loaded_threads(&mut ws2, /*first_id*/ 5, &[thread_id.as_str()]).await?;
process
.kill()
@@ -11,6 +11,8 @@ use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::MockExperimentalMethodParams;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadMemoryMode;
use codex_app_server_protocol::ThreadMemoryModeSetParams;
use codex_app_server_protocol::ThreadRealtimeStartParams;
use codex_app_server_protocol::ThreadRealtimeStartTransport;
use codex_app_server_protocol::ThreadStartParams;
@@ -89,6 +91,39 @@ async fn realtime_conversation_start_requires_experimental_api_capability() -> R
Ok(())
}
#[tokio::test]
async fn thread_memory_mode_set_requires_experimental_api_capability() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
let init = mcp
.initialize_with_capabilities(
default_client_info(),
Some(InitializeCapabilities {
experimental_api: false,
opt_out_notification_methods: None,
}),
)
.await?;
let JSONRPCMessage::Response(_) = init else {
anyhow::bail!("expected initialize response, got {init:?}");
};
let request_id = mcp
.send_thread_memory_mode_set_request(ThreadMemoryModeSetParams {
thread_id: "thr_123".to_string(),
mode: ThreadMemoryMode::Disabled,
})
.await?;
let error = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_experimental_capability_error(error, "thread/memoryMode/set");
Ok(())
}
#[tokio::test]
async fn realtime_webrtc_start_requires_experimental_api_capability() -> Result<()> {
let codex_home = TempDir::new()?;
+31
View File
@@ -89,6 +89,7 @@ async fn fs_get_metadata_returns_only_used_fields() -> Result<()> {
"createdAtMs".to_string(),
"isDirectory".to_string(),
"isFile".to_string(),
"isSymlink".to_string(),
"modifiedAtMs".to_string(),
]
);
@@ -99,6 +100,7 @@ async fn fs_get_metadata_returns_only_used_fields() -> Result<()> {
FsGetMetadataResponse {
is_directory: false,
is_file: true,
is_symlink: false,
created_at_ms: stat.created_at_ms,
modified_at_ms: stat.modified_at_ms,
}
@@ -111,6 +113,35 @@ async fn fs_get_metadata_returns_only_used_fields() -> Result<()> {
Ok(())
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fs_get_metadata_reports_symlink() -> Result<()> {
let codex_home = TempDir::new()?;
let file_path = codex_home.path().join("note.txt");
let symlink_path = codex_home.path().join("note-link.txt");
std::fs::write(&file_path, "hello")?;
symlink(&file_path, &symlink_path)?;
let mut mcp = initialized_mcp(&codex_home).await?;
let request_id = mcp
.send_fs_get_metadata_request(codex_app_server_protocol::FsGetMetadataParams {
path: absolute_path(symlink_path),
})
.await?;
let response = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let stat: FsGetMetadataResponse = to_response(response)?;
assert_eq!(stat.is_directory, false);
assert_eq!(stat.is_file, true);
assert_eq!(stat.is_symlink, true);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn fs_methods_cover_current_fs_utils_surface() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -0,0 +1,40 @@
use anyhow::Result;
use app_test_support::McpProcess;
use codex_app_server_protocol::MarketplaceAddParams;
use codex_app_server_protocol::RequestId;
use tempfile::TempDir;
use tokio::time::Duration;
use tokio::time::timeout;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
#[tokio::test]
async fn marketplace_add_rejects_local_directory_source() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_marketplace_add_request(MarketplaceAddParams {
source: "./marketplace".to_string(),
ref_name: None,
sparse_paths: None,
})
.await?;
let err = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
)
.await??;
assert_eq!(err.error.code, -32600);
assert!(
err.error.message.contains(
"local marketplace sources are not supported yet; use an HTTP(S) Git URL, SSH Git URL, or GitHub owner/repo"
),
"unexpected error: {}",
err.error.message
);
Ok(())
}
@@ -32,6 +32,7 @@ use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_config::types::AuthCredentialsStoreMode;
use core_test_support::assert_regex_match;
use core_test_support::responses;
use pretty_assertions::assert_eq;
use rmcp::handler::server::ServerHandler;
@@ -274,8 +275,15 @@ async fn mcp_server_elicitation_round_trip() -> Result<()> {
.get("output")
.and_then(Value::as_str)
.expect("function_call_output output should be a JSON string");
let payload = assert_regex_match(
r#"(?s)^Wall time: [0-9]+(?:\.[0-9]+)? seconds\nOutput:\n(.*)$"#,
output,
)
.get(1)
.expect("wall-time wrapped output should include payload")
.as_str();
assert_eq!(
serde_json::from_str::<Value>(output)?,
serde_json::from_str::<Value>(payload)?,
json!([{
"type": "text",
"text": "accepted"
@@ -15,6 +15,7 @@ mod experimental_api;
mod experimental_feature_list;
mod fs;
mod initialize;
mod marketplace_add;
mod mcp_resource;
mod mcp_server_elicitation;
mod mcp_server_status;
@@ -35,8 +36,10 @@ mod safety_check_downgrade;
mod skills_list;
mod thread_archive;
mod thread_fork;
mod thread_inject_items;
mod thread_list;
mod thread_loaded_list;
mod thread_memory_mode_set;
mod thread_metadata_update;
mod thread_name_websocket;
mod thread_read;
@@ -661,7 +661,7 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> {
let connections = realtime_server.connections();
assert_eq!(connections.len(), 1);
let connection = &connections[0];
assert_eq!(connection.len(), 4);
assert_eq!(connection.len(), 3);
assert_eq!(
connection[0].body_json()["type"].as_str(),
Some("session.update")
@@ -679,10 +679,6 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> {
.as_str()
.context("expected websocket request type")?
.to_string(),
connection[3].body_json()["type"]
.as_str()
.context("expected websocket request type")?
.to_string(),
];
request_types.sort();
assert_eq!(
@@ -690,7 +686,6 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> {
[
"conversation.item.create".to_string(),
"input_audio_buffer.append".to_string(),
"response.create".to_string(),
]
);
@@ -978,24 +973,8 @@ async fn realtime_webrtc_start_emits_sdp_notification() -> Result<()> {
Some("multipart/form-data; boundary=codex-realtime-call-boundary")
);
let body = String::from_utf8(request.body).context("multipart body should be utf-8")?;
let session = r#"{"tool_choice":"auto","type":"realtime","model":"gpt-realtime-1.5","instructions":"backend prompt\n\nstartup context","output_modalities":["audio"],"audio":{"input":{"format":{"type":"audio/pcm","rate":24000},"noise_reduction":{"type":"near_field"},"turn_detection":{"type":"server_vad","interrupt_response":true,"create_response":true}},"output":{"format":{"type":"audio/pcm","rate":24000},"voice":"marin"}},"tools":[{"type":"function","name":"background_agent","description":"Send a user request to the background agent. Use this as the default action. If the background agent is idle, this starts a new task and returns the final result to the user. If the background agent is already working on a task, this sends the request as guidance to steer that previous task. If the user asks to do something next, later, after this, or once current work finishes, call this tool so the work is actually queued instead of merely promising to do it later.","parameters":{"type":"object","properties":{"prompt":{"type":"string","description":"The user request to delegate to the background agent."}},"required":["prompt"],"additionalProperties":false}}]}"#;
assert_eq!(
body,
format!(
"--codex-realtime-call-boundary\r\n\
Content-Disposition: form-data; name=\"sdp\"\r\n\
Content-Type: application/sdp\r\n\
\r\n\
v=offer\r\n\
\r\n\
--codex-realtime-call-boundary\r\n\
Content-Disposition: form-data; name=\"session\"\r\n\
Content-Type: application/json\r\n\
\r\n\
{session}\r\n\
--codex-realtime-call-boundary--\r\n"
)
);
let session = r#"{"tool_choice":"auto","type":"realtime","model":"gpt-realtime-1.5","instructions":"backend prompt\n\nstartup context","output_modalities":["audio"],"audio":{"input":{"format":{"type":"audio/pcm","rate":24000},"noise_reduction":{"type":"near_field"},"turn_detection":{"type":"server_vad","interrupt_response":true,"create_response":true}},"output":{"format":{"type":"audio/pcm","rate":24000},"voice":"marin"}},"tools":[{"type":"function","name":"background_agent","description":"Send a user request to the background agent. Use this as the default action. Do not rephrase the user's ask or rewrite it in your own words; pass along the user's own words. If the background agent is idle, this starts a new task and returns the final result to the user. If the background agent is already working on a task, this sends the request as guidance to steer that previous task. If the user asks to do something next, later, after this, or once current work finishes, call this tool so the work is actually queued instead of merely promising to do it later.","parameters":{"type":"object","properties":{"prompt":{"type":"string","description":"The user request to delegate to the background agent."}},"required":["prompt"],"additionalProperties":false}}]}"#;
assert_call_create_body(&body, "v=offer\r\n", session)?;
realtime_server.shutdown().await;
Ok(())
@@ -1153,7 +1132,6 @@ async fn webrtc_v2_forwards_audio_and_text_between_client_and_sideband() -> Resu
"samples_per_channel": 512
}),
],
vec![],
])]),
)
.await?;
@@ -1185,7 +1163,6 @@ async fn webrtc_v2_forwards_audio_and_text_between_client_and_sideband() -> Resu
let requests = [
harness.sideband_outbound_request(/*request_index*/ 1).await,
harness.sideband_outbound_request(/*request_index*/ 2).await,
harness.sideband_outbound_request(/*request_index*/ 3).await,
];
assert!(
requests
@@ -1208,13 +1185,12 @@ async fn webrtc_v2_forwards_audio_and_text_between_client_and_sideband() -> Resu
Ok(())
}
/// Regression coverage for Realtime V2's single-active-response rule.
/// Regression coverage for Realtime V2 text input while a response is active.
///
/// The Realtime API rejects a new `response.create` while a default response is
/// still active, so the input task should queue the second create and flush it
/// only after the server sends `response.done` for the active response.
/// Text input is append-only, so app-server should send the user message without
/// requesting a new realtime response.
#[tokio::test]
async fn webrtc_v2_queues_text_response_create_while_response_is_active() -> Result<()> {
async fn webrtc_v2_text_input_is_append_only_while_response_is_active() -> Result<()> {
skip_if_no_network!(Ok(()));
// Phase 1: script a server-side response that becomes active after the first
@@ -1224,7 +1200,6 @@ async fn webrtc_v2_queues_text_response_create_while_response_is_active() -> Res
no_main_loop_responses(),
realtime_sideband(vec![realtime_sideband_connection(vec![
vec![session_updated("sess_v2_response_queue")],
vec![],
vec![
json!({
"type": "response.created",
@@ -1240,7 +1215,6 @@ async fn webrtc_v2_queues_text_response_create_while_response_is_active() -> Res
"type": "response.done",
"response": { "id": "resp_active" }
})],
vec![],
])]),
)
.await?;
@@ -1253,15 +1227,14 @@ async fn webrtc_v2_queues_text_response_create_while_response_is_active() -> Res
// notifications; they are the protocol frames app-server sends upstream.
assert_v2_session_update(&harness.sideband_outbound_request(/*request_index*/ 0).await)?;
// Phase 2: send the first text turn. It is safe to emit `response.create`
// immediately because no default response is active yet.
// Phase 2: send the first text turn. Text input is append-only, so this
// sends only the user text item.
let thread_id = started.started.thread_id.clone();
harness.append_text(thread_id.clone(), "first").await?;
assert_v2_user_text_item(
&harness.sideband_outbound_request(/*request_index*/ 1).await,
"first",
);
assert_v2_response_create(&harness.sideband_outbound_request(/*request_index*/ 2).await);
let transcript = harness
.read_notification::<ThreadRealtimeTranscriptUpdatedNotification>(
"thread/realtime/transcriptUpdated",
@@ -1270,39 +1243,28 @@ async fn webrtc_v2_queues_text_response_create_while_response_is_active() -> Res
assert_eq!(transcript.text, "active response started");
// Phase 3: send a second text turn while `resp_active` is still open. The
// user message must reach realtime, but `response.create` must not be sent
// yet or the Realtime API rejects it as an active-response conflict.
// user message must reach realtime without requesting another response.
harness.append_text(thread_id.clone(), "second").await?;
assert_v2_user_text_item(
&harness.sideband_outbound_request(/*request_index*/ 3).await,
&harness.sideband_outbound_request(/*request_index*/ 2).await,
"second",
);
// Phase 4: the audio input causes the scripted sideband stream to send
// `response.done`, which clears the active response and flushes the queued
// `response.create` for the second text turn.
// Phase 4: audio still forwards normally after text input.
harness.append_audio(thread_id).await?;
// This is the negative check: if the second text turn had emitted
// `response.create` immediately, request 4 would be that create instead of
// the audio append.
let audio = harness.sideband_outbound_request(/*request_index*/ 4).await;
let audio = harness.sideband_outbound_request(/*request_index*/ 3).await;
assert_eq!(audio["type"], "input_audio_buffer.append");
assert_eq!(audio["audio"], "BQYH");
assert_v2_response_create(&harness.sideband_outbound_request(/*request_index*/ 5).await);
harness.shutdown().await;
Ok(())
}
/// Regression coverage for the same queued `response.create` path when the
/// active Realtime V2 response is cancelled instead of completed.
///
/// `response.cancelled` should clear the active-response guard exactly like
/// `response.done`, so a text turn queued during the active response still gets
/// one deferred `response.create`.
/// Regression coverage for append-only Realtime V2 text input when the active
/// response is cancelled instead of completed.
#[tokio::test]
async fn webrtc_v2_flushes_queued_text_response_create_when_response_is_cancelled() -> Result<()> {
async fn webrtc_v2_text_input_is_append_only_when_response_is_cancelled() -> Result<()> {
skip_if_no_network!(Ok(()));
// Phase 1: script a server-side response that becomes active after the first
@@ -1312,7 +1274,6 @@ async fn webrtc_v2_flushes_queued_text_response_create_when_response_is_cancelle
no_main_loop_responses(),
realtime_sideband(vec![realtime_sideband_connection(vec![
vec![session_updated("sess_v2_response_cancel_queue")],
vec![],
vec![json!({
"type": "response.created",
"response": { "id": "resp_cancelled" }
@@ -1322,7 +1283,6 @@ async fn webrtc_v2_flushes_queued_text_response_create_when_response_is_cancelle
"type": "response.cancelled",
"response": { "id": "resp_cancelled" }
})],
vec![],
])]),
)
.await?;
@@ -1331,36 +1291,29 @@ async fn webrtc_v2_flushes_queued_text_response_create_when_response_is_cancelle
assert_eq!(started.started.version, RealtimeConversationVersion::V2);
assert_v2_session_update(&harness.sideband_outbound_request(/*request_index*/ 0).await)?;
// Phase 2: send the first text turn. It is safe to emit `response.create`
// immediately because no default response is active yet.
// Phase 2: send the first text turn. Text input is append-only, so this
// sends only the user text item.
let thread_id = started.started.thread_id.clone();
harness.append_text(thread_id.clone(), "first").await?;
assert_v2_user_text_item(
&harness.sideband_outbound_request(/*request_index*/ 1).await,
"first",
);
assert_v2_response_create(&harness.sideband_outbound_request(/*request_index*/ 2).await);
// Phase 3: send a second text turn while `resp_cancelled` is still open.
// The user message must reach realtime, but `response.create` stays queued.
// The user message must reach realtime without requesting another response.
harness.append_text(thread_id.clone(), "second").await?;
assert_v2_user_text_item(
&harness.sideband_outbound_request(/*request_index*/ 3).await,
&harness.sideband_outbound_request(/*request_index*/ 2).await,
"second",
);
// Phase 4: the audio input causes the scripted sideband stream to send
// `response.cancelled`, which clears the active response and flushes the
// queued `response.create` for the second text turn.
// Phase 4: audio still forwards normally after text input.
harness.append_audio(thread_id).await?;
// This is the negative check: if the second text turn had emitted
// `response.create` immediately, request 4 would be that create instead of
// the audio append.
let audio = harness.sideband_outbound_request(/*request_index*/ 4).await;
let audio = harness.sideband_outbound_request(/*request_index*/ 3).await;
assert_eq!(audio["type"], "input_audio_buffer.append");
assert_eq!(audio["audio"], "BQYH");
assert_v2_response_create(&harness.sideband_outbound_request(/*request_index*/ 5).await);
harness.shutdown().await;
Ok(())
@@ -2049,22 +2002,31 @@ fn assert_call_create_multipart(
Some("multipart/form-data; boundary=codex-realtime-call-boundary")
);
let body = String::from_utf8(request.body).context("multipart body should be utf-8")?;
assert_eq!(
body,
format!(
"--codex-realtime-call-boundary\r\n\
Content-Disposition: form-data; name=\"sdp\"\r\n\
Content-Type: application/sdp\r\n\
\r\n\
{offer_sdp}\r\n\
--codex-realtime-call-boundary\r\n\
Content-Disposition: form-data; name=\"session\"\r\n\
Content-Type: application/json\r\n\
\r\n\
{session}\r\n\
--codex-realtime-call-boundary--\r\n"
)
assert_call_create_body(&body, offer_sdp, session)?;
Ok(())
}
fn assert_call_create_body(body: &str, offer_sdp: &str, session: &str) -> Result<()> {
let expected_prefix = format!(
"--codex-realtime-call-boundary\r\n\
Content-Disposition: form-data; name=\"sdp\"\r\n\
Content-Type: application/sdp\r\n\
\r\n\
{offer_sdp}\r\n\
--codex-realtime-call-boundary\r\n\
Content-Disposition: form-data; name=\"session\"\r\n\
Content-Type: application/json\r\n\
\r\n"
);
let session_body = body
.strip_prefix(&expected_prefix)
.and_then(|value| value.strip_suffix("\r\n--codex-realtime-call-boundary--\r\n"))
.context("multipart body should contain expected sdp part and session json part")?;
let actual_session: Value =
serde_json::from_str(session_body).context("session part should be json")?;
let expected_session: Value =
serde_json::from_str(session).context("expected session should be json")?;
assert_eq!(actual_session, expected_session);
Ok(())
}
@@ -98,6 +98,35 @@ async fn skills_list_rejects_relative_extra_user_roots() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn skills_list_accepts_relative_cwds() -> Result<()> {
let codex_home = TempDir::new()?;
let relative_cwd = std::path::PathBuf::from("relative-cwd");
std::fs::create_dir_all(codex_home.path().join(&relative_cwd))?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_skills_list_request(SkillsListParams {
cwds: vec![relative_cwd.clone()],
force_reload: true,
per_cwd_extra_user_roots: None,
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let SkillsListResponse { data } = to_response(response)?;
assert_eq!(data.len(), 1);
assert_eq!(data[0].cwd, relative_cwd);
assert_eq!(data[0].errors, Vec::new());
Ok(())
}
#[tokio::test]
async fn skills_list_ignores_per_cwd_extra_roots_for_unknown_cwd() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -0,0 +1,288 @@
use anyhow::Context;
use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::to_response;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadInjectItemsParams;
use codex_app_server_protocol::ThreadInjectItemsResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_core::RolloutRecorder;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::InitialHistory;
use codex_protocol::protocol::RolloutItem;
use core_test_support::responses;
use serde_json::Value;
use std::path::Path;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[tokio::test]
async fn thread_inject_items_adds_raw_response_items_to_thread_history() -> Result<()> {
let server = responses::start_mock_server().await;
let body = responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_assistant_message("msg-1", "Done"),
responses::ev_completed("resp-1"),
]);
let response_mock = responses::mount_sse_once(&server, body).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let thread_req = mcp
.send_thread_start_request(ThreadStartParams {
model: Some("mock-model".to_string()),
..Default::default()
})
.await?;
let thread_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
let injected_text = "Injected assistant context";
let injected_item = ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: injected_text.to_string(),
}],
end_turn: None,
phase: None,
};
let inject_req = mcp
.send_thread_inject_items_request(ThreadInjectItemsParams {
thread_id: thread.id.clone(),
items: vec![serde_json::to_value(&injected_item)?],
})
.await?;
let inject_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(inject_req)),
)
.await??;
let _response: ThreadInjectItemsResponse =
to_response::<ThreadInjectItemsResponse>(inject_resp)?;
let rollout_path = thread.path.as_ref().context("thread path missing")?;
let history = RolloutRecorder::get_rollout_history(rollout_path).await?;
let InitialHistory::Resumed(resumed_history) = history else {
panic!("expected resumed rollout history");
};
assert!(
resumed_history
.history
.iter()
.any(|item| matches!(item, RolloutItem::ResponseItem(response_item) if response_item == &injected_item)),
"injected item should be persisted in rollout history"
);
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![V2UserInput::Text {
text: "Hello".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
)
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let injected_value = serde_json::to_value(&injected_item)?;
let model_input = response_mock.single_request().input();
let environment_context_index =
response_item_text_position(&model_input, "<environment_context>")
.expect("environment context should be injected before the first user turn");
let injected_index = model_input
.iter()
.position(|item| item == &injected_value)
.expect("injected item should be sent in the next model request");
let user_prompt_index = response_item_text_position(&model_input, "Hello")
.expect("user prompt should be sent in the next model request");
assert!(
environment_context_index < injected_index,
"standard initial context should be sent before injected items"
);
assert!(
injected_index < user_prompt_index,
"injected items should be sent before the user prompt"
);
Ok(())
}
#[tokio::test]
async fn thread_inject_items_adds_raw_response_items_after_a_turn() -> Result<()> {
let server = responses::start_mock_server().await;
let first_body = responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_assistant_message("msg-1", "First done"),
responses::ev_completed("resp-1"),
]);
let second_body = responses::sse(vec![
responses::ev_response_created("resp-2"),
responses::ev_assistant_message("msg-2", "Second done"),
responses::ev_completed("resp-2"),
]);
let response_mock = responses::mount_sse_sequence(&server, vec![first_body, second_body]).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let thread_req = mcp
.send_thread_start_request(ThreadStartParams {
model: Some("mock-model".to_string()),
..Default::default()
})
.await?;
let thread_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
let first_turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![V2UserInput::Text {
text: "First turn".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(first_turn_req)),
)
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let injected_item = ResponseItem::Message {
id: None,
role: "assistant".to_string(),
content: vec![ContentItem::OutputText {
text: "Injected after first turn".to_string(),
}],
end_turn: None,
phase: None,
};
let injected_value = serde_json::to_value(&injected_item)?;
let inject_req = mcp
.send_thread_inject_items_request(ThreadInjectItemsParams {
thread_id: thread.id.clone(),
items: vec![injected_value.clone()],
})
.await?;
let inject_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(inject_req)),
)
.await??;
let _response: ThreadInjectItemsResponse =
to_response::<ThreadInjectItemsResponse>(inject_resp)?;
let second_turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![V2UserInput::Text {
text: "Second turn".to_string(),
text_elements: Vec::new(),
}],
..Default::default()
})
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(second_turn_req)),
)
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let requests = response_mock.requests();
assert_eq!(requests.len(), 2);
assert!(
!requests[0].input().contains(&injected_value),
"injected item should not be sent before it is injected"
);
assert!(
requests[1].input().contains(&injected_value),
"injected item should be sent after being injected into existing history"
);
Ok(())
}
fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> {
let config_toml = codex_home.join("config.toml");
std::fs::write(
config_toml,
format!(
r#"
model = "mock-model"
approval_policy = "never"
sandbox_mode = "read-only"
model_provider = "mock_provider"
[model_providers.mock_provider]
name = "Mock provider for test"
base_url = "{server_uri}/v1"
wire_api = "responses"
request_max_retries = 0
stream_max_retries = 0
"#
),
)
}
fn response_item_text_position(items: &[Value], needle: &str) -> Option<usize> {
items.iter().position(|item| {
item.get("content")
.and_then(Value::as_array)
.into_iter()
.flatten()
.any(|content| {
content
.get("text")
.and_then(Value::as_str)
.is_some_and(|text| text.contains(needle))
})
})
}
@@ -0,0 +1,138 @@
use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::create_fake_rollout;
use app_test_support::create_mock_responses_server_repeating_assistant;
use app_test_support::to_response;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadMemoryMode;
use codex_app_server_protocol::ThreadMemoryModeSetParams;
use codex_app_server_protocol::ThreadMemoryModeSetResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_protocol::ThreadId;
use codex_state::StateRuntime;
use pretty_assertions::assert_eq;
use std::path::Path;
use std::sync::Arc;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[tokio::test]
async fn thread_memory_mode_set_updates_loaded_thread_state() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let state_db = init_state_db(codex_home.path()).await?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let start_id = mcp
.send_thread_start_request(ThreadStartParams {
model: Some("mock-model".to_string()),
..Default::default()
})
.await?;
let start_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(start_id)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(start_resp)?;
let thread_uuid = ThreadId::from_string(&thread.id)?;
let set_id = mcp
.send_thread_memory_mode_set_request(ThreadMemoryModeSetParams {
thread_id: thread.id,
mode: ThreadMemoryMode::Disabled,
})
.await?;
let set_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(set_id)),
)
.await??;
let _: ThreadMemoryModeSetResponse = to_response::<ThreadMemoryModeSetResponse>(set_resp)?;
let memory_mode = state_db.get_thread_memory_mode(thread_uuid).await?;
assert_eq!(memory_mode.as_deref(), Some("disabled"));
Ok(())
}
#[tokio::test]
async fn thread_memory_mode_set_updates_stored_thread_state() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let state_db = init_state_db(codex_home.path()).await?;
let thread_id = create_fake_rollout(
codex_home.path(),
"2025-01-06T08-30-00",
"2025-01-06T08:30:00Z",
"Stored thread preview",
Some("mock_provider"),
/*git_info*/ None,
)?;
let thread_uuid = ThreadId::from_string(&thread_id)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
for mode in [ThreadMemoryMode::Disabled, ThreadMemoryMode::Enabled] {
let set_id = mcp
.send_thread_memory_mode_set_request(ThreadMemoryModeSetParams {
thread_id: thread_id.clone(),
mode,
})
.await?;
let set_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(set_id)),
)
.await??;
let _: ThreadMemoryModeSetResponse = to_response::<ThreadMemoryModeSetResponse>(set_resp)?;
}
let memory_mode = state_db.get_thread_memory_mode(thread_uuid).await?;
assert_eq!(memory_mode.as_deref(), Some("enabled"));
Ok(())
}
async fn init_state_db(codex_home: &Path) -> Result<Arc<StateRuntime>> {
let state_db = StateRuntime::init(codex_home.to_path_buf(), "mock_provider".into()).await?;
state_db
.mark_backfill_complete(/*last_watermark*/ None)
.await?;
Ok(state_db)
}
fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> {
let config_toml = codex_home.join("config.toml");
std::fs::write(
config_toml,
format!(
r#"
model = "mock-model"
approval_policy = "never"
sandbox_mode = "read-only"
model_provider = "mock_provider"
suppress_unstable_features_warning = true
[features]
sqlite = true
[model_providers.mock_provider]
name = "Mock provider for test"
base_url = "{server_uri}/v1"
wire_api = "responses"
request_max_retries = 0
stream_max_retries = 0
"#
),
)
}
@@ -29,6 +29,7 @@ use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
use tempfile::TempDir;
use tokio::time::timeout;
@@ -162,6 +163,64 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn thread_start_response_includes_loaded_instruction_sources() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?;
let global_agents_path = codex_home.path().join("AGENTS.md");
std::fs::write(&global_agents_path, "global instructions")?;
let workspace = TempDir::new()?;
let project_agents_path = workspace.path().join("AGENTS.md");
std::fs::write(&project_agents_path, "project instructions")?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_thread_start_request(ThreadStartParams {
cwd: Some(workspace.path().display().to_string()),
..Default::default()
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let ThreadStartResponse {
instruction_sources,
..
} = to_response::<ThreadStartResponse>(response)?;
let instruction_sources = instruction_sources
.into_iter()
.map(normalize_path_for_comparison)
.collect::<Vec<_>>();
let expected_instruction_sources = vec![
std::fs::canonicalize(global_agents_path)?,
std::fs::canonicalize(project_agents_path)?,
]
.into_iter()
.map(normalize_path_for_comparison)
.collect::<Vec<_>>();
assert_eq!(instruction_sources, expected_instruction_sources);
Ok(())
}
#[cfg(windows)]
fn normalize_path_for_comparison(path: PathBuf) -> PathBuf {
let path = path.display().to_string();
PathBuf::from(path.strip_prefix(r"\\?\").unwrap_or(&path))
}
#[cfg(not(windows))]
fn normalize_path_for_comparison(path: PathBuf) -> PathBuf {
path
}
#[tokio::test]
async fn thread_start_tracks_thread_initialized_analytics() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
@@ -7,10 +7,8 @@ use app_test_support::create_mock_responses_server_sequence_unchecked;
use app_test_support::create_shell_command_sse_response;
use app_test_support::to_response;
use codex_app_server_protocol::ItemStartedNotification;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::ThreadLoadedListParams;
use codex_app_server_protocol::ThreadLoadedListResponse;
@@ -21,7 +19,6 @@ use codex_app_server_protocol::ThreadResumeResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::ThreadStatus;
use codex_app_server_protocol::ThreadStatusChangedNotification;
use codex_app_server_protocol::ThreadUnsubscribeParams;
use codex_app_server_protocol::ThreadUnsubscribeResponse;
use codex_app_server_protocol::ThreadUnsubscribeStatus;
@@ -81,7 +78,7 @@ async fn wait_for_responses_request_count_to_stabilize(
}
#[tokio::test]
async fn thread_unsubscribe_unloads_thread_and_emits_thread_closed_notification() -> Result<()> {
async fn thread_unsubscribe_keeps_thread_loaded_until_idle_timeout() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
@@ -104,20 +101,14 @@ async fn thread_unsubscribe_unloads_thread_and_emits_thread_closed_notification(
let unsubscribe = to_response::<ThreadUnsubscribeResponse>(unsubscribe_resp)?;
assert_eq!(unsubscribe.status, ThreadUnsubscribeStatus::Unsubscribed);
let closed_notif: JSONRPCNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("thread/closed"),
)
.await??;
let parsed: ServerNotification = closed_notif.try_into()?;
let ServerNotification::ThreadClosed(payload) = parsed else {
anyhow::bail!("expected thread/closed notification");
};
assert_eq!(payload.thread_id, thread_id);
let status_changed = wait_for_thread_status_not_loaded(&mut mcp, &payload.thread_id).await?;
assert_eq!(status_changed.thread_id, payload.thread_id);
assert_eq!(status_changed.status, ThreadStatus::NotLoaded);
assert!(
timeout(
std::time::Duration::from_millis(250),
mcp.read_stream_until_notification_message("thread/closed"),
)
.await
.is_err()
);
let list_id = mcp
.send_thread_loaded_list_request(ThreadLoadedListParams::default())
@@ -129,22 +120,22 @@ async fn thread_unsubscribe_unloads_thread_and_emits_thread_closed_notification(
.await??;
let ThreadLoadedListResponse { data, next_cursor } =
to_response::<ThreadLoadedListResponse>(list_resp)?;
assert_eq!(data, Vec::<String>::new());
assert_eq!(data, vec![thread_id]);
assert_eq!(next_cursor, None);
Ok(())
}
#[tokio::test]
async fn thread_unsubscribe_during_turn_interrupts_turn_and_emits_thread_closed() -> Result<()> {
async fn thread_unsubscribe_during_turn_keeps_turn_running() -> Result<()> {
#[cfg(target_os = "windows")]
let shell_command = vec![
"powershell".to_string(),
"-Command".to_string(),
"Start-Sleep -Seconds 10".to_string(),
"Start-Sleep -Seconds 1".to_string(),
];
#[cfg(not(target_os = "windows"))]
let shell_command = vec!["sleep".to_string(), "10".to_string()];
let shell_command = vec!["sleep".to_string(), "1".to_string()];
let tmp = TempDir::new()?;
let codex_home = tmp.path().join("codex_home");
@@ -206,20 +197,18 @@ async fn thread_unsubscribe_during_turn_interrupts_turn_and_emits_thread_closed(
let unsubscribe = to_response::<ThreadUnsubscribeResponse>(unsubscribe_resp)?;
assert_eq!(unsubscribe.status, ThreadUnsubscribeStatus::Unsubscribed);
let closed_notif: JSONRPCNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("thread/closed"),
)
.await??;
let parsed: ServerNotification = closed_notif.try_into()?;
let ServerNotification::ThreadClosed(payload) = parsed else {
anyhow::bail!("expected thread/closed notification");
};
assert_eq!(payload.thread_id, thread_id);
assert!(
timeout(
std::time::Duration::from_millis(250),
mcp.read_stream_until_notification_message("thread/closed"),
)
.await
.is_err()
);
wait_for_responses_request_count_to_stabilize(
&server,
/*expected_count*/ 1,
/*expected_count*/ 2,
std::time::Duration::from_millis(200),
)
.await?;
@@ -228,7 +217,7 @@ async fn thread_unsubscribe_during_turn_interrupts_turn_and_emits_thread_closed(
}
#[tokio::test]
async fn thread_unsubscribe_clears_cached_status_before_resume() -> Result<()> {
async fn thread_unsubscribe_preserves_cached_status_before_idle_unload() -> Result<()> {
let server = responses::start_mock_server().await;
let _response_mock = responses::mount_sse_once(
&server,
@@ -291,11 +280,14 @@ async fn thread_unsubscribe_clears_cached_status_before_resume() -> Result<()> {
.await??;
let unsubscribe = to_response::<ThreadUnsubscribeResponse>(unsubscribe_resp)?;
assert_eq!(unsubscribe.status, ThreadUnsubscribeStatus::Unsubscribed);
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("thread/closed"),
)
.await??;
assert!(
timeout(
std::time::Duration::from_millis(250),
mcp.read_stream_until_notification_message("thread/closed"),
)
.await
.is_err()
);
let resume_id = mcp
.send_thread_resume_request(ThreadResumeParams {
@@ -309,13 +301,13 @@ async fn thread_unsubscribe_clears_cached_status_before_resume() -> Result<()> {
)
.await??;
let resume: ThreadResumeResponse = to_response::<ThreadResumeResponse>(resume_resp)?;
assert_eq!(resume.thread.status, ThreadStatus::Idle);
assert_eq!(resume.thread.status, ThreadStatus::SystemError);
Ok(())
}
#[tokio::test]
async fn thread_unsubscribe_reports_not_loaded_after_thread_is_unloaded() -> Result<()> {
async fn thread_unsubscribe_reports_not_subscribed_before_idle_unload() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
@@ -341,12 +333,6 @@ async fn thread_unsubscribe_reports_not_loaded_after_thread_is_unloaded() -> Res
ThreadUnsubscribeStatus::Unsubscribed
);
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("thread/closed"),
)
.await??;
let second_unsubscribe_id = mcp
.send_thread_unsubscribe_request(ThreadUnsubscribeParams { thread_id })
.await?;
@@ -358,7 +344,7 @@ async fn thread_unsubscribe_reports_not_loaded_after_thread_is_unloaded() -> Res
let second_unsubscribe = to_response::<ThreadUnsubscribeResponse>(second_unsubscribe_resp)?;
assert_eq!(
second_unsubscribe.status,
ThreadUnsubscribeStatus::NotLoaded
ThreadUnsubscribeStatus::NotSubscribed
);
Ok(())
@@ -377,28 +363,6 @@ async fn wait_for_command_execution_item_started(mcp: &mut McpProcess) -> Result
}
}
async fn wait_for_thread_status_not_loaded(
mcp: &mut McpProcess,
thread_id: &str,
) -> Result<ThreadStatusChangedNotification> {
loop {
let status_changed_notif: JSONRPCNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("thread/status/changed"),
)
.await??;
let status_changed_params = status_changed_notif
.params
.context("thread/status/changed params must be present")?;
let status_changed: ThreadStatusChangedNotification =
serde_json::from_value(status_changed_params)?;
if status_changed.thread_id == thread_id && status_changed.status == ThreadStatus::NotLoaded
{
return Ok(status_changed);
}
}
}
fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> {
let config_toml = codex_home.join("config.toml");
std::fs::write(
+17 -10
View File
@@ -135,6 +135,7 @@ pub async fn maybe_parse_apply_patch_verified(
argv: &[String],
cwd: &AbsolutePathBuf,
fs: &dyn ExecutorFileSystem,
sandbox: Option<&codex_exec_server::FileSystemSandboxContext>,
) -> MaybeApplyPatchVerified {
// Detect a raw patch body passed directly as the command or as the body of a shell
// script. In these cases, report an explicit error rather than applying the patch.
@@ -170,7 +171,7 @@ pub async fn maybe_parse_apply_patch_verified(
);
}
Hunk::DeleteFile { .. } => {
let content = match fs.read_file_text(&path).await {
let content = match fs.read_file_text(&path, sandbox).await {
Ok(content) => content,
Err(e) => {
return MaybeApplyPatchVerified::CorrectnessError(
@@ -192,7 +193,7 @@ pub async fn maybe_parse_apply_patch_verified(
let ApplyPatchFileUpdate {
unified_diff,
content: contents,
} = match unified_diff_from_chunks(&path, &chunks, fs).await {
} = match unified_diff_from_chunks(&path, &chunks, fs, sandbox).await {
Ok(diff) => diff,
Err(e) => {
return MaybeApplyPatchVerified::CorrectnessError(e);
@@ -467,7 +468,8 @@ mod tests {
maybe_parse_apply_patch_verified(
&args,
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
LOCAL_FS.as_ref()
LOCAL_FS.as_ref(),
/*sandbox*/ None,
)
.await,
MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation)
@@ -483,7 +485,8 @@ mod tests {
maybe_parse_apply_patch_verified(
&args,
&AbsolutePathBuf::from_absolute_path(dir.path()).unwrap(),
LOCAL_FS.as_ref()
LOCAL_FS.as_ref(),
/*sandbox*/ None,
)
.await,
MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation)
@@ -693,9 +696,10 @@ PATCH"#,
};
let path_abs = path.as_path().abs();
let diff = unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref())
.await
.unwrap();
let diff =
unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None)
.await
.unwrap();
let expected_diff = r#"@@ -2,2 +2,2 @@
bar
-baz
@@ -731,9 +735,10 @@ PATCH"#,
};
let path_abs = path.as_path().abs();
let diff = unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref())
.await
.unwrap();
let diff =
unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None)
.await
.unwrap();
let expected_diff = r#"@@ -3 +3,2 @@
baz
+quux
@@ -770,6 +775,7 @@ PATCH"#,
&argv,
&AbsolutePathBuf::from_absolute_path(session_dir.path()).unwrap(),
LOCAL_FS.as_ref(),
/*sandbox*/ None,
)
.await;
@@ -823,6 +829,7 @@ PATCH"#,
&argv,
&AbsolutePathBuf::from_absolute_path(session_dir.path()).unwrap(),
LOCAL_FS.as_ref(),
/*sandbox*/ None,
)
.await;
let action = match result {
+77 -37
View File
@@ -12,6 +12,7 @@ use anyhow::Context;
use anyhow::Result;
use codex_exec_server::CreateDirectoryOptions;
use codex_exec_server::ExecutorFileSystem;
use codex_exec_server::FileSystemSandboxContext;
use codex_exec_server::RemoveOptions;
use codex_utils_absolute_path::AbsolutePathBuf;
pub use parser::Hunk;
@@ -184,6 +185,7 @@ pub async fn apply_patch(
stdout: &mut impl std::io::Write,
stderr: &mut impl std::io::Write,
fs: &dyn ExecutorFileSystem,
sandbox: Option<&FileSystemSandboxContext>,
) -> Result<(), ApplyPatchError> {
let hunks = match parse_patch(patch) {
Ok(source) => source.hunks,
@@ -207,7 +209,7 @@ pub async fn apply_patch(
}
};
apply_hunks(&hunks, cwd, stdout, stderr, fs).await?;
apply_hunks(&hunks, cwd, stdout, stderr, fs, sandbox).await?;
Ok(())
}
@@ -219,9 +221,10 @@ pub async fn apply_hunks(
stdout: &mut impl std::io::Write,
stderr: &mut impl std::io::Write,
fs: &dyn ExecutorFileSystem,
sandbox: Option<&FileSystemSandboxContext>,
) -> Result<(), ApplyPatchError> {
// Delegate to a helper that applies each hunk to the filesystem.
match apply_hunks_to_files(hunks, cwd, fs).await {
match apply_hunks_to_files(hunks, cwd, fs, sandbox).await {
Ok(affected) => {
print_summary(&affected, stdout).map_err(ApplyPatchError::from)?;
Ok(())
@@ -257,6 +260,7 @@ async fn apply_hunks_to_files(
hunks: &[Hunk],
cwd: &AbsolutePathBuf,
fs: &dyn ExecutorFileSystem,
sandbox: Option<&FileSystemSandboxContext>,
) -> anyhow::Result<AffectedPaths> {
if hunks.is_empty() {
anyhow::bail!("No files were modified.");
@@ -271,23 +275,27 @@ async fn apply_hunks_to_files(
match hunk {
Hunk::AddFile { contents, .. } => {
if let Some(parent_abs) = path_abs.parent() {
fs.create_directory(&parent_abs, CreateDirectoryOptions { recursive: true })
.await
.with_context(|| {
format!(
"Failed to create parent directories for {}",
path_abs.display()
)
})?;
fs.create_directory(
&parent_abs,
CreateDirectoryOptions { recursive: true },
sandbox,
)
.await
.with_context(|| {
format!(
"Failed to create parent directories for {}",
path_abs.display()
)
})?;
}
fs.write_file(&path_abs, contents.clone().into_bytes())
fs.write_file(&path_abs, contents.clone().into_bytes(), sandbox)
.await
.with_context(|| format!("Failed to write file {}", path_abs.display()))?;
added.push(affected_path);
}
Hunk::DeleteFile { .. } => {
let result: io::Result<()> = async {
let metadata = fs.get_metadata(&path_abs).await?;
let metadata = fs.get_metadata(&path_abs, sandbox).await?;
if metadata.is_directory {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
@@ -300,6 +308,7 @@ async fn apply_hunks_to_files(
recursive: false,
force: false,
},
sandbox,
)
.await
}
@@ -311,13 +320,14 @@ async fn apply_hunks_to_files(
move_path, chunks, ..
} => {
let AppliedPatch { new_contents, .. } =
derive_new_contents_from_chunks(&path_abs, chunks, fs).await?;
derive_new_contents_from_chunks(&path_abs, chunks, fs, sandbox).await?;
if let Some(dest) = move_path {
let dest_abs = AbsolutePathBuf::resolve_path_against_base(dest, cwd);
if let Some(parent_abs) = dest_abs.parent() {
fs.create_directory(
&parent_abs,
CreateDirectoryOptions { recursive: true },
sandbox,
)
.await
.with_context(|| {
@@ -327,11 +337,11 @@ async fn apply_hunks_to_files(
)
})?;
}
fs.write_file(&dest_abs, new_contents.into_bytes())
fs.write_file(&dest_abs, new_contents.into_bytes(), sandbox)
.await
.with_context(|| format!("Failed to write file {}", dest_abs.display()))?;
let result: io::Result<()> = async {
let metadata = fs.get_metadata(&path_abs).await?;
let metadata = fs.get_metadata(&path_abs, sandbox).await?;
if metadata.is_directory {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
@@ -344,6 +354,7 @@ async fn apply_hunks_to_files(
recursive: false,
force: false,
},
sandbox,
)
.await
}
@@ -353,7 +364,7 @@ async fn apply_hunks_to_files(
})?;
modified.push(affected_path);
} else {
fs.write_file(&path_abs, new_contents.into_bytes())
fs.write_file(&path_abs, new_contents.into_bytes(), sandbox)
.await
.with_context(|| format!("Failed to write file {}", path_abs.display()))?;
modified.push(affected_path);
@@ -379,8 +390,9 @@ async fn derive_new_contents_from_chunks(
path_abs: &AbsolutePathBuf,
chunks: &[UpdateFileChunk],
fs: &dyn ExecutorFileSystem,
sandbox: Option<&FileSystemSandboxContext>,
) -> std::result::Result<AppliedPatch, ApplyPatchError> {
let original_contents = fs.read_file_text(path_abs).await.map_err(|err| {
let original_contents = fs.read_file_text(path_abs, sandbox).await.map_err(|err| {
ApplyPatchError::IoError(IoError {
context: format!("Failed to read file to update {}", path_abs.display()),
source: err,
@@ -540,8 +552,9 @@ pub async fn unified_diff_from_chunks(
path_abs: &AbsolutePathBuf,
chunks: &[UpdateFileChunk],
fs: &dyn ExecutorFileSystem,
sandbox: Option<&FileSystemSandboxContext>,
) -> std::result::Result<ApplyPatchFileUpdate, ApplyPatchError> {
unified_diff_from_chunks_with_context(path_abs, chunks, /*context*/ 1, fs).await
unified_diff_from_chunks_with_context(path_abs, chunks, /*context*/ 1, fs, sandbox).await
}
pub async fn unified_diff_from_chunks_with_context(
@@ -549,11 +562,12 @@ pub async fn unified_diff_from_chunks_with_context(
chunks: &[UpdateFileChunk],
context: usize,
fs: &dyn ExecutorFileSystem,
sandbox: Option<&FileSystemSandboxContext>,
) -> std::result::Result<ApplyPatchFileUpdate, ApplyPatchError> {
let AppliedPatch {
original_contents,
new_contents,
} = derive_new_contents_from_chunks(path_abs, chunks, fs).await?;
} = derive_new_contents_from_chunks(path_abs, chunks, fs, sandbox).await?;
let text_diff = TextDiff::from_lines(&original_contents, &new_contents);
let unified_diff = text_diff.unified_diff().context_radius(context).to_string();
Ok(ApplyPatchFileUpdate {
@@ -614,6 +628,7 @@ mod tests {
&mut stdout,
&mut stderr,
LOCAL_FS.as_ref(),
/*sandbox*/ None,
)
.await
.unwrap();
@@ -667,9 +682,16 @@ mod tests {
let mut stdout = Vec::new();
let mut stderr = Vec::new();
apply_patch(&patch, &cwd, &mut stdout, &mut stderr, LOCAL_FS.as_ref())
.await
.unwrap();
apply_patch(
&patch,
&cwd,
&mut stdout,
&mut stderr,
LOCAL_FS.as_ref(),
/*sandbox*/ None,
)
.await
.unwrap();
assert_eq!(fs::read_to_string(&relative_add).unwrap(), "relative add\n");
assert_eq!(fs::read_to_string(&absolute_add).unwrap(), "absolute add\n");
@@ -709,6 +731,7 @@ mod tests {
&mut stdout,
&mut stderr,
LOCAL_FS.as_ref(),
/*sandbox*/ None,
)
.await
.unwrap();
@@ -744,6 +767,7 @@ mod tests {
&mut stdout,
&mut stderr,
LOCAL_FS.as_ref(),
/*sandbox*/ None,
)
.await
.unwrap();
@@ -783,6 +807,7 @@ mod tests {
&mut stdout,
&mut stderr,
LOCAL_FS.as_ref(),
/*sandbox*/ None,
)
.await
.unwrap();
@@ -831,6 +856,7 @@ mod tests {
&mut stdout,
&mut stderr,
LOCAL_FS.as_ref(),
/*sandbox*/ None,
)
.await
.unwrap();
@@ -888,6 +914,7 @@ mod tests {
&mut stdout,
&mut stderr,
LOCAL_FS.as_ref(),
/*sandbox*/ None,
)
.await
.unwrap();
@@ -931,6 +958,7 @@ mod tests {
&mut stdout,
&mut stderr,
LOCAL_FS.as_ref(),
/*sandbox*/ None,
)
.await
.unwrap();
@@ -973,6 +1001,7 @@ mod tests {
&mut stdout,
&mut stderr,
LOCAL_FS.as_ref(),
/*sandbox*/ None,
)
.await
.unwrap();
@@ -1019,9 +1048,14 @@ mod tests {
_ => panic!("Expected a single UpdateFile hunk"),
};
let path_abs = path.as_path().abs();
let diff = unified_diff_from_chunks(&path_abs, update_file_chunks, LOCAL_FS.as_ref())
.await
.unwrap();
let diff = unified_diff_from_chunks(
&path_abs,
update_file_chunks,
LOCAL_FS.as_ref(),
/*sandbox*/ None,
)
.await
.unwrap();
let expected_diff = r#"@@ -1,4 +1,4 @@
foo
-bar
@@ -1061,9 +1095,10 @@ mod tests {
};
let path_abs = path.as_path().abs();
let diff = unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref())
.await
.unwrap();
let diff =
unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None)
.await
.unwrap();
let expected_diff = r#"@@ -1,2 +1,2 @@
-foo
+FOO
@@ -1101,9 +1136,10 @@ mod tests {
};
let path_abs = path.as_path().abs();
let diff = unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref())
.await
.unwrap();
let diff =
unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None)
.await
.unwrap();
let expected_diff = r#"@@ -2,2 +2,2 @@
bar
-baz
@@ -1139,9 +1175,10 @@ mod tests {
};
let path_abs = path.as_path().abs();
let diff = unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref())
.await
.unwrap();
let diff =
unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None)
.await
.unwrap();
let expected_diff = r#"@@ -3 +3,2 @@
baz
+quux
@@ -1188,9 +1225,10 @@ mod tests {
};
let path_abs = path.as_path().abs();
let diff = unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref())
.await
.unwrap();
let diff =
unified_diff_from_chunks(&path_abs, chunks, LOCAL_FS.as_ref(), /*sandbox*/ None)
.await
.unwrap();
let expected_diff = r#"@@ -1,6 +1,7 @@
a
@@ -1219,6 +1257,7 @@ mod tests {
&mut stdout,
&mut stderr,
LOCAL_FS.as_ref(),
/*sandbox*/ None,
)
.await
.unwrap();
@@ -1258,6 +1297,7 @@ g
&mut stdout,
&mut stderr,
LOCAL_FS.as_ref(),
/*sandbox*/ None,
)
.await;
assert!(result.is_err());
@@ -71,6 +71,7 @@ pub fn run_main() -> i32 {
&mut stdout,
&mut stderr,
codex_exec_server::LOCAL_FS.as_ref(),
/*sandbox*/ None,
)) {
Ok(()) => {
// Flush to ensure output ordering when used in pipelines.
+7 -2
View File
@@ -4,6 +4,7 @@ use std::path::Path;
use std::path::PathBuf;
use codex_apply_patch::CODEX_CORE_APPLY_PATCH_ARG1;
use codex_exec_server::CODEX_FS_HELPER_ARG1;
use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0;
use codex_utils_home_dir::find_codex_home;
#[cfg(unix)]
@@ -93,6 +94,9 @@ pub fn arg0_dispatch() -> Option<Arg0PathEntryGuard> {
}
let argv1 = args.next().unwrap_or_default();
if argv1 == CODEX_FS_HELPER_ARG1 {
codex_exec_server::run_fs_helper_main();
}
if argv1 == CODEX_CORE_APPLY_PATCH_ARG1 {
let patch_arg = args.next().and_then(|s| s.to_str().map(str::to_owned));
let exit_code = match patch_arg {
@@ -116,6 +120,7 @@ pub fn arg0_dispatch() -> Option<Arg0PathEntryGuard> {
&mut stdout,
&mut stderr,
codex_exec_server::LOCAL_FS.as_ref(),
/*sandbox*/ None,
)) {
Ok(()) => 0,
Err(_) => 1,
@@ -325,13 +330,13 @@ pub fn prepend_path_entry_for_codex_aliases() -> std::io::Result<Arg0PathEntryGu
#[cfg(windows)]
{
let batch_script = path.join(format!("{filename}.bat"));
let exe = exe.display();
std::fs::write(
&batch_script,
format!(
r#"@echo off
"{}" {CODEX_CORE_APPLY_PATCH_ARG1} %*
"{exe}" {CODEX_CORE_APPLY_PATCH_ARG1} %*
"#,
exe.display()
),
)?;
}
+2 -2
View File
@@ -29,7 +29,7 @@ const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60);
async fn apps_enabled(config: &Config) -> bool {
let auth_manager = AuthManager::shared(
config.codex_home.clone(),
config.codex_home.to_path_buf(),
/*enable_codex_api_key_env*/ false,
config.cli_auth_credentials_store_mode,
);
@@ -120,7 +120,7 @@ fn all_connectors_cache_key(config: &Config, token_data: &TokenData) -> AllConne
}
fn plugin_apps_for_config(config: &Config) -> Vec<codex_core::plugins::AppConnectorId> {
PluginsManager::new(config.codex_home.clone())
PluginsManager::new(config.codex_home.to_path_buf())
.plugins_for_config(config)
.effective_apps()
}
+3 -3
View File
@@ -141,7 +141,7 @@ pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) ->
let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone();
match login_with_chatgpt(
config.codex_home,
config.codex_home.to_path_buf(),
forced_chatgpt_workspace_id,
config.cli_auth_credentials_store_mode,
)
@@ -229,7 +229,7 @@ pub async fn run_login_with_device_code(
}
let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone();
let mut opts = ServerOptions::new(
config.codex_home,
config.codex_home.to_path_buf(),
client_id.unwrap_or(CLIENT_ID.to_string()),
forced_chatgpt_workspace_id,
config.cli_auth_credentials_store_mode,
@@ -268,7 +268,7 @@ pub async fn run_login_with_device_code_fallback_to_browser(
let forced_chatgpt_workspace_id = config.forced_chatgpt_workspace_id.clone();
let mut opts = ServerOptions::new(
config.codex_home,
config.codex_home.to_path_buf(),
client_id.unwrap_or(CLIENT_ID.to_string()),
forced_chatgpt_workspace_id,
config.cli_auth_credentials_store_mode,
+15 -4
View File
@@ -155,7 +155,7 @@ enum Subcommand {
#[clap(hide = true, name = "stdio-to-uds")]
StdioToUds(StdioToUdsCommand),
/// [EXPERIMENTAL] Run the standalone exec-server binary.
/// [EXPERIMENTAL] Run the standalone exec-server service.
ExecServer(ExecServerCommand),
/// Inspect feature flags.
@@ -1031,7 +1031,7 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
root_remote_auth_token_env.as_deref(),
"exec-server",
)?;
run_exec_server_command(cmd).await?;
run_exec_server_command(cmd, &arg0_paths).await?;
}
Some(Subcommand::Features(FeaturesCli { sub })) => match sub {
FeaturesSubcommand::List => {
@@ -1103,8 +1103,19 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
Ok(())
}
async fn run_exec_server_command(cmd: ExecServerCommand) -> anyhow::Result<()> {
codex_exec_server::run_main_with_listen_url(&cmd.listen)
async fn run_exec_server_command(
cmd: ExecServerCommand,
arg0_paths: &Arg0DispatchPaths,
) -> anyhow::Result<()> {
let codex_self_exe = arg0_paths
.codex_self_exe
.clone()
.ok_or_else(|| anyhow::anyhow!("Codex executable path is not configured"))?;
let runtime_paths = codex_exec_server::ExecServerRuntimePaths::new(
codex_self_exe,
arg0_paths.codex_linux_sandbox_exe.clone(),
)?;
codex_exec_server::run_main(&cmd.listen, runtime_paths)
.await
.map_err(anyhow::Error::from_boxed)
}
+23 -451
View File
@@ -1,22 +1,10 @@
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use clap::Parser;
use codex_config::MarketplaceConfigUpdate;
use codex_config::record_user_marketplace;
use codex_core::config::find_codex_home;
use codex_core::plugins::OPENAI_CURATED_MARKETPLACE_NAME;
use codex_core::plugins::marketplace_install_root;
use codex_core::plugins::validate_marketplace_root;
use codex_core::plugins::validate_plugin_segment;
use codex_core::plugins::MarketplaceAddRequest;
use codex_core::plugins::add_marketplace;
use codex_utils_cli::CliConfigOverrides;
use std::fs;
use std::path::Path;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
mod metadata;
mod ops;
#[derive(Debug, Parser)]
pub struct MarketplaceCli {
@@ -51,14 +39,6 @@ struct AddMarketplaceArgs {
sparse_paths: Vec<String>,
}
#[derive(Debug, PartialEq, Eq)]
pub(super) enum MarketplaceSource {
Git {
url: String,
ref_name: Option<String>,
},
}
impl MarketplaceCli {
pub async fn run(self) -> Result<()> {
let MarketplaceCli {
@@ -87,449 +67,41 @@ async fn run_add(args: AddMarketplaceArgs) -> Result<()> {
sparse_paths,
} = args;
let source = parse_marketplace_source(&source, ref_name)?;
let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?;
let install_root = marketplace_install_root(&codex_home);
fs::create_dir_all(&install_root).with_context(|| {
format!(
"failed to create marketplace install directory {}",
install_root.display()
)
})?;
let install_metadata =
metadata::MarketplaceInstallMetadata::from_source(&source, &sparse_paths);
if let Some(existing_root) = metadata::installed_marketplace_root_for_source(
&codex_home,
&install_root,
&install_metadata,
)? {
let marketplace_name = validate_marketplace_root(&existing_root).with_context(|| {
format!(
"failed to validate installed marketplace at {}",
existing_root.display()
)
})?;
record_added_marketplace(&codex_home, &marketplace_name, &install_metadata)?;
let outcome = add_marketplace(
codex_home.to_path_buf(),
MarketplaceAddRequest {
source,
ref_name,
sparse_paths,
},
)
.await?;
if outcome.already_added {
println!(
"Marketplace `{marketplace_name}` is already added from {}.",
source.display()
"Marketplace `{}` is already added from {}.",
outcome.marketplace_name, outcome.source_display
);
println!("Installed marketplace root: {}", existing_root.display());
return Ok(());
}
let staging_root = ops::marketplace_staging_root(&install_root);
fs::create_dir_all(&staging_root).with_context(|| {
format!(
"failed to create marketplace staging directory {}",
staging_root.display()
)
})?;
let staged_dir = tempfile::Builder::new()
.prefix("marketplace-add-")
.tempdir_in(&staging_root)
.with_context(|| {
format!(
"failed to create temporary marketplace directory in {}",
staging_root.display()
)
})?;
let staged_root = staged_dir.path().to_path_buf();
let MarketplaceSource::Git { url, ref_name } = &source;
ops::clone_git_source(url, ref_name.as_deref(), &sparse_paths, &staged_root)?;
let marketplace_name = validate_marketplace_source_root(&staged_root)
.with_context(|| format!("failed to validate marketplace from {}", source.display()))?;
if marketplace_name == OPENAI_CURATED_MARKETPLACE_NAME {
bail!(
"marketplace `{OPENAI_CURATED_MARKETPLACE_NAME}` is reserved and cannot be added from {}",
source.display()
);
}
let destination = install_root.join(safe_marketplace_dir_name(&marketplace_name)?);
ensure_marketplace_destination_is_inside_install_root(&install_root, &destination)?;
if destination.exists() {
bail!(
"marketplace `{marketplace_name}` is already added from a different source; remove it before adding {}",
source.display()
);
}
ops::replace_marketplace_root(&staged_root, &destination)
.with_context(|| format!("failed to install marketplace at {}", destination.display()))?;
if let Err(err) = record_added_marketplace(&codex_home, &marketplace_name, &install_metadata) {
if let Err(rollback_err) = fs::rename(&destination, &staged_root) {
bail!(
"{err}; additionally failed to roll back installed marketplace at {}: {rollback_err}",
destination.display()
);
}
return Err(err);
}
println!(
"Added marketplace `{marketplace_name}` from {}.",
source.display()
);
println!("Installed marketplace root: {}", destination.display());
Ok(())
}
fn record_added_marketplace(
codex_home: &Path,
marketplace_name: &str,
install_metadata: &metadata::MarketplaceInstallMetadata,
) -> Result<()> {
let source = install_metadata.config_source();
let last_updated = utc_timestamp_now()?;
let update = MarketplaceConfigUpdate {
last_updated: &last_updated,
source_type: install_metadata.config_source_type(),
source: &source,
ref_name: install_metadata.ref_name(),
sparse_paths: install_metadata.sparse_paths(),
};
record_user_marketplace(codex_home, marketplace_name, &update).with_context(|| {
format!("failed to add marketplace `{marketplace_name}` to user config.toml")
})?;
Ok(())
}
fn validate_marketplace_source_root(root: &Path) -> Result<String> {
let marketplace_name = validate_marketplace_root(root)?;
validate_plugin_segment(&marketplace_name, "marketplace name").map_err(anyhow::Error::msg)?;
Ok(marketplace_name)
}
fn parse_marketplace_source(
source: &str,
explicit_ref: Option<String>,
) -> Result<MarketplaceSource> {
let source = source.trim();
if source.is_empty() {
bail!("marketplace source must not be empty");
}
let (base_source, parsed_ref) = split_source_ref(source);
let ref_name = explicit_ref.or(parsed_ref);
if looks_like_local_path(&base_source) {
bail!(
"local marketplace sources are not supported yet; use an HTTP(S) Git URL, SSH Git URL, or GitHub owner/repo"
);
}
if is_ssh_git_url(&base_source) || is_git_url(&base_source) {
let url = normalize_git_url(&base_source);
return Ok(MarketplaceSource::Git { url, ref_name });
}
if looks_like_github_shorthand(&base_source) {
let url = format!("https://github.com/{base_source}.git");
return Ok(MarketplaceSource::Git { url, ref_name });
}
bail!("invalid marketplace source format: {source}");
}
fn split_source_ref(source: &str) -> (String, Option<String>) {
if let Some((base, ref_name)) = source.rsplit_once('#') {
return (base.to_string(), non_empty_ref(ref_name));
}
if !source.contains("://")
&& !is_ssh_git_url(source)
&& let Some((base, ref_name)) = source.rsplit_once('@')
{
return (base.to_string(), non_empty_ref(ref_name));
}
(source.to_string(), None)
}
fn non_empty_ref(ref_name: &str) -> Option<String> {
let ref_name = ref_name.trim();
(!ref_name.is_empty()).then(|| ref_name.to_string())
}
fn normalize_git_url(url: &str) -> String {
let url = url.trim_end_matches('/');
if url.starts_with("https://github.com/") && !url.ends_with(".git") {
format!("{url}.git")
} else {
url.to_string()
}
}
fn looks_like_local_path(source: &str) -> bool {
source.starts_with("./")
|| source.starts_with("../")
|| source.starts_with('/')
|| source.starts_with("~/")
|| source == "."
|| source == ".."
}
fn is_ssh_git_url(source: &str) -> bool {
source.starts_with("ssh://") || source.starts_with("git@") && source.contains(':')
}
fn is_git_url(source: &str) -> bool {
source.starts_with("http://") || source.starts_with("https://")
}
fn looks_like_github_shorthand(source: &str) -> bool {
let mut segments = source.split('/');
let owner = segments.next();
let repo = segments.next();
let extra = segments.next();
owner.is_some_and(is_github_shorthand_segment)
&& repo.is_some_and(is_github_shorthand_segment)
&& extra.is_none()
}
fn is_github_shorthand_segment(segment: &str) -> bool {
!segment.is_empty()
&& segment
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
}
fn safe_marketplace_dir_name(marketplace_name: &str) -> Result<String> {
let safe = marketplace_name
.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
ch
} else {
'-'
}
})
.collect::<String>();
let safe = safe.trim_matches('.').to_string();
if safe.is_empty() || safe == ".." {
bail!("marketplace name `{marketplace_name}` cannot be used as an install directory");
}
Ok(safe)
}
fn ensure_marketplace_destination_is_inside_install_root(
install_root: &Path,
destination: &Path,
) -> Result<()> {
let install_root = install_root.canonicalize().with_context(|| {
format!(
"failed to resolve marketplace install root {}",
install_root.display()
)
})?;
let destination_parent = destination
.parent()
.context("marketplace destination has no parent")?
.canonicalize()
.with_context(|| {
format!(
"failed to resolve marketplace destination parent {}",
destination.display()
)
})?;
if !destination_parent.starts_with(&install_root) {
bail!(
"marketplace destination {} is outside install root {}",
destination.display(),
install_root.display()
println!(
"Added marketplace `{}` from {}.",
outcome.marketplace_name, outcome.source_display
);
}
println!(
"Installed marketplace root: {}",
outcome.installed_root.as_path().display()
);
Ok(())
}
fn utc_timestamp_now() -> Result<String> {
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
.context("system clock is before Unix epoch")?;
Ok(format_utc_timestamp(duration.as_secs() as i64))
}
fn format_utc_timestamp(seconds_since_epoch: i64) -> String {
const SECONDS_PER_DAY: i64 = 86_400;
let days = seconds_since_epoch.div_euclid(SECONDS_PER_DAY);
let seconds_of_day = seconds_since_epoch.rem_euclid(SECONDS_PER_DAY);
let (year, month, day) = civil_from_days(days);
let hour = seconds_of_day / 3_600;
let minute = (seconds_of_day % 3_600) / 60;
let second = seconds_of_day % 60;
format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
}
fn civil_from_days(days_since_epoch: i64) -> (i64, i64, i64) {
let days = days_since_epoch + 719_468;
let era = if days >= 0 { days } else { days - 146_096 } / 146_097;
let day_of_era = days - era * 146_097;
let year_of_era =
(day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
let mut year = year_of_era + era * 400;
let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
let month_prime = (5 * day_of_year + 2) / 153;
let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
let month = month_prime + if month_prime < 10 { 3 } else { -9 };
year += if month <= 2 { 1 } else { 0 };
(year, month, day)
}
impl MarketplaceSource {
fn display(&self) -> String {
match self {
Self::Git { url, ref_name } => {
if let Some(ref_name) = ref_name {
format!("{url}#{ref_name}")
} else {
url.clone()
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn github_shorthand_parses_ref_suffix() {
assert_eq!(
parse_marketplace_source("owner/repo@main", /*explicit_ref*/ None).unwrap(),
MarketplaceSource::Git {
url: "https://github.com/owner/repo.git".to_string(),
ref_name: Some("main".to_string()),
}
);
}
#[test]
fn git_url_parses_fragment_ref() {
assert_eq!(
parse_marketplace_source(
"https://example.com/team/repo.git#v1",
/*explicit_ref*/ None,
)
.unwrap(),
MarketplaceSource::Git {
url: "https://example.com/team/repo.git".to_string(),
ref_name: Some("v1".to_string()),
}
);
}
#[test]
fn explicit_ref_overrides_source_ref() {
assert_eq!(
parse_marketplace_source(
"owner/repo@main",
/*explicit_ref*/ Some("release".to_string()),
)
.unwrap(),
MarketplaceSource::Git {
url: "https://github.com/owner/repo.git".to_string(),
ref_name: Some("release".to_string()),
}
);
}
#[test]
fn github_shorthand_and_git_url_normalize_to_same_source() {
let shorthand = parse_marketplace_source("owner/repo", /*explicit_ref*/ None).unwrap();
let git_url = parse_marketplace_source(
"https://github.com/owner/repo.git",
/*explicit_ref*/ None,
)
.unwrap();
assert_eq!(shorthand, git_url);
assert_eq!(
shorthand,
MarketplaceSource::Git {
url: "https://github.com/owner/repo.git".to_string(),
ref_name: None,
}
);
}
#[test]
fn github_url_with_trailing_slash_normalizes_without_extra_path_segment() {
assert_eq!(
parse_marketplace_source("https://github.com/owner/repo/", /*explicit_ref*/ None)
.unwrap(),
MarketplaceSource::Git {
url: "https://github.com/owner/repo.git".to_string(),
ref_name: None,
}
);
}
#[test]
fn non_github_https_source_parses_as_git_url() {
assert_eq!(
parse_marketplace_source("https://gitlab.com/owner/repo", /*explicit_ref*/ None)
.unwrap(),
MarketplaceSource::Git {
url: "https://gitlab.com/owner/repo".to_string(),
ref_name: None,
}
);
}
#[test]
fn file_url_source_is_rejected() {
let err =
parse_marketplace_source("file:///tmp/marketplace.git", /*explicit_ref*/ None)
.unwrap_err();
assert!(
err.to_string()
.contains("invalid marketplace source format"),
"unexpected error: {err}"
);
}
#[test]
fn local_path_source_is_rejected() {
let err = parse_marketplace_source("./marketplace", /*explicit_ref*/ None).unwrap_err();
assert!(
err.to_string()
.contains("local marketplace sources are not supported yet"),
"unexpected error: {err}"
);
}
#[test]
fn ssh_url_parses_as_git_url() {
assert_eq!(
parse_marketplace_source(
"ssh://git@github.com/owner/repo.git#main",
/*explicit_ref*/ None,
)
.unwrap(),
MarketplaceSource::Git {
url: "ssh://git@github.com/owner/repo.git".to_string(),
ref_name: Some("main".to_string()),
}
);
}
#[test]
fn utc_timestamp_formats_unix_epoch_as_rfc3339_utc() {
assert_eq!(
format_utc_timestamp(/*seconds_since_epoch*/ 0),
"1970-01-01T00:00:00Z"
);
assert_eq!(
format_utc_timestamp(/*seconds_since_epoch*/ 1_775_779_200),
"2026-04-10T00:00:00Z"
);
}
#[test]
fn sparse_paths_parse_before_or_after_source() {
let sparse_before_source =
@@ -1,150 +0,0 @@
use super::MarketplaceSource;
use anyhow::Context;
use anyhow::Result;
use codex_config::CONFIG_TOML_FILE;
use codex_core::plugins::validate_marketplace_root;
use std::io::ErrorKind;
use std::path::Path;
use std::path::PathBuf;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct MarketplaceInstallMetadata {
source: InstalledMarketplaceSource,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum InstalledMarketplaceSource {
Git {
url: String,
ref_name: Option<String>,
sparse_paths: Vec<String>,
},
}
pub(super) fn installed_marketplace_root_for_source(
codex_home: &Path,
install_root: &Path,
install_metadata: &MarketplaceInstallMetadata,
) -> Result<Option<PathBuf>> {
let config_path = codex_home.join(CONFIG_TOML_FILE);
let config = match std::fs::read_to_string(&config_path) {
Ok(config) => config,
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None),
Err(err) => {
return Err(err)
.with_context(|| format!("failed to read user config {}", config_path.display()));
}
};
let config: toml::Value = toml::from_str(&config)
.with_context(|| format!("failed to parse user config {}", config_path.display()))?;
let Some(marketplaces) = config.get("marketplaces").and_then(toml::Value::as_table) else {
return Ok(None);
};
for (marketplace_name, marketplace) in marketplaces {
if !install_metadata.matches_config(marketplace) {
continue;
}
let root = install_root.join(marketplace_name);
if validate_marketplace_root(&root).is_ok() {
return Ok(Some(root));
}
}
Ok(None)
}
impl MarketplaceInstallMetadata {
pub(super) fn from_source(source: &MarketplaceSource, sparse_paths: &[String]) -> Self {
let source = match source {
MarketplaceSource::Git { url, ref_name } => InstalledMarketplaceSource::Git {
url: url.clone(),
ref_name: ref_name.clone(),
sparse_paths: sparse_paths.to_vec(),
},
};
Self { source }
}
pub(super) fn config_source_type(&self) -> &'static str {
match &self.source {
InstalledMarketplaceSource::Git { .. } => "git",
}
}
pub(super) fn config_source(&self) -> String {
match &self.source {
InstalledMarketplaceSource::Git { url, .. } => url.clone(),
}
}
pub(super) fn ref_name(&self) -> Option<&str> {
match &self.source {
InstalledMarketplaceSource::Git { ref_name, .. } => ref_name.as_deref(),
}
}
pub(super) fn sparse_paths(&self) -> &[String] {
match &self.source {
InstalledMarketplaceSource::Git { sparse_paths, .. } => sparse_paths,
}
}
fn matches_config(&self, marketplace: &toml::Value) -> bool {
marketplace.get("source_type").and_then(toml::Value::as_str)
== Some(self.config_source_type())
&& marketplace.get("source").and_then(toml::Value::as_str)
== Some(self.config_source().as_str())
&& marketplace.get("ref").and_then(toml::Value::as_str) == self.ref_name()
&& config_sparse_paths(marketplace) == self.sparse_paths()
}
}
fn config_sparse_paths(marketplace: &toml::Value) -> Vec<String> {
marketplace
.get("sparse_paths")
.and_then(toml::Value::as_array)
.map(|paths| {
paths
.iter()
.filter_map(toml::Value::as_str)
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
#[test]
fn installed_marketplace_root_for_source_propagates_config_read_errors() -> Result<()> {
let codex_home = TempDir::new()?;
let config_path = codex_home.path().join(CONFIG_TOML_FILE);
std::fs::create_dir(&config_path)?;
let install_root = codex_home.path().join("marketplaces");
let source = MarketplaceSource::Git {
url: "https://github.com/owner/repo.git".to_string(),
ref_name: None,
};
let install_metadata = MarketplaceInstallMetadata::from_source(&source, &[]);
let err = installed_marketplace_root_for_source(
codex_home.path(),
&install_root,
&install_metadata,
)
.unwrap_err();
assert_eq!(
err.to_string(),
format!("failed to read user config {}", config_path.display())
);
Ok(())
}
}
-118
View File
@@ -1,118 +0,0 @@
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
pub(super) fn clone_git_source(
url: &str,
ref_name: Option<&str>,
sparse_paths: &[String],
destination: &Path,
) -> Result<()> {
let destination = destination.to_string_lossy().to_string();
if sparse_paths.is_empty() {
run_git(&["clone", url, destination.as_str()], /*cwd*/ None)?;
if let Some(ref_name) = ref_name {
run_git(&["checkout", ref_name], Some(Path::new(&destination)))?;
}
return Ok(());
}
run_git(
&[
"clone",
"--filter=blob:none",
"--no-checkout",
url,
destination.as_str(),
],
/*cwd*/ None,
)?;
let mut sparse_args = vec!["sparse-checkout", "set"];
sparse_args.extend(sparse_paths.iter().map(String::as_str));
let destination = Path::new(&destination);
run_git(&sparse_args, Some(destination))?;
run_git(&["checkout", ref_name.unwrap_or("HEAD")], Some(destination))?;
Ok(())
}
fn run_git(args: &[&str], cwd: Option<&Path>) -> Result<()> {
let mut command = Command::new("git");
command.args(args);
command.env("GIT_TERMINAL_PROMPT", "0");
if let Some(cwd) = cwd {
command.current_dir(cwd);
}
let output = command
.output()
.with_context(|| format!("failed to run git {}", args.join(" ")))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
bail!(
"git {} failed with status {}\nstdout:\n{}\nstderr:\n{}",
args.join(" "),
output.status,
stdout.trim(),
stderr.trim()
);
}
pub(super) fn replace_marketplace_root(staged_root: &Path, destination: &Path) -> Result<()> {
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)?;
}
if destination.exists() {
bail!(
"marketplace destination already exists: {}",
destination.display()
);
}
fs::rename(staged_root, destination).map_err(Into::into)
}
pub(super) fn marketplace_staging_root(install_root: &Path) -> PathBuf {
install_root.join(".staging")
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
#[test]
fn replace_marketplace_root_rejects_existing_destination() {
let temp_dir = TempDir::new().unwrap();
let staged_root = temp_dir.path().join("staged");
let destination = temp_dir.path().join("destination");
fs::create_dir_all(&staged_root).unwrap();
fs::write(staged_root.join("marker.txt"), "staged").unwrap();
fs::create_dir_all(&destination).unwrap();
fs::write(destination.join("marker.txt"), "installed").unwrap();
let err = replace_marketplace_root(&staged_root, &destination).unwrap_err();
assert!(
err.to_string()
.contains("marketplace destination already exists"),
"unexpected error: {err}"
);
assert_eq!(
fs::read_to_string(staged_root.join("marker.txt")).unwrap(),
"staged"
);
assert_eq!(
fs::read_to_string(destination.join("marker.txt")).unwrap(),
"installed"
);
}
}
+13 -4
View File
@@ -299,6 +299,7 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re
transport: transport.clone(),
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
@@ -390,7 +391,9 @@ async fn run_login(config_overrides: &CliConfigOverrides, login_args: LoginArgs)
let config = Config::load_with_cli_overrides(overrides)
.await
.context("failed to load configuration")?;
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(config.codex_home.clone())));
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(
config.codex_home.to_path_buf(),
)));
let mcp_servers = mcp_manager.effective_servers(&config, /*auth*/ None);
let LoginArgs { name, scopes } = login_args;
@@ -441,7 +444,9 @@ async fn run_logout(config_overrides: &CliConfigOverrides, logout_args: LogoutAr
let config = Config::load_with_cli_overrides(overrides)
.await
.context("failed to load configuration")?;
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(config.codex_home.clone())));
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(
config.codex_home.to_path_buf(),
)));
let mcp_servers = mcp_manager.effective_servers(&config, /*auth*/ None);
let LogoutArgs { name } = logout_args;
@@ -471,7 +476,9 @@ async fn run_list(config_overrides: &CliConfigOverrides, list_args: ListArgs) ->
let config = Config::load_with_cli_overrides(overrides)
.await
.context("failed to load configuration")?;
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(config.codex_home.clone())));
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(
config.codex_home.to_path_buf(),
)));
let mcp_servers = mcp_manager.effective_servers(&config, /*auth*/ None);
let mut entries: Vec<_> = mcp_servers.iter().collect();
@@ -720,7 +727,9 @@ async fn run_get(config_overrides: &CliConfigOverrides, get_args: GetArgs) -> Re
let config = Config::load_with_cli_overrides(overrides)
.await
.context("failed to load configuration")?;
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(config.codex_home.clone())));
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(
config.codex_home.to_path_buf(),
)));
let mcp_servers = mcp_manager.effective_servers(&config, /*auth*/ None);
let Some(server) = mcp_servers.get(&get_args.name) else {
+1 -1
View File
@@ -63,7 +63,7 @@ pub async fn load_auth_manager() -> Option<AuthManager> {
// TODO: pass in cli overrides once cloud tasks properly support them.
let config = Config::load_with_cli_overrides(Vec::new()).await.ok()?;
Some(AuthManager::new(
config.codex_home,
config.codex_home.to_path_buf(),
/*enable_codex_api_key_env*/ false,
config.cli_auth_credentials_store_mode,
))
@@ -28,7 +28,7 @@ use serde_json::json;
const REALTIME_V2_OUTPUT_MODALITY_AUDIO: &str = "audio";
const REALTIME_V2_TOOL_CHOICE: &str = "auto";
const REALTIME_V2_BACKGROUND_AGENT_TOOL_NAME: &str = "background_agent";
const REALTIME_V2_BACKGROUND_AGENT_TOOL_DESCRIPTION: &str = "Send a user request to the background agent. Use this as the default action. If the background agent is idle, this starts a new task and returns the final result to the user. If the background agent is already working on a task, this sends the request as guidance to steer that previous task. If the user asks to do something next, later, after this, or once current work finishes, call this tool so the work is actually queued instead of merely promising to do it later.";
const REALTIME_V2_BACKGROUND_AGENT_TOOL_DESCRIPTION: &str = "Send a user request to the background agent. Use this as the default action. Do not rephrase the user's ask or rewrite it in your own words; pass along the user's own words. If the background agent is idle, this starts a new task and returns the final result to the user. If the background agent is already working on a task, this sends the request as guidance to steer that previous task. If the user asks to do something next, later, after this, or once current work finishes, call this tool so the work is actually queued instead of merely promising to do it later.";
pub(super) fn conversation_item_create_message(text: String) -> RealtimeOutboundMessage {
RealtimeOutboundMessage::ConversationItemCreate {
+1
View File
@@ -35,6 +35,7 @@ tracing = { workspace = true }
url = { workspace = true }
[dev-dependencies]
codex-utils-absolute-path = { workspace = true }
pretty_assertions = { workspace = true }
rmcp = { workspace = true, default-features = false, features = ["base64", "macros", "schemars", "server"] }
tempfile = { workspace = true }
+1
View File
@@ -272,6 +272,7 @@ fn codex_apps_mcp_server_config(config: &McpConfig, auth: Option<&CodexAuth>) ->
},
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: Some(Duration::from_secs(30)),
tool_timeout_sec: None,
+2
View File
@@ -195,6 +195,7 @@ async fn effective_mcp_servers_preserve_user_servers_and_add_codex_apps() {
},
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
@@ -216,6 +217,7 @@ async fn effective_mcp_servers_preserve_user_servers_and_add_codex_apps() {
},
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
@@ -121,6 +121,7 @@ fn mcp_dependency_to_server_config(
},
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
@@ -147,6 +148,7 @@ fn mcp_dependency_to_server_config(
},
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
@@ -2,8 +2,9 @@ use super::*;
use codex_protocol::protocol::SkillDependencies;
use codex_protocol::protocol::SkillMetadata;
use codex_protocol::protocol::SkillScope;
use codex_utils_absolute_path::test_support::PathBufExt as _;
use codex_utils_absolute_path::test_support::test_path_buf;
use pretty_assertions::assert_eq;
use std::path::PathBuf;
fn skill_with_tools(tools: Vec<SkillToolDependency>) -> SkillMetadata {
SkillMetadata {
@@ -12,7 +13,7 @@ fn skill_with_tools(tools: Vec<SkillToolDependency>) -> SkillMetadata {
short_description: None,
interface: None,
dependencies: Some(SkillDependencies { tools }),
path: PathBuf::from("skill"),
path: test_path_buf("/tmp/skill").abs(),
scope: SkillScope::User,
enabled: true,
}
@@ -40,6 +41,7 @@ fn collect_missing_respects_canonical_installed_key() {
},
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
@@ -90,6 +92,7 @@ fn collect_missing_dedupes_by_canonical_key_but_preserves_original_name() {
},
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
@@ -1191,11 +1191,14 @@ impl McpConnectionManager {
.with_context(|| format!("resources/read failed for `{server}` ({uri})"))
}
pub async fn parse_tool_name(&self, tool_name: &str) -> Option<(String, String)> {
self.list_all_tools()
.await
.get(tool_name)
.map(|tool| (tool.server_name.clone(), tool.tool.name.to_string()))
pub async fn resolve_tool_info(&self, name: &str, namespace: Option<&str>) -> Option<ToolInfo> {
let qualified_name = match namespace {
Some(namespace) if name.starts_with(namespace) => name.to_string(),
Some(namespace) => format!("{namespace}{name}"),
None => name.to_string(),
};
self.list_all_tools().await.get(&qualified_name).cloned()
}
pub async fn notify_sandbox_state_change(&self, sandbox_state: &SandboxState) -> Result<()> {
@@ -757,6 +757,7 @@ fn mcp_init_error_display_prompts_for_github_pat() {
},
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
@@ -806,6 +807,7 @@ fn mcp_init_error_display_reports_generic_errors() {
},
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
+1
View File
@@ -14,6 +14,7 @@ pub mod profile_toml;
mod project_root_markers;
mod requirements_exec_policy;
pub mod schema;
pub mod shell_environment;
mod skills_config;
mod state;
pub mod types;
+3
View File
@@ -177,6 +177,9 @@ fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem {
if config.required {
entry["required"] = value(true);
}
if config.supports_parallel_tool_calls {
entry["supports_parallel_tool_calls"] = value(true);
}
if let Some(timeout) = config.startup_timeout_sec {
entry["startup_timeout_sec"] = value(timeout.as_secs_f64());
}
+2
View File
@@ -24,6 +24,7 @@ async fn replace_mcp_servers_serializes_per_tool_approval_overrides() -> anyhow:
},
enabled: true,
required: false,
supports_parallel_tool_calls: true,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
@@ -59,6 +60,7 @@ async fn replace_mcp_servers_serializes_per_tool_approval_overrides() -> anyhow:
serialized,
r#"[mcp_servers.docs]
command = "docs-server"
supports_parallel_tool_calls = true
[mcp_servers.docs.tools]
+8
View File
@@ -69,6 +69,10 @@ pub struct McpServerConfig {
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub required: bool,
/// When `true`, every tool from this server is advertised as safe for parallel tool calls.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub supports_parallel_tool_calls: bool,
/// Reason this server was disabled after applying requirements.
#[serde(skip)]
pub disabled_reason: Option<McpServerDisabledReason>,
@@ -146,6 +150,8 @@ pub struct RawMcpServerConfig {
#[serde(default)]
pub required: Option<bool>,
#[serde(default)]
pub supports_parallel_tool_calls: Option<bool>,
#[serde(default)]
pub enabled_tools: Option<Vec<String>>,
#[serde(default)]
pub disabled_tools: Option<Vec<String>>,
@@ -180,6 +186,7 @@ impl TryFrom<RawMcpServerConfig> for McpServerConfig {
tool_timeout_sec,
enabled,
required,
supports_parallel_tool_calls,
enabled_tools,
disabled_tools,
scopes,
@@ -243,6 +250,7 @@ impl TryFrom<RawMcpServerConfig> for McpServerConfig {
tool_timeout_sec,
enabled: enabled.unwrap_or_else(default_enabled),
required: required.unwrap_or_default(),
supports_parallel_tool_calls: supports_parallel_tool_calls.unwrap_or_default(),
disabled_reason: None,
enabled_tools,
disabled_tools,
+33
View File
@@ -245,6 +245,38 @@ fn deserialize_server_config_with_tool_filters() {
assert_eq!(cfg.disabled_tools, Some(vec!["blocked".to_string()]));
}
#[test]
fn deserialize_server_config_with_parallel_tool_calls() {
let cfg: McpServerConfig = toml::from_str(
r#"
command = "echo"
supports_parallel_tool_calls = true
"#,
)
.expect("should deserialize supports_parallel_tool_calls");
assert!(cfg.supports_parallel_tool_calls);
}
#[test]
fn serialize_round_trips_server_config_with_parallel_tool_calls() {
let cfg: McpServerConfig = toml::from_str(
r#"
command = "echo"
supports_parallel_tool_calls = true
tool_timeout_sec = 2.0
"#,
)
.expect("should deserialize supports_parallel_tool_calls");
let serialized = toml::to_string(&cfg).expect("should serialize MCP config");
assert!(serialized.contains("supports_parallel_tool_calls = true"));
let round_tripped: McpServerConfig =
toml::from_str(&serialized).expect("should deserialize serialized MCP config");
assert_eq!(round_tripped, cfg);
}
#[test]
fn deserialize_ignores_unknown_server_fields() {
let cfg: McpServerConfig = toml::from_str(
@@ -267,6 +299,7 @@ fn deserialize_ignores_unknown_server_fields() {
},
enabled: true,
required: false,
supports_parallel_tool_calls: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
+123
View File
@@ -0,0 +1,123 @@
use crate::types::EnvironmentVariablePattern;
use crate::types::ShellEnvironmentPolicy;
use crate::types::ShellEnvironmentPolicyInherit;
use std::collections::HashMap;
use std::collections::HashSet;
pub const CODEX_THREAD_ID_ENV_VAR: &str = "CODEX_THREAD_ID";
/// Construct a shell environment from the supplied process environment and
/// shell-environment policy.
pub fn create_env(
policy: &ShellEnvironmentPolicy,
thread_id: Option<&str>,
) -> HashMap<String, String> {
create_env_from_vars(std::env::vars(), policy, thread_id)
}
pub fn create_env_from_vars<I>(
vars: I,
policy: &ShellEnvironmentPolicy,
thread_id: Option<&str>,
) -> HashMap<String, String>
where
I: IntoIterator<Item = (String, String)>,
{
let mut env_map = populate_env(vars, policy, thread_id);
if cfg!(target_os = "windows") {
// This is a workaround to address the failures we are seeing in the
// following tests when run via Bazel on Windows:
//
// ```
// suite::shell_command::unicode_output::with_login
// suite::shell_command::unicode_output::without_login
// ```
//
// Currently, we can only reproduce these failures in CI, which makes
// iteration times long, so we include this quick fix for now to unblock
// getting the Windows Bazel build running.
if !env_map.keys().any(|k| k.eq_ignore_ascii_case("PATHEXT")) {
env_map.insert("PATHEXT".to_string(), ".COM;.EXE;.BAT;.CMD".to_string());
}
}
env_map
}
pub fn populate_env<I>(
vars: I,
policy: &ShellEnvironmentPolicy,
thread_id: Option<&str>,
) -> HashMap<String, String>
where
I: IntoIterator<Item = (String, String)>,
{
// Step 1 - determine the starting set of variables based on the
// `inherit` strategy.
let mut env_map: HashMap<String, String> = match policy.inherit {
ShellEnvironmentPolicyInherit::All => vars.into_iter().collect(),
ShellEnvironmentPolicyInherit::None => HashMap::new(),
ShellEnvironmentPolicyInherit::Core => {
let core_vars: HashSet<&str> = COMMON_CORE_VARS
.iter()
.copied()
.chain(PLATFORM_CORE_VARS.iter().copied())
.collect();
let is_core_var = |name: &str| {
if cfg!(target_os = "windows") {
core_vars
.iter()
.any(|allowed| allowed.eq_ignore_ascii_case(name))
} else {
core_vars.contains(name)
}
};
vars.into_iter().filter(|(k, _)| is_core_var(k)).collect()
}
};
// Internal helper - does `name` match any pattern in `patterns`?
let matches_any = |name: &str, patterns: &[EnvironmentVariablePattern]| -> bool {
patterns.iter().any(|pattern| pattern.matches(name))
};
// Step 2 - Apply the default exclude if not disabled.
if !policy.ignore_default_excludes {
let default_excludes = vec![
EnvironmentVariablePattern::new_case_insensitive("*KEY*"),
EnvironmentVariablePattern::new_case_insensitive("*SECRET*"),
EnvironmentVariablePattern::new_case_insensitive("*TOKEN*"),
];
env_map.retain(|k, _| !matches_any(k, &default_excludes));
}
// Step 3 - Apply custom excludes.
if !policy.exclude.is_empty() {
env_map.retain(|k, _| !matches_any(k, &policy.exclude));
}
// Step 4 - Apply user-provided overrides.
for (key, val) in &policy.r#set {
env_map.insert(key.clone(), val.clone());
}
// Step 5 - If include_only is non-empty, keep only the matching vars.
if !policy.include_only.is_empty() {
env_map.retain(|k, _| matches_any(k, &policy.include_only));
}
// Step 6 - Populate the thread ID environment variable when provided.
if let Some(thread_id) = thread_id {
env_map.insert(CODEX_THREAD_ID_ENV_VAR.to_string(), thread_id.to_string());
}
env_map
}
const COMMON_CORE_VARS: &[&str] = &["PATH", "SHELL", "TMPDIR", "TEMP", "TMP"];
#[cfg(target_os = "windows")]
const PLATFORM_CORE_VARS: &[&str] = &["PATHEXT", "USERNAME", "USERPROFILE"];
#[cfg(unix)]
const PLATFORM_CORE_VARS: &[&str] = &["HOME", "LANG", "LC_ALL", "LC_CTYPE", "LOGNAME", "USER"];

Some files were not shown because too many files have changed in this diff Show More