Skip to content

Commit 68e5fc8

Browse files
rustyconoverclaude
andcommitted
Add VGI_SIGNING_KEY and VGI_ENABLE_DESCRIBE env var support
VGI_SIGNING_KEY provides a shared signing key for state tokens in multi-process deployments (suppresses random key warning). VGI_ENABLE_DESCRIBE overrides --describe/--no-describe from the environment (accepts 1/true/yes and 0/false/no). Both env vars work in vgi-serve CLI and Worker.main()/main_http() paths. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ab596ff commit 68e5fc8

3 files changed

Lines changed: 175 additions & 3 deletions

File tree

‎tests/test_serve.py‎

Lines changed: 133 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import pytest
1414

1515
from vgi.scalar_function import ScalarFunction
16-
from vgi.serve import create_app, load_worker_class
16+
from vgi.serve import _resolve_describe, _resolve_signing_key, create_app, load_worker_class
1717
from vgi.worker import Worker
1818

1919
# ---------------------------------------------------------------------------
@@ -198,6 +198,64 @@ def test_describe_disabled(self) -> None:
198198

199199
assert isinstance(app, falcon.App)
200200

201+
def test_signing_key_passed(self) -> None:
202+
"""Explicit signing_key is accepted without warning."""
203+
import warnings
204+
205+
with warnings.catch_warnings():
206+
warnings.simplefilter("error", UserWarning)
207+
app = create_app(_SingleWorker, signing_key=b"test-secret-key-1234")
208+
209+
import falcon
210+
211+
assert isinstance(app, falcon.App)
212+
213+
214+
# ---------------------------------------------------------------------------
215+
# Tests: env var helpers
216+
# ---------------------------------------------------------------------------
217+
218+
219+
class TestResolveSigningKey:
220+
"""Tests for _resolve_signing_key()."""
221+
222+
def test_unset_returns_none(self, monkeypatch: pytest.MonkeyPatch) -> None:
223+
"""No env var returns None."""
224+
monkeypatch.delenv("VGI_SIGNING_KEY", raising=False)
225+
assert _resolve_signing_key() is None
226+
227+
def test_empty_returns_none(self, monkeypatch: pytest.MonkeyPatch) -> None:
228+
"""Empty string returns None."""
229+
monkeypatch.setenv("VGI_SIGNING_KEY", "")
230+
assert _resolve_signing_key() is None
231+
232+
def test_value_returns_bytes(self, monkeypatch: pytest.MonkeyPatch) -> None:
233+
"""Non-empty value returns UTF-8 encoded bytes."""
234+
monkeypatch.setenv("VGI_SIGNING_KEY", "my-secret")
235+
assert _resolve_signing_key() == b"my-secret"
236+
237+
238+
class TestResolveDescribe:
239+
"""Tests for _resolve_describe()."""
240+
241+
def test_unset_uses_cli(self, monkeypatch: pytest.MonkeyPatch) -> None:
242+
"""No env var passes through CLI value."""
243+
monkeypatch.delenv("VGI_ENABLE_DESCRIBE", raising=False)
244+
assert _resolve_describe(True) is True
245+
assert _resolve_describe(False) is False
246+
247+
def test_env_true_overrides(self, monkeypatch: pytest.MonkeyPatch) -> None:
248+
"""Truthy env values enable describe regardless of CLI."""
249+
for val in ("1", "true", "yes", "True", "YES"):
250+
monkeypatch.setenv("VGI_ENABLE_DESCRIBE", val)
251+
assert _resolve_describe(False) is True
252+
253+
def test_env_false_overrides(self, monkeypatch: pytest.MonkeyPatch) -> None:
254+
"""Falsy env values disable describe regardless of CLI."""
255+
for val in ("0", "false", "no", "False", "NO"):
256+
monkeypatch.setenv("VGI_ENABLE_DESCRIBE", val)
257+
assert _resolve_describe(True) is False
258+
201259

202260
# ---------------------------------------------------------------------------
203261
# Tests: CLI integration
@@ -319,3 +377,77 @@ def test_auto_discover_module(self) -> None:
319377
finally:
320378
proc.terminate()
321379
proc.wait(timeout=5)
380+
381+
def test_describe_env_var_disables_worker_page(self) -> None:
382+
"""VGI_ENABLE_DESCRIBE=0 disables the worker description page."""
383+
import urllib.error
384+
import urllib.request
385+
386+
port = _free_port()
387+
env = os.environ.copy()
388+
env["VGI_ENABLE_DESCRIBE"] = "0"
389+
proc = subprocess.Popen(
390+
[
391+
sys.executable,
392+
"-m",
393+
"vgi.serve",
394+
"vgi.examples.worker:ExampleWorker",
395+
"--http",
396+
"--host",
397+
"127.0.0.1",
398+
"--port",
399+
str(port),
400+
],
401+
stdout=subprocess.PIPE,
402+
stderr=subprocess.PIPE,
403+
text=True,
404+
env=env,
405+
)
406+
try:
407+
assert proc.stdout is not None
408+
port_line = proc.stdout.readline()
409+
assert port_line.strip() == f"PORT:{port}"
410+
411+
time.sleep(0.5)
412+
413+
# Worker page should not be served (404 or 405, not 200)
414+
url = f"http://127.0.0.1:{port}/vgi/worker"
415+
with pytest.raises(urllib.error.HTTPError) as exc_info:
416+
urllib.request.urlopen(url, timeout=5)
417+
assert exc_info.value.code in (404, 405)
418+
finally:
419+
proc.terminate()
420+
proc.wait(timeout=5)
421+
422+
def test_signing_key_env_var(self) -> None:
423+
"""VGI_SIGNING_KEY suppresses the random key warning."""
424+
port = _free_port()
425+
env = os.environ.copy()
426+
env["VGI_SIGNING_KEY"] = "test-key-for-ci"
427+
proc = subprocess.Popen(
428+
[
429+
sys.executable,
430+
"-m",
431+
"vgi.serve",
432+
"vgi.examples.worker:ExampleWorker",
433+
"--http",
434+
"--host",
435+
"127.0.0.1",
436+
"--port",
437+
str(port),
438+
],
439+
stdout=subprocess.PIPE,
440+
stderr=subprocess.PIPE,
441+
text=True,
442+
env=env,
443+
)
444+
try:
445+
assert proc.stdout is not None
446+
port_line = proc.stdout.readline()
447+
assert port_line.strip() == f"PORT:{port}"
448+
finally:
449+
proc.terminate()
450+
proc.wait(timeout=5)
451+
assert proc.stderr is not None
452+
stderr = proc.stderr.read()
453+
assert "No signing_key provided" not in stderr

‎vgi/serve.py‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ def create_app(
154154
prefix: str = "/vgi",
155155
cors_origins: str = "*",
156156
describe: bool = True,
157+
signing_key: bytes | None = None,
157158
log_level: int = logging.INFO,
158159
) -> falcon.App[Any, Any]:
159160
"""Create a WSGI app for a VGI worker.
@@ -166,6 +167,10 @@ def create_app(
166167
prefix: URL prefix for RPC endpoints.
167168
cors_origins: Allowed CORS origins.
168169
describe: Enable worker + API description pages.
170+
signing_key: Shared signing key for state tokens. When ``None``,
171+
a random per-process key is generated (tokens are invalid
172+
across workers). Set via ``VGI_SIGNING_KEY`` env var or
173+
pass explicitly for multi-process deployments.
169174
log_level: Logging level for the worker instance.
170175
171176
Returns:
@@ -186,7 +191,7 @@ def create_app(
186191

187192
worker = worker_cls(quiet=True, log_level=log_level)
188193
server = RpcServer(VgiProtocol, worker, enable_describe=describe)
189-
wsgi_app = make_wsgi_app(server, prefix=prefix, cors_origins=cors_origins)
194+
wsgi_app = make_wsgi_app(server, prefix=prefix, cors_origins=cors_origins, signing_key=signing_key)
190195

191196
if describe:
192197
from vgi.http.worker_page import WorkerPageResource, build_worker_page
@@ -241,6 +246,10 @@ def serve(
241246
log_format=log_format,
242247
)
243248

249+
# Resolve env var overrides
250+
describe = _resolve_describe(describe)
251+
signing_key = _resolve_signing_key()
252+
244253
worker_cls = load_worker_class(worker_ref)
245254

246255
if http:
@@ -252,13 +261,38 @@ def serve(
252261
prefix=prefix,
253262
cors_origins=cors_origins,
254263
describe=describe,
264+
signing_key=signing_key,
255265
)
256266
else:
257267
worker_cls(quiet=quiet, log_level=effective_level).run()
258268

259269
app()
260270

261271

272+
def _resolve_signing_key() -> bytes | None:
273+
"""Read ``VGI_SIGNING_KEY`` from the environment."""
274+
raw = os.environ.get("VGI_SIGNING_KEY")
275+
if raw:
276+
return raw.encode()
277+
return None
278+
279+
280+
def _resolve_describe(cli_value: bool) -> bool:
281+
"""Apply ``VGI_ENABLE_DESCRIBE`` env var override.
282+
283+
The env var only takes effect when it is explicitly set. Accepts
284+
``1``/``true``/``yes`` (enable) and ``0``/``false``/``no`` (disable),
285+
case-insensitive. The CLI flag (``--describe`` / ``--no-describe``)
286+
wins when Typer reports a non-default value, but since we cannot
287+
distinguish "user passed --describe" from "default True", the env var
288+
always overrides when present.
289+
"""
290+
raw = os.environ.get("VGI_ENABLE_DESCRIBE")
291+
if raw is None:
292+
return cli_value
293+
return raw.lower() in ("1", "true", "yes")
294+
295+
262296
def _serve_http(
263297
worker_cls: type[Worker],
264298
*,
@@ -268,6 +302,7 @@ def _serve_http(
268302
prefix: str,
269303
cors_origins: str,
270304
describe: bool,
305+
signing_key: bytes | None,
271306
) -> None:
272307
"""Start the worker as an HTTP server."""
273308
import socket
@@ -295,6 +330,7 @@ def _serve_http(
295330
prefix=prefix,
296331
cors_origins=cors_origins,
297332
describe=describe,
333+
signing_key=signing_key,
298334
log_level=effective_level,
299335
)
300336

‎vgi/worker.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -547,13 +547,17 @@ def _run_http(
547547
s.bind((host, 0))
548548
port = int(s.getsockname()[1])
549549

550-
from vgi.serve import create_app
550+
from vgi.serve import _resolve_describe, _resolve_signing_key, create_app
551+
552+
describe = _resolve_describe(describe)
553+
signing_key = _resolve_signing_key()
551554

552555
wsgi_app = create_app(
553556
cls,
554557
prefix=prefix,
555558
cors_origins=cors_origins,
556559
describe=describe,
560+
signing_key=signing_key,
557561
log_level=effective_level,
558562
)
559563

0 commit comments

Comments
 (0)