diff --git a/.github/prompts/issue-deduplicator.txt b/.github/prompts/issue-deduplicator.txt new file mode 100644 index 0000000000..9bdd3af9ef --- /dev/null +++ b/.github/prompts/issue-deduplicator.txt @@ -0,0 +1,18 @@ +You are an assistant that triages new GitHub issues by identifying potential duplicates. + +You will receive the following JSON files located in the current working directory: +- `codex-current-issue.json`: JSON object describing the newly created issue (fields: number, title, body). +- `codex-existing-issues.json`: JSON array of recent issues (each element includes number, title, body, createdAt). + +Instructions: +- Load both files as JSON and review their contents carefully. The codex-existing-issues.json file is large, ensure you explore all of it. +- Compare the current issue against the existing issues to find up to five that appear to describe the same underlying problem or request. +- Only consider an issue a potential duplicate if there is a clear overlap in symptoms, feature requests, reproduction steps, or error messages. +- Prioritize newer issues when similarity is comparable. +- Ignore pull requests and issues whose similarity is tenuous. +- When unsure, prefer returning fewer matches. + +Output requirements: +- Respond with a JSON array of issue numbers (integers), ordered from most likely duplicate to least. +- Include at most five numbers. +- If you find no plausible duplicates, respond with `[]`. diff --git a/.github/prompts/issue-labeler.txt b/.github/prompts/issue-labeler.txt new file mode 100644 index 0000000000..7e880d307f --- /dev/null +++ b/.github/prompts/issue-labeler.txt @@ -0,0 +1,26 @@ +You are an assistant that reviews GitHub issues for the repository. + +Your job is to choose the most appropriate existing labels for the issue described later in this prompt. +Follow these rules: +- Only pick labels out of the list below. +- Prefer a small set of precise labels over many broad ones. +- If none of the labels fit, respond with an empty JSON array: [] +- Output must be a JSON array of label names (strings) with no additional commentary. + +Labels to apply: +1. bug — Reproducible defects in Codex products (CLI, VS Code extension, web, auth). +2. enhancement — Feature requests or usability improvements that ask for new capabilities, better ergonomics, or quality-of-life tweaks. +3. extension — VS Code (or other IDE) extension-specific issues. +4. windows-os — Bugs or friction specific to Windows environments (PowerShell behavior, path handling, copy/paste, OS-specific auth or tooling failures). +5. mcp — Topics involving Model Context Protocol servers/clients. +6. codex-web — Issues targeting the Codex web UI/Cloud experience. +8. azure — Problems or requests tied to Azure OpenAI deployments. +9. documentation — Updates or corrections needed in docs/README/config references (broken links, missing examples, outdated keys, clarification requests). +10. model-behavior — Undesirable LLM behavior: forgetting goals, refusing work, hallucinating environment details, quota misreports, or other reasoning/performance anomalies. + +Issue information is available in environment variables: + +ISSUE_NUMBER +ISSUE_TITLE +ISSUE_BODY +REPO_FULL_NAME diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a10d25f533..c0e4166600 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,7 +27,7 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - # build_npm_package.py requires DotSlash when staging releases. + # stage_npm_packages.py requires DotSlash when staging releases. - uses: facebook/install-dotslash@v2 - name: Stage npm package @@ -37,10 +37,12 @@ jobs: run: | set -euo pipefail CODEX_VERSION=0.40.0 - PACK_OUTPUT="${RUNNER_TEMP}/codex-npm.tgz" - python3 ./codex-cli/scripts/build_npm_package.py \ + OUTPUT_DIR="${RUNNER_TEMP}" + python3 ./scripts/stage_npm_packages.py \ --release-version "$CODEX_VERSION" \ - --pack-output "$PACK_OUTPUT" + --package codex \ + --output-dir "$OUTPUT_DIR" + PACK_OUTPUT="${OUTPUT_DIR}/codex-npm-${CODEX_VERSION}.tgz" echo "pack_output=$PACK_OUTPUT" >> "$GITHUB_OUTPUT" - name: Upload staged npm package artifact diff --git a/.github/workflows/issue-deduplicator.yml b/.github/workflows/issue-deduplicator.yml new file mode 100644 index 0000000000..779e739b20 --- /dev/null +++ b/.github/workflows/issue-deduplicator.yml @@ -0,0 +1,102 @@ +name: Issue Deduplicator + +on: + issues: + types: + - opened + - labeled + +jobs: + gather-duplicates: + name: Identify potential duplicates + if: ${{ github.event.action == 'opened' || (github.event.action == 'labeled' && github.event.label.name == 'codex-deduplicate') }} + runs-on: ubuntu-latest + permissions: + contents: read + outputs: + codex_output: ${{ steps.codex.outputs.final_message }} + steps: + - uses: actions/checkout@v4 + + - name: Prepare Codex inputs + env: + GH_TOKEN: ${{ github.token }} + run: | + set -eo pipefail + + CURRENT_ISSUE_FILE=codex-current-issue.json + EXISTING_ISSUES_FILE=codex-existing-issues.json + + gh issue list --repo "${{ github.repository }}" \ + --json number,title,body,createdAt \ + --limit 1000 \ + --state all \ + --search "sort:created-desc" \ + | jq '.' \ + > "$EXISTING_ISSUES_FILE" + + gh issue view "${{ github.event.issue.number }}" \ + --repo "${{ github.repository }}" \ + --json number,title,body \ + | jq '.' \ + > "$CURRENT_ISSUE_FILE" + + - id: codex + uses: openai/codex-action@main + with: + openai_api_key: ${{ secrets.CODEX_OPENAI_API_KEY }} + prompt_file: .github/prompts/issue-deduplicator.txt + require_repo_write: false + codex_version: 0.43.0-alpha.16 + codex_args: -m gpt-5 + + comment-on-issue: + name: Comment with potential duplicates + needs: gather-duplicates + if: ${{ needs.gather-duplicates.result != 'skipped' }} + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + steps: + - name: Comment on issue + uses: actions/github-script@v7 + env: + CODEX_OUTPUT: ${{ needs.gather-duplicates.outputs.codex_output }} + with: + github-token: ${{ github.token }} + script: | + let numbers; + try { + numbers = JSON.parse(process.env.CODEX_OUTPUT); + } catch (error) { + core.info(`Codex output was not valid JSON. Raw output: ${raw}`); + return; + } + + if (numbers.length === 0) { + core.info('Codex reported no potential duplicates.'); + return; + } + + const lines = [ + 'Potential duplicates detected:' + ...numbers.map((value) => `- #${value}`), + '', + '*Powered by [Codex Action](https://github.com/openai/codex-action)*']; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.issue.number, + body: lines.join("\n"), + }); + + - name: Remove codex-deduplicate label + if: ${{ always() && github.event.action == 'labeled' && github.event.label.name == 'codex-deduplicate' }} + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + run: | + gh issue edit "${{ github.event.issue.number }}" --remove-label codex-deduplicate || true + echo "Attempted to remove label: codex-deduplicate" diff --git a/.github/workflows/issue-labeler.yml b/.github/workflows/issue-labeler.yml new file mode 100644 index 0000000000..4b893d0c91 --- /dev/null +++ b/.github/workflows/issue-labeler.yml @@ -0,0 +1,78 @@ +name: Issue Labeler + +on: + issues: + types: + - opened + - labeled + +jobs: + gather-labels: + name: Generate label suggestions + if: ${{ github.event.action == 'opened' || (github.event.action == 'labeled' && github.event.label.name == 'codex-label') }} + runs-on: ubuntu-latest + permissions: + contents: read + env: + ISSUE_NUMBER: ${{ github.event.issue.number }} + ISSUE_TITLE: ${{ github.event.issue.title }} + ISSUE_BODY: ${{ github.event.issue.body }} + REPO_FULL_NAME: ${{ github.repository }} + outputs: + codex_output: ${{ steps.codex.outputs.final_message }} + steps: + - uses: actions/checkout@v4 + + - id: codex + uses: openai/codex-action@main + with: + openai_api_key: ${{ secrets.CODEX_OPENAI_API_KEY }} + prompt_file: .github/prompts/issue-labeler.txt + require_repo_write: false + codex_version: 0.43.0-alpha.16 + + apply-labels: + name: Apply labels from Codex output + needs: gather-labels + if: ${{ needs.gather-labels.result != 'skipped' }} + runs-on: ubuntu-latest + permissions: + contents: read + issues: write + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + ISSUE_NUMBER: ${{ github.event.issue.number }} + CODEX_OUTPUT: ${{ needs.gather-labels.outputs.codex_output }} + steps: + - name: Apply labels + run: | + json=${CODEX_OUTPUT//$'\r'/} + if [ -z "$json" ]; then + echo "Codex produced no output. Skipping label application." + exit 0 + fi + + if ! printf '%s' "$json" | jq -e 'type == "array"' >/dev/null 2>&1; then + echo "Codex output was not a JSON array. Raw output: $json" + exit 0 + fi + + labels=$(printf '%s' "$json" | jq -r '.[] | tostring') + if [ -z "$labels" ]; then + echo "Codex returned an empty array. Nothing to do." + exit 0 + fi + + cmd=(gh issue edit "$ISSUE_NUMBER") + while IFS= read -r label; do + cmd+=(--add-label "$label") + done <<< "$labels" + + "${cmd[@]}" || true + + - name: Remove codex-label trigger + if: ${{ always() && github.event.action == 'labeled' && github.event.label.name == 'codex-label' }} + run: | + gh issue edit "$ISSUE_NUMBER" --remove-label codex-label || true + echo "Attempted to remove label: codex-label" diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 05bbb9a4d9..aebdef5948 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -216,31 +216,30 @@ jobs: echo "npm_tag=" >> "$GITHUB_OUTPUT" fi - # build_npm_package.py requires DotSlash when staging releases. - - uses: facebook/install-dotslash@v2 - - name: Stage codex CLI npm package - env: - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - TMP_DIR="${RUNNER_TEMP}/npm-stage" - ./codex-cli/scripts/build_npm_package.py \ - --package codex \ - --release-version "${{ steps.release_name.outputs.name }}" \ - --staging-dir "${TMP_DIR}" \ - --pack-output "${GITHUB_WORKSPACE}/dist/npm/codex-npm-${{ steps.release_name.outputs.name }}.tgz" + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + run_install: false - - name: Stage responses API proxy npm package + - name: Setup Node.js for npm packaging + uses: actions/setup-node@v5 + with: + node-version: 22 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # stage_npm_packages.py requires DotSlash when staging releases. + - uses: facebook/install-dotslash@v2 + - name: Stage npm packages env: GH_TOKEN: ${{ github.token }} run: | - set -euo pipefail - TMP_DIR="${RUNNER_TEMP}/npm-stage-responses" - ./codex-cli/scripts/build_npm_package.py \ - --package codex-responses-api-proxy \ + ./scripts/stage_npm_packages.py \ --release-version "${{ steps.release_name.outputs.name }}" \ - --staging-dir "${TMP_DIR}" \ - --pack-output "${GITHUB_WORKSPACE}/dist/npm/codex-responses-api-proxy-npm-${{ steps.release_name.outputs.name }}.tgz" + --package codex \ + --package codex-responses-api-proxy \ + --package codex-sdk - name: Create GitHub Release uses: softprops/action-gh-release@v2 @@ -300,6 +299,10 @@ jobs: --repo "${GITHUB_REPOSITORY}" \ --pattern "codex-responses-api-proxy-npm-${version}.tgz" \ --dir dist/npm + gh release download "$tag" \ + --repo "${GITHUB_REPOSITORY}" \ + --pattern "codex-sdk-npm-${version}.tgz" \ + --dir dist/npm # No NODE_AUTH_TOKEN needed because we use OIDC. - name: Publish to npm @@ -316,6 +319,7 @@ jobs: tarballs=( "codex-npm-${VERSION}.tgz" "codex-responses-api-proxy-npm-${VERSION}.tgz" + "codex-sdk-npm-${VERSION}.tgz" ) for tarball in "${tarballs[@]}"; do diff --git a/README.md b/README.md index ae04239ae6..0a57a608eb 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ Codex CLI supports a rich set of configuration options, with preferences stored - [**Authentication**](./docs/authentication.md) - [Auth methods](./docs/authentication.md#forcing-a-specific-auth-method-advanced) - [Login on a "Headless" machine](./docs/authentication.md#connecting-on-a-headless-machine) +- [**Non-interactive mode**](./docs/exec.md) - [**Advanced**](./docs/advanced.md) - [Non-interactive / CI mode](./docs/advanced.md#non-interactive--ci-mode) - [Tracing / verbose logging](./docs/advanced.md#tracing--verbose-logging) diff --git a/codex-cli/scripts/README.md b/codex-cli/scripts/README.md index fd2265b299..052cf81a37 100644 --- a/codex-cli/scripts/README.md +++ b/codex-cli/scripts/README.md @@ -1,11 +1,19 @@ # npm releases -Run the following: - -To build the 0.2.x or later version of the npm module, which runs the Rust version of the CLI, build it as follows: +Use the staging helper in the repo root to generate npm tarballs for a release. For +example, to stage the CLI, responses proxy, and SDK packages for version `0.6.0`: ```bash -./codex-cli/scripts/build_npm_package.py --release-version 0.6.0 +./scripts/stage_npm_packages.py \ + --release-version 0.6.0 \ + --package codex \ + --package codex-responses-api-proxy \ + --package codex-sdk ``` -Note this will create `./codex-cli/vendor/` as a side-effect. +This downloads the native artifacts once, hydrates `vendor/` for each package, and writes +tarballs to `dist/npm/`. + +If you need to invoke `build_npm_package.py` directly, run +`codex-cli/scripts/install_native_deps.py` first and pass `--vendor-src` pointing to the +directory that contains the populated `vendor/` tree. diff --git a/codex-cli/scripts/build_npm_package.py b/codex-cli/scripts/build_npm_package.py index 2ae8a96e43..ef96bef2ee 100755 --- a/codex-cli/scripts/build_npm_package.py +++ b/codex-cli/scripts/build_npm_package.py @@ -3,7 +3,6 @@ import argparse import json -import re import shutil import subprocess import sys @@ -14,19 +13,25 @@ SCRIPT_DIR = Path(__file__).resolve().parent CODEX_CLI_ROOT = SCRIPT_DIR.parent REPO_ROOT = CODEX_CLI_ROOT.parent RESPONSES_API_PROXY_NPM_ROOT = REPO_ROOT / "codex-rs" / "responses-api-proxy" / "npm" -GITHUB_REPO = "openai/codex" +CODEX_SDK_ROOT = REPO_ROOT / "sdk" / "typescript" -# The docs are not clear on what the expected value/format of -# workflow/workflowName is: -# https://cli.github.com/manual/gh_run_list -WORKFLOW_NAME = ".github/workflows/rust-release.yml" +PACKAGE_NATIVE_COMPONENTS: dict[str, list[str]] = { + "codex": ["codex", "rg"], + "codex-responses-api-proxy": ["codex-responses-api-proxy"], + "codex-sdk": ["codex"], +} +COMPONENT_DEST_DIR: dict[str, str] = { + "codex": "codex", + "codex-responses-api-proxy": "codex-responses-api-proxy", + "rg": "path", +} def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Build or stage the Codex CLI npm package.") parser.add_argument( "--package", - choices=("codex", "codex-responses-api-proxy"), + choices=("codex", "codex-responses-api-proxy", "codex-sdk"), default="codex", help="Which npm package to stage (default: codex).", ) @@ -37,14 +42,9 @@ def parse_args() -> argparse.Namespace: parser.add_argument( "--release-version", help=( - "Version to stage for npm release. When provided, the script also resolves the " - "matching rust-release workflow unless --workflow-url is supplied." + "Version to stage for npm release." ), ) - parser.add_argument( - "--workflow-url", - help="Optional GitHub Actions workflow run URL used to download native binaries.", - ) parser.add_argument( "--staging-dir", type=Path, @@ -64,6 +64,11 @@ def parse_args() -> argparse.Namespace: type=Path, help="Path where the generated npm tarball should be written.", ) + parser.add_argument( + "--vendor-src", + type=Path, + help="Directory containing pre-installed native binaries to bundle (vendor root).", + ) return parser.parse_args() @@ -86,29 +91,19 @@ def main() -> int: try: stage_sources(staging_dir, version, package) - workflow_url = args.workflow_url - resolved_head_sha: str | None = None - if not workflow_url: - if release_version: - workflow = resolve_release_workflow(version) - workflow_url = workflow["url"] - resolved_head_sha = workflow.get("headSha") - else: - workflow_url = resolve_latest_alpha_workflow_url() - elif release_version: - try: - workflow = resolve_release_workflow(version) - resolved_head_sha = workflow.get("headSha") - except Exception: - resolved_head_sha = None + vendor_src = args.vendor_src.resolve() if args.vendor_src else None + native_components = PACKAGE_NATIVE_COMPONENTS.get(package, []) - if release_version and resolved_head_sha: - print(f"should `git checkout {resolved_head_sha}`") + if native_components: + if vendor_src is None: + components_str = ", ".join(native_components) + raise RuntimeError( + "Native components " + f"({components_str}) required for package '{package}'. Provide --vendor-src " + "pointing to a directory containing pre-installed binaries." + ) - if not workflow_url: - raise RuntimeError("Unable to determine workflow URL for native binaries.") - - install_native_binaries(staging_dir, workflow_url, package) + copy_native_binaries(vendor_src, staging_dir, native_components) if release_version: staging_dir_str = str(staging_dir) @@ -119,12 +114,20 @@ def main() -> int: f" node {staging_dir_str}/bin/codex.js --version\n" f" node {staging_dir_str}/bin/codex.js --help\n\n" ) - else: + elif package == "codex-responses-api-proxy": print( f"Staged version {version} for release in {staging_dir_str}\n\n" "Verify the responses API proxy:\n" f" node {staging_dir_str}/bin/codex-responses-api-proxy.js --help\n\n" ) + else: + print( + f"Staged version {version} for release in {staging_dir_str}\n\n" + "Verify the SDK contents:\n" + f" ls {staging_dir_str}/dist\n" + f" ls {staging_dir_str}/vendor\n" + " node -e \"import('./dist/index.js').then(() => console.log('ok'))\"\n\n" + ) else: print(f"Staged package in {staging_dir}") @@ -152,10 +155,9 @@ def prepare_staging_dir(staging_dir: Path | None) -> tuple[Path, bool]: def stage_sources(staging_dir: Path, version: str, package: str) -> None: - bin_dir = staging_dir / "bin" - bin_dir.mkdir(parents=True, exist_ok=True) - if package == "codex": + bin_dir = staging_dir / "bin" + bin_dir.mkdir(parents=True, exist_ok=True) shutil.copy2(CODEX_CLI_ROOT / "bin" / "codex.js", bin_dir / "codex.js") rg_manifest = CODEX_CLI_ROOT / "bin" / "rg" if rg_manifest.exists(): @@ -167,6 +169,8 @@ def stage_sources(staging_dir: Path, version: str, package: str) -> None: package_json_path = CODEX_CLI_ROOT / "package.json" elif package == "codex-responses-api-proxy": + bin_dir = staging_dir / "bin" + bin_dir.mkdir(parents=True, exist_ok=True) launcher_src = RESPONSES_API_PROXY_NPM_ROOT / "bin" / "codex-responses-api-proxy.js" shutil.copy2(launcher_src, bin_dir / "codex-responses-api-proxy.js") @@ -175,6 +179,9 @@ def stage_sources(staging_dir: Path, version: str, package: str) -> None: shutil.copy2(readme_src, staging_dir / "README.md") package_json_path = RESPONSES_API_PROXY_NPM_ROOT / "package.json" + elif package == "codex-sdk": + package_json_path = CODEX_SDK_ROOT / "package.json" + stage_codex_sdk_sources(staging_dir) else: raise RuntimeError(f"Unknown package '{package}'.") @@ -182,91 +189,85 @@ def stage_sources(staging_dir: Path, version: str, package: str) -> None: package_json = json.load(fh) package_json["version"] = version + if package == "codex-sdk": + scripts = package_json.get("scripts") + if isinstance(scripts, dict): + scripts.pop("prepare", None) + + files = package_json.get("files") + if isinstance(files, list): + if "vendor" not in files: + files.append("vendor") + else: + package_json["files"] = ["dist", "vendor"] + with open(staging_dir / "package.json", "w", encoding="utf-8") as out: json.dump(package_json, out, indent=2) out.write("\n") -def install_native_binaries(staging_dir: Path, workflow_url: str, package: str) -> None: - package_components = { - "codex": ["codex", "rg"], - "codex-responses-api-proxy": ["codex-responses-api-proxy"], - } - - components = package_components.get(package) - if components is None: - raise RuntimeError(f"Unknown package '{package}'.") - - cmd = ["./scripts/install_native_deps.py", "--workflow-url", workflow_url] - for component in components: - cmd.extend(["--component", component]) - cmd.append(str(staging_dir)) - subprocess.check_call(cmd, cwd=CODEX_CLI_ROOT) +def run_command(cmd: list[str], cwd: Path | None = None) -> None: + print("+", " ".join(cmd)) + subprocess.run(cmd, cwd=cwd, check=True) -def resolve_latest_alpha_workflow_url() -> str: - version = determine_latest_alpha_version() - workflow = resolve_release_workflow(version) - return workflow["url"] +def stage_codex_sdk_sources(staging_dir: Path) -> None: + package_root = CODEX_SDK_ROOT + + run_command(["pnpm", "install", "--frozen-lockfile"], cwd=package_root) + run_command(["pnpm", "run", "build"], cwd=package_root) + + dist_src = package_root / "dist" + if not dist_src.exists(): + raise RuntimeError("codex-sdk build did not produce a dist directory.") + + shutil.copytree(dist_src, staging_dir / "dist") + + readme_src = package_root / "README.md" + if readme_src.exists(): + shutil.copy2(readme_src, staging_dir / "README.md") + + license_src = REPO_ROOT / "LICENSE" + if license_src.exists(): + shutil.copy2(license_src, staging_dir / "LICENSE") -def determine_latest_alpha_version() -> str: - releases = list_releases() - best_key: tuple[int, int, int, int] | None = None - best_version: str | None = None - pattern = re.compile(r"^rust-v(\d+)\.(\d+)\.(\d+)-alpha\.(\d+)$") - for release in releases: - tag = release.get("tag_name", "") - match = pattern.match(tag) - if not match: +def copy_native_binaries(vendor_src: Path, staging_dir: Path, components: list[str]) -> None: + vendor_src = vendor_src.resolve() + if not vendor_src.exists(): + raise RuntimeError(f"Vendor source directory not found: {vendor_src}") + + components_set = {component for component in components if component in COMPONENT_DEST_DIR} + if not components_set: + return + + vendor_dest = staging_dir / "vendor" + if vendor_dest.exists(): + shutil.rmtree(vendor_dest) + vendor_dest.mkdir(parents=True, exist_ok=True) + + for target_dir in vendor_src.iterdir(): + if not target_dir.is_dir(): continue - key = tuple(int(match.group(i)) for i in range(1, 5)) - if best_key is None or key > best_key: - best_key = key - best_version = ( - f"{match.group(1)}.{match.group(2)}.{match.group(3)}-alpha.{match.group(4)}" - ) - if best_version is None: - raise RuntimeError("No alpha releases found when resolving workflow URL.") - return best_version + dest_target_dir = vendor_dest / target_dir.name + dest_target_dir.mkdir(parents=True, exist_ok=True) + for component in components_set: + dest_dir_name = COMPONENT_DEST_DIR.get(component) + if dest_dir_name is None: + continue -def list_releases() -> list[dict]: - stdout = subprocess.check_output( - ["gh", "api", f"/repos/{GITHUB_REPO}/releases?per_page=100"], - text=True, - ) - try: - releases = json.loads(stdout or "[]") - except json.JSONDecodeError as exc: - raise RuntimeError("Unable to parse releases JSON.") from exc - if not isinstance(releases, list): - raise RuntimeError("Unexpected response when listing releases.") - return releases + src_component_dir = target_dir / dest_dir_name + if not src_component_dir.exists(): + raise RuntimeError( + f"Missing native component '{component}' in vendor source: {src_component_dir}" + ) - -def resolve_release_workflow(version: str) -> dict: - stdout = subprocess.check_output( - [ - "gh", - "run", - "list", - "--branch", - f"rust-v{version}", - "--json", - "workflowName,url,headSha", - "--workflow", - WORKFLOW_NAME, - "--jq", - "first(.[])", - ], - text=True, - ) - workflow = json.loads(stdout or "[]") - if not workflow: - raise RuntimeError(f"Unable to find rust-release workflow for version {version}.") - return workflow + dest_component_dir = dest_target_dir / dest_dir_name + if dest_component_dir.exists(): + shutil.rmtree(dest_component_dir) + shutil.copytree(src_component_dir, dest_component_dir) def run_npm_pack(staging_dir: Path, output_path: Path) -> Path: diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 269dfcf6b5..aa9736d29a 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -864,6 +864,7 @@ dependencies = [ "codex-utils-string", "core_test_support", "dirs", + "dunce", "env-flags", "escargot", "eventsource-stream", @@ -913,7 +914,6 @@ version = "0.0.0" dependencies = [ "anyhow", "assert_cmd", - "chrono", "clap", "codex-arg0", "codex-common", @@ -930,6 +930,7 @@ dependencies = [ "serde", "serde_json", "shlex", + "supports-color", "tempfile", "tokio", "tracing", @@ -1201,6 +1202,7 @@ dependencies = [ "crossterm", "diffy", "dirs", + "dunce", "image", "insta", "itertools 0.14.0", @@ -1803,6 +1805,12 @@ version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dupe" version = "0.9.1" @@ -4069,7 +4077,7 @@ dependencies = [ "once_cell", "socket2 0.5.10", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 05e6cd02c2..f8e88797dc 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -99,6 +99,7 @@ derive_more = "2" diffy = "0.4.2" dirs = "6" dotenvy = "0.15.7" +dunce = "1.0.4" env-flags = "0.1.1" env_logger = "0.11.5" escargot = "0.5" diff --git a/codex-rs/app-server-protocol/src/jsonrpc_lite.rs b/codex-rs/app-server-protocol/src/jsonrpc_lite.rs index e458125dd2..9d6d6da06f 100644 --- a/codex-rs/app-server-protocol/src/jsonrpc_lite.rs +++ b/codex-rs/app-server-protocol/src/jsonrpc_lite.rs @@ -11,6 +11,7 @@ pub const JSONRPC_VERSION: &str = "2.0"; #[serde(untagged)] pub enum RequestId { String(String), + #[ts(type = "number")] Integer(i64), } diff --git a/codex-rs/app-server-protocol/src/protocol.rs b/codex-rs/app-server-protocol/src/protocol.rs index 510506deeb..eaa324c2a1 100644 --- a/codex-rs/app-server-protocol/src/protocol.rs +++ b/codex-rs/app-server-protocol/src/protocol.rs @@ -727,6 +727,7 @@ pub struct FuzzyFileSearchParams { pub struct FuzzyFileSearchResult { pub root: String, pub path: String, + pub file_name: String, pub score: u32, #[serde(skip_serializing_if = "Option::is_none")] pub indices: Option>, diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 1e5eb82ef0..c158621d2e 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -53,6 +53,7 @@ use codex_core::AuthManager; use codex_core::CodexConversation; use codex_core::ConversationManager; use codex_core::Cursor as RolloutCursor; +use codex_core::INTERACTIVE_SESSION_SOURCES; use codex_core::NewConversation; use codex_core::RolloutRecorder; use codex_core::SessionMeta; @@ -708,6 +709,7 @@ impl CodexMessageProcessor { &self.config.codex_home, page_size, cursor_ref, + INTERACTIVE_SESSION_SOURCES, ) .await { diff --git a/codex-rs/app-server/src/fuzzy_file_search.rs b/codex-rs/app-server/src/fuzzy_file_search.rs index 146cc12a39..6c83a0f4ec 100644 --- a/codex-rs/app-server/src/fuzzy_file_search.rs +++ b/codex-rs/app-server/src/fuzzy_file_search.rs @@ -1,5 +1,6 @@ use std::num::NonZero; use std::num::NonZeroUsize; +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; @@ -56,9 +57,16 @@ pub(crate) async fn run_fuzzy_file_search( match res { Ok(Ok((root, res))) => { for m in res.matches { + let path = m.path; + //TODO(shijie): Move file name generation to file_search lib. + let file_name = Path::new(&path) + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.clone()); let result = FuzzyFileSearchResult { root: root.clone(), - path: m.path, + path, + file_name, score: m.score, indices: m.indices, }; diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 1b9fcffb71..15086c19e1 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -17,6 +17,7 @@ use codex_core::ConversationManager; use codex_core::config::Config; use codex_core::default_client::USER_AGENT_SUFFIX; use codex_core::default_client::get_codex_user_agent; +use codex_protocol::protocol::SessionSource; use std::sync::Arc; pub(crate) struct MessageProcessor { @@ -34,8 +35,11 @@ impl MessageProcessor { config: Arc, ) -> Self { let outgoing = Arc::new(outgoing); - let auth_manager = AuthManager::shared(config.codex_home.clone()); - let conversation_manager = Arc::new(ConversationManager::new(auth_manager.clone())); + let auth_manager = AuthManager::shared(config.codex_home.clone(), false); + let conversation_manager = Arc::new(ConversationManager::new( + auth_manager.clone(), + SessionSource::VSCode, + )); let codex_message_processor = CodexMessageProcessor::new( auth_manager, conversation_manager, diff --git a/codex-rs/app-server/tests/suite/fuzzy_file_search.rs b/codex-rs/app-server/tests/suite/fuzzy_file_search.rs index 8e33b130e1..a2bc974ad0 100644 --- a/codex-rs/app-server/tests/suite/fuzzy_file_search.rs +++ b/codex-rs/app-server/tests/suite/fuzzy_file_search.rs @@ -1,3 +1,5 @@ +use anyhow::Context; +use anyhow::Result; use app_test_support::McpProcess; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; @@ -9,30 +11,41 @@ use tokio::time::timeout; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_fuzzy_file_search_sorts_and_includes_indices() { +async fn test_fuzzy_file_search_sorts_and_includes_indices() -> Result<()> { // Prepare a temporary Codex home and a separate root with test files. - let codex_home = TempDir::new().expect("create temp codex home"); - let root = TempDir::new().expect("create temp search root"); + let codex_home = TempDir::new().context("create temp codex home")?; + let root = TempDir::new().context("create temp search root")?; - // Create files designed to have deterministic ordering for query "abc". - std::fs::write(root.path().join("abc"), "x").expect("write file abc"); - std::fs::write(root.path().join("abcde"), "x").expect("write file abcx"); - std::fs::write(root.path().join("abexy"), "x").expect("write file abcx"); - std::fs::write(root.path().join("zzz.txt"), "x").expect("write file zzz"); + // Create files designed to have deterministic ordering for query "abe". + std::fs::write(root.path().join("abc"), "x").context("write file abc")?; + std::fs::write(root.path().join("abcde"), "x").context("write file abcde")?; + std::fs::write(root.path().join("abexy"), "x").context("write file abexy")?; + std::fs::write(root.path().join("zzz.txt"), "x").context("write file zzz")?; + let sub_dir = root.path().join("sub"); + std::fs::create_dir_all(&sub_dir).context("create sub dir")?; + let sub_abce_path = sub_dir.join("abce"); + std::fs::write(&sub_abce_path, "x").context("write file sub/abce")?; + let sub_abce_rel = sub_abce_path + .strip_prefix(root.path()) + .context("strip root prefix from sub/abce")? + .to_string_lossy() + .to_string(); // Start MCP server and initialize. - let mut mcp = McpProcess::new(codex_home.path()).await.expect("spawn mcp"); + let mut mcp = McpProcess::new(codex_home.path()) + .await + .context("spawn mcp")?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()) .await - .expect("init timeout") - .expect("init failed"); + .context("init timeout")? + .context("init failed")?; let root_path = root.path().to_string_lossy().to_string(); // Send fuzzyFileSearch request. let request_id = mcp .send_fuzzy_file_search_request("abe", vec![root_path.clone()], None) .await - .expect("send fuzzyFileSearch"); + .context("send fuzzyFileSearch")?; // Read response and verify shape and ordering. let resp: JSONRPCResponse = timeout( @@ -40,39 +53,65 @@ async fn test_fuzzy_file_search_sorts_and_includes_indices() { mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await - .expect("fuzzyFileSearch timeout") - .expect("fuzzyFileSearch resp"); + .context("fuzzyFileSearch timeout")? + .context("fuzzyFileSearch resp")?; let value = resp.result; + // The path separator on Windows affects the score. + let expected_score = if cfg!(windows) { 69 } else { 72 }; + assert_eq!( value, json!({ "files": [ - { "root": root_path.clone(), "path": "abexy", "score": 88, "indices": [0, 1, 2] }, - { "root": root_path.clone(), "path": "abcde", "score": 74, "indices": [0, 1, 4] }, + { + "root": root_path.clone(), + "path": "abexy", + "file_name": "abexy", + "score": 88, + "indices": [0, 1, 2], + }, + { + "root": root_path.clone(), + "path": "abcde", + "file_name": "abcde", + "score": 74, + "indices": [0, 1, 4], + }, + { + "root": root_path.clone(), + "path": sub_abce_rel, + "file_name": "abce", + "score": expected_score, + "indices": [4, 5, 7], + }, ] }) ); + + Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn test_fuzzy_file_search_accepts_cancellation_token() { - let codex_home = TempDir::new().expect("create temp codex home"); - let root = TempDir::new().expect("create temp search root"); +async fn test_fuzzy_file_search_accepts_cancellation_token() -> Result<()> { + let codex_home = TempDir::new().context("create temp codex home")?; + let root = TempDir::new().context("create temp search root")?; - std::fs::write(root.path().join("alpha.txt"), "contents").expect("write alpha"); + std::fs::write(root.path().join("alpha.txt"), "contents").context("write alpha")?; - let mut mcp = McpProcess::new(codex_home.path()).await.expect("spawn mcp"); + let mut mcp = McpProcess::new(codex_home.path()) + .await + .context("spawn mcp")?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()) .await - .expect("init timeout") - .expect("init failed"); + .context("init timeout")? + .context("init failed")?; let root_path = root.path().to_string_lossy().to_string(); let request_id = mcp .send_fuzzy_file_search_request("alp", vec![root_path.clone()], None) .await - .expect("send fuzzyFileSearch"); + .context("send fuzzyFileSearch")?; let request_id_2 = mcp .send_fuzzy_file_search_request( @@ -81,24 +120,27 @@ async fn test_fuzzy_file_search_accepts_cancellation_token() { Some(request_id.to_string()), ) .await - .expect("send fuzzyFileSearch"); + .context("send fuzzyFileSearch")?; let resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id_2)), ) .await - .expect("fuzzyFileSearch timeout") - .expect("fuzzyFileSearch resp"); + .context("fuzzyFileSearch timeout")? + .context("fuzzyFileSearch resp")?; let files = resp .result .get("files") - .and_then(|value| value.as_array()) - .cloned() - .expect("files array"); + .context("files key missing")? + .as_array() + .context("files not array")? + .clone(); assert_eq!(files.len(), 1); assert_eq!(files[0]["root"], root_path); assert_eq!(files[0]["path"], "alpha.txt"); + + Ok(()) } diff --git a/codex-rs/cli/src/login.rs b/codex-rs/cli/src/login.rs index 2b497c0642..cd20e2bae8 100644 --- a/codex-rs/cli/src/login.rs +++ b/codex-rs/cli/src/login.rs @@ -9,6 +9,8 @@ use codex_core::config::ConfigOverrides; use codex_login::ServerOptions; use codex_login::run_device_code_login; use codex_login::run_login_server; +use std::io::IsTerminal; +use std::io::Read; use std::path::PathBuf; pub async fn login_with_chatgpt(codex_home: PathBuf) -> std::io::Result<()> { @@ -56,6 +58,33 @@ pub async fn run_login_with_api_key( } } +pub fn read_api_key_from_stdin() -> String { + let mut stdin = std::io::stdin(); + + if stdin.is_terminal() { + eprintln!( + "--with-api-key expects the API key on stdin. Try piping it, e.g. `printenv OPENAI_API_KEY | codex login --with-api-key`." + ); + std::process::exit(1); + } + + eprintln!("Reading API key from stdin..."); + + let mut buffer = String::new(); + if let Err(err) = stdin.read_to_string(&mut buffer) { + eprintln!("Failed to read API key from stdin: {err}"); + std::process::exit(1); + } + + let api_key = buffer.trim().to_string(); + if api_key.is_empty() { + eprintln!("No API key provided via stdin."); + std::process::exit(1); + } + + api_key +} + /// Login using the OAuth device code flow. pub async fn run_login_with_device_code( cli_config_overrides: CliConfigOverrides, diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 10e2621533..51c13742d9 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -7,6 +7,7 @@ use codex_chatgpt::apply_command::ApplyCommand; use codex_chatgpt::apply_command::run_apply_command; use codex_cli::LandlockCommand; use codex_cli::SeatbeltCommand; +use codex_cli::login::read_api_key_from_stdin; use codex_cli::login::run_login_status; use codex_cli::login::run_login_with_api_key; use codex_cli::login::run_login_with_chatgpt; @@ -139,7 +140,18 @@ struct LoginCommand { #[clap(skip)] config_overrides: CliConfigOverrides, - #[arg(long = "api-key", value_name = "API_KEY")] + #[arg( + long = "with-api-key", + help = "Read the API key from stdin (e.g. `printenv OPENAI_API_KEY | codex login --with-api-key`)" + )] + with_api_key: bool, + + #[arg( + long = "api-key", + value_name = "API_KEY", + help = "(deprecated) Previously accepted the API key directly; now exits with guidance to use --with-api-key", + hide = true + )] api_key: Option, /// EXPERIMENTAL: Use device code flow (not yet supported) @@ -298,7 +310,13 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() login_cli.client_id, ) .await; - } else if let Some(api_key) = login_cli.api_key { + } else if login_cli.api_key.is_some() { + eprintln!( + "The --api-key flag is no longer supported. Pipe the key instead, e.g. `printenv OPENAI_API_KEY | codex login --with-api-key`." + ); + std::process::exit(1); + } else if login_cli.with_api_key { + let api_key = read_api_key_from_stdin(); run_login_with_api_key(login_cli.config_overrides, api_key).await; } else { run_login_with_chatgpt(login_cli.config_overrides).await; diff --git a/codex-rs/cloud-tasks/src/lib.rs b/codex-rs/cloud-tasks/src/lib.rs index d57a40572c..da2d4eb908 100644 --- a/codex-rs/cloud-tasks/src/lib.rs +++ b/codex-rs/cloud-tasks/src/lib.rs @@ -190,7 +190,7 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option) -> a // Require ChatGPT login (SWIC). Exit with a clear message if missing. let _token = match codex_core::config::find_codex_home() .ok() - .map(codex_login::AuthManager::new) + .map(|home| codex_login::AuthManager::new(home, false)) .and_then(|am| am.auth()) { Some(auth) => { @@ -839,6 +839,9 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option) -> a && matches!(key.code, KeyCode::Char('n') | KeyCode::Char('N')) || matches!(key.code, KeyCode::Char('\u{000E}')); if is_ctrl_n { + if app.new_task.is_none() { + continue; + } if app.best_of_modal.is_some() { app.best_of_modal = None; needs_redraw = true; diff --git a/codex-rs/cloud-tasks/src/ui.rs b/codex-rs/cloud-tasks/src/ui.rs index 94fb3ad97d..fe91b0ce9b 100644 --- a/codex-rs/cloud-tasks/src/ui.rs +++ b/codex-rs/cloud-tasks/src/ui.rs @@ -262,9 +262,9 @@ fn draw_footer(frame: &mut Frame, area: Rect, app: &mut App) { help.push(": Apply ".dim()); } help.push("o : Set Env ".dim()); - help.push("Ctrl+N".dim()); - help.push(format!(": Attempts {}x ", app.best_of_n).dim()); if app.new_task.is_some() { + help.push("Ctrl+N".dim()); + help.push(format!(": Attempts {}x ", app.best_of_n).dim()); help.push("(editing new task) ".dim()); } else { help.push("n : New Task ".dim()); @@ -1004,32 +1004,40 @@ pub fn draw_best_of_modal(frame: &mut Frame, area: Rect, app: &mut App) { use ratatui::widgets::Wrap; let inner = overlay_outer(area); + const MAX_WIDTH: u16 = 40; + const MIN_WIDTH: u16 = 20; + const MAX_HEIGHT: u16 = 12; + const MIN_HEIGHT: u16 = 6; + let modal_width = inner.width.min(MAX_WIDTH).max(inner.width.min(MIN_WIDTH)); + let modal_height = inner + .height + .min(MAX_HEIGHT) + .max(inner.height.min(MIN_HEIGHT)); + let modal_x = inner.x + (inner.width.saturating_sub(modal_width)) / 2; + let modal_y = inner.y + (inner.height.saturating_sub(modal_height)) / 2; + let modal_area = Rect::new(modal_x, modal_y, modal_width, modal_height); let title = Line::from(vec!["Parallel Attempts".magenta().bold()]); let block = overlay_block().title(title); - frame.render_widget(Clear, inner); - frame.render_widget(block.clone(), inner); - let content = overlay_content(inner); + frame.render_widget(Clear, modal_area); + frame.render_widget(block.clone(), modal_area); + let content = overlay_content(modal_area); let rows = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Length(2), Constraint::Min(1)]) .split(content); - let hint = Paragraph::new(Line::from( - "Use ↑/↓ to choose, 1-4 jump; Enter confirm, Esc cancel" - .cyan() - .dim(), - )) - .wrap(Wrap { trim: true }); + let hint = Paragraph::new(Line::from("Use ↑/↓ to choose, 1-4 jump".cyan().dim())) + .wrap(Wrap { trim: true }); frame.render_widget(hint, rows[0]); let selected = app.best_of_modal.as_ref().map(|m| m.selected).unwrap_or(0); let options = [1usize, 2, 3, 4]; let mut items: Vec = Vec::new(); for &attempts in &options { - let mut spans: Vec = - vec![format!("{attempts} attempt{}", if attempts == 1 { "" } else { "s" }).into()]; + let noun = if attempts == 1 { "attempt" } else { "attempts" }; + let mut spans: Vec = vec![format!("{attempts} {noun:<8}").into()]; spans.push(" ".into()); spans.push(format!("{attempts}x parallel").dim()); if attempts == app.best_of_n { diff --git a/codex-rs/cloud-tasks/src/util.rs b/codex-rs/cloud-tasks/src/util.rs index 9a610da244..8003a02f1e 100644 --- a/codex-rs/cloud-tasks/src/util.rs +++ b/codex-rs/cloud-tasks/src/util.rs @@ -70,7 +70,7 @@ pub async fn build_chatgpt_headers() -> HeaderMap { HeaderValue::from_str(&ua).unwrap_or(HeaderValue::from_static("codex-cli")), ); if let Ok(home) = codex_core::config::find_codex_home() { - let am = codex_login::AuthManager::new(home); + let am = codex_login::AuthManager::new(home, false); if let Some(auth) = am.auth() && let Ok(tok) = auth.get_token().await && !tok.is_empty() diff --git a/codex-rs/common/src/model_presets.rs b/codex-rs/common/src/model_presets.rs index 68acb5ce90..9954ad1890 100644 --- a/codex-rs/common/src/model_presets.rs +++ b/codex-rs/common/src/model_presets.rs @@ -20,49 +20,49 @@ const PRESETS: &[ModelPreset] = &[ ModelPreset { id: "gpt-5-codex-low", label: "gpt-5-codex low", - description: "", + description: "Fastest responses with limited reasoning", model: "gpt-5-codex", effort: Some(ReasoningEffort::Low), }, ModelPreset { id: "gpt-5-codex-medium", label: "gpt-5-codex medium", - description: "", + description: "Dynamically adjusts reasoning based on the task", model: "gpt-5-codex", effort: Some(ReasoningEffort::Medium), }, ModelPreset { id: "gpt-5-codex-high", label: "gpt-5-codex high", - description: "", + description: "Maximizes reasoning depth for complex or ambiguous problems", model: "gpt-5-codex", effort: Some(ReasoningEffort::High), }, ModelPreset { id: "gpt-5-minimal", label: "gpt-5 minimal", - description: "— fastest responses with limited reasoning; ideal for coding, instructions, or lightweight tasks", + description: "Fastest responses with little reasoning", model: "gpt-5", effort: Some(ReasoningEffort::Minimal), }, ModelPreset { id: "gpt-5-low", label: "gpt-5 low", - description: "— balances speed with some reasoning; useful for straightforward queries and short explanations", + description: "Balances speed with some reasoning; useful for straightforward queries and short explanations", model: "gpt-5", effort: Some(ReasoningEffort::Low), }, ModelPreset { id: "gpt-5-medium", label: "gpt-5 medium", - description: "— default setting; provides a solid balance of reasoning depth and latency for general-purpose tasks", + description: "Provides a solid balance of reasoning depth and latency for general-purpose tasks", model: "gpt-5", effort: Some(ReasoningEffort::Medium), }, ModelPreset { id: "gpt-5-high", label: "gpt-5 high", - description: "— maximizes reasoning depth for complex or ambiguous problems", + description: "Maximizes reasoning depth for complex or ambiguous problems", model: "gpt-5", effort: Some(ReasoningEffort::High), }, diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 442d6408e2..5054ce98bd 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -28,6 +28,7 @@ codex-app-server-protocol = { workspace = true } codex-otel = { workspace = true, features = ["otel"] } codex-utils-string = { workspace = true } dirs = { workspace = true } +dunce = { workspace = true } env-flags = { workspace = true } eventsource-stream = { workspace = true } futures = { workspace = true } diff --git a/codex-rs/core/gpt_5_codex_prompt.md b/codex-rs/core/gpt_5_codex_prompt.md index acff4b2f9e..33ab98807d 100644 --- a/codex-rs/core/gpt_5_codex_prompt.md +++ b/codex-rs/core/gpt_5_codex_prompt.md @@ -5,7 +5,6 @@ You are Codex, based on GPT-5. You are running as a coding agent in the Codex CL - The arguments to `shell` will be passed to execvp(). Most terminal commands should be prefixed with ["bash", "-lc"]. - Always set the `workdir` param when using the shell function. Do not use `cd` unless absolutely necessary. - When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.) -- When editing or creating files, you MUST use apply_patch as a standalone tool without going through ["bash", "-lc"], `Python`, `cat`, `sed`, ... Example: functions.shell({"command":["apply_patch","*** Begin Patch\nAdd File: hello.txt\n+Hello, world!\n*** End Patch"]}). ## Editing constraints @@ -90,7 +89,7 @@ You are producing plain text that will later be styled by the CLI. Follow these - Headers: optional; short Title Case (1-3 words) wrapped in **…**; no blank line before the first bullet; add only if they truly help. - Bullets: use - ; merge related points; keep to one line when possible; 4–6 per list ordered by importance; keep phrasing consistent. - Monospace: backticks for commands/paths/env vars/code ids and inline examples; use for literal keyword bullets; never combine with **. -- Code samples or multi-line snippets should be wrapped in fenced code blocks; add a language hint whenever obvious. +- Code samples or multi-line snippets should be wrapped in fenced code blocks; include an info string as often as possible. - Structure: group related bullets; order sections general → specific → supporting; for subsections, start with a bolded keyword bullet, then items; match complexity to the task. - Tone: collaborative, concise, factual; present tense, active voice; self‑contained; no "above/below"; parallel wording. - Don'ts: no nested bullets/hierarchies; no ANSI codes; don't cram unrelated keywords; keep keyword lists short—wrap/reformat if long; avoid naming formatting styles in answers. diff --git a/codex-rs/core/src/auth.rs b/codex-rs/core/src/auth.rs index b68540615a..4eea313e66 100644 --- a/codex-rs/core/src/auth.rs +++ b/codex-rs/core/src/auth.rs @@ -73,7 +73,7 @@ impl CodexAuth { /// Loads the available auth information from the auth.json. pub fn from_codex_home(codex_home: &Path) -> std::io::Result> { - load_auth(codex_home) + load_auth(codex_home, false) } pub async fn get_token_data(&self) -> Result { @@ -188,6 +188,7 @@ impl CodexAuth { } pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY"; +pub const CODEX_API_KEY_ENV_VAR: &str = "CODEX_API_KEY"; pub fn read_openai_api_key_from_env() -> Option { env::var(OPENAI_API_KEY_ENV_VAR) @@ -196,6 +197,13 @@ pub fn read_openai_api_key_from_env() -> Option { .filter(|value| !value.is_empty()) } +pub fn read_codex_api_key_from_env() -> Option { + env::var(CODEX_API_KEY_ENV_VAR) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) +} + pub fn get_auth_file(codex_home: &Path) -> PathBuf { codex_home.join("auth.json") } @@ -221,7 +229,18 @@ pub fn login_with_api_key(codex_home: &Path, api_key: &str) -> std::io::Result<( write_auth_json(&get_auth_file(codex_home), &auth_dot_json) } -fn load_auth(codex_home: &Path) -> std::io::Result> { +fn load_auth( + codex_home: &Path, + enable_codex_api_key_env: bool, +) -> std::io::Result> { + if enable_codex_api_key_env && let Some(api_key) = read_codex_api_key_from_env() { + let client = crate::default_client::create_client(); + return Ok(Some(CodexAuth::from_api_key_with_client( + api_key.as_str(), + client, + ))); + } + let auth_file = get_auth_file(codex_home); let client = crate::default_client::create_client(); let auth_dot_json = match try_read_auth_json(&auth_file) { @@ -455,7 +474,7 @@ mod tests { auth_dot_json, auth_file: _, .. - } = super::load_auth(codex_home.path()).unwrap().unwrap(); + } = super::load_auth(codex_home.path(), false).unwrap().unwrap(); assert_eq!(None, api_key); assert_eq!(AuthMode::ChatGPT, mode); @@ -494,7 +513,7 @@ mod tests { ) .unwrap(); - let auth = super::load_auth(dir.path()).unwrap().unwrap(); + let auth = super::load_auth(dir.path(), false).unwrap().unwrap(); assert_eq!(auth.mode, AuthMode::ApiKey); assert_eq!(auth.api_key, Some("sk-test-key".to_string())); @@ -577,6 +596,7 @@ mod tests { pub struct AuthManager { codex_home: PathBuf, inner: RwLock, + enable_codex_api_key_env: bool, } impl AuthManager { @@ -584,11 +604,14 @@ impl AuthManager { /// preferred auth method. Errors loading auth are swallowed; `auth()` will /// simply return `None` in that case so callers can treat it as an /// unauthenticated state. - pub fn new(codex_home: PathBuf) -> Self { - let auth = CodexAuth::from_codex_home(&codex_home).ok().flatten(); + pub fn new(codex_home: PathBuf, enable_codex_api_key_env: bool) -> Self { + let auth = load_auth(&codex_home, enable_codex_api_key_env) + .ok() + .flatten(); Self { codex_home, inner: RwLock::new(CachedAuth { auth }), + enable_codex_api_key_env, } } @@ -598,6 +621,7 @@ impl AuthManager { Arc::new(Self { codex_home: PathBuf::new(), inner: RwLock::new(cached), + enable_codex_api_key_env: false, }) } @@ -609,7 +633,9 @@ impl AuthManager { /// Force a reload of the auth information from auth.json. Returns /// whether the auth value changed. pub fn reload(&self) -> bool { - let new_auth = CodexAuth::from_codex_home(&self.codex_home).ok().flatten(); + let new_auth = load_auth(&self.codex_home, self.enable_codex_api_key_env) + .ok() + .flatten(); if let Ok(mut guard) = self.inner.write() { let changed = !AuthManager::auths_equal(&guard.auth, &new_auth); guard.auth = new_auth; @@ -628,8 +654,8 @@ impl AuthManager { } /// Convenience constructor returning an `Arc` wrapper. - pub fn shared(codex_home: PathBuf) -> Arc { - Arc::new(Self::new(codex_home)) + pub fn shared(codex_home: PathBuf, enable_codex_api_key_env: bool) -> Arc { + Arc::new(Self::new(codex_home, enable_codex_api_key_env)) } /// Attempt to refresh the current auth token (if any). On success, reload diff --git a/codex-rs/core/src/chat_completions.rs b/codex-rs/core/src/chat_completions.rs index f7194a0853..feedf3c4e5 100644 --- a/codex-rs/core/src/chat_completions.rs +++ b/codex-rs/core/src/chat_completions.rs @@ -6,6 +6,8 @@ use crate::client_common::ResponseEvent; use crate::client_common::ResponseStream; use crate::error::CodexErr; use crate::error::Result; +use crate::error::RetryLimitReachedError; +use crate::error::UnexpectedResponseError; use crate::model_family::ModelFamily; use crate::openai_tools::create_tools_json_for_chat_completions_api; use crate::util::backoff; @@ -320,11 +322,18 @@ pub(crate) async fn stream_chat_completions( let status = res.status(); if !(status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error()) { let body = (res.text().await).unwrap_or_default(); - return Err(CodexErr::UnexpectedStatus(status, body)); + return Err(CodexErr::UnexpectedStatus(UnexpectedResponseError { + status, + body, + request_id: None, + })); } if attempt > max_retries { - return Err(CodexErr::RetryLimit(status)); + return Err(CodexErr::RetryLimit(RetryLimitReachedError { + status, + request_id: None, + })); } let retry_after_secs = res diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index d8fd452431..0110f6bc7d 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -5,6 +5,8 @@ use std::time::Duration; use crate::AuthManager; use crate::auth::CodexAuth; +use crate::error::RetryLimitReachedError; +use crate::error::UnexpectedResponseError; use bytes::Bytes; use codex_app_server_protocol::AuthMode; use codex_protocol::ConversationId; @@ -308,14 +310,17 @@ impl ModelClient { .log_request(attempt, || req_builder.send()) .await; + let mut request_id = None; if let Ok(resp) = &res { + request_id = resp + .headers() + .get("cf-ray") + .map(|v| v.to_str().unwrap_or_default().to_string()); + trace!( - "Response status: {}, cf-ray: {}", + "Response status: {}, cf-ray: {:?}", resp.status(), - resp.headers() - .get("cf-ray") - .map(|v| v.to_str().unwrap_or_default()) - .unwrap_or_default() + request_id ); } @@ -375,7 +380,11 @@ impl ModelClient { // Surface the error body to callers. Use `unwrap_or_default` per Clippy. let body = res.text().await.unwrap_or_default(); return Err(StreamAttemptError::Fatal(CodexErr::UnexpectedStatus( - status, body, + UnexpectedResponseError { + status, + body, + request_id: None, + }, ))); } @@ -406,6 +415,7 @@ impl ModelClient { Err(StreamAttemptError::RetryableHttpError { status, retry_after, + request_id, }) } Err(e) => Err(StreamAttemptError::RetryableTransportError(e.into())), @@ -449,6 +459,7 @@ enum StreamAttemptError { RetryableHttpError { status: StatusCode, retry_after: Option, + request_id: Option, }, RetryableTransportError(CodexErr), Fatal(CodexErr), @@ -473,11 +484,13 @@ impl StreamAttemptError { fn into_error(self) -> CodexErr { match self { - Self::RetryableHttpError { status, .. } => { + Self::RetryableHttpError { + status, request_id, .. + } => { if status == StatusCode::INTERNAL_SERVER_ERROR { CodexErr::InternalServerError } else { - CodexErr::RetryLimit(status) + CodexErr::RetryLimit(RetryLimitReachedError { status, request_id }) } } Self::RetryableTransportError(error) => error, diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index f1c0d90094..a076e7f21a 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -1,3 +1,4 @@ +use crate::client_common::tools::ToolSpec; use crate::error::Result; use crate::model_family::ModelFamily; use crate::openai_tools::ToolSpec; @@ -159,6 +160,54 @@ pub(crate) struct ResponsesApiRequest<'a> { pub(crate) text: Option, } +pub(crate) mod tools { + use crate::openai_tools::JsonSchema; + use serde::Deserialize; + use serde::Serialize; + + /// When serialized as JSON, this produces a valid "Tool" in the OpenAI + /// Responses API. + #[derive(Debug, Clone, Serialize, PartialEq)] + #[serde(tag = "type")] + pub(crate) enum ToolSpec { + #[serde(rename = "function")] + Function(ResponsesApiTool), + #[serde(rename = "local_shell")] + LocalShell {}, + // TODO: Understand why we get an error on web_search although the API docs say it's supported. + // https://platform.openai.com/docs/guides/tools-web-search?api-mode=responses#:~:text=%7B%20type%3A%20%22web_search%22%20%7D%2C + #[serde(rename = "web_search")] + WebSearch {}, + #[serde(rename = "custom")] + Freeform(FreeformTool), + } + + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] + pub struct FreeformTool { + pub(crate) name: String, + pub(crate) description: String, + pub(crate) format: FreeformToolFormat, + } + + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] + pub struct FreeformToolFormat { + pub(crate) r#type: String, + pub(crate) syntax: String, + pub(crate) definition: String, + } + + #[derive(Debug, Clone, Serialize, PartialEq)] + pub struct ResponsesApiTool { + pub(crate) name: String, + pub(crate) description: String, + /// TODO: Validation. When strict is set to true, the JSON schema, + /// `required` and `additional_properties` must be present. All fields in + /// `properties` must be present in `required`. + pub(crate) strict: bool, + pub(crate) parameters: JsonSchema, + } +} + pub(crate) fn create_reasoning_param_for_request( model_family: &ModelFamily, effort: Option, diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 167668ccf1..e5211f8e2c 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -19,6 +19,7 @@ use codex_protocol::protocol::ConversationPathResponseEvent; use codex_protocol::protocol::ExitedReviewModeEvent; use codex_protocol::protocol::ReviewRequest; use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::TaskStartedEvent; use codex_protocol::protocol::TurnAbortReason; use codex_protocol::protocol::TurnContextItem; @@ -98,9 +99,7 @@ use crate::state::SessionServices; use crate::tasks::CompactTask; use crate::tasks::RegularTask; use crate::tasks::ReviewTask; -use crate::tools::Router; -use crate::tools::executor::ProcessedResponseItem; -use crate::tools::executor::ToolCallExecutor; +use crate::tools::ToolRouter; use crate::tools::format_exec_output_str; use crate::turn_diff_tracker::TurnDiffTracker; use crate::unified_exec::UnifiedExecSessionManager; @@ -146,6 +145,7 @@ impl Codex { config: Config, auth_manager: Arc, conversation_history: InitialHistory, + session_source: SessionSource, ) -> CodexResult { let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY); let (tx_event, rx_event) = async_channel::unbounded(); @@ -174,6 +174,7 @@ impl Codex { auth_manager.clone(), tx_event.clone(), conversation_history, + session_source, ) .await .map_err(|e| { @@ -308,6 +309,7 @@ impl Session { auth_manager: Arc, tx_event: Sender, initial_history: InitialHistory, + session_source: SessionSource, ) -> anyhow::Result<(Arc, TurnContext)> { let ConfigureSession { provider, @@ -331,7 +333,11 @@ impl Session { let conversation_id = ConversationId::default(); ( conversation_id, - RolloutRecorderParams::new(conversation_id, user_instructions.clone()), + RolloutRecorderParams::new( + conversation_id, + user_instructions.clone(), + session_source, + ), ) } InitialHistory::Resumed(resumed_history) => ( @@ -501,6 +507,10 @@ impl Session { Ok((sess, turn_context)) } + pub(crate) fn get_tx_event(&self) -> Sender { + self.tx_event.clone() + } + fn next_internal_sub_id(&self) -> String { let id = self .next_internal_sub_id @@ -1907,16 +1917,11 @@ async fn run_turn( input: Vec, ) -> CodexResult { let mcp_tools = sess.services.mcp_connection_manager.list_all_tools(); - let router = Arc::new(Router::from_config( - &turn_context.tools_config, - Some(mcp_tools), - )); - - let tool_specs = router.specs().to_vec(); + let router = ToolRouter::from_config(&turn_context.tools_config, Some(mcp_tools)); let prompt = Prompt { input, - tools: tool_specs, + tools: router.specs().to_vec(), base_instructions_override: turn_context.base_instructions.clone(), output_schema: turn_context.final_output_json_schema.clone(), }; @@ -1938,6 +1943,7 @@ async fn run_turn( Ok(output) => return Ok(output), Err(CodexErr::Interrupted) => return Err(CodexErr::Interrupted), Err(CodexErr::EnvVar(var)) => return Err(CodexErr::EnvVar(var)), + Err(e @ CodexErr::Fatal(_)) => return Err(e), Err(CodexErr::UsageLimitReached(e)) => { let rate_limits = e.rate_limits.clone(); if let Some(rate_limits) = rate_limits { @@ -2301,6 +2307,13 @@ mod tests { use crate::tools::MODEL_FORMAT_MAX_LINES; use crate::tools::MODEL_FORMAT_TAIL_LINES; use crate::tools::handle_container_exec_with_params; + use crate::tools::MODEL_FORMAT_HEAD_LINES; + use crate::tools::MODEL_FORMAT_MAX_BYTES; + use crate::tools::MODEL_FORMAT_MAX_LINES; + use crate::tools::MODEL_FORMAT_TAIL_LINES; + use crate::tools::ToolRouter; + use crate::tools::handle_container_exec_with_params; + use crate::turn_diff_tracker::TurnDiffTracker; use codex_app_server_protocol::AuthMode; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; @@ -2794,6 +2807,43 @@ mod tests { ); } + #[tokio::test] + async fn fatal_tool_error_stops_turn_and_reports_error() { + let (session, turn_context, _rx) = make_session_and_context_with_rx(); + let session_ref = session.as_ref(); + let turn_context_ref = turn_context.as_ref(); + let router = ToolRouter::from_config( + &turn_context_ref.tools_config, + Some(session_ref.services.mcp_connection_manager.list_all_tools()), + ); + let mut tracker = TurnDiffTracker::new(); + let item = ResponseItem::CustomToolCall { + id: None, + status: None, + call_id: "call-1".to_string(), + name: "shell".to_string(), + input: "{}".to_string(), + }; + + let err = handle_response_item( + &router, + session_ref, + turn_context_ref, + &mut tracker, + "sub-id", + item, + ) + .await + .expect_err("expected fatal error"); + + match err { + CodexErr::Fatal(message) => { + assert_eq!(message, "tool shell invoked with incompatible payload"); + } + other => panic!("expected CodexErr::Fatal, got {other:?}"), + } + } + fn sample_rollout( session: &Session, turn_context: &TurnContext, diff --git a/codex-rs/core/src/command_safety/windows_safe_commands.rs b/codex-rs/core/src/command_safety/windows_safe_commands.rs index c6e781f8ae..ff0a3d2e7b 100644 --- a/codex-rs/core/src/command_safety/windows_safe_commands.rs +++ b/codex-rs/core/src/command_safety/windows_safe_commands.rs @@ -1,25 +1,431 @@ -// This is a WIP. This will eventually contain a real list of common safe Windows commands. -pub fn is_safe_command_windows(_command: &[String]) -> bool { +use shlex::split as shlex_split; + +/// On Windows, we conservatively allow only clearly read-only PowerShell invocations +/// that match a small safelist. Anything else (including direct CMD commands) is unsafe. +pub fn is_safe_command_windows(command: &[String]) -> bool { + if let Some(commands) = try_parse_powershell_command_sequence(command) { + return commands + .iter() + .all(|cmd| is_safe_powershell_command(cmd.as_slice())); + } + // Only PowerShell invocations are allowed on Windows for now; anything else is unsafe. + false +} + +/// Returns each command sequence if the invocation starts with a PowerShell binary. +/// For example, the tokens from `pwsh Get-ChildItem | Measure-Object` become two sequences. +fn try_parse_powershell_command_sequence(command: &[String]) -> Option>> { + let (exe, rest) = command.split_first()?; + if !is_powershell_executable(exe) { + return None; + } + parse_powershell_invocation(rest) +} + +/// Parses a PowerShell invocation into discrete command vectors, rejecting unsafe patterns. +fn parse_powershell_invocation(args: &[String]) -> Option>> { + if args.is_empty() { + // Examples rejected here: "pwsh" and "powershell.exe" with no additional arguments. + return None; + } + + let mut idx = 0; + while idx < args.len() { + let arg = &args[idx]; + let lower = arg.to_ascii_lowercase(); + match lower.as_str() { + "-command" | "/command" | "-c" => { + let script = args.get(idx + 1)?; + if idx + 2 != args.len() { + // Reject if there is more than one token representing the actual command. + // Examples rejected here: "pwsh -Command foo bar" and "powershell -c ls extra". + return None; + } + return parse_powershell_script(script); + } + _ if lower.starts_with("-command:") || lower.starts_with("/command:") => { + if idx + 1 != args.len() { + // Reject if there are more tokens after the command itself. + // Examples rejected here: "pwsh -Command:dir C:\\" and "powershell /Command:dir C:\\" with trailing args. + return None; + } + let script = arg.split_once(':')?.1; + return parse_powershell_script(script); + } + + // Benign, no-arg flags we tolerate. + "-nologo" | "-noprofile" | "-noninteractive" | "-mta" | "-sta" => { + idx += 1; + continue; + } + + // Explicitly forbidden/opaque or unnecessary for read-only operations. + "-encodedcommand" | "-ec" | "-file" | "/file" | "-windowstyle" | "-executionpolicy" + | "-workingdirectory" => { + // Examples rejected here: "pwsh -EncodedCommand ..." and "powershell -File script.ps1". + return None; + } + + // Unknown switch → bail conservatively. + _ if lower.starts_with('-') => { + // Examples rejected here: "pwsh -UnknownFlag" and "powershell -foo bar". + return None; + } + + // If we hit non-flag tokens, treat the remainder as a command sequence. + // This happens if powershell is invoked without -Command, e.g. + // ["pwsh", "-NoLogo", "git", "-c", "core.pager=cat", "status"] + _ => { + return split_into_commands(args[idx..].to_vec()); + } + } + } + + // Examples rejected here: "pwsh" and "powershell.exe -NoLogo" without a script. + None +} + +/// Tokenizes an inline PowerShell script and delegates to the command splitter. +/// Examples of when this is called: pwsh.exe -Command '