generated from coulomb/repo-seed
feat: reachability and consumer profiles (SAND-WP-0011)
Add reachability enrichment (tunnel metadata, ops-bridge pointer), secret_refs boundary resolution, profile.agent-dev and profile.build, CLI reachability show, API endpoint, consumer smoke scripts, and tests.
This commit is contained in:
17
SCOPE.md
17
SCOPE.md
@@ -116,24 +116,24 @@ own tunnels or CAs.
|
||||
|
||||
- **Status:** v0 operational — self-hosted compose path proven on CoulombCore;
|
||||
routing, payments stub, and snapshots shipped
|
||||
- **Workplans finished:** SAND-WP-0001–0010 (0003/0004 in sibling repos)
|
||||
- **Workplans ready:** SAND-WP-0011–0012 (consumers, Packer)
|
||||
- **Workplans finished:** SAND-WP-0001–0011 (0003/0004 in sibling repos)
|
||||
- **Workplans ready:** SAND-WP-0012 (Packer orchestration)
|
||||
- **Package:** `src/sandboxer/` — CLI, manager, extensions, routing, payments,
|
||||
snapshots, telemetry, HTTP API
|
||||
- **Profiles:** compose e2e/checkpoint, canary, vm-haskell-build, saas-stub,
|
||||
burst-sandbox, e2b-burst, modal-gpu
|
||||
burst-sandbox, e2b-burst, modal-gpu, agent-dev, build
|
||||
- **Extensions:** `ext.compose-ssh`, `ext.vm-packer`, `ext.saas-stub`,
|
||||
`ext.e2b`, `ext.modal`
|
||||
- **Docs:** `meta-framework`, `extension-sdk`, `host-telemetry`, `routing`,
|
||||
`payments`, `snapshots`, `migration-gaps`, `migration-build-machines`
|
||||
- **Registry:** `capability.execution.sandbox-provision` indexed (draft)
|
||||
- **Tests:** 77 pytest cases; `make check` green
|
||||
- **Tests:** 86 pytest cases; `make check` green
|
||||
- **Siblings:** wise-validator `validate run` (SAND-WP-0003); the-custodian
|
||||
`make e2e REPO=` shim (SAND-WP-0004)
|
||||
|
||||
Latest gap analysis: `history/2026-06-24-post-wp0007-intent-scope-gap-analysis.md`
|
||||
Gap analysis: `history/2026-06-24-post-wp0007-intent-scope-gap-analysis.md`
|
||||
**Ready workplans:** SAND-WP-0011 (consumer profiles), 0012 (Packer orchestration).
|
||||
**Ready workplans:** SAND-WP-0012 (Packer orchestration).
|
||||
|
||||
---
|
||||
|
||||
@@ -154,6 +154,9 @@ sandboxer expire [--apply]
|
||||
sandboxer create --ttl 2h ...
|
||||
sandboxer credits show / credits add <amount>
|
||||
sandboxer inspect host / inspect stale / reap-stale [--apply]
|
||||
sandboxer reachability show <id>
|
||||
sandboxer create --profile profile.agent-dev --input repo=/path --actor agt --project glas-harness
|
||||
sandboxer create --profile profile.build --input vm=haskell-build --actor agt --project snuggle-inventor
|
||||
make smoke-remote # CoulombCore compose smoke (SANDBOXER_HOST)
|
||||
|
||||
# Full e2e validation (wise-validator, separate install):
|
||||
@@ -174,9 +177,9 @@ cd ~/the-custodian && make e2e REPO=activity-core
|
||||
- ~~TTL auto-expiry / `extend_ttl` enforcement~~ — done (SAND-WP-0009)
|
||||
- Packer build orchestration from `create` — **SAND-WP-0012**
|
||||
- ~~Real E2B / Modal adapters~~ — done (SAND-WP-0010)
|
||||
- Consumer profiles (agent-dev, build) — **SAND-WP-0011**
|
||||
- ~~Consumer profiles (agent-dev, build)~~ — done (SAND-WP-0011)
|
||||
- Cross-host snapshot transfer
|
||||
- Formal ops-bridge tunnel attachment — **SAND-WP-0011**
|
||||
- ~~Formal ops-bridge tunnel attachment~~ — done (SAND-WP-0011; descriptor only)
|
||||
- Dedicated sandboxer01 host (CoulombCore interim only today)
|
||||
- `reuse-surface validate` / federation publish workflow
|
||||
- ~~`.repo-classification.yaml`~~ — done (SAND-WP-0009)
|
||||
|
||||
@@ -31,6 +31,16 @@ sandboxer create \
|
||||
| SSH / tunnel reachability setup | glas-harness + ops-bridge |
|
||||
| Agent memory and session state | glas-harness |
|
||||
|
||||
## Smoke test
|
||||
|
||||
```bash
|
||||
# Requires sandboxer CLI and SANDBOXER_HOST (or profile placement fallback)
|
||||
SANDBOXER_HOST=coulombcore ./scripts/smoke-agent-dev.sh
|
||||
```
|
||||
|
||||
Creates `profile.agent-dev`, prints reachability (tunnel metadata + SSH
|
||||
one-liner), then destroys.
|
||||
|
||||
## Out of scope for sand-boxer
|
||||
|
||||
- Tool schemas and approval flows
|
||||
|
||||
@@ -30,6 +30,16 @@ sandboxer create \
|
||||
| Generated code and PR output | snuggle-inventor |
|
||||
| Secret resolution at boundary | sand-boxer (via ops-warden / OpenBao) |
|
||||
|
||||
## Smoke test
|
||||
|
||||
```bash
|
||||
# Skips live create when SANDBOXER_SECRET_BUILD_REGISTRY_TOKEN is unset
|
||||
export SANDBOXER_SECRET_BUILD_REGISTRY_TOKEN=<token>
|
||||
SANDBOXER_VM_TUNNEL_PORT=12222 ./scripts/smoke-build-profile.sh
|
||||
```
|
||||
|
||||
Optional env: `SMOKE_VM` (default `haskell-build`).
|
||||
|
||||
## Out of scope for sand-boxer
|
||||
|
||||
- Code generation prompts and tech specs
|
||||
|
||||
@@ -65,7 +65,13 @@ Event `detail` payload (JSON):
|
||||
"consumer": {"actor": "atm", "project": "wise-validator", "run_id": "..."},
|
||||
"actor_type": "atm",
|
||||
"state": "ready",
|
||||
"reachability": {"ssh": "root@coulombcore", "remote_dir": "/tmp/sandboxer/abc12345"},
|
||||
"reachability": {
|
||||
"ssh": "root@coulombcore",
|
||||
"remote_dir": "/tmp/sandboxer/abc12345",
|
||||
"tunnel": "localhost:12222",
|
||||
"tunnel_via": "ops-bridge",
|
||||
"identity": "ops-warden"
|
||||
},
|
||||
"timestamps": {"created_at": "...", "ready_at": "..."}
|
||||
}
|
||||
```
|
||||
@@ -100,6 +106,36 @@ HTTP surface (optional v0; CLI calls core library directly):
|
||||
- `POST /v1/sandboxes/{id}/recreate` — recreate
|
||||
- `PATCH /v1/sandboxes/{id}/ttl` — extend TTL
|
||||
- `POST /v1/sandboxes/expire` — TTL reap (query `apply=true`)
|
||||
- `GET /v1/sandboxes/{id}/reachability` — enriched descriptor + SSH one-liner
|
||||
|
||||
---
|
||||
|
||||
## Reachability descriptor
|
||||
|
||||
When a sandbox reaches `ready`, sand-boxer emits a **reachability** block on
|
||||
`SandboxStatus`, lifecycle events, and `sandboxer reachability show <id>`.
|
||||
|
||||
| Field | Source | Description |
|
||||
|-------|--------|-------------|
|
||||
| `ssh` | Extension | SSH target (`user@host`) |
|
||||
| `remote_dir` | Extension | Workspace root on remote host |
|
||||
| `host` | Extension | Placement host name |
|
||||
| `tunnel` | Profile + env | Local port (`localhost:PORT`) or VM alias |
|
||||
| `tunnel_via` | Profile spec | Route owner (default `ops-bridge`) |
|
||||
| `identity` | Profile spec | Warden actor hint (default `ops-warden`) |
|
||||
|
||||
Tunnel metadata is enriched from profile `reachability` and environment:
|
||||
|
||||
- `SANDBOXER_TUNNEL_PORT` / handle `tunnel_port` or `ssh_port`
|
||||
- `SANDBOXER_TUNNEL_ALIAS` / handle `vm_target`
|
||||
- `SANDBOXER_TUNNEL_VIA` (optional override)
|
||||
|
||||
sand-boxer **does not** bring tunnels up. Consumers use ops-bridge (MCP or
|
||||
`bridge` CLI) to attach SSH routes; the descriptor is a pointer only.
|
||||
|
||||
`secret_refs` from `profile.setup` are resolved at the provision boundary and
|
||||
passed to the extension handle — they never appear on `SandboxStatus` or State
|
||||
Hub events.
|
||||
|
||||
---
|
||||
|
||||
|
||||
42
profiles/profile.agent-dev.yaml
Normal file
42
profiles/profile.agent-dev.yaml
Normal file
@@ -0,0 +1,42 @@
|
||||
id: profile.agent-dev
|
||||
version: "1.0.0"
|
||||
extension: ext.compose-ssh
|
||||
route:
|
||||
strategy: prefer-self-hosted
|
||||
extensions:
|
||||
- ext.compose-ssh
|
||||
- ext.e2b
|
||||
- ext.modal
|
||||
- ext.saas-stub
|
||||
max_cost_per_hour_usd: 1.0
|
||||
isolation:
|
||||
level: container
|
||||
network:
|
||||
default: deny
|
||||
egress: []
|
||||
workspace:
|
||||
mode: remote-canonical
|
||||
access: rw
|
||||
scope_default: agent
|
||||
ttl:
|
||||
default: 8h
|
||||
max: 24h
|
||||
idle_reap: 2h
|
||||
resources:
|
||||
cpu: null
|
||||
memory_mb: null
|
||||
setup:
|
||||
instructions: >
|
||||
Agent development sandbox for glas-harness. Prefer self-hosted compose;
|
||||
burst to cloud when host unavailable. Consumer actor: agt.
|
||||
secret_refs: []
|
||||
placement:
|
||||
prefer: [sandboxer01]
|
||||
fallback: [coulombcore]
|
||||
reachability:
|
||||
tunnel: ops-bridge
|
||||
identity: ops-warden
|
||||
metadata:
|
||||
cost_class: self-hosted
|
||||
latency_class: standard
|
||||
observability: none
|
||||
35
profiles/profile.build.yaml
Normal file
35
profiles/profile.build.yaml
Normal file
@@ -0,0 +1,35 @@
|
||||
id: profile.build
|
||||
version: "1.0.0"
|
||||
extension: ext.vm-packer
|
||||
isolation:
|
||||
level: microvm
|
||||
network:
|
||||
default: deny
|
||||
egress: []
|
||||
workspace:
|
||||
mode: remote-canonical
|
||||
access: rw
|
||||
scope_default: agent
|
||||
ttl:
|
||||
default: 8h
|
||||
max: 24h
|
||||
idle_reap: null
|
||||
resources:
|
||||
cpu: null
|
||||
memory_mb: null
|
||||
setup:
|
||||
instructions: >
|
||||
Build sandbox for snuggle-inventor. Attach to pre-built VM via ops-bridge
|
||||
tunnel (e.g. haskell-build). Secret refs resolved at provision boundary only.
|
||||
secret_refs:
|
||||
- build-registry-token
|
||||
placement:
|
||||
prefer: [localhost]
|
||||
fallback: [workstation]
|
||||
reachability:
|
||||
tunnel: ops-bridge
|
||||
identity: ops-warden
|
||||
metadata:
|
||||
cost_class: self-hosted
|
||||
latency_class: standard
|
||||
observability: none
|
||||
20
scripts/smoke-agent-dev.sh
Executable file
20
scripts/smoke-agent-dev.sh
Executable file
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
# Smoke profile.agent-dev — requires SANDBOXER_HOST or defaults via placement.
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -z "${SANDBOXER_HOST:-}" ]]; then
|
||||
echo "SANDBOXER_HOST not set — using profile placement fallback" >&2
|
||||
fi
|
||||
|
||||
REPO="${SMOKE_REPO:-$(pwd)}"
|
||||
echo "Smoke: profile.agent-dev repo=$REPO"
|
||||
STATUS=$(sandboxer create \
|
||||
--profile profile.agent-dev \
|
||||
--input "repo=$REPO" \
|
||||
--actor agt \
|
||||
--project glas-harness)
|
||||
ID=$(echo "$STATUS" | python3 -c "import sys,json; print(json.load(sys.stdin)['sandbox_id'])")
|
||||
echo "Created: $ID"
|
||||
sandboxer reachability show "$ID"
|
||||
sandboxer destroy "$ID"
|
||||
echo "OK: agent-dev smoke"
|
||||
23
scripts/smoke-build-profile.sh
Executable file
23
scripts/smoke-build-profile.sh
Executable file
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
# Smoke profile.build — requires VM tunnel and optional build-registry-token secret.
|
||||
set -euo pipefail
|
||||
|
||||
if [[ -z "${SANDBOXER_SECRET_BUILD_REGISTRY_TOKEN:-}" ]]; then
|
||||
echo "SANDBOXER_SECRET_BUILD_REGISTRY_TOKEN not set — skipping live smoke" >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
VM="${SMOKE_VM:-haskell-build}"
|
||||
TUNNEL_PORT="${SANDBOXER_VM_TUNNEL_PORT:-12222}"
|
||||
echo "Smoke: profile.build vm=$VM tunnel=$TUNNEL_PORT"
|
||||
STATUS=$(sandboxer create \
|
||||
--profile profile.build \
|
||||
--input "vm=$VM" \
|
||||
--input "tunnel_port=$TUNNEL_PORT" \
|
||||
--actor agt \
|
||||
--project snuggle-inventor)
|
||||
ID=$(echo "$STATUS" | python3 -c "import sys,json; print(json.load(sys.stdin)['sandbox_id'])")
|
||||
echo "Created: $ID"
|
||||
sandboxer reachability show "$ID"
|
||||
sandboxer destroy "$ID"
|
||||
echo "OK: build profile smoke"
|
||||
@@ -34,6 +34,14 @@ def get_sandbox(sandbox_id: str) -> SandboxStatus:
|
||||
return status
|
||||
|
||||
|
||||
@app.get("/v1/sandboxes/{sandbox_id}/reachability")
|
||||
def get_sandbox_reachability(sandbox_id: str) -> dict:
|
||||
try:
|
||||
return _manager.reachability_report(sandbox_id)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
|
||||
@app.get("/v1/sandboxes", response_model=list[SandboxStatus])
|
||||
def list_sandboxes() -> list[SandboxStatus]:
|
||||
return _manager.list()
|
||||
|
||||
@@ -30,6 +30,8 @@ credits_app = typer.Typer(help="SaaS sandbox credits (metered extensions).")
|
||||
app.add_typer(credits_app, name="credits")
|
||||
snapshots_app = typer.Typer(help="Workspace checkpoint snapshots.")
|
||||
app.add_typer(snapshots_app, name="snapshots")
|
||||
reachability_app = typer.Typer(help="Consumer reachability descriptors.")
|
||||
app.add_typer(reachability_app, name="reachability")
|
||||
|
||||
|
||||
@app.callback()
|
||||
@@ -112,6 +114,18 @@ def sandbox_create(
|
||||
_print_telemetry_summary(status.telemetry)
|
||||
|
||||
|
||||
@reachability_app.command("show")
|
||||
def reachability_show(sandbox_id: str) -> None:
|
||||
"""Show reachability descriptor and SSH one-liner for a sandbox."""
|
||||
manager = SandboxManager()
|
||||
try:
|
||||
report = manager.reachability_report(sandbox_id)
|
||||
except KeyError as exc:
|
||||
typer.echo(str(exc), err=True)
|
||||
raise typer.Exit(code=1) from exc
|
||||
_print_json(report)
|
||||
|
||||
|
||||
@app.command("get")
|
||||
def sandbox_get(sandbox_id: str) -> None:
|
||||
"""Get sandbox status by id."""
|
||||
|
||||
@@ -26,7 +26,9 @@ from sandboxer.payments.credits import CreditsStore
|
||||
from sandboxer.payments.metering import estimate_cost, settle_usage
|
||||
from sandboxer.placement import resolve_host
|
||||
from sandboxer.profiles.loader import load_profile
|
||||
from sandboxer.reachability.enrich import enrich_reachability
|
||||
from sandboxer.routing.resolver import resolve_extension
|
||||
from sandboxer.secrets.resolver import resolve_setup_secrets
|
||||
from sandboxer.snapshots.store import SnapshotStore
|
||||
from sandboxer.telemetry.export import export_telemetry
|
||||
from sandboxer.telemetry.introspection import (
|
||||
@@ -127,7 +129,11 @@ class SandboxManager:
|
||||
provision_before = collect_host_snapshot(resolved_host)
|
||||
|
||||
try:
|
||||
handle = backend.provision(profile, request.inputs, resolved_host)
|
||||
secret_bundle = resolve_setup_secrets(profile)
|
||||
provision_inputs = dict(request.inputs)
|
||||
handle = backend.provision(profile, provision_inputs, resolved_host)
|
||||
if secret_bundle:
|
||||
handle["_secret_refs"] = secret_bundle
|
||||
status.sandbox_id = handle["sandbox_id"]
|
||||
status.inputs["compose_file"] = handle.get("compose_file", "")
|
||||
status.inputs["ssh_user"] = handle.get("ssh_user", "")
|
||||
@@ -139,6 +145,7 @@ class SandboxManager:
|
||||
status.inputs["provider_sandbox_id"] = handle.get("provider_sandbox_id", "")
|
||||
status.inputs["provider"] = handle.get("provider", "")
|
||||
reach = backend.wait_ready(handle)
|
||||
reach = enrich_reachability(reach, profile, handle)
|
||||
status.reachability = Reachability(**reach)
|
||||
status.state = SandboxState.READY
|
||||
status.ready_at = utcnow()
|
||||
@@ -178,6 +185,14 @@ class SandboxManager:
|
||||
def get(self, sandbox_id: str) -> SandboxStatus | None:
|
||||
return self.store.get(sandbox_id)
|
||||
|
||||
def reachability_report(self, sandbox_id: str) -> dict:
|
||||
status = self.store.get(sandbox_id)
|
||||
if not status:
|
||||
raise KeyError(f"Sandbox not found: {sandbox_id}")
|
||||
from sandboxer.reachability.enrich import build_reachability_report
|
||||
|
||||
return build_reachability_report(status)
|
||||
|
||||
def list(self) -> list[SandboxStatus]:
|
||||
return sorted(self.store.list_all(), key=lambda s: s.created_at, reverse=True)
|
||||
|
||||
@@ -453,7 +468,11 @@ class SandboxManager:
|
||||
status.inputs["vm_host"] = handle.get("vm_host", "")
|
||||
status.inputs["endpoint"] = handle.get("endpoint", "")
|
||||
status.inputs["restored_from"] = record.snapshot_id
|
||||
secret_bundle = resolve_setup_secrets(profile)
|
||||
if secret_bundle:
|
||||
handle["_secret_refs"] = secret_bundle
|
||||
reach = backend.wait_ready(handle)
|
||||
reach = enrich_reachability(reach, profile, handle)
|
||||
status.reachability = Reachability(**reach)
|
||||
status.state = SandboxState.READY
|
||||
status.ready_at = utcnow()
|
||||
|
||||
@@ -153,6 +153,9 @@ class Reachability(BaseModel):
|
||||
compose_project: str | None = None
|
||||
host: str | None = None
|
||||
endpoint: str | None = None
|
||||
tunnel: str | None = None
|
||||
tunnel_via: str | None = None
|
||||
identity: str | None = None
|
||||
|
||||
|
||||
class SandboxStatus(BaseModel):
|
||||
|
||||
5
src/sandboxer/reachability/__init__.py
Normal file
5
src/sandboxer/reachability/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Reachability descriptor enrichment."""
|
||||
|
||||
from sandboxer.reachability.enrich import build_reachability_report, enrich_reachability
|
||||
|
||||
__all__ = ["enrich_reachability", "build_reachability_report"]
|
||||
68
src/sandboxer/reachability/enrich.py
Normal file
68
src/sandboxer/reachability/enrich.py
Normal file
@@ -0,0 +1,68 @@
|
||||
"""Merge profile reachability spec and env into consumer descriptors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from sandboxer.models import Profile, Reachability, SandboxStatus
|
||||
|
||||
OPS_BRIDGE_DOC = "ops-bridge MCP or `bridge` CLI — sand-boxer does not manage tunnels"
|
||||
|
||||
|
||||
def enrich_reachability(
|
||||
reach: dict[str, str],
|
||||
profile: Profile,
|
||||
handle: dict[str, str],
|
||||
) -> dict[str, str]:
|
||||
"""Add tunnel/identity metadata from profile spec and environment."""
|
||||
enriched = dict(reach)
|
||||
spec = profile.reachability
|
||||
|
||||
if spec.tunnel:
|
||||
enriched.setdefault("tunnel_via", spec.tunnel)
|
||||
if spec.identity:
|
||||
enriched["identity"] = spec.identity
|
||||
|
||||
tunnel_port = (
|
||||
os.environ.get("SANDBOXER_TUNNEL_PORT")
|
||||
or handle.get("tunnel_port")
|
||||
or handle.get("ssh_port")
|
||||
)
|
||||
tunnel_alias = os.environ.get("SANDBOXER_TUNNEL_ALIAS") or handle.get("vm_target")
|
||||
if tunnel_port:
|
||||
enriched["tunnel"] = f"localhost:{tunnel_port}"
|
||||
elif tunnel_alias:
|
||||
enriched["tunnel"] = tunnel_alias
|
||||
|
||||
tunnel_via = os.environ.get("SANDBOXER_TUNNEL_VIA")
|
||||
if tunnel_via:
|
||||
enriched["tunnel_via"] = tunnel_via
|
||||
|
||||
return enriched
|
||||
|
||||
|
||||
def ssh_one_liner(reach: Reachability) -> str | None:
|
||||
if reach.ssh and reach.remote_dir:
|
||||
return f"ssh {reach.ssh} 'cd {reach.remote_dir} && exec $SHELL'"
|
||||
if reach.ssh:
|
||||
return f"ssh {reach.ssh}"
|
||||
return None
|
||||
|
||||
|
||||
def build_reachability_report(status: SandboxStatus) -> dict[str, Any]:
|
||||
"""Consumer-facing reachability report with ops-bridge pointer."""
|
||||
reach = status.reachability
|
||||
payload: dict[str, Any] = {
|
||||
"sandbox_id": status.sandbox_id,
|
||||
"profile_id": status.profile_id,
|
||||
"host": status.host,
|
||||
"reachability": reach.model_dump(mode="json") if reach else None,
|
||||
"ops_bridge": {
|
||||
"doc": OPS_BRIDGE_DOC,
|
||||
"note": "Bring tunnels up via ops-bridge; sand-boxer emits descriptor only",
|
||||
},
|
||||
}
|
||||
if reach:
|
||||
payload["ssh_one_liner"] = ssh_one_liner(reach)
|
||||
return payload
|
||||
5
src/sandboxer/secrets/__init__.py
Normal file
5
src/sandboxer/secrets/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Setup secret resolution at provision boundary."""
|
||||
|
||||
from sandboxer.secrets.resolver import resolve_setup_secrets
|
||||
|
||||
__all__ = ["resolve_setup_secrets"]
|
||||
41
src/sandboxer/secrets/resolver.py
Normal file
41
src/sandboxer/secrets/resolver.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Resolve profile.setup.secret_refs from operator-injected env (BYOK boundary)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from sandboxer.models import Profile
|
||||
|
||||
|
||||
def _secret_env_name(ref: str) -> str:
|
||||
normalized = ref.upper().replace("-", "_").replace(".", "_")
|
||||
return f"SANDBOXER_SECRET_{normalized}"
|
||||
|
||||
|
||||
def resolve_secret_ref(ref: str) -> str | None:
|
||||
"""Resolve a single secret ref from env. OpenBao injection is operator-owned."""
|
||||
return os.environ.get(_secret_env_name(ref))
|
||||
|
||||
|
||||
def resolve_setup_secrets(profile: Profile) -> dict[str, str]:
|
||||
"""Resolve all profile secret_refs or raise if any are missing."""
|
||||
refs = profile.setup.secret_refs
|
||||
if not refs:
|
||||
return {}
|
||||
|
||||
resolved: dict[str, str] = {}
|
||||
missing: list[str] = []
|
||||
for ref in refs:
|
||||
value = resolve_secret_ref(ref)
|
||||
if value:
|
||||
resolved[ref] = value
|
||||
else:
|
||||
missing.append(ref)
|
||||
|
||||
if missing:
|
||||
env_hints = ", ".join(_secret_env_name(r) for r in missing)
|
||||
raise ValueError(
|
||||
f"Unresolved secret_refs for {profile.id}: {missing}. "
|
||||
f"Set env ({env_hints}) or use warden route find for OpenBao path."
|
||||
)
|
||||
return resolved
|
||||
20
tests/test_consumer_profiles.py
Normal file
20
tests/test_consumer_profiles.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""Consumer profile loader smoke tests."""
|
||||
|
||||
from sandboxer.profiles.loader import load_profile
|
||||
|
||||
|
||||
def test_profile_agent_dev_loads() -> None:
|
||||
profile = load_profile("profile.agent-dev")
|
||||
assert profile.id == "profile.agent-dev"
|
||||
assert profile.extension == "ext.compose-ssh"
|
||||
assert profile.scope_default == "agent"
|
||||
assert profile.route is not None
|
||||
assert profile.ttl.default == "8h"
|
||||
|
||||
|
||||
def test_profile_build_loads() -> None:
|
||||
profile = load_profile("profile.build")
|
||||
assert profile.id == "profile.build"
|
||||
assert profile.extension == "ext.vm-packer"
|
||||
assert "build-registry-token" in profile.setup.secret_refs
|
||||
assert profile.reachability.tunnel == "ops-bridge"
|
||||
78
tests/test_reachability.py
Normal file
78
tests/test_reachability.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""Reachability enrichment tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sandboxer.models import (
|
||||
ActorType,
|
||||
Consumer,
|
||||
Profile,
|
||||
Reachability,
|
||||
ReachabilitySpec,
|
||||
SandboxState,
|
||||
SandboxStatus,
|
||||
)
|
||||
from sandboxer.reachability.enrich import (
|
||||
build_reachability_report,
|
||||
enrich_reachability,
|
||||
ssh_one_liner,
|
||||
)
|
||||
|
||||
|
||||
def _profile() -> Profile:
|
||||
return Profile.model_validate(
|
||||
{
|
||||
"id": "profile.agent-dev",
|
||||
"version": "1.0.0",
|
||||
"extension": "ext.compose-ssh",
|
||||
"reachability": ReachabilitySpec(
|
||||
tunnel="ops-bridge", identity="ops-warden"
|
||||
).model_dump(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_enrich_adds_tunnel_and_identity(monkeypatch) -> None:
|
||||
monkeypatch.setenv("SANDBOXER_TUNNEL_PORT", "12222")
|
||||
reach = enrich_reachability(
|
||||
{"ssh": "build@localhost", "remote_dir": "/build/sbx-1", "host": "localhost"},
|
||||
_profile(),
|
||||
{"ssh_port": "12222"},
|
||||
)
|
||||
assert reach["identity"] == "ops-warden"
|
||||
assert reach["tunnel"] == "localhost:12222"
|
||||
assert reach["tunnel_via"] == "ops-bridge"
|
||||
|
||||
|
||||
def test_ssh_one_liner() -> None:
|
||||
reach = Reachability(ssh="user@host", remote_dir="/tmp/ws")
|
||||
line = ssh_one_liner(reach)
|
||||
assert line is not None
|
||||
assert "user@host" in line
|
||||
assert "/tmp/ws" in line
|
||||
|
||||
|
||||
def test_build_reachability_report() -> None:
|
||||
now = datetime.now(UTC)
|
||||
status = SandboxStatus(
|
||||
sandbox_id="abc12345",
|
||||
profile_id="profile.agent-dev",
|
||||
extension_id="ext.compose-ssh",
|
||||
state=SandboxState.READY,
|
||||
consumer=Consumer(actor=ActorType.AGT, project="glas-harness"),
|
||||
host="coulombcore",
|
||||
reachability=Reachability(
|
||||
ssh="root@coulombcore",
|
||||
remote_dir="/tmp/sandboxer/abc12345",
|
||||
tunnel="localhost:22",
|
||||
tunnel_via="ops-bridge",
|
||||
identity="ops-warden",
|
||||
),
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
report = build_reachability_report(status)
|
||||
assert report["sandbox_id"] == "abc12345"
|
||||
assert report["ssh_one_liner"] is not None
|
||||
assert "ops_bridge" in report
|
||||
51
tests/test_secrets.py
Normal file
51
tests/test_secrets.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Setup secret resolution tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from sandboxer.models import Profile, SetupSpec
|
||||
from sandboxer.secrets.resolver import resolve_secret_ref, resolve_setup_secrets
|
||||
|
||||
|
||||
def test_resolve_secret_ref_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("SANDBOXER_SECRET_BUILD_REGISTRY_TOKEN", "tok123")
|
||||
assert resolve_secret_ref("build-registry-token") == "tok123"
|
||||
|
||||
|
||||
def test_resolve_setup_secrets_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("SANDBOXER_SECRET_BUILD_REGISTRY_TOKEN", "tok123")
|
||||
profile = Profile.model_validate(
|
||||
{
|
||||
"id": "profile.build",
|
||||
"version": "1.0.0",
|
||||
"extension": "ext.vm-packer",
|
||||
"setup": SetupSpec(secret_refs=["build-registry-token"]).model_dump(),
|
||||
}
|
||||
)
|
||||
secrets = resolve_setup_secrets(profile)
|
||||
assert secrets["build-registry-token"] == "tok123"
|
||||
|
||||
|
||||
def test_resolve_setup_secrets_missing_raises() -> None:
|
||||
profile = Profile.model_validate(
|
||||
{
|
||||
"id": "profile.build",
|
||||
"version": "1.0.0",
|
||||
"extension": "ext.vm-packer",
|
||||
"setup": SetupSpec(secret_refs=["missing-ref"]).model_dump(),
|
||||
}
|
||||
)
|
||||
with pytest.raises(ValueError, match="Unresolved secret_refs"):
|
||||
resolve_setup_secrets(profile)
|
||||
|
||||
|
||||
def test_empty_secret_refs() -> None:
|
||||
profile = Profile.model_validate(
|
||||
{
|
||||
"id": "profile.compose-e2e",
|
||||
"version": "1.0.0",
|
||||
"extension": "ext.compose-ssh",
|
||||
}
|
||||
)
|
||||
assert resolve_setup_secrets(profile) == {}
|
||||
@@ -4,7 +4,7 @@ type: workplan
|
||||
title: "Reachability and consumer profiles"
|
||||
domain: infotech
|
||||
repo: sand-boxer
|
||||
status: ready
|
||||
status: finished
|
||||
owner: codex
|
||||
topic_slug: custodian
|
||||
created: "2026-06-24"
|
||||
@@ -19,18 +19,16 @@ first-class profiles for glas-harness and snuggle-inventor consumers.
|
||||
|
||||
Gap analysis P6/P7: `history/2026-06-24-post-wp0007-intent-scope-gap-analysis.md`
|
||||
|
||||
**Predecessor:** SAND-WP-0010 (cloud adapters — proposed)
|
||||
**Predecessor:** SAND-WP-0010 (cloud adapters)
|
||||
**Follow-on:** SAND-WP-0012 (Packer orchestration)
|
||||
|
||||
Note: Can proceed in parallel with SAND-WP-0010 where profiles are self-hosted.
|
||||
|
||||
---
|
||||
|
||||
## Reachability descriptor enrichment
|
||||
|
||||
```task
|
||||
id: SAND-WP-0011-T01
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "ccf21aaf-9439-41e2-9ce3-becc08f734a7"
|
||||
```
|
||||
@@ -44,77 +42,76 @@ Document contract in `docs/meta-framework.md`; sand-boxer does not own tunnels.
|
||||
|
||||
```task
|
||||
id: SAND-WP-0011-T02
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "61d41e09-ca21-4fbe-9b56-98f0ffe356c6"
|
||||
```
|
||||
|
||||
Optional `sandboxer reachability show <id>` (or enrich `get` output) surfacing
|
||||
SSH one-liner and tunnel status pointer (`ops-bridge` MCP / CLI doc link). No
|
||||
tunnel bring-up in sand-boxer — pointer only.
|
||||
`sandboxer reachability show <id>` and `GET /v1/sandboxes/{id}/reachability`
|
||||
surfacing SSH one-liner and tunnel status pointer (`ops-bridge` MCP / CLI doc
|
||||
link). No tunnel bring-up in sand-boxer — pointer only.
|
||||
|
||||
## profile.agent-dev
|
||||
|
||||
```task
|
||||
id: SAND-WP-0011-T03
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "1a10a784-6a7c-4af6-9fbf-48d31e7e22cb"
|
||||
```
|
||||
|
||||
Profile for glas-harness: longer TTL defaults, `actor: agt` examples, route
|
||||
`prefer-self-hosted`. Extension `ext.compose-ssh` or vm-packer attach variant.
|
||||
Update `docs/integrations/glas-harness.md` with real profile id.
|
||||
`prefer-self-hosted`. Extension `ext.compose-ssh`. Updated
|
||||
`docs/integrations/glas-harness.md` with real profile id.
|
||||
|
||||
## profile.build (snuggle-inventor)
|
||||
|
||||
```task
|
||||
id: SAND-WP-0011-T04
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "a8142492-32c8-40d4-b882-b555858b44bb"
|
||||
```
|
||||
|
||||
Build sandbox profile binding `profile.vm-haskell-build` or compose path;
|
||||
`setup.instructions` placeholder; `secret_refs` list on profile (resolution v0:
|
||||
validate refs exist via `warden route`, inject at provision boundary only).
|
||||
Update `docs/integrations/snuggle-inventor.md`.
|
||||
Build sandbox profile binding `ext.vm-packer`; `setup.instructions` placeholder;
|
||||
`secret_refs` list on profile (resolution v0: env `SANDBOXER_SECRET_*`, inject at
|
||||
provision boundary only). Updated `docs/integrations/snuggle-inventor.md`.
|
||||
|
||||
## Secret boundary v0
|
||||
|
||||
```task
|
||||
id: SAND-WP-0011-T05
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "df4053de-ec74-40a3-ae9b-422c1be973cd"
|
||||
```
|
||||
|
||||
`SetupSpec.secret_refs` resolution in manager pre-provision hook: fetch via
|
||||
operator-documented OpenBao path; pass to extension handle; never store on
|
||||
`SetupSpec.secret_refs` resolution in manager pre-provision hook via
|
||||
`SANDBOXER_SECRET_<REF>` env; pass to extension handle; never store on
|
||||
`SandboxStatus` or emit to State Hub. Tests with mocked resolver.
|
||||
|
||||
## Consumer smoke scripts
|
||||
|
||||
```task
|
||||
id: SAND-WP-0011-T06
|
||||
status: todo
|
||||
status: done
|
||||
priority: medium
|
||||
state_hub_task_id: "9d5feebe-16a2-4448-ad0c-3276858341d1"
|
||||
```
|
||||
|
||||
`scripts/smoke-agent-dev.sh`, `scripts/smoke-build-profile.sh` (dry-run or
|
||||
CoulombCore gated). Integration section in each consumer doc.
|
||||
`scripts/smoke-agent-dev.sh`, `scripts/smoke-build-profile.sh` (CoulombCore
|
||||
gated). Integration section in each consumer doc.
|
||||
|
||||
## Tests and docs
|
||||
|
||||
```task
|
||||
id: SAND-WP-0011-T07
|
||||
status: todo
|
||||
status: done
|
||||
priority: high
|
||||
state_hub_task_id: "849e0701-fe8f-4c08-ac24-98cdf554c24b"
|
||||
```
|
||||
|
||||
Model tests for reachability fields; profile loader tests; update `SCOPE.md`
|
||||
Model tests for reachability fields; profile loader tests; updated `SCOPE.md`
|
||||
profile catalog. `make check` green.
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user