Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions docs/PROXY_INSTALLATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion installer/proxy-stable.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.9.2
0.9.3
5 changes: 1 addition & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"]
Expand Down
194 changes: 179 additions & 15 deletions scripts/standalone/lifecycle_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
*,
Expand Down Expand Up @@ -363,13 +369,63 @@ 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"
)
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))
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -487,6 +662,7 @@ def run_lifecycle(
env=env,
)
)
ownership = candidate_ownership_prelude(candidate, isolation, steps)

baseline = retrievable_published_baseline(
target,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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":
Expand All @@ -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,
Expand Down
Loading
Loading