From 4ea3a05884c2adfe7966959089f23b39b2b2f9d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 14:12:46 +0000 Subject: [PATCH] Auto-discover the running server's port for test/env/status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local server could be started on a non-default port (`serve --port`, `OG_VEIL_PORT`), but `test`, `env`, and `status` always fell back to `ServerConfig.from_env()` and assumed 11434 — so `og-veil test` hit the wrong port and 404'd unless the caller re-exported OG_VEIL_PORT in the same shell and restarted everything. Persist the config the background server actually started with to `server.json` (mirroring the pidfile lifecycle), and have the client commands read it back: - config: `runtime_path()` + `ServerConfig.to_dict()`/`from_dict()`. - daemon: `save_runtime`/`load_runtime`/`clear_runtime`; `load_runtime` returns the recorded config only while a server is actually running, and the recorded config is cleared on stop and when a stale pidfile is reaped (so a server that died on a port conflict can't mislead later commands). - cli: save the runtime config on a successful background start; `test`, `env`, and `status` resolve the host/port from the running server. `status` now also prints the live Base URL. - cli: add `--host`/`--port` overrides to `test` for pointing at a specific server, and clarify the help text. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01U1jJ45sjA3o5KtBR4b2TM7 --- README.md | 2 +- tests/test_cli.py | 43 ++++++++++++++++++++++++++++++++++++++ tests/test_daemon.py | 33 +++++++++++++++++++++++++++++ uv.lock | 2 +- veil/cli.py | 49 ++++++++++++++++++++++++++++++++++++++------ veil/config.py | 29 +++++++++++++++++++++++++- veil/daemon.py | 30 ++++++++++++++++++++++++++- 7 files changed, 178 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 871b9ee..0e23808 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ the local OpenAI-compatible server. | `og-veil stop` | Stop the background server. | | `og-veil restart` | Stop and start the background server — e.g. after `og-veil update`. | | `og-veil status` | Login + network config + whether the server is running. | -| `og-veil test ["prompt"]` | Send a one-off prompt to the running server and print the verified reply. | +| `og-veil test ["prompt"]` | Send a one-off prompt to the running server and print the verified reply. Automatically targets the port the server was started on; override with `--host`/`--port`. | | `og-veil update` | Update og-veil to the latest version. | | `og-veil login` | Authorize / re-authorize this device. | | `og-veil setup` | Re-run the setup wizard. | diff --git a/tests/test_cli.py b/tests/test_cli.py index d842b41..8f15923 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -96,6 +96,49 @@ def test_test_command_posts_prompt_to_localhost_and_prints_reply(): assert "0xabc" in out.output +def test_test_command_targets_running_servers_saved_port(): + from veil.config import ServerConfig + + resp = mock.MagicMock(status_code=200, headers={}) + resp.json.return_value = {"choices": [{"message": {"content": "hi"}}]} + # Server was started on a non-default port; `test` should find it without + # the caller re-specifying anything. + with ( + mock.patch("veil.daemon.load_runtime", return_value=ServerConfig(port=11435)), + mock.patch("requests.post", return_value=resp) as post, + ): + out = CliRunner().invoke(cli.main, ["test", "hi"]) + assert out.exit_code == 0 + assert post.call_args.args[0] == "http://127.0.0.1:11435/v1/chat/completions" + + +def test_test_command_port_flag_overrides(): + from veil.config import ServerConfig + + resp = mock.MagicMock(status_code=200, headers={}) + resp.json.return_value = {"choices": [{"message": {"content": "hi"}}]} + with ( + mock.patch("veil.daemon.load_runtime", return_value=ServerConfig(port=11435)), + mock.patch("requests.post", return_value=resp) as post, + ): + out = CliRunner().invoke(cli.main, ["test", "--port", "11500", "hi"]) + assert out.exit_code == 0 + # Explicit --port wins over the running server's recorded port. + assert post.call_args.args[0] == "http://127.0.0.1:11500/v1/chat/completions" + + +def test_start_server_records_runtime_config(): + from veil.config import ServerConfig + + with ( + mock.patch("veil.daemon.start_background", return_value=4321), + mock.patch("veil.daemon.save_runtime") as save, + ): + cli._start_server(ServerConfig(port=11435), foreground=False) + assert save.called + assert save.call_args.args[0].port == 11435 + + def test_test_command_reports_server_not_running(): import requests diff --git a/tests/test_daemon.py b/tests/test_daemon.py index a4f0e72..0a151c2 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -35,9 +35,42 @@ def test_start_writes_pidfile_and_stop_removes_it(home): def test_running_pid_clears_stale_pidfile(home): daemon.pid_path().write_text("999999") + daemon.runtime_path().write_text('{"port": 11500}') with mock.patch("os.kill", side_effect=OSError): # process not found assert daemon.running_pid() is None assert not daemon.pid_path().exists() + # A dead server's recorded config must not linger and mislead `test`/`env`. + assert not daemon.runtime_path().exists() + + +def test_save_and_load_runtime_roundtrip(home): + from veil.config import ServerConfig + + daemon.save_runtime(ServerConfig(port=11500, pii_scrub=True)) + daemon.pid_path().write_text("4321") + with mock.patch("os.kill"): # pretend 4321 is alive + loaded = daemon.load_runtime() + assert loaded is not None + assert loaded.port == 11500 + assert loaded.pii_scrub is True + + +def test_load_runtime_returns_none_when_not_running(home): + from veil.config import ServerConfig + + daemon.save_runtime(ServerConfig(port=11500)) + # No pidfile → no running server → callers should fall back to env/defaults. + assert daemon.load_runtime() is None + + +def test_stop_clears_recorded_runtime(home): + from veil.config import ServerConfig + + daemon.save_runtime(ServerConfig(port=11500)) + daemon.pid_path().write_text("4321") + with mock.patch("os.kill"): # alive check + SIGTERM both succeed + assert daemon.stop_background() == 4321 + assert not daemon.runtime_path().exists() def test_start_refuses_when_already_running(home): diff --git a/uv.lock b/uv.lock index 5af5be1..ec2cc1b 100644 --- a/uv.lock +++ b/uv.lock @@ -2329,7 +2329,7 @@ wheels = [ [[package]] name = "opengradient-veil" -version = "0.2.7" +version = "0.2.8" source = { editable = "." } dependencies = [ { name = "click" }, diff --git a/veil/cli.py b/veil/cli.py index 5aec652..822e26e 100644 --- a/veil/cli.py +++ b/veil/cli.py @@ -142,6 +142,18 @@ def serve( _start_server(config, foreground=foreground or skip_setup) +def _client_config() -> ServerConfig: + """Config for commands that talk to the running server (``test``, ``env``). + + Prefers the config the background server was actually started with — so a + server launched on a non-default port is reached automatically — and falls + back to the environment/defaults when nothing is running. + """ + from veil.daemon import load_runtime + + return load_runtime() or ServerConfig.from_env() + + def _config_flags(config: ServerConfig) -> list[str]: flags = ["--host", config.host, "--port", str(config.port)] if config.pinned_tee_id: @@ -161,7 +173,7 @@ def _start_server(config: ServerConfig, *, foreground: bool) -> None: run_server(config) return - from veil.daemon import log_path, running_pid, start_background + from veil.daemon import log_path, running_pid, save_runtime, start_background try: pid = start_background(_config_flags(config)) @@ -173,6 +185,9 @@ def _start_server(config: ServerConfig, *, foreground: bool) -> None: click.echo(" Stop it with: og-veil stop") return raise click.ClickException(str(exc)) + # Record where the server is listening so `test`/`env`/`status` find it + # without the caller having to re-specify the port. + save_runtime(config) click.secho(f"✓ OpenGradient Veil running in the background (pid {pid}).", fg="green") click.echo(f" Base URL : {config.advertised_base_url()}") click.echo(f" Logs : {log_path()}") @@ -252,7 +267,7 @@ def env_cmd() -> None: """Print the env vars to point your agent at OpenGradient Veil.""" from veil.daemon import running_pid - config = ServerConfig.from_env() + config = _client_config() click.echo("Point your agent at OpenGradient Veil (one env var change):") click.secho(f" export OPENAI_BASE_URL={config.advertised_base_url()}", bold=True) click.echo(" export OPENAI_API_KEY=og-veil # ignored; your Chat session authenticates") @@ -301,18 +316,35 @@ def login_cmd(app_url: str, no_browser: bool, manual: bool) -> None: @main.command(name="test") @click.argument("prompt", nargs=-1) @click.option("--model", default="gpt-4.1", show_default=True, help="Model to send the prompt to.") -def test_cmd(prompt: tuple[str, ...], model: str) -> None: +@click.option( + "--host", default=None, help="Override the host to reach (default: the running server's)." +) +@click.option( + "--port", + type=int, + default=None, + help="Override the port to reach (default: the running server's).", +) +def test_cmd(prompt: tuple[str, ...], model: str, host: str | None, port: int | None) -> None: """Send a one-off PROMPT to the running local server and print the reply. Posts to the localhost OpenAI-compatible endpoint — the same one your agent uses — so the background server must already be running (start it with ``og-veil``). The reply is verified TEE output. + + By default this targets whatever host/port the running server was started on + (e.g. ``og-veil serve --port 11435``), so you don't have to remember it. Pass + ``--host``/``--port`` to point at a specific server instead. """ import requests text = " ".join(prompt).strip() or "Say hello from a verified TEE in one short sentence." - config = ServerConfig.from_env() + config = _client_config() + if host: + config.host = host + if port: + config.port = port base_url = config.advertised_base_url() body = {"model": model, "messages": [{"role": "user", "content": text}]} try: @@ -352,7 +384,7 @@ def status() -> None: session = Session.load() except AuthError as exc: raise click.ClickException(str(exc)) - from veil.daemon import running_pid + from veil.daemon import load_runtime, running_pid from veil import __version__ @@ -365,7 +397,12 @@ def status() -> None: f"TEE registry : {cfg.tee_registry_address or '(none)'} @ {cfg.tee_registry_rpc_url or '(none)'}" ) pid = running_pid() - click.echo(f"Background : running (pid {pid})" if pid else "Background : not running") + if pid: + running = load_runtime() or ServerConfig.from_env() + click.echo(f"Background : running (pid {pid})") + click.echo(f"Base URL : {running.advertised_base_url()}") + else: + click.echo("Background : not running") def _update_command() -> list[str]: diff --git a/veil/config.py b/veil/config.py index 56c3f72..7dcd460 100644 --- a/veil/config.py +++ b/veil/config.py @@ -9,7 +9,7 @@ from __future__ import annotations import os -from dataclasses import dataclass +from dataclasses import dataclass, fields from pathlib import Path # Relay endpoint on the chat-api (the OpenGradient OHTTP relay). The relay holds @@ -38,6 +38,16 @@ def session_path() -> Path: return config_home() / "session.json" +def runtime_path() -> Path: + """File recording the config the background server actually started with. + + Written when the server is launched and removed when it stops, so client + commands (``test``, ``env``, ``status``) can discover the host/port the + server is really listening on instead of guessing the default. + """ + return config_home() / "server.json" + + @dataclass class ServerConfig: """Settings for the local OpenAI-compatible server.""" @@ -84,6 +94,23 @@ def advertised_base_url(self) -> str: """The ``/v1`` base URL to tell agents to use.""" return f"http://{self.host}:{self.port}/v1" + def to_dict(self) -> dict: + """Serializable view used to persist the running server's config.""" + return { + "host": self.host, + "port": self.port, + "expected_pcr_hash": self.expected_pcr_hash, + "pinned_tee_id": self.pinned_tee_id, + "pii_scrub": self.pii_scrub, + "tee_refresh_interval": self.tee_refresh_interval, + } + + @classmethod + def from_dict(cls, data: dict) -> "ServerConfig": + """Rebuild from :meth:`to_dict`, ignoring unknown/missing keys.""" + known = {f.name for f in fields(cls)} + return cls(**{k: v for k, v in data.items() if k in known}) + def _env_bool(value: str | None) -> bool: return value is not None and value.strip().lower() in ("1", "true", "yes", "on") diff --git a/veil/daemon.py b/veil/daemon.py index ca681d3..25d9f4b 100644 --- a/veil/daemon.py +++ b/veil/daemon.py @@ -8,13 +8,14 @@ from __future__ import annotations +import json import os import signal import subprocess import sys from pathlib import Path -from veil.config import config_home +from veil.config import ServerConfig, config_home, runtime_path def pid_path() -> Path: @@ -38,10 +39,36 @@ def running_pid() -> int | None: os.kill(pid, 0) # signal 0 just checks the process exists except OSError: path.unlink(missing_ok=True) # stale pidfile + clear_runtime() # and the config it recorded return None return pid +def save_runtime(config: ServerConfig) -> None: + """Record the config the background server was started with.""" + runtime_path().write_text(json.dumps(config.to_dict())) + + +def clear_runtime() -> None: + runtime_path().unlink(missing_ok=True) + + +def load_runtime() -> ServerConfig | None: + """Config of the running background server, or None if none is running. + + Lets ``test``/``env``/``status`` point at whatever host/port the server was + actually started on instead of assuming the default. Returns None when no + server is running so callers fall back to the environment/defaults. + """ + if running_pid() is None: + return None + try: + data = json.loads(runtime_path().read_text()) + except (OSError, ValueError): + return None + return ServerConfig.from_dict(data) + + def start_background(serve_flags: list[str]) -> int: """Launch ``og-veil serve --skip-setup `` detached; return its pid. @@ -75,6 +102,7 @@ def stop_background() -> int | None: except OSError: pass pid_path().unlink(missing_ok=True) + clear_runtime() return pid