diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..5be1bb04 --- /dev/null +++ b/Makefile @@ -0,0 +1,44 @@ +.DEFAULT_GOAL := help +UV ?= uv +RUN_DIR ?= run/gateway + +.PHONY: help install run stop status test lint check build conformance clean + +help: + @printf '%s\n' \ + 'make install Install locked development dependencies' \ + 'make run Start only the configured gateway in the background (Linux)' \ + 'make stop Stop only the gateway started by make run' \ + 'make status Show the managed gateway process status' \ + 'make test Run tests and coverage' \ + 'make lint Run the canonical lint/format/type-check pipeline' \ + 'make check Run doctor (lint, types, tests, coverage, build, wheel smoke)' \ + 'make build Build distributions with hash-locked build dependencies' \ + 'make conformance Run repository-owned A2A compatibility probes' \ + 'make clean Remove build/test caches, never runtime state or databases' + +install: + $(UV) sync --all-extras --frozen + +run stop status: + @$(UV) run --frozen python scripts/gateway_process.py $@ "$(RUN_DIR)" + +test: + $(UV) run pytest + +lint: + bash scripts/lint.sh + +check: + bash scripts/doctor.sh + +build: + $(UV) build --no-sources --force-pep517 \ + --build-constraints build-requirements/requirements.txt --require-hashes + +conformance: + bash scripts/conformance.sh + +clean: + rm -rf build dist htmlcov .pytest_cache .mypy_cache .ruff_cache + rm -f .coverage .coverage.json diff --git a/README.md b/README.md index 84f351b7..8eb491aa 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,37 @@ flowchart TD ## Quick Start +### Development commands + +From a checkout, run `make help` for the available commands: + +```bash +make install # Install the locked development environment +make run # Start only the gateway in the background +make status +make stop # Stop only that managed gateway +make test +make lint +make check # Full doctor gate +make build +make conformance +make clean # Build/test caches only; preserves runtime state and databases +``` + +`make run` requires your existing gateway configuration in the environment or a +protected, untracked `.env`, including `A2A_STATIC_AUTH_CREDENTIALS`. It does not +start OpenCode, generate credentials, or enable V2 execution. The V2 private +deployment requirements below still apply; run it inside that deployment when +using `OPENCODE_V2_EXECUTION=exclusive`. + +The `run`, `stop`, and `status` convenience targets require Linux with PID file +descriptor support. They keep private process metadata and logs under +`run/gateway/` (override with `RUN_DIR=/path/to/private/state`). Duplicate starts +reuse the tracked process; stop validates its identity and never kills by name +or port. Treat this as a local convenience, not a production supervisor. + +### API selection and installation + **Breaking default change:** V2 is now the default upstream API, but A2A execution remains disabled until `OPENCODE_V2_EXECUTION=exclusive` is explicitly set under the [private deployment contract](docs/v2-exclusive-execution.md). diff --git a/scripts/README.md b/scripts/README.md index 17d27fdf..20f264a7 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -11,6 +11,7 @@ Executable scripts live in this directory. This file is the entry index for the ## Other Scripts +- [`gateway_process.py`](./gateway_process.py): Linux-only local gateway lifecycle helper behind `make run/stop/status`; inherits configuration, detaches the gateway with private logs, and verifies process identity before signaling. It never manages upstream OpenCode or opts into exclusive execution. - [`doctor.sh`](./doctor.sh): primary local development regression entrypoint with explicit fix/verify/package phases (uv sync + dependency compatibility + lint + mypy + tests + coverage + hash-locked PEP 517 build + built-wheel smoke test) - [`conformance.sh`](./conformance.sh): repository-owned A2A 1.0 black-box compatibility probes; can launch a dummy-backed local SUT and preserves machine-readable artifacts under `run/conformance/` - [`live_opencode_smoke.sh`](./live_opencode_smoke.sh): local/manual live integration smoke with explicit V1 opt-in against a real OpenCode runtime; boots an isolated password-hardened `opencode serve` plus the opencode-a2a runtime in throwaway directories and verifies upstream auth, JSON-RPC extension reads, and a streaming prompt round-trip diff --git a/scripts/gateway_process.py b/scripts/gateway_process.py new file mode 100644 index 00000000..9b6e129a --- /dev/null +++ b/scripts/gateway_process.py @@ -0,0 +1,182 @@ +"""Linux-only local gateway lifecycle; never manages the upstream OpenCode process.""" + +from __future__ import annotations + +import argparse +import ctypes +import fcntl +import ipaddress +import json +import os +import select +import signal +import socket +import subprocess +import sys +import time +from pathlib import Path +from typing import TextIO + +from pydantic import ValidationError + +from opencode_a2a.config import Settings + +ROOT = Path(__file__).resolve().parents[1] +NATIVE = ctypes.CDLL(None, use_errno=True) + + +def checked(result: int) -> int: + if result < 0: + error = ctypes.get_errno() + raise OSError(error, os.strerror(error)) + return result + + +def open_process(pid: int) -> int: + # Some Python builds omit pidfd wrappers even when libc/kernel support them. + command = NATIVE.pidfd_open + command.argtypes = (ctypes.c_int, ctypes.c_uint) + command.restype = ctypes.c_int + return checked(command(pid, 0)) + + +def terminate_process(handle: int) -> None: + command = NATIVE.pidfd_send_signal + command.argtypes = (ctypes.c_int, ctypes.c_int, ctypes.c_void_p, ctypes.c_uint) + command.restype = ctypes.c_int + checked(command(handle, signal.SIGTERM, None, 0)) + + +def fingerprint(pid: int) -> str: + start = Path(f"/proc/{pid}/stat").read_text().rsplit(")", 1)[1].split()[19] + boot = Path("/proc/sys/kernel/random/boot_id").read_text().strip() + return f"{boot}:{start}" + + +def acquire(lock: TextIO) -> bool: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + return True + except BlockingIOError: + return False + + +def identify(record: Path, lifetime: TextIO) -> tuple[int, int]: + data = json.loads(record.read_text()) + if not isinstance(data, dict): + raise ValueError("Invalid gateway process record; refusing to signal") + pid, descriptor, start = data.get("pid"), data.get("descriptor"), data.get("start") + if not isinstance(pid, int) or pid <= 1 or not isinstance(descriptor, int): + raise ValueError("Invalid gateway process identity; refusing to signal") + handle = open_process(pid) + try: + owned = Path(f"/proc/{pid}/fd/{descriptor}").stat() + expected = os.fstat(lifetime.fileno()) + if start != fingerprint(pid) or (owned.st_dev, owned.st_ino) != ( + expected.st_dev, + expected.st_ino, + ): + raise ValueError("Gateway process identity changed; refusing to signal") + except BaseException: + os.close(handle) + raise + return pid, handle + + +def start_gateway(state: Path, record: Path, lifetime: TextIO) -> None: + settings = Settings() + host, port = settings.a2a_host, settings.a2a_port + resolved = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)[0] + local = ipaddress.ip_address(resolved[4][0]) + address = ("::1" if local.version == 6 else "127.0.0.1") if local.is_unspecified else str(local) + family = resolved[0] + with socket.create_server((host, port), family=family): + pass + log = state / "gateway.log" + with log.open("ab") as output: + log.chmod(0o600) + child = subprocess.Popen( + [ + sys.executable, + "-c", + "from opencode_a2a.cli import main; raise SystemExit(main(['serve']))", + ], + cwd=ROOT, + stdin=subprocess.DEVNULL, + stdout=output, + stderr=subprocess.STDOUT, + start_new_session=True, + pass_fds=(lifetime.fileno(),), + ) + try: + record.write_text( + json.dumps( + {"pid": child.pid, "start": fingerprint(child.pid), "descriptor": lifetime.fileno()} + ) + ) + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + if child.poll() is not None: + raise RuntimeError(f"Gateway exited during startup; inspect {log}") + try: + with socket.create_connection((address, port), timeout=0.2): + print(f"Gateway listening on {address}:{port} (PID {child.pid}); log: {log}") + return + except OSError: + time.sleep(0.1) + raise RuntimeError(f"Gateway startup timed out; inspect {log}") + except BaseException: + child.terminate() + child.wait(timeout=10) + record.unlink(missing_ok=True) + raise + + +def manage(action: str, state: Path) -> None: + state.mkdir(parents=True, exist_ok=True, mode=0o700) + record = state / "process.json" + with (state / "control.lock").open("a+") as control: + if not acquire(control): + raise RuntimeError("Another gateway lifecycle command is in progress; retry shortly") + with (state / "process.lock").open("a+") as lifetime: + if acquire(lifetime): + record.unlink(missing_ok=True) + if action == "run": + start_gateway(state, record, lifetime) + return + print("Gateway is stopped") + return + pid, handle = identify(record, lifetime) + try: + if action != "stop": + print(f"Gateway is running (PID {pid}); log: {state / 'gateway.log'}") + return + terminate_process(handle) + if not select.select([handle], [], [], 15)[0]: + raise RuntimeError("Gateway did not stop within 15 seconds; no force-kill sent") + record.unlink(missing_ok=True) + print("Gateway stopped") + finally: + os.close(handle) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("action", choices=("run", "stop", "status")) + parser.add_argument("state", type=Path) + args = parser.parse_args() + if not hasattr(NATIVE, "pidfd_open") or not hasattr(NATIVE, "pidfd_send_signal"): + parser.exit(1, "Gateway run/stop requires Linux PID file-descriptor support\n") + os.umask(0o077) + try: + manage(args.action, args.state.resolve()) + except ValidationError: + parser.exit( + 1, "Invalid gateway configuration; check your environment/.env and auth registry\n" + ) + except (OSError, ValueError, RuntimeError, subprocess.TimeoutExpired) as error: + parser.exit(1, f"{error}\n") + + +if __name__ == "__main__": + main() diff --git a/tests/server/test_makefile.py b/tests/server/test_makefile.py new file mode 100644 index 00000000..0e6f2d04 --- /dev/null +++ b/tests/server/test_makefile.py @@ -0,0 +1,151 @@ +from __future__ import annotations + +import fcntl +import json +import os +import shutil +import socket +import subprocess +import sys +from collections.abc import Iterator +from pathlib import Path + +import httpx +import pytest + +ROOT = Path(__file__).resolve().parents[2] +pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="Linux gateway process lifecycle") + + +def invoke( + project: Path, environment: dict[str, str], target: str +) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["make", target], cwd=project, env=environment, capture_output=True, text=True, timeout=30 + ) + + +@pytest.fixture +def gateway_project(tmp_path: Path) -> Iterator[tuple[Path, dict[str, str]]]: + project = tmp_path / "project" + (project / "scripts").mkdir(parents=True) + for name in ("Makefile", "pyproject.toml", "uv.lock", "scripts/gateway_process.py"): + shutil.copyfile(ROOT / name, project / name) + (project / ".venv").symlink_to(Path(sys.executable).parent.parent, target_is_directory=True) + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + port = listener.getsockname()[1] + environment = { + "PATH": os.environ["PATH"], + "HOME": str(tmp_path), + "UV_NO_SYNC": "1", + "OPENCODE_API_VERSION": "v2", + "OPENCODE_V2_EXECUTION": "disabled", + "A2A_STATIC_AUTH_CREDENTIALS": json.dumps( + [{"scheme": "bearer", "token": "make-test-token", "principal": "test"}] + ), + "A2A_HOST": "127.0.0.1", + "A2A_PORT": str(port), + "A2A_PUBLIC_URL": f"http://127.0.0.1:{port}", + "A2A_TASK_STORE_BACKEND": "memory", + } + try: + yield project, environment + finally: + result = invoke(project, environment, "stop") + assert result.returncode == 0, result.stderr + + +def test_make_run_status_duplicate_and_stop_real_gateway(gateway_project) -> None: + project, environment = gateway_project + started = invoke(project, environment, "run") + assert started.returncode == 0, started.stderr + assert "Gateway listening" in started.stdout + state = project / "run/gateway" + original = json.loads((state / "process.json").read_text()) + + with httpx.Client(base_url=environment["A2A_PUBLIC_URL"], timeout=5) as client: + health = client.get("/health", headers={"Authorization": "Bearer make-test-token"}) + card = client.get("/.well-known/agent-card.json") + assert health.status_code == 200 + assert card.json()["capabilities"]["streaming"] is False + assert (state / "gateway.log").stat().st_mode & 0o777 == 0o600 + + duplicate = invoke(project, environment, "run") + status = invoke(project, environment, "status") + assert duplicate.returncode == 0, duplicate.stderr + assert json.loads((state / "process.json").read_text())["pid"] == original["pid"] + assert status.returncode == 0 + assert f"PID {original['pid']}" in status.stdout + + stopped = invoke(project, environment, "stop") + assert stopped.returncode == 0, stopped.stderr + assert not (state / "process.json").exists() + with socket.socket() as connection: + connection.settimeout(2) + assert connection.connect_ex(("127.0.0.1", int(environment["A2A_PORT"]))) != 0 + again = invoke(project, environment, "stop") + assert again.returncode == 0 + assert "Gateway is stopped" in again.stdout + + +def test_make_run_rejects_missing_auth_without_starting(gateway_project) -> None: + project, environment = gateway_project + environment.pop("A2A_STATIC_AUTH_CREDENTIALS") + result = invoke(project, environment, "run") + assert result.returncode != 0 + assert "Invalid gateway configuration" in result.stderr + assert not (project / "run/gateway/process.json").exists() + + +def test_make_run_refuses_occupied_port_without_stopping_listener(gateway_project) -> None: + project, environment = gateway_project + with socket.create_server(("127.0.0.1", 0)) as listener: + environment["A2A_PORT"] = str(listener.getsockname()[1]) + result = invoke(project, environment, "run") + assert result.returncode != 0 + assert not (project / "run/gateway/process.json").exists() + with socket.create_connection(listener.getsockname(), timeout=2): + connection, _ = listener.accept() + connection.close() + + +def test_make_stop_refuses_unrelated_live_pid(gateway_project) -> None: + project, environment = gateway_project + state = project / "run/gateway" + state.mkdir(parents=True) + unrelated = subprocess.Popen( + [sys.executable, "-c", "import time; time.sleep(60)"], env=environment + ) + try: + start = Path(f"/proc/{unrelated.pid}/stat").read_text().rsplit(")", 1)[1].split()[19] + boot = Path("/proc/sys/kernel/random/boot_id").read_text().strip() + (state / "process.json").write_text( + json.dumps({"pid": unrelated.pid, "start": f"{boot}:{start}", "descriptor": 9999}) + ) + with (state / "process.lock").open("a+") as lifetime: + fcntl.flock(lifetime, fcntl.LOCK_EX) + result = invoke(project, environment, "stop") + assert result.returncode != 0 + assert unrelated.poll() is None + assert (state / "process.json").exists() + finally: + unrelated.terminate() + unrelated.wait(timeout=5) + + +def test_make_clean_preserves_runtime_state_and_database(gateway_project) -> None: + project, environment = gateway_project + (project / "dist").mkdir() + artifact = project / "dist/example.whl" + artifact.touch() + state = project / "run/gateway" + state.mkdir(parents=True) + (state / "gateway.log").write_text("runtime log") + database = project / "opencode-a2a.db" + database.write_text("persistent state") + result = invoke(project, environment, "clean") + assert result.returncode == 0, result.stderr + assert not artifact.exists() + assert database.read_text() == "persistent state" + assert (state / "gateway.log").read_text() == "runtime log"