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
44 changes: 44 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
1 change: 1 addition & 0 deletions scripts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
182 changes: 182 additions & 0 deletions scripts/gateway_process.py
Original file line number Diff line number Diff line change
@@ -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()
Loading
Loading