mirror of
https://github.com/Colton-z/AstraBox.git
synced 2026-09-28 14:13:16 +08:00
Strengthen sandbox isolation and authentication, make all five engines work through the bundled installer, and preserve conversations across sandbox and service restarts. Add team login and single-container deployment, with upgrade instructions for replacing existing 0.1.0 sandboxes. Co-Authored-By: Claude <noreply@anthropic.com>
819 lines
36 KiB
Python
819 lines
36 KiB
Python
"""The env-var registry must stay a complete, fresh census of ``astrabox/``.
|
|
|
|
Five independent guarantees, each pinned by its own test below:
|
|
|
|
1. **Completeness** — an AST-based scanner walks every ``.py`` file under
|
|
``astrabox/`` and finds every environment-variable READ: ``os.getenv`` /
|
|
``os.environ[...]`` / ``os.environ.get`` with a literal name, the
|
|
``_env`` / ``_strict_flag`` wrappers in ``providers/identity_sso.py``
|
|
(first arg), and the ``AstraBoxSettings`` + ``AstraBoxRuntimeSettings``
|
|
pydantic fields (a framework-magic read no ``os.getenv``-shaped AST node can
|
|
see — ``common/utils/settings.py``'s wide operational surface is now these
|
|
fields' ``AliasChoices`` — so it is resolved by introspecting the live classes
|
|
instead). Every name the scanner finds must have
|
|
a row in :data:`astrabox.config.env_registry.ENV_REGISTRY`
|
|
(``test_every_scanned_env_read_has_a_registry_entry``) — a new env read with
|
|
no row fails loud instead of silently going undocumented.
|
|
2. **Freshness (no stale rows)** — every registry row must match a name the
|
|
AST scanner OR the embedded-script census below still finds
|
|
(``test_registry_entries_are_not_stale``) — a renamed or deleted read site
|
|
leaves its old row behind otherwise.
|
|
3. **Docs freshness** — ``docs/configuration.md`` must be exactly what
|
|
``scripts/gen_config_docs.py`` renders from the CURRENT registry
|
|
(``test_configuration_docs_are_generated_freshly``) — a registry edit with no
|
|
regenerate drifts the committed doc from its source of truth otherwise.
|
|
4. **Embedded-script completeness** — the AST scanner above only sees env reads
|
|
in astrabox/'s own parseable Python source; a name read inside a SCRIPT
|
|
STRING LITERAL shipped into the sandbox (a bash heredoc, or a Python shim
|
|
rendered as an f-string) never appears as an ``os.getenv``-shaped AST node,
|
|
so it is invisible to guarantee 1 above. ``test_embedded_script_env_reads_have_a_registry_entry``
|
|
regex-extracts the literal env names present in each enumerated script
|
|
producer and asserts that every name has a registry row —
|
|
see the "Embedded-script census" section below.
|
|
5. **Quickstart integrity** — every setting in ``.env.example`` either matches
|
|
its registry default or has one explicit expected value and reason below;
|
|
Compose-only settings have the same explicit treatment. This keeps the
|
|
hand-picked quickstart useful without letting its values silently drift from
|
|
the registry (``test_env_example_values_are_defaults_or_documented_examples``).
|
|
|
|
A handful of env reads are genuinely dynamic — the argument is not a literal and
|
|
cannot be resolved to one by the constant-folding this scanner does (see
|
|
``_resolve_name`` / ``ModuleScanner.visit_For``) — but ARE still literal
|
|
composition (module-level ``NAME = "LITERAL"`` constants, and small ``for`` loops
|
|
over a literal tuple of names). Those are resolved, not exempted. Exactly one
|
|
site is truly dynamic — ``astrabox/secrets.py``'s
|
|
``SecretProvider.get_secret``, which reads an operator-CHOSEN name
|
|
(``secret_name.upper().replace("-", "_")``) — and it is the one entry in
|
|
``_ALLOWED_UNRESOLVED`` below, documented in the registry as the
|
|
``SECRET_NAME_PATTERN_ROW`` pattern instead of a literal row. A NEW dynamic site
|
|
the scanner cannot resolve fails ``test_no_new_unresolved_dynamic_env_reads``
|
|
loudly rather than silently vanishing from the census.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import importlib.util
|
|
import re
|
|
import sys
|
|
from dataclasses import dataclass, field
|
|
from functools import lru_cache
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
_ASTRABOX_ROOT = _REPO_ROOT / "astrabox"
|
|
|
|
from astrabox.config.env_registry import ENV_REGISTRY, concrete_names # noqa: E402
|
|
from astrabox.providers.sandbox_image import IN_BOX_SIDECAR_PORT # noqa: E402
|
|
|
|
_ENV_EXAMPLE_NONDEFAULT_VALUES: dict[str, tuple[str, str]] = {
|
|
"ANTHROPIC_API_KEY": (
|
|
"sk-your-key-here",
|
|
"A fill-in placeholder makes the one required operator action visible.",
|
|
),
|
|
"ASTRABOX_AGENT_PREWARM_REDIS_URL": (
|
|
"redis://127.0.0.1:56379/0",
|
|
"The commented example shows an explicit client-pool coordination URL; "
|
|
"the runtime default is unset and Compose supplies its internal URL.",
|
|
),
|
|
"ASTRABOX_AWS_KMS_KEY_ARN": (
|
|
"arn:aws:kms:us-west-2:123456789012:key/00000000-0000-0000-0000-000000000000",
|
|
"The KMS example uses a structurally valid placeholder key ARN.",
|
|
),
|
|
"ASTRABOX_DB_URL": (
|
|
"postgresql+asyncpg://astrabox:change-me@db.example.com:5432/astrabox",
|
|
"The quickstart shows the shape required for an external PostgreSQL database.",
|
|
),
|
|
"ASTRABOX_EXTENSION_PROVIDER": (
|
|
"litellm",
|
|
"The optional provider example names the bundled implementation explicitly.",
|
|
),
|
|
"ASTRABOX_LITELLM_API_KEY": (
|
|
"sk-astrabox-local",
|
|
"The external-gateway example needs a recognizable placeholder credential.",
|
|
),
|
|
"ASTRABOX_LITELLM_BASE_URL": (
|
|
"https://gateway.example.com",
|
|
"The optional external-gateway example needs a sandbox-reachable URL.",
|
|
),
|
|
"ASTRABOX_LITELLM_SERVER_BASE_URL": (
|
|
"http://127.0.0.1:4000",
|
|
"The split-network example shows a distinct server-side gateway URL.",
|
|
),
|
|
"ASTRABOX_LOCAL_MODE": (
|
|
"1",
|
|
"The commented example demonstrates how to enable this opt-in mode.",
|
|
),
|
|
"ASTRABOX_MCP_PROXY_BASE_URL": (
|
|
"https://sandbox-callback.example.com",
|
|
"The external callback-route example needs a concrete URL shape.",
|
|
),
|
|
"ASTRABOX_MODEL_ENDPOINT_PROVIDER": (
|
|
"litellm",
|
|
"The optional gateway example demonstrates explicit provider selection.",
|
|
),
|
|
"ASTRABOX_MODEL_GATEWAY_REQUIRE_HTTPS": (
|
|
"1",
|
|
"The team-gateway example demonstrates opting into the HTTPS requirement.",
|
|
),
|
|
"ASTRABOX_PUBLISH_HOST_IP": (
|
|
"172.17.0.1",
|
|
"The maintained Compose stack runs AstraBox in a container and uses the "
|
|
"Docker bridge gateway instead of the host-process default.",
|
|
),
|
|
"ASTRABOX_SANDBOX_CREDENTIAL_VAULT": (
|
|
"0",
|
|
"The commented example demonstrates explicitly disabling the default vault.",
|
|
),
|
|
"ASTRABOX_SANDBOX_ENDPOINT_SCHEME": (
|
|
"https",
|
|
"The Kubernetes ingress example uses TLS rather than the unset default.",
|
|
),
|
|
"ASTRABOX_SANDBOX_SECURE_ACCESS": (
|
|
"true",
|
|
"The Kubernetes ingress example demonstrates enabling signed links.",
|
|
),
|
|
"ASTRABOX_SANDBOX_SERVER_INGRESS_GATEWAY_ADDRESS": (
|
|
"sandbox.example.com",
|
|
"The Kubernetes ingress example needs a concrete public-hostname shape.",
|
|
),
|
|
"ASTRABOX_SANDBOX_SERVER_INGRESS_MODE": (
|
|
"gateway",
|
|
"The Kubernetes ingress example selects the non-default gateway mode.",
|
|
),
|
|
"ASTRABOX_SANDBOX_SERVER_PORT_RANGE": (
|
|
"20000-21000",
|
|
"The firewall example narrows the default range to a span a policy can name.",
|
|
),
|
|
"ASTRABOX_SANDBOX_SERVER_RUNTIME": (
|
|
"kubernetes",
|
|
"The Kubernetes deployment example selects the non-default runtime.",
|
|
),
|
|
"ASTRABOX_SECRET_STORE": (
|
|
"aws_kms",
|
|
"The multi-replica example selects the non-default KMS provider.",
|
|
),
|
|
"ASTRABOX_VAULT_MASTER_KEY": (
|
|
"",
|
|
"Operators fill this field; the registry's generated sentinel is not a literal key.",
|
|
),
|
|
}
|
|
|
|
_ENV_EXAMPLE_COMPOSE_VALUES: dict[str, tuple[str, str]] = {
|
|
"ASTRABOX_POSTGRES_VOLUME": (
|
|
"astrabox-postgres",
|
|
"Compose interpolation names the persistent PostgreSQL volume.",
|
|
),
|
|
"ASTRABOX_SERVER_IMAGE": (
|
|
"astrabox/server:latest",
|
|
"Compose and the source launcher select the server image before AstraBox starts.",
|
|
),
|
|
"ASTRABOX_STATE_VOLUME": (
|
|
"astrabox-state",
|
|
"Compose interpolation names the persistent AstraBox state volume.",
|
|
),
|
|
"LITELLM_DATABASE_URL": (
|
|
"postgresql://litellm:change-me@db.example.com:5432/litellm",
|
|
"Compose maps this launcher-facing value to LiteLLM's DATABASE_URL.",
|
|
),
|
|
}
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Wrapper functions that compose an env-var name from a literal first argument
|
|
# (grep-evading by construction — a plain ``grep os.getenv`` never sees these
|
|
# call sites). Keyed by (defining module, function name); value is the index of
|
|
# the literal-name argument at each CALL site.
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
_WRAPPER_CALLS: dict[tuple[str, str], int] = {
|
|
("astrabox.providers.identity_sso", "_env"): 0,
|
|
("astrabox.providers.identity_sso", "_strict_flag"): 0,
|
|
("astrabox.providers.identity_oidc", "_env"): 0,
|
|
("astrabox.identity.oidc", "_env"): 0,
|
|
("astrabox.deploy.sandbox_server", "_env"): 0,
|
|
("astrabox.deploy.sandbox_server", "_flag"): 0,
|
|
("astrabox.deploy.onebox", "_env"): 0,
|
|
("astrabox.deploy.onebox", "export_default"): 0,
|
|
(
|
|
"astrabox.core.service.orchestrator.session_kernel.workers.reconcile_worker",
|
|
"_env_float",
|
|
): 0,
|
|
}
|
|
|
|
#: The wrappers' OWN implementations read `os.getenv(<formal parameter>)` — that
|
|
#: internal read is not a new finding (it is already represented by every call
|
|
#: site above), so it is skipped rather than reported as unresolved-dynamic.
|
|
_WRAPPER_IMPL_FUNCS: dict[str, frozenset[str]] = {
|
|
"astrabox.providers.identity_sso": frozenset({"_env", "_strict_flag"}),
|
|
"astrabox.providers.identity_oidc": frozenset({"_env"}),
|
|
"astrabox.identity.oidc": frozenset({"_env"}),
|
|
"astrabox.deploy.sandbox_server": frozenset({"_env", "_flag"}),
|
|
"astrabox.deploy.onebox": frozenset({"_env", "export_default"}),
|
|
"astrabox.core.service.orchestrator.session_kernel.workers.reconcile_worker": frozenset(
|
|
{"_env_float"}
|
|
),
|
|
}
|
|
|
|
#: The one genuinely dynamic read site: ``astrabox/secrets.py``'s
|
|
#: ``SecretProvider.get_secret`` does
|
|
#: ``os.getenv(secret_env_key(secret_name))`` — a CALL expression, not a name a
|
|
#: module-constant or for-loop table could ever resolve, because the value comes
|
|
#: from a caller-supplied string at runtime. Documented as
|
|
#: ``env_registry.SECRET_NAME_PATTERN_ROW`` instead of a literal row.
|
|
_ALLOWED_UNRESOLVED: frozenset[tuple[str, int]] = frozenset(
|
|
{
|
|
("astrabox.secrets", 42),
|
|
}
|
|
)
|
|
|
|
|
|
def _module_dotted(path: Path) -> str:
|
|
"""``astrabox/foo/bar.py`` (repo-root-relative) -> ``"astrabox.foo.bar"``."""
|
|
rel = path.relative_to(_REPO_ROOT)
|
|
return ".".join(rel.with_suffix("").parts)
|
|
|
|
|
|
def _literal_str(node: ast.expr) -> str | None:
|
|
return node.value if isinstance(node, ast.Constant) and isinstance(node.value, str) else None
|
|
|
|
|
|
def _collect_module_literals(tree: ast.Module) -> dict[str, Any]:
|
|
"""Module-level ``NAME = <literal>`` (and annotated) assignments, evaluated.
|
|
|
|
Powers two resolution shapes seen in the tree: a bare module constant used
|
|
as an env-read argument (``DEFAULT_AGENT_IMAGE_ENV = "ASTRABOX_AGENT_IMAGE"``
|
|
then ``os.environ.get(DEFAULT_AGENT_IMAGE_ENV)``), and a for-loop iterating a
|
|
module-level tuple of literal names/pairs (see ``ModuleScanner.visit_For``).
|
|
"""
|
|
out: dict[str, Any] = {}
|
|
for node in tree.body:
|
|
target: ast.expr | None = None
|
|
value: ast.expr | None = None
|
|
if isinstance(node, ast.Assign) and len(node.targets) == 1:
|
|
target, value = node.targets[0], node.value
|
|
elif isinstance(node, ast.AnnAssign) and node.value is not None:
|
|
target, value = node.target, node.value
|
|
if isinstance(target, ast.Name) and value is not None:
|
|
try:
|
|
out[target.id] = ast.literal_eval(value)
|
|
except Exception:
|
|
pass
|
|
return out
|
|
|
|
|
|
@dataclass
|
|
class _ScanResult:
|
|
#: name -> {(module, lineno, how), ...}
|
|
sites: dict[str, set[tuple[str, int, str]]] = field(default_factory=dict)
|
|
#: (module, lineno) for a read whose argument could not be resolved to a literal.
|
|
unresolved: set[tuple[str, int]] = field(default_factory=set)
|
|
|
|
@property
|
|
def names(self) -> set[str]:
|
|
return set(self.sites)
|
|
|
|
def add(self, name: str, module: str, lineno: int, how: str) -> None:
|
|
self.sites.setdefault(name, set()).add((module, lineno, how))
|
|
|
|
|
|
class _ModuleScanner(ast.NodeVisitor):
|
|
"""Finds env-var reads in one module's AST (see the file docstring for scope)."""
|
|
|
|
def __init__(self, module: str, result: _ScanResult) -> None:
|
|
self.module = module
|
|
self.result = result
|
|
self.module_literals: dict[str, Any] = {}
|
|
self._func_stack: list[str] = []
|
|
self._scope_stack: list[dict[str, frozenset[str]]] = []
|
|
|
|
def _in_skipped_wrapper_body(self) -> bool:
|
|
skip = _WRAPPER_IMPL_FUNCS.get(self.module, frozenset())
|
|
return any(f in skip for f in self._func_stack)
|
|
|
|
def _resolve_name(self, node: ast.Name) -> list[str] | None:
|
|
for scope in reversed(self._scope_stack):
|
|
if node.id in scope:
|
|
return sorted(scope[node.id])
|
|
value = self.module_literals.get(node.id)
|
|
return [value] if isinstance(value, str) else None
|
|
|
|
def _handle(self, node: ast.AST, arg: ast.expr, how: str) -> None:
|
|
if self._in_skipped_wrapper_body():
|
|
return
|
|
literal = _literal_str(arg)
|
|
if literal is not None:
|
|
self.result.add(literal, self.module, node.lineno, how)
|
|
return
|
|
if isinstance(arg, ast.Name):
|
|
resolved = self._resolve_name(arg)
|
|
if resolved is not None:
|
|
for name in resolved:
|
|
self.result.add(name, self.module, node.lineno, f"{how} (resolved)")
|
|
return
|
|
self.result.unresolved.add((self.module, node.lineno))
|
|
|
|
# -- scope plumbing ------------------------------------------------------
|
|
|
|
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
|
|
self._func_stack.append(node.name)
|
|
self.generic_visit(node)
|
|
self._func_stack.pop()
|
|
|
|
visit_AsyncFunctionDef = visit_FunctionDef # type: ignore[assignment]
|
|
|
|
def _eval_iter(self, iter_node: ast.expr) -> Any:
|
|
if isinstance(iter_node, ast.Name):
|
|
return self.module_literals.get(iter_node.id)
|
|
try:
|
|
return ast.literal_eval(iter_node)
|
|
except Exception:
|
|
return None
|
|
|
|
def visit_For(self, node: ast.For) -> None:
|
|
# ``for key in ("A", "B"):`` or ``for a, b in SOME_MODULE_TUPLE:`` — bind
|
|
# the loop variable(s) to the literal value(s) they range over for the
|
|
# duration of the loop body, so an env-read call inside resolves them.
|
|
binding: dict[str, frozenset[str]] = {}
|
|
values = self._eval_iter(node.iter)
|
|
if isinstance(values, (list, tuple)) and values:
|
|
if isinstance(node.target, ast.Name):
|
|
strs = frozenset(v for v in values if isinstance(v, str))
|
|
if strs:
|
|
binding[node.target.id] = strs
|
|
elif isinstance(node.target, (ast.Tuple, ast.List)):
|
|
for i, elt in enumerate(node.target.elts):
|
|
if not isinstance(elt, ast.Name):
|
|
continue
|
|
col = frozenset(
|
|
row[i]
|
|
for row in values
|
|
if isinstance(row, (list, tuple))
|
|
and len(row) > i
|
|
and isinstance(row[i], str)
|
|
)
|
|
if col:
|
|
binding[elt.id] = col
|
|
if binding:
|
|
self._scope_stack.append(binding)
|
|
self.generic_visit(node)
|
|
if binding:
|
|
self._scope_stack.pop()
|
|
|
|
# -- the actual env-read shapes ------------------------------------------
|
|
|
|
def visit_Subscript(self, node: ast.Subscript) -> None:
|
|
val = node.value
|
|
if (
|
|
isinstance(val, ast.Attribute)
|
|
and val.attr == "environ"
|
|
and isinstance(val.value, ast.Name)
|
|
and val.value.id == "os"
|
|
):
|
|
self._handle(node, node.slice, "os.environ[]")
|
|
self.generic_visit(node)
|
|
|
|
def visit_Call(self, node: ast.Call) -> None:
|
|
func = node.func
|
|
if (
|
|
isinstance(func, ast.Attribute)
|
|
and func.attr == "getenv"
|
|
and isinstance(func.value, ast.Name)
|
|
and func.value.id == "os"
|
|
and node.args
|
|
):
|
|
self._handle(node, node.args[0], "os.getenv")
|
|
elif (
|
|
isinstance(func, ast.Attribute)
|
|
and func.attr == "get"
|
|
and isinstance(func.value, ast.Attribute)
|
|
and func.value.attr == "environ"
|
|
and isinstance(func.value.value, ast.Name)
|
|
and func.value.value.id == "os"
|
|
and node.args
|
|
):
|
|
self._handle(node, node.args[0], "os.environ.get")
|
|
elif isinstance(func, ast.Name):
|
|
idx = _WRAPPER_CALLS.get((self.module, func.id))
|
|
if idx is not None and len(node.args) > idx:
|
|
self._handle(node, node.args[idx], func.id)
|
|
self.generic_visit(node)
|
|
|
|
|
|
#: Python programs the sandbox images install and run. They read their
|
|
#: configuration from the environment the platform writes, so they are read
|
|
#: sites exactly like the package itself, only not imported by it.
|
|
_IMAGE_PROGRAMS_ROOT = _REPO_ROOT / "scripts" / "runtime"
|
|
|
|
|
|
def _scan_ast() -> _ScanResult:
|
|
result = _ScanResult()
|
|
paths = [*_ASTRABOX_ROOT.rglob("*.py"), *_IMAGE_PROGRAMS_ROOT.glob("*.py")]
|
|
for path in sorted(paths):
|
|
if "__pycache__" in path.parts:
|
|
continue
|
|
module = _module_dotted(path)
|
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
|
scanner = _ModuleScanner(module, result)
|
|
scanner.module_literals = _collect_module_literals(tree)
|
|
scanner.visit(tree)
|
|
return result
|
|
|
|
|
|
def _add_pydantic_settings_fields(result: _ScanResult) -> None:
|
|
"""Env names read by ``pydantic-settings`` itself — no ``os.getenv``-shaped AST
|
|
node exists for the scanner to find, so they are resolved by introspecting the
|
|
live ``BaseSettings`` classes (their own alias/prefix rules, not a re-parse of
|
|
how ``Field(...)`` happens to be written).
|
|
|
|
Covers both typed-settings surfaces: ``AstraBoxSettings`` (LLM/db/state) and
|
|
``AstraBoxRuntimeSettings`` (the wide operational config, whose ``ASTRABOX_*``
|
|
names live in each field's ``AliasChoices``). A field with only an
|
|
``AliasPath`` (a YAML-only field) contributes no env name — exactly matching
|
|
that it reads no environment variable."""
|
|
from pydantic import AliasChoices
|
|
|
|
from astrabox.common.utils.settings import AstraBoxRuntimeSettings
|
|
from astrabox.config.settings import AstraBoxSettings
|
|
|
|
for settings_cls in (AstraBoxSettings, AstraBoxRuntimeSettings):
|
|
prefix = str(settings_cls.model_config.get("env_prefix") or "")
|
|
module = settings_cls.__module__
|
|
for field_name, info in settings_cls.model_fields.items():
|
|
alias = info.validation_alias
|
|
if isinstance(alias, AliasChoices):
|
|
names = [c for c in alias.choices if isinstance(c, str)]
|
|
elif isinstance(alias, str):
|
|
names = [alias]
|
|
else:
|
|
names = [(prefix + field_name).upper()]
|
|
for name in names:
|
|
result.add(name, module, 0, f"pydantic-field:{field_name}")
|
|
|
|
|
|
@lru_cache(maxsize=1)
|
|
def _scan_env_reads() -> _ScanResult:
|
|
# Cached: every test below wants the same whole-tree scan, and re-parsing
|
|
# ~200 files under astrabox/ per test would multiply this file's runtime for
|
|
# no benefit (the scan is a pure function of the checked-out tree).
|
|
result = _scan_ast()
|
|
_add_pydantic_settings_fields(result)
|
|
return result
|
|
|
|
|
|
def _load_gen_config_docs() -> Any:
|
|
spec = importlib.util.spec_from_file_location(
|
|
"gen_config_docs", _REPO_ROOT / "scripts" / "gen_config_docs.py"
|
|
)
|
|
assert spec is not None and spec.loader is not None
|
|
module = importlib.util.module_from_spec(spec)
|
|
sys.modules[spec.name] = module
|
|
spec.loader.exec_module(module)
|
|
return module
|
|
|
|
|
|
# ── embedded-script environment census ──────────────────────────────────────
|
|
# The AST scanner cannot inspect environment reads inside generated shell or
|
|
# Python source strings. These runtime assets require explicit string scanning:
|
|
#
|
|
# * ``hermes._HERMES_PROFILE_SETUP_SCRIPT`` — a bash+python heredoc script
|
|
# installed into the sandbox to prepare the per-profile Hermes files.
|
|
# * ``runtime/astrabox-assistant-workspace-storage`` — the image-owned shared
|
|
# workspace mount helper.
|
|
# * ``runtime/provision-conversation`` — the image-owned conversation
|
|
# bootstrap and its command-scoped host-to-sandbox environment.
|
|
# * ``runtime/astrabox-transcript-mirror`` — the image-owned relay that
|
|
# mirrors an engine's own session log out of a box. Both engine images
|
|
# install the same program and tell it, through the environment, which
|
|
# files theirs writes.
|
|
# * ``sandbox-hermes/astrabox-hermes-serve`` — the image-owned launcher
|
|
# that reads the Assistant profile and WebSocket session credential.
|
|
# * ``sandbox-hermes/astrabox-hermes-forward`` — its readiness-gated proxy.
|
|
#
|
|
# Extract every literal name and require a registry row. Add any new embedded
|
|
# script producer to this census explicitly.
|
|
|
|
_ENV_NAME_SHAPE = r"[A-Z][A-Z0-9_]*"
|
|
|
|
# os.environ.get("X") / os.environ.get('X')
|
|
_RE_ENVIRON_GET = re.compile(rf"os\.environ\.get\(\s*[\"']({_ENV_NAME_SHAPE})[\"']")
|
|
# os.environ["X"] / os.environ['X']
|
|
_RE_ENVIRON_GETITEM = re.compile(rf"os\.environ\[\s*[\"']({_ENV_NAME_SHAPE})[\"']\s*\]")
|
|
# "X" in os.environ / 'X' in os.environ
|
|
_RE_ENVIRON_CONTAINS = re.compile(rf"[\"']({_ENV_NAME_SHAPE})[\"']\s+in\s+os\.environ")
|
|
# a `keys = [...]` / `keys += [...]` python list literal of bare quoted names
|
|
# (the gateway script's own env-forwarding allowlist) — scoped to the bracketed
|
|
# block (non-greedy up to the first `]`) so an unrelated quoted string
|
|
# elsewhere in the script (a printf format string, a heredoc delimiter like
|
|
# ``<<'PY'``) is never mistaken for a name: neither is a bare `keys = [...]`
|
|
# list element, so scoping this way finds only real candidates.
|
|
_RE_KEYS_LIST_BLOCK = re.compile(r"\bkeys\s*\+?=\s*\[(.*?)\]", re.DOTALL)
|
|
_RE_QUOTED_NAME = re.compile(rf"[\"']({_ENV_NAME_SHAPE})[\"']")
|
|
# bash ${NAME} / ${NAME:-default} reads — scoped to the namespaces these
|
|
# scripts actually use, so a local lowercase shell var (``${workspace}``, the
|
|
# gateway script's own ``required()`` helper's ``${!name:-}`` indirection)
|
|
# never matches.
|
|
_RE_BASH_BRACE = re.compile(
|
|
r"\$\{(ASTRABOX_[A-Z0-9_]+|HERMES_[A-Z0-9_]+|API_SERVER_[A-Z0-9_]+|"
|
|
r"CONV_[A-Z0-9_]+|GIT_HTTPS_TOKEN)(?::[^}]*)?\}"
|
|
)
|
|
# bash `$(required NAME)` reads — the gateway script's fail-loud accessor takes
|
|
# the env NAME as a bare function argument, so neither the ${...} nor the
|
|
# quoted-name families see it. Same namespaces as _RE_BASH_BRACE.
|
|
_RE_BASH_REQUIRED = re.compile(
|
|
r"\b(?:required|require_env)\s+"
|
|
r"(ASTRABOX_[A-Z0-9_]+|HERMES_[A-Z0-9_]+|API_SERVER_[A-Z0-9_]+|"
|
|
r"CONV_[A-Z0-9_]+|GIT_HTTPS_TOKEN)\b"
|
|
)
|
|
|
|
#: Embedded-script reads intentionally excluded from the registry. Each entry
|
|
#: requires a comment explaining why it is not a configuration surface.
|
|
_EMBEDDED_SCRIPT_CENSUS_EXCLUDED: frozenset[str] = frozenset()
|
|
|
|
# Pinned third-party processes read these names. Hermes receives its custom
|
|
# provider key in a private profile env file; the bundled gateway's Langfuse
|
|
# export (containers/litellm/langfuse_tracing.py and LiteLLM's langfuse_otel
|
|
# logger) reads the three observability values; the pi image's boot script renders the
|
|
# vendor's models.json from its base URL and model, and pi itself resolves the
|
|
# credential out of the environment at request time (models.json carries the
|
|
# variable's NAME, so the value never lands on disk). No local ``os.environ``
|
|
# expression can appear in the AST census for reads performed inside a sandbox
|
|
# image or by an installed package.
|
|
_EXTERNAL_RUNTIME_ENV_READS: frozenset[str] = frozenset(
|
|
{
|
|
"ASTRABOX_HERMES_MODEL_API_KEY",
|
|
"ASTRABOX_PI_API_KEY",
|
|
"ASTRABOX_PI_BASE_URL",
|
|
"ASTRABOX_PI_MODEL",
|
|
"LANGFUSE_HOST",
|
|
"LANGFUSE_PUBLIC_KEY",
|
|
"LANGFUSE_SECRET_KEY",
|
|
}
|
|
)
|
|
|
|
|
|
def _bundled_litellm_env_reads() -> frozenset[str]:
|
|
config = (_REPO_ROOT / "containers" / "litellm" / "config.yaml").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
return frozenset(re.findall(r"\bos\.environ/([A-Z][A-Z0-9_]*)\b", config))
|
|
|
|
|
|
def _extract_embedded_script_env_names(script: str) -> set[str]:
|
|
names: set[str] = set()
|
|
for pattern in (
|
|
_RE_ENVIRON_GET,
|
|
_RE_ENVIRON_GETITEM,
|
|
_RE_ENVIRON_CONTAINS,
|
|
_RE_BASH_BRACE,
|
|
_RE_BASH_REQUIRED,
|
|
):
|
|
names.update(m.group(1) for m in pattern.finditer(script))
|
|
for block in _RE_KEYS_LIST_BLOCK.finditer(script):
|
|
names.update(m.group(1) for m in _RE_QUOTED_NAME.finditer(block.group(1)))
|
|
return names
|
|
|
|
|
|
def _embedded_script_env_names() -> frozenset[str]:
|
|
"""Every literal env name found across the embedded-script census.
|
|
|
|
Keeps ``test_registry_entries_are_not_stale`` from flagging a registry
|
|
row that is real but only visible here, not to the AST scanner (which
|
|
cannot see inside a script string literal at all).
|
|
"""
|
|
names: set[str] = set()
|
|
for script in _embedded_scripts().values():
|
|
names.update(_extract_embedded_script_env_names(script))
|
|
return frozenset(names)
|
|
|
|
|
|
def _embedded_scripts() -> dict[str, str]:
|
|
from astrabox.core.service.orchestrator.engine.hermes import (
|
|
_HERMES_PROFILE_SETUP_SCRIPT,
|
|
)
|
|
return {
|
|
"hermes._HERMES_PROFILE_SETUP_SCRIPT": _HERMES_PROFILE_SETUP_SCRIPT,
|
|
"runtime/astrabox-assistant-workspace-storage": (
|
|
_REPO_ROOT
|
|
/ "astrabox/core/service/orchestrator/runtime/astrabox-assistant-workspace-storage"
|
|
).read_text(encoding="utf-8"),
|
|
"runtime/provision-conversation": (
|
|
_REPO_ROOT
|
|
/ "astrabox/core/service/orchestrator/runtime/provision-conversation"
|
|
).read_text(encoding="utf-8"),
|
|
"runtime/astrabox-transcript-mirror": (
|
|
_REPO_ROOT / "astrabox/core/service/orchestrator/runtime/astrabox-transcript-mirror"
|
|
).read_text(encoding="utf-8"),
|
|
"sandbox-hermes/astrabox-hermes-serve": (
|
|
_REPO_ROOT / "containers/sandbox-hermes/astrabox-hermes-serve"
|
|
).read_text(encoding="utf-8"),
|
|
"sandbox-hermes/astrabox-hermes-forward": (
|
|
_REPO_ROOT / "containers/sandbox-hermes/astrabox-hermes-forward"
|
|
).read_text(encoding="utf-8"),
|
|
}
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Tests
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_every_scanned_env_read_has_a_registry_entry() -> None:
|
|
scanned = _scan_env_reads()
|
|
missing = sorted(scanned.names - concrete_names())
|
|
assert not missing, (
|
|
"env var(s) read somewhere in astrabox/ with no astrabox/config/env_registry.py "
|
|
f"row: {missing}. Add a (name, tier, default, description, read_site) row for each, "
|
|
"then `python scripts/gen_config_docs.py`."
|
|
)
|
|
|
|
|
|
def test_registry_entries_are_not_stale() -> None:
|
|
scanned = _scan_env_reads()
|
|
known = (
|
|
scanned.names
|
|
| _embedded_script_env_names()
|
|
| _bundled_litellm_env_reads()
|
|
| _EXTERNAL_RUNTIME_ENV_READS
|
|
)
|
|
stale = sorted(concrete_names() - known)
|
|
assert not stale, (
|
|
f"astrabox/config/env_registry.py row(s) with no matching env read left in "
|
|
f"astrabox/ (AST scan) or the embedded-script census: {stale}. Remove the row "
|
|
"(or fix its name) if the read site was renamed or removed."
|
|
)
|
|
|
|
|
|
def test_no_new_unresolved_dynamic_env_reads() -> None:
|
|
scanned = _scan_env_reads()
|
|
unexpected = sorted(scanned.unresolved - _ALLOWED_UNRESOLVED)
|
|
assert not unexpected, (
|
|
f"new dynamic (non-literal) env-read site(s) the scanner cannot resolve to a "
|
|
f"literal name: {unexpected}. Either make it a resolvable composition (a "
|
|
'module-level NAME = "LITERAL" constant, or register it in _WRAPPER_CALLS) or '
|
|
"add it to _ALLOWED_UNRESOLVED with a registry pattern row explaining why it is "
|
|
"genuinely dynamic (see SECRET_NAME_PATTERN_ROW)."
|
|
)
|
|
also_missing = sorted(_ALLOWED_UNRESOLVED - scanned.unresolved)
|
|
assert not also_missing, (
|
|
f"_ALLOWED_UNRESOLVED entries no longer found as unresolved by the scanner "
|
|
f"(the read site moved, was resolved, or was removed): {also_missing}. Update "
|
|
"_ALLOWED_UNRESOLVED to match."
|
|
)
|
|
|
|
|
|
def test_embedded_script_env_reads_have_a_registry_entry() -> None:
|
|
registry_names = concrete_names()
|
|
for label, script in _embedded_scripts().items():
|
|
found = _extract_embedded_script_env_names(script) - _EMBEDDED_SCRIPT_CENSUS_EXCLUDED
|
|
missing = sorted(found - registry_names)
|
|
assert not missing, (
|
|
f"{label} reads env var(s) with no astrabox/config/env_registry.py row: "
|
|
f"{missing}. Add a (name, tier='injected', default, description, read_site) "
|
|
"row for each, then `python scripts/gen_config_docs.py`."
|
|
)
|
|
|
|
|
|
def test_bundled_litellm_env_reads_have_registry_entries() -> None:
|
|
missing = sorted(
|
|
(_bundled_litellm_env_reads() | _EXTERNAL_RUNTIME_ENV_READS)
|
|
- concrete_names()
|
|
)
|
|
assert not missing, (
|
|
"environment variables consumed by bundled third-party runtimes need "
|
|
f"env_registry.py rows too: {missing}"
|
|
)
|
|
|
|
|
|
def test_compose_server_environment_is_governed_by_the_registry() -> None:
|
|
class ComposeLoader(yaml.SafeLoader):
|
|
pass
|
|
|
|
def construct_override(loader: yaml.SafeLoader, node: yaml.Node) -> object:
|
|
if isinstance(node, yaml.SequenceNode):
|
|
return loader.construct_sequence(node)
|
|
if isinstance(node, yaml.MappingNode):
|
|
return loader.construct_mapping(node)
|
|
return loader.construct_scalar(node)
|
|
|
|
ComposeLoader.add_constructor("!override", construct_override)
|
|
ComposeLoader.add_constructor("!reset", lambda loader, node: None)
|
|
forwarded: set[str] = set()
|
|
for path in (_REPO_ROOT / "containers").glob("compose*.yaml"):
|
|
compose = yaml.load(path.read_text(encoding="utf-8"), Loader=ComposeLoader)
|
|
environment = compose.get("services", {}).get("server", {}).get("environment", {})
|
|
forwarded.update(environment or {})
|
|
|
|
assert not forwarded - concrete_names(), (
|
|
"a maintained server Compose overlay forwards unregistered or inert "
|
|
f"environment variables: {sorted(forwarded - concrete_names())}"
|
|
)
|
|
|
|
|
|
def test_configuration_docs_are_generated_freshly() -> None:
|
|
gen = _load_gen_config_docs()
|
|
generated = gen.render_markdown()
|
|
doc_path = _REPO_ROOT / "docs" / "configuration.md"
|
|
on_disk = doc_path.read_text(encoding="utf-8")
|
|
assert generated == on_disk, (
|
|
"docs/configuration.md does not match astrabox/config/env_registry.py — run "
|
|
"`python scripts/gen_config_docs.py` and commit the diff."
|
|
)
|
|
|
|
|
|
def test_runner_port_matches_the_image_contract_everywhere() -> None:
|
|
"""Pooled and resumed boxes must listen where the host dials."""
|
|
boot_script = (
|
|
_REPO_ROOT / "containers" / "sandbox-claude-code" / "boot.sh"
|
|
).read_text(encoding="utf-8")
|
|
boot_default_match = re.search(
|
|
r'^: "\$\{ASTRABOX_RUNNER_PORT:=(\d+)\}"$', boot_script, re.MULTILINE
|
|
)
|
|
assert boot_default_match is not None, (
|
|
"boot.sh must declare the runner's default port so image-only startup "
|
|
"works for pooled and resumed boxes"
|
|
)
|
|
registry_default = next(
|
|
row.default for row in ENV_REGISTRY if row.name == "ASTRABOX_RUNNER_PORT"
|
|
)
|
|
|
|
assert (
|
|
int(boot_default_match.group(1))
|
|
== IN_BOX_SIDECAR_PORT
|
|
== int(registry_default)
|
|
), (
|
|
"boot.sh chooses where pooled and resumed boxes listen, sandbox_image.py "
|
|
"chooses where the host dials, and env_registry.py publishes that contract; "
|
|
"the three values must move together"
|
|
)
|
|
|
|
|
|
def test_env_example_values_are_defaults_or_documented_examples() -> None:
|
|
"""The hand-picked quickstart must not silently drift from the registry."""
|
|
text = (_REPO_ROOT / ".env.example").read_text(encoding="utf-8")
|
|
pairs = re.findall(r"^(?:#\s*)?([A-Z][A-Z0-9_]*)=(.*)$", text, re.MULTILINE)
|
|
assignments = dict(pairs)
|
|
assert len(assignments) == len(pairs), (
|
|
".env.example defines the same setting more than once; keep one operator "
|
|
"example per name so its intended value is unambiguous."
|
|
)
|
|
|
|
registry = {row.name: row.default for row in ENV_REGISTRY if not row.is_pattern}
|
|
compose_values = {
|
|
name: value for name, (value, _reason) in _ENV_EXAMPLE_COMPOSE_VALUES.items()
|
|
}
|
|
unregistered = set(assignments) - set(registry)
|
|
assert unregistered == set(compose_values), (
|
|
".env.example settings outside the application registry must be explicit "
|
|
"Compose/launcher inputs with a reason; unexpected or stale names: "
|
|
f"{sorted(unregistered ^ set(compose_values))}"
|
|
)
|
|
actual_compose_values = {name: assignments[name] for name in unregistered}
|
|
compose_mismatches = sorted(
|
|
name
|
|
for name in set(actual_compose_values) | set(compose_values)
|
|
if actual_compose_values.get(name) != compose_values.get(name)
|
|
)
|
|
assert not compose_mismatches, (
|
|
".env.example Compose/launcher examples changed without updating their "
|
|
f"documented expected values: {compose_mismatches}"
|
|
)
|
|
|
|
nondefault_values = {
|
|
name: value
|
|
for name, value in assignments.items()
|
|
if name in registry and value != registry[name]
|
|
}
|
|
documented_nondefaults = {
|
|
name: value
|
|
for name, (value, _reason) in _ENV_EXAMPLE_NONDEFAULT_VALUES.items()
|
|
}
|
|
nondefault_mismatches = sorted(
|
|
name
|
|
for name in set(nondefault_values) | set(documented_nondefaults)
|
|
if nondefault_values.get(name) != documented_nondefaults.get(name)
|
|
)
|
|
assert not nondefault_mismatches, (
|
|
".env.example registry-backed values must match their defaults unless an "
|
|
"exact example value and reason are documented; unexpected or stale names: "
|
|
f"{nondefault_mismatches}"
|
|
)
|
|
|
|
missing_reasons = sorted(
|
|
name
|
|
for examples in (_ENV_EXAMPLE_NONDEFAULT_VALUES, _ENV_EXAMPLE_COMPOSE_VALUES)
|
|
for name, (_value, reason) in examples.items()
|
|
if not reason.strip()
|
|
)
|
|
assert not missing_reasons, f".env.example allowlist entries need reasons: {missing_reasons}"
|
|
|
|
|
|
@pytest.mark.parametrize("name", sorted(concrete_names()))
|
|
def test_registry_name_looks_like_an_env_var(name: str) -> None:
|
|
"""Cheap shape guard: every concrete name is upper-snake, no stray whitespace."""
|
|
assert name == name.strip()
|
|
assert name == name.upper()
|
|
assert " " not in name
|