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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,9 @@ That's it. Every response is verified before you see it — check the
on the body). Streaming works too; it's verified before the first token replays.

Useful commands: `og-veil test` (send a one-off prompt to check the path),
`og-veil stop`, `og-veil status`, `og-veil env` (re-prints the env vars),
`og-veil models` (list available models), `og-veil update`, `og-veil logout`.
`og-veil stop`, `og-veil restart` (after an update), `og-veil status`,
`og-veil env` (re-prints the env vars), `og-veil models` (list available
models), `og-veil update`, `og-veil logout`.

### Use it with Hermes Agent

Expand Down Expand Up @@ -144,6 +145,7 @@ the local OpenAI-compatible server.
|---------|--------------|
| `og-veil` | Set up on first run, then serve (detached). The one command you need. |
| `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 update` | Update og-veil to the latest version. |
Expand Down
39 changes: 39 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,3 +129,42 @@ def test_update_surfaces_failure():
result = CliRunner().invoke(cli.main, ["update"])
assert result.exit_code != 0
assert "update failed" in result.output


def test_restart_stops_waits_then_starts():
with (
mock.patch("veil.daemon.stop_background", return_value=4321) as stop,
mock.patch("veil.daemon.wait_until_stopped", return_value=True) as wait,
mock.patch.object(cli, "_start_server") as start,
):
result = CliRunner().invoke(cli.main, ["restart"])
assert stop.called
assert wait.call_args.args[0] == 4321, "should wait on the stopped pid"
assert start.called and start.call_args.kwargs["foreground"] is False
assert "Stopped background server (pid 4321)" in result.output
assert result.exit_code == 0


def test_restart_starts_fresh_when_nothing_running():
with (
mock.patch("veil.daemon.stop_background", return_value=None),
mock.patch("veil.daemon.wait_until_stopped") as wait,
mock.patch.object(cli, "_start_server") as start,
):
result = CliRunner().invoke(cli.main, ["restart"])
assert not wait.called, "no running server → nothing to wait for"
assert start.called
assert "No background server was running" in result.output
assert result.exit_code == 0


def test_restart_errors_if_old_process_lingers():
with (
mock.patch("veil.daemon.stop_background", return_value=99),
mock.patch("veil.daemon.wait_until_stopped", return_value=False),
mock.patch.object(cli, "_start_server") as start,
):
result = CliRunner().invoke(cli.main, ["restart"])
assert not start.called, "must not start a new server while the old one lingers"
assert result.exit_code != 0
assert "did not exit" in result.output
17 changes: 17 additions & 0 deletions tests/test_daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,20 @@ def test_stop_command(home):
result = CliRunner().invoke(cli.main, ["stop"])
assert "4321" in result.output
assert result.exit_code == 0


def test_wait_until_stopped_returns_true_once_process_gone():
# Alive on the first poll, gone on the second.
with (
mock.patch("os.kill", side_effect=[None, OSError]),
mock.patch("time.sleep"),
):
assert daemon.wait_until_stopped(4321, timeout=1.0, interval=0.0) is True


def test_wait_until_stopped_times_out_if_process_lingers():
with (
mock.patch("os.kill"), # always alive
mock.patch("time.sleep"),
):
assert daemon.wait_until_stopped(4321, timeout=0.0) is False
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

62 changes: 59 additions & 3 deletions veil/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

The common path is a single command: run ``og-veil`` and it logs you in on first
use, then starts the local server in the background. Individual steps (``serve``,
``login``, ``stop``, ``status``, ``env``, ``models``, ``test``, ``update``,
``logout``) are available on their own too.
``login``, ``stop``, ``restart``, ``status``, ``env``, ``models``, ``test``,
``update``, ``logout``) are available on their own too.
"""

from __future__ import annotations
Expand Down Expand Up @@ -191,6 +191,62 @@ def stop() -> None:
click.secho(f"✓ Stopped background server (pid {pid}).", fg="green")


@main.command()
@click.option("--host", default=None, help="Bind host (default 127.0.0.1 / OG_VEIL_HOST).")
@click.option("--port", type=int, default=None, help="Bind port (default 11434 / OG_VEIL_PORT).")
@click.option("--tee-id", default=None, help="Pin a specific tee_id from the registry.")
@click.option(
"--expected-pcr", default=None, help="Refuse any TEE whose registry pcrHash differs from this."
)
@click.option(
"--pii-scrub",
is_flag=True,
default=False,
help="Redact high-impact PII (email, SSN, bank numbers; addresses with the [pii] extra) "
"from prompts locally before they leave this machine.",
)
def restart(
host: str | None,
port: int | None,
tee_id: str | None,
expected_pcr: str | None,
pii_scrub: bool,
) -> None:
"""Stop the background server (if running) and start it again.

Handy after ``og-veil update`` to load the new version. Login is reused, so
this never prompts. Flags work the same as ``og-veil serve``; without them
the server restarts with its environment-based config.
"""
from veil.daemon import stop_background, wait_until_stopped

pid = stop_background()
if pid is None:
click.echo("No background server was running — starting a fresh one.")
else:
click.secho(f"✓ Stopped background server (pid {pid}).", fg="green")
if not wait_until_stopped(pid):
raise click.ClickException(
f"the previous server (pid {pid}) did not exit — try `og-veil stop` again"
)

config = ServerConfig.from_env()
if host:
config.host = host
if port:
config.port = port
if tee_id:
config.pinned_tee_id = tee_id if tee_id.startswith("0x") else "0x" + tee_id
if expected_pcr:
config.expected_pcr_hash = (
expected_pcr if expected_pcr.startswith("0x") else "0x" + expected_pcr
).lower()
if pii_scrub:
config.pii_scrub = True

_start_server(config, foreground=False)


@main.command(name="env")
def env_cmd() -> None:
"""Print the env vars to point your agent at OpenGradient Veil."""
Expand Down Expand Up @@ -338,7 +394,7 @@ def update() -> None:
raise click.ClickException(
f"update failed: {exc}\nTry manually, e.g.: uv tool upgrade opengradient-veil"
)
click.secho("✓ Updated. Restart the server to pick it up: og-veil stop && og-veil", fg="green")
click.secho("✓ Updated. Restart the server to pick it up: og-veil restart", fg="green")


@main.command()
Expand Down
26 changes: 26 additions & 0 deletions veil/daemon.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,29 @@ def stop_background() -> int | None:
pass
pid_path().unlink(missing_ok=True)
return pid


def _pid_alive(pid: int) -> bool:
try:
os.kill(pid, 0) # signal 0 just checks the process exists
except OSError:
return False
return True


def wait_until_stopped(pid: int, timeout: float = 5.0, interval: float = 0.1) -> bool:
"""Block until ``pid`` has exited, or ``timeout`` seconds elapse.

Returns True once the process is gone, False if it was still alive at the
deadline. Used by ``og-veil restart`` so the new server doesn't collide with
the old one still occupying the same port. The pidfile is already gone by
this point, so we poll the process directly.
"""
import time

deadline = time.monotonic() + timeout
while _pid_alive(pid):
if time.monotonic() >= deadline:
return False
time.sleep(interval)
return True
Loading