diff --git a/.gitattributes b/.gitattributes index 8b9528d..f2b6bab 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,5 +5,7 @@ *.toml text eol=lf *.yml text eol=lf *.yaml text eol=lf +*.sh text eol=lf +tests/fixtures/proxy_bootstrap/forge-proxy-* binary # Eval result dumps (any variant: bare, rig-tagged, version-tagged) go to LFS. eval_results*.jsonl filter=lfs diff=lfs merge=lfs -text diff --git a/.github/workflows/proxy-release-candidate.yml b/.github/workflows/proxy-release-candidate.yml new file mode 100644 index 0000000..11fd426 --- /dev/null +++ b/.github/workflows/proxy-release-candidate.yml @@ -0,0 +1,94 @@ +name: Proxy release candidate + +on: + pull_request: + branches: [main] + paths: + - installer/proxy-stable.txt + +permissions: + contents: read + +jobs: + native: + name: Proxy ${{ matrix.target }} + strategy: + fail-fast: false + matrix: + include: + - target: windows-x86_64 + runner: windows-2022 + artifact: standalone-dist/windows-x86_64/onefile/forge-proxy.exe + - target: linux-x86_64-gnu + runner: ubuntu-22.04 + artifact: standalone-dist/linux-x86_64-gnu/onefile/forge-proxy + - target: macos-arm64 + runner: macos-14 + artifact: standalone-dist/macos-arm64/onefile/forge-proxy + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.14' + - run: python -m pip install '.[anthropic]' pyinstaller pytest + - name: Verify Proxy and Forge versions agree + run: >- + python -c "from pathlib import Path; from scripts.standalone.release import project_version; + proxy_version = Path('installer/proxy-stable.txt').read_text(encoding='utf-8').strip(); + forge_version = project_version(); + assert proxy_version == forge_version, + f'Proxy version {proxy_version!r} does not match Forge version {forge_version!r}'" + - name: Run public bootstrap contracts + run: >- + python -m pytest -m integration + tests/integration/bootstrap_contract -v --tb=short + - name: Build once and run packaged smoke + run: python -m scripts.standalone.build --target ${{ matrix.target }} --form all + - name: Run Windows installer acceptance + if: runner.os == 'Windows' + run: >- + python -m pytest -m acceptance + tests/integration/platform_acceptance -v --tb=short + - name: Run selected-artifact lifecycle smoke + shell: bash + run: | + version=$(python -c "from scripts.standalone.release import project_version; print(project_version())") + digest=$(python -c "from pathlib import Path; from scripts.standalone.release import sha256; print(sha256(Path(r'${{ matrix.artifact }}')))") + python -m scripts.standalone.lifecycle_smoke '${{ matrix.artifact }}' --version "$version" --sha256 "$digest" --target '${{ matrix.target }}' --output 'standalone-dist/${{ matrix.target }}/lifecycle.json' + - name: Record selected immutable bytes and portable evidence + shell: bash + run: | + python -m scripts.standalone.release record --artifact '${{ matrix.artifact }}' --target '${{ matrix.target }}' --output 'release-input/${{ matrix.target }}' --evidence 'standalone-dist/${{ matrix.target }}/onefile/evidence.json' --evidence 'standalone-dist/${{ matrix.target }}/lifecycle.json' + python -m scripts.standalone.release verify 'release-input/${{ matrix.target }}' + tar -czf 'proxy-${{ matrix.target }}.tgz' -C release-input '${{ matrix.target }}' + - uses: actions/upload-artifact@v4 + with: + name: proxy-${{ matrix.target }} + path: proxy-${{ matrix.target }}.tgz + if-no-files-found: error + - name: Exercise identical Linux bytes on Ubuntu 22.04 + if: runner.os == 'Linux' + shell: bash + run: | + mkdir -p linux-evidence/ubuntu-22.04 + docker run --rm -v "$PWD:/work" -w /work ubuntu:22.04 bash -lc 'apt-get update && apt-get install -y python3 ca-certificates curl && python3 -m scripts.standalone.release verify release-input/linux-x86_64-gnu && version=$(python3 -c "import json; print(json.load(open(\"release-input/linux-x86_64-gnu/selection.json\"))[\"version\"])") && digest=$(python3 -c "import json; print(json.load(open(\"release-input/linux-x86_64-gnu/selection.json\"))[\"sha256\"])") && cat /etc/os-release > /work/linux-evidence/ubuntu-22.04/os-release.txt && ldd --version > /work/linux-evidence/ubuntu-22.04/glibc.txt 2>&1 && sha256sum release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu > /work/linux-evidence/ubuntu-22.04/sha256.txt && python3 -m scripts.standalone.smoke release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu --form onefile --expected-version "$version" > /work/linux-evidence/ubuntu-22.04/packaged-smoke.json && python3 -m scripts.standalone.lifecycle_smoke release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu --version "$version" --sha256 "$digest" --target linux-x86_64-gnu --output /work/linux-evidence/ubuntu-22.04/lifecycle.json' + - name: Exercise identical Linux bytes on Debian 12 + if: runner.os == 'Linux' + shell: bash + run: | + mkdir -p linux-evidence/debian-12 + docker run --rm -v "$PWD:/work" -w /work debian:12 bash -lc 'apt-get update && apt-get install -y python3 ca-certificates curl && python3 -m scripts.standalone.release verify release-input/linux-x86_64-gnu && version=$(python3 -c "import json; print(json.load(open(\"release-input/linux-x86_64-gnu/selection.json\"))[\"version\"])") && digest=$(python3 -c "import json; print(json.load(open(\"release-input/linux-x86_64-gnu/selection.json\"))[\"sha256\"])") && cat /etc/os-release > /work/linux-evidence/debian-12/os-release.txt && ldd --version > /work/linux-evidence/debian-12/glibc.txt 2>&1 && sha256sum release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu > /work/linux-evidence/debian-12/sha256.txt && python3 -m scripts.standalone.smoke release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu --form onefile --expected-version "$version" > /work/linux-evidence/debian-12/packaged-smoke.json && python3 -m scripts.standalone.lifecycle_smoke release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu --version "$version" --sha256 "$digest" --target linux-x86_64-gnu --output /work/linux-evidence/debian-12/lifecycle.json' + - name: Exercise identical Linux bytes on Fedora 44 + if: runner.os == 'Linux' + shell: bash + run: | + mkdir -p linux-evidence/fedora-44 + docker run --rm -v "$PWD:/work" -w /work fedora:44 bash -lc 'dnf install -y python3 ca-certificates curl && python3 -m scripts.standalone.release verify release-input/linux-x86_64-gnu && version=$(python3 -c "import json; print(json.load(open(\"release-input/linux-x86_64-gnu/selection.json\"))[\"version\"])") && digest=$(python3 -c "import json; print(json.load(open(\"release-input/linux-x86_64-gnu/selection.json\"))[\"sha256\"])") && cat /etc/os-release > /work/linux-evidence/fedora-44/os-release.txt && ldd --version > /work/linux-evidence/fedora-44/glibc.txt 2>&1 && sha256sum release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu > /work/linux-evidence/fedora-44/sha256.txt && python3 -m scripts.standalone.smoke release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu --form onefile --expected-version "$version" > /work/linux-evidence/fedora-44/packaged-smoke.json && python3 -m scripts.standalone.lifecycle_smoke release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu --version "$version" --sha256 "$digest" --target linux-x86_64-gnu --output /work/linux-evidence/fedora-44/lifecycle.json' + - name: Upload Linux runtime evidence + if: runner.os == 'Linux' + uses: actions/upload-artifact@v4 + with: + name: linux-runtime-evidence + path: linux-evidence + if-no-files-found: error diff --git a/.github/workflows/proxy-release.yml b/.github/workflows/proxy-release.yml new file mode 100644 index 0000000..09a0be5 --- /dev/null +++ b/.github/workflows/proxy-release.yml @@ -0,0 +1,250 @@ +name: Publish exact-tag Proxy release + +on: + workflow_dispatch: + inputs: + tag: + description: Existing exact Forge tag and GitHub Release (vX.Y.Z) + required: true + type: string + +concurrency: + group: proxy-release-${{ inputs.tag }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + identity: + runs-on: ubuntu-22.04 + outputs: + version: ${{ steps.identity.outputs.version }} + commit: ${{ steps.identity.outputs.commit }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag }} + fetch-depth: 0 + - id: identity + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.tag }} + DISPATCH_REF: ${{ github.ref }} + DISPATCH_SHA: ${{ github.sha }} + REPOSITORY: ${{ github.repository }} + run: | + version=$(python3 -c "from scripts.standalone.release import project_version; print(project_version())") + test "$TAG" = "v$version" + test "$DISPATCH_REF" = "refs/tags/$TAG" + checkout=$(git rev-parse HEAD) + peeled=$(git rev-parse "$TAG^{commit}") + test "$checkout" = "$peeled" + test "$DISPATCH_SHA" = "$peeled" + release=$(gh api "repos/$REPOSITORY/releases/tags/$TAG") + test "$(printf '%s' "$release" | jq -r .tag_name)" = "$TAG" + printf 'Release target_commitish (informational only): %s\n' "$(printf '%s' "$release" | jq -r .target_commitish)" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "commit=$peeled" >> "$GITHUB_OUTPUT" + + native: + needs: identity + name: Tag bytes / ${{ matrix.target }} + strategy: + fail-fast: false + matrix: + include: + - target: windows-x86_64 + runner: windows-2022 + artifact: standalone-dist/windows-x86_64/onefile/forge-proxy.exe + - target: linux-x86_64-gnu + runner: ubuntu-22.04 + artifact: standalone-dist/linux-x86_64-gnu/onefile/forge-proxy + - target: macos-arm64 + runner: macos-14 + artifact: standalone-dist/macos-arm64/onefile/forge-proxy + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag }} + - uses: actions/setup-python@v5 + with: + python-version: '3.14' + - run: python -m pip install '.[anthropic]' pyinstaller pytest + - name: Build once and run packaged smoke + run: python -m scripts.standalone.build --target ${{ matrix.target }} --form all + - name: Run Windows installer acceptance + if: runner.os == 'Windows' + run: >- + python -m pytest -m acceptance + tests/integration/platform_acceptance -v --tb=short + - name: Run lifecycle on the selected bytes + shell: bash + run: | + digest=$(python -c "from pathlib import Path; from scripts.standalone.release import sha256; print(sha256(Path(r'${{ matrix.artifact }}')))") + python -m scripts.standalone.lifecycle_smoke '${{ matrix.artifact }}' --version '${{ needs.identity.outputs.version }}' --sha256 "$digest" --target '${{ matrix.target }}' --output 'standalone-dist/${{ matrix.target }}/lifecycle.json' + - name: Archive tested exact bytes with digest evidence + shell: bash + run: | + python -m scripts.standalone.release record --artifact '${{ matrix.artifact }}' --target '${{ matrix.target }}' --output 'release-input/${{ matrix.target }}' --evidence 'standalone-dist/${{ matrix.target }}/onefile/evidence.json' --evidence 'standalone-dist/${{ matrix.target }}/lifecycle.json' + python -m scripts.standalone.release verify 'release-input/${{ matrix.target }}' + tar -czf 'proxy-${{ matrix.target }}.tgz' -C release-input '${{ matrix.target }}' + - uses: actions/upload-artifact@v4 + with: + name: tag-proxy-${{ matrix.target }} + path: proxy-${{ matrix.target }}.tgz + if-no-files-found: error + + linux_compat: + needs: [identity, native] + name: Tag Linux bytes / ${{ matrix.name }} + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + include: + - name: ubuntu-22.04 + image: ubuntu:22.04 + setup: apt-get update && apt-get install -y python3 ca-certificates curl + - name: debian-12 + image: debian:12 + setup: apt-get update && apt-get install -y python3 ca-certificates curl + - name: fedora-44 + image: fedora:44 + setup: dnf install -y python3 ca-certificates curl + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag }} + - uses: actions/download-artifact@v4 + with: + name: tag-proxy-linux-x86_64-gnu + - name: Verify download and execute identical Linux bytes + run: | + tar -xzf proxy-linux-x86_64-gnu.tgz + python3 -m scripts.standalone.release verify release-input/linux-x86_64-gnu + mkdir -p 'linux-evidence/${{ matrix.name }}' + docker run --rm -v "$PWD:/work" -w /work '${{ matrix.image }}' bash -lc '${{ matrix.setup }} && python3 -m scripts.standalone.release verify release-input/linux-x86_64-gnu && cat /etc/os-release > /work/linux-evidence/${{ matrix.name }}/os-release.txt && ldd --version > /work/linux-evidence/${{ matrix.name }}/glibc.txt 2>&1 && sha256sum release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu > /work/linux-evidence/${{ matrix.name }}/sha256.txt && python3 -m scripts.standalone.smoke release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu --form onefile --expected-version "${{ needs.identity.outputs.version }}" > /work/linux-evidence/${{ matrix.name }}/packaged-smoke.json && digest=$(python3 -c "import json; print(json.load(open(\"release-input/linux-x86_64-gnu/selection.json\"))[\"sha256\"])") && python3 -m scripts.standalone.lifecycle_smoke release-input/linux-x86_64-gnu/forge-proxy-linux-x86_64-gnu --version "${{ needs.identity.outputs.version }}" --sha256 "$digest" --target linux-x86_64-gnu --output /work/linux-evidence/${{ matrix.name }}/lifecycle.json' + - uses: actions/upload-artifact@v4 + with: + name: tag-linux-runtime-${{ matrix.name }} + path: linux-evidence/${{ matrix.name }} + if-no-files-found: error + + staging: + needs: [identity, native, linux_compat] + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag }} + - uses: actions/download-artifact@v4 + with: + pattern: tag-proxy-* + path: downloads + - name: Re-hash every download and assemble one immutable complete set + run: | + mkdir inputs + find downloads -name '*.tgz' -print0 | while IFS= read -r -d '' archive; do tar -xzf "$archive" -C inputs; done + for target in windows-x86_64 linux-x86_64-gnu macos-arm64; do python3 -m scripts.standalone.release verify "inputs/$target"; done + python3 -m scripts.standalone.release assemble --input inputs/windows-x86_64 --input inputs/linux-x86_64-gnu --input inputs/macos-arm64 --output publication + python3 -m scripts.standalone.release verify-staging publication + tar -czf proxy-publication.tgz publication + - uses: actions/upload-artifact@v4 + with: + name: immutable-proxy-publication + path: proxy-publication.tgz + if-no-files-found: error + + publish: + needs: [identity, staging] + runs-on: ubuntu-22.04 + environment: proxy-release + permissions: + contents: write + id-token: write + attestations: write + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag }} + fetch-depth: 0 + - uses: actions/download-artifact@v4 + with: + name: immutable-proxy-publication + - name: Re-hash the immutable staged set and recheck exact identity + env: + GH_TOKEN: ${{ github.token }} + TAG: ${{ inputs.tag }} + REPOSITORY: ${{ github.repository }} + run: | + tar -xzf proxy-publication.tgz + python3 -m scripts.standalone.release verify-staging publication + test "$(git rev-parse "$TAG^{commit}")" = '${{ needs.identity.outputs.commit }}' + test "$(git rev-parse HEAD)" = '${{ needs.identity.outputs.commit }}' + test "$(python3 -c "from scripts.standalone.release import project_version; print('v' + project_version())")" = "$TAG" + test "$(gh api "repos/$REPOSITORY/releases/tags/$TAG" --jq .tag_name)" = "$TAG" + - name: Attest all three platform artifacts + uses: actions/attest-build-provenance@v2 + with: + subject-path: | + publication/forge-proxy-windows-x86_64.exe + publication/forge-proxy-linux-x86_64-gnu + publication/forge-proxy-macos-arm64 + - name: Publish with journaled rollback and manifest last + env: + GH_TOKEN: ${{ github.token }} + run: python3 -m scripts.standalone.release publish --repository '${{ github.repository }}' --tag '${{ inputs.tag }}' --peeled-commit '${{ needs.identity.outputs.commit }}' --expected-commit '${{ github.sha }}' publication + + exact_install: + needs: [identity, publish] + name: Published exact install / ${{ matrix.target }} + strategy: + fail-fast: false + matrix: + include: + - target: windows-x86_64 + runner: windows-2022 + - target: linux-x86_64-gnu + runner: ubuntu-22.04 + - target: macos-arm64 + runner: macos-14 + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.tag }} + - name: Exact install, initialize, check, and uninstall on Windows + if: runner.os == 'Windows' + shell: pwsh + run: | + $root = Join-Path $env:RUNNER_TEMP 'proxy exact install' + $env:APPDATA = Join-Path $env:RUNNER_TEMP 'proxy-appdata' + $env:LOCALAPPDATA = Join-Path $env:RUNNER_TEMP 'proxy-localappdata' + $env:FORGE_PROXY_PATH_FILE = Join-Path $env:RUNNER_TEMP 'proxy-path.txt' + Set-Content -NoNewline $env:FORGE_PROXY_PATH_FILE 'existing-path' + .\install.ps1 -Version '${{ needs.identity.outputs.version }}' -NoInit -InstallRoot $root + $proxy = Join-Path $root 'bin\forge-proxy.cmd' + & $proxy init --non-interactive --force --backend-url 'http://127.0.0.1:1' + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + & $proxy check + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + & $proxy uninstall + $deadline = (Get-Date).AddSeconds(20) + while ((Test-Path $root) -and (Get-Date) -lt $deadline) { Start-Sleep -Milliseconds 100 } + if (Test-Path $root) { throw 'exact installation remained after uninstall' } + - name: Exact install, initialize, check, and uninstall on POSIX + if: runner.os != 'Windows' + shell: bash + run: | + root="$RUNNER_TEMP/proxy exact install" + export HOME="$RUNNER_TEMP/proxy-home" + export XDG_CONFIG_HOME="$HOME/.config" + mkdir -p "$HOME" + sh install.sh --version '${{ needs.identity.outputs.version }}' --no-init --install-root "$root" + "$root/bin/forge-proxy" init --non-interactive --force --backend-url 'http://127.0.0.1:1' + "$root/bin/forge-proxy" check + "$root/bin/forge-proxy" uninstall + for attempt in $(seq 1 200); do test ! -e "$root" && break; sleep 0.1; done + test ! -e "$root" diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 13cd82f..38b9cfc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.12", "3.13"] + python-version: ["3.12", "3.13", "3.14"] steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index 40aaf45..ed78e82 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ __pycache__/ *.egg-info/ dist/ build/ +standalone-dist/ +.standalone-build-env/ *.egg # Virtual environments diff --git a/CHANGELOG.md b/CHANGELOG.md index 3979d7a..0a90921 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,48 @@ All notable changes to forge are documented here. +## [0.9.1] — 2026-08-16 + +A distribution and evaluation maintenance release. Forge 0.9.1 adds a +standalone, dependency-free delivery path for Forge Proxy without changing the +0.9 forwarding, routing, or guardrail contract. It also extends managed +llama.cpp operation and publishes the latest v0.9 evaluation work. + +### Added + +- **Standalone Forge Proxy distribution.** One-file artifacts for Windows x64, + Linux x64/glibc, and macOS ARM64 include Forge and their private Python 3.14 + runtime. Thin PowerShell and POSIX bootstraps verify exact release manifests + and checksums before handing installation to the frozen executable. +- **Profile and installation lifecycle commands.** `forge-proxy init`, + `check`, `update`, and `uninstall` provide sparse TOML profiles, offline + validation, immutable version slots, forward updates, exact-version recovery, + PATH integration, and profile-preserving removal. Installation remains + noninteractive and prints explicit configuration steps for humans and + external wrappers. +- **Cross-platform Proxy release gates.** Candidate and exact-tag workflows + build and exercise Windows, Linux, and macOS artifacts; the selected Linux + bytes are additionally checked on Ubuntu 22.04, Debian 12, and Fedora. Frozen + lifecycle coverage includes installation, initialization, health checking, + update/recovery, safe failure before promotion, and uninstall. +- **Managed llama.cpp RPC operation.** Forge can own a one-worker RPC topology, + use it from canonical managed-backend lifecycles, and stop the worker through + its foreground SSH session. DeepSeek V4 campaign guidance and topology + examples accompany the new path. #143 +- **Expanded v0.9 evaluation publication.** Published results now include Muse + Glimmer and Qwen3.8 27B reasoning-effort sweeps, with explicit effort metadata + and updated managed-server recipes. #143, #144 + +### Changed + +- **Evaluation outcome and publication vocabulary is explicit.** Collection, + reports, dashboards, dataset metadata, and citation guidance now distinguish + task outcomes from run health and carry the clarified publication contract + consistently. #141 +- **Python 3.14 is supported.** The source package remains compatible with + Python 3.12 and 3.13, while CI now covers 3.14 and standalone Proxy artifacts + use Python 3.14 as their bundled private runtime. + ## [0.9.0] — 2026-08-09 Forge's Proxy has become its most-used integration surface while backend diff --git a/README.md b/README.md index 9e903c3..bafa567 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Forge takes an 8B local model from single digits to 84% across forge's 26-scenar **Three ways to use it:** -- **Proxy server** — Drop-in proxy (`python -m forge.proxy`) speaking both the OpenAI chat-completions and Anthropic Messages (`/v1/messages`) APIs, sitting between any client and a local model server. Point OpenAI-compatible tools (opencode, Continue, aider) **or Claude Code** at it and forge applies guardrails transparently — the client thinks it's talking to a smarter model. Most popular entry point. +- **Proxy server** — Drop-in proxy (`forge-proxy`, or `python -m forge.proxy` from the Python package) speaking both the OpenAI chat-completions and Anthropic Messages (`/v1/messages`) APIs, sitting between any client and a local model server. Point OpenAI-compatible tools (opencode, Continue, aider) **or Claude Code** at it and forge applies guardrails transparently — the client thinks it's talking to a smarter model. Most popular entry point. - **WorkflowRunner** — Define tools, pick a backend, run structured agent loops. Forge manages the full lifecycle: system prompts, tool execution, context compaction, and guardrails. **SlotWorker** adds priority-queued access to a shared inference slot with auto-preemption — for multi-agent architectures where specialist workflows share a GPU slot. Best when you're building on forge directly. @@ -25,13 +25,48 @@ Forge takes an 8B local model from single digits to 84% across forge's 26-scenar Supports generic OpenAI-compatible endpoints, Ollama, llama-server (llama.cpp), Llamafile, vLLM, and Anthropic as backends. -## Requirements +## Standalone Forge Proxy + +Forge Proxy is a self-contained developer sidecar: point an OpenAI- or +Anthropic-compatible client at it to add Forge guardrails without rewriting the +client or integrating the Python library. The command bundles Forge, its private +Python runtime, and the Anthropic SDK, so the host does not need Python or pip. +It does not install a backend executable, model, GPU stack, service, +credentials, or client configuration. + +Install the latest verified standalone Proxy release: + +Linux and macOS: + +```sh +curl -fsSL https://raw.githubusercontent.com/antoinezambelli/forge/main/install.sh | sh +``` + +Windows PowerShell: + +```powershell +irm https://raw.githubusercontent.com/antoinezambelli/forge/main/install.ps1 | iex +``` + +Open a refreshed terminal, then create and validate a profile: + +```bash +forge-proxy init +forge-proxy check +``` + +See [Forge Proxy Installation](docs/PROXY_INSTALLATION.md) for supported +platforms, exact-version installation, profiles, updates, recovery, and +uninstall. + +## Python Library Install + +Use the Python package for `WorkflowRunner`, guardrails middleware, development, +or a Python-managed Proxy. It requires: - Python 3.12+ - A running LLM backend (see below) -## Install - ```bash pip install forge-guardrails # core only pip install "forge-guardrails[anthropic]" # + Anthropic client @@ -320,6 +355,7 @@ tests/ ## Documentation +- [Forge Proxy Installation](docs/PROXY_INSTALLATION.md) — Standalone platform installation, profiles, updates, recovery, and uninstall - [User Guide](docs/USER_GUIDE.md) — Usage patterns, multi-turn, context management, guardrails, slot worker, long-running session advisory - [Model Guide](docs/MODEL_GUIDE.md) — Which model and backend for your hardware - [Backend Setup](docs/BACKEND_SETUP.md) — Backend installation and server setup diff --git a/docs/PROXY_INSTALLATION.md b/docs/PROXY_INSTALLATION.md new file mode 100644 index 0000000..6efcfd2 --- /dev/null +++ b/docs/PROXY_INSTALLATION.md @@ -0,0 +1,306 @@ +# Install Forge Proxy + +Forge Proxy is available as a standalone, self-contained command. The bundle +contains Forge, a private Python runtime, Forge's core dependencies, and the +Anthropic SDK. It does **not** install a backend executable, model, GPU or driver +stack, service, credentials, or client configuration. + +Install and operate a downstream backend separately. See [Backend +Setup](BACKEND_SETUP.md) for backend installation and the [Proxy Server +overview](../README.md#proxy-server) and [User Guide](USER_GUIDE.md) for Proxy +behavior and backend selection. + +## Release availability + +The versionless installers resolve +`installer/proxy-stable.txt`. If that pointer is absent, they report that no +stable standalone Proxy release has been published. This is conditional: +ordinary Forge, PyPI, and GitHub releases may omit the standalone Proxy assets. +Other pointer fetch failures are reported as unavailable downloads rather than +as an unpublished release. + +An exact `X.Y.Z` install works only when the exact `vX.Y.Z` Forge Release +contains the complete Proxy artifact set. The examples below do not imply that +any particular stable or exact standalone release has been published. + +## Supported hosts and prerequisites + +| Host | Native target | Release artifact | Prerequisites | +|---|---|---|---| +| Windows x64 | `windows-x86_64` | `forge-proxy-windows-x86_64.exe` | PowerShell | +| Linux x64 | `linux-x86_64-gnu` | `forge-proxy-linux-x86_64-gnu` | GNU libc 2.35 or newer, with `ldd`; `curl`; `mktemp`; `sha256sum` | +| macOS arm64 | `macos-arm64` | `forge-proxy-macos-arm64` | `curl`; `mktemp`; `shasum -a 256` | + +Other operating systems, architectures, and libc combinations are unsupported +and fail closed. There is no fallback to pip. + +## Install with the public bootstrap + +### One command, versionless + +These commands use the stable pointer and succeed only while that pointer +exists. + +POSIX: + +```sh +curl -fsSL https://raw.githubusercontent.com/antoinezambelli/forge/main/install.sh | sh +``` + +Installation never consumes onboarding input. After either bootstrap finishes, +open a refreshed terminal and run `forge-proxy init`, then `forge-proxy check`. + +Windows PowerShell: + +```powershell +irm https://raw.githubusercontent.com/antoinezambelli/forge/main/install.ps1 | iex +``` + +### Save, inspect, then execute + +POSIX: + +```sh +curl -fsSLo install.sh https://raw.githubusercontent.com/antoinezambelli/forge/main/install.sh +less install.sh +sh install.sh +``` + +Windows PowerShell: + +```powershell +iwr https://raw.githubusercontent.com/antoinezambelli/forge/main/install.ps1 -OutFile install.ps1 +Get-Content .\install.ps1 +.\install.ps1 +``` + +The saved scripts accept exact version, no-init, and absolute custom-root +options. This example combines all three: + +```text +install.sh [--version X.Y.Z] [--no-init] [--install-root ABSOLUTE] +install.ps1 [-Version X.Y.Z] [-NoInit] [-InstallRoot ABSOLUTE] +``` + +```sh +sh install.sh --version X.Y.Z --no-init --install-root "$HOME/.local/share/forge-proxy-custom" +``` + +```powershell +.\install.ps1 -Version X.Y.Z -NoInit -InstallRoot "$env:LOCALAPPDATA\Forge Proxy Custom" +``` + +`--no-init`/`-NoInit` remains accepted for explicit automation, although all +installations now leave initialization to the subsequent `forge-proxy init` +command. Both scripts select the native target, download `proxy-X.Y.Z.json` and +its declared artifact, verify the declared byte size and SHA-256 digest, then +hand the verified digest to the artifact's `install-artifact` command. +Unsupported hosts stop before download. + +## Manual immutable artifact handoff + +Use this path when another system downloads or transfers release assets. First +download `proxy-X.Y.Z.json` from the exact `vX.Y.Z` Forge Release. In that +manifest, find the entry for the native target from the table above and copy its +`name`, `size`, and `sha256` values. Download the manifest-provided filename and +measure and hash those exact bytes. Pass the same verified digest to +`install-artifact`. + +The following POSIX example also shows an exact custom-root install or recovery. +Substitute the four uppercase values from the native manifest entry. On macOS, +replace the `sha256sum` line with +`test "$(shasum -a 256 "$artifact" | awk '{print $1}')" = "$sha256"`. + +```sh +version=X.Y.Z +artifact=ARTIFACT_NAME_FROM_THE_TARGET_ENTRY +size=SIZE_FROM_THE_TARGET_ENTRY +sha256=SHA256_FROM_THE_TARGET_ENTRY +root="$HOME/.local/share/forge-proxy-custom" +curl -fsSLO "https://github.com/antoinezambelli/forge/releases/download/v$version/proxy-$version.json" +curl -fsSLO "https://github.com/antoinezambelli/forge/releases/download/v$version/$artifact" +test "$(wc -c < "$artifact" | tr -d ' ')" = "$size" +printf '%s %s\n' "$sha256" "$artifact" | sha256sum -c - +chmod +x "./$artifact" +"./$artifact" install-artifact --version "$version" --sha256 "$sha256" --no-init --install-root "$root" +``` + +The equivalent Windows PowerShell handoff is: + +```powershell +$Version = 'X.Y.Z' +$Artifact = 'ARTIFACT_NAME_FROM_THE_TARGET_ENTRY' +$Size = SIZE_FROM_THE_TARGET_ENTRY +$Sha256 = 'SHA256_FROM_THE_TARGET_ENTRY' +$Root = "$env:LOCALAPPDATA\Forge Proxy Custom" +iwr "https://github.com/antoinezambelli/forge/releases/download/v$Version/proxy-$Version.json" -OutFile "proxy-$Version.json" +iwr "https://github.com/antoinezambelli/forge/releases/download/v$Version/$Artifact" -OutFile $Artifact +if ((Get-Item -LiteralPath $Artifact).Length -ne $Size) { throw 'artifact size mismatch' } +if ((Get-FileHash -LiteralPath $Artifact -Algorithm SHA256).Hash.ToLowerInvariant() -ne $Sha256.ToLowerInvariant()) { throw 'artifact checksum mismatch' } +& ".\$Artifact" install-artifact --version $Version --sha256 $Sha256 --no-init --install-root $Root +``` + +`--no-init` and the absolute custom root are optional for a new installation. +For recovery of an installation originally created at a custom root, they are +not interchangeable: every exact bootstrap or manual handoff must repeat the +same `--install-root`/`-InstallRoot` value. Omitting the original root targets +the platform default and does not recover the custom-root installation; rerun +with the original absolute root. Do not treat the resulting locations as +multiple supported active installations. + +For example, exact bootstrap recovery at the roots used above is: + +```sh +sh install.sh --version X.Y.Z --no-init --install-root "$HOME/.local/share/forge-proxy-custom" +``` + +```powershell +.\install.ps1 -Version X.Y.Z -NoInit -InstallRoot "$env:LOCALAPPDATA\Forge Proxy Custom" +``` + +## Create a profile or launch with flags + +Installation prints these as the next steps. Create the default profile, or +create another named profile, with: + +```console +forge-proxy init +forge-proxy init --profile local-openai +``` + +`init` preserves supplied string values exactly and prints the corresponding +`forge-proxy --profile NAME` launch command after writing the profile. + +For a noninteractive unmanaged OpenAI-shaped backend: + +```console +forge-proxy init --profile local-openai --non-interactive --backend-url http://127.0.0.1:8000 --backend openai +``` + +For a noninteractive unmanaged Anthropic-shaped backend, select it explicitly: + +```console +forge-proxy init --profile anthropic-gateway --non-interactive --backend-url https://gateway.example --backend anthropic +``` + +Managed profile TOML uses `schema_version = 1`. CLI hyphenated names map to +underscore TOML keys, such as `--backend-url` to `backend_url`. Profile fields +and CLI flags share meanings, defaults, applicability, and canonical +validation. Managed profile files are sparse: omitted defaults are applied at +load time instead of being written. + +A profile or config is one complete configuration source, not an overlay. +`--profile` and `--config` are mutually exclusive, and either selector is also +mutually exclusive with all Proxy configuration flags. Multiple configuration +flags may—and often must—be combined in flag-only mode. + +These are three separate, valid launch shapes: + +```console +forge-proxy --profile local-openai +``` + +```console +forge-proxy --backend-url http://127.0.0.1:8000 --backend openai --host 127.0.0.1 --port 8081 +``` + +```console +forge-proxy --backend-url https://gateway.example --backend anthropic --model claude-route --port 8081 +``` + +Use `forge-proxy --config /absolute/path/to/profile.toml` when another tool owns +the complete TOML file; Forge reads but does not rewrite it. + +## Check, update, recover, and uninstall + +After at least one managed profile exists, validate the private runtime, all +managed profiles, and a local Forge health listener: + +```console +forge-proxy check +``` + +Updates are forward-only. With no option, `update` follows the stable pointer; +an exact option selects a newer exact release: + +```console +forge-proxy update +forge-proxy update --version X.Y.Z +``` + +`update` does not install a lower version. For an exact reinstall, lower-version +recovery, or recovery when the installed command cannot run, use the external +bootstrap or manual artifact handoff above. For a custom-root installation, +repeat its original absolute `--install-root`/`-InstallRoot` on every recovery +command; omitting it targets the platform default and does not recover the +custom-root installation. + +To remove the managed installation: + +```console +forge-proxy uninstall +``` + +This delegates to the installation's owned native uninstaller. It removes only +owned installation state and PATH/shell integration; it does not uninstall a +backend, model, driver, or other independently managed software. + +## Filesystem and PATH behavior + +| Host | Default install root | Command directory | Managed profile root | +|---|---|---|---| +| Windows | `%LOCALAPPDATA%\Forge` | `%LOCALAPPDATA%\Forge\bin` | `%APPDATA%\Forge\profiles` | +| Linux | `${XDG_DATA_HOME:-$HOME/.local/share}/forge` | `$HOME/.local/bin` | `${XDG_CONFIG_HOME:-$HOME/.config}/forge/profiles` | +| macOS | `$HOME/Library/Application Support/Forge` | `$HOME/.local/bin` | `$HOME/Library/Application Support/Forge/profiles` | +| Custom absolute root | `` | `/bin` | The host default above | + +Artifacts occupy immutable `/versions/X.Y.Z/` slots. Installation state, +an ownership marker, the current command, and the native uninstaller record the +owned installation. After an update, the current version and at most one prior +version slot are retained. + +On Windows, Forge owns one exact user-PATH entry for the command directory. On +bash and zsh, it owns one marked startup-file block and reports the startup file +it changed plus how to undo the change. For an unknown POSIX shell, the +installer prints an `export PATH=...` instruction instead. Open a refreshed +terminal or reload the shell startup file before expecting `forge-proxy` to be +found. + +## Release identity and integrity + +Forge Proxy uses the exact Forge `X.Y.Z` version, `vX.Y.Z` tag, and Forge +Release namespace. It has no separate Proxy semantic version and no moving +Proxy tag. A complete standalone release contains one ruled artifact for each +supported target plus `proxy-X.Y.Z.json` and `proxy-X.Y.Z.sha256`. + +The exact manifest declares every artifact filename, byte size, and SHA-256 +digest. The bootstraps verify size and digest before invoking the artifact, and +`install-artifact` verifies the same digest again. Releases may also carry free +GitHub build-provenance attestations for optional independent verification; no +attestation verifier is required at install time. + +The stable pointer contains one exact `X.Y.Z` value and resolves to that exact +release manifest. Advancing the pointer selects another immutable Forge +Release; it does not change a tag or artifact in place. If the pointer is +absent, only complete exact releases can be targeted, and only by version. + +## Generic noninteractive wrapper + +This generic shell flow installs one exact release without prompts, creates an +unmanaged named profile, checks the installation, and starts with that profile: + +```sh +version=X.Y.Z +root="$HOME/.local/share/forge-proxy-custom" +sh ./install.sh --version "$version" --no-init --install-root "$root" +"$root/bin/forge-proxy" init --profile local-openai --non-interactive --backend-url http://127.0.0.1:8000 --backend openai +"$root/bin/forge-proxy" check +"$root/bin/forge-proxy" --profile local-openai +``` + +## Further reading + +- [Backend Setup](BACKEND_SETUP.md) — install and run downstream backends. +- [README: Proxy Server](../README.md#proxy-server) — behavior and common launch context. +- [User Guide](USER_GUIDE.md) — detailed Proxy and backend behavior. +- `forge-proxy --help` — installed CLI source-selection, lifecycle, and documentation links. diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..daede62 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,202 @@ +# Forge Proxy bootstrap installer for Windows x64. +# One line: irm https://raw.githubusercontent.com/antoinezambelli/forge/main/install.ps1 | iex +# Save, inspect, execute: +# iwr https://raw.githubusercontent.com/antoinezambelli/forge/main/install.ps1 -OutFile install.ps1 +# Get-Content .\install.ps1 +# .\install.ps1 -Version X.Y.Z -NoInit -InstallRoot 'C:\Forge Proxy' +# Manual immutable install: +# iwr https://github.com/antoinezambelli/forge/releases/download/vX.Y.Z/proxy-X.Y.Z.json -OutFile proxy-X.Y.Z.json +# # Download the windows-x86_64 artifact named by the manifest, then compare its size and: +# Get-FileHash .\forge-proxy-windows-x86_64.exe -Algorithm SHA256 +# .\forge-proxy-windows-x86_64.exe install-artifact --version X.Y.Z --sha256 HEX + +$ErrorActionPreference = "Stop" + +function Show-Help { + @' +Usage: install.ps1 [-Version X.Y.Z] [-NoInit] [-InstallRoot ABSOLUTE] [-Help] + +Downloads, verifies, and hands a Windows x64 release to its install-artifact +operation. With no version, the stable pointer is used. Installation never +prompts; -NoInit remains accepted for explicit automation. + +One line: + irm https://raw.githubusercontent.com/antoinezambelli/forge/main/install.ps1 | iex + +Save, inspect, execute: + iwr https://raw.githubusercontent.com/antoinezambelli/forge/main/install.ps1 -OutFile install.ps1 + Get-Content .\install.ps1 + .\install.ps1 -Version X.Y.Z -NoInit -InstallRoot 'C:\Forge Proxy' + +Manual immutable install: + iwr https://github.com/antoinezambelli/forge/releases/download/vX.Y.Z/proxy-X.Y.Z.json -OutFile proxy-X.Y.Z.json + # Download the windows-x86_64 artifact named by the manifest, then compare its size and: + Get-FileHash .\forge-proxy-windows-x86_64.exe -Algorithm SHA256 + .\forge-proxy-windows-x86_64.exe install-artifact --version X.Y.Z --sha256 HEX +'@ +} + +function Test-Version([string]$Value) { + return $Value -cmatch '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' +} + +function Test-ExactProperties($Object, [string[]]$Expected) { + if ($null -eq $Object -or $Object -isnot [pscustomobject]) { return $false } + $names = @($Object.PSObject.Properties | ForEach-Object { $_.Name }) + if ($names.Count -ne $Expected.Count) { return $false } + foreach ($name in $Expected) { + if ($names -cnotcontains $name) { return $false } + } + return $true +} + +function Get-NativeTarget([bool]$Testing) { + if ($Testing) { + $system = $env:_FORGE_PROXY_BOOTSTRAP_SYSTEM + $machine = $env:_FORGE_PROXY_BOOTSTRAP_MACHINE + } else { + $system = if ($env:OS -eq 'Windows_NT') { 'Windows' } else { 'Unsupported' } + $machine = if ($env:PROCESSOR_ARCHITEW6432) { + $env:PROCESSOR_ARCHITEW6432 + } else { + $env:PROCESSOR_ARCHITECTURE + } + } + if ($system -ceq 'Windows' -and $machine -in @('AMD64', 'x86_64')) { + return 'windows-x86_64' + } + throw "unsupported standalone target: $system $machine" +} + +$version = $null +$noInit = $false +$installRoot = $null +$help = $false +$seen = @{} +for ($index = 0; $index -lt $args.Count; $index++) { + $argument = $args[$index] + if (@('-Version', '-NoInit', '-InstallRoot', '-Help') -cnotcontains $argument) { + throw "unknown argument: $argument" + } + if ($seen.ContainsKey($argument)) { throw "duplicate argument: $argument" } + $seen[$argument] = $true + switch -CaseSensitive ($argument) { + '-Version' { + $index++ + if ($index -ge $args.Count) { throw '-Version requires X.Y.Z' } + $version = $args[$index] + } + '-NoInit' { $noInit = $true } + '-InstallRoot' { + $index++ + if ($index -ge $args.Count) { throw '-InstallRoot requires an absolute path' } + $installRoot = $args[$index] + } + '-Help' { $help = $true } + } +} + +if ($help) { + if ($args.Count -ne 1) { throw '-Help cannot be combined with other arguments' } + Show-Help + exit 0 +} +if ($null -ne $version -and -not (Test-Version $version)) { + throw "invalid Proxy version: '$version'; expected X.Y.Z" +} +if ($null -ne $installRoot -and $installRoot -cnotmatch '^(?:[A-Za-z]:[\\/]|\\\\[^\\]+\\[^\\]+)') { + throw '-InstallRoot must be an absolute path' +} + +$testing = $env:_FORGE_PROXY_BOOTSTRAP_TESTING -ceq '1' +$target = Get-NativeTarget $testing +$pointerUrl = 'https://raw.githubusercontent.com/antoinezambelli/forge/main/installer/proxy-stable.txt' +$releaseBase = 'https://github.com/antoinezambelli/forge/releases/download' +$temporaryBase = [IO.Path]::GetTempPath() +if ($testing) { + if ($env:_FORGE_PROXY_BOOTSTRAP_POINTER_URL) { $pointerUrl = $env:_FORGE_PROXY_BOOTSTRAP_POINTER_URL } + if ($env:_FORGE_PROXY_BOOTSTRAP_RELEASE_BASE_URL) { $releaseBase = $env:_FORGE_PROXY_BOOTSTRAP_RELEASE_BASE_URL.TrimEnd('/') } + if ($env:_FORGE_PROXY_BOOTSTRAP_TEMP_ROOT) { $temporaryBase = $env:_FORGE_PROXY_BOOTSTRAP_TEMP_ROOT } +} + +$temporary = Join-Path $temporaryBase ("forge-proxy-bootstrap-" + [guid]::NewGuid().ToString('N')) +$exitCode = 1 +try { + [void](New-Item -ItemType Directory -Path $temporary) + if ($null -eq $version) { + $pointerPath = Join-Path $temporary 'proxy-stable.txt' + try { + Invoke-WebRequest -UseBasicParsing -Uri $pointerUrl -OutFile $pointerPath + } catch { + $response = $_.Exception.Response + $status = if ($null -ne $response) { [int]$response.StatusCode } else { $null } + if ($status -eq 404) { + throw 'no stable standalone Proxy release has been published' + } + throw "download unavailable: $pointerUrl" + } + $pointer = [IO.File]::ReadAllText($pointerPath, [Text.Encoding]::ASCII) + if ($pointer.EndsWith("`n")) { $pointer = $pointer.Substring(0, $pointer.Length - 1) } + if (-not (Test-Version $pointer)) { throw 'stable pointer must contain one bare X.Y.Z line' } + $version = $pointer + } + + $manifestUrl = "$releaseBase/v$version/proxy-$version.json" + $manifestPath = Join-Path $temporary "proxy-$version.json" + Invoke-WebRequest -UseBasicParsing -Uri $manifestUrl -OutFile $manifestPath + try { + $manifest = [IO.File]::ReadAllText($manifestPath) | ConvertFrom-Json + } catch { + throw 'release manifest is not valid JSON' + } + if (-not (Test-ExactProperties $manifest @('version', 'artifacts'))) { + throw 'release manifest must contain only version and artifacts' + } + if ($manifest.version -cne $version -or -not (Test-ExactProperties $manifest.artifacts @($manifest.artifacts.PSObject.Properties.Name))) { + throw 'release manifest version does not match the requested version' + } + + $selected = $null + foreach ($property in $manifest.artifacts.PSObject.Properties) { + if ($property.Name -notin @('windows-x86_64', 'linux-x86_64-gnu', 'macos-arm64')) { + throw "unsupported release target: $($property.Name)" + } + $entry = $property.Value + if (-not (Test-ExactProperties $entry @('name', 'sha256', 'size'))) { + throw "invalid release manifest entry for $($property.Name)" + } + $safeName = $entry.name -is [string] -and $entry.name.Length -gt 0 -and + [IO.Path]::GetFileName($entry.name) -ceq $entry.name -and + $entry.name -notin @('.', '..') -and $entry.name -cnotmatch '[\\/]' + $validSize = ($entry.size -is [int] -or $entry.size -is [long]) -and $entry.size -ge 0 + if (-not $safeName -or $entry.sha256 -isnot [string] -or + $entry.sha256 -cnotmatch '^[0-9a-fA-F]{64}$' -or -not $validSize) { + throw "invalid release manifest entry for $($property.Name)" + } + if ($property.Name -ceq $target) { $selected = $entry } + } + if ($null -eq $selected) { throw "release manifest has no artifact for $target" } + + $artifactPath = Join-Path $temporary $selected.name + $artifactUrl = "$releaseBase/v$version/$($selected.name)" + Invoke-WebRequest -UseBasicParsing -Uri $artifactUrl -OutFile $artifactPath + if ((Get-Item -LiteralPath $artifactPath).Length -ne [long]$selected.size) { + throw 'downloaded artifact size does not match release manifest' + } + $actual = (Get-FileHash -LiteralPath $artifactPath -Algorithm SHA256).Hash.ToLowerInvariant() + $expected = $selected.sha256.ToLowerInvariant() + if ($actual -cne $expected) { throw 'downloaded artifact checksum mismatch' } + + $handoff = @('install-artifact', '--version', $version, '--sha256', $expected) + if ($noInit) { $handoff += '--no-init' } + if ($null -ne $installRoot) { $handoff += @('--install-root', $installRoot) } + & $artifactPath @handoff + $exitCode = $LASTEXITCODE +} catch { + [Console]::Error.WriteLine("forge-proxy bootstrap: $($_.Exception.Message)") +} finally { + if (Test-Path -LiteralPath $temporary) { + Remove-Item -Recurse -Force -LiteralPath $temporary + } +} +exit $exitCode diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..e15d262 --- /dev/null +++ b/install.sh @@ -0,0 +1,255 @@ +#!/bin/sh +# Forge Proxy bootstrap installer for Linux x64/glibc 2.35+ and macOS arm64. +# One line: curl -fsSL https://raw.githubusercontent.com/antoinezambelli/forge/main/install.sh | sh +# Save, inspect, execute: +# curl -fsSLo install.sh https://raw.githubusercontent.com/antoinezambelli/forge/main/install.sh +# less install.sh +# sh install.sh --version X.Y.Z --no-init --install-root '/opt/forge proxy' +# Manual immutable install: +# curl -fsSLO https://github.com/antoinezambelli/forge/releases/download/vX.Y.Z/proxy-X.Y.Z.json +# artifact=ARTIFACT_NAME_FROM_THE_TARGET_ENTRY +# curl -fsSLO "https://github.com/antoinezambelli/forge/releases/download/vX.Y.Z/$artifact" +# # Compare its manifest byte size and verify with sha256sum or shasum -a 256. +# chmod +x "./$artifact" +# "./$artifact" install-artifact --version X.Y.Z --sha256 HEX + +set -eu + +show_help() { + cat <<'EOF' +Usage: install.sh [--version X.Y.Z] [--no-init] [--install-root ABSOLUTE] [--help] + +Downloads, verifies, and hands a supported release to its install-artifact +operation. With no version, the stable pointer is used. Installation never +prompts; --no-init remains accepted for explicit automation. + +One line: + curl -fsSL https://raw.githubusercontent.com/antoinezambelli/forge/main/install.sh | sh + +Save, inspect, execute: + curl -fsSLo install.sh https://raw.githubusercontent.com/antoinezambelli/forge/main/install.sh + less install.sh + sh install.sh --version X.Y.Z --no-init --install-root '/opt/forge proxy' + +Manual immutable install: + curl -fsSLO https://github.com/antoinezambelli/forge/releases/download/vX.Y.Z/proxy-X.Y.Z.json + artifact=ARTIFACT_NAME_FROM_THE_TARGET_ENTRY + curl -fsSLO "https://github.com/antoinezambelli/forge/releases/download/vX.Y.Z/$artifact" + # Compare its manifest byte size and verify with sha256sum or shasum -a 256. + chmod +x "./$artifact" + "./$artifact" install-artifact --version X.Y.Z --sha256 HEX +EOF +} + +die() { + printf 'forge-proxy bootstrap: %s\n' "$1" >&2 + exit 1 +} + +valid_version() { + printf '%s\n' "$1" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$' +} + +version= +no_init=0 +install_root= +seen_version=0 +seen_no_init=0 +seen_root=0 +while [ "$#" -gt 0 ]; do + case "$1" in + --version) + [ "$seen_version" -eq 0 ] || die 'duplicate argument: --version' + seen_version=1 + shift + [ "$#" -gt 0 ] || die '--version requires X.Y.Z' + version=$1 + ;; + --no-init) + [ "$seen_no_init" -eq 0 ] || die 'duplicate argument: --no-init' + seen_no_init=1 + no_init=1 + ;; + --install-root) + [ "$seen_root" -eq 0 ] || die 'duplicate argument: --install-root' + seen_root=1 + shift + [ "$#" -gt 0 ] || die '--install-root requires an absolute path' + install_root=$1 + ;; + --help) + [ "$#" -eq 1 ] || die '--help cannot be combined with other arguments' + show_help + exit 0 + ;; + *) die "unknown argument: $1" ;; + esac + shift +done + +[ -z "$version" ] || valid_version "$version" || die "invalid Proxy version: '$version'; expected X.Y.Z" +case "$install_root" in + ''|/*) ;; + *) die '--install-root must be an absolute path' ;; +esac + +testing=${_FORGE_PROXY_BOOTSTRAP_TESTING:-0} +if [ "$testing" = 1 ]; then + system=${_FORGE_PROXY_BOOTSTRAP_SYSTEM:-} + machine=${_FORGE_PROXY_BOOTSTRAP_MACHINE:-} +else + system=$(uname -s) + machine=$(uname -m) +fi + +case "$system:$machine" in + Darwin:arm64|Darwin:aarch64) target=macos-arm64 ;; + Linux:x86_64|Linux:amd64) + if [ "$testing" = 1 ]; then + libc_banner=${_FORGE_PROXY_BOOTSTRAP_LDD_OUTPUT:-} + else + command -v ldd >/dev/null 2>&1 || die 'unsupported Linux libc: ldd is unavailable' + libc_banner=$(ldd --version 2>&1) || die 'unsupported Linux libc: ldd --version failed' + fi + printf '%s\n' "$libc_banner" | grep -Eiq '(GNU libc|GLIBC|GNU C Library)' || + die 'unsupported Linux libc: GNU libc/glibc could not be proven' + libc_version=$(printf '%s\n' "$libc_banner" | sed -nE 's/.* ([0-9]+)\.([0-9]+)([^0-9].*)?$/\1 \2/p' | sed -n '1p') + [ -n "$libc_version" ] || die 'unsupported Linux libc: glibc version is unknown' + libc_major=${libc_version% *} + libc_minor=${libc_version#* } + if [ "$libc_major" -lt 2 ] || { [ "$libc_major" -eq 2 ] && [ "$libc_minor" -lt 35 ]; }; then + die 'unsupported Linux libc: glibc 2.35 or newer is required' + fi + target=linux-x86_64-gnu + ;; + *) die "unsupported standalone target: $system $machine" ;; +esac + +pointer_url=https://raw.githubusercontent.com/antoinezambelli/forge/main/installer/proxy-stable.txt +release_base=https://github.com/antoinezambelli/forge/releases/download +temp_base=${TMPDIR:-/tmp} +if [ "$testing" = 1 ]; then + pointer_url=${_FORGE_PROXY_BOOTSTRAP_POINTER_URL:-$pointer_url} + release_base=${_FORGE_PROXY_BOOTSTRAP_RELEASE_BASE_URL:-$release_base} + temp_base=${_FORGE_PROXY_BOOTSTRAP_TEMP_ROOT:-$temp_base} +fi +release_base=${release_base%/} + +command -v curl >/dev/null 2>&1 || die 'curl is required' +temporary=$(mktemp -d "$temp_base/forge-proxy-bootstrap.XXXXXX") || die 'cannot create temporary directory' +cleanup() { rm -rf -- "$temporary"; } +trap cleanup EXIT HUP INT TERM + +fetch() { + curl --fail --location --silent --show-error --output "$2" "$1" +} + +fetch_pointer() { + pointer_status=0 + pointer_http=$(curl --fail --location --silent --show-error \ + --write-out '%{http_code}' --output "$2" "$1") || pointer_status=$? + [ "$pointer_status" -eq 0 ] && return 0 + [ "$pointer_http" = 404 ] && return 44 + return 1 +} + +if [ -z "$version" ]; then + if fetch_pointer "$pointer_url" "$temporary/proxy-stable.txt"; then + : + else + pointer_status=$? + [ "$pointer_status" -eq 44 ] && + die 'no stable standalone Proxy release has been published' + die "download unavailable: $pointer_url" + fi + version=$(cat "$temporary/proxy-stable.txt") + pointer_size=$(wc -c < "$temporary/proxy-stable.txt" | tr -d ' ') + version_size=${#version} + valid_version "$version" && + { [ "$pointer_size" -eq "$version_size" ] || [ "$pointer_size" -eq $((version_size + 1)) ]; } || + die 'stable pointer must contain one bare X.Y.Z line' +fi + +manifest="$temporary/proxy-$version.json" +manifest_url="$release_base/v$version/proxy-$version.json" +fetch "$manifest_url" "$manifest" || die "download unavailable: $manifest_url" +compact=$(tr -d ' \t\r\n' < "$manifest") +manifest_version=$(printf '%s\n' "$compact" | sed -n 's/^{"version":"\([^"]*\)","artifacts":{.*}}$/\1/p') +artifacts=$(printf '%s\n' "$compact" | sed -n 's/^{"version":"[^"]*","artifacts":{\(.*\)}}$/\1/p') +if [ -z "$manifest_version" ]; then + manifest_version=$(printf '%s\n' "$compact" | sed -n 's/^{"artifacts":{.*},"version":"\([^"]*\)"}$/\1/p') + artifacts=$(printf '%s\n' "$compact" | sed -n 's/^{"artifacts":{\(.*\)},"version":"[^"]*"}$/\1/p') +fi +[ -n "$manifest_version" ] || die 'release manifest must contain only version and artifacts' +[ "$manifest_version" = "$version" ] || die 'release manifest version does not match the requested version' +[ -n "$artifacts" ] || die 'release manifest contains no artifacts' + +selection="$temporary/selection" +printf '%s\n' "$artifacts" | sed 's/},"/}\ +"/g' > "$temporary/entries" +while IFS= read -r manifest_entry; do + entry_target=$(printf '%s\n' "$manifest_entry" | sed -n 's/^"\([^"]*\)":{.*}$/\1/p') + entry_body=$(printf '%s\n' "$manifest_entry" | sed -n 's/^"[^"]*":{\(.*\)}$/\1/p') + case "$entry_target" in + windows-x86_64|linux-x86_64-gnu|macos-arm64) ;; + *) die "unsupported release target: $entry_target" ;; + esac + entry_name= + entry_sha= + entry_size= + field_count=0 + old_ifs=$IFS + IFS=, + for field in $entry_body; do + field_count=$((field_count + 1)) + case "$field" in + '"name":"'*) entry_name=$(printf '%s\n' "$field" | sed -n 's/^"name":"\([^"]*\)"$/\1/p') ;; + '"sha256":"'*) entry_sha=$(printf '%s\n' "$field" | sed -n 's/^"sha256":"\([^"]*\)"$/\1/p') ;; + '"size":'*) entry_size=$(printf '%s\n' "$field" | sed -n 's/^"size":\([0-9][0-9]*\)$/\1/p') ;; + *) die "invalid release manifest entry for $entry_target" ;; + esac + done + IFS=$old_ifs + [ "$field_count" -eq 3 ] && [ -n "$entry_name" ] && [ -n "$entry_sha" ] && [ -n "$entry_size" ] || + die "invalid release manifest entry for $entry_target" + printf '%s\n' "$entry_name" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._-]*$' || + die "invalid release manifest entry for $entry_target" + [ "$entry_name" != . ] && [ "$entry_name" != .. ] || die "invalid release manifest entry for $entry_target" + printf '%s\n' "$entry_sha" | grep -Eq '^[0-9a-fA-F]{64}$' || + die "invalid release manifest entry for $entry_target" + if [ "$entry_target" = "$target" ]; then + printf '%s\n%s\n%s\n' "$entry_name" "$entry_sha" "$entry_size" > "$selection" + fi +done < "$temporary/entries" + +[ -f "$selection" ] || die "release manifest has no artifact for $target" +artifact_name=$(sed -n '1p' "$selection") +expected_sha=$(sed -n '2p' "$selection" | tr 'A-F' 'a-f') +expected_size=$(sed -n '3p' "$selection") +artifact="$temporary/$artifact_name" +artifact_url="$release_base/v$version/$artifact_name" +fetch "$artifact_url" "$artifact" || die "download unavailable: $artifact_url" +actual_size=$(wc -c < "$artifact" | tr -d ' ') +[ "$actual_size" = "$expected_size" ] || die 'downloaded artifact size does not match release manifest' +case "$target" in + macos-arm64) + command -v shasum >/dev/null 2>&1 || die 'shasum is required' + actual_sha=$(shasum -a 256 "$artifact" | awk '{print $1}') + ;; + *) + command -v sha256sum >/dev/null 2>&1 || die 'sha256sum is required' + actual_sha=$(sha256sum "$artifact" | awk '{print $1}') + ;; +esac +[ "$actual_sha" = "$expected_sha" ] || die 'downloaded artifact checksum mismatch' +chmod +x "$artifact" + +set -- install-artifact --version "$version" --sha256 "$expected_sha" +[ "$no_init" -eq 0 ] || set -- "$@" --no-init +[ -z "$install_root" ] || set -- "$@" --install-root "$install_root" +if "$artifact" "$@"; then + handoff_status=0 +else + handoff_status=$? +fi +exit "$handoff_status" diff --git a/installer/proxy-stable.txt b/installer/proxy-stable.txt new file mode 100644 index 0000000..f374f66 --- /dev/null +++ b/installer/proxy-stable.txt @@ -0,0 +1 @@ +0.9.1 diff --git a/packaging/standalone/README.md b/packaging/standalone/README.md new file mode 100644 index 0000000..558775e --- /dev/null +++ b/packaging/standalone/README.md @@ -0,0 +1,95 @@ +# Standalone Forge Proxy builds + +All three targets use `forge_proxy.spec` through the Python 3.12 build driver. +The driver rejects non-native target requests, builds and fully smokes `onedir` +before allowing `onefile`, and writes artifact-derived `evidence.json` beside +each generated payload under the ignored `standalone-dist/` directory. A fully +passing two-form build writes `selection.json` choosing the onefile payload. + +## Windows x64 + +From a PowerShell prompt at the repository root: + +```powershell +.\scripts\standalone\build_windows.ps1 +``` + +This recreates `.standalone-build-env` with only the project `anthropic` extra +and PyInstaller, then builds and smokes both forms. The packaged smoke runs the +executable from an unrelated temporary directory with Python path variables +removed and checks version, help, health, OpenAI-shaped forwarding, +Anthropic-shaped SDK forwarding, `CTRL_BREAK_EVENT` shutdown, listener closure, +and onefile extraction cleanup. + +## Linux x64 / glibc 2.35 + +Build natively in the Ubuntu 22.04 image (Docker output can be copied from the +container's `/forge/standalone-dist` directory): + +```sh +docker build -f packaging/standalone/linux/Dockerfile -t forge-proxy-linux . +docker run --name forge-proxy-linux-build forge-proxy-linux +``` + +The completed artifact inspection checks every ELF object in the onedir bundle, +the onefile launcher, and every ELF object recorded in its collection inventory, +failing if any referenced GLIBC symbol exceeds 2.35. + +## macOS arm64 + +On an arm64 Mac with Python 3.12: + +```sh +./scripts/standalone/build_macos.sh +``` + +Linux x64 and macOS arm64 definitions are authored for native execution. They +are not verified by a Windows build run. + +Generated payloads and evidence are local build output. This workflow does not +publish release assets, tags, or remote state. + +## Release automation and evidence + +Changing `installer/proxy-stable.txt` in a pull request declares that the Forge +release is also a Proxy release and triggers `proxy-release-candidate.yml`. +The workflow exposes exactly three jobs: Windows x64, Linux x64, and macOS. +Each job runs its public bootstrap contracts, builds the native artifact, and +exercises packaged smoke plus the isolated +install/init/check/same-version-repair/uninstall lifecycle. The Linux job also +executes the same Ubuntu-built bytes sequentially on Ubuntu 22.04, Debian 12, +and Fedora 44. An ordinary Forge release leaves the pointer unchanged and does +not run Proxy CI. + +The Proxy pointer and `pyproject.toml` must contain the same version in a Proxy +release pull request. Permission-preserving archives carry the selected byte, +its SHA-256 identity, size, version, and the portable cold-start, +extraction/layout, dependency/GLIBC, packaged-smoke, and lifecycle evidence. + +`proxy-release.yml` must be manually dispatched from an existing exact +`refs/tags/vX.Y.Z` whose version matches `pyproject.toml` and whose GitHub +Release already exists. It rebuilds no selected byte after testing. The three +native outputs and all Linux compatibility results gate one immutable staging +job. One environment-gated publication job re-hashes that staging archive, +adds free GitHub build-provenance attestations for the three executables, and +uploads to the existing exact Release. The checksum file precedes the manifest; +`proxy-X.Y.Z.json` is uploaded last as the completeness marker. Publication +rejects existing Proxy names and rolls back only assets journaled by that run. +The Release's `target_commitish` is recorded for information, not used as tag +identity. + +## Mould-owned human release handoff + +The combined procedure remains outside Forge and is performed in this order: + +1. In the release pull request, bump `pyproject.toml` and + `installer/proxy-stable.txt` to the same version. +2. Require all three Proxy release-candidate jobs to pass. +3. Follow the existing Forge PyPI release recipe. +4. Dispatch the exact-tag Proxy workflow from that same Forge tag. +5. Require complete manifest-last publication and all three published exact + install checks to pass. No later pointer change is required. + +An ordinary Forge/PyPI/GitHub release may omit Proxy artifacts by leaving the +pointer unchanged. This implementation run does not dispatch workflows, create +tags, publish, upload, attest, or edit a Release. diff --git a/packaging/standalone/forge_proxy.spec b/packaging/standalone/forge_proxy.spec new file mode 100644 index 0000000..065eeaa --- /dev/null +++ b/packaging/standalone/forge_proxy.spec @@ -0,0 +1,78 @@ +"""Single PyInstaller definition for all ruled native targets and both forms.""" + +import os +import sys +from pathlib import Path + +from PyInstaller.utils.hooks import collect_all, copy_metadata + + +ROOT = Path(SPECPATH).parents[1] +sys.path.insert(0, str(ROOT)) + +from scripts.standalone.inputs import COLLECT_PACKAGES, EXCLUDED_MODULES + + +form = os.environ["FORGE_STANDALONE_FORM"] +if form not in {"onedir", "onefile"}: + raise ValueError(f"unsupported standalone form: {form}") + +datas = copy_metadata("forge-guardrails") +binaries = [] +hiddenimports = [] +for package in COLLECT_PACKAGES: + package_datas, package_binaries, package_hidden = collect_all(package) + datas.extend(package_datas) + binaries.extend(package_binaries) + hiddenimports.extend(package_hidden) + +analysis = Analysis( + [str(ROOT / "src" / "forge" / "proxy" / "__main__.py")], + pathex=[str(ROOT / "src")], + binaries=binaries, + datas=datas, + hiddenimports=hiddenimports, + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + excludes=list(EXCLUDED_MODULES), + noarchive=False, + optimize=0, +) +pyz = PYZ(analysis.pure) + +if form == "onedir": + executable = EXE( + pyz, + analysis.scripts, + [], + exclude_binaries=True, + name="forge-proxy", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=True, + ) + bundle = COLLECT( + executable, + analysis.binaries, + analysis.datas, + strip=False, + upx=False, + name="forge-proxy", + ) +else: + executable = EXE( + pyz, + analysis.scripts, + analysis.binaries, + analysis.datas, + [], + name="forge-proxy", + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=False, + console=True, + ) diff --git a/packaging/standalone/linux/Dockerfile b/packaging/standalone/linux/Dockerfile new file mode 100644 index 0000000..6f6e161 --- /dev/null +++ b/packaging/standalone/linux/Dockerfile @@ -0,0 +1,17 @@ +FROM ubuntu:22.04 + +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update \ + && apt-get install -y --no-install-recommends software-properties-common binutils \ + && add-apt-repository ppa:deadsnakes/ppa \ + && apt-get update \ + && apt-get install -y --no-install-recommends python3.12 python3.12-venv \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /forge +COPY . /forge +RUN python3.12 -m venv /build-env \ + && /build-env/bin/python -m pip install --upgrade pip \ + && /build-env/bin/python -m pip install '/forge[anthropic]' pyinstaller + +CMD ["/build-env/bin/python", "-m", "scripts.standalone.build", "--target", "linux-x86_64-gnu", "--form", "all"] diff --git a/pyproject.toml b/pyproject.toml index ee4033e..3702d17 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "forge-guardrails" -version = "0.9.0" +version = "0.9.1" description = "A reliability layer for self-hosted LLM tool-calling. Guardrails, context management, and backend adapters for multi-step agentic workflows." requires-python = ">=3.12" license = "MIT" @@ -18,6 +18,7 @@ classifiers = [ "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Scientific/Engineering :: Artificial Intelligence", "Topic :: Software Development :: Libraries :: Application Frameworks", "Typing :: Typed", @@ -25,6 +26,7 @@ classifiers = [ dependencies = [ "pydantic>=2.0", "httpx>=0.27", + "tomli-w>=1.0", ] [project.urls] @@ -43,6 +45,7 @@ dev = [ "pytest", "pytest-cov", "pytest-asyncio", + "pyyaml", "anthropic>=0.86.0", "mpmath", "pyarrow>=18.0.0", @@ -63,8 +66,10 @@ exclude = [ [tool.pytest.ini_options] testpaths = ["tests"] +addopts = "-m \"not integration\"" markers = [ - "integration: marks tests that require a running LLM backend (deselect with '-m \"not integration\"')", + "integration: explicitly invoked tests that use external processes, networking, or running backends", + "acceptance: platform lifecycle tests that execute native or frozen artifacts", ] asyncio_mode = "auto" diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..c4712a9 --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""Repository automation helpers.""" diff --git a/scripts/standalone/__init__.py b/scripts/standalone/__init__.py new file mode 100644 index 0000000..440d0a9 --- /dev/null +++ b/scripts/standalone/__init__.py @@ -0,0 +1 @@ +"""Standalone Forge Proxy build and verification helpers.""" diff --git a/scripts/standalone/build.py b/scripts/standalone/build.py new file mode 100644 index 0000000..2ca400f --- /dev/null +++ b/scripts/standalone/build.py @@ -0,0 +1,231 @@ +"""Build and verify a native standalone Forge Proxy artifact.""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib.metadata +import json +import os +import platform +import subprocess +import sys +from pathlib import Path +from typing import Any + +from forge import __version__ +from scripts.standalone.evidence import ( + dependency_observation, + inspect_glibc, + is_elf, + onefile_elf_inventory, + read_evidence, + validate_evidence, +) +from scripts.standalone.inputs import SUPPORTED_TARGETS +from scripts.standalone.smoke import run_smoke + + +ROOT = Path(__file__).resolve().parents[2] +SPEC = ROOT / "packaging" / "standalone" / "forge_proxy.spec" + + +def native_target() -> str | None: + system = platform.system().lower() + machine = platform.machine().lower() + if system == "windows" and machine in {"amd64", "x86_64"}: + return "windows-x86_64" + if system == "linux" and machine in {"amd64", "x86_64"}: + return "linux-x86_64-gnu" + if system == "darwin" and machine in {"arm64", "aarch64"}: + return "macos-arm64" + return None + + +def require_native_target(target: str) -> None: + native = native_target() + if target != native: + raise ValueError( + f"target {target!r} requires its native host; current host is {native!r}" + ) + + +def require_python_314() -> None: + if sys.version_info[:2] != (3, 14): + raise RuntimeError( + f"standalone builds require Python 3.14, got {platform.python_version()}" + ) + + +def pyinstaller_args( + target: str, + form: str, + output_root: Path, +) -> list[str]: + form_root = output_root / target / form + return [ + sys.executable, + "-m", + "PyInstaller", + "--noconfirm", + "--clean", + "--distpath", + str(form_root), + "--workpath", + str(form_root / "work"), + str(SPEC), + ] + + +def evidence_path(output_root: Path, target: str, form: str) -> Path: + return output_root / target / form / "evidence.json" + + +def require_onedir_gate(output_root: Path, target: str) -> None: + path = evidence_path(output_root, target, "onedir") + if not path.is_file(): + raise RuntimeError("onefile requires a completed passing onedir evidence.json") + read_evidence(path) + + +def artifact_path(output_root: Path, target: str, form: str) -> Path: + suffix = ".exe" if target == "windows-x86_64" else "" + root = output_root / target / form + if form == "onedir": + return root / "forge-proxy" / f"forge-proxy{suffix}" + return root / f"forge-proxy{suffix}" + + +def recursive_size(path: Path) -> int: + if path.is_file(): + return path.stat().st_size + return sum(item.stat().st_size for item in path.rglob("*") if item.is_file()) + + +def artifact_files(artifact: Path, form: str) -> list[str]: + if form == "onefile": + return [artifact.name] + return [ + str(path.relative_to(artifact.parent)).replace("\\", "/") + for path in artifact.parent.rglob("*") + if path.is_file() + ] + + +def build_one(target: str, form: str, output_root: Path) -> Path: + if form == "onefile": + require_onedir_gate(output_root, target) + + form_root = output_root / target / form + env = os.environ.copy() + env["FORGE_STANDALONE_FORM"] = form + subprocess.run( + pyinstaller_args(target, form, output_root), + cwd=ROOT, + env=env, + check=True, + ) + + artifact = artifact_path(output_root, target, form) + if not artifact.is_file(): + raise RuntimeError(f"PyInstaller did not produce {artifact}") + work = form_root / "work" / "forge_proxy" + analysis_toc = work / "Analysis-00.toc" + dependencies = dependency_observation( + analysis_toc, + artifact_files(artifact, form), + ) + + glibc: dict[str, Any] + if target == "linux-x86_64-gnu": + if form == "onedir": + elf_paths = [path for path in artifact.parent.rglob("*") if is_elf(path)] + else: + elf_paths = onefile_elf_inventory(work / "PKG-00.toc", artifact) + glibc = inspect_glibc(elf_paths) + else: + glibc = {"verified": None, "max_version": None, "objects": []} + + smoke = run_smoke(artifact, form, __version__) + evidence: dict[str, Any] = { + "target": target, + "form": form, + "path": str(artifact.resolve()), + "size_bytes": recursive_size( + artifact if form == "onefile" else artifact.parent + ), + "build_identity": { + "python": platform.python_version(), + "pyinstaller": importlib.metadata.version("pyinstaller"), + "host_system": platform.system(), + "host_machine": platform.machine(), + }, + **smoke, + "dependency_evidence": dependencies, + "glibc": glibc, + } + validate_evidence(evidence) + output = evidence_path(output_root, target, form) + output.write_text( + json.dumps(evidence, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + print(json.dumps(evidence, indent=2, sort_keys=True)) + return output + + +def write_selection(output_root: Path, target: str) -> Path: + onedir = read_evidence(evidence_path(output_root, target, "onedir")) + onefile = read_evidence(evidence_path(output_root, target, "onefile")) + selection = { + "target": target, + "version": __version__, + "name": ( + f"forge-proxy-{target}.exe" + if target == "windows-x86_64" + else f"forge-proxy-{target}" + ), + "size": Path(onefile["path"]).stat().st_size, + "sha256": hashlib.sha256(Path(onefile["path"]).read_bytes()).hexdigest(), + "selected_form": "onefile", + "selected_path": onefile["path"], + "reason": ( + "onefile passed the same supported smoke and graceful-shutdown " + "checks as onedir, including extraction cleanup" + ), + "measurements": { + "onedir": { + "size_bytes": onedir["size_bytes"], + "cold_start_seconds": onedir["cold_start_seconds"], + }, + "onefile": { + "size_bytes": onefile["size_bytes"], + "cold_start_seconds": onefile["cold_start_seconds"], + }, + }, + } + output = output_root / target / "selection.json" + output.write_text( + json.dumps(selection, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + print(json.dumps(selection, indent=2, sort_keys=True)) + return output + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--target", choices=SUPPORTED_TARGETS, required=True) + parser.add_argument("--form", choices=("onedir", "onefile", "all"), default="all") + parser.add_argument("--output-root", type=Path, default=ROOT / "standalone-dist") + args = parser.parse_args() + + require_python_314() + require_native_target(args.target) + forms = ("onedir", "onefile") if args.form == "all" else (args.form,) + for form in forms: + build_one(args.target, form, args.output_root.resolve()) + if args.form == "all": + write_selection(args.output_root.resolve(), args.target) + + +if __name__ == "__main__": + main() diff --git a/scripts/standalone/build_macos.sh b/scripts/standalone/build_macos.sh new file mode 100755 index 0000000..e44cd7a --- /dev/null +++ b/scripts/standalone/build_macos.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -eu + +repo_root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +build_env="$repo_root/.standalone-build-env" + +python3.14 -m venv "$build_env" +"$build_env/bin/python" -m pip install --upgrade pip +"$build_env/bin/python" -m pip install "$repo_root[anthropic]" pyinstaller +"$build_env/bin/python" -m scripts.standalone.build \ + --target macos-arm64 --form all diff --git a/scripts/standalone/build_windows.ps1 b/scripts/standalone/build_windows.ps1 new file mode 100644 index 0000000..2194cc5 --- /dev/null +++ b/scripts/standalone/build_windows.ps1 @@ -0,0 +1,13 @@ +$ErrorActionPreference = "Stop" +$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path +$BuildEnv = Join-Path $RepoRoot ".standalone-build-env" + +py -3.14 -m venv --clear $BuildEnv +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +& (Join-Path $BuildEnv "Scripts\python.exe") -m pip install --upgrade pip +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +& (Join-Path $BuildEnv "Scripts\python.exe") -m pip install "$RepoRoot[anthropic]" pyinstaller +if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } +& (Join-Path $BuildEnv "Scripts\python.exe") -m scripts.standalone.build ` + --target windows-x86_64 --form all +exit $LASTEXITCODE diff --git a/scripts/standalone/evidence.py b/scripts/standalone/evidence.py new file mode 100644 index 0000000..58ad08c --- /dev/null +++ b/scripts/standalone/evidence.py @@ -0,0 +1,206 @@ +"""Artifact-derived inventory, evidence policy, and Linux ABI inspection.""" + +from __future__ import annotations + +import ast +import json +import re +import subprocess +from collections.abc import Callable, Iterable +from pathlib import Path +from typing import Any + +from scripts.standalone.inputs import ( + EXCLUDED_ARTIFACT_NAMES, + EXCLUDED_MODULES, + REQUIRED_CONTENT, +) + + +_GLIBC = re.compile(r"GLIBC_(\d+)\.(\d+)") + + +def toc_inventory(path: Path) -> list[str]: + """Return every string recorded in a completed PyInstaller TOC.""" + + value = ast.literal_eval(path.read_text(encoding="utf-8")) + found: list[str] = [] + + def visit(item: object) -> None: + if isinstance(item, str): + found.append(item.replace("\\", "/")) + elif isinstance(item, (tuple, list, set)): + for child in item: + visit(child) + elif isinstance(item, dict): + for key, child in item.items(): + visit(key) + visit(child) + + visit(value) + return sorted(set(found)) + + +def collected_toc_inventory(path: Path) -> list[str]: + """Return names and sources from collected TOC entries, not build options.""" + + value = ast.literal_eval(path.read_text(encoding="utf-8")) + found: list[str] = [] + entry_types = { + "BINARY", "DATA", "DEPENDENCY", "EXECUTABLE", "EXTENSION", + "PYMODULE", "PYSOURCE", "PYZ", + } + + def visit(item: object) -> None: + if ( + isinstance(item, tuple) + and len(item) >= 3 + and isinstance(item[2], str) + and item[2] in entry_types + ): + for value in item[:2]: + if isinstance(value, str): + found.append(value.replace("\\", "/")) + return + if isinstance(item, (tuple, list)): + for child in item: + visit(child) + + visit(value) + return sorted(set(found)) + + +def dependency_observation( + analysis_toc: Path, + artifact_files: Iterable[str] = (), +) -> dict[str, Any]: + inventory = sorted( + set(collected_toc_inventory(analysis_toc)) | set(artifact_files) + ) + normalized_items = [item.lower().replace("-", "_") for item in inventory] + normalized = "\n".join(normalized_items) + required = { + name: name.lower().replace("-", "_") in normalized + for name in REQUIRED_CONTENT + } + excluded: list[str] = [] + for name in EXCLUDED_MODULES: + token = name.lower().replace("-", "_") + if any( + item == token + or item.startswith(f"{token}.") + or f"/{token}/" in item + for item in normalized_items + ): + excluded.append(name) + artifact_basenames = { + Path(item).name.lower().replace("-", "_") for item in normalized_items + } + for name in EXCLUDED_ARTIFACT_NAMES: + if name.lower().replace("-", "_") in artifact_basenames: + excluded.append(name) + excluded.sort() + return { + "analysis_toc": str(analysis_toc.resolve()), + "required": required, + "excluded_present": excluded, + "inventory": inventory, + } + + +def validate_evidence(evidence: dict[str, Any]) -> None: + """Enforce the release-gate fields and supported behavior.""" + + required_fields = { + "target", "form", "path", "size_bytes", "build_identity", + "runtime_identity", "cold_start_seconds", "shutdown_seconds", + "extraction", "smoke", "dependency_evidence", "glibc", + } + missing = sorted(required_fields - evidence.keys()) + if missing: + raise ValueError(f"evidence missing required fields: {', '.join(missing)}") + + failed_smoke = sorted( + name for name, passed in evidence["smoke"].items() if passed is not True + ) + if failed_smoke: + raise ValueError(f"smoke checks failed: {', '.join(failed_smoke)}") + + missing_content = sorted( + name + for name, present in evidence["dependency_evidence"]["required"].items() + if present is not True + ) + if missing_content: + raise ValueError( + f"artifact dependency content missing: {', '.join(missing_content)}" + ) + excluded = evidence["dependency_evidence"]["excluded_present"] + if excluded: + raise ValueError(f"excluded artifact content present: {', '.join(excluded)}") + + if evidence["form"] == "onefile" and ( + evidence["extraction"].get("cleanup") is not True + ): + raise ValueError("onefile extraction directory was not cleaned up") + if evidence["target"] == "linux-x86_64-gnu" and ( + evidence["glibc"].get("verified") is not True + ): + raise ValueError("Linux GLIBC inspection did not pass") + + +def read_evidence(path: Path) -> dict[str, Any]: + evidence = json.loads(path.read_text(encoding="utf-8")) + validate_evidence(evidence) + return evidence + + +def is_elf(path: Path) -> bool: + try: + with path.open("rb") as stream: + return stream.read(4) == b"\x7fELF" + except OSError: + return False + + +def onefile_elf_inventory(package_toc: Path, launcher: Path) -> list[Path]: + """Return the onefile launcher and collected ELF source paths.""" + + return sorted({ + Path(value) + for value in [str(launcher), *toc_inventory(package_toc)] + if Path(value).is_file() and is_elf(Path(value)) + }) + + +def inspect_glibc( + paths: Iterable[Path], + runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run, +) -> dict[str, Any]: + """Inspect every supplied ELF object and enforce the Ubuntu 22.04 ceiling.""" + + maximum = (0, 0) + objects: list[str] = [] + for path in paths: + if not is_elf(path): + continue + objects.append(str(path)) + result = runner( + ["readelf", "--version-info", str(path)], + capture_output=True, + text=True, + check=True, + ) + versions = [(int(a), int(b)) for a, b in _GLIBC.findall(result.stdout)] + if versions: + maximum = max(maximum, *versions) + if any(version > (2, 35) for version in versions): + rendered = max(versions) + raise ValueError( + f"{path} references GLIBC_{rendered[0]}.{rendered[1]} above 2.35" + ) + return { + "verified": True, + "max_version": f"{maximum[0]}.{maximum[1]}", + "objects": objects, + } diff --git a/scripts/standalone/inputs.py b/scripts/standalone/inputs.py new file mode 100644 index 0000000..c0212d6 --- /dev/null +++ b/scripts/standalone/inputs.py @@ -0,0 +1,49 @@ +"""The shared PyInstaller inputs and artifact dependency policy.""" + +from __future__ import annotations + +SUPPORTED_TARGETS = ( + "windows-x86_64", + "linux-x86_64-gnu", + "macos-arm64", +) + +COLLECT_PACKAGES = ( + "forge", + "pydantic", + "httpx", + "anthropic", + "tomli_w", +) + +REQUIRED_CONTENT = ( + "forge.clients.anthropic", + "forge_guardrails", + "pydantic", + "httpx", + "anthropic", + "tomli_w", +) + +EXCLUDED_MODULES = ( + "pyarrow", + "pytest", + "_pytest", + "mpmath", + "datasets", + "torch", + "tensorflow", + "jax", + "vllm", +) + +EXCLUDED_ARTIFACT_NAMES = ( + "llama-server", + "llama-server.exe", + "llamafile", + "llamafile.exe", + "ollama.exe", + "ollama", + "vllm.exe", + "vllm", +) diff --git a/scripts/standalone/lifecycle_smoke.py b/scripts/standalone/lifecycle_smoke.py new file mode 100644 index 0000000..7d676cc --- /dev/null +++ b/scripts/standalone/lifecycle_smoke.py @@ -0,0 +1,678 @@ +"""Exercise a selected frozen artifact through the Proxy release lifecycle.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import platform +import shutil +import subprocess +import tempfile +import threading +import time +import urllib.error +import urllib.request +from collections.abc import Callable, Iterator +from contextlib import contextmanager +from dataclasses import dataclass +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any + +from scripts.standalone.release import artifact_name, validate_manifest + + +ROOT = Path(__file__).resolve().parents[2] +STABLE_POINTER_URL = ( + "https://raw.githubusercontent.com/antoinezambelli/forge/" + "main/installer/proxy-stable.txt" +) +RELEASE_BASE_URL = "https://github.com/antoinezambelli/forge/releases/download" +Runner = Callable[..., subprocess.CompletedProcess[str]] + + +@dataclass(frozen=True) +class ReleaseArtifact: + path: Path + version: str + sha256: str + target: str + + @property + def name(self) -> str: + return artifact_name(self.target) + + +class LocalReleaseServer(ThreadingHTTPServer): + routes: dict[str, tuple[int, bytes]] + + +class LocalReleaseHandler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + server = self.server + assert isinstance(server, LocalReleaseServer) + status, payload = server.routes.get(self.path, (404, b"missing")) + self.send_response(status) + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, _format: str, *_args: object) -> None: + return + + +@contextmanager +def local_release_server( + routes: dict[str, tuple[int, bytes]], +) -> Iterator[str]: + server = LocalReleaseServer(("127.0.0.1", 0), LocalReleaseHandler) + server.routes = routes + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_port}" + finally: + server.shutdown() + thread.join(timeout=5) + server.server_close() + + +def artifact_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def command(executable: Path, arguments: list[str]) -> list[str]: + if os.name == "nt" and executable.suffix.lower() in {".cmd", ".bat"}: + return ["cmd", "/d", "/c", str(executable), *arguments] + return [str(executable), *arguments] + + +def run_process( + arguments: list[str], + *, + cwd: Path, + env: dict[str, str], + runner: Runner = subprocess.run, + expected_error: str | None = None, +) -> dict[str, Any]: + result = runner( + arguments, + cwd=cwd, + env=env, + capture_output=True, + text=True, + check=False, + timeout=120, + ) + record: dict[str, Any] = { + "command": [Path(arguments[0]).name, *arguments[1:]], + "status": result.returncode, + "stdout": result.stdout, + "stderr": result.stderr, + } + if expected_error is None: + if result.returncode != 0: + raise RuntimeError( + f"lifecycle step failed ({' '.join(record['command'])}): " + f"{result.stderr or result.stdout}" + ) + else: + if result.returncode == 0: + raise RuntimeError( + f"lifecycle step unexpectedly succeeded: {' '.join(record['command'])}" + ) + if expected_error.lower() not in (result.stderr + result.stdout).lower(): + raise RuntimeError( + f"lifecycle failure did not report {expected_error!r}: " + f"{result.stderr or result.stdout}" + ) + record["expected_failure"] = expected_error + return record + + +def run_step( + executable: Path, + arguments: list[str], + *, + cwd: Path, + env: dict[str, str], + runner: Runner = subprocess.run, + expected_error: str | None = None, +) -> dict[str, Any]: + return run_process( + command(executable, arguments), + cwd=cwd, + env=env, + runner=runner, + expected_error=expected_error, + ) + + +def tree_digest(root: Path) -> str: + digest = hashlib.sha256() + if root.exists(): + for path in sorted(root.rglob("*")): + digest.update(str(path.relative_to(root)).replace("\\", "/").encode()) + if path.is_file(): + digest.update(path.read_bytes()) + return digest.hexdigest() + + +def version_key(version: str) -> tuple[int, int, int]: + parts = version.split(".") + if len(parts) != 3 or any(not part.isdigit() for part in parts): + raise ValueError(f"invalid release version: {version}") + if any(len(part) > 1 and part.startswith("0") for part in parts): + raise ValueError(f"invalid release version: {version}") + return tuple(int(part) for part in parts) # type: ignore[return-value] + + +def next_patch_version(version: str) -> str: + major, minor, patch = version_key(version) + return f"{major}.{minor}.{patch + 1}" + + +def _read_url(url: str, *, missing_ok: bool = False) -> bytes | None: + try: + with urllib.request.urlopen(url, timeout=30) as response: + return response.read() + except urllib.error.HTTPError as exc: + if missing_ok and exc.code == 404: + return None + raise RuntimeError(f"download unavailable: {url}") from exc + except (OSError, urllib.error.URLError) as exc: + raise RuntimeError(f"download unavailable: {url}") from exc + + +def resolve_published_baseline( + target: str, + destination: Path, + *, + pointer_url: str = STABLE_POINTER_URL, + release_base_url: str = RELEASE_BASE_URL, + reader: Callable[..., bytes | None] = _read_url, +) -> ReleaseArtifact | None: + raw_pointer = reader(pointer_url, missing_ok=True) + if raw_pointer is None: + return None + try: + pointer = raw_pointer.decode("ascii") + except UnicodeDecodeError as exc: + raise ValueError("stable pointer is not ASCII") from exc + if pointer.endswith("\n"): + pointer = pointer[:-1] + version_key(pointer) + if "\n" in pointer or "\r" in pointer: + raise ValueError("stable pointer must contain one bare X.Y.Z line") + + base = release_base_url.rstrip("/") + manifest_url = f"{base}/v{pointer}/proxy-{pointer}.json" + raw_manifest = reader(manifest_url) + if raw_manifest is None: + raise RuntimeError(f"download unavailable: {manifest_url}") + try: + document = json.loads(raw_manifest) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ValueError("published Proxy manifest is not valid JSON") from exc + manifest = validate_manifest(document, pointer) + entry = manifest["artifacts"][target] + artifact_url = f"{base}/v{pointer}/{entry['name']}" + payload = reader(artifact_url) + if payload is None: + raise RuntimeError(f"download unavailable: {artifact_url}") + if len(payload) != entry["size"]: + raise ValueError("published baseline size does not match its manifest") + destination.mkdir(parents=True, exist_ok=True) + path = destination / entry["name"] + path.write_bytes(payload) + if artifact_sha256(path) != entry["sha256"]: + raise ValueError("published baseline checksum does not match its manifest") + if os.name != "nt": + path.chmod(0o755) + return ReleaseArtifact(path, pointer, entry["sha256"], target) + + +def release_manifest(artifact: ReleaseArtifact, *, sha256: str | None = None) -> bytes: + document = { + "version": artifact.version, + "artifacts": { + artifact.target: { + "name": artifact.name, + "sha256": sha256 or artifact.sha256, + "size": artifact.path.stat().st_size, + } + }, + } + return (json.dumps(document, sort_keys=True) + "\n").encode() + + +def set_release_routes( + routes: dict[str, tuple[int, bytes]], + artifact: ReleaseArtifact, + *, + sha256: str | None = None, +) -> None: + routes[f"/v{artifact.version}/proxy-{artifact.version}.json"] = ( + 200, + release_manifest(artifact, sha256=sha256), + ) + routes[f"/v{artifact.version}/{artifact.name}"] = ( + 200, + artifact.path.read_bytes(), + ) + + +def isolated_environment(root: Path, path_file: Path) -> dict[str, str]: + user = root / "user" + env = os.environ.copy() + env.update( + { + "HOME": str(user), + "USERPROFILE": str(user), + "APPDATA": str(user / "AppData" / "Roaming"), + "LOCALAPPDATA": str(user / "AppData" / "Local"), + "XDG_CONFIG_HOME": str(user / ".config"), + "FORGE_PROXY_PATH_FILE": str(path_file), + } + ) + return env + + +def release_test_environment( + env: dict[str, str], base_url: str, temporary: Path +) -> dict[str, str]: + env = dict(env) + env.update( + { + "_FORGE_PROXY_BOOTSTRAP_TESTING": "1", + "_FORGE_PROXY_BOOTSTRAP_SYSTEM": platform.system(), + "_FORGE_PROXY_BOOTSTRAP_MACHINE": platform.machine(), + "_FORGE_PROXY_BOOTSTRAP_POINTER_URL": f"{base_url}/pointer", + "_FORGE_PROXY_BOOTSTRAP_RELEASE_BASE_URL": base_url, + "_FORGE_PROXY_BOOTSTRAP_TEMP_ROOT": str(temporary), + "_FORGE_PROXY_INSTALLER_TESTING": "1", + "_FORGE_PROXY_INSTALLER_POINTER_URL": f"{base_url}/pointer", + "_FORGE_PROXY_INSTALLER_RELEASE_BASE_URL": base_url, + } + ) + if platform.system() == "Linux": + result = subprocess.run( + ["ldd", "--version"], capture_output=True, text=True, check=False + ) + env["_FORGE_PROXY_BOOTSTRAP_LDD_OUTPUT"] = result.stdout + result.stderr + return env + + +def bootstrap_arguments(artifact: ReleaseArtifact, install_root: Path) -> list[str]: + if artifact.target == "windows-x86_64": + powershell = shutil.which("powershell") or shutil.which("pwsh") + if powershell is None: + raise RuntimeError("PowerShell is required for the Windows bootstrap gate") + return [ + powershell, + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + str(ROOT / "install.ps1"), + "-Version", + artifact.version, + "-NoInit", + "-InstallRoot", + str(install_root), + ] + return [ + "sh", + str(ROOT / "install.sh"), + "--version", + artifact.version, + "--no-init", + "--install-root", + str(install_root), + ] + + +def shim_path(install_root: Path, target: str) -> Path: + name = "forge-proxy.cmd" if target == "windows-x86_64" else "forge-proxy" + return install_root / "bin" / name + + +def slot_path(install_root: Path, artifact: ReleaseArtifact) -> Path: + executable = ( + "forge-proxy.exe" if artifact.target == "windows-x86_64" else "forge-proxy" + ) + return install_root / "versions" / artifact.version / executable + + +def path_snapshot(path: Path) -> tuple[str, bytes | str]: + if path.is_symlink(): + return ("symlink", os.readlink(path)) + return ("file", path.read_bytes()) + + +def active_snapshot( + install_root: Path, target: str +) -> tuple[bytes, tuple[str, bytes | str], str]: + state_path = install_root / "state.json" + state = json.loads(state_path.read_text(encoding="utf-8")) + active = ReleaseArtifact( + install_root, + str(state["current_version"]), + "", + target, + ) + return ( + state_path.read_bytes(), + path_snapshot(shim_path(install_root, target)), + artifact_sha256(slot_path(install_root, active)), + ) + + +def assert_active( + artifact: ReleaseArtifact, + install_root: Path, + isolation: Path, + env: dict[str, str], + steps: list[dict[str, Any]], +) -> None: + state = json.loads((install_root / "state.json").read_text(encoding="utf-8")) + shim = shim_path(install_root, artifact.target) + if state["current_version"] != artifact.version: + raise RuntimeError("installed state reports the wrong active version") + if ( + Path(state["command_dir"]).resolve() != shim.parent.resolve() + or not state["path_integration"] + ): + raise RuntimeError("owned shim/PATH state was not recorded") + slot = slot_path(install_root, artifact) + if artifact_sha256(slot) != artifact.sha256: + raise RuntimeError("active slot does not contain the selected bytes") + steps.append(run_step(shim, ["--version"], cwd=isolation, env=env)) + if steps[-1]["stdout"].strip() != artifact.version: + raise RuntimeError("installed command reported the wrong version") + steps.append(run_step(shim, ["check"], cwd=isolation, env=env)) + + +def profile_snapshot(user: Path) -> tuple[Path, bytes]: + profiles = list(user.rglob("*.toml")) + if len(profiles) != 1: + raise RuntimeError("lifecycle gate expected exactly one initialized profile") + return profiles[0], profiles[0].read_bytes() + + +def assert_profile(snapshot: tuple[Path, bytes]) -> None: + path, content = snapshot + if not path.is_file() or path.read_bytes() != content: + raise RuntimeError("install lifecycle changed or removed the managed profile") + + +def assert_failed_update_preserved( + before: tuple[bytes, tuple[str, bytes | str], str], + install_root: Path, + active: ReleaseArtifact, + isolation: Path, + env: dict[str, str], + steps: list[dict[str, Any]], +) -> None: + if active_snapshot(install_root, active.target) != before: + raise RuntimeError("failed update changed the active installation") + assert_active(active, install_root, isolation, env, steps) + + +def run_lifecycle( + artifact: Path, + version: str, + digest: str, + target: str, + *, + pointer_url: str = STABLE_POINTER_URL, + release_base_url: str = RELEASE_BASE_URL, +) -> dict[str, Any]: + artifact = artifact.resolve() + version_key(version) + if artifact_sha256(artifact) != digest: + raise ValueError("selected artifact digest changed before lifecycle smoke") + candidate = ReleaseArtifact(artifact, version, digest, target) + + with tempfile.TemporaryDirectory(prefix="forge-lifecycle-") as raw_root: + isolation = Path(raw_root) + user = isolation / "user" + user.mkdir() + sentinel = user / "unchanged.txt" + sentinel.write_text("real-user-state-sentinel\n", encoding="utf-8") + before_user = tree_digest(user) + install_root = isolation / "install root" + path_file = isolation / "user-path.txt" + path_file.write_text("existing-path", encoding="utf-8") + bootstrap_temp = isolation / "bootstrap-temp" + bootstrap_temp.mkdir() + env = isolated_environment(isolation, path_file) + steps: list[dict[str, Any]] = [] + + steps.append(run_step(candidate.path, ["--version"], cwd=isolation, env=env)) + if steps[-1]["stdout"].strip() != candidate.version: + raise RuntimeError("selected artifact reported the wrong version") + steps.append(run_step(candidate.path, ["--help"], cwd=isolation, env=env)) + steps.append( + run_step( + candidate.path, + ["_installer-self-check", "--expected-version", candidate.version], + cwd=isolation, + env=env, + ) + ) + + baseline = resolve_published_baseline( + target, + isolation / "baseline", + pointer_url=pointer_url, + release_base_url=release_base_url, + ) + if baseline is not None and version_key(baseline.version) >= version_key( + version + ): + raise RuntimeError( + "published stable Proxy baseline must be older than the candidate" + ) + + routes: dict[str, tuple[int, bytes]] = {} + set_release_routes(routes, candidate) + if baseline is not None: + set_release_routes(routes, baseline) + + with local_release_server(routes) as base_url: + env = release_test_environment(env, base_url, bootstrap_temp) + initial = baseline or candidate + steps.append( + run_process( + bootstrap_arguments(initial, install_root), + cwd=isolation, + env=env, + ) + ) + shim = shim_path(install_root, target) + steps.append( + run_step( + shim, + [ + "init", + "--non-interactive", + "--force", + "--backend-url", + "http://127.0.0.1:1", + ], + cwd=isolation, + env=env, + ) + ) + assert_active(initial, install_root, isolation, env, steps) + profile = profile_snapshot(user) + + if baseline is not None: + steps.append( + run_step( + shim, + ["update", "--version", candidate.version], + cwd=isolation, + env=env, + ) + ) + assert_active(candidate, install_root, isolation, env, steps) + assert_profile(profile) + + install_args = [ + "install-artifact", + "--version", + candidate.version, + "--sha256", + candidate.sha256, + "--no-init", + "--install-root", + str(install_root), + ] + steps.append(run_step(candidate.path, install_args, cwd=isolation, env=env)) + assert_active(candidate, install_root, isolation, env, steps) + assert_profile(profile) + + failure_version = next_patch_version(candidate.version) + failed_candidate = ReleaseArtifact( + candidate.path, + failure_version, + candidate.sha256, + candidate.target, + ) + + routes["/pointer"] = (200, f"{failure_version}\n".encode()) + routes.pop(f"/v{failure_version}/proxy-{failure_version}.json", None) + protected = active_snapshot(install_root, target) + steps.append( + run_step( + shim, + ["update"], + cwd=isolation, + env=env, + expected_error="download unavailable", + ) + ) + assert_failed_update_preserved( + protected, install_root, candidate, isolation, env, steps + ) + + set_release_routes(routes, failed_candidate, sha256="f" * 64) + protected = active_snapshot(install_root, target) + steps.append( + run_step( + shim, + ["update", "--version", failure_version], + cwd=isolation, + env=env, + expected_error="checksum mismatch", + ) + ) + assert_failed_update_preserved( + protected, install_root, candidate, isolation, env, steps + ) + + set_release_routes(routes, failed_candidate) + protected = active_snapshot(install_root, target) + steps.append( + run_step( + shim, + ["update", "--version", failure_version], + cwd=isolation, + env=env, + expected_error="does not match requested version", + ) + ) + assert_failed_update_preserved( + protected, install_root, candidate, isolation, env, steps + ) + + if baseline is not None: + steps.append( + run_process( + bootstrap_arguments(baseline, install_root), + cwd=isolation, + env=env, + ) + ) + assert_active(baseline, install_root, isolation, env, steps) + assert_profile(profile) + final_active = baseline + baseline_status = "forward-update-and-exact-recovery-exercised" + else: + final_active = candidate + baseline_status = ( + "inaugural-release: update/recovery success cases not applicable; " + "missing-artifact failure exercised" + ) + + steps.append( + run_step( + slot_path(install_root, final_active), + ["uninstall"], + cwd=isolation, + env=env, + ) + ) + deadline = time.monotonic() + 20 + while install_root.exists() and time.monotonic() < deadline: + time.sleep(0.05) + if install_root.exists(): + raise RuntimeError("owned installation remained after uninstall") + assert_profile(profile) + + if path_file.read_text(encoding="utf-8") != "existing-path": + raise RuntimeError("uninstall did not restore isolated PATH state") + if ( + not sentinel.is_file() + or sentinel.read_text(encoding="utf-8") != "real-user-state-sentinel\n" + ): + raise RuntimeError("isolated user sentinel changed") + if list(bootstrap_temp.iterdir()): + raise RuntimeError("bootstrap left temporary files behind") + return { + "version": version, + "sha256": digest, + "artifact": artifact.name, + "target": target, + "baseline": baseline.version if baseline is not None else None, + "baseline_status": baseline_status, + "steps": steps, + "owned_state_removed": True, + "path_state_restored": True, + "profile_preserved": True, + "real_user_state_untouched": True, + "isolated_user_before": before_user, + "isolated_user_after": tree_digest(user), + } + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("artifact", type=Path) + parser.add_argument("--version", required=True) + parser.add_argument("--sha256", required=True) + parser.add_argument( + "--target", + required=True, + choices=("windows-x86_64", "linux-x86_64-gnu", "macos-arm64"), + ) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + result = run_lifecycle(args.artifact, args.version, args.sha256, args.target) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text( + json.dumps(result, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + print(json.dumps(result, indent=2, sort_keys=True)) + + +if __name__ == "__main__": + main() diff --git a/scripts/standalone/release.py b/scripts/standalone/release.py new file mode 100644 index 0000000..dbf6ff9 --- /dev/null +++ b/scripts/standalone/release.py @@ -0,0 +1,402 @@ +"""Assemble, validate, and publish exact-version Proxy release artifacts.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import shutil +import subprocess +import tempfile +import urllib.error +import urllib.request +from collections.abc import Callable, Iterable +from pathlib import Path +from typing import Any, Protocol + +from scripts.standalone.inputs import SUPPORTED_TARGETS + + +ROOT = Path(__file__).resolve().parents[2] +POINTER = ROOT / "installer" / "proxy-stable.txt" +VERSION_RE = re.compile(r"(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)") +SHA256_RE = re.compile(r"[0-9a-f]{64}") + + +def project_version(pyproject: Path = ROOT / "pyproject.toml") -> str: + import tomllib + + version = tomllib.loads(pyproject.read_text(encoding="utf-8"))["project"]["version"] + if not isinstance(version, str) or VERSION_RE.fullmatch(version) is None: + raise ValueError("pyproject.toml project version must be X.Y.Z") + return version + + +def exact_tag(version: str) -> str: + if VERSION_RE.fullmatch(version) is None: + raise ValueError("version must be a canonical X.Y.Z value") + return f"v{version}" + + +def artifact_name(target: str) -> str: + if target not in SUPPORTED_TARGETS: + raise ValueError(f"unsupported target: {target}") + suffix = ".exe" if target == "windows-x86_64" else "" + return f"forge-proxy-{target}{suffix}" + + +def sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def write_selection( + artifact: Path, + target: str, + output: Path, + *, + version: str | None = None, + evidence: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Copy one tested artifact to its release name and record its identity.""" + + version = version or project_version() + exact_tag(version) + name = artifact_name(target) + output.mkdir(parents=True, exist_ok=True) + selected = output / name + shutil.copy2(artifact, selected) + record: dict[str, Any] = { + "target": target, + "name": name, + "version": version, + "size": selected.stat().st_size, + "sha256": sha256(selected), + "evidence": evidence or {}, + } + (output / "selection.json").write_text( + json.dumps(record, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + return record + + +def validate_selection(directory: Path, expected_version: str | None = None) -> dict[str, Any]: + record = json.loads((directory / "selection.json").read_text(encoding="utf-8")) + required = {"target", "name", "version", "size", "sha256", "evidence"} + if set(record) != required: + raise ValueError("selection record has unexpected or missing fields") + target = record["target"] + version = record["version"] + exact_tag(version) + if expected_version is not None and version != expected_version: + raise ValueError("selection version does not match requested version") + if record["name"] != artifact_name(target): + raise ValueError("selection artifact name does not match target") + if not isinstance(record["size"], int) or record["size"] < 1: + raise ValueError("selection size must be a positive integer") + if not isinstance(record["sha256"], str) or SHA256_RE.fullmatch(record["sha256"]) is None: + raise ValueError("selection SHA-256 is invalid") + artifact = directory / record["name"] + if not artifact.is_file(): + raise ValueError(f"selected artifact is missing: {record['name']}") + if artifact.stat().st_size != record["size"]: + raise ValueError("selected artifact size does not match selection record") + if sha256(artifact) != record["sha256"]: + raise ValueError("selected artifact digest does not match selection record") + return record + + +def validate_manifest(document: dict[str, Any], expected_version: str | None = None) -> dict[str, Any]: + if set(document) != {"version", "artifacts"}: + raise ValueError("manifest must contain only version and artifacts") + version = document["version"] + exact_tag(version) + if expected_version is not None and version != expected_version: + raise ValueError("manifest version does not match requested version") + artifacts = document["artifacts"] + if not isinstance(artifacts, dict) or set(artifacts) != set(SUPPORTED_TARGETS): + raise ValueError("manifest must contain the complete ruled target set") + for target in SUPPORTED_TARGETS: + entry = artifacts[target] + if not isinstance(entry, dict) or set(entry) != {"name", "sha256", "size"}: + raise ValueError(f"invalid manifest entry for {target}") + if entry["name"] != artifact_name(target): + raise ValueError(f"manifest name does not match {target}") + if not isinstance(entry["size"], int) or entry["size"] < 1: + raise ValueError(f"invalid manifest size for {target}") + if not isinstance(entry["sha256"], str) or SHA256_RE.fullmatch(entry["sha256"]) is None: + raise ValueError(f"invalid manifest digest for {target}") + return document + + +def assemble(inputs: Iterable[Path], output: Path, version: str | None = None) -> Path: + """Atomically assemble exactly one verified input for every ruled target.""" + + version = version or project_version() + exact_tag(version) + if output.exists(): + raise ValueError("publication directory must not already exist") + records: dict[str, tuple[Path, dict[str, Any]]] = {} + for directory in inputs: + record = validate_selection(directory, version) + target = record["target"] + if target in records: + raise ValueError(f"duplicate selected target: {target}") + records[target] = (directory, record) + missing = set(SUPPORTED_TARGETS) - set(records) + if missing: + raise ValueError(f"missing selected targets: {', '.join(sorted(missing))}") + + output.parent.mkdir(parents=True, exist_ok=True) + temporary = Path(tempfile.mkdtemp(prefix=f".{output.name}-", dir=output.parent)) + try: + artifacts: dict[str, dict[str, Any]] = {} + for target in SUPPORTED_TARGETS: + directory, record = records[target] + destination = temporary / record["name"] + shutil.copy2(directory / record["name"], destination) + if destination.stat().st_size != record["size"] or sha256(destination) != record["sha256"]: + raise ValueError(f"staged artifact identity changed for {target}") + artifacts[target] = { + "name": record["name"], + "sha256": record["sha256"], + "size": record["size"], + } + manifest = {"version": version, "artifacts": artifacts} + manifest_name = f"proxy-{version}.json" + (temporary / manifest_name).write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + checksum_lines = [ + f"{artifacts[target]['sha256']} {artifacts[target]['name']}" + for target in SUPPORTED_TARGETS + ] + (temporary / f"proxy-{version}.sha256").write_text( + "\n".join(checksum_lines) + "\n", encoding="utf-8" + ) + validate_staging(temporary, version) + os.replace(temporary, output) + except BaseException: + shutil.rmtree(temporary, ignore_errors=True) + raise + return output + + +def validate_staging(directory: Path, version: str | None = None) -> dict[str, Any]: + version = version or project_version() + manifest_path = directory / f"proxy-{version}.json" + manifest = validate_manifest(json.loads(manifest_path.read_text(encoding="utf-8")), version) + expected_files = { + *(artifact_name(target) for target in SUPPORTED_TARGETS), + f"proxy-{version}.json", + f"proxy-{version}.sha256", + } + if {path.name for path in directory.iterdir() if path.is_file()} != expected_files: + raise ValueError("staging directory does not contain the exact release file set") + for target in SUPPORTED_TARGETS: + entry = manifest["artifacts"][target] + path = directory / entry["name"] + if path.stat().st_size != entry["size"] or sha256(path) != entry["sha256"]: + raise ValueError(f"staged artifact does not match manifest for {target}") + expected_checksums = "\n".join( + f"{manifest['artifacts'][target]['sha256']} {manifest['artifacts'][target]['name']}" + for target in SUPPORTED_TARGETS + ) + "\n" + if (directory / f"proxy-{version}.sha256").read_text(encoding="utf-8") != expected_checksums: + raise ValueError("checksum file does not match the canonical manifest order") + return manifest + + +def http_manifest_resolver(version: str) -> bytes: + url = ( + "https://github.com/antoinezambelli/forge/releases/download/" + f"v{version}/proxy-{version}.json" + ) + with urllib.request.urlopen(url, timeout=30) as response: + return response.read() + + +def validate_pointer( + pointer: Path = POINTER, + resolver: Callable[[str], bytes] = http_manifest_resolver, +) -> str | None: + """Validate an optional stable pointer without ever writing it.""" + + if not pointer.exists(): + return None + raw = pointer.read_bytes() + try: + text = raw.decode("ascii") + except UnicodeDecodeError as exc: + raise ValueError("stable pointer must be one ASCII X.Y.Z line") from exc + if not text.endswith("\n") or text.count("\n") != 1: + raise ValueError("stable pointer must be one bare X.Y.Z line") + version = text[:-1] + exact_tag(version) + payload = resolver(version) + validate_manifest(json.loads(payload), version) + return version + + +class ReleaseClient(Protocol): + def release(self, tag: str) -> dict[str, Any]: ... + def assets(self, release_id: int) -> list[dict[str, Any]]: ... + def upload(self, tag: str, path: Path) -> int: ... + def delete(self, asset_id: int) -> None: ... + + +def proxy_asset_names(version: str) -> list[str]: + return [ + *(artifact_name(target) for target in SUPPORTED_TARGETS), + f"proxy-{version}.sha256", + f"proxy-{version}.json", + ] + + +def publish( + client: ReleaseClient, + tag: str, + peeled_commit: str, + expected_commit: str, + directory: Path, +) -> None: + """Publish a complete namespace, rolling back only assets from this run.""" + + version = project_version() + if tag != exact_tag(version): + raise ValueError("requested tag does not match pyproject.toml version") + if peeled_commit != expected_commit: + raise ValueError("peeled tag commit does not match checked-out commit") + manifest = validate_staging(directory, version) + release = client.release(tag) + if release.get("tag_name") != tag: + raise ValueError("existing GitHub Release tag_name does not match exact tag") + release_id = int(release["id"]) + expected = set(proxy_asset_names(version)) + existing = {asset["name"] for asset in client.assets(release_id)} + collision = expected & existing + if collision: + raise ValueError(f"Proxy release assets already exist: {', '.join(sorted(collision))}") + + journal: list[int] = [] + ordered = [ + *(manifest["artifacts"][target]["name"] for target in SUPPORTED_TARGETS), + f"proxy-{version}.sha256", + f"proxy-{version}.json", + ] + try: + for name in ordered: + path = directory / name + if name in {entry["name"] for entry in manifest["artifacts"].values()}: + entry = next(item for item in manifest["artifacts"].values() if item["name"] == name) + if path.stat().st_size != entry["size"] or sha256(path) != entry["sha256"]: + raise ValueError(f"artifact identity changed before upload: {name}") + journal.append(client.upload(tag, path)) + final_names = {asset["name"] for asset in client.assets(release_id)} + if final_names & expected != expected: + raise RuntimeError("published Proxy namespace is incomplete") + except BaseException as exc: + cleanup_errors: list[str] = [] + for asset_id in reversed(journal): + try: + client.delete(asset_id) + except BaseException as cleanup_exc: + cleanup_errors.append(str(cleanup_exc)) + try: + remaining = { + asset["name"] for asset in client.assets(release_id) + } & expected + except BaseException as verification_exc: + cleanup_errors.append(str(verification_exc)) + remaining = expected + if cleanup_errors or remaining: + raise RuntimeError( + "Proxy publication cleanup could not prove an empty namespace; " + "use a new version or perform manual remediation" + ) from exc + raise + + +class GhReleaseClient: + def __init__(self, repository: str) -> None: + self.repository = repository + + def _json(self, *args: str) -> Any: + result = subprocess.run( + ["gh", *args], check=True, capture_output=True, text=True + ) + return json.loads(result.stdout) + + def release(self, tag: str) -> dict[str, Any]: + return self._json("api", f"repos/{self.repository}/releases/tags/{tag}") + + def assets(self, release_id: int) -> list[dict[str, Any]]: + return self._json("api", f"repos/{self.repository}/releases/{release_id}/assets") + + def upload(self, tag: str, path: Path) -> int: + subprocess.run( + ["gh", "release", "upload", tag, str(path), "--repo", self.repository], + check=True, + ) + release_id = int(self.release(tag)["id"]) + return int(next(asset["id"] for asset in self.assets(release_id) if asset["name"] == path.name)) + + def delete(self, asset_id: int) -> None: + subprocess.run( + ["gh", "api", "--method", "DELETE", f"repos/{self.repository}/releases/assets/{asset_id}"], + check=True, + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest="command", required=True) + record = commands.add_parser("record") + record.add_argument("--artifact", type=Path, required=True) + record.add_argument("--target", choices=SUPPORTED_TARGETS, required=True) + record.add_argument("--output", type=Path, required=True) + record.add_argument("--evidence", type=Path, action="append", default=[]) + verify = commands.add_parser("verify") + verify.add_argument("directory", type=Path) + assembly = commands.add_parser("assemble") + assembly.add_argument("--input", type=Path, action="append", required=True) + assembly.add_argument("--output", type=Path, required=True) + staged = commands.add_parser("verify-staging") + staged.add_argument("directory", type=Path) + commands.add_parser("pointer") + publication = commands.add_parser("publish") + publication.add_argument("--repository", required=True) + publication.add_argument("--tag", required=True) + publication.add_argument("--peeled-commit", required=True) + publication.add_argument("--expected-commit", required=True) + publication.add_argument("directory", type=Path) + args = parser.parse_args() + + if args.command == "record": + portable = { + path.stem: json.loads(path.read_text(encoding="utf-8")) + for path in args.evidence + } + print(json.dumps(write_selection(args.artifact, args.target, args.output, evidence=portable), sort_keys=True)) + elif args.command == "verify": + print(json.dumps(validate_selection(args.directory), sort_keys=True)) + elif args.command == "assemble": + print(assemble(args.input, args.output)) + elif args.command == "verify-staging": + print(json.dumps(validate_staging(args.directory), sort_keys=True)) + elif args.command == "pointer": + print(validate_pointer() or "no stable Proxy release") + else: + publish( + GhReleaseClient(args.repository), args.tag, args.peeled_commit, + args.expected_commit, args.directory, + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/standalone/smoke.py b/scripts/standalone/smoke.py new file mode 100644 index 0000000..1638a16 --- /dev/null +++ b/scripts/standalone/smoke.py @@ -0,0 +1,289 @@ +"""Cross-process smoke for a frozen Forge Proxy executable.""" + +from __future__ import annotations + +import argparse +import json +import locale +import os +import signal +import socket +import subprocess +import sys +import tempfile +import threading +import time +import urllib.error +import urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any + + +def reserve_port() -> int: + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +class MockBackend: + def __init__(self, protocol: str) -> None: + self.protocol = protocol + self.requests: list[dict[str, Any]] = [] + owner = self + + class Handler(BaseHTTPRequestHandler): + def log_message(self, _format: str, *args: object) -> None: + del args + + def do_POST(self) -> None: + length = int(self.headers.get("content-length", "0")) + body = json.loads(self.rfile.read(length)) + owner.requests.append({"path": self.path, "body": body}) + if owner.protocol == "anthropic": + response = { + "id": "msg_packaged", "type": "message", + "role": "assistant", "model": "claude-packaged", + "content": [{"type": "text", "text": "anthropic-ok"}], + "stop_reason": "end_turn", "stop_sequence": None, + "usage": {"input_tokens": 3, "output_tokens": 2}, + } + else: + response = { + "id": "chatcmpl-packaged", "object": "chat.completion", + "model": "mock-model", + "choices": [{ + "index": 0, + "message": {"role": "assistant", "content": "openai-ok"}, + "finish_reason": "stop", + }], + "usage": { + "prompt_tokens": 3, "completion_tokens": 2, + "total_tokens": 5, + }, + } + payload = json.dumps(response).encode() + self.send_response(200) + self.send_header("content-type", "application/json") + self.send_header("content-length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + self._server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) + + @property + def url(self) -> str: + return f"http://127.0.0.1:{self._server.server_port}" + + def __enter__(self) -> "MockBackend": + self._thread.start() + return self + + def __exit__(self, *_args: object) -> None: + self._server.shutdown() + self._server.server_close() + self._thread.join(timeout=5) + + +def request_json( + method: str, + url: str, + body: dict[str, Any] | None = None, +) -> tuple[int, dict[str, Any]]: + data = json.dumps(body).encode() if body is not None else None + request = urllib.request.Request( + url, + data=data, + method=method, + headers={"content-type": "application/json"}, + ) + with urllib.request.urlopen(request, timeout=2) as response: + payload = response.read() + return response.status, json.loads(payload) if payload else {} + + +def wait_for_health(port: int, process: subprocess.Popen[str]) -> float: + started = time.monotonic() + deadline = started + 30 + while time.monotonic() < deadline: + if process.poll() is not None: + stdout, stderr = process.communicate() + raise RuntimeError( + f"packaged proxy exited before health: {stdout}\n{stderr}" + ) + try: + status, body = request_json( + "GET", f"http://127.0.0.1:{port}/forge/health" + ) + if status == 200 and body == {"status": "ok"}: + return time.monotonic() - started + except (OSError, urllib.error.URLError, TimeoutError): + time.sleep(0.05) + raise TimeoutError("packaged proxy did not become healthy within 30 seconds") + + +def port_closed(port: int) -> bool: + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.2): + return False + except OSError: + return True + + +def start_proxy(executable: Path, args: list[str], cwd: Path) -> subprocess.Popen[str]: + env = os.environ.copy() + env.pop("PYTHONHOME", None) + env.pop("PYTHONPATH", None) + env.pop("PYTHONIOENCODING", None) + env.pop("PYTHONUTF8", None) + kwargs: dict[str, Any] = {} + if os.name == "nt": + kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + else: + kwargs["start_new_session"] = True + return subprocess.Popen( + [str(executable), *args], + cwd=cwd, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + **kwargs, + ) + + +def graceful_stop(process: subprocess.Popen[str], port: int) -> tuple[float, bool]: + started = time.monotonic() + if os.name == "nt": + process.send_signal(signal.CTRL_BREAK_EVENT) + else: + os.killpg(process.pid, signal.SIGTERM) + try: + process.communicate(timeout=20) + except subprocess.TimeoutExpired as exc: + raise RuntimeError("packaged proxy did not exit after graceful signal") from exc + elapsed = time.monotonic() - started + return elapsed, process.returncode == 0 and port_closed(port) + + +def extraction_snapshot() -> set[Path]: + root = Path(tempfile.gettempdir()) + return {path.resolve() for path in root.glob("_MEI*") if path.is_dir()} + + +def cli_check(executable: Path, option: str, cwd: Path) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + env.pop("PYTHONHOME", None) + env.pop("PYTHONPATH", None) + env.pop("PYTHONIOENCODING", None) + env.pop("PYTHONUTF8", None) + getencoding = getattr(locale, "getencoding", None) + encoding = ( + getencoding() if getencoding is not None else locale.getpreferredencoding(False) + ) + return subprocess.run( + [str(executable), option], cwd=cwd, env=env, + capture_output=True, text=True, encoding=encoding, + check=False, timeout=30, + ) + + +def run_smoke(executable: Path, form: str, expected_version: str) -> dict[str, Any]: + executable = executable.resolve() + with tempfile.TemporaryDirectory(prefix="forge-packaged-smoke-") as raw_cwd: + cwd = Path(raw_cwd) + version = cli_check(executable, "--version", cwd) + help_result = cli_check(executable, "--help", cwd) + exact_version = version.returncode == 0 and version.stdout.strip() == expected_version + help_ok = help_result.returncode == 0 and "usage:" in help_result.stdout.lower() + + before_extract = extraction_snapshot() + with MockBackend("openai") as backend: + port = reserve_port() + process = start_proxy(executable, [ + "--backend-url", backend.url, "--model", "mock-model", + "--port", str(port), + ], cwd) + cold_start = wait_for_health(port, process) + during_extract = extraction_snapshot() - before_extract + status, body = request_json( + "POST", f"http://127.0.0.1:{port}/v1/chat/completions", + {"model": "mock-model", "messages": [{"role": "user", "content": "hi"}]}, + ) + openai_ok = ( + status == 200 + and body["choices"][0]["message"]["content"] == "openai-ok" + and backend.requests[-1]["path"].endswith("/v1/chat/completions") + and backend.requests[-1]["body"]["messages"][0]["role"] == "user" + ) + shutdown_seconds, openai_shutdown = graceful_stop(process, port) + + cleanup = all(not path.exists() for path in during_extract) + if form == "onedir": + extraction = { + "kind": "directory", + "observed_path": str(executable.parent), + "cleanup": None, + } + else: + extraction = { + "kind": "temporary-onefile", + "observed_path": ( + str(next(iter(during_extract))) if during_extract else None + ), + "cleanup": cleanup and bool(during_extract), + } + + with MockBackend("anthropic") as backend: + port = reserve_port() + process = start_proxy(executable, [ + "--backend-url", backend.url, "--backend", "anthropic", + "--model", "claude-packaged", "--backend-api-key", "packaged-key", + "--port", str(port), + ], cwd) + wait_for_health(port, process) + status, body = request_json( + "POST", f"http://127.0.0.1:{port}/v1/messages", + { + "model": "claude-packaged", "max_tokens": 32, + "messages": [{"role": "user", "content": "hi"}], + }, + ) + anthropic_ok = ( + status == 200 + and body["content"][0]["text"] == "anthropic-ok" + and backend.requests[-1]["path"].endswith("/v1/messages") + and backend.requests[-1]["body"]["messages"][0]["role"] == "user" + ) + _, anthropic_shutdown = graceful_stop(process, port) + + return { + "runtime_identity": {"version": version.stdout.strip()}, + "cold_start_seconds": round(cold_start, 6), + "shutdown_seconds": round(shutdown_seconds, 6), + "extraction": extraction, + "smoke": { + "version": exact_version, + "help": help_ok, + "health": True, + "openai": openai_ok, + "anthropic": anthropic_ok, + "graceful_shutdown": openai_shutdown and anthropic_shutdown, + "listener_closed": openai_shutdown and anthropic_shutdown, + "process_exited": openai_shutdown and anthropic_shutdown, + }, + } + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("executable", type=Path) + parser.add_argument("--form", choices=("onedir", "onefile"), required=True) + parser.add_argument("--expected-version", required=True) + args = parser.parse_args() + print(json.dumps(run_smoke(args.executable, args.form, args.expected_version), indent=2)) + + +if __name__ == "__main__": + main() diff --git a/src/forge/proxy/__main__.py b/src/forge/proxy/__main__.py index 4817ecb..787ecdf 100644 --- a/src/forge/proxy/__main__.py +++ b/src/forge/proxy/__main__.py @@ -3,172 +3,505 @@ from __future__ import annotations import argparse +import asyncio +import importlib +import importlib.metadata import logging import os +import shlex import signal +import subprocess import sys import time from collections.abc import Sequence +from pathlib import Path +from typing import cast -from forge._backend_profiles import proxy_backend_selectors -from forge.core.reasoning import DEFAULT_REASONING_REPLAY, REASONING_REPLAY_CHOICES +from forge import __version__ +from forge._backend_profiles import ClientAdapter, find_managed_profile +from forge.clients.base import LLMClient +from forge.context.manager import ContextManager +from forge.context.strategies import NoCompact +from forge.proxy._config import _RawProxyConfig +from forge.proxy._options import add_proxy_options, supplied_proxy_options +from forge.proxy._profiles import ( + _load_profile, + _managed_profile_path, + _managed_profile_root, + _managed_profiles, + _parse_profile_document, + _profile_bytes, + _validate_profile_name, + _write_managed_profile, +) from forge.proxy.proxy import ProxyServer +from forge.proxy.server import HTTPServer from forge.server import BudgetMode +_SOURCE_GUIDANCE = """Configuration sources (choose exactly one): + forge-proxy --profile NAME + forge-proxy --config PATH + forge-proxy --backend-url URL [PROXY OPTIONS] + +With no options, forge-proxy discovers the managed default.toml profile. +Create it with: forge-proxy init + +Commands: + forge-proxy init [OPTIONS] Create a managed profile. + forge-proxy check Validate managed profiles and local health. + forge-proxy install-artifact --version X.Y.Z --sha256 HEX [--no-init] + Install this standalone artifact. + forge-proxy update [--version X.Y.Z] + Install a newer standalone release. + forge-proxy uninstall Remove the owned standalone installation. + +Proxy guidance: + https://github.com/antoinezambelli/forge/blob/main/docs/PROXY_INSTALLATION.md + https://github.com/antoinezambelli/forge#proxy-server + https://github.com/antoinezambelli/forge/blob/main/docs/USER_GUIDE.md +""" + + def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( description="forge proxy — OpenAI- and Anthropic-compatible proxy with guardrails", + epilog=_SOURCE_GUIDANCE, + formatter_class=argparse.RawDescriptionHelpFormatter, ) - - # Mode selection. External mode uses --backend-url; managed mode uses - # --backend (+ an identity flag). For an external vLLM server, pass both - # --backend-url and --backend vllm so the proxy selects the vLLM adapter. - # ProxyServer enforces "exactly one of url/backend" and the per-backend rules. - parser.add_argument( - "--backend-url", - help="URL of an externally managed backend (external mode)", + parser.add_argument("--version", action="version", version=__version__) + selectors = parser.add_mutually_exclusive_group() + selectors.add_argument( + "--profile", + metavar="NAME", + help="Load a Forge-managed named profile", ) - parser.add_argument( - "--backend", - choices=proxy_backend_selectors(), - help="Managed backend or unmanaged wire/profile selector.", + selectors.add_argument( + "--config", + type=Path, + metavar="PATH", + help="Load an externally owned TOML profile without rewriting it", ) + add_proxy_options(parser) + return parser - # Managed mode options - parser.add_argument( - "--model", - help="Model name (required for managed ollama). External generic " - "OpenAI/llama profiles use it as a fallback when the request " - "omits model; external vLLM and Anthropic profiles use it as a " - "wire-model pin. It does not provide or suppress context-window " - "reporting metadata.", - ) - parser.add_argument("--gguf", help="Path to GGUF file (llamaserver/llamafile)") - parser.add_argument("--model-path", help="Model directory or HF repo id (vllm, managed mode)") - parser.add_argument("--backend-port", type=int, help="Backend target port") - parser.add_argument( - "--budget-mode", - choices=["backend", "manual", "forge-full", "forge-fast"], - help="Managed context budget mode (default: backend)", - ) - parser.add_argument( - "--budget-tokens", - type=int, - help="Positive managed manual allocation with --budget-mode manual; " - "in unmanaged mode, reporting denominator only (never compacts " - "or enforces caller history)", - ) - parser.add_argument( - "--extra-flags", - nargs=argparse.REMAINDER, - help="Terminal argv tail for a Forge-spawned llama-server, llamafile, " - "or vLLM backend; rejected for Ollama and unmanaged mode; all " - "Forge options must precede it.", + +def _raw_from_args(args: argparse.Namespace) -> _RawProxyConfig: + return _RawProxyConfig( + backend_url=args.backend_url, + backend=args.backend, + model=args.model, + gguf=args.gguf, + model_path=args.model_path, + backend_port=args.backend_port, + budget_mode=(BudgetMode(args.budget_mode) if args.budget_mode else None), + budget_tokens=args.budget_tokens, + extra_flags=args.extra_flags, + host=args.host, + port=args.port, + serialize=args.serialize, + max_retries=args.max_retries, + max_tool_errors=args.max_tool_errors, + rescue_enabled=not args.no_rescue, + backend_capability=args.backend_capability, + inject_respond_tool=args.inject_respond_tool, + backend_timeout=args.backend_timeout, + reasoning_replay=args.reasoning_replay, + backend_api_key=args.backend_api_key, ) - # Proxy options - parser.add_argument("--host", default="127.0.0.1", help="Proxy listen host (default: 127.0.0.1)") - parser.add_argument("--port", type=int, default=8081, help="Proxy listen port (default: 8081)") - serialization = parser.add_mutually_exclusive_group() - serialization.add_argument( - "--serialize", dest="serialize", action="store_true", - help="Force request serialization", + +def _proxy_from_raw( + parser: argparse.ArgumentParser, + raw: _RawProxyConfig, +) -> ProxyServer: + try: + return ProxyServer( + backend_url=raw.backend_url, + backend=raw.backend, + model=raw.model, + gguf=raw.gguf, + model_path=raw.model_path, + backend_port=raw.backend_port, + budget_mode=raw.budget_mode, + budget_tokens=raw.budget_tokens, + extra_flags=raw.extra_flags, + host=raw.host, + port=raw.port, + serialize=raw.serialize, + max_retries=raw.max_retries, + max_tool_errors=raw.max_tool_errors, + rescue_enabled=raw.rescue_enabled, + backend_capability=raw.backend_capability, + inject_respond_tool=raw.inject_respond_tool, + backend_timeout=raw.backend_timeout, + reasoning_replay=raw.reasoning_replay, + backend_api_key=raw.backend_api_key, + ) + except ValueError as exc: + parser.error(str(exc)) + + +def _proxy_from_args( + parser: argparse.ArgumentParser, + args: argparse.Namespace, +) -> ProxyServer: + return _proxy_from_raw(parser, _raw_from_args(args)) + + +def _selected_launch( + parser: argparse.ArgumentParser, + args: argparse.Namespace, + argv: list[str], +) -> tuple[_RawProxyConfig, bool, bool]: + supplied = supplied_proxy_options(argv) + if (args.profile is not None or args.config is not None) and supplied: + parser.error( + "profile/config selectors cannot be combined with Proxy configuration " + "flags. Use either 'forge-proxy --profile NAME' or " + "'forge-proxy --backend-url URL [PROXY OPTIONS]'." + ) + + if args.profile is not None: + try: + path = _managed_profile_path(args.profile) + except ValueError as exc: + parser.error(str(exc)) + elif args.config is not None: + path = args.config + elif supplied: + return _raw_from_args(args), bool(args.verbose), True + else: + path = _managed_profile_path("default") + if not path.is_file(): + parser.error( + f"no default profile found at {path}. Run 'forge-proxy init' or " + "launch with flags, for example 'forge-proxy --backend-url URL'." + ) + + try: + launch = _load_profile(path) + except (OSError, ValueError) as exc: + parser.error(f"cannot load profile {path}: {exc}") + return launch.raw, launch.verbose, False + + +def _build_init_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="forge-proxy init", + description="Create or replace one Forge-managed Proxy profile.", ) - serialization.add_argument( - "--no-serialize", dest="serialize", action="store_false", - help="Disable request serialization", + parser.add_argument("--profile", default="default", metavar="NAME") + parser.add_argument("--non-interactive", action="store_true") + parser.add_argument("--force", action="store_true") + add_proxy_options(parser, suppress_defaults=True, include_credentials=False) + return parser + + +def _profile_launch_command(name: str) -> str: + arguments = ["forge-proxy", "--profile", name] + return ( + subprocess.list2cmdline(arguments) if os.name == "nt" else shlex.join(arguments) ) - parser.set_defaults(serialize=None) - parser.add_argument("--max-retries", type=int, default=3, help="Max retries per request (default: 3)") - parser.add_argument("--max-tool-errors", type=int, default=2, help="Max consecutive tool-call errors per request (default: 2)") - parser.add_argument( - "--backend-timeout", - type=float, - default=300.0, - help="Backend response timeout in seconds (default: 300)", + + +def _prompt_required(prompt: str) -> str: + value = input(prompt) + if not value: + raise ValueError("a value is required") + return value + + +def _interactive_values(values: dict[str, object]) -> dict[str, object]: + values = dict(values) + backend = cast(str | None, values.get("backend")) + unmanaged = ( + "backend_url" in values + or backend is not None + and find_managed_profile(backend) is None ) - parser.add_argument("--no-rescue", action="store_true", help="Disable rescue parsing") - parser.add_argument( - "--backend-api-key", - default=os.environ.get("FORGE_BACKEND_API_KEY"), - help="Static credential forge sends to the backend in its native auth " - "header (LM Studio, hosted providers, service accounts). forge " - "relocates it to the backend's protocol slot. When set, an inbound " - "auth header is refused as a second credential (at most one " - "credential per " - "request). This is backend authentication, not caller authorization; " - "Proxy does not authenticate callers. Defaults to the " - "FORGE_BACKEND_API_KEY env var.", + if not unmanaged and "backend" not in values: + mode = _prompt_required("Backend ownership [managed/unmanaged]: ").lower() + if mode not in {"managed", "unmanaged"}: + raise ValueError("backend ownership must be 'managed' or 'unmanaged'") + unmanaged = mode == "unmanaged" + + if unmanaged: + if "backend_url" not in values: + values["backend_url"] = _prompt_required("Backend URL: ") + if "backend" not in values: + selector = input("Backend selector [openai]: ") + if selector: + values["backend"] = selector + else: + if "backend" not in values: + values["backend"] = _prompt_required( + "Managed backend [llamaserver/llamafile/ollama/vllm]: " + ) + backend = cast(str, values["backend"]) + profile = find_managed_profile(backend) + if profile is not None: + identity = { + "model-tag": "model", + "gguf-path": "gguf", + "model-path": "model_path", + }[profile.required_identity.value] + if identity not in values: + values[identity] = _prompt_required( + f"{identity.replace('_', ' ').title()}: " + ) + + if "host" not in values: + host = input("Proxy host [127.0.0.1]: ") + if host: + values["host"] = host + if "port" not in values: + port = input("Proxy port [8081]: ") + if port: + values["port"] = int(port) + return values + + +def _run_init(argv: list[str]) -> None: + parser = _build_init_parser() + args = parser.parse_args(argv) + try: + _validate_profile_name(args.profile) + controls = {"profile", "non_interactive", "force"} + explicit = { + name: value for name, value in vars(args).items() if name not in controls + } + if not args.non_interactive: + explicit = _interactive_values(explicit) + document = {"schema_version": 1, **explicit} + _parse_profile_document(document) + content = _profile_bytes(explicit) + print(content.decode("utf-8"), end="") + path = _managed_profile_path(args.profile) + changed = _write_managed_profile(path, content, force=args.force) + except (OSError, ValueError) as exc: + parser.error(str(exc)) + print(f"{'Wrote' if changed else 'Unchanged'} profile: {path}") + print(f"Launch with: {_profile_launch_command(args.profile)}") + + +def _build_check_parser() -> argparse.ArgumentParser: + return argparse.ArgumentParser( + prog="forge-proxy check", + description="Validate all managed profiles and one local Forge health listener.", ) - parser.add_argument( - "--backend-capability", - choices=["native", "prompt"], - default="native", - help="Tool-calling protocol for the backend (default: native). " - "'native' uses the selected adapter's structured-tool path; " - "compatible OpenAI-shaped clean paths preserve raw tool fields, " - "while other adapters convert or rebuild them. 'prompt' opts into " - "prompt-injection for non-FC llama.cpp/llamafile backends " - "(strips tools into the prompt, parses the JSON call back). " - "Frozen at startup — never probed or switched mid-stream.", + + +def _runtime_check() -> None: + for module in ( + "forge.clients.anthropic", + "pydantic", + "httpx", + "anthropic", + "tomli_w", + ): + importlib.import_module(module) + installed = importlib.metadata.version("forge-guardrails") + if installed != __version__: + raise ValueError( + f"runtime version {__version__} does not match package metadata {installed}" + ) + + +async def _local_health_check() -> None: + server = HTTPServer( + client=cast(LLMClient, object()), + context_manager=ContextManager(strategy=NoCompact(), budget_tokens=None), + client_adapter=ClientAdapter.LLAMAFILE, + host="127.0.0.1", + port=0, + serialize_requests=False, ) - parser.add_argument( - "--inject-respond-tool", - action="store_true", - help="Inject forge's synthetic respond() tool when the client sends " - "tools (keeps small models in tool-calling mode). Default off.", + await server.start() + try: + assert server._server is not None + port = server._server.sockets[0].getsockname()[1] + reader, writer = await asyncio.open_connection("127.0.0.1", port) + writer.write( + b"GET /forge/health HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n" + ) + await writer.drain() + response = await reader.read() + writer.close() + await writer.wait_closed() + if b" 200 " not in response.partition(b"\r\n")[0] or not response.endswith( + b'{"status":"ok"}' + ): + raise ValueError("unexpected /forge/health response") + finally: + await server.stop() + + +def _run_check(argv: list[str]) -> None: + parser = _build_check_parser() + parser.parse_args(argv) + passed = True + try: + _runtime_check() + print(f"OK runtime {__version__}") + except Exception as exc: + passed = False + print(f"ERROR runtime: {exc}") + + profiles = _managed_profiles() + if not profiles: + passed = False + print( + f"ERROR profiles: no managed profiles in {_managed_profile_root()}; " + "run 'forge-proxy init'" + ) + for path in profiles: + try: + _validate_profile_name(path.stem) + _load_profile(path) + print(f"OK profile {path.stem}") + except (OSError, ValueError) as exc: + passed = False + print(f"ERROR profile {path.stem}: {exc}") + + try: + asyncio.run(_local_health_check()) + print("OK local /forge/health") + except Exception as exc: + passed = False + print(f"ERROR local /forge/health: {exc}") + if not passed: + raise SystemExit(1) + + +def _run_installer_self_check(argv: list[str]) -> None: + parser = argparse.ArgumentParser(prog="forge-proxy _installer-self-check") + parser.add_argument("--expected-version", required=True) + args = parser.parse_args(argv) + if args.expected_version != __version__: + parser.error( + f"artifact version {__version__} does not match requested version " + f"{args.expected_version}" + ) + try: + _runtime_check() + asyncio.run(_local_health_check()) + except Exception as exc: + parser.error(str(exc)) + + +def _run_installer_profile_check(argv: list[str]) -> None: + parser = argparse.ArgumentParser(prog="forge-proxy _installer-profile-check") + parser.parse_args(argv) + compatible = True + for path in _managed_profiles(): + try: + _load_profile(path) + print(f"Compatible managed profile: {path.stem}") + except (OSError, ValueError) as exc: + compatible = False + print(f"Incompatible managed profile {path.stem}: {exc}") + if not compatible: + raise SystemExit(1) + + +def _run_install_artifact(argv: list[str]) -> None: + from forge.proxy import _installer + + parser = argparse.ArgumentParser( + prog="forge-proxy install-artifact", + description="Install the currently executing standalone artifact.", ) - parser.add_argument( - "--reasoning-replay", - choices=REASONING_REPLAY_CHOICES, - default=DEFAULT_REASONING_REPLAY, - help="How much captured reasoning to replay to the backend " - "(default: none).", + parser.add_argument("--version", required=True) + parser.add_argument("--sha256", required=True) + parser.add_argument("--no-init", action="store_true") + parser.add_argument("--install-root", type=Path) + args = parser.parse_args(argv) + try: + _installer.install_artifact( + _installer.current_artifact(), + args.version, + args.sha256, + install_root=args.install_root, + no_init=args.no_init, + ) + except (OSError, _installer.InstallerError) as exc: + parser.error(str(exc)) + + +def _run_update(argv: list[str]) -> None: + from forge.proxy import _installer + + parser = argparse.ArgumentParser( + prog="forge-proxy update", + description="Install a newer stable or exact standalone Proxy release.", ) - parser.add_argument("--verbose", "-v", action="store_true", help="Verbose logging") + parser.add_argument("--version") + args = parser.parse_args(argv) + release_urls: dict[str, str] = {} + if os.environ.get("_FORGE_PROXY_INSTALLER_TESTING") == "1": + pointer_url = os.environ.get("_FORGE_PROXY_INSTALLER_POINTER_URL") + release_base = os.environ.get("_FORGE_PROXY_INSTALLER_RELEASE_BASE_URL") + if pointer_url: + release_urls["pointer_url"] = pointer_url + if release_base: + release_base = release_base.rstrip("/") + release_urls["manifest_url"] = ( + f"{release_base}/v{{version}}/proxy-{{version}}.json" + ) + release_urls["asset_url"] = f"{release_base}/v{{version}}/{{name}}" + try: + _installer.update(args.version, **release_urls) + except (OSError, _installer.InstallerError) as exc: + parser.error(str(exc)) - return parser +def _run_uninstall(argv: list[str]) -> None: + from forge.proxy import _installer -def _proxy_from_args( - parser: argparse.ArgumentParser, - args: argparse.Namespace, -) -> ProxyServer: + parser = argparse.ArgumentParser( + prog="forge-proxy uninstall", + description="Delegate removal to the installed native uninstaller.", + ) + parser.parse_args(argv) try: - return ProxyServer( - backend_url=args.backend_url, - backend=args.backend, - model=args.model, - gguf=args.gguf, - model_path=args.model_path, - backend_port=args.backend_port, - budget_mode=(BudgetMode(args.budget_mode) if args.budget_mode else None), - budget_tokens=args.budget_tokens, - extra_flags=args.extra_flags, - host=args.host, - port=args.port, - serialize=args.serialize, - max_retries=args.max_retries, - max_tool_errors=args.max_tool_errors, - rescue_enabled=not args.no_rescue, - backend_capability=args.backend_capability, - inject_respond_tool=args.inject_respond_tool, - backend_timeout=args.backend_timeout, - reasoning_replay=args.reasoning_replay, - backend_api_key=args.backend_api_key, - ) - except ValueError as exc: + _installer.delegate_uninstall() + except (OSError, _installer.InstallerError) as exc: parser.error(str(exc)) def main(argv: Sequence[str] | None = None) -> None: + arguments = list(sys.argv[1:] if argv is None else argv) + if arguments and arguments[0] == "_installer-self-check": + _run_installer_self_check(arguments[1:]) + return + if arguments and arguments[0] == "_installer-profile-check": + _run_installer_profile_check(arguments[1:]) + return + if arguments and arguments[0] == "install-artifact": + _run_install_artifact(arguments[1:]) + return + if arguments and arguments[0] == "update": + _run_update(arguments[1:]) + return + if arguments and arguments[0] == "uninstall": + _run_uninstall(arguments[1:]) + return + if arguments and arguments[0] == "init": + _run_init(arguments[1:]) + return + if arguments and arguments[0] == "check": + _run_check(arguments[1:]) + return + parser = _build_parser() - args = parser.parse_args(argv) - proxy = _proxy_from_args(parser, args) + args = parser.parse_args(arguments) + raw, verbose, is_flag_only = _selected_launch(parser, args, arguments) + proxy = ( + _proxy_from_args(parser, args) if is_flag_only else _proxy_from_raw(parser, raw) + ) - # Logging - level = logging.DEBUG if args.verbose else logging.INFO + level = logging.DEBUG if verbose else logging.INFO logging.basicConfig( level=level, format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", @@ -183,15 +516,14 @@ def _shutdown(sig: int, _frame: object) -> None: signal.signal(signal.SIGINT, _shutdown) if hasattr(signal, "SIGTERM"): signal.signal(signal.SIGTERM, _shutdown) + if hasattr(signal, "SIGBREAK"): + signal.signal(signal.SIGBREAK, _shutdown) proxy.start() print(f"forge proxy running at {proxy.url}") print(f" Point your client at {proxy.url}/v1/chat/completions") print(" Ctrl+C to stop") - # Block main thread. Use a timed loop so Python can deliver - # signals between iterations (Event.wait() without timeout - # blocks signal handling on Windows). try: while True: time.sleep(0.1) diff --git a/src/forge/proxy/_installer.py b/src/forge/proxy/_installer.py new file mode 100644 index 0000000..d19654c --- /dev/null +++ b/src/forge/proxy/_installer.py @@ -0,0 +1,919 @@ +"""Artifact-owned installation lifecycle for the standalone Forge Proxy.""" + +from __future__ import annotations + +import ctypes +import hashlib +import json +import os +import platform +import re +import shlex +import shutil +import stat +import subprocess +import sys +import tempfile +import urllib.error +import urllib.request +import uuid +from ctypes import wintypes +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Mapping, Protocol + + +PRODUCT = "forge-proxy" +STATE_SCHEMA = 1 +SUPPORTED_TARGETS = ( + "windows-x86_64", + "linux-x86_64-gnu", + "macos-arm64", +) +STABLE_POINTER_URL = ( + "https://raw.githubusercontent.com/antoinezambelli/forge/" + "main/installer/proxy-stable.txt" +) +RELEASE_MANIFEST_URL = ( + "https://github.com/antoinezambelli/forge/releases/download/" + "v{version}/proxy-{version}.json" +) +RELEASE_ASSET_URL = ( + "https://github.com/antoinezambelli/forge/releases/download/v{version}/{name}" +) +_VERSION_PART = r"(?:0|[1-9][0-9]*)" +_VERSION = re.compile(rf"{_VERSION_PART}\.{_VERSION_PART}\.{_VERSION_PART}") +_CHECKSUM = re.compile(r"[0-9a-fA-F]{64}") +_POSIX_START = "# >>> forge-proxy PATH >>>" +_POSIX_END = "# <<< forge-proxy PATH <<<" + + +class InstallerError(RuntimeError): + """A supported lifecycle operation could not be completed.""" + + +@dataclass(frozen=True) +class InstallPaths: + root: Path + command_dir: Path + system: str + + @classmethod + def resolve( + cls, + install_root: Path | None = None, + *, + system: str | None = None, + environ: Mapping[str, str] | None = None, + home: Path | None = None, + ) -> "InstallPaths": + system = system or platform.system() + environ = os.environ if environ is None else environ + home = Path.home() if home is None else home + if install_root is not None: + root = install_root.expanduser() + if not root.is_absolute(): + raise InstallerError("--install-root must be an absolute path") + root = root.resolve() + return cls(root, root / "bin", system) + if system == "Windows": + forge_root = Path(environ["LOCALAPPDATA"]) / "Forge" + return cls(forge_root, forge_root / "bin", system) + if system == "Darwin": + forge_root = home / "Library" / "Application Support" / "Forge" + return cls(forge_root, home / ".local" / "bin", system) + data_root = Path(environ.get("XDG_DATA_HOME", home / ".local" / "share")) + return cls(data_root / "forge", home / ".local" / "bin", system) + + @classmethod + def from_installed_artifact(cls, artifact: Path) -> "InstallPaths | None": + artifact = artifact.resolve() + # /versions//forge-proxy[.exe] + if artifact.parent.parent.name != "versions": + return None + root = artifact.parents[2] + state_path = root / "state.json" + if not state_path.is_file(): + return None + state = read_state(state_path) + return cls(root, Path(state["command_dir"]), str(state["system"])) + + @property + def versions(self) -> Path: + return self.root / "versions" + + @property + def staging(self) -> Path: + return self.root / ".staging" + + @property + def state(self) -> Path: + return self.root / "state.json" + + @property + def marker(self) -> Path: + return self.root / "ownership.txt" + + @property + def executable_name(self) -> str: + return "forge-proxy.exe" if self.system == "Windows" else "forge-proxy" + + @property + def command(self) -> Path: + suffix = ".cmd" if self.system == "Windows" else "" + return self.command_dir / f"forge-proxy{suffix}" + + @property + def uninstaller(self) -> Path: + suffix = ".cmd" if self.system == "Windows" else ".sh" + return self.root / f"uninstall{suffix}" + + def slot(self, version: str) -> Path: + return self.versions / version / self.executable_name + + +class Transport(Protocol): + def read(self, url: str) -> bytes: ... + + +class UrlTransport: + def read(self, url: str) -> bytes: + try: + with urllib.request.urlopen(url, timeout=30) as response: + return response.read() + except urllib.error.HTTPError as exc: + if exc.code == 404 and url == STABLE_POINTER_URL: + raise InstallerError( + "no stable standalone Proxy release has been published" + ) from exc + raise InstallerError(f"download unavailable: {url}") from exc + except (OSError, urllib.error.URLError) as exc: + raise InstallerError(f"download unavailable: {url}") from exc + + +class ProcessRunner(Protocol): + def run( + self, executable: Path, arguments: list[str] + ) -> subprocess.CompletedProcess[str]: ... + + +class SubprocessRunner: + def run( + self, executable: Path, arguments: list[str] + ) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [str(executable), *arguments], + capture_output=True, + text=True, + check=False, + timeout=60, + ) + + +class PathAdapter(Protocol): + def ensure( + self, command_dir: Path, previous: dict[str, Any] | None + ) -> dict[str, Any]: ... + def remove(self, record: Mapping[str, Any]) -> None: ... + + +class WindowsPathAdapter: + """User PATH adapter; a text-file representation is the local test seam.""" + + def __init__(self, representation: Path | None = None) -> None: + self.representation = representation + + def _read(self) -> str: + if self.representation is not None: + if not self.representation.exists(): + return "" + return self.representation.read_text(encoding="utf-8") + import winreg + + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment") as key: + return str(winreg.QueryValueEx(key, "Path")[0]) + except FileNotFoundError: + return "" + + def _write(self, value: str) -> None: + if self.representation is not None: + _atomic_write(self.representation, value.encode("utf-8")) + return + import winreg + + with winreg.CreateKey(winreg.HKEY_CURRENT_USER, "Environment") as key: + winreg.SetValueEx(key, "Path", 0, winreg.REG_EXPAND_SZ, value) + _broadcast_windows_environment_change() + + def ensure( + self, command_dir: Path, previous: dict[str, Any] | None + ) -> dict[str, Any]: + if previous is not None: + return previous + exact = str(command_dir) + entries = [item for item in self._read().split(";") if item] + added = exact not in entries + if added: + entries.append(exact) + self._write(";".join(entries)) + return { + "kind": "windows", + "command_dir": exact, + "added": added, + "representation": ( + str(self.representation.resolve()) if self.representation else None + ), + } + + def remove(self, record: Mapping[str, Any]) -> None: + if not record.get("added"): + return + exact = str(record["command_dir"]) + entries = [item for item in self._read().split(";") if item] + self._write(";".join(item for item in entries if item != exact)) + + +class PosixPathAdapter: + def __init__( + self, + *, + shell: str | None = None, + home: Path | None = None, + output: Callable[[str], None] = print, + ) -> None: + self.shell = shell if shell is not None else os.environ.get("SHELL", "") + self.home = Path.home() if home is None else home + self.output = output + + @staticmethod + def block(command_dir: Path) -> str: + return ( + f"{_POSIX_START}\n" + f'export PATH={shlex.quote(str(command_dir))}:"$PATH"\n' + f"{_POSIX_END}\n" + ) + + def ensure( + self, command_dir: Path, previous: dict[str, Any] | None + ) -> dict[str, Any]: + if previous is not None: + return previous + shell_name = Path(self.shell).name + if shell_name not in {"bash", "zsh"}: + self.output(f'export PATH={shlex.quote(str(command_dir))}:"$PATH"') + return {"kind": "guidance", "command_dir": str(command_dir)} + startup = self.home / (".bashrc" if shell_name == "bash" else ".zshrc") + block = self.block(command_dir) + content = startup.read_text(encoding="utf-8") if startup.exists() else "" + added = block not in content + if added: + if content and not content.endswith("\n"): + content += "\n" + _atomic_write(startup, (content + block).encode("utf-8")) + self.output(f"Updated PATH startup file: {startup.resolve()}") + self.output( + "Undo with 'forge-proxy uninstall' or remove the block from " + f"'{_POSIX_START}' through '{_POSIX_END}' in {startup.resolve()}" + ) + return { + "kind": "posix", + "command_dir": str(command_dir), + "startup_file": str(startup.resolve()), + "block": block, + "added": added, + } + + def remove(self, record: Mapping[str, Any]) -> None: + if record.get("kind") != "posix" or not record.get("added"): + return + startup = Path(str(record["startup_file"])) + if startup.exists(): + content = startup.read_text(encoding="utf-8") + _atomic_write( + startup, content.replace(str(record["block"]), "").encode("utf-8") + ) + + +def _broadcast_windows_environment_change() -> None: + send = ctypes.windll.user32.SendMessageTimeoutW # type: ignore[attr-defined] + send.argtypes = [ + wintypes.HWND, + wintypes.UINT, + wintypes.WPARAM, + wintypes.LPCWSTR, + wintypes.UINT, + wintypes.UINT, + ctypes.POINTER(wintypes.WPARAM), + ] + send.restype = wintypes.LPARAM + result = wintypes.WPARAM() + send( + 0xFFFF, + 0x001A, + 0, + "Environment", + 0x0002, + 5000, + ctypes.byref(result), + ) + + +def default_path_adapter( + paths: InstallPaths, *, output: Callable[[str], None] = print +) -> PathAdapter: + if paths.system == "Windows": + representation = os.environ.get("FORGE_PROXY_PATH_FILE") + return WindowsPathAdapter(Path(representation) if representation else None) + return PosixPathAdapter(output=output) + + +def parse_version(value: str) -> tuple[int, int, int]: + if _VERSION.fullmatch(value) is None: + raise InstallerError(f"invalid Proxy version: {value!r}; expected X.Y.Z") + return tuple(int(part) for part in value.split(".")) # type: ignore[return-value] + + +def parse_checksum(value: str) -> str: + if _CHECKSUM.fullmatch(value) is None: + raise InstallerError("SHA-256 must contain exactly 64 hexadecimal characters") + return value.lower() + + +def parse_pointer(payload: bytes) -> str: + try: + value = payload.decode("ascii") + except UnicodeDecodeError as exc: + raise InstallerError("stable pointer is not ASCII") from exc + if value.endswith("\n"): + value = value[:-1] + if "\n" in value or "\r" in value: + raise InstallerError("stable pointer must contain one bare X.Y.Z line") + parse_version(value) + return value + + +def native_target(*, system: str | None = None, machine: str | None = None) -> str: + system = system or platform.system() + machine = (machine or platform.machine()).lower() + key = (system, machine) + targets = { + ("Windows", "amd64"): "windows-x86_64", + ("Windows", "x86_64"): "windows-x86_64", + ("Linux", "amd64"): "linux-x86_64-gnu", + ("Linux", "x86_64"): "linux-x86_64-gnu", + ("Darwin", "arm64"): "macos-arm64", + ("Darwin", "aarch64"): "macos-arm64", + } + try: + return targets[key] + except KeyError as exc: + raise InstallerError( + f"unsupported standalone target: {system} {machine}" + ) from exc + + +def parse_manifest(payload: bytes, expected_version: str) -> dict[str, dict[str, Any]]: + parse_version(expected_version) + try: + document = json.loads(payload) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise InstallerError("release manifest is not valid JSON") from exc + if not isinstance(document, dict) or set(document) != {"version", "artifacts"}: + raise InstallerError("release manifest must contain only version and artifacts") + if document["version"] != expected_version or not isinstance( + document["artifacts"], dict + ): + raise InstallerError( + "release manifest version does not match the requested version" + ) + artifacts: dict[str, dict[str, Any]] = {} + for target, entry in document["artifacts"].items(): + if target not in SUPPORTED_TARGETS: + raise InstallerError(f"unsupported release target: {target}") + if not isinstance(entry, dict) or set(entry) != {"name", "sha256", "size"}: + raise InstallerError(f"invalid release manifest entry for {target}") + if ( + not isinstance(entry["name"], str) + or not entry["name"] + or Path(entry["name"]).name != entry["name"] + or not isinstance(entry["size"], int) + or isinstance(entry["size"], bool) + or entry["size"] < 0 + or not isinstance(entry["sha256"], str) + ): + raise InstallerError(f"invalid release manifest entry for {target}") + artifacts[target] = { + "name": entry["name"], + "sha256": parse_checksum(entry["sha256"]), + "size": entry["size"], + } + if not artifacts: + raise InstallerError("release manifest contains no artifacts") + return artifacts + + +def _atomic_write(path: Path, content: bytes, *, executable: bool = False) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="wb", dir=path.parent, prefix=f".{path.name}.", delete=False + ) as stream: + temporary = Path(stream.name) + stream.write(content) + if executable and os.name != "nt": + temporary.chmod(0o755) + os.replace(temporary, path) + finally: + if temporary is not None and temporary.exists(): + temporary.unlink() + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def read_state(path: Path) -> dict[str, Any]: + try: + state = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise InstallerError(f"cannot read installed state: {path}") from exc + required = { + "schema", + "product", + "ownership_id", + "root", + "command_dir", + "system", + "current_version", + "previous_versions", + "verified_slots", + "path_integration", + } + if not isinstance(state, dict) or set(state) != required: + raise InstallerError("installed state has an unsupported schema") + if state["schema"] != STATE_SCHEMA or state["product"] != PRODUCT: + raise InstallerError("installed state is not owned by Forge Proxy") + return state + + +def _write_state(path: Path, state: Mapping[str, Any]) -> None: + _atomic_write( + path, (json.dumps(state, indent=2, sort_keys=True) + "\n").encode("utf-8") + ) + + +def current_artifact() -> Path: + if getattr(sys, "frozen", False): + return Path(sys.executable).resolve() + return Path(sys.argv[0]).resolve() + + +def discover_paths(artifact: Path | None = None) -> InstallPaths: + artifact = current_artifact() if artifact is None else artifact + installed = InstallPaths.from_installed_artifact(artifact) + return installed or InstallPaths.resolve() + + +def _checked_run( + runner: ProcessRunner, executable: Path, arguments: list[str], label: str +) -> subprocess.CompletedProcess[str]: + result = runner.run(executable, arguments) + if result.returncode != 0: + detail = (result.stderr or result.stdout or "no details").strip() + raise InstallerError(f"staged {label} failed: {detail}") + return result + + +def _verify_executable( + executable: Path, + version: str, + runner: ProcessRunner, + output: Callable[[str], None], +) -> None: + _checked_run( + runner, + executable, + ["_installer-self-check", "--expected-version", version], + "runtime/health/version check", + ) + profiles = runner.run(executable, ["_installer-profile-check"]) + for line in (profiles.stdout + profiles.stderr).splitlines(): + output(line) + if profiles.returncode != 0: + output("Managed profile incompatibilities do not block this update.") + + +def _snapshot(path: Path) -> tuple[str, bytes | str | None]: + if path.is_symlink(): + return ("symlink", os.readlink(path)) + if path.is_file(): + return ("file", path.read_bytes()) + return ("missing", None) + + +def _restore(path: Path, snapshot: tuple[str, bytes | str | None]) -> None: + if path.exists() or path.is_symlink(): + path.unlink() + kind, value = snapshot + if kind == "file": + _atomic_write(path, value if isinstance(value, bytes) else b"") + elif kind == "symlink": + path.parent.mkdir(parents=True, exist_ok=True) + os.symlink(str(value), path) + + +def _publish_command(paths: InstallPaths, slot: Path) -> None: + paths.command_dir.mkdir(parents=True, exist_ok=True) + if paths.system == "Windows": + content = f'@echo off\r\n"{slot}" %*\r\n'.encode("utf-8") + _atomic_write(paths.command, content) + return + relative = os.path.relpath(slot, paths.command_dir) + temporary = paths.command_dir / f".forge-proxy.{uuid.uuid4().hex}" + os.symlink(relative, temporary) + try: + os.replace(temporary, paths.command) + finally: + if temporary.is_symlink(): + temporary.unlink() + + +def _marker_content(paths: InstallPaths, ownership_id: str) -> str: + return ( + f"product={PRODUCT}|ownership_id={ownership_id}|root={paths.root}|" + f"command={paths.command}" + ) + + +def _ps_quote(value: str) -> str: + return "'" + value.replace("'", "''") + "'" + + +def _render_windows_uninstaller( + paths: InstallPaths, ownership_id: str, path_record: Mapping[str, Any] +) -> bytes: + marker = _marker_content(paths, ownership_id) + ps = [ + "$ErrorActionPreference='SilentlyContinue'", + "$parent=[int]%1", + "while(Get-Process -Id $parent -ErrorAction SilentlyContinue){Start-Sleep -Milliseconds 100}", + "Start-Sleep -Milliseconds 250", + f"$marker={_ps_quote(str(paths.marker))}", + f"if((Get-Content -Raw -LiteralPath $marker) -ne {_ps_quote(marker)}){{exit 2}}", + ] + targets = ",".join( + _ps_quote(str(target)) for target in (paths.versions, paths.staging) + ) + ps.append( + f"$locked=$false;foreach($target in @({targets})){{$attempt=0;" + "while((Test-Path -LiteralPath $target)-and $attempt -lt 50){" + "Remove-Item -Recurse -Force -LiteralPath $target;$attempt++;" + "if(Test-Path -LiteralPath $target){Start-Sleep -Milliseconds 100}};" + "if(Test-Path -LiteralPath $target){$locked=$true;" + "Write-Output ('Locked remnant: '+$target)}};if($locked){exit 1}" + ) + if path_record.get("kind") == "windows" and path_record.get("added"): + command_dir = _ps_quote(str(path_record["command_dir"])) + representation = path_record.get("representation") + if representation: + rep = _ps_quote(str(representation)) + ps.extend( + [ + f"$p={rep}", + "$v=if(Test-Path -LiteralPath $p){Get-Content -Raw -LiteralPath $p}else{''}", + f"$v=(($v -split ';')|Where-Object{{$_ -and $_ -ne {command_dir}}}) -join ';'", + "Set-Content -NoNewline -LiteralPath $p -Value $v", + ] + ) + else: + ps.extend( + [ + "$p=[Environment]::GetEnvironmentVariable('Path','User')", + f"$p=(($p -split ';')|Where-Object{{$_ -and $_ -ne {command_dir}}}) -join ';'", + "[Environment]::SetEnvironmentVariable('Path',$p,'User')", + "Add-Type -TypeDefinition 'using System;using System.Runtime.InteropServices;public static class ForgeEnvironment{[DllImport(\"user32.dll\",CharSet=CharSet.Unicode)]public static extern IntPtr SendMessageTimeout(IntPtr hWnd,uint msg,UIntPtr wParam,string lParam,uint flags,uint timeout,out UIntPtr result);}'", + "$broadcast=[UIntPtr]::Zero", + "[void][ForgeEnvironment]::SendMessageTimeout([IntPtr]0xffff,0x001A,[UIntPtr]::Zero,'Environment',0x0002,5000,[ref]$broadcast)", + ] + ) + for target in (paths.command, paths.state, paths.marker): + ps.append(f"Remove-Item -Force -LiteralPath {_ps_quote(str(target))}") + ps.extend( + [ + f"Remove-Item -Force -LiteralPath {_ps_quote(str(paths.uninstaller))}", + f"Remove-Item -Force -LiteralPath {_ps_quote(str(paths.command_dir))}", + f"Remove-Item -Force -LiteralPath {_ps_quote(str(paths.root))}", + f"if(Test-Path -LiteralPath {_ps_quote(str(paths.root))})" + f"{{Write-Output 'Locked remnant: {str(paths.root)}'}}", + ] + ) + command = ";".join(ps).replace('"', '\\"') + return ( + f'@echo off\r\nstart "" /b powershell.exe -NoProfile -Command "{command}" ' + "& exit /b\r\n" + ).encode("utf-8") + + +def _render_posix_uninstaller( + paths: InstallPaths, ownership_id: str, path_record: Mapping[str, Any] +) -> bytes: + q = shlex.quote + lines = [ + "#!/bin/sh", + "parent=$1", + 'while kill -0 "$parent" 2>/dev/null; do sleep 0.1; done', + f"marker={q(str(paths.marker))}", + f"expected={q(_marker_content(paths, ownership_id))}", + '[ "$(cat "$marker" 2>/dev/null)" = "$expected" ] || exit 2', + ] + if path_record.get("kind") == "posix" and path_record.get("added"): + startup = q(str(path_record["startup_file"])) + lines.extend( + [ + f"startup={startup}", + 'if [ -f "$startup" ]; then', + ' tmp="$startup.forge-proxy.$$"', + f' awk \'BEGIN{{skip=0}} $0=="{_POSIX_START}"{{skip=1;next}} ' + f'$0=="{_POSIX_END}"{{skip=0;next}} !skip{{print}}\' "$startup" > "$tmp"', + ' mv "$tmp" "$startup"', + "fi", + ] + ) + lines.extend( + [ + f"rm -f -- {q(str(paths.command))} {q(str(paths.state))} {q(str(paths.marker))}", + f"rm -rf -- {q(str(paths.versions))} {q(str(paths.staging))}", + f"rm -f -- {q(str(paths.uninstaller))}", + f"rmdir -- {q(str(paths.command_dir))} 2>/dev/null || true", + f"rmdir -- {q(str(paths.root))} 2>/dev/null || true", + "", + ] + ) + return "\n".join(lines).encode("utf-8") + + +def _render_ownership_files( + paths: InstallPaths, ownership_id: str, path_record: Mapping[str, Any] +) -> None: + _atomic_write(paths.marker, _marker_content(paths, ownership_id).encode("utf-8")) + content = ( + _render_windows_uninstaller(paths, ownership_id, path_record) + if paths.system == "Windows" + else _render_posix_uninstaller(paths, ownership_id, path_record) + ) + _atomic_write(paths.uninstaller, content, executable=paths.system != "Windows") + + +def _previous_path_record(state: dict[str, Any] | None) -> dict[str, Any] | None: + return None if state is None else dict(state["path_integration"]) + + +def install_artifact( + artifact: Path, + version: str, + sha256: str, + *, + install_root: Path | None = None, + no_init: bool = False, + paths: InstallPaths | None = None, + runner: ProcessRunner | None = None, + path_adapter: PathAdapter | None = None, + output: Callable[[str], None] = print, +) -> dict[str, Any]: + parse_version(version) + sha256 = parse_checksum(sha256) + artifact = artifact.resolve() + paths = paths or InstallPaths.resolve(install_root) + runner = runner or SubprocessRunner() + path_adapter = path_adapter or default_path_adapter(paths, output=output) + prior = read_state(paths.state) if paths.state.is_file() else None + if prior is not None and Path(prior["root"]) != paths.root: + raise InstallerError("installed state belongs to a different root") + + paths.staging.mkdir(parents=True, exist_ok=True) + suffix = ".exe" if paths.system == "Windows" else "" + staged = paths.staging / f"{uuid.uuid4().hex}{suffix}" + shutil.copyfile(artifact, staged) + if paths.system != "Windows": + staged.chmod(staged.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + try: + if _sha256(staged) != sha256: + raise InstallerError("downloaded artifact checksum mismatch") + slot = paths.slot(version) + reusable = slot.is_file() and _sha256(slot) == sha256 + checked = slot if reusable else staged + _verify_executable(checked, version, runner, output) + if not reusable: + slot.parent.mkdir(parents=True, exist_ok=True) + os.replace(staged, slot) + if paths.system != "Windows": + slot.chmod(0o755) + + old_current = prior["current_version"] if prior else None + previous = list(prior["previous_versions"]) if prior else [] + if old_current and old_current != version: + previous = [ + old_current, + *[item for item in previous if item != old_current], + ] + previous = [item for item in previous if item != version][:1] + ownership_id = prior["ownership_id"] if prior else uuid.uuid4().hex + + watched = [paths.command, paths.state, paths.marker, paths.uninstaller] + snapshots = {path: _snapshot(path) for path in watched} + path_record: dict[str, Any] | None = None + try: + path_record = path_adapter.ensure( + paths.command_dir, _previous_path_record(prior) + ) + verified_by_version = { + item["version"]: item + for item in (prior["verified_slots"] if prior else []) + } + verified_by_version[version] = {"version": version, "sha256": sha256} + retained = [version, *previous] + state = { + "schema": STATE_SCHEMA, + "product": PRODUCT, + "ownership_id": ownership_id, + "root": str(paths.root), + "command_dir": str(paths.command_dir), + "system": paths.system, + "current_version": version, + "previous_versions": previous, + "verified_slots": [verified_by_version[item] for item in retained], + "path_integration": path_record, + } + _render_ownership_files(paths, ownership_id, path_record) + _write_state(paths.state, state) + _publish_command(paths, slot) + except Exception: + if path_record is not None and ( + prior is None or path_record != prior["path_integration"] + ): + path_adapter.remove(path_record) + for path, snapshot in snapshots.items(): + _restore(path, snapshot) + raise + + retained_versions = {version, *previous} + if paths.versions.is_dir(): + for directory in paths.versions.iterdir(): + if directory.is_dir() and directory.name not in retained_versions: + shutil.rmtree(directory) + + output(f"Installed forge-proxy {version} at {slot}") + output("Next, configure and verify the installation:") + output(" forge-proxy init") + output(" forge-proxy check") + output("For noninteractive unmanaged setup:") + output(" forge-proxy init --non-interactive --backend-url URL") + output(" forge-proxy check") + return state + finally: + if staged.exists(): + staged.unlink() + + +def update( + version: str | None = None, + *, + paths: InstallPaths | None = None, + transport: Transport | None = None, + runner: ProcessRunner | None = None, + path_adapter: PathAdapter | None = None, + output: Callable[[str], None] = print, + target: str | None = None, + pointer_url: str = STABLE_POINTER_URL, + manifest_url: str = RELEASE_MANIFEST_URL, + asset_url: str = RELEASE_ASSET_URL, +) -> dict[str, Any] | None: + paths = paths or discover_paths() + if not paths.state.is_file(): + raise InstallerError("forge-proxy is not installed") + state = read_state(paths.state) + transport = transport or UrlTransport() + exact = version is not None + if version is None: + version = parse_pointer(transport.read(pointer_url)) + else: + parse_version(version) + current = str(state["current_version"]) + if parse_version(version) == parse_version(current): + output(f"forge-proxy {current} is already installed") + return None + if parse_version(version) < parse_version(current): + if exact: + raise InstallerError( + f"update cannot downgrade {current} to {version}; use the external " + "installer for exact-version reinstall or recovery" + ) + output( + f"Installed forge-proxy {current} is newer than stable {version}; no update applied" + ) + return None + manifest = parse_manifest( + transport.read(manifest_url.format(version=version)), version + ) + target = target or native_target(system=paths.system) + if target not in manifest: + raise InstallerError(f"release manifest has no artifact for {target}") + entry = manifest[target] + retained = {item["version"]: item for item in state["verified_slots"]} + slot = paths.slot(version) + if ( + version in retained + and retained[version]["sha256"] == entry["sha256"] + and slot.is_file() + and _sha256(slot) == entry["sha256"] + ): + artifact = slot + else: + payload = transport.read(asset_url.format(version=version, name=entry["name"])) + if len(payload) != entry["size"]: + raise InstallerError( + "downloaded artifact size does not match release manifest" + ) + paths.staging.mkdir(parents=True, exist_ok=True) + artifact = ( + paths.staging / f"download-{uuid.uuid4().hex}{Path(entry['name']).suffix}" + ) + _atomic_write(artifact, payload, executable=paths.system != "Windows") + try: + return install_artifact( + artifact, + version, + entry["sha256"], + paths=paths, + no_init=False, + runner=runner, + path_adapter=path_adapter, + output=output, + ) + finally: + if artifact.parent == paths.staging and artifact.exists(): + artifact.unlink() + + +def validate_owned_install(paths: InstallPaths) -> dict[str, Any]: + state = read_state(paths.state) + if ( + Path(state["root"]) != paths.root + or Path(state["command_dir"]) != paths.command_dir + ): + raise InstallerError("installed ownership paths do not match this installation") + expected = _marker_content(paths, str(state["ownership_id"])) + if ( + not paths.marker.is_file() + or paths.marker.read_text(encoding="utf-8") != expected + ): + raise InstallerError("installed ownership marker does not match state") + if not paths.uninstaller.is_file(): + raise InstallerError("installed native uninstaller is missing") + return state + + +def delegate_uninstall(paths: InstallPaths | None = None) -> None: + paths = paths or discover_paths() + validate_owned_install(paths) + kwargs: dict[str, Any] = { + "stdin": subprocess.DEVNULL, + } + if paths.system == "Windows": + kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + kwargs["shell"] = True + command = [str(paths.uninstaller), str(os.getpid())] + else: + kwargs["start_new_session"] = True + command = [str(paths.uninstaller), str(os.getpid())] + subprocess.Popen(command, **kwargs) + + +def uninstall_owned( + paths: InstallPaths, + *, + path_adapter: PathAdapter | None = None, +) -> None: + """Synchronous ownership-aware equivalent used by local fixture tests.""" + state = validate_owned_install(paths) + path_adapter = path_adapter or default_path_adapter(paths) + path_adapter.remove(state["path_integration"]) + if paths.command.exists() or paths.command.is_symlink(): + paths.command.unlink() + for path in (paths.state, paths.marker, paths.uninstaller): + if path.exists(): + path.unlink() + for directory in (paths.versions, paths.staging): + if directory.exists(): + shutil.rmtree(directory) + for directory in (paths.command_dir, paths.root): + try: + directory.rmdir() + except OSError: + pass diff --git a/src/forge/proxy/_options.py b/src/forge/proxy/_options.py new file mode 100644 index 0000000..da9177d --- /dev/null +++ b/src/forge/proxy/_options.py @@ -0,0 +1,238 @@ +"""Shared definitions for the Proxy CLI and TOML profile surface.""" + +from __future__ import annotations + +import argparse +import os +from dataclasses import dataclass, field +from typing import Any, Literal + +from forge._backend_profiles import proxy_backend_selectors +from forge.core.reasoning import DEFAULT_REASONING_REPLAY, REASONING_REPLAY_CHOICES + + +ProfileKind = Literal["string", "integer", "number", "boolean", "string_list"] + + +@dataclass(frozen=True) +class _OptionDefinition: + name: str + flags: tuple[str, ...] + profile_kind: ProfileKind + default: object = None + argparse_kwargs: dict[str, Any] = field(default_factory=dict) + group: str | None = None + credential: bool = False + + +_OPTIONS = ( + _OptionDefinition( + "backend_url", ("--backend-url",), "string", + argparse_kwargs={"help": "URL of an externally managed backend (external mode)"}, + ), + _OptionDefinition( + "backend", ("--backend",), "string", + argparse_kwargs={ + "choices": proxy_backend_selectors(), + "help": "Managed backend or unmanaged wire/profile selector.", + }, + ), + _OptionDefinition( + "model", ("--model",), "string", + argparse_kwargs={ + "help": "Model name (required for managed ollama). External generic " + "OpenAI/llama profiles use it as a fallback when the request omits " + "model; external vLLM and Anthropic profiles use it as a wire-model " + "pin. It does not provide or suppress context-window reporting metadata.", + }, + ), + _OptionDefinition( + "gguf", ("--gguf",), "string", + argparse_kwargs={"help": "Path to GGUF file (llamaserver/llamafile)"}, + ), + _OptionDefinition( + "model_path", ("--model-path",), "string", + argparse_kwargs={"help": "Model directory or HF repo id (vllm, managed mode)"}, + ), + _OptionDefinition( + "backend_port", ("--backend-port",), "integer", + argparse_kwargs={"type": int, "help": "Backend target port"}, + ), + _OptionDefinition( + "budget_mode", ("--budget-mode",), "string", + argparse_kwargs={ + "choices": ("backend", "manual", "forge-full", "forge-fast"), + "help": "Managed context budget mode (default: backend)", + }, + ), + _OptionDefinition( + "budget_tokens", ("--budget-tokens",), "integer", + argparse_kwargs={ + "type": int, + "help": "Positive managed manual allocation with --budget-mode manual; " + "in unmanaged mode, reporting denominator only (never compacts or " + "enforces caller history)", + }, + ), + _OptionDefinition( + "extra_flags", ("--extra-flags",), "string_list", + argparse_kwargs={ + "nargs": argparse.REMAINDER, + "help": "Terminal argv tail for a Forge-spawned llama-server, llamafile, " + "or vLLM backend; rejected for Ollama and unmanaged mode; all Forge " + "options must precede it.", + }, + ), + _OptionDefinition( + "host", ("--host",), "string", "127.0.0.1", + argparse_kwargs={"help": "Proxy listen host (default: 127.0.0.1)"}, + ), + _OptionDefinition( + "port", ("--port",), "integer", 8081, + argparse_kwargs={"type": int, "help": "Proxy listen port (default: 8081)"}, + ), + _OptionDefinition( + "serialize", ("--serialize",), "boolean", None, + argparse_kwargs={ + "dest": "serialize", "action": "store_true", + "help": "Force request serialization", + }, + group="serialization", + ), + _OptionDefinition( + "serialize", ("--no-serialize",), "boolean", None, + argparse_kwargs={ + "dest": "serialize", "action": "store_false", + "help": "Disable request serialization", + }, + group="serialization", + ), + _OptionDefinition( + "max_retries", ("--max-retries",), "integer", 3, + argparse_kwargs={"type": int, "help": "Max retries per request (default: 3)"}, + ), + _OptionDefinition( + "max_tool_errors", ("--max-tool-errors",), "integer", 2, + argparse_kwargs={ + "type": int, + "help": "Max consecutive tool-call errors per request (default: 2)", + }, + ), + _OptionDefinition( + "backend_timeout", ("--backend-timeout",), "number", 300.0, + argparse_kwargs={ + "type": float, + "help": "Backend response timeout in seconds (default: 300)", + }, + ), + _OptionDefinition( + "no_rescue", ("--no-rescue",), "boolean", False, + argparse_kwargs={"action": "store_true", "help": "Disable rescue parsing"}, + ), + _OptionDefinition( + "backend_api_key", ("--backend-api-key",), "string", None, + argparse_kwargs={ + "help": "Static credential forge sends to the backend in its native auth " + "header (LM Studio, hosted providers, service accounts). forge relocates " + "it to the backend's protocol slot. When set, an inbound auth header is " + "refused as a second credential (at most one credential per request). " + "This is backend authentication, not caller authorization; Proxy does " + "not authenticate callers. Defaults to the FORGE_BACKEND_API_KEY env var.", + }, + credential=True, + ), + _OptionDefinition( + "backend_capability", ("--backend-capability",), "string", "native", + argparse_kwargs={ + "choices": ("native", "prompt"), + "help": "Tool-calling protocol for the backend (default: native). 'native' " + "uses the selected adapter's structured-tool path; compatible OpenAI-shaped " + "clean paths preserve raw tool fields, while other adapters convert or " + "rebuild them. 'prompt' opts into prompt-injection for non-FC llama.cpp/" + "llamafile backends (strips tools into the prompt, parses the JSON call " + "back). Frozen at startup — never probed or switched mid-stream.", + }, + ), + _OptionDefinition( + "inject_respond_tool", ("--inject-respond-tool",), "boolean", False, + argparse_kwargs={ + "action": "store_true", + "help": "Inject forge's synthetic respond() tool when the client sends tools. Default off.", + }, + ), + _OptionDefinition( + "reasoning_replay", ("--reasoning-replay",), "string", + DEFAULT_REASONING_REPLAY, + argparse_kwargs={ + "choices": REASONING_REPLAY_CHOICES, + "help": "How much captured reasoning to replay to the backend (default: none).", + }, + ), + _OptionDefinition( + "verbose", ("--verbose", "-v"), "boolean", False, + argparse_kwargs={"action": "store_true", "help": "Verbose logging"}, + ), +) + + +def option_definitions(*, include_credentials: bool = True) -> tuple[_OptionDefinition, ...]: + return tuple( + option for option in _OPTIONS + if include_credentials or not option.credential + ) + + +def profile_definitions() -> dict[str, _OptionDefinition]: + return { + option.name: option + for option in option_definitions(include_credentials=False) + } + + +def option_defaults(*, backend_api_key_from_environment: bool) -> dict[str, object]: + defaults = { + option.name: option.default + for option in option_definitions() + } + if backend_api_key_from_environment: + defaults["backend_api_key"] = os.environ.get("FORGE_BACKEND_API_KEY") + return defaults + + +def add_proxy_options( + parser: argparse.ArgumentParser, + *, + suppress_defaults: bool = False, + include_credentials: bool = True, +) -> None: + groups = {"serialization": parser.add_mutually_exclusive_group()} + seen_defaults: set[str] = set() + for option in option_definitions(include_credentials=include_credentials): + target = groups.get(option.group, parser) + kwargs = dict(option.argparse_kwargs) + if suppress_defaults: + kwargs["default"] = argparse.SUPPRESS + elif option.name not in seen_defaults: + kwargs["default"] = ( + os.environ.get("FORGE_BACKEND_API_KEY") + if option.credential else option.default + ) + target.add_argument(*option.flags, **kwargs) + seen_defaults.add(option.name) + + +def supplied_proxy_options(argv: list[str]) -> set[str]: + """Return Proxy values explicitly supplied before the terminal backend tail.""" + + head = argv[: argv.index("--extra-flags") + 1] if "--extra-flags" in argv else argv + by_flag = { + flag: option.name + for option in option_definitions() + for flag in option.flags + } + supplied: set[str] = set() + for token in head: + flag = token.split("=", 1)[0] + if flag in by_flag: + supplied.add(by_flag[flag]) + return supplied diff --git a/src/forge/proxy/_profiles.py b/src/forge/proxy/_profiles.py new file mode 100644 index 0000000..0fa11da --- /dev/null +++ b/src/forge/proxy/_profiles.py @@ -0,0 +1,198 @@ +"""Forge-owned Proxy profile locations, parsing, and managed writes.""" + +from __future__ import annotations + +import os +import platform +import tempfile +import tomllib +from dataclasses import dataclass +from pathlib import Path +from typing import Mapping + +import tomli_w + +from forge.proxy._config import ( + _NormalizedProxyConfig, + _RawProxyConfig, + _normalize_proxy_config, +) +from forge.proxy._options import option_defaults, profile_definitions + + +@dataclass(frozen=True) +class _ProfileLaunch: + raw: _RawProxyConfig + normalized: _NormalizedProxyConfig + verbose: bool + explicit_values: dict[str, object] + + +def _managed_profile_root( + *, + system: str | None = None, + environ: Mapping[str, str] | None = None, + home: Path | None = None, +) -> Path: + system = system or platform.system() + environ = os.environ if environ is None else environ + home = Path.home() if home is None else home + if system == "Windows": + return Path(environ["APPDATA"]) / "Forge" / "profiles" + if system == "Darwin": + return home / "Library" / "Application Support" / "Forge" / "profiles" + config_root = Path(environ.get("XDG_CONFIG_HOME", home / ".config")) + return config_root / "forge" / "profiles" + + +def _validate_profile_name(name: str) -> None: + if not name or name in {".", ".."} or "/" in name or "\\" in name: + raise ValueError( + "profile name must be nonempty and may not be '.', '..', or contain '/' or '\\'" + ) + + +def _managed_profile_path(name: str, *, root: Path | None = None) -> Path: + _validate_profile_name(name) + return (root or _managed_profile_root()) / f"{name}.toml" + + +def _validate_profile_value(name: str, value: object, kind: str) -> object: + if kind == "string": + valid = isinstance(value, str) + elif kind == "integer": + valid = isinstance(value, int) and not isinstance(value, bool) + elif kind == "number": + valid = isinstance(value, (int, float)) and not isinstance(value, bool) + if valid: + value = float(value) + elif kind == "boolean": + valid = isinstance(value, bool) + else: + valid = ( + isinstance(value, list) + and all(isinstance(item, str) for item in value) + ) + if not valid: + raise ValueError(f"profile field {name!r} has the wrong TOML type") + return value + + +def _parse_profile_document( + document: Mapping[str, object], + *, + backend_api_key: str | None = None, +) -> _ProfileLaunch: + schema_version = document.get("schema_version") + if not ( + isinstance(schema_version, int) + and not isinstance(schema_version, bool) + and schema_version == 1 + ): + raise ValueError("profile requires integer schema_version = 1") + + definitions = profile_definitions() + unknown = sorted(set(document) - {"schema_version", *definitions}) + if unknown: + raise ValueError(f"unknown profile fields: {', '.join(unknown)}") + + explicit: dict[str, object] = {} + for name, value in document.items(): + if name == "schema_version": + continue + definition = definitions[name] + explicit[name] = _validate_profile_value( + name, value, definition.profile_kind + ) + + values = option_defaults(backend_api_key_from_environment=False) + values.update(explicit) + values["backend_api_key"] = backend_api_key + raw = _RawProxyConfig( + backend_url=values["backend_url"], # type: ignore[arg-type] + backend=values["backend"], # type: ignore[arg-type] + model=values["model"], # type: ignore[arg-type] + gguf=values["gguf"], # type: ignore[arg-type] + model_path=values["model_path"], # type: ignore[arg-type] + backend_port=values["backend_port"], # type: ignore[arg-type] + budget_mode=values["budget_mode"], # type: ignore[arg-type] + budget_tokens=values["budget_tokens"], # type: ignore[arg-type] + extra_flags=values["extra_flags"], # type: ignore[arg-type] + host=values["host"], # type: ignore[arg-type] + port=values["port"], # type: ignore[arg-type] + serialize=values["serialize"], # type: ignore[arg-type] + max_retries=values["max_retries"], # type: ignore[arg-type] + max_tool_errors=values["max_tool_errors"], # type: ignore[arg-type] + rescue_enabled=not values["no_rescue"], + backend_capability=values["backend_capability"], # type: ignore[arg-type] + inject_respond_tool=values["inject_respond_tool"], # type: ignore[arg-type] + backend_timeout=values["backend_timeout"], # type: ignore[arg-type] + reasoning_replay=values["reasoning_replay"], # type: ignore[arg-type] + backend_api_key=values["backend_api_key"], # type: ignore[arg-type] + ) + return _ProfileLaunch( + raw=raw, + normalized=_normalize_proxy_config(raw), + verbose=bool(values["verbose"]), + explicit_values=explicit, + ) + + +def _load_profile(path: Path) -> _ProfileLaunch: + with path.open("rb") as stream: + document = tomllib.load(stream) + return _parse_profile_document( + document, + backend_api_key=os.environ.get("FORGE_BACKEND_API_KEY"), + ) + + +def _profile_bytes(explicit_values: Mapping[str, object]) -> bytes: + definitions = profile_definitions() + document: dict[str, object] = {"schema_version": 1} + for name in definitions: + if name in explicit_values: + document[name] = explicit_values[name] + return tomli_w.dumps(document).encode("utf-8") + + +def _write_managed_profile(path: Path, content: bytes, *, force: bool) -> bool: + """Atomically write a managed profile; return False for identical content.""" + + if path.exists(): + if path.read_bytes() == content: + return False + if not force: + raise FileExistsError(f"profile already exists: {path}; use --force to replace it") + + missing_directories: list[Path] = [] + current = path.parent + while not current.exists(): + missing_directories.append(current) + current = current.parent + path.parent.mkdir(parents=True, exist_ok=True) + if os.name != "nt": + for directory in reversed(missing_directories): + directory.chmod(0o700) + + temporary: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="wb", dir=path.parent, prefix=f".{path.name}.", delete=False + ) as stream: + temporary = Path(stream.name) + stream.write(content) + if os.name != "nt": + temporary.chmod(0o600) + os.replace(temporary, path) + finally: + if temporary is not None and temporary.exists(): + temporary.unlink() + return True + + +def _managed_profiles(*, root: Path | None = None) -> list[Path]: + directory = root or _managed_profile_root() + if not directory.is_dir(): + return [] + return sorted(directory.glob("*.toml"), key=lambda path: path.name) diff --git a/tests/fixtures/proxy_bootstrap/forge-proxy-linux-x86_64-gnu b/tests/fixtures/proxy_bootstrap/forge-proxy-linux-x86_64-gnu new file mode 100755 index 0000000..ba4b230 --- /dev/null +++ b/tests/fixtures/proxy_bootstrap/forge-proxy-linux-x86_64-gnu @@ -0,0 +1,5 @@ +#!/bin/sh +printf '%s\n' "$@" > "$FORGE_BOOTSTRAP_HANDOFF_LOG" +printf 'shell handoff stdout\n' +printf 'shell handoff stderr\n' >&2 +exit "${FORGE_BOOTSTRAP_HANDOFF_STATUS:-0}" diff --git a/tests/fixtures/proxy_bootstrap/forge-proxy-macos-arm64 b/tests/fixtures/proxy_bootstrap/forge-proxy-macos-arm64 new file mode 100644 index 0000000..ba4b230 --- /dev/null +++ b/tests/fixtures/proxy_bootstrap/forge-proxy-macos-arm64 @@ -0,0 +1,5 @@ +#!/bin/sh +printf '%s\n' "$@" > "$FORGE_BOOTSTRAP_HANDOFF_LOG" +printf 'shell handoff stdout\n' +printf 'shell handoff stderr\n' >&2 +exit "${FORGE_BOOTSTRAP_HANDOFF_STATUS:-0}" diff --git a/tests/fixtures/proxy_bootstrap/forge-proxy-windows-x86_64.cmd b/tests/fixtures/proxy_bootstrap/forge-proxy-windows-x86_64.cmd new file mode 100644 index 0000000..151a64d --- /dev/null +++ b/tests/fixtures/proxy_bootstrap/forge-proxy-windows-x86_64.cmd @@ -0,0 +1,5 @@ +@echo off +(for %%A in (%*) do @echo %%~A)>"%FORGE_BOOTSTRAP_HANDOFF_LOG%" +echo powershell handoff stdout +echo powershell handoff stderr 1>&2 +exit /b %FORGE_BOOTSTRAP_HANDOFF_STATUS% diff --git a/tests/fixtures/proxy_bootstrap/proxy-1.2.3-order-whitespace.json b/tests/fixtures/proxy_bootstrap/proxy-1.2.3-order-whitespace.json new file mode 100644 index 0000000..f69e4ec --- /dev/null +++ b/tests/fixtures/proxy_bootstrap/proxy-1.2.3-order-whitespace.json @@ -0,0 +1,5 @@ +{ "version" : "1.2.3", "artifacts" : { + "windows-x86_64" : { "size" : 182, "name" : "forge-proxy-windows-x86_64.cmd", "sha256" : "6f2be1b60db97e83baf60316f1a4463333aeb244e8e3c6223c2c0fa21106d873" }, + "macos-arm64" : { "sha256" : "9ffc2e63c6095b928c58612081e8cccab4513c2a1e25b61973b775a47e487275", "size" : 174, "name" : "forge-proxy-macos-arm64" }, + "linux-x86_64-gnu" : { "sha256" : "9ffc2e63c6095b928c58612081e8cccab4513c2a1e25b61973b775a47e487275", "size" : 174, "name" : "forge-proxy-linux-x86_64-gnu" } +} } diff --git a/tests/fixtures/proxy_bootstrap/proxy-1.2.3.json b/tests/fixtures/proxy_bootstrap/proxy-1.2.3.json new file mode 100644 index 0000000..5fb22bf --- /dev/null +++ b/tests/fixtures/proxy_bootstrap/proxy-1.2.3.json @@ -0,0 +1,20 @@ +{ + "artifacts": { + "linux-x86_64-gnu": { + "name": "forge-proxy-linux-x86_64-gnu", + "sha256": "9ffc2e63c6095b928c58612081e8cccab4513c2a1e25b61973b775a47e487275", + "size": 174 + }, + "macos-arm64": { + "name": "forge-proxy-macos-arm64", + "sha256": "9ffc2e63c6095b928c58612081e8cccab4513c2a1e25b61973b775a47e487275", + "size": 174 + }, + "windows-x86_64": { + "name": "forge-proxy-windows-x86_64.cmd", + "sha256": "6f2be1b60db97e83baf60316f1a4463333aeb244e8e3c6223c2c0fa21106d873", + "size": 182 + } + }, + "version": "1.2.3" +} diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..3eab2b2 --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1 @@ +"""Explicitly invoked integration and end-to-end tests.""" diff --git a/tests/integration/_bootstrap_support.py b/tests/integration/_bootstrap_support.py new file mode 100644 index 0000000..76ffdde --- /dev/null +++ b/tests/integration/_bootstrap_support.py @@ -0,0 +1,189 @@ +"""Shared local-release fixtures for installer integration and acceptance tests.""" + +from __future__ import annotations + +import os +import platform +import shutil +import subprocess +import threading +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Iterator + +import pytest + + +ROOT = Path(__file__).parents[2] +FIXTURES = ROOT / "tests" / "fixtures" / "proxy_bootstrap" +INSTALL_PS1 = ROOT / "install.ps1" +INSTALL_SH = ROOT / "install.sh" +POWERSHELL = shutil.which("powershell") +_BASH = shutil.which("bash") +BASH = Path(_BASH) if _BASH else Path(r"C:\Program Files\Git\bin\bash.exe") +VERSION = "1.2.3" + + +class FixtureServer(ThreadingHTTPServer): + routes: dict[str, tuple[int, bytes]] + requests: list[str] + + +class FixtureHandler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + server = self.server + assert isinstance(server, FixtureServer) + server.requests.append(self.path) + status, payload = server.routes.get(self.path, (404, b"missing")) + self.send_response(status) + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, _format: str, *_args: object) -> None: + return + + +@contextmanager +def fixture_server( + routes: dict[str, tuple[int, bytes]], +) -> Iterator[tuple[FixtureServer, str]]: + server = FixtureServer(("127.0.0.1", 0), FixtureHandler) + server.routes = routes + server.requests = [] + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield server, f"http://127.0.0.1:{server.server_port}" + finally: + server.shutdown() + thread.join(timeout=5) + server.server_close() + + +def bash_path(path: Path) -> str: + value = str(path.resolve()).replace("\\", "/") + if os.name != "nt": + return value + return f"/{value[0].lower()}{value[2:]}" + + +def fixture_routes( + manifest_name: str = f"proxy-{VERSION}.json", +) -> dict[str, tuple[int, bytes]]: + return { + f"/v{VERSION}/proxy-{VERSION}.json": ( + 200, + (FIXTURES / manifest_name).read_bytes(), + ), + f"/v{VERSION}/forge-proxy-linux-x86_64-gnu": ( + 200, + (FIXTURES / "forge-proxy-linux-x86_64-gnu").read_bytes(), + ), + f"/v{VERSION}/forge-proxy-macos-arm64": ( + 200, + (FIXTURES / "forge-proxy-macos-arm64").read_bytes(), + ), + f"/v{VERSION}/forge-proxy-windows-x86_64.cmd": ( + 200, + (FIXTURES / "forge-proxy-windows-x86_64.cmd").read_bytes(), + ), + } + + +def run_powershell( + tmp_path: Path, + base_url: str, + arguments: list[str], + *, + system: str = "Windows", + machine: str = "AMD64", + status: int = 0, + input_text: str | None = None, + extra_env: dict[str, str] | None = None, +) -> subprocess.CompletedProcess[str]: + if POWERSHELL is None: + pytest.skip("Windows PowerShell is unavailable") + temp_root = tmp_path / "bootstrap temp" + temp_root.mkdir(exist_ok=True) + env = os.environ.copy() + env.update( + { + "_FORGE_PROXY_BOOTSTRAP_TESTING": "1", + "_FORGE_PROXY_BOOTSTRAP_SYSTEM": system, + "_FORGE_PROXY_BOOTSTRAP_MACHINE": machine, + "_FORGE_PROXY_BOOTSTRAP_POINTER_URL": f"{base_url}/pointer", + "_FORGE_PROXY_BOOTSTRAP_RELEASE_BASE_URL": base_url, + "_FORGE_PROXY_BOOTSTRAP_TEMP_ROOT": str(temp_root), + "FORGE_BOOTSTRAP_HANDOFF_LOG": str(tmp_path / "powershell-handoff.txt"), + "FORGE_BOOTSTRAP_HANDOFF_STATUS": str(status), + } + ) + # Windows PowerShell must use its own built-in modules rather than an + # inherited PowerShell 7 module path. + system_root = Path(env.get("SystemRoot", r"C:\Windows")) + env["PSModulePath"] = str( + system_root / "System32" / "WindowsPowerShell" / "v1.0" / "Modules" + ) + if extra_env: + env.update(extra_env) + return subprocess.run( + [ + POWERSHELL, + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + str(INSTALL_PS1), + *arguments, + ], + input=input_text, + capture_output=True, + text=True, + check=False, + env=env, + timeout=120, + ) + + +def run_shell( + tmp_path: Path, + base_url: str, + arguments: list[str], + *, + system: str | None = None, + machine: str | None = None, + ldd_output: str = "ldd (Ubuntu GLIBC 2.35-0ubuntu3.8) 2.35", + status: int = 0, +) -> subprocess.CompletedProcess[str]: + if not BASH.is_file(): + pytest.skip("Bash is unavailable") + temp_root = tmp_path / "shell-temp" + temp_root.mkdir(exist_ok=True) + env = os.environ.copy() + env.update( + { + "_FORGE_PROXY_BOOTSTRAP_TESTING": "1", + "_FORGE_PROXY_BOOTSTRAP_SYSTEM": system or platform.system(), + "_FORGE_PROXY_BOOTSTRAP_MACHINE": machine or platform.machine(), + "_FORGE_PROXY_BOOTSTRAP_LDD_OUTPUT": ldd_output, + "_FORGE_PROXY_BOOTSTRAP_POINTER_URL": f"{base_url}/pointer", + "_FORGE_PROXY_BOOTSTRAP_RELEASE_BASE_URL": base_url, + "_FORGE_PROXY_BOOTSTRAP_TEMP_ROOT": bash_path(temp_root), + "FORGE_BOOTSTRAP_HANDOFF_LOG": bash_path(tmp_path / "shell-handoff.txt"), + "FORGE_BOOTSTRAP_HANDOFF_STATUS": str(status), + } + ) + return subprocess.run( + [str(BASH), bash_path(INSTALL_SH), *arguments], + capture_output=True, + text=True, + check=False, + env=env, + timeout=30, + ) + + +def handoff_lines(path: Path) -> list[str]: + return path.read_text(encoding="utf-8").splitlines() diff --git a/tests/integration/bootstrap_contract/__init__.py b/tests/integration/bootstrap_contract/__init__.py new file mode 100644 index 0000000..6cdff6e --- /dev/null +++ b/tests/integration/bootstrap_contract/__init__.py @@ -0,0 +1 @@ +"""Local-release contracts for the public bootstrap scripts.""" diff --git a/tests/integration/bootstrap_contract/test_bootstrap_contract.py b/tests/integration/bootstrap_contract/test_bootstrap_contract.py new file mode 100644 index 0000000..d0f558d --- /dev/null +++ b/tests/integration/bootstrap_contract/test_bootstrap_contract.py @@ -0,0 +1,436 @@ +"""Small subprocess contracts for the public bootstrap scripts. + +Each collected case exercises exactly one bootstrap implementation. The suite +uses only fixture payloads and a localhost release server; frozen-artifact +lifecycle checks live under ``platform_acceptance``. +""" + +from __future__ import annotations + +import json +import os +import platform +import subprocess +from pathlib import Path + +import pytest + +from tests.integration._bootstrap_support import ( + BASH, + FIXTURES, + INSTALL_PS1, + INSTALL_SH, + POWERSHELL, + VERSION, + bash_path, + fixture_routes, + fixture_server, + handoff_lines, + run_powershell, + run_shell, +) + +pytestmark = pytest.mark.integration + + +def require_native_runner(runner: str) -> None: + native = "powershell" if os.name == "nt" else "shell" + if runner != native: + pytest.skip(f"{runner} is not the native bootstrap on this runner") + + +def shell_target() -> str: + return "macos-arm64" if platform.system() == "Darwin" else "linux-x86_64-gnu" + + +@pytest.mark.parametrize( + ("runner", "manifest_name"), + [ + ("powershell", "proxy-1.2.3.json"), + ("powershell", "proxy-1.2.3-order-whitespace.json"), + ("shell", "proxy-1.2.3.json"), + ("shell", "proxy-1.2.3-order-whitespace.json"), + ], +) +def test_manifest_drives_one_bootstrap_handoff( + tmp_path: Path, runner: str, manifest_name: str +) -> None: + require_native_runner(runner) + routes = fixture_routes(manifest_name) + with fixture_server(routes) as (server, base_url): + if runner == "powershell": + root = tmp_path / "PowerShell root with spaces" + result = run_powershell( + tmp_path, + base_url, + ["-Version", VERSION, "-NoInit", "-InstallRoot", str(root)], + ) + handoff = tmp_path / "powershell-handoff.txt" + expected_sha = ( + "6f2be1b60db97e83baf60316f1a4463333aeb244e8e3c6223c2c0fa21106d873" + ) + temp_root = tmp_path / "bootstrap temp" + else: + root = "/opt/forge proxy" + result = run_shell( + tmp_path, + base_url, + ["--version", VERSION, "--no-init", "--install-root", str(root)], + ) + handoff = tmp_path / "shell-handoff.txt" + expected_sha = ( + "9ffc2e63c6095b928c58612081e8cccab4513c2a1e25b61973b775a47e487275" + ) + temp_root = tmp_path / "shell-temp" + + assert result.returncode == 0, result.stderr + assert handoff_lines(handoff) == [ + "install-artifact", + "--version", + VERSION, + "--sha256", + expected_sha, + "--no-init", + "--install-root", + str(root), + ] + assert "/pointer" not in server.requests + assert not list(temp_root.iterdir()) + + +@pytest.mark.parametrize("runner", ["powershell", "shell"]) +def test_pointer_selection_and_public_help(tmp_path: Path, runner: str) -> None: + require_native_runner(runner) + routes = fixture_routes() + routes["/pointer"] = (200, b"1.2.3\n") + with fixture_server(routes) as (_server, base_url): + result = ( + run_powershell(tmp_path, base_url, []) + if runner == "powershell" + else run_shell(tmp_path, base_url, []) + ) + assert result.returncode == 0 + assert "handoff stdout" in result.stdout + assert "handoff stderr" in result.stderr + + command = ( + [POWERSHELL or "powershell", "-NoProfile", "-File", str(INSTALL_PS1), "-Help"] + if runner == "powershell" + else [str(BASH), bash_path(INSTALL_SH), "--help"] + ) + help_text = subprocess.run( + command, + capture_output=True, + text=True, + check=False, + ).stdout + assert "X.Y.Z" in help_text + assert "NoInit" in help_text or "no-init" in help_text + assert "InstallRoot" in help_text or "install-root" in help_text + assert "install-artifact --version X.Y.Z --sha256 HEX" in help_text + assert "inspect" in help_text.lower() + + +@pytest.mark.parametrize("runner", ["powershell", "shell"]) +def test_missing_stable_pointer_reports_no_published_release( + tmp_path: Path, runner: str +) -> None: + require_native_runner(runner) + with fixture_server(fixture_routes()) as (server, base_url): + result = ( + run_powershell(tmp_path, base_url, []) + if runner == "powershell" + else run_shell(tmp_path, base_url, []) + ) + assert result.returncode == 1 + assert "no stable standalone Proxy release has been published" in result.stderr + assert server.requests == ["/pointer"] + temp_name = "bootstrap temp" if runner == "powershell" else "shell-temp" + assert not list((tmp_path / temp_name).iterdir()) + + +@pytest.mark.parametrize("runner", ["powershell", "shell"]) +def test_unavailable_stable_pointer_is_not_reported_as_unpublished( + tmp_path: Path, runner: str +) -> None: + require_native_runner(runner) + with fixture_server({"/pointer": (503, b"unavailable")}) as (server, base_url): + result = ( + run_powershell(tmp_path, base_url, []) + if runner == "powershell" + else run_shell(tmp_path, base_url, []) + ) + assert result.returncode == 1 + assert "download unavailable" in result.stderr + assert "no stable standalone Proxy release has been published" not in result.stderr + assert server.requests == ["/pointer"] + + +@pytest.mark.parametrize( + ("runner", "arguments", "expected"), + [ + ("powershell", ["-Version", VERSION], "404"), + ("shell", ["--version", VERSION], "download unavailable"), + ], +) +def test_explicit_version_download_failure_is_not_a_missing_stable_release( + tmp_path: Path, runner: str, arguments: list[str], expected: str +) -> None: + require_native_runner(runner) + with fixture_server({}) as (server, base_url): + result = ( + run_powershell(tmp_path, base_url, arguments) + if runner == "powershell" + else run_shell(tmp_path, base_url, arguments) + ) + assert result.returncode == 1 + assert expected in result.stderr + assert "no stable standalone Proxy release has been published" not in result.stderr + assert server.requests == [f"/v{VERSION}/proxy-{VERSION}.json"] + temp_name = "bootstrap temp" if runner == "powershell" else "shell-temp" + assert not list((tmp_path / temp_name).iterdir()) + + +@pytest.mark.parametrize( + ("runner", "kwargs", "message", "exact"), + [ + ( + "powershell", + {"system": "Linux", "machine": "AMD64"}, + "unsupported standalone target", + False, + ), + ( + "powershell", + {"system": "Windows", "machine": "ARM64"}, + "unsupported standalone target", + False, + ), + ( + "shell", + {"system": "Darwin", "machine": "x86_64"}, + "unsupported standalone target", + False, + ), + ( + "shell", + { + "system": "Linux", + "machine": "x86_64", + "ldd_output": "musl libc (x86_64) Version 1.2.5", + }, + "could not be proven", + False, + ), + ( + "shell", + {"system": "Linux", "machine": "x86_64", "ldd_output": "ldd unknown"}, + "could not be proven", + False, + ), + ( + "shell", + { + "system": "Linux", + "machine": "x86_64", + "ldd_output": "ldd (GNU libc) 2.34", + }, + "2.35 or newer", + False, + ), + ( + "powershell", + {"system": "Windows", "machine": "ARM64"}, + "unsupported standalone target", + True, + ), + ( + "shell", + { + "system": "Linux", + "machine": "x86_64", + "ldd_output": "ldd (GNU libc) 2.34", + }, + "2.35 or newer", + True, + ), + ], +) +def test_unsupported_hosts_make_zero_requests( + tmp_path: Path, + runner: str, + kwargs: dict[str, str], + message: str, + exact: bool, +) -> None: + require_native_runner(runner) + with fixture_server({}) as (server, base_url): + arguments = ["-Version", VERSION] if runner == "powershell" and exact else [] + if runner == "shell" and exact: + arguments = ["--version", VERSION] + result = ( + run_powershell(tmp_path, base_url, arguments, **kwargs) + if runner == "powershell" + else run_shell(tmp_path, base_url, arguments, **kwargs) + ) + assert result.returncode != 0 + assert message in result.stderr + assert server.requests == [] + + +@pytest.mark.parametrize( + "banner", + [ + "ldd (Ubuntu GLIBC 2.35-0ubuntu3.8) 2.35", + "ldd (Debian GLIBC 2.36-9+deb12u4) 2.36", + "ldd (GNU libc) 2.100", + ], +) +@pytest.mark.skipif(platform.system() != "Linux", reason="Linux bootstrap contract") +def test_linux_glibc_banners_at_and_above_floor_are_accepted( + tmp_path: Path, banner: str +) -> None: + with fixture_server(fixture_routes()) as (_server, base_url): + result = run_shell( + tmp_path, + base_url, + ["--version", VERSION], + system="Linux", + machine="x86_64", + ldd_output=banner, + ) + assert result.returncode == 0, result.stderr + + +@pytest.mark.parametrize("runner", ["powershell", "shell"]) +@pytest.mark.parametrize("failure", ["artifact", "checksum", "handoff"]) +def test_artifact_checksum_and_handoff_failures_cleanup( + tmp_path: Path, runner: str, failure: str +) -> None: + require_native_runner(runner) + routes = fixture_routes() + arguments = ( + ["-Version", VERSION] if runner == "powershell" else ["--version", VERSION] + ) + status = 0 + if failure == "artifact": + target = "windows-x86_64" if runner == "powershell" else shell_target() + document = json.loads((FIXTURES / f"proxy-{VERSION}.json").read_bytes()) + suffix = document["artifacts"][target]["name"] + routes.pop(f"/v{VERSION}/{suffix}") + elif failure == "checksum": + document = json.loads((FIXTURES / f"proxy-{VERSION}.json").read_bytes()) + target = "windows-x86_64" if runner == "powershell" else shell_target() + document["artifacts"][target]["sha256"] = "f" * 64 + routes[f"/v{VERSION}/proxy-{VERSION}.json"] = ( + 200, + json.dumps(document).encode(), + ) + else: + status = 37 + + with fixture_server(routes) as (_server, base_url): + result = ( + run_powershell(tmp_path, base_url, arguments, status=status) + if runner == "powershell" + else run_shell(tmp_path, base_url, arguments, status=status) + ) + assert result.returncode == (37 if failure == "handoff" else 1) + if failure == "handoff": + assert "handoff stdout" in result.stdout + assert "handoff stderr" in result.stderr + temp_name = "bootstrap temp" if runner == "powershell" else "shell-temp" + assert not list((tmp_path / temp_name).iterdir()) + + +@pytest.mark.parametrize("runner", ["powershell", "shell"]) +@pytest.mark.parametrize("failure", ["version", "target"]) +def test_manifest_version_and_selected_target_are_required( + tmp_path: Path, runner: str, failure: str +) -> None: + require_native_runner(runner) + document = json.loads((FIXTURES / f"proxy-{VERSION}.json").read_bytes()) + if failure == "version": + document["version"] = "9.9.9" + expected = "version does not match" + else: + document["artifacts"].pop( + "windows-x86_64" if runner == "powershell" else shell_target() + ) + expected = "has no artifact" + routes = fixture_routes() + routes[f"/v{VERSION}/proxy-{VERSION}.json"] = (200, json.dumps(document).encode()) + with fixture_server(routes) as (_server, base_url): + result = ( + run_powershell(tmp_path, base_url, ["-Version", VERSION]) + if runner == "powershell" + else run_shell(tmp_path, base_url, ["--version", VERSION]) + ) + assert result.returncode == 1 + assert expected in result.stderr + + +@pytest.mark.parametrize( + ("runner", "arguments"), + [ + ("powershell", ["-Version", "1.02.3"]), + ("powershell", ["-InstallRoot", "relative"]), + ("powershell", ["-Stable"]), + ("shell", ["--version", "1.02.3"]), + ("shell", ["--install-root", "relative"]), + ("shell", ["--stable"]), + ], +) +def test_malformed_input_fails_before_fetch( + tmp_path: Path, + runner: str, + arguments: list[str], +) -> None: + require_native_runner(runner) + with fixture_server({}) as (server, base_url): + result = ( + run_powershell(tmp_path, base_url, arguments) + if runner == "powershell" + else run_shell(tmp_path, base_url, arguments) + ) + assert result.returncode != 0 + assert server.requests == [] + + +@pytest.mark.skipif(not BASH.is_file(), reason="Bash is unavailable") +@pytest.mark.skipif(os.name == "nt", reason="POSIX bootstrap contract") +def test_piped_posix_bootstrap_hands_off_without_consuming_user_input( + tmp_path: Path, +) -> None: + handoff = tmp_path / "piped-posix-handoff.txt" + temp_root = tmp_path / "piped-shell-temp" + temp_root.mkdir() + env = os.environ.copy() + env.update( + { + "_FORGE_PROXY_BOOTSTRAP_TESTING": "1", + "_FORGE_PROXY_BOOTSTRAP_SYSTEM": platform.system(), + "_FORGE_PROXY_BOOTSTRAP_MACHINE": platform.machine(), + "_FORGE_PROXY_BOOTSTRAP_LDD_OUTPUT": "ldd (GNU libc) 2.35", + "_FORGE_PROXY_BOOTSTRAP_TEMP_ROOT": bash_path(temp_root), + "FORGE_BOOTSTRAP_HANDOFF_LOG": bash_path(handoff), + "FORGE_BOOTSTRAP_HANDOFF_STATUS": "0", + } + ) + routes = fixture_routes() + routes["/install.sh"] = (200, INSTALL_SH.read_bytes()) + routes["/pointer"] = (200, f"{VERSION}\n".encode("ascii")) + with fixture_server(routes) as (_server, base_url): + env["_FORGE_PROXY_BOOTSTRAP_POINTER_URL"] = f"{base_url}/pointer" + env["_FORGE_PROXY_BOOTSTRAP_RELEASE_BASE_URL"] = base_url + result = subprocess.run( + [str(BASH), "-c", f"curl -fsSL {base_url}/install.sh | sh"], + cwd=tmp_path, + capture_output=True, + text=True, + check=False, + env=env, + timeout=30, + ) + assert result.returncode == 0, result.stderr + assert "--no-init" not in handoff_lines(handoff) diff --git a/tests/integration/platform_acceptance/__init__.py b/tests/integration/platform_acceptance/__init__.py new file mode 100644 index 0000000..6e15547 --- /dev/null +++ b/tests/integration/platform_acceptance/__init__.py @@ -0,0 +1 @@ +"""Opt-in tests that execute platform-native or frozen installer artifacts.""" diff --git a/tests/integration/platform_acceptance/test_windows_installer.py b/tests/integration/platform_acceptance/test_windows_installer.py new file mode 100644 index 0000000..b7d1158 --- /dev/null +++ b/tests/integration/platform_acceptance/test_windows_installer.py @@ -0,0 +1,200 @@ +"""Windows acceptance checks that execute installer-owned native processes.""" + +from __future__ import annotations + +import hashlib +import json +import os +import subprocess +import time +from pathlib import Path + +import pytest + +from scripts.standalone.release import project_version +from tests.integration._bootstrap_support import ROOT, fixture_server, run_powershell +from tests.unit.test_proxy_installer import ( + FakeRunner, + adapter, + artifact, + install, + windows_paths, +) + + +pytestmark = [pytest.mark.integration, pytest.mark.acceptance] + + +def sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def tree_snapshot(root: Path) -> tuple[tuple[str, str], ...] | None: + if not root.exists(): + return None + rows: list[tuple[str, str]] = [] + for path in sorted(root.rglob("*")): + relative = str(path.relative_to(root)) + rows.append((relative, sha256(path) if path.is_file() else "directory")) + return tuple(rows) + + +def user_path() -> str | None: + import winreg + + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment") as key: + return str(winreg.QueryValueEx(key, "Path")[0]) + except FileNotFoundError: + return None + + +def installed_bytes(root: Path, version: str) -> tuple[bytes, bytes, bytes]: + return ( + (root / "bin" / "forge-proxy.cmd").read_bytes(), + (root / "state.json").read_bytes(), + (root / "versions" / version / "forge-proxy.exe").read_bytes(), + ) + + +def wait_for_uninstall(root: Path) -> None: + deadline = time.monotonic() + 15 + while (root / "state.json").exists() and time.monotonic() < deadline: + time.sleep(0.05) + assert not (root / "state.json").exists() + + +@pytest.mark.skipif(os.name != "nt", reason="Windows packaged-artifact lifecycle") +def test_windows_frozen_artifact_install_failure_and_uninstall( + tmp_path: Path, +) -> None: + frozen = ROOT / "standalone-dist" / "windows-x86_64" / "onefile" / "forge-proxy.exe" + if not frozen.is_file(): + pytest.skip("packaged Windows artifact is unavailable") + version = project_version() + name = "forge-proxy-windows-x86_64.exe" + manifest = json.dumps( + { + "artifacts": { + "windows-x86_64": { + "name": name, + "sha256": sha256(frozen), + "size": frozen.stat().st_size, + } + }, + "version": version, + }, + indent=2, + sort_keys=True, + ).encode() + routes = { + "/pointer": (200, f"{version}\n".encode()), + f"/v{version}/proxy-{version}.json": (200, manifest), + f"/v{version}/{name}": (200, frozen.read_bytes()), + } + + real_local = Path(os.environ["LOCALAPPDATA"]) / "Forge" + real_tree_before = tree_snapshot(real_local) + real_path_before = user_path() + redirected = { + "APPDATA": str(tmp_path / "appdata"), + "LOCALAPPDATA": str(tmp_path / "localappdata"), + "FORGE_PROXY_PATH_FILE": str(tmp_path / "fixture-path.txt"), + } + Path(redirected["APPDATA"]).mkdir() + Path(redirected["LOCALAPPDATA"]).mkdir() + Path(redirected["FORGE_PROXY_PATH_FILE"]).write_text( + "C:\\Existing", encoding="utf-8" + ) + + with fixture_server(routes) as (_server, base_url): + install_root = tmp_path / "exact root with spaces" + result = run_powershell( + tmp_path, + base_url, + ["-Version", version, "-NoInit", "-InstallRoot", str(install_root)], + extra_env=redirected, + ) + assert result.returncode == 0, result.stderr + assert "forge-proxy init --non-interactive" in result.stdout + before_failure = installed_bytes(install_root, version) + + bad_document = json.loads(manifest) + bad_document["artifacts"]["windows-x86_64"]["sha256"] = "f" * 64 + routes[f"/v{version}/proxy-{version}.json"] = ( + 200, + json.dumps(bad_document).encode(), + ) + failed = run_powershell( + tmp_path, + base_url, + ["-Version", version, "-NoInit", "-InstallRoot", str(install_root)], + extra_env=redirected, + ) + assert failed.returncode == 1 + assert "checksum mismatch" in failed.stderr + assert installed_bytes(install_root, version) == before_failure + + profile = Path(redirected["APPDATA"]) / "Forge" / "profiles" / "default.toml" + profile.parent.mkdir(parents=True) + profile.write_text("backend = 'openai'\n", encoding="utf-8") + uninstall_env = os.environ.copy() + uninstall_env.update(redirected) + uninstall = subprocess.run( + [ + str(install_root / "versions" / version / "forge-proxy.exe"), + "uninstall", + ], + capture_output=True, + text=True, + check=False, + env=uninstall_env, + timeout=30, + ) + assert uninstall.returncode == 0, uninstall.stderr + wait_for_uninstall(install_root) + + assert profile.read_text(encoding="utf-8") == "backend = 'openai'\n" + assert Path(redirected["FORGE_PROXY_PATH_FILE"]).read_text() == "C:\\Existing" + assert not list((tmp_path / "bootstrap temp").iterdir()) + assert user_path() == real_path_before + assert tree_snapshot(real_local) == real_tree_before + + +@pytest.mark.skipif(os.name != "nt", reason="Windows file locking behavior") +def test_windows_locked_slot_preserves_uninstall_retry_path(tmp_path: Path) -> None: + paths = windows_paths(tmp_path, "locked native root") + path_file = adapter(tmp_path, "C:\\Existing") + source, sha = artifact(tmp_path, "1.0.0") + install(source, sha, "1.0.0", paths, FakeRunner(), path_file) + + with paths.slot("1.0.0").open("rb"): + subprocess.run( + [str(paths.uninstaller), "999999"], + capture_output=True, + text=True, + check=False, + shell=True, + timeout=15, + ) + assert paths.command.is_file() + assert paths.state.is_file() + assert paths.marker.is_file() + assert paths.uninstaller.is_file() + assert str(paths.command_dir) in (tmp_path / "user-path.txt").read_text() + + result = subprocess.run( + [str(paths.uninstaller), "999999"], + capture_output=True, + text=True, + check=False, + shell=True, + timeout=15, + ) + assert result.returncode == 0, result.stderr + deadline = time.monotonic() + 10 + while paths.state.exists() and time.monotonic() < deadline: + time.sleep(0.05) + assert not paths.state.exists() + assert not paths.command.exists() + assert (tmp_path / "user-path.txt").read_text() == "C:\\Existing" diff --git a/tests/unit/test_proxy_cli.py b/tests/unit/test_proxy_cli.py index 7e8df46..1605685 100644 --- a/tests/unit/test_proxy_cli.py +++ b/tests/unit/test_proxy_cli.py @@ -4,28 +4,137 @@ import subprocess import sys +import tomllib +from pathlib import Path from unittest.mock import ANY, MagicMock, patch import pytest from forge.proxy import __main__ as proxy_cli +from forge.proxy import _installer from forge.proxy.__main__ import _build_parser, _proxy_from_args +def test_version_exits_zero_without_configuration_and_matches_project( + capsys: pytest.CaptureFixture[str], +) -> None: + project = tomllib.loads( + (Path(__file__).parents[2] / "pyproject.toml").read_text(encoding="utf-8") + ) + with pytest.raises(SystemExit) as exc: + proxy_cli.main(["--version"]) + assert exc.value.code == 0 + assert capsys.readouterr().out.strip() == project["project"]["version"] + + +def test_help_lists_installed_lifecycle_without_out_of_scope_modes() -> None: + help_text = _build_parser().format_help() + assert "init" in help_text + assert "check" in help_text + assert "install-artifact" in help_text + assert "update [--version X.Y.Z]" in help_text + assert "uninstall" in help_text + assert "--stable" not in help_text + assert "rollback" not in help_text + + +def test_install_artifact_dispatches_current_artifact_and_custom_root( + tmp_path: Path, +) -> None: + current = tmp_path / "forge-proxy.exe" + root = tmp_path / "custom root" + with ( + patch.object(_installer, "current_artifact", return_value=current), + patch.object(_installer, "install_artifact") as install_artifact, + ): + proxy_cli.main( + [ + "install-artifact", + "--version", + "1.2.3", + "--sha256", + "a" * 64, + "--no-init", + "--install-root", + str(root), + ] + ) + install_artifact.assert_called_once_with( + current, + "1.2.3", + "a" * 64, + install_root=root, + no_init=True, + ) + + +def test_update_and_uninstall_dispatch_before_launch_parser() -> None: + with patch.object(_installer, "update") as update: + proxy_cli.main(["update", "--version", "1.2.3"]) + update.assert_called_once_with("1.2.3") + with patch.object(_installer, "delegate_uninstall") as uninstall: + proxy_cli.main(["uninstall"]) + uninstall.assert_called_once_with() + + +def test_release_gate_can_point_installed_update_at_local_candidate( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("_FORGE_PROXY_INSTALLER_TESTING", "1") + monkeypatch.setenv( + "_FORGE_PROXY_INSTALLER_POINTER_URL", "http://127.0.0.1:1234/pointer" + ) + monkeypatch.setenv( + "_FORGE_PROXY_INSTALLER_RELEASE_BASE_URL", "http://127.0.0.1:1234/" + ) + with patch.object(_installer, "update") as update: + proxy_cli.main(["update", "--version", "1.2.3"]) + update.assert_called_once_with( + "1.2.3", + pointer_url="http://127.0.0.1:1234/pointer", + manifest_url="http://127.0.0.1:1234/v{version}/proxy-{version}.json", + asset_url="http://127.0.0.1:1234/v{version}/{name}", + ) + + +def test_private_self_check_rejects_requested_version_mismatch( + capsys: pytest.CaptureFixture[str], +) -> None: + with pytest.raises(SystemExit) as exc: + proxy_cli.main( + [ + "_installer-self-check", + "--expected-version", + "999.0.0", + ] + ) + assert exc.value.code == 2 + assert "does not match requested version" in capsys.readouterr().err + + def test_backend_choices_come_from_complete_selector_set() -> None: parser = _build_parser() backend_action = next( action for action in parser._actions if action.dest == "backend" ) assert backend_action.choices == ( - "llamaserver", "llamafile", "ollama", "vllm", "openai", "anthropic", + "llamaserver", + "llamafile", + "ollama", + "vllm", + "openai", + "anthropic", ) -def test_removed_backend_protocol_is_unrecognized(capsys: pytest.CaptureFixture[str]) -> None: +def test_removed_backend_protocol_is_unrecognized( + capsys: pytest.CaptureFixture[str], +) -> None: parser = _build_parser() with pytest.raises(SystemExit) as exc: - parser.parse_args(["--backend-url", "http://host", "--backend-protocol", "openai"]) + parser.parse_args( + ["--backend-url", "http://host", "--backend-protocol", "openai"] + ) assert exc.value.code == 2 assert "unrecognized arguments: --backend-protocol" in capsys.readouterr().err @@ -35,19 +144,34 @@ def test_serialization_switches_are_mutually_exclusive( ) -> None: parser = _build_parser() with pytest.raises(SystemExit) as exc: - parser.parse_args([ - "--backend-url", "http://host", "--serialize", "--no-serialize", - ]) + parser.parse_args( + [ + "--backend-url", + "http://host", + "--serialize", + "--no-serialize", + ] + ) assert exc.value.code == 2 assert "not allowed with argument" in capsys.readouterr().err def test_extra_flags_are_a_terminal_remainder() -> None: parser = _build_parser() - args = parser.parse_args([ - "--backend", "vllm", "--model-path", "/m", "--port", "9000", - "--extra-flags", "--verbose", "backend-value", "--no-rescue", - ]) + args = parser.parse_args( + [ + "--backend", + "vllm", + "--model-path", + "/m", + "--port", + "9000", + "--extra-flags", + "--verbose", + "backend-value", + "--no-rescue", + ] + ) assert args.port == 9000 assert args.no_rescue is False assert args.extra_flags == ["--verbose", "backend-value", "--no-rescue"] @@ -68,9 +192,15 @@ def test_help_describes_budget_and_auth_boundaries() -> None: def test_empty_cli_extra_flags_normalize_as_absent() -> None: parser = _build_parser() - args = parser.parse_args([ - "--backend", "ollama", "--model", "tag", "--extra-flags", - ]) + args = parser.parse_args( + [ + "--backend", + "ollama", + "--model", + "tag", + "--extra-flags", + ] + ) assert args.extra_flags == [] assert _proxy_from_args(parser, args)._extra_flags is None @@ -117,9 +247,48 @@ def test_main_starts_and_stops_on_keyboard_interrupt( assert exc.value.code == 0 configure_logging.assert_called_once() - register_signal.assert_any_call(proxy_cli.signal.SIGINT, ANY) + supported_signals = [proxy_cli.signal.SIGINT] + if hasattr(proxy_cli.signal, "SIGTERM"): + supported_signals.append(proxy_cli.signal.SIGTERM) + if hasattr(proxy_cli.signal, "SIGBREAK"): + supported_signals.append(proxy_cli.signal.SIGBREAK) + for supported_signal in supported_signals: + register_signal.assert_any_call(supported_signal, ANY) proxy.start.assert_called_once_with() proxy.stop.assert_called_once_with() output = capsys.readouterr().out assert "forge proxy running at http://127.0.0.1:8081" in output assert "Shutting down..." in output + + +@pytest.mark.parametrize( + "shutdown_signal", + [ + proxy_cli.signal.SIGINT, + *([proxy_cli.signal.SIGTERM] if hasattr(proxy_cli.signal, "SIGTERM") else []), + *([proxy_cli.signal.SIGBREAK] if hasattr(proxy_cli.signal, "SIGBREAK") else []), + ], +) +def test_supported_signal_reaches_stop_and_exit(shutdown_signal: int) -> None: + proxy = MagicMock() + proxy.url = "http://127.0.0.1:8081" + handlers: dict[int, object] = {} + + def register(sig: int, handler: object) -> None: + handlers[sig] = handler + + def deliver_signal(_seconds: float) -> None: + handler = handlers[shutdown_signal] + assert callable(handler) + handler(shutdown_signal, None) + + with ( + patch.object(proxy_cli, "_proxy_from_args", return_value=proxy), + patch.object(proxy_cli.signal, "signal", side_effect=register), + patch.object(proxy_cli.time, "sleep", side_effect=deliver_signal), + pytest.raises(SystemExit) as exc, + ): + proxy_cli.main(["--backend-url", "http://backend"]) + + assert exc.value.code == 0 + proxy.stop.assert_called_once_with() diff --git a/tests/unit/test_proxy_installation_docs.py b/tests/unit/test_proxy_installation_docs.py new file mode 100644 index 0000000..70867b8 --- /dev/null +++ b/tests/unit/test_proxy_installation_docs.py @@ -0,0 +1,173 @@ +"""Contract and local-fixture checks for the canonical Proxy installation page.""" + +from __future__ import annotations + +import re +import shlex +from pathlib import Path + +import pytest + +from forge.proxy import __main__ as proxy_cli +from forge.proxy import _profiles +from forge.proxy._config import _normalize_proxy_config +from forge.proxy._options import supplied_proxy_options +from scripts.standalone import inputs, release + + +ROOT = Path(__file__).parents[2] +PAGE = ROOT / "docs" / "PROXY_INSTALLATION.md" +PAGE_TEXT = PAGE.read_text(encoding="utf-8") + + +def test_single_canonical_page_is_discoverable_from_readme_and_help() -> None: + assert [path.relative_to(ROOT) for path in ROOT.rglob("PROXY_INSTALLATION.md")] == [ + Path("docs/PROXY_INSTALLATION.md") + ] + assert "[Forge Proxy Installation](docs/PROXY_INSTALLATION.md)" in ( + ROOT / "README.md" + ).read_text(encoding="utf-8") + help_text = proxy_cli._build_parser().format_help() + for url in ( + "https://github.com/antoinezambelli/forge/blob/main/docs/PROXY_INSTALLATION.md", + "https://github.com/antoinezambelli/forge#proxy-server", + "https://github.com/antoinezambelli/forge/blob/main/docs/USER_GUIDE.md", + ): + assert url in help_text + + +def test_target_artifact_and_path_claims_come_from_product_contracts() -> None: + for target in inputs.SUPPORTED_TARGETS: + assert f"`{target}`" in PAGE_TEXT + assert f"`{release.artifact_name(target)}`" in PAGE_TEXT + + for claim in ( + "%LOCALAPPDATA%\\Forge", + "%APPDATA%\\Forge\\profiles", + "${XDG_DATA_HOME:-$HOME/.local/share}/forge", + "${XDG_CONFIG_HOME:-$HOME/.config}/forge/profiles", + "$HOME/Library/Application Support/Forge", + "/bin", + ): + assert claim in PAGE_TEXT + + +def test_documented_option_spellings_match_script_and_cli_help( + capsys: pytest.CaptureFixture[str], +) -> None: + shell = (ROOT / "install.sh").read_text(encoding="utf-8") + powershell = (ROOT / "install.ps1").read_text(encoding="utf-8") + for line in ( + "artifact=ARTIFACT_NAME_FROM_THE_TARGET_ENTRY", + 'curl -fsSLO "https://github.com/antoinezambelli/forge/releases/download/vX.Y.Z/$artifact"', + 'chmod +x "./$artifact"', + '"./$artifact" install-artifact --version X.Y.Z --sha256 HEX', + ): + assert shell.count(line) == 2 + shell_usage = re.search(r"Usage: install\.sh \[(.*?)\] \[(.*?)\] \[(.*?)\]", shell) + ps_usage = re.search(r"Usage: install\.ps1 \[(.*?)\] \[(.*?)\] \[(.*?)\]", powershell) + assert shell_usage is not None and ps_usage is not None + for spelling in ("--version X.Y.Z", "--no-init", "--install-root ABSOLUTE"): + assert spelling in shell_usage.group(0) + assert spelling in PAGE_TEXT + for spelling in ("-Version X.Y.Z", "-NoInit", "-InstallRoot ABSOLUTE"): + assert spelling in ps_usage.group(0) + assert spelling in PAGE_TEXT + + for argv, spellings in ( + (["install-artifact", "--help"], ("--version", "--sha256", "--no-init", "--install-root")), + (["update", "--help"], ("--version",)), + (["init", "--help"], ("--profile", "--non-interactive", "--backend-url")), + (["check", "--help"], ()), + (["--help"], ("--profile", "--config", "--backend-url")), + ): + with pytest.raises(SystemExit) as exc: + proxy_cli.main(argv) + assert exc.value.code == 0 + actual_help = capsys.readouterr().out + for spelling in spellings: + assert spelling in actual_help + assert spelling in PAGE_TEXT + + +def test_unmanaged_profile_and_flag_only_examples_normalize_without_launching() -> None: + commands = { + line + for line in PAGE_TEXT.splitlines() + if line.startswith("forge-proxy init --profile") + or line.startswith("forge-proxy --backend-url") + } + init_commands = [ + line + for line in commands + if line.startswith("forge-proxy init") and "--non-interactive" in line + ] + launch_commands = [ + line for line in commands if line.startswith("forge-proxy --backend-url") + ] + assert len(init_commands) == 2 + assert len(launch_commands) == 2 + + for command in init_commands: + argv = shlex.split(command)[2:] + args = proxy_cli._build_init_parser().parse_args(argv) + explicit = { + name: value + for name, value in vars(args).items() + if name not in {"profile", "non_interactive", "force"} + } + parsed = _profiles._parse_profile_document( + {"schema_version": 1, **explicit} + ) + assert parsed.normalized.backend_url is not None + if "anthropic-gateway" in command: + assert parsed.normalized.protocol == "anthropic" + + for command in launch_commands: + argv = shlex.split(command)[1:] + parser = proxy_cli._build_parser() + args = parser.parse_args(argv) + raw, _verbose, flag_only = proxy_cli._selected_launch(parser, args, argv) + normalized = _normalize_proxy_config(raw) + assert flag_only is True + assert normalized.backend_url is not None + assert len(supplied_proxy_options(argv)) >= 4 + + +def test_stable_pointer_wording_remains_conditional_for_absent_and_present() -> None: + prose = " ".join(PAGE_TEXT.split()) + assert "If that pointer is absent" in prose + assert "no stable standalone Proxy release has been published" in prose + assert "only while that pointer exists" in prose + assert "any particular stable or exact standalone release has been published" in prose + + +def test_custom_root_examples_are_user_owned_absolute_paths() -> None: + assert "/opt/forge proxy" not in PAGE_TEXT + assert "C:\\Forge Proxy" not in PAGE_TEXT + assert PAGE_TEXT.count('"$HOME/.local/share/forge-proxy-custom"') == 4 + assert PAGE_TEXT.count('"$env:LOCALAPPDATA\\Forge Proxy Custom"') == 3 + + +def _heading_anchors(markdown: str) -> set[str]: + anchors: set[str] = set() + for heading in re.findall(r"^#{1,6} +(.*)$", markdown, re.MULTILINE): + anchor = heading.strip().lower() + anchor = re.sub(r"[^\w\- ]", "", anchor) + anchors.add(re.sub(r" +", "-", anchor)) + return anchors + + +def test_repository_relative_links_and_anchors_resolve() -> None: + links = re.findall(r"\[[^]]+\]\(([^)]+)\)", PAGE_TEXT) + assert links + for link in links: + if re.match(r"^[a-z]+://", link): + continue + target_text, _, fragment = link.partition("#") + target = (PAGE.parent / target_text).resolve() + assert target.is_file(), link + assert target.is_relative_to(ROOT), link + if fragment: + anchors = _heading_anchors(target.read_text(encoding="utf-8")) + assert fragment in anchors, link diff --git a/tests/unit/test_proxy_installer.py b/tests/unit/test_proxy_installer.py new file mode 100644 index 0000000..63f0f75 --- /dev/null +++ b/tests/unit/test_proxy_installer.py @@ -0,0 +1,671 @@ +"""Fixture-only coverage for the installed standalone Proxy lifecycle.""" + +from __future__ import annotations + +import hashlib +import json +import os +import shlex +import subprocess +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from forge.proxy import _installer + + +class FakeRunner: + def __init__(self) -> None: + self.calls: list[tuple[Path, list[str]]] = [] + self.fail_payloads: set[bytes] = set() + self.profile_incompatible = False + + def run( + self, executable: Path, arguments: list[str] + ) -> subprocess.CompletedProcess[str]: + self.calls.append((executable, arguments)) + payload = executable.read_bytes() + if arguments[0] == "_installer-self-check": + expected = arguments[arguments.index("--expected-version") + 1] + embedded = payload.decode().split()[1] + if payload in self.fail_payloads: + return subprocess.CompletedProcess([], 1, "", "health failed") + if embedded != expected: + return subprocess.CompletedProcess([], 2, "", "version mismatch") + if arguments[0] == "_installer-profile-check" and self.profile_incompatible: + return subprocess.CompletedProcess( + [], 1, "Incompatible managed profile old: unsupported field\n", "" + ) + return subprocess.CompletedProcess([], 0, "", "") + + +class FixtureTransport: + def __init__(self, values: dict[str, bytes | Exception]) -> None: + self.values = values + self.reads: list[str] = [] + + def read(self, url: str) -> bytes: + self.reads.append(url) + value = self.values[url] + if isinstance(value, Exception): + raise value + return value + + +def artifact(tmp_path: Path, version: str, suffix: str = "") -> tuple[Path, str]: + path = tmp_path / f"source-{version}-{suffix}.exe" + path.write_bytes(f"artifact {version} {suffix}".encode()) + return path, hashlib.sha256(path.read_bytes()).hexdigest() + + +def windows_paths(tmp_path: Path, name: str = "install") -> _installer.InstallPaths: + root = tmp_path / name + return _installer.InstallPaths(root, root / "bin", "Windows") + + +def adapter(tmp_path: Path, initial: str = "") -> _installer.WindowsPathAdapter: + representation = tmp_path / "user-path.txt" + representation.write_text(initial, encoding="utf-8") + return _installer.WindowsPathAdapter(representation) + + +def install( + source: Path, + checksum: str, + version: str, + paths: _installer.InstallPaths, + runner: FakeRunner, + path_adapter: _installer.PathAdapter, + **kwargs: object, +) -> dict[str, object]: + return _installer.install_artifact( + source, + version, + checksum, + paths=paths, + runner=runner, + path_adapter=path_adapter, + no_init=True, + output=lambda _line: None, + **kwargs, # type: ignore[arg-type] + ) + + +@pytest.mark.parametrize("value", ["1.2", "v1.2.3", "1.2.3 ", "01.2.3", "1.2.3-beta"]) +def test_version_is_strict(value: str) -> None: + with pytest.raises(_installer.InstallerError, match="expected X.Y.Z"): + _installer.parse_version(value) + + +def test_numeric_versions_do_not_compare_lexically() -> None: + assert _installer.parse_version("0.9.9") < _installer.parse_version("0.10.0") + + +@pytest.mark.parametrize("payload", [b"1.2.3", b"1.2.3\n"]) +def test_pointer_accepts_one_bare_version(payload: bytes) -> None: + assert _installer.parse_pointer(payload) == "1.2.3" + + +@pytest.mark.parametrize("payload", [b" 1.2.3\n", b"1.2.3\r\n", b"1.2.3\nextra\n"]) +def test_pointer_rejects_non_bare_content(payload: bytes) -> None: + with pytest.raises(_installer.InstallerError): + _installer.parse_pointer(payload) + + +def test_url_transport_distinguishes_missing_pointer_from_unavailable_fetch() -> None: + missing = _installer.urllib.error.HTTPError( + _installer.STABLE_POINTER_URL, 404, "missing", {}, None + ) + with ( + patch.object(_installer.urllib.request, "urlopen", side_effect=missing), + pytest.raises(_installer.InstallerError, match="has been published"), + ): + _installer.UrlTransport().read(_installer.STABLE_POINTER_URL) + + unavailable = _installer.urllib.error.HTTPError( + _installer.STABLE_POINTER_URL, 503, "unavailable", {}, None + ) + with ( + patch.object(_installer.urllib.request, "urlopen", side_effect=unavailable), + pytest.raises(_installer.InstallerError, match="download unavailable"), + ): + _installer.UrlTransport().read(_installer.STABLE_POINTER_URL) + + +def test_manifest_schema_and_target_are_strict() -> None: + sha = "a" * 64 + valid = json.dumps( + { + "version": "1.2.3", + "artifacts": { + "windows-x86_64": {"name": "proxy.exe", "sha256": sha, "size": 3} + }, + } + ).encode() + assert _installer.parse_manifest(valid, "1.2.3")["windows-x86_64"]["sha256"] == sha + invalid = json.dumps( + { + "version": "1.2.3", + "artifacts": { + "windows-arm64": {"name": "proxy.exe", "sha256": sha, "size": 3} + }, + } + ).encode() + with pytest.raises(_installer.InstallerError, match="unsupported release target"): + _installer.parse_manifest(invalid, "1.2.3") + + +def test_custom_root_must_be_absolute_and_relocates_bin(tmp_path: Path) -> None: + with pytest.raises(_installer.InstallerError, match="absolute"): + _installer.InstallPaths.resolve(Path("relative"), system="Windows") + root = tmp_path / "root with spaces" + paths = _installer.InstallPaths.resolve(root, system="Windows") + assert paths.root == root + assert paths.command_dir == root / "bin" + + +def test_default_roots_match_each_ruled_platform(tmp_path: Path) -> None: + windows = _installer.InstallPaths.resolve( + system="Windows", environ={"LOCALAPPDATA": str(tmp_path / "local")} + ) + assert windows.root == tmp_path / "local" / "Forge" + assert windows.command_dir == windows.root / "bin" + + linux = _installer.InstallPaths.resolve( + system="Linux", environ={"XDG_DATA_HOME": str(tmp_path / "xdg")}, home=tmp_path + ) + assert linux.root == tmp_path / "xdg" / "forge" + assert linux.command_dir == tmp_path / ".local" / "bin" + + macos = _installer.InstallPaths.resolve(system="Darwin", environ={}, home=tmp_path) + assert macos.root == tmp_path / "Library" / "Application Support" / "Forge" + assert macos.command_dir == tmp_path / ".local" / "bin" + + +def test_fresh_install_writes_owned_layout_and_windows_argv_shim( + tmp_path: Path, +) -> None: + source, sha = artifact(tmp_path, "1.0.0") + paths = windows_paths(tmp_path) + runner = FakeRunner() + path_file = adapter(tmp_path) + state = install(source, sha, "1.0.0", paths, runner, path_file) + + slot = paths.slot("1.0.0") + assert slot.read_bytes() == source.read_bytes() + assert paths.command.read_text(encoding="utf-8") == (f'@echo off\n"{slot}" %*\n') + assert state["current_version"] == "1.0.0" + assert "selection" not in json.dumps(state) + assert paths.uninstaller.is_file() and paths.marker.is_file() + assert str(paths.command_dir) in (tmp_path / "user-path.txt").read_text() + + +def test_idempotent_install_rechecks_slot_without_rewriting(tmp_path: Path) -> None: + source, sha = artifact(tmp_path, "1.0.0") + paths = windows_paths(tmp_path) + runner = FakeRunner() + path_file = adapter(tmp_path) + install(source, sha, "1.0.0", paths, runner, path_file) + before = paths.slot("1.0.0").stat().st_mtime_ns + install(source, sha, "1.0.0", paths, runner, path_file) + assert paths.slot("1.0.0").stat().st_mtime_ns == before + assert runner.calls[-2][0] == paths.slot("1.0.0") + + +def test_forward_updates_retain_current_and_one_previous_slot(tmp_path: Path) -> None: + paths = windows_paths(tmp_path) + runner = FakeRunner() + path_file = adapter(tmp_path) + for version in ("1.0.0", "1.1.0", "1.2.0"): + source, sha = artifact(tmp_path, version) + state = install(source, sha, version, paths, runner, path_file) + assert state["current_version"] == "1.2.0" + assert state["previous_versions"] == ["1.1.0"] + assert {item.name for item in paths.versions.iterdir()} == {"1.1.0", "1.2.0"} + + +def test_external_exact_install_can_recover_to_lower_version(tmp_path: Path) -> None: + paths = windows_paths(tmp_path) + runner = FakeRunner() + path_file = adapter(tmp_path) + high, high_sha = artifact(tmp_path, "2.0.0") + low, low_sha = artifact(tmp_path, "1.0.0") + install(high, high_sha, "2.0.0", paths, runner, path_file) + state = install(low, low_sha, "1.0.0", paths, runner, path_file) + assert state["current_version"] == "1.0.0" + assert state["previous_versions"] == ["2.0.0"] + + +def test_embedded_version_mismatch_never_publishes(tmp_path: Path) -> None: + source, sha = artifact(tmp_path, "1.0.0") + paths = windows_paths(tmp_path) + with pytest.raises(_installer.InstallerError, match="version"): + install(source, sha, "2.0.0", paths, FakeRunner(), adapter(tmp_path)) + assert not paths.command.exists() + assert not paths.state.exists() + assert not paths.slot("2.0.0").exists() + + +def test_checksum_failure_preserves_existing_command_state_and_bytes( + tmp_path: Path, +) -> None: + source, sha = artifact(tmp_path, "1.0.0") + paths = windows_paths(tmp_path) + runner = FakeRunner() + path_file = adapter(tmp_path) + install(source, sha, "1.0.0", paths, runner, path_file) + before = ( + paths.command.read_bytes(), + paths.state.read_bytes(), + paths.slot("1.0.0").read_bytes(), + ) + bad, _ = artifact(tmp_path, "1.1.0") + with pytest.raises(_installer.InstallerError, match="checksum mismatch"): + install(bad, "0" * 64, "1.1.0", paths, runner, path_file) + assert before == ( + paths.command.read_bytes(), + paths.state.read_bytes(), + paths.slot("1.0.0").read_bytes(), + ) + + +def test_fresh_staged_check_failure_has_no_promoted_state(tmp_path: Path) -> None: + source, sha = artifact(tmp_path, "1.0.0") + runner = FakeRunner() + runner.fail_payloads.add(source.read_bytes()) + paths = windows_paths(tmp_path) + with pytest.raises(_installer.InstallerError, match="health failed"): + install(source, sha, "1.0.0", paths, runner, adapter(tmp_path)) + assert not paths.command.exists() and not paths.state.exists() + assert not paths.slot("1.0.0").exists() + + +def test_same_version_failed_staging_preserves_active_bytes(tmp_path: Path) -> None: + active, active_sha = artifact(tmp_path, "1.0.0", "active") + replacement, replacement_sha = artifact(tmp_path, "1.0.0", "replacement") + paths = windows_paths(tmp_path) + runner = FakeRunner() + path_file = adapter(tmp_path) + install(active, active_sha, "1.0.0", paths, runner, path_file) + runner.fail_payloads.add(replacement.read_bytes()) + before = paths.slot("1.0.0").read_bytes() + with pytest.raises(_installer.InstallerError, match="health failed"): + install(replacement, replacement_sha, "1.0.0", paths, runner, path_file) + assert paths.slot("1.0.0").read_bytes() == before + + +def test_profile_incompatibility_reports_and_promotes_without_rewriting_source( + tmp_path: Path, +) -> None: + source, sha = artifact(tmp_path, "1.0.0") + before = source.read_bytes() + output: list[str] = [] + runner = FakeRunner() + runner.profile_incompatible = True + paths = windows_paths(tmp_path) + _installer.install_artifact( + source, + "1.0.0", + sha, + paths=paths, + runner=runner, + path_adapter=adapter(tmp_path), + no_init=True, + output=output.append, + ) + assert paths.slot("1.0.0").read_bytes() == before == source.read_bytes() + assert any("Incompatible managed profile" in line for line in output) + assert any("do not block" in line for line in output) + + +def test_promotion_failure_rolls_back_command_state_and_owned_path( + tmp_path: Path, +) -> None: + source, sha = artifact(tmp_path, "1.0.0") + paths = windows_paths(tmp_path) + runner = FakeRunner() + path_file = adapter(tmp_path) + install(source, sha, "1.0.0", paths, runner, path_file) + before = ( + paths.command.read_bytes(), + paths.state.read_bytes(), + (tmp_path / "user-path.txt").read_bytes(), + ) + newer, newer_sha = artifact(tmp_path, "1.1.0") + with ( + patch.object( + _installer, "_publish_command", side_effect=OSError("publish failed") + ), + pytest.raises(OSError, match="publish failed"), + ): + install(newer, newer_sha, "1.1.0", paths, runner, path_file) + assert before == ( + paths.command.read_bytes(), + paths.state.read_bytes(), + (tmp_path / "user-path.txt").read_bytes(), + ) + + +def release_fixture( + version: str, source: Path, sha: str +) -> tuple[str, str, bytes, bytes]: + manifest_url = f"manifest/{version}" + asset_url = f"asset/{version}/{source.name}" + manifest = json.dumps( + { + "version": version, + "artifacts": { + "windows-x86_64": { + "name": source.name, + "sha256": sha, + "size": source.stat().st_size, + } + }, + } + ).encode() + return manifest_url, asset_url, manifest, source.read_bytes() + + +def test_production_release_urls_use_raw_pointer_and_exact_forge_tag( + tmp_path: Path, +) -> None: + assert _installer.STABLE_POINTER_URL == ( + "https://raw.githubusercontent.com/antoinezambelli/forge/" + "main/installer/proxy-stable.txt" + ) + assert _installer.RELEASE_MANIFEST_URL == ( + "https://github.com/antoinezambelli/forge/releases/download/" + "v{version}/proxy-{version}.json" + ) + assert _installer.RELEASE_ASSET_URL == ( + "https://github.com/antoinezambelli/forge/releases/download/v{version}/{name}" + ) + + paths = windows_paths(tmp_path) + runner = FakeRunner() + path_file = adapter(tmp_path) + one, one_sha = artifact(tmp_path, "1.0.0") + install(one, one_sha, "1.0.0", paths, runner, path_file) + two, two_sha = artifact(tmp_path, "1.1.0") + _, _, manifest, payload = release_fixture("1.1.0", two, two_sha) + manifest_url = _installer.RELEASE_MANIFEST_URL.format(version="1.1.0") + asset_url = _installer.RELEASE_ASSET_URL.format(version="1.1.0", name=two.name) + transport = FixtureTransport( + { + _installer.STABLE_POINTER_URL: b"1.1.0\n", + manifest_url: manifest, + asset_url: payload, + } + ) + + _installer.update( + paths=paths, + transport=transport, + runner=runner, + path_adapter=path_file, + output=lambda _line: None, + target="windows-x86_64", + ) + + assert transport.reads == [ + _installer.STABLE_POINTER_URL, + manifest_url, + asset_url, + ] + + +def test_update_forward_same_newer_than_stable_and_lower_exact(tmp_path: Path) -> None: + paths = windows_paths(tmp_path) + runner = FakeRunner() + path_file = adapter(tmp_path) + one, one_sha = artifact(tmp_path, "1.0.0") + install(one, one_sha, "1.0.0", paths, runner, path_file) + two, two_sha = artifact(tmp_path, "1.1.0") + manifest_url, asset_url, manifest, payload = release_fixture("1.1.0", two, two_sha) + transport = FixtureTransport( + {"pointer": b"1.1.0\n", manifest_url: manifest, asset_url: payload} + ) + state = _installer.update( + paths=paths, + transport=transport, + runner=runner, + path_adapter=path_file, + output=lambda _line: None, + target="windows-x86_64", + pointer_url="pointer", + manifest_url="manifest/{version}", + asset_url="asset/{version}/{name}", + ) + assert state is not None and state["current_version"] == "1.1.0" + assert ( + _installer.update( + "1.1.0", paths=paths, transport=transport, output=lambda _line: None + ) + is None + ) + old_stable = FixtureTransport({"pointer": b"1.0.0\n"}) + assert ( + _installer.update( + paths=paths, + transport=old_stable, + output=lambda _line: None, + pointer_url="pointer", + ) + is None + ) + with pytest.raises(_installer.InstallerError, match="cannot downgrade"): + _installer.update("1.0.0", paths=paths, transport=transport) + + +def test_unavailable_update_preserves_current_install(tmp_path: Path) -> None: + source, sha = artifact(tmp_path, "1.0.0") + paths = windows_paths(tmp_path) + runner = FakeRunner() + path_file = adapter(tmp_path) + install(source, sha, "1.0.0", paths, runner, path_file) + before = ( + paths.command.read_bytes(), + paths.state.read_bytes(), + paths.slot("1.0.0").read_bytes(), + ) + transport = FixtureTransport( + {"pointer": _installer.InstallerError("download unavailable")} + ) + with pytest.raises(_installer.InstallerError, match="download unavailable"): + _installer.update(paths=paths, transport=transport, pointer_url="pointer") + assert before == ( + paths.command.read_bytes(), + paths.state.read_bytes(), + paths.slot("1.0.0").read_bytes(), + ) + + +def test_windows_path_preexisting_remains_unowned_across_reinstall_and_uninstall( + tmp_path: Path, +) -> None: + paths = windows_paths(tmp_path) + path_file = adapter(tmp_path, str(paths.command_dir)) + runner = FakeRunner() + source, sha = artifact(tmp_path, "1.0.0") + state = install(source, sha, "1.0.0", paths, runner, path_file) + assert state["path_integration"]["added"] is False # type: ignore[index] + install(source, sha, "1.0.0", paths, runner, path_file) + _installer.uninstall_owned(paths, path_adapter=path_file) + assert (tmp_path / "user-path.txt").read_text() == str(paths.command_dir) + + +def test_windows_registry_path_write_broadcasts_environment_change() -> None: + registry_key = MagicMock() + winreg = MagicMock() + winreg.HKEY_CURRENT_USER = object() + winreg.REG_EXPAND_SZ = object() + winreg.CreateKey.return_value = registry_key + with ( + patch.dict(sys.modules, {"winreg": winreg}), + patch.object(_installer, "_broadcast_windows_environment_change") as broadcast, + ): + _installer.WindowsPathAdapter()._write("C:\\Forge") + + winreg.SetValueEx.assert_called_once() + broadcast.assert_called_once_with() + + +def test_windows_environment_broadcast_uses_setting_change_message() -> None: + send = MagicMock() + windll = MagicMock() + windll.user32.SendMessageTimeoutW = send + with patch.object(_installer.ctypes, "windll", windll, create=True): + _installer._broadcast_windows_environment_change() + + args = send.call_args.args + assert args[:6] == (0xFFFF, 0x001A, 0, "Environment", 0x0002, 5000) + assert args[6] is not None + assert send.argtypes[3] is _installer.wintypes.LPCWSTR + assert send.restype is _installer.wintypes.LPARAM + + +def test_generated_windows_uninstaller_broadcasts_only_for_real_user_path( + tmp_path: Path, +) -> None: + paths = windows_paths(tmp_path) + real_record = { + "kind": "windows", + "command_dir": str(paths.command_dir), + "added": True, + "representation": None, + } + real_script = _installer._render_windows_uninstaller( + paths, "owned", real_record + ).decode("utf-8") + assert "SetEnvironmentVariable" in real_script + assert "SendMessageTimeout" in real_script + assert "'Environment'" in real_script + + fixture_record = { + **real_record, + "representation": str(tmp_path / "path.txt"), + } + fixture_script = _installer._render_windows_uninstaller( + paths, "owned", fixture_record + ).decode("utf-8") + assert "SetEnvironmentVariable" not in fixture_script + assert "SendMessageTimeout" not in fixture_script + + +def test_owned_uninstall_removes_only_owned_files_and_preserves_unowned( + tmp_path: Path, +) -> None: + paths = windows_paths(tmp_path, "root with spaces") + path_file = adapter(tmp_path, "C:\\Existing") + source, sha = artifact(tmp_path, "1.0.0") + install(source, sha, "1.0.0", paths, FakeRunner(), path_file) + unowned = paths.root / "keep.txt" + unowned.write_text("keep", encoding="utf-8") + external = tmp_path / "profiles" / "default.toml" + external.parent.mkdir() + external.write_text("profile", encoding="utf-8") + _installer.uninstall_owned(paths, path_adapter=path_file) + assert unowned.read_text() == "keep" + assert external.read_text() == "profile" + assert (tmp_path / "user-path.txt").read_text() == "C:\\Existing" + assert not paths.command.exists() and not paths.state.exists() + + +def test_posix_symlink_and_marked_startup_are_owned_and_removed(tmp_path: Path) -> None: + root = tmp_path / "app" + paths = _installer.InstallPaths(root, tmp_path / "bin with spaces", "Linux") + startup_home = tmp_path / "home" + startup_home.mkdir() + startup = startup_home / ".bashrc" + startup.write_text("# existing\n", encoding="utf-8") + output: list[str] = [] + path_adapter = _installer.PosixPathAdapter( + shell="/bin/bash", home=startup_home, output=output.append + ) + record = path_adapter.ensure(paths.command_dir, None) + assert path_adapter.ensure(paths.command_dir, record) == record + assert startup.read_text().count(_installer._POSIX_START) == 1 + assert output == [ + f"Updated PATH startup file: {startup.resolve()}", + "Undo with 'forge-proxy uninstall' or remove the block from " + f"'{_installer._POSIX_START}' through '{_installer._POSIX_END}' " + f"in {startup.resolve()}", + ] + path_adapter.remove(record) + assert startup.read_text() == "# existing\n" + + slot = paths.slot("1.0.0") + with ( + patch.object(_installer.os, "symlink") as make_symlink, + patch.object(_installer.os, "replace") as replace, + ): + _installer._publish_command(paths, slot) + linked_target = make_symlink.call_args.args[0] + assert linked_target == os.path.relpath(slot, paths.command_dir) + assert not Path(linked_target).is_absolute() + assert replace.call_args.args[1] == paths.command + + +def test_preexisting_posix_path_block_is_not_claimed_or_reported( + tmp_path: Path, +) -> None: + startup = tmp_path / ".zshrc" + command_dir = tmp_path / "bin" + startup.write_text(_installer.PosixPathAdapter.block(command_dir), encoding="utf-8") + output: list[str] = [] + adapter = _installer.PosixPathAdapter( + shell="/bin/zsh", home=tmp_path, output=output.append + ) + + record = adapter.ensure(command_dir, None) + + assert record["added"] is False + assert output == [] + + +def test_unknown_posix_shell_prints_exact_export_without_editing( + tmp_path: Path, +) -> None: + output: list[str] = [] + adapter = _installer.PosixPathAdapter( + shell="/bin/fish", home=tmp_path, output=output.append + ) + command_dir = tmp_path / "bin with spaces" + record = adapter.ensure(command_dir, None) + assert record["kind"] == "guidance" + assert output == [f'export PATH={shlex.quote(str(command_dir))}:"$PATH"'] + assert list(tmp_path.iterdir()) == [] + + +@pytest.mark.parametrize("no_init", [False, True]) +def test_install_never_onboards_and_prints_post_install_commands( + tmp_path: Path, + no_init: bool, +) -> None: + paths = windows_paths(tmp_path) + source, sha = artifact(tmp_path, "1.0.0") + runner = FakeRunner() + path_file = adapter(tmp_path) + output: list[str] = [] + _installer.install_artifact( + source, + "1.0.0", + sha, + paths=paths, + runner=runner, + path_adapter=path_file, + no_init=no_init, + output=output.append, + ) + assert not [args for _, args in runner.calls if args[0] in {"init", "check"}] + assert output[-7:] == [ + "Installed forge-proxy 1.0.0 at " + str(paths.slot("1.0.0")), + "Next, configure and verify the installation:", + " forge-proxy init", + " forge-proxy check", + "For noninteractive unmanaged setup:", + " forge-proxy init --non-interactive --backend-url URL", + " forge-proxy check", + ] diff --git a/tests/unit/test_proxy_lifecycle_smoke.py b/tests/unit/test_proxy_lifecycle_smoke.py new file mode 100644 index 0000000..b31e61d --- /dev/null +++ b/tests/unit/test_proxy_lifecycle_smoke.py @@ -0,0 +1,150 @@ +"""Selected-artifact lifecycle smoke orchestration tests.""" + +from __future__ import annotations + +import hashlib +import json +import subprocess +from pathlib import Path + +import pytest + +from scripts.standalone import lifecycle_smoke +from scripts.standalone.inputs import SUPPORTED_TARGETS +from scripts.standalone.release import artifact_name + + +def test_windows_shim_command_uses_cmd(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(lifecycle_smoke.os, "name", "nt") + assert lifecycle_smoke.command(Path("forge-proxy.cmd"), ["check"]) == [ + "cmd", + "/d", + "/c", + "forge-proxy.cmd", + "check", + ] + + +def test_expected_failure_is_a_successful_gate_observation(tmp_path: Path) -> None: + def runner( + arguments: list[str], **_kwargs: object + ) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(arguments, 2, "", "download unavailable") + + record = lifecycle_smoke.run_process( + ["forge-proxy", "update"], + cwd=tmp_path, + env={}, + runner=runner, + expected_error="download unavailable", + ) + assert record["status"] == 2 + assert record["expected_failure"] == "download unavailable" + + +def test_unexpected_success_fails_a_failure_gate(tmp_path: Path) -> None: + def runner( + arguments: list[str], **_kwargs: object + ) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(arguments, 0, "", "") + + with pytest.raises(RuntimeError, match="unexpectedly succeeded"): + lifecycle_smoke.run_process( + ["forge-proxy", "update"], + cwd=tmp_path, + env={}, + runner=runner, + expected_error="download unavailable", + ) + + +def test_missing_stable_pointer_means_no_published_baseline(tmp_path: Path) -> None: + def reader(url: str, *, missing_ok: bool = False) -> bytes | None: + assert url == "https://fixture.invalid/pointer" + assert missing_ok is True + return None + + assert ( + lifecycle_smoke.resolve_published_baseline( + "windows-x86_64", + tmp_path, + pointer_url="https://fixture.invalid/pointer", + reader=reader, + ) + is None + ) + + +def test_published_baseline_is_manifest_verified(tmp_path: Path) -> None: + target = "windows-x86_64" + payload = b"published baseline bytes" + digest = hashlib.sha256(payload).hexdigest() + version = "1.2.3" + artifacts = { + item: { + "name": artifact_name(item), + "sha256": digest if item == target else "a" * 64, + "size": len(payload) if item == target else 1, + } + for item in SUPPORTED_TARGETS + } + routes = { + "https://fixture.invalid/pointer": f"{version}\n".encode(), + f"https://fixture.invalid/releases/v{version}/proxy-{version}.json": ( + json.dumps({"version": version, "artifacts": artifacts}).encode() + ), + f"https://fixture.invalid/releases/v{version}/{artifact_name(target)}": payload, + } + + def reader(url: str, *, missing_ok: bool = False) -> bytes | None: + del missing_ok + return routes[url] + + baseline = lifecycle_smoke.resolve_published_baseline( + target, + tmp_path, + pointer_url="https://fixture.invalid/pointer", + release_base_url="https://fixture.invalid/releases", + reader=reader, + ) + assert baseline is not None + assert baseline.version == version + assert baseline.sha256 == digest + assert baseline.path.read_bytes() == payload + + +def test_local_release_routes_can_declare_a_bad_checksum(tmp_path: Path) -> None: + path = tmp_path / "forge-proxy-windows-x86_64.exe" + path.write_bytes(b"candidate") + artifact = lifecycle_smoke.ReleaseArtifact( + path, + "1.2.4", + lifecycle_smoke.artifact_sha256(path), + "windows-x86_64", + ) + routes: dict[str, tuple[int, bytes]] = {} + lifecycle_smoke.set_release_routes(routes, artifact, sha256="f" * 64) + manifest = json.loads(routes["/v1.2.4/proxy-1.2.4.json"][1]) + assert manifest["artifacts"][artifact.target]["sha256"] == "f" * 64 + assert routes[f"/v1.2.4/{artifact.name}"][1] == b"candidate" + + +def test_inaugural_version_still_has_a_higher_failure_target() -> None: + assert lifecycle_smoke.next_patch_version("0.9.0") == "0.9.1" + with pytest.raises(ValueError, match="invalid release version"): + lifecycle_smoke.next_patch_version("0.09.0") + + +def test_bootstrap_arguments_are_exact_version_and_noninteractive( + tmp_path: Path, +) -> None: + path = tmp_path / "forge-proxy" + path.write_bytes(b"candidate") + artifact = lifecycle_smoke.ReleaseArtifact( + path, "1.2.3", "a" * 64, "linux-x86_64-gnu" + ) + arguments = lifecycle_smoke.bootstrap_arguments(artifact, tmp_path / "root") + assert arguments[:2] == ["sh", str(lifecycle_smoke.ROOT / "install.sh")] + assert arguments[arguments.index("--version") + 1] == "1.2.3" + assert "--no-init" in arguments + assert arguments[arguments.index("--install-root") + 1] == str(tmp_path / "root") diff --git a/tests/unit/test_proxy_profiles.py b/tests/unit/test_proxy_profiles.py new file mode 100644 index 0000000..63cb576 --- /dev/null +++ b/tests/unit/test_proxy_profiles.py @@ -0,0 +1,493 @@ +"""Focused Proxy profile, init, source-selection, and check coverage.""" + +from __future__ import annotations + +import os +from pathlib import Path +from unittest.mock import AsyncMock, patch + +import pytest + +from forge.proxy import __main__ as proxy_cli +from forge.proxy import _profiles as profiles +from forge.proxy._config import _normalize_proxy_config +from forge.proxy._options import supplied_proxy_options + + +def _document(**values: object) -> dict[str, object]: + return {"schema_version": 1, **values} + + +def _write(path: Path, **values: object) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(profiles._profile_bytes(values)) + + +@pytest.mark.parametrize( + ("system", "environment", "home", "expected"), + [ + ( + "Windows", + {"APPDATA": "C:/Users/a/AppData/Roaming"}, + Path("C:/Users/a"), + Path("C:/Users/a/AppData/Roaming/Forge/profiles"), + ), + ( + "Linux", + {"XDG_CONFIG_HOME": "/xdg"}, + Path("/home/a"), + Path("/xdg/forge/profiles"), + ), + ("Linux", {}, Path("/home/a"), Path("/home/a/.config/forge/profiles")), + ( + "Darwin", + {}, + Path("/Users/a"), + Path("/Users/a/Library/Application Support/Forge/profiles"), + ), + ], +) +def test_managed_profile_roots( + system: str, + environment: dict[str, str], + home: Path, + expected: Path, +) -> None: + assert ( + profiles._managed_profile_root(system=system, environ=environment, home=home) + == expected + ) + + +@pytest.mark.parametrize("name", ["", ".", "..", "a/b", "a\\b"]) +def test_only_ruled_profile_names_are_rejected(name: str) -> None: + with pytest.raises(ValueError, match="profile name"): + profiles._validate_profile_name(name) + + +@pytest.mark.parametrize("name", ["default", "team profile", "...", "x.toml"]) +def test_other_profile_names_are_accepted(name: str) -> None: + profiles._validate_profile_name(name) + + +@pytest.mark.parametrize( + "document", + [ + {}, + {"schema_version": True}, + {"schema_version": 1.0}, + {"schema_version": 2}, + ], +) +def test_schema_version_is_exact_nonboolean_integer( + document: dict[str, object], +) -> None: + with pytest.raises(ValueError, match="schema_version"): + profiles._parse_profile_document(document) + + +@pytest.mark.parametrize( + "field", + ["unknown", "profile", "config", "backend_api_key", "api_key"], +) +def test_unknown_selectors_and_credentials_are_rejected(field: str) -> None: + with pytest.raises(ValueError, match="unknown profile fields"): + profiles._parse_profile_document( + _document(backend_url="http://host", **{field: "value"}) + ) + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("port", True), + ("backend_port", 1.5), + ("budget_tokens", False), + ("max_retries", "3"), + ("serialize", 0), + ("backend_timeout", True), + ("extra_flags", "--flag"), + ("extra_flags", ["ok", 2]), + ("verbose", "yes"), + ], +) +def test_profile_types_are_exact(field: str, value: object) -> None: + with pytest.raises(ValueError, match="wrong TOML type"): + profiles._parse_profile_document( + _document(backend_url="http://host", **{field: value}) + ) + + +@pytest.mark.parametrize("timeout", [4, 4.5]) +def test_timeout_accepts_integer_or_float_and_normalizes_to_float( + timeout: object, +) -> None: + launch = profiles._parse_profile_document( + _document(backend_url="http://host", backend_timeout=timeout) + ) + assert launch.raw.backend_timeout == float(timeout) # type: ignore[arg-type] + + +def test_profile_and_cli_share_normalization_with_inversions_and_tail() -> None: + parser = proxy_cli._build_parser() + args = parser.parse_args( + [ + "--backend", + "vllm", + "--model-path", + "literal/$MODEL", + "--host", + "0.0.0.0", + "--port", + "9010", + "--no-serialize", + "--no-rescue", + "--verbose", + "--extra-flags", + "--dtype", + "float16", + ] + ) + cli = _normalize_proxy_config(proxy_cli._raw_from_args(args)) + profile = profiles._parse_profile_document( + _document( + backend="vllm", + model_path="literal/$MODEL", + host="0.0.0.0", + port=9010, + serialize=False, + no_rescue=True, + verbose=True, + extra_flags=["--dtype", "float16"], + ) + ) + assert profile.normalized == cli + assert profile.verbose is True + assert profile.raw.model_path == "literal/$MODEL" + + +def test_unmanaged_profile_inherits_environment_credential( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + path = tmp_path / "external.toml" + _write(path, backend_url="http://host") + monkeypatch.setenv("FORGE_BACKEND_API_KEY", "environment-secret") + launch = profiles._load_profile(path) + assert launch.raw.backend_api_key == "environment-secret" + assert b"secret" not in path.read_bytes() + + +@pytest.mark.parametrize( + ("selector", "value"), + [("--profile", "named"), ("--config", "external.toml")], +) +def test_profile_and_config_selectors_reject_mixed_configuration( + selector: str, + value: str, + capsys: pytest.CaptureFixture[str], +) -> None: + parser = proxy_cli._build_parser() + argv = [selector, value, "--port", "8081"] + args = parser.parse_args(argv) + with pytest.raises(SystemExit) as exc: + proxy_cli._selected_launch(parser, args, argv) + assert exc.value.code == 2 + error = capsys.readouterr().err + assert "forge-proxy --profile NAME" in error + assert "forge-proxy --backend-url URL" in error + + +def test_tail_selector_tokens_remain_backend_argv() -> None: + argv = [ + "--backend", + "vllm", + "--model-path", + "/m", + "--extra-flags", + "--profile", + "tail", + "--config=tail.toml", + "--port", + "8081", + ] + parser = proxy_cli._build_parser() + args = parser.parse_args(argv) + assert args.profile is None + assert args.config is None + assert supplied_proxy_options(argv) == {"backend", "model_path", "extra_flags"} + assert args.extra_flags == [ + "--profile", + "tail", + "--config=tail.toml", + "--port", + "8081", + ] + + +def test_default_discovery_and_external_config_use_profile_values( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + root = tmp_path / "profiles" + _write(root / "default.toml", backend_url="http://default", verbose=True) + external = tmp_path / "external.toml" + _write(external, backend_url="http://external") + monkeypatch.setattr(profiles, "_managed_profile_root", lambda: root) + + parser = proxy_cli._build_parser() + default = parser.parse_args([]) + raw, verbose, cli_only = proxy_cli._selected_launch(parser, default, []) + assert raw.backend_url == "http://default" + assert verbose is True + assert cli_only is False + + argv = ["--config", str(external)] + selected = parser.parse_args(argv) + raw, _, cli_only = proxy_cli._selected_launch(parser, selected, argv) + assert raw.backend_url == "http://external" + assert cli_only is False + + +def test_missing_default_has_init_and_flag_guidance( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setattr(profiles, "_managed_profile_root", lambda: tmp_path) + parser = proxy_cli._build_parser() + args = parser.parse_args([]) + with pytest.raises(SystemExit) as exc: + proxy_cli._selected_launch(parser, args, []) + assert exc.value.code == 2 + error = capsys.readouterr().err + assert "forge-proxy init" in error + assert "--backend-url URL" in error + + +def test_noninteractive_init_is_sparse_atomic_and_identical_is_unchanged( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + root = tmp_path / "profiles" + monkeypatch.setattr(profiles, "_managed_profile_root", lambda: root) + proxy_cli.main( + [ + "init", + "--non-interactive", + "--backend-url", + "http://host", + "--host", + "127.0.0.1", + "--no-serialize", + ] + ) + path = root / "default.toml" + content = path.read_bytes() + assert b"schema_version = 1" in content + assert b'backend_url = "http://host"' in content + assert b'host = "127.0.0.1"' in content + assert b"serialize = false" in content + assert b"port" not in content + timestamp = path.stat().st_mtime_ns + with patch.object(profiles.os, "replace") as replace: + proxy_cli.main( + [ + "init", + "--non-interactive", + "--backend-url", + "http://host", + "--host", + "127.0.0.1", + "--no-serialize", + ] + ) + replace.assert_not_called() + assert path.read_bytes() == content + assert path.stat().st_mtime_ns == timestamp + output = capsys.readouterr().out + assert "Unchanged profile" in output + assert output.count("Launch with: forge-proxy --profile default") == 2 + + +def test_init_requires_force_for_different_content( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + root = tmp_path / "profiles" + monkeypatch.setattr(profiles, "_managed_profile_root", lambda: root) + proxy_cli.main(["init", "--non-interactive", "--backend-url", "http://one"]) + with pytest.raises(SystemExit) as exc: + proxy_cli.main(["init", "--non-interactive", "--backend-url", "http://two"]) + assert exc.value.code == 2 + assert b"http://one" in (root / "default.toml").read_bytes() + proxy_cli.main( + ["init", "--non-interactive", "--force", "--backend-url", "http://two"] + ) + assert b"http://two" in (root / "default.toml").read_bytes() + + +def test_init_prints_usable_launch_command_for_spaced_profile_name( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + root = tmp_path / "profiles" + monkeypatch.setattr(profiles, "_managed_profile_root", lambda: root) + proxy_cli.main( + [ + "init", + "--profile", + "team profile", + "--non-interactive", + "--backend-url", + "http://host", + ] + ) + + expected = proxy_cli._profile_launch_command("team profile") + assert expected != "forge-proxy --profile team profile" + assert f"Launch with: {expected}" in capsys.readouterr().out + + +def test_interactive_enter_omits_defaults_and_typed_defaults_persist( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + root = tmp_path / "profiles" + monkeypatch.setattr(profiles, "_managed_profile_root", lambda: root) + answers = iter(["managed", "ollama", "tag", "", ""]) + monkeypatch.setattr("builtins.input", lambda _prompt: next(answers)) + proxy_cli.main(["init", "--profile", "omitted"]) + omitted = (root / "omitted.toml").read_text(encoding="utf-8") + assert "host" not in omitted + assert "port" not in omitted + + answers = iter(["managed", "ollama", "tag", "127.0.0.1", "8081"]) + monkeypatch.setattr("builtins.input", lambda _prompt: next(answers)) + proxy_cli.main(["init", "--profile", "explicit"]) + explicit = (root / "explicit.toml").read_text(encoding="utf-8") + assert 'host = "127.0.0.1"' in explicit + assert "port = 8081" in explicit + + +@pytest.mark.parametrize("backend", ["anthropic", "openai"]) +def test_interactive_unmanaged_selector_prompts_for_missing_backend_url( + backend: str, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + root = tmp_path / "profiles" + monkeypatch.setattr(profiles, "_managed_profile_root", lambda: root) + answers = iter(["https://backend.example", "", ""]) + prompts: list[str] = [] + + def answer(prompt: str) -> str: + prompts.append(prompt) + return next(answers) + + monkeypatch.setattr("builtins.input", answer) + proxy_cli.main(["init", "--profile", backend, "--backend", backend]) + + content = (root / f"{backend}.toml").read_text(encoding="utf-8") + assert f'backend = "{backend}"' in content + assert 'backend_url = "https://backend.example"' in content + assert prompts[0] == "Backend URL: " + assert not any("ownership" in prompt for prompt in prompts) + + +def test_interactive_values_preserve_supplied_strings_exactly( + monkeypatch: pytest.MonkeyPatch, +) -> None: + answers = iter( + [ + "unmanaged", + " https://backend.example/path ", + " openai ", + " 127.0.0.1 ", + "", + ] + ) + monkeypatch.setattr("builtins.input", lambda _prompt: next(answers)) + + values = proxy_cli._interactive_values({}) + + assert values["backend_url"] == " https://backend.example/path " + assert values["backend"] == " openai " + assert values["host"] == " 127.0.0.1 " + + +def test_init_validates_before_creating_directory( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + root = tmp_path / "profiles" + monkeypatch.setattr(profiles, "_managed_profile_root", lambda: root) + with pytest.raises(SystemExit): + proxy_cli.main(["init", "--non-interactive", "--backend", "ollama"]) + assert not root.exists() + + +def test_check_rejects_arguments(capsys: pytest.CaptureFixture[str]) -> None: + with pytest.raises(SystemExit) as exc: + proxy_cli.main(["check", "anything"]) + assert exc.value.code == 2 + assert "unrecognized arguments" in capsys.readouterr().err + + +@pytest.mark.parametrize("profile_count", [0, 1, 3]) +def test_check_runs_one_health_check_for_any_profile_count( + profile_count: int, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + root = tmp_path / "profiles" + for index in range(profile_count): + if index == 0: + _write(root / f"{index}.toml", backend_url="http://host") + else: + root.mkdir(parents=True, exist_ok=True) + (root / f"{index}.toml").write_text( + "schema_version = 1\nunknown = true\n", encoding="utf-8" + ) + monkeypatch.setattr(profiles, "_managed_profile_root", lambda: root) + monkeypatch.setattr(proxy_cli, "_managed_profile_root", lambda: root) + monkeypatch.setattr(proxy_cli, "_runtime_check", lambda: None) + health_check = AsyncMock() + monkeypatch.setattr(proxy_cli, "_local_health_check", health_check) + with patch.object( + proxy_cli, "ProxyServer", side_effect=AssertionError("backend startup") + ): + if profile_count == 1: + proxy_cli.main(["check"]) + else: + with pytest.raises(SystemExit) as exc: + proxy_cli.main(["check"]) + assert exc.value.code == 1 + health_check.assert_awaited_once_with() + output = capsys.readouterr().out + assert "OK local /forge/health" in output + if profile_count == 0: + assert "forge-proxy init" in output + + +def test_managed_profile_enumeration_is_deterministic(tmp_path: Path) -> None: + for name in ("z.toml", "a.toml", "middle.toml"): + _write(tmp_path / name, backend_url="http://host") + assert [path.name for path in profiles._managed_profiles(root=tmp_path)] == [ + "a.toml", + "middle.toml", + "z.toml", + ] + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX permission contract") +def test_new_posix_managed_paths_have_private_permissions(tmp_path: Path) -> None: + path = tmp_path / "profiles" / "default.toml" + profiles._write_managed_profile( + path, profiles._profile_bytes({"backend_url": "http://host"}), force=False + ) + assert path.parent.stat().st_mode & 0o777 == 0o700 + assert path.stat().st_mode & 0o777 == 0o600 diff --git a/tests/unit/test_proxy_release.py b/tests/unit/test_proxy_release.py new file mode 100644 index 0000000..f0ef0ea --- /dev/null +++ b/tests/unit/test_proxy_release.py @@ -0,0 +1,204 @@ +"""Release assembly, pointer, and journaled publication contracts.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from scripts.standalone import release +from scripts.standalone.inputs import SUPPORTED_TARGETS + + +VERSION = "0.9.1" + + +def selections(tmp_path: Path) -> list[Path]: + result = [] + for target in reversed(SUPPORTED_TARGETS): + source = tmp_path / f"source-{target}" + source.write_bytes(f"bytes-{target}".encode()) + output = tmp_path / f"selected-{target}" + release.write_selection(source, target, output, version=VERSION, evidence={"passed": True}) + result.append(output) + return result + + +def test_complete_assembly_is_canonical_and_revalidates(tmp_path: Path) -> None: + output = release.assemble(selections(tmp_path), tmp_path / "publication", VERSION) + manifest = release.validate_staging(output, VERSION) + assert list(manifest["artifacts"]) == sorted(SUPPORTED_TARGETS) + lines = (output / f"proxy-{VERSION}.sha256").read_text().splitlines() + assert [line.split(" ")[1] for line in lines] == [ + release.artifact_name(target) for target in SUPPORTED_TARGETS + ] + + +@pytest.mark.parametrize("failure", ["missing", "duplicate", "version", "name", "size", "digest"]) +def test_assembly_rejects_incomplete_or_changed_inputs(tmp_path: Path, failure: str) -> None: + inputs = selections(tmp_path) + if failure == "missing": + inputs.pop() + elif failure == "duplicate": + inputs.append(inputs[0]) + else: + record_path = inputs[0] / "selection.json" + record = json.loads(record_path.read_text()) + if failure == "version": + record["version"] = "0.9.2" + elif failure == "name": + record["name"] = "wrong" + elif failure == "size": + record["size"] += 1 + else: + record["sha256"] = "f" * 64 + record_path.write_text(json.dumps(record)) + output = tmp_path / "publication" + with pytest.raises(ValueError): + release.assemble(inputs, output, VERSION) + assert not output.exists() + + +def test_staging_detects_tamper_at_final_digest_boundary(tmp_path: Path) -> None: + output = release.assemble(selections(tmp_path), tmp_path / "publication", VERSION) + (output / release.artifact_name("linux-x86_64-gnu")).write_bytes(b"tampered") + with pytest.raises(ValueError, match="does not match manifest"): + release.validate_staging(output, VERSION) + + +def complete_manifest() -> bytes: + return json.dumps({ + "version": VERSION, + "artifacts": { + target: {"name": release.artifact_name(target), "sha256": "a" * 64, "size": 1} + for target in SUPPORTED_TARGETS + }, + }).encode() + + +def test_pointer_absence_is_success_without_lookup(tmp_path: Path) -> None: + calls = [] + assert release.validate_pointer(tmp_path / "missing", lambda version: calls.append(version)) is None + assert calls == [] + + +@pytest.mark.parametrize("content", [b"0.9.0", b"v0.9.0\n", b"0.9.0\nextra\n", b"01.2.3\n"]) +def test_pointer_requires_one_bare_canonical_version_line(tmp_path: Path, content: bytes) -> None: + pointer = tmp_path / "pointer" + pointer.write_bytes(content) + with pytest.raises(ValueError): + release.validate_pointer(pointer, lambda _version: complete_manifest()) + assert pointer.read_bytes() == content + + +def test_pointer_resolves_exact_complete_manifest_and_is_read_only(tmp_path: Path) -> None: + pointer = tmp_path / "pointer" + pointer.write_bytes(f"{VERSION}\n".encode("ascii")) + before = pointer.read_bytes() + calls = [] + assert release.validate_pointer(pointer, lambda version: calls.append(version) or complete_manifest()) == VERSION + assert calls == [VERSION] + assert pointer.read_bytes() == before + + +def test_pointer_propagates_live_404(tmp_path: Path) -> None: + pointer = tmp_path / "pointer" + pointer.write_bytes(f"{VERSION}\n".encode("ascii")) + def missing(_version: str) -> bytes: + raise release.urllib.error.HTTPError("url", 404, "missing", {}, None) + with pytest.raises(release.urllib.error.HTTPError): + release.validate_pointer(pointer, missing) + + +class FakeClient: + def __init__( + self, *, fail_upload: int | None = None, fail_delete: bool = False, + fail_assets_after: int | None = None, + ) -> None: + self.release_data = {"id": 7, "tag_name": f"v{VERSION}", "target_commitish": "main"} + self.current = [{"id": 1, "name": "forge-wheel.whl"}] + self.uploaded: list[str] = [] + self.deleted: list[int] = [] + self.fail_upload = fail_upload + self.fail_delete = fail_delete + self.fail_assets_after = fail_assets_after + self.asset_calls = 0 + + def release(self, _tag: str) -> dict[str, object]: + return self.release_data + + def assets(self, _release_id: int) -> list[dict[str, object]]: + if self.fail_assets_after is not None and self.asset_calls >= self.fail_assets_after: + raise RuntimeError("asset verification failed") + self.asset_calls += 1 + return list(self.current) + + def upload(self, _tag: str, path: Path) -> int: + position = len(self.uploaded) + if self.fail_upload == position: + raise RuntimeError("upload failed") + asset_id = 100 + position + self.uploaded.append(path.name) + self.current.append({"id": asset_id, "name": path.name}) + return asset_id + + def delete(self, asset_id: int) -> None: + if self.fail_delete: + raise RuntimeError("cleanup failed") + self.deleted.append(asset_id) + self.current = [asset for asset in self.current if asset["id"] != asset_id] + + +def staged(tmp_path: Path) -> Path: + return release.assemble(selections(tmp_path), tmp_path / "publication", VERSION) + + +def test_publication_ignores_branch_valued_target_commitish_and_uploads_manifest_last(tmp_path: Path) -> None: + client = FakeClient() + release.publish(client, f"v{VERSION}", "abc", "abc", staged(tmp_path)) + assert client.uploaded == release.proxy_asset_names(VERSION) + assert client.uploaded[-1] == f"proxy-{VERSION}.json" + assert client.current[0]["name"] == "forge-wheel.whl" + + +@pytest.mark.parametrize("position", range(5)) +def test_each_partial_upload_failure_removes_only_journaled_assets(tmp_path: Path, position: int) -> None: + client = FakeClient(fail_upload=position) + with pytest.raises(RuntimeError, match="upload failed"): + release.publish(client, f"v{VERSION}", "abc", "abc", staged(tmp_path)) + assert client.current == [{"id": 1, "name": "forge-wheel.whl"}] + assert len(client.deleted) == position + + +def test_cleanup_verification_failure_requires_manual_remediation(tmp_path: Path) -> None: + client = FakeClient(fail_upload=1, fail_delete=True) + with pytest.raises(RuntimeError, match="new version or perform manual remediation"): + release.publish(client, f"v{VERSION}", "abc", "abc", staged(tmp_path)) + + +def test_cleanup_asset_lookup_failure_requires_manual_remediation(tmp_path: Path) -> None: + client = FakeClient(fail_upload=0, fail_assets_after=1) + with pytest.raises(RuntimeError, match="new version or perform manual remediation"): + release.publish(client, f"v{VERSION}", "abc", "abc", staged(tmp_path)) + + +def test_publication_rejects_existing_expected_name_without_clobber(tmp_path: Path) -> None: + client = FakeClient() + client.current.append({"id": 2, "name": release.artifact_name(SUPPORTED_TARGETS[0])}) + with pytest.raises(ValueError, match="already exist"): + release.publish(client, f"v{VERSION}", "abc", "abc", staged(tmp_path)) + assert client.uploaded == [] + + +@pytest.mark.parametrize("failure", ["tag", "commit"]) +def test_publication_binds_exact_release_tag_and_peeled_commit(tmp_path: Path, failure: str) -> None: + client = FakeClient() + if failure == "tag": + client.release_data["tag_name"] = "v9.9.9" + with pytest.raises(ValueError): + release.publish( + client, f"v{VERSION}", "wrong" if failure == "commit" else "abc", + "abc", staged(tmp_path), + ) + assert client.uploaded == [] diff --git a/tests/unit/test_proxy_release_workflows.py b/tests/unit/test_proxy_release_workflows.py new file mode 100644 index 0000000..661ac9f --- /dev/null +++ b/tests/unit/test_proxy_release_workflows.py @@ -0,0 +1,132 @@ +"""Structural safety and graph checks for Proxy release workflows.""" + +from __future__ import annotations + +from pathlib import Path + +import yaml + + +ROOT = Path(__file__).parents[2] +WORKFLOWS = ROOT / ".github" / "workflows" + + +def load(name: str) -> tuple[dict, str]: + path = WORKFLOWS / name + text = path.read_text(encoding="utf-8") + document = yaml.safe_load(text) + assert isinstance(document, dict) + return document, text + + +def test_candidate_matrix_is_read_only_and_preserves_same_linux_bytes() -> None: + document, text = load("proxy-release-candidate.yml") + trigger = document.get("on", document.get(True)) + assert set(trigger) == {"pull_request"} + assert trigger["pull_request"]["paths"] == ["installer/proxy-stable.txt"] + assert document["permissions"] == {"contents": "read"} + assert set(document["jobs"]) == {"native"} + assert all("permissions" not in job for job in document["jobs"].values()) + assert { + row["target"] + for row in document["jobs"]["native"]["strategy"]["matrix"]["include"] + } == {"windows-x86_64", "linux-x86_64-gnu", "macos-arm64"} + assert "ubuntu:22.04" in text + assert "debian:12" in text + assert "fedora:44" in text + assert text.count("scripts.standalone.lifecycle_smoke") >= 2 + assert text.count("--target") >= 2 + assert "python3 ca-certificates curl" in text + assert text.count("python-version: '3.14'") == 1 + assert "tar -czf" in text and "release verify" in text + assert "tests/integration/bootstrap_contract" in text + assert "project_version" in text and "installer/proxy-stable.txt" in text + assert "linux-runtime-evidence" in text + assert "real_backends" not in text + assert "aggregate" not in document["jobs"] + assert not (WORKFLOWS / "proxy-pointer.yml").exists() + + +def test_general_ci_has_only_three_always_on_python_suites() -> None: + document, _text = load("tests.yml") + trigger = document.get("on", document.get(True)) + assert set(trigger) == {"pull_request", "push"} + assert set(document["jobs"]) == {"test"} + assert document["jobs"]["test"]["strategy"]["matrix"]["python-version"] == [ + "3.12", + "3.13", + "3.14", + ] + + +def test_exact_release_has_one_mutation_job_after_every_gate() -> None: + document, text = load("proxy-release.yml") + jobs = document["jobs"] + writers = [ + name + for name, job in jobs.items() + if job.get("permissions", {}).get("contents") == "write" + ] + assert writers == ["publish"] + assert jobs["publish"]["permissions"] == { + "contents": "write", + "id-token": "write", + "attestations": "write", + } + assert set(jobs["staging"]["needs"]) == {"identity", "native", "linux_compat"} + assert set(jobs["publish"]["needs"]) == {"identity", "staging"} + assert set(jobs["exact_install"]["needs"]) == {"identity", "publish"} + assert "environment: proxy-release" in text + assert "actions/attest-build-provenance@v2" in text + assert "manifest last" in text.lower() + + +def test_exact_identity_and_install_matrices_cover_ruled_targets() -> None: + document, text = load("proxy-release.yml") + jobs = document["jobs"] + ruled = {"windows-x86_64", "linux-x86_64-gnu", "macos-arm64"} + assert { + row["target"] for row in jobs["native"]["strategy"]["matrix"]["include"] + } == ruled + assert { + row["target"] for row in jobs["exact_install"]["strategy"]["matrix"]["include"] + } == ruled + assert "refs/tags/$TAG" in text + assert 'git rev-parse "$TAG^{commit}"' in text + assert "target_commitish (informational only)" in text + assert "release verify-staging" in text + assert "install.sh --version" in text and "install.ps1 -Version" in text + assert text.count("--target") >= 2 + assert text.count("python-version: '3.14'") == 1 + + +def test_windows_exact_install_propagates_native_init_and_check_failures() -> None: + _document, text = load("proxy-release.yml") + windows = text.split( + " - name: Exact install, initialize, check, and uninstall on Windows", 1 + )[1].split( + " - name: Exact install, initialize, check, and uninstall on POSIX", 1 + )[0] + exit_check = "if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }" + assert ( + "& $proxy init --non-interactive --force --backend-url " + "'http://127.0.0.1:1'\n " + exit_check + ) in windows + assert "& $proxy check\n " + exit_check in windows + + +def test_release_graph_has_no_forbidden_release_or_pointer_operations() -> None: + _document, text = load("proxy-release.yml") + lowered = text.lower() + for forbidden in ( + "gh release create", + "git tag ", + "--clobber", + "proxy-stable.txt", + "cosign", + "sigstore", + "gpg --sign", + ): + assert forbidden not in lowered + publish_text = text.split(" publish:", 1)[1].split(" exact_install:", 1)[0] + assert "scripts.standalone.build" not in publish_text diff --git a/tests/unit/test_standalone_build.py b/tests/unit/test_standalone_build.py new file mode 100644 index 0000000..1d63dbe --- /dev/null +++ b/tests/unit/test_standalone_build.py @@ -0,0 +1,291 @@ +"""Focused tests for standalone target, build, and evidence behavior.""" + +from __future__ import annotations + +import json +import signal +import subprocess +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from scripts.standalone import build, evidence, smoke + + +def passing_evidence(form: str = "onedir") -> dict[str, object]: + return { + "target": "windows-x86_64", + "form": form, + "path": "forge-proxy.exe", + "size_bytes": 1, + "build_identity": {}, + "runtime_identity": {"version": "0.9.0"}, + "cold_start_seconds": 0.1, + "shutdown_seconds": 0.1, + "extraction": { + "kind": "directory" if form == "onedir" else "temporary-onefile", + "observed_path": "bundle", + "cleanup": None if form == "onedir" else True, + }, + "smoke": { + "version": True, + "help": True, + "health": True, + "openai": True, + "anthropic": True, + "graceful_shutdown": True, + "listener_closed": True, + "process_exited": True, + }, + "dependency_evidence": { + "required": { + "forge.clients.anthropic": True, + "forge_guardrails": True, + "pydantic": True, + "httpx": True, + "anthropic": True, + "tomli_w": True, + }, + "excluded_present": [], + }, + "glibc": {"verified": None, "max_version": None, "objects": []}, + } + + +@pytest.mark.parametrize( + ("system", "machine", "expected"), + [ + ("Windows", "AMD64", "windows-x86_64"), + ("Linux", "x86_64", "linux-x86_64-gnu"), + ("Darwin", "arm64", "macos-arm64"), + ], +) +def test_native_target_selection(system: str, machine: str, expected: str) -> None: + with ( + patch.object(build.platform, "system", return_value=system), + patch.object(build.platform, "machine", return_value=machine), + ): + assert build.native_target() == expected + + +def test_non_native_target_is_rejected() -> None: + with patch.object(build, "native_target", return_value="windows-x86_64"): + with pytest.raises(ValueError, match="requires its native host"): + build.require_native_target("macos-arm64") + + +def test_standalone_builder_requires_python_314( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(build.sys, "version_info", (3, 13, 0)) + with pytest.raises(RuntimeError, match="require Python 3.14"): + build.require_python_314() + + monkeypatch.setattr(build.sys, "version_info", (3, 14, 0)) + build.require_python_314() + + +def test_pyinstaller_forms_share_the_spec_and_inputs(tmp_path: Path) -> None: + onedir = build.pyinstaller_args("windows-x86_64", "onedir", tmp_path) + onefile = build.pyinstaller_args("windows-x86_64", "onefile", tmp_path) + assert onedir[-1] == onefile[-1] == str(build.SPEC) + assert "onedir" in onedir[onedir.index("--distpath") + 1] + assert "onefile" in onefile[onefile.index("--distpath") + 1] + + +def test_onefile_requires_passing_onedir_evidence(tmp_path: Path) -> None: + with pytest.raises(RuntimeError, match="completed passing onedir"): + build.require_onedir_gate(tmp_path, "windows-x86_64") + + path = build.evidence_path(tmp_path, "windows-x86_64", "onedir") + path.parent.mkdir(parents=True) + path.write_text(json.dumps(passing_evidence()), encoding="utf-8") + build.require_onedir_gate(tmp_path, "windows-x86_64") + + +def test_passing_two_form_build_selects_onefile(tmp_path: Path) -> None: + selected = build.artifact_path(tmp_path, "windows-x86_64", "onefile") + selected.parent.mkdir(parents=True) + selected.write_bytes(b"selected artifact") + for form in ("onedir", "onefile"): + path = build.evidence_path(tmp_path, "windows-x86_64", form) + path.parent.mkdir(parents=True, exist_ok=True) + record = passing_evidence(form) + if form == "onefile": + record["path"] = str(selected) + path.write_text(json.dumps(record), encoding="utf-8") + output = build.write_selection(tmp_path, "windows-x86_64") + selection = json.loads(output.read_text(encoding="utf-8")) + assert selection["selected_form"] == "onefile" + assert selection["size"] == len(b"selected artifact") + assert len(selection["sha256"]) == 64 + + +def test_evidence_policy_reports_missing_and_excluded_content() -> None: + record = passing_evidence() + required = record["dependency_evidence"]["required"] # type: ignore[index] + required["anthropic"] = False # type: ignore[index] + with pytest.raises(ValueError, match="anthropic"): + evidence.validate_evidence(record) # type: ignore[arg-type] + + record = passing_evidence() + dependency = record["dependency_evidence"] # type: ignore[assignment] + dependency["excluded_present"] = ["pyarrow"] # type: ignore[index] + with pytest.raises(ValueError, match="pyarrow"): + evidence.validate_evidence(record) # type: ignore[arg-type] + + +def test_onefile_evidence_requires_extraction_cleanup() -> None: + record = passing_evidence("onefile") + record["extraction"]["cleanup"] = False # type: ignore[index] + with pytest.raises(ValueError, match="not cleaned up"): + evidence.validate_evidence(record) # type: ignore[arg-type] + + +def test_toc_dependency_inventory(tmp_path: Path) -> None: + toc = tmp_path / "Analysis-00.toc" + toc.write_text( + repr( + ( + ["pyarrow", "pytest", "mpmath"], + [ + ( + "forge.clients.anthropic", + "forge/clients/anthropic.py", + "PYMODULE", + ), + ("forge.clients.vllm", "forge/clients/vllm.py", "PYMODULE"), + ("pydantic", "pydantic/__init__.py", "PYMODULE"), + ("httpx", "httpx/__init__.py", "PYMODULE"), + ("anthropic", "anthropic/__init__.py", "PYMODULE"), + ("tomli_w", "tomli_w/__init__.py", "PYMODULE"), + ("forge_guardrails-0.9.0.dist-info/METADATA", "metadata", "DATA"), + ], + ) + ), + encoding="utf-8", + ) + observed = evidence.dependency_observation(toc) + assert all(observed["required"].values()) + assert observed["excluded_present"] == [] + + +def test_backend_executable_fails_dependency_inventory(tmp_path: Path) -> None: + toc = tmp_path / "Analysis-00.toc" + toc.write_text(repr([]), encoding="utf-8") + observed = evidence.dependency_observation( + toc, ["forge-proxy/_internal/llama-server.exe"] + ) + assert observed["excluded_present"] == ["llama-server.exe"] + + +def test_nested_elf_failure_is_not_hidden_by_passing_launcher(tmp_path: Path) -> None: + launcher = tmp_path / "forge-proxy" + nested = tmp_path / "_internal" / "pydantic_core.so" + nested.parent.mkdir() + launcher.write_bytes(b"\x7fELFlauncher") + nested.write_bytes(b"\x7fELFnested") + + def fake_readelf( + args: list[str], + **_kwargs: object, + ) -> subprocess.CompletedProcess[str]: + version = "GLIBC_2.35" if Path(args[-1]) == launcher else "GLIBC_2.36" + return subprocess.CompletedProcess(args, 0, stdout=version, stderr="") + + with pytest.raises(ValueError, match="pydantic_core.*GLIBC_2.36"): + evidence.inspect_glibc([launcher, nested], runner=fake_readelf) + + +def test_onefile_launcher_is_included_in_glibc_inspection(tmp_path: Path) -> None: + launcher = tmp_path / "forge-proxy" + nested = tmp_path / "_internal" / "pydantic_core.so" + nested.parent.mkdir() + launcher.write_bytes(b"\x7fELFlauncher") + nested.write_bytes(b"\x7fELFnested") + package_toc = tmp_path / "PKG-00.toc" + package_toc.write_text( + repr([("pydantic_core.so", str(nested), "BINARY")]), + encoding="utf-8", + ) + + def fake_readelf( + args: list[str], + **_kwargs: object, + ) -> subprocess.CompletedProcess[str]: + version = "GLIBC_2.36" if Path(args[-1]) == launcher else "GLIBC_2.35" + return subprocess.CompletedProcess(args, 0, stdout=version, stderr="") + + paths = evidence.onefile_elf_inventory(package_toc, launcher) + with pytest.raises(ValueError, match="forge-proxy.*GLIBC_2.36"): + evidence.inspect_glibc(paths, runner=fake_readelf) + + +def test_cli_capture_uses_child_windows_locale_under_parent_utf8_mode( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("PYTHONUTF8", "1") + monkeypatch.setenv("PYTHONIOENCODING", "utf-8") + executable = Path("forge-proxy.exe") + completed = subprocess.CompletedProcess( + [str(executable), "--version"], + 0, + stdout="forge-proxy \u2014 standalone\n", + stderr="", + ) + + with ( + patch.object(smoke.locale, "getencoding", return_value="cp1252"), + patch.object(smoke.subprocess, "run", return_value=completed) as run, + ): + result = smoke.cli_check(executable, "--version", tmp_path) + + assert result.returncode == 0 + assert result.stdout == "forge-proxy \u2014 standalone\n" + run.assert_called_once() + command = run.call_args.args[0] + kwargs = run.call_args.kwargs + assert command == [str(executable), "--version"] + assert kwargs["cwd"] == tmp_path + assert kwargs["encoding"] == "cp1252" + assert "PYTHONUTF8" not in kwargs["env"] + assert "PYTHONIOENCODING" not in kwargs["env"] + + +def test_cli_capture_uses_python_310_locale_fallback( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + executable = Path("forge-proxy") + completed = subprocess.CompletedProcess( + [str(executable), "--version"], 0, stdout="0.9.1\n", stderr="" + ) + monkeypatch.delattr(smoke.locale, "getencoding") + + with ( + patch.object( + smoke.locale, "getpreferredencoding", return_value="UTF-8" + ) as fallback, + patch.object(smoke.subprocess, "run", return_value=completed) as run, + ): + smoke.cli_check(executable, "--version", tmp_path) + + fallback.assert_called_once_with(False) + assert run.call_args.kwargs["encoding"] == "UTF-8" + + +def test_windows_graceful_stop_uses_ctrl_break_and_requires_listener_close() -> None: + process = MagicMock() + process.returncode = 0 + ctrl_break = getattr(signal, "CTRL_BREAK_EVENT", 1) + with ( + patch.object(smoke.os, "name", "nt"), + patch.object(smoke.signal, "CTRL_BREAK_EVENT", ctrl_break, create=True), + patch.object(smoke, "port_closed", return_value=True), + ): + _, passed = smoke.graceful_stop(process, 8123) + process.send_signal.assert_called_once_with(ctrl_break) + process.communicate.assert_called_once_with(timeout=20) + assert passed is True