From 04ae6c464d060863a9aec56c19dbf8a93ed4145a Mon Sep 17 00:00:00 2001 From: Antoine Zambelli Date: Fri, 21 Aug 2026 19:40:45 -0500 Subject: [PATCH 1/4] test: support Anthropic 1.0 wire capture --- tests/unit/test_client_auth.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_client_auth.py b/tests/unit/test_client_auth.py index 8ea1258..2e158da 100644 --- a/tests/unit/test_client_auth.py +++ b/tests/unit/test_client_auth.py @@ -363,9 +363,14 @@ def _anthropic_capturing( """ import anthropic - def handler(request: httpx.Request) -> httpx.Response: + if int(anthropic.__version__.split(".", 1)[0]) >= 1: + import httpx2 as sdk_http + else: + sdk_http = httpx + + def handler(request): captured["request"] = request - return httpx.Response( + return sdk_http.Response( 200, json={ "id": "msg_1", @@ -382,7 +387,7 @@ def handler(request: httpx.Request) -> httpx.Response: client._client = anthropic.AsyncAnthropic( api_key=api_key, base_url="https://b", - http_client=httpx.AsyncClient(transport=httpx.MockTransport(handler)), + http_client=sdk_http.AsyncClient(transport=sdk_http.MockTransport(handler)), ) From 5deccf99a231ebab66dff572f39139bc62c79d9b Mon Sep 17 00:00:00 2001 From: Antoine Zambelli Date: Fri, 21 Aug 2026 19:40:52 -0500 Subject: [PATCH 2/4] fix(proxy): enforce exclusive command ownership --- CHANGELOG.md | 21 +++ Dockerfile | 2 +- README.md | 7 +- docs/PROXY_INSTALLATION.md | 14 ++ installer/proxy-stable.txt | 2 +- pyproject.toml | 5 +- scripts/standalone/lifecycle_smoke.py | 194 +++++++++++++++++++++-- src/forge/proxy/_installer.py | 154 ++++++++++++++++-- tests/unit/test_proxy_cli.py | 8 +- tests/unit/test_proxy_installer.py | 124 ++++++++++++++- tests/unit/test_proxy_lifecycle_smoke.py | 7 + tests/unit/test_proxy_proxy.py | 9 +- tests/unit/test_proxy_release.py | 7 +- 13 files changed, 508 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cc427f..f9aa9ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ All notable changes to forge are documented here. +## [0.9.3] — 2026-08-21 + +A command-ownership hotfix for the standalone Forge Proxy distribution. Proxy +request handling, routing, backend behavior, and guardrail policy are +unchanged. + +### Fixed + +- **The standalone installer exclusively owns `forge-proxy`.** The + `forge-guardrails` Python package no longer creates a competing global + command; Python-managed Proxy execution remains available through + `python -m forge.proxy`, including in the Docker image. +- **Foreign commands are preserved.** Installation and update refuse when an + unowned `forge-proxy` is already on PATH or occupies the intended command + pathname. Reinstall and uninstall also preserve a command that replaced a + previously owned shim, leaving its package manager responsible for removal. +- **Command ownership is exercised on every native candidate.** The shared + lifecycle validates collision refusal, successful name-based resolution, + replaced-command refusal, and ownership-aware uninstall on Windows, Linux, + and macOS without release-specific migration logic. + ## [0.9.2] — 2026-08-17 A packaging-only maintenance release completing the standalone Forge Proxy diff --git a/Dockerfile b/Dockerfile index 24d04ee..9381bfb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,4 +48,4 @@ HEALTHCHECK \ EXPOSE 8081 -ENTRYPOINT ["forge-proxy", "--host", "0.0.0.0", "--port", "8081"] +ENTRYPOINT ["python", "-m", "forge.proxy", "--host", "0.0.0.0", "--port", "8081"] diff --git a/README.md b/README.md index bafa567..327b30f 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 (`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. +- **Proxy server** — Drop-in proxy (`forge-proxy` from the standalone distribution, 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. @@ -72,6 +72,11 @@ pip install forge-guardrails # core only pip install "forge-guardrails[anthropic]" # + Anthropic client ``` +The Python package intentionally does not install a global `forge-proxy` +command. Run its Proxy implementation with `python -m forge.proxy`; the +standalone installer above is the sole owner of the `forge-proxy` command and +its update/uninstall lifecycle. + For development: ```bash diff --git a/docs/PROXY_INSTALLATION.md b/docs/PROXY_INSTALLATION.md index 4c61ca2..67c6691 100644 --- a/docs/PROXY_INSTALLATION.md +++ b/docs/PROXY_INSTALLATION.md @@ -235,6 +235,20 @@ 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. +### Existing command ownership + +The standalone installer is the sole owner of the global `forge-proxy` +command. The `forge-guardrails` Python package runs Proxy as +`python -m forge.proxy` and does not create that global command. + +If installation reports an existing unowned `forge-proxy`, it stops before +changing the installation or PATH and leaves that command untouched. This is +not a request to reorder PATH. If an older `forge-guardrails` installation +created the launcher, upgrade that package in the same Python environment so +pip removes its own launcher, then retry. If another tool owns the command, +remove it through that tool before retrying. The standalone installer and +uninstaller never delete an unowned command. + To remove the managed installation: ```console diff --git a/installer/proxy-stable.txt b/installer/proxy-stable.txt index 2003b63..965065d 100644 --- a/installer/proxy-stable.txt +++ b/installer/proxy-stable.txt @@ -1 +1 @@ -0.9.2 +0.9.3 diff --git a/pyproject.toml b/pyproject.toml index 4be8709..2ef60c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "forge-guardrails" -version = "0.9.2" +version = "0.9.3" 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" @@ -35,9 +35,6 @@ Repository = "https://github.com/antoinezambelli/forge" Documentation = "https://github.com/antoinezambelli/forge/tree/main/docs" Changelog = "https://github.com/antoinezambelli/forge/blob/main/CHANGELOG.md" -[project.scripts] -forge-proxy = "forge.proxy.__main__:main" - [project.optional-dependencies] anthropic = ["anthropic>=0.86.0"] dataset-builder = ["pyarrow>=18.0.0"] diff --git a/scripts/standalone/lifecycle_smoke.py b/scripts/standalone/lifecycle_smoke.py index f40ed00..570b576 100644 --- a/scripts/standalone/lifecycle_smoke.py +++ b/scripts/standalone/lifecycle_smoke.py @@ -93,6 +93,12 @@ def command(executable: Path, arguments: list[str]) -> list[str]: return [str(executable), *arguments] +def named_command(arguments: list[str]) -> list[str]: + if os.name == "nt": + return ["cmd", "/d", "/c", "forge-proxy", *arguments] + return ["forge-proxy", *arguments] + + def run_process( arguments: list[str], *, @@ -363,6 +369,19 @@ def shim_path(install_root: Path, target: str) -> Path: return install_root / "bin" / name +def install_arguments(artifact: ReleaseArtifact, install_root: Path) -> list[str]: + return [ + "install-artifact", + "--version", + artifact.version, + "--sha256", + artifact.sha256, + "--no-init", + "--install-root", + str(install_root), + ] + + def slot_path(install_root: Path, artifact: ReleaseArtifact) -> Path: executable = ( "forge-proxy.exe" if artifact.target == "windows-x86_64" else "forge-proxy" @@ -370,6 +389,43 @@ def slot_path(install_root: Path, artifact: ReleaseArtifact) -> Path: return install_root / "versions" / artifact.version / executable +def command_environment(env: dict[str, str], command_dir: Path) -> dict[str, str]: + resolved = dict(env) + inherited = resolved.get("PATH", "") + resolved["PATH"] = ( + f"{command_dir}{os.pathsep}{inherited}" if inherited else str(command_dir) + ) + return resolved + + +def foreign_path_command(directory: Path, target: str) -> Path: + directory.mkdir(parents=True, exist_ok=True) + name = "forge-proxy.exe" if target == "windows-x86_64" else "forge-proxy" + path = directory / name + path.write_bytes(b"foreign forge-proxy PATH fixture\n") + if target != "windows-x86_64": + path.chmod(0o755) + return path + + +def replace_installed_command(path: Path, target: str) -> bytes: + if path.exists() or path.is_symlink(): + path.unlink() + content = b"foreign replacement command\n" + path.write_bytes(content) + if target != "windows-x86_64": + path.chmod(0o755) + return content + + +def wait_for_removal(path: Path, *, seconds: float = 20) -> None: + deadline = time.monotonic() + seconds + while path.exists() and time.monotonic() < deadline: + time.sleep(0.05) + if path.exists(): + raise RuntimeError(f"owned installation state remained after uninstall: {path}") + + def path_snapshot(path: Path) -> tuple[str, bytes | str]: if path.is_symlink(): return ("symlink", os.readlink(path)) @@ -445,6 +501,125 @@ def assert_failed_update_preserved( assert_active(active, install_root, isolation, env, steps) +def candidate_ownership_prelude( + candidate: ReleaseArtifact, + isolation: Path, + steps: list[dict[str, Any]], +) -> dict[str, bool]: + fixture = isolation / "candidate-ownership" + user = fixture / "user" + user.mkdir(parents=True) + install_root = fixture / "install root" + path_file = fixture / "user-path.txt" + path_file.write_text("existing-path", encoding="utf-8") + env = isolated_environment(fixture, path_file) + if candidate.target != "windows-x86_64": + env["SHELL"] = "/bin/bash" + + foreign_dir = fixture / "foreign-command" + foreign = foreign_path_command(foreign_dir, candidate.target) + foreign_before = foreign.read_bytes() + conflict_env = command_environment(env, foreign_dir) + install_args = install_arguments(candidate, install_root) + steps.append( + run_step( + candidate.path, + install_args, + cwd=fixture, + env=conflict_env, + expected_error="unowned forge-proxy command", + ) + ) + if foreign.read_bytes() != foreign_before: + raise RuntimeError("collision refusal changed the foreign PATH command") + if install_root.exists(): + raise RuntimeError("collision refusal published standalone installation state") + if path_file.read_text(encoding="utf-8") != "existing-path": + raise RuntimeError("collision refusal changed isolated PATH state") + + foreign.unlink() + foreign_dir.rmdir() + steps.append(run_step(candidate.path, install_args, cwd=fixture, env=env)) + shim = shim_path(install_root, candidate.target) + steps.append( + run_step( + shim, + [ + "init", + "--non-interactive", + "--force", + "--backend-url", + "http://127.0.0.1:1", + ], + cwd=fixture, + env=env, + ) + ) + resolved_env = command_environment(env, shim.parent) + steps.append( + run_process( + named_command(["--version"]), + cwd=fixture, + env=resolved_env, + ) + ) + if steps[-1]["stdout"].strip() != candidate.version: + raise RuntimeError("bare forge-proxy resolved to the wrong installation") + steps.append(run_process(named_command(["check"]), cwd=fixture, env=resolved_env)) + + replacement = replace_installed_command(shim, candidate.target) + steps.append( + run_step( + candidate.path, + install_args, + cwd=fixture, + env=resolved_env, + expected_error="unowned forge-proxy command", + ) + ) + if shim.read_bytes() != replacement: + raise RuntimeError("reinstall refusal changed the replacement command") + + state = install_root / "state.json" + marker = install_root / "ownership.txt" + uninstaller_name = ( + "uninstall.cmd" if candidate.target == "windows-x86_64" else "uninstall.sh" + ) + uninstaller = install_root / uninstaller_name + versions = install_root / "versions" + staging = install_root / ".staging" + profile = profile_snapshot(user) + steps.append( + run_step( + slot_path(install_root, candidate), + ["uninstall"], + cwd=fixture, + env=resolved_env, + ) + ) + for owned_path in (state, marker, uninstaller, versions, staging): + wait_for_removal(owned_path) + if not shim.is_file() or shim.read_bytes() != replacement: + raise RuntimeError("uninstall removed or changed the replacement command") + if path_file.read_text(encoding="utf-8") != "existing-path": + raise RuntimeError("ownership uninstall did not restore isolated PATH state") + assert_profile(profile) + + shim.unlink() + for directory in (shim.parent, install_root): + try: + directory.rmdir() + except OSError: + pass + return { + "collision_rejected": True, + "foreign_command_preserved": True, + "bare_command_resolved": True, + "replacement_reinstall_rejected": True, + "replacement_survived_uninstall": True, + } + + def run_lifecycle( artifact: Path, version: str, @@ -487,6 +662,7 @@ def run_lifecycle( env=env, ) ) + ownership = candidate_ownership_prelude(candidate, isolation, steps) baseline = retrievable_published_baseline( target, @@ -546,16 +722,7 @@ def run_lifecycle( 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), - ] + install_args = install_arguments(candidate, 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) @@ -641,11 +808,7 @@ def run_lifecycle( 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") + wait_for_removal(install_root) assert_profile(profile) if path_file.read_text(encoding="utf-8") != "existing-path": @@ -665,6 +828,7 @@ def run_lifecycle( "baseline": baseline.version if baseline is not None else None, "baseline_status": baseline_status, "steps": steps, + "command_ownership": ownership, "owned_state_removed": True, "path_state_restored": True, "profile_preserved": True, diff --git a/src/forge/proxy/_installer.py b/src/forge/proxy/_installer.py index d19654c..d5ac145 100644 --- a/src/forge/proxy/_installer.py +++ b/src/forge/proxy/_installer.py @@ -517,6 +517,109 @@ def _snapshot(path: Path) -> tuple[str, bytes | str | None]: return ("missing", None) +def _windows_command_content(slot: Path) -> bytes: + return f'@echo off\r\n"{slot}" %*\r\n'.encode("utf-8") + + +def _posix_command_target(paths: InstallPaths, slot: Path) -> str: + return os.path.relpath(slot, paths.command_dir) + + +def _command_is_owned(paths: InstallPaths, state: Mapping[str, Any]) -> bool: + command = paths.command + slot = paths.slot(str(state["current_version"])) + try: + if paths.system == "Windows": + return ( + command.is_file() + and not command.is_symlink() + and command.read_bytes() == _windows_command_content(slot) + ) + return command.is_symlink() and os.readlink(command) == _posix_command_target( + paths, slot + ) + except OSError: + return False + + +def _command_key(path: Path, system: str) -> str: + value = os.path.abspath(path) + return os.path.normcase(value) if system == "Windows" else value + + +def _path_command_names(system: str, environ: Mapping[str, str]) -> tuple[str, ...]: + if system != "Windows": + return (PRODUCT,) + raw_extensions = environ.get("PATHEXT", ".COM;.EXE;.BAT;.CMD") + extensions: list[str] = [""] + for item in raw_extensions.split(";"): + extension = item.strip().lower() + if extension and not extension.startswith("."): + extension = f".{extension}" + if extension not in extensions: + extensions.append(extension) + return tuple(f"{PRODUCT}{extension}" for extension in extensions) + + +def _command_conflicts( + paths: InstallPaths, + prior: Mapping[str, Any] | None, + *, + environ: Mapping[str, str] | None = None, +) -> list[Path]: + environ = os.environ if environ is None else environ + candidates: list[Path] = [] + if paths.command.exists() or paths.command.is_symlink(): + candidates.append(paths.command) + + separator = ";" if paths.system == "Windows" else ":" + for raw_directory in environ.get("PATH", "").split(separator): + directory = raw_directory.strip().strip('"') + if not directory: + continue + for name in _path_command_names(paths.system, environ): + candidate = Path(directory) / name + if not candidate.is_file(): + continue + if paths.system != "Windows" and not os.access(candidate, os.X_OK): + continue + candidates.append(candidate) + + owned_key = _command_key(paths.command, paths.system) + owned = prior is not None and _command_is_owned(paths, prior) + conflicts: list[Path] = [] + seen: set[str] = set() + for candidate in candidates: + key = _command_key(candidate, paths.system) + if key in seen: + continue + seen.add(key) + if key == owned_key and owned: + continue + conflicts.append(Path(os.path.abspath(candidate))) + return conflicts + + +def _refuse_command_conflicts( + paths: InstallPaths, + prior: Mapping[str, Any] | None, + *, + environ: Mapping[str, str] | None = None, +) -> None: + conflicts = _command_conflicts(paths, prior, environ=environ) + if not conflicts: + return + locations = ", ".join(f"'{path}'" for path in conflicts) + raise InstallerError( + f"unowned forge-proxy command already exists at {locations}; the " + "standalone installer changed nothing and will not overwrite or compete " + "with it. If an older forge-guardrails package created the command, " + "upgrade that package in the same Python environment so pip removes its " + "launcher, then retry. Otherwise remove the command through the tool " + "that owns it, then retry." + ) + + def _restore(path: Path, snapshot: tuple[str, bytes | str | None]) -> None: if path.exists() or path.is_symlink(): path.unlink() @@ -531,12 +634,10 @@ def _restore(path: Path, snapshot: tuple[str, bytes | str | None]) -> None: 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) + _atomic_write(paths.command, _windows_command_content(slot)) return - relative = os.path.relpath(slot, paths.command_dir) temporary = paths.command_dir / f".forge-proxy.{uuid.uuid4().hex}" - os.symlink(relative, temporary) + os.symlink(_posix_command_target(paths, slot), temporary) try: os.replace(temporary, paths.command) finally: @@ -556,9 +657,13 @@ def _ps_quote(value: str) -> str: def _render_windows_uninstaller( - paths: InstallPaths, ownership_id: str, path_record: Mapping[str, Any] + paths: InstallPaths, + ownership_id: str, + path_record: Mapping[str, Any], + slot: Path, ) -> bytes: marker = _marker_content(paths, ownership_id) + command_sha256 = hashlib.sha256(_windows_command_content(slot)).hexdigest() ps = [ "$ErrorActionPreference='SilentlyContinue'", "$parent=[int]%1", @@ -566,6 +671,11 @@ def _render_windows_uninstaller( "Start-Sleep -Milliseconds 250", f"$marker={_ps_quote(str(paths.marker))}", f"if((Get-Content -Raw -LiteralPath $marker) -ne {_ps_quote(marker)}){{exit 2}}", + "$ownedCommand=$false", + f"$command={_ps_quote(str(paths.command))}", + "if(Test-Path -LiteralPath $command){" + "$ownedCommand=((Get-FileHash -LiteralPath $command -Algorithm SHA256).Hash.ToLowerInvariant()" + f" -eq '{command_sha256}')}}", ] targets = ",".join( _ps_quote(str(target)) for target in (paths.versions, paths.staging) @@ -602,7 +712,8 @@ def _render_windows_uninstaller( "[void][ForgeEnvironment]::SendMessageTimeout([IntPtr]0xffff,0x001A,[UIntPtr]::Zero,'Environment',0x0002,5000,[ref]$broadcast)", ] ) - for target in (paths.command, paths.state, paths.marker): + ps.append("if($ownedCommand){Remove-Item -Force -LiteralPath $command}") + for target in (paths.state, paths.marker): ps.append(f"Remove-Item -Force -LiteralPath {_ps_quote(str(target))}") ps.extend( [ @@ -621,7 +732,10 @@ def _render_windows_uninstaller( def _render_posix_uninstaller( - paths: InstallPaths, ownership_id: str, path_record: Mapping[str, Any] + paths: InstallPaths, + ownership_id: str, + path_record: Mapping[str, Any], + slot: Path, ) -> bytes: q = shlex.quote lines = [ @@ -631,6 +745,10 @@ def _render_posix_uninstaller( f"marker={q(str(paths.marker))}", f"expected={q(_marker_content(paths, ownership_id))}", '[ "$(cat "$marker" 2>/dev/null)" = "$expected" ] || exit 2', + f"command={q(str(paths.command))}", + f"expected_command={q(_posix_command_target(paths, slot))}", + "owned_command=0", + '[ -L "$command" ] && [ "$(readlink "$command")" = "$expected_command" ] && owned_command=1', ] if path_record.get("kind") == "posix" and path_record.get("added"): startup = q(str(path_record["startup_file"])) @@ -647,7 +765,8 @@ def _render_posix_uninstaller( ) lines.extend( [ - f"rm -f -- {q(str(paths.command))} {q(str(paths.state))} {q(str(paths.marker))}", + '[ "$owned_command" -eq 1 ] && rm -f -- "$command"', + f"rm -f -- {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", @@ -659,13 +778,16 @@ def _render_posix_uninstaller( def _render_ownership_files( - paths: InstallPaths, ownership_id: str, path_record: Mapping[str, Any] + paths: InstallPaths, + ownership_id: str, + path_record: Mapping[str, Any], + slot: Path, ) -> None: _atomic_write(paths.marker, _marker_content(paths, ownership_id).encode("utf-8")) content = ( - _render_windows_uninstaller(paths, ownership_id, path_record) + _render_windows_uninstaller(paths, ownership_id, path_record, slot) if paths.system == "Windows" - else _render_posix_uninstaller(paths, ownership_id, path_record) + else _render_posix_uninstaller(paths, ownership_id, path_record, slot) ) _atomic_write(paths.uninstaller, content, executable=paths.system != "Windows") @@ -685,6 +807,7 @@ def install_artifact( runner: ProcessRunner | None = None, path_adapter: PathAdapter | None = None, output: Callable[[str], None] = print, + environ: Mapping[str, str] | None = None, ) -> dict[str, Any]: parse_version(version) sha256 = parse_checksum(sha256) @@ -695,6 +818,7 @@ def install_artifact( 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") + _refuse_command_conflicts(paths, prior, environ=environ) paths.staging.mkdir(parents=True, exist_ok=True) suffix = ".exe" if paths.system == "Windows" else "" @@ -750,7 +874,7 @@ def install_artifact( "verified_slots": [verified_by_version[item] for item in retained], "path_integration": path_record, } - _render_ownership_files(paths, ownership_id, path_record) + _render_ownership_files(paths, ownership_id, path_record, slot) _write_state(paths.state, state) _publish_command(paths, slot) except Exception: @@ -793,11 +917,13 @@ def update( pointer_url: str = STABLE_POINTER_URL, manifest_url: str = RELEASE_MANIFEST_URL, asset_url: str = RELEASE_ASSET_URL, + environ: Mapping[str, str] | None = None, ) -> 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) + _refuse_command_conflicts(paths, state, environ=environ) transport = transport or UrlTransport() exact = version is not None if version is None: @@ -855,6 +981,7 @@ def update( runner=runner, path_adapter=path_adapter, output=output, + environ=environ, ) finally: if artifact.parent == paths.staging and artifact.exists(): @@ -902,9 +1029,10 @@ def uninstall_owned( ) -> None: """Synchronous ownership-aware equivalent used by local fixture tests.""" state = validate_owned_install(paths) + owned_command = _command_is_owned(paths, state) path_adapter = path_adapter or default_path_adapter(paths) path_adapter.remove(state["path_integration"]) - if paths.command.exists() or paths.command.is_symlink(): + if owned_command: paths.command.unlink() for path in (paths.state, paths.marker, paths.uninstaller): if path.exists(): diff --git a/tests/unit/test_proxy_cli.py b/tests/unit/test_proxy_cli.py index 1605685..52d93ad 100644 --- a/tests/unit/test_proxy_cli.py +++ b/tests/unit/test_proxy_cli.py @@ -2,6 +2,7 @@ from __future__ import annotations +import importlib.metadata import subprocess import sys import tomllib @@ -15,7 +16,7 @@ from forge.proxy.__main__ import _build_parser, _proxy_from_args -def test_version_exits_zero_without_configuration_and_matches_project( +def test_python_distribution_keeps_module_cli_without_global_command( capsys: pytest.CaptureFixture[str], ) -> None: project = tomllib.loads( @@ -25,6 +26,11 @@ def test_version_exits_zero_without_configuration_and_matches_project( proxy_cli.main(["--version"]) assert exc.value.code == 0 assert capsys.readouterr().out.strip() == project["project"]["version"] + distribution = importlib.metadata.distribution("forge-guardrails") + assert not any( + entry.group == "console_scripts" and entry.name == "forge-proxy" + for entry in distribution.entry_points + ) def test_help_lists_installed_lifecycle_without_out_of_scope_modes() -> None: diff --git a/tests/unit/test_proxy_installer.py b/tests/unit/test_proxy_installer.py index 63f0f75..88f9cbd 100644 --- a/tests/unit/test_proxy_installer.py +++ b/tests/unit/test_proxy_installer.py @@ -80,6 +80,7 @@ def install( path_adapter: _installer.PathAdapter, **kwargs: object, ) -> dict[str, object]: + kwargs.setdefault("environ", {"PATH": ""}) return _installer.install_artifact( source, version, @@ -202,6 +203,60 @@ def test_fresh_install_writes_owned_layout_and_windows_argv_shim( assert str(paths.command_dir) in (tmp_path / "user-path.txt").read_text() +def test_fresh_install_refuses_foreign_path_and_destination_commands( + tmp_path: Path, +) -> None: + windows = tmp_path / "windows" + windows.mkdir() + foreign_dir = windows / "Python" / "Scripts" + foreign_dir.mkdir(parents=True) + foreign_exe = foreign_dir / "forge-proxy.exe" + foreign_exe.write_bytes(b"pip-owned launcher") + source, sha = artifact(windows, "1.0.0") + paths = windows_paths(windows) + path_file = adapter(windows, "C:\\Existing") + with pytest.raises(_installer.InstallerError, match="unowned forge-proxy") as exc: + install( + source, + sha, + "1.0.0", + paths, + FakeRunner(), + path_file, + environ={ + "PATH": str(foreign_dir), + "PATHEXT": ".COM;.EXE;.BAT;.CMD", + }, + ) + assert str(foreign_exe) in str(exc.value) + assert "same Python environment" in str(exc.value) + assert foreign_exe.read_bytes() == b"pip-owned launcher" + assert not paths.root.exists() + assert (windows / "user-path.txt").read_text() == "C:\\Existing" + + posix = tmp_path / "posix" + posix.mkdir() + source, sha = artifact(posix, "1.0.0") + paths = _installer.InstallPaths(posix / "app", posix / "bin", "Linux") + paths.command_dir.mkdir() + paths.command.write_bytes(b"pip-owned script") + paths.command.chmod(0o755) + path_file = adapter(posix, "existing-path") + with pytest.raises(_installer.InstallerError, match="unowned forge-proxy"): + install( + source, + sha, + "1.0.0", + paths, + FakeRunner(), + path_file, + environ={"PATH": str(paths.command_dir)}, + ) + assert paths.command.read_bytes() == b"pip-owned script" + assert not paths.root.exists() + assert (posix / "user-path.txt").read_text() == "existing-path" + + 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) @@ -214,6 +269,44 @@ def test_idempotent_install_rechecks_slot_without_rewriting(tmp_path: Path) -> N assert runner.calls[-2][0] == paths.slot("1.0.0") +def test_replaced_owned_command_blocks_reinstall_update_and_survives_uninstall( + tmp_path: Path, +) -> None: + paths = ( + windows_paths(tmp_path) + if os.name == "nt" + else _installer.InstallPaths(tmp_path / "app", tmp_path / "bin", "Linux") + ) + runner = FakeRunner() + path_file = adapter(tmp_path, "existing-path") + source, sha = artifact(tmp_path, "1.0.0") + install(source, sha, "1.0.0", paths, runner, path_file) + + paths.command.unlink() + paths.command.write_bytes(b"replacement owned elsewhere") + paths.command.chmod(0o755) + before = paths.command.read_bytes() + + with pytest.raises(_installer.InstallerError, match="unowned forge-proxy"): + install(source, sha, "1.0.0", paths, runner, path_file) + with pytest.raises(_installer.InstallerError, match="unowned forge-proxy"): + _installer.update( + "1.0.0", + paths=paths, + path_adapter=path_file, + environ={"PATH": str(paths.command_dir)}, + ) + assert paths.command.read_bytes() == before + + _installer.uninstall_owned(paths, path_adapter=path_file) + assert paths.command.read_bytes() == before + assert not paths.state.exists() + assert not paths.marker.exists() + assert not paths.uninstaller.exists() + assert not paths.versions.exists() + assert (tmp_path / "user-path.txt").read_text() == "existing-path" + + def test_forward_updates_retain_current_and_one_previous_slot(tmp_path: Path) -> None: paths = windows_paths(tmp_path) runner = FakeRunner() @@ -407,6 +500,7 @@ def test_production_release_urls_use_raw_pointer_and_exact_forge_tag( path_adapter=path_file, output=lambda _line: None, target="windows-x86_64", + environ={"PATH": ""}, ) assert transport.reads == [ @@ -437,11 +531,16 @@ def test_update_forward_same_newer_than_stable_and_lower_exact(tmp_path: Path) - pointer_url="pointer", manifest_url="manifest/{version}", asset_url="asset/{version}/{name}", + environ={"PATH": ""}, ) 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 + "1.1.0", + paths=paths, + transport=transport, + output=lambda _line: None, + environ={"PATH": ""}, ) is None ) @@ -452,11 +551,14 @@ def test_update_forward_same_newer_than_stable_and_lower_exact(tmp_path: Path) - transport=old_stable, output=lambda _line: None, pointer_url="pointer", + environ={"PATH": ""}, ) is None ) with pytest.raises(_installer.InstallerError, match="cannot downgrade"): - _installer.update("1.0.0", paths=paths, transport=transport) + _installer.update( + "1.0.0", paths=paths, transport=transport, environ={"PATH": ""} + ) def test_unavailable_update_preserves_current_install(tmp_path: Path) -> None: @@ -474,7 +576,12 @@ def test_unavailable_update_preserves_current_install(tmp_path: Path) -> None: {"pointer": _installer.InstallerError("download unavailable")} ) with pytest.raises(_installer.InstallerError, match="download unavailable"): - _installer.update(paths=paths, transport=transport, pointer_url="pointer") + _installer.update( + paths=paths, + transport=transport, + pointer_url="pointer", + environ={"PATH": ""}, + ) assert before == ( paths.command.read_bytes(), paths.state.read_bytes(), @@ -537,8 +644,10 @@ def test_generated_windows_uninstaller_broadcasts_only_for_real_user_path( "representation": None, } real_script = _installer._render_windows_uninstaller( - paths, "owned", real_record + paths, "owned", real_record, paths.slot("1.0.0") ).decode("utf-8") + assert "Get-FileHash" in real_script + assert "if($ownedCommand){Remove-Item" in real_script assert "SetEnvironmentVariable" in real_script assert "SendMessageTimeout" in real_script assert "'Environment'" in real_script @@ -548,7 +657,7 @@ def test_generated_windows_uninstaller_broadcasts_only_for_real_user_path( "representation": str(tmp_path / "path.txt"), } fixture_script = _installer._render_windows_uninstaller( - paths, "owned", fixture_record + paths, "owned", fixture_record, paths.slot("1.0.0") ).decode("utf-8") assert "SetEnvironmentVariable" not in fixture_script assert "SendMessageTimeout" not in fixture_script @@ -606,6 +715,11 @@ def test_posix_symlink_and_marked_startup_are_owned_and_removed(tmp_path: Path) 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 + uninstaller = _installer._render_posix_uninstaller( + paths, "owned", record, slot + ).decode("utf-8") + assert 'readlink "$command"' in uninstaller + assert '[ "$owned_command" -eq 1 ] && rm -f -- "$command"' in uninstaller def test_preexisting_posix_path_block_is_not_claimed_or_reported( diff --git a/tests/unit/test_proxy_lifecycle_smoke.py b/tests/unit/test_proxy_lifecycle_smoke.py index 6e1ed23..400d0ef 100644 --- a/tests/unit/test_proxy_lifecycle_smoke.py +++ b/tests/unit/test_proxy_lifecycle_smoke.py @@ -23,6 +23,13 @@ def test_windows_shim_command_uses_cmd(monkeypatch: pytest.MonkeyPatch) -> None: "forge-proxy.cmd", "check", ] + assert lifecycle_smoke.named_command(["--version"]) == [ + "cmd", + "/d", + "/c", + "forge-proxy", + "--version", + ] def test_expected_failure_is_a_successful_gate_observation(tmp_path: Path) -> None: diff --git a/tests/unit/test_proxy_proxy.py b/tests/unit/test_proxy_proxy.py index ac0ba6e..2894675 100644 --- a/tests/unit/test_proxy_proxy.py +++ b/tests/unit/test_proxy_proxy.py @@ -643,7 +643,12 @@ async def close_client() -> None: assert order == ["http", "backend", "client"] - def test_docker_liveness_uses_forge_namespace(self) -> None: + def test_docker_uses_module_entrypoint_and_forge_liveness(self) -> None: dockerfile = Path("Dockerfile").read_text(encoding="utf-8") + assert ( + 'ENTRYPOINT ["python", "-m", "forge.proxy", "--host", "0.0.0.0", ' + '"--port", "8081"]' + ) in dockerfile + assert 'ENTRYPOINT ["forge-proxy"' not in dockerfile assert "http://127.0.0.1:8081/forge/health" in dockerfile - assert "http://127.0.0.1:8081/health\"]" not in dockerfile + assert 'http://127.0.0.1:8081/health"]' not in dockerfile diff --git a/tests/unit/test_proxy_release.py b/tests/unit/test_proxy_release.py index fc4aa38..697473d 100644 --- a/tests/unit/test_proxy_release.py +++ b/tests/unit/test_proxy_release.py @@ -12,7 +12,8 @@ from scripts.standalone.inputs import SUPPORTED_TARGETS -VERSION = "0.9.2" +VERSION = release.project_version() +OTHER_VERSION = "9.9.9" SOURCE_TREE = "a" * 40 @@ -51,7 +52,7 @@ def test_candidate_identity_binds_version_and_source_tree(tmp_path: Path) -> Non @pytest.mark.parametrize( ("version", "source_tree"), - [("0.9.3", SOURCE_TREE), (VERSION, "b" * 40)], + [(OTHER_VERSION, SOURCE_TREE), (VERSION, "b" * 40)], ) def test_candidate_identity_rejects_a_different_tag_tree( tmp_path: Path, @@ -75,7 +76,7 @@ def test_assembly_rejects_incomplete_or_changed_inputs(tmp_path: Path, failure: record_path = inputs[0] / "selection.json" record = json.loads(record_path.read_text()) if failure == "version": - record["version"] = "0.9.3" + record["version"] = OTHER_VERSION elif failure == "name": record["name"] = "wrong" elif failure == "size": From 30d275ffaada30086d51afb3f4d25f85cc27c8d9 Mon Sep 17 00:00:00 2001 From: Antoine Zambelli Date: Fri, 21 Aug 2026 20:05:46 -0500 Subject: [PATCH 3/4] test(proxy): serialize Windows uninstall retry --- .../test_windows_installer.py | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/tests/integration/platform_acceptance/test_windows_installer.py b/tests/integration/platform_acceptance/test_windows_installer.py index b7d1158..ca12db0 100644 --- a/tests/integration/platform_acceptance/test_windows_installer.py +++ b/tests/integration/platform_acceptance/test_windows_installer.py @@ -169,7 +169,7 @@ def test_windows_locked_slot_preserves_uninstall_retry_path(tmp_path: Path) -> N install(source, sha, "1.0.0", paths, FakeRunner(), path_file) with paths.slot("1.0.0").open("rb"): - subprocess.run( + locked = subprocess.run( [str(paths.uninstaller), "999999"], capture_output=True, text=True, @@ -177,6 +177,8 @@ def test_windows_locked_slot_preserves_uninstall_retry_path(tmp_path: Path) -> N shell=True, timeout=15, ) + assert locked.returncode == 0, locked.stderr + assert "Locked remnant:" in locked.stdout, (locked.stdout, locked.stderr) assert paths.command.is_file() assert paths.state.is_file() assert paths.marker.is_file() @@ -192,9 +194,20 @@ def test_windows_locked_slot_preserves_uninstall_retry_path(tmp_path: Path) -> N timeout=15, ) assert result.returncode == 0, result.stderr + owned_paths = ( + paths.command, + paths.state, + paths.marker, + paths.uninstaller, + paths.versions, + paths.staging, + ) deadline = time.monotonic() + 10 - while paths.state.exists() and time.monotonic() < deadline: + while any(path.exists() for path in owned_paths) and time.monotonic() < deadline: time.sleep(0.05) - assert not paths.state.exists() - assert not paths.command.exists() + remaining = [str(path) for path in owned_paths if path.exists()] + assert not remaining, ( + f"owned paths remained after uninstall retry: {remaining}; " + f"stdout={result.stdout!r}; stderr={result.stderr!r}" + ) assert (tmp_path / "user-path.txt").read_text() == "C:\\Existing" From 92bc9c6fa8338d8238c0eb12ae9a1ef6d07398a5 Mon Sep 17 00:00:00 2001 From: Antoine Zambelli Date: Fri, 21 Aug 2026 20:56:27 -0500 Subject: [PATCH 4/4] fix(proxy): make Windows ownership cleanup self-contained --- src/forge/proxy/_installer.py | 24 ++++++++++++++++++++---- tests/unit/test_proxy_installer.py | 5 +++-- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/src/forge/proxy/_installer.py b/src/forge/proxy/_installer.py index d5ac145..24c2ed3 100644 --- a/src/forge/proxy/_installer.py +++ b/src/forge/proxy/_installer.py @@ -671,11 +671,21 @@ def _render_windows_uninstaller( "Start-Sleep -Milliseconds 250", f"$marker={_ps_quote(str(paths.marker))}", f"if((Get-Content -Raw -LiteralPath $marker) -ne {_ps_quote(marker)}){{exit 2}}", - "$ownedCommand=$false", + "$commandStatus='missing'", + "$commandHash=''", + f"$expectedCommandHash='{command_sha256}'", f"$command={_ps_quote(str(paths.command))}", "if(Test-Path -LiteralPath $command){" - "$ownedCommand=((Get-FileHash -LiteralPath $command -Algorithm SHA256).Hash.ToLowerInvariant()" - f" -eq '{command_sha256}')}}", + "$commandStatus='unreadable';$attempt=0;" + "while($commandStatus -eq 'unreadable' -and $attempt -lt 50){" + "try{$bytes=[System.IO.File]::ReadAllBytes($command);" + "$sha=[System.Security.Cryptography.SHA256]::Create();" + "try{$commandHash=[System.BitConverter]::ToString($sha.ComputeHash($bytes)).Replace('-','').ToLowerInvariant()}" + "finally{$sha.Dispose()};" + "if($commandHash -eq $expectedCommandHash){$commandStatus='owned'}else{$commandStatus='foreign'}" + "}catch{};$attempt++;" + "if($commandStatus -eq 'unreadable'){Start-Sleep -Milliseconds 100}};" + "if($commandStatus -eq 'unreadable'){Write-Output ('Locked remnant: '+$command);exit 1}}", ] targets = ",".join( _ps_quote(str(target)) for target in (paths.versions, paths.staging) @@ -688,6 +698,13 @@ def _render_windows_uninstaller( "if(Test-Path -LiteralPath $target){$locked=$true;" "Write-Output ('Locked remnant: '+$target)}};if($locked){exit 1}" ) + ps.append( + "if($commandStatus -eq 'owned'){$attempt=0;" + "while((Test-Path -LiteralPath $command)-and $attempt -lt 50){" + "try{Remove-Item -Force -LiteralPath $command -ErrorAction Stop}catch{};" + "$attempt++;if(Test-Path -LiteralPath $command){Start-Sleep -Milliseconds 100}};" + "if(Test-Path -LiteralPath $command){Write-Output ('Locked remnant: '+$command);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") @@ -712,7 +729,6 @@ def _render_windows_uninstaller( "[void][ForgeEnvironment]::SendMessageTimeout([IntPtr]0xffff,0x001A,[UIntPtr]::Zero,'Environment',0x0002,5000,[ref]$broadcast)", ] ) - ps.append("if($ownedCommand){Remove-Item -Force -LiteralPath $command}") for target in (paths.state, paths.marker): ps.append(f"Remove-Item -Force -LiteralPath {_ps_quote(str(target))}") ps.extend( diff --git a/tests/unit/test_proxy_installer.py b/tests/unit/test_proxy_installer.py index 88f9cbd..b97208d 100644 --- a/tests/unit/test_proxy_installer.py +++ b/tests/unit/test_proxy_installer.py @@ -646,8 +646,9 @@ def test_generated_windows_uninstaller_broadcasts_only_for_real_user_path( real_script = _installer._render_windows_uninstaller( paths, "owned", real_record, paths.slot("1.0.0") ).decode("utf-8") - assert "Get-FileHash" in real_script - assert "if($ownedCommand){Remove-Item" in real_script + assert "Get-FileHash" not in real_script + assert "[System.Security.Cryptography.SHA256]::Create()" in real_script + assert "if($commandStatus -eq 'owned')" in real_script assert "SetEnvironmentVariable" in real_script assert "SendMessageTimeout" in real_script assert "'Environment'" in real_script