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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,25 @@ Versions follow [SemVer](https://semver.org).

## [Unreleased]

- Reject Codex authors and judges on chat-only endpoints during preflight when
the bridge runtime is missing or stale, with `outerloop harness upgrade --used`
as the fix, before spending the author budget.

- Codex authors and judges can use chat-only endpoint profiles through a
per-session LiteLLM bridge and streaming shim, including tool calls and
reasoning replay on resume. The bridge runtime is isolated, transitively
locked, installed by `outerloop harness upgrade`, and mounted read-only.
Direct Responses profiles keep their existing route and interruption behavior;
the Codex pin is unchanged. Bridge runtime defaults use `OUTERLOOP_CACHE_ROOT`,
else `$OUTERLOOP_ROOT/cache` (local fallback: `~/.outerloop/cache`). Harness
upgrades skip bridge detection for misconfigured roles with a diagnostic and
continue upgrading the other configured harnesses.
- Upgrading: install the bridge with `outerloop harness upgrade --used` before
selecting chat-only Codex profiles. Existing run records, session homes, and
harness retry records retain their formats; no backfill is needed. In-flight
direct-endpoint runs are unchanged. Before rollback, finish chat-only Codex
sessions or select a Responses-capable profile; older kernels reject that route.

- Research-line salvage and terminal snapshots now recheck scope admission
against the trusted contract before sealing. Out-of-scope changes are
dropped from the seal (tracked paths retain their parent content), while
Expand Down
31 changes: 31 additions & 0 deletions docs/endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,3 +186,34 @@ request checks the server with a three-second timeout and the profile key in an
Authorization header. Missing files, dead servers, and interrupted checks park
fresh runs and defer wakes without consuming a wake retry. There is no polling. Malformed contents are configuration errors. Profile names, served model,
API capabilities and credential paths retain their existing semantics.

### Codex on a chat-completions endpoint

A Codex author or panel lens may select a profile with `API=chat`. Install its
bridge with `outerloop harness upgrade --used` (or `outerloop harness upgrade
bridge`); the command records `OUTERLOOP_BRIDGE_RUNTIME`. A profile advertising
`responses` continues to use the direct endpoint, even if it also lists `chat`.
Authors, overrides, and judges use their own selected profiles and credentials.

The bridge runs pinned LiteLLM and a streaming shim inside the session container,
with a frozen dependency lock and a managed Python runtime mounted read-only.
Both listeners bind loopback sockets before launching children and require
independent ephemeral tokens. Only the shim receives the endpoint credential.
Configuration uses environment references; sidecar payload/debug logs are disabled.
Exiting or interrupting the session also stops and reaps the bridge processes.

Codex continues to use Responses over HTTP/SSE and replays full history on resume.
The bridge supports text, reasoning, function tools, and freeform tools such as
`apply_patch`. Freeform input travels in a function's `content` string; its grammar
is described to the model, not enforced by the chat endpoint. Hosted tools,
images, WebSockets, server-side response references, remote compaction, and
structured text formats are rejected. Reasoning effort and output token limits
use LiteLLM's chat translation; model-specific unsupported parameters follow
LiteLLM's `drop_params` behavior. Codex's ordinary local compaction remains the
context-management path.

Offline compatibility tests use a fake chat upstream. To include the real pinned
proxy and CLI in the test gate, set `OUTERLOOP_BRIDGE_TEST_PYTHON` to the installed
runtime's `venv/bin/python` and `OUTERLOOP_BRIDGE_TEST_CODEX` to the pinned Codex
binary before running `uv run pytest -q`. These tests never install packages or
contact a model endpoint; without these artifacts, the real-binary tests skip.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ packages = ["src/outerloop"]
[tool.hatch.build.targets.wheel.force-include]
"scripts/install_claude.sh" = "outerloop/_installers/install_claude.sh"
"scripts/install_codex.sh" = "outerloop/_installers/install_codex.sh"
"scripts/install_bridge.sh" = "outerloop/_installers/install_bridge.sh"
"scripts/install_hermes.sh" = "outerloop/_installers/install_hermes.sh"

[tool.ruff]
Expand Down
33 changes: 33 additions & 0 deletions scripts/install_bridge.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#!/bin/bash
# Frozen runtime with an in-place managed interpreter; never moved after installation.
set -euo pipefail
pin() {
local reader
reader="$(dirname "${BASH_SOURCE[0]}")/../src/outerloop/harness_pins.py"
if [ -f "$reader" ]; then python3 "$reader" "$@"; else python3 -m outerloop.harness_pins "$@"; fi
}
WANT="$(pin bridge version)"
SOURCE="$(dirname "${BASH_SOURCE[0]}")/../src/outerloop/bridge_runtime"
if [ ! -d "$SOURCE" ]; then
SOURCE=$(python3 -c 'from pathlib import Path; import outerloop; print(Path(outerloop.__file__).parent / "bridge_runtime")')
fi
TARGET="${1:-${OUTERLOOP_BRIDGE_RUNTIME:-${OUTERLOOP_CACHE_ROOT:-${OUTERLOOP_ROOT:-$HOME/.outerloop}/cache}/bridge}}"
mkdir -p "$TARGET"
TARGET=$(cd "$TARGET" && pwd -P)
mkdir "$TARGET/.installing"
trap 'rmdir "$TARGET/.installing"' EXIT
rm -f "$TARGET/.complete"
export UV_PYTHON_INSTALL_DIR="$TARGET/python"
export UV_PROJECT_ENVIRONMENT="$TARGET/venv"
export UV_CACHE_DIR="$TARGET/cache"
export UV_PYTHON_PREFERENCE=only-managed
export UV_LINK_MODE=copy
uv python install --no-bin 3.12
python=$(uv python find --system 3.12)
case "$python" in "$TARGET/python/"*) ;; *) exit 1 ;; esac
uv sync --project "$SOURCE" --frozen --compile-bytecode --no-install-project --python "$python"
"$TARGET/venv/bin/python" -c 'import importlib.metadata, sys; assert importlib.metadata.version("litellm") == sys.argv[1]' "$WANT"
DIGEST=$(python3 -c 'import hashlib,sys; print(hashlib.sha256(open(sys.argv[1],"rb").read()).hexdigest())' "$SOURCE/uv.lock")
python3 -c 'import hashlib,sys; print(hashlib.sha256(open(sys.argv[1],"rb").read()).hexdigest())' "$TARGET/venv/bin/python" > "$TARGET/python.sha256"
printf '%s %s\n' "$WANT" "$DIGEST" > "$TARGET/.complete"
rm -rf "$UV_CACHE_DIR"
4 changes: 2 additions & 2 deletions scripts/tick_deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ if [ -n "$ENV_TRUSTED" ]; then
for _k in OUTERLOOP_CLAUDE_VERSION OUTERLOOP_CODEX_VERSION \
OUTERLOOP_CLAUDE_SHA256 OUTERLOOP_CODEX_SHA256 \
OUTERLOOP_HERMES_REF OUTERLOOP_HERMES_SHA \
OUTERLOOP_CACHE_ROOT REVIEW_BACKEND \
OUTERLOOP_CACHE_ROOT REVIEW_BACKEND OUTERLOOP_BRIDGE_RUNTIME \
OUTERLOOP_AUTHOR_ENDPOINT REVIEW_ENDPOINT REVIEW_MODEL \
OUTERLOOP_AUTHOR_BACKEND OUTERLOOP_AUTHOR_MODEL OUTERLOOP_AUTHOR_OVERRIDES \
OUTERLOOP_CLAUDE_MODEL \
Expand Down Expand Up @@ -256,7 +256,7 @@ if [ -n "$HARNESS_UPDATE_READY" ]; then
fi
fi
# The upgrade atomically records verified paths; use them in this tick too.
for _k in OUTERLOOP_CLAUDE_BIN OUTERLOOP_CODEX_BIN REVIEW_HERMES_REPO; do
for _k in OUTERLOOP_CLAUDE_BIN OUTERLOOP_CODEX_BIN REVIEW_HERMES_REPO OUTERLOOP_BRIDGE_RUNTIME; do
env_line
if [ -n "$_line" ]; then
env_value
Expand Down
5 changes: 5 additions & 0 deletions src/outerloop/attempt.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,10 @@ def author_config_error(
except ValueError as exc:
return str(exc)
if profile:
try:
profile.validate_runtime(backend, env)
except ValueError as exc:
return str(exc)
if backend != "claude" and not image:
return f"author-backend {backend} requires --image (it runs contained)"
return ""
Expand Down Expand Up @@ -3359,6 +3363,7 @@ def _panel_lenses_from_args(
claude_panel_path = Path(args.panel_key_file or PANEL_KEY_DEFAULT).expanduser()
_, endpoint = resolve_endpoint(model, backend)
if endpoint:
endpoint.validate_runtime(backend)
if not args.image:
raise ValueError(f"a {backend} endpoint panel lens requires --image")
from outerloop.endpoints import validate_judge_key_file
Expand Down
35 changes: 35 additions & 0 deletions src/outerloop/bridge_install.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""Locked, isolated LiteLLM runtime (never provisioned during a session)."""

import hashlib
import os
from collections.abc import Mapping
from pathlib import Path

from outerloop.harness_pins import pins


def runtime_path(env: Mapping[str, str] | None = None) -> Path:
env = os.environ if env is None else env
root = Path(env.get("OUTERLOOP_ROOT") or Path.home() / ".outerloop")
cache = Path(env.get("OUTERLOOP_CACHE_ROOT") or root / "cache")
return Path(env.get("OUTERLOOP_BRIDGE_RUNTIME") or cache / "bridge").expanduser().resolve()


def lock_digest() -> str:
return hashlib.sha256(
Path(__file__).with_name("bridge_runtime").joinpath("uv.lock").read_bytes()
).hexdigest()


def ready(path: Path) -> bool:
try:
marker = (path / ".complete").read_text().strip()
python = path / "venv/bin/python"
return (
marker == f"{pins('bridge')['version']} {lock_digest()}"
and os.access(python, os.X_OK)
and (path / "python.sha256").read_text().strip()
== hashlib.sha256(python.read_bytes()).hexdigest()
)
except OSError:
return False
5 changes: 5 additions & 0 deletions src/outerloop/bridge_runtime/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[project]
name = "outerloop-codex-bridge"
version = "0.1.0"
requires-python = "==3.12.*"
dependencies = ["litellm[proxy]==1.103.1", "fastapi==0.136.3"]
Loading
Loading