From 1f87be4c6bf205587e61e6134403e76bc04e9587 Mon Sep 17 00:00:00 2001 From: tegwick Date: Wed, 24 Jun 2026 12:54:27 +0200 Subject: [PATCH] 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. --- SCOPE.md | 17 ++-- docs/integrations/glas-harness.md | 10 +++ docs/integrations/snuggle-inventor.md | 10 +++ docs/meta-framework.md | 38 ++++++++- profiles/profile.agent-dev.yaml | 42 ++++++++++ profiles/profile.build.yaml | 35 +++++++++ scripts/smoke-agent-dev.sh | 20 +++++ scripts/smoke-build-profile.sh | 23 ++++++ src/sandboxer/api/app.py | 8 ++ src/sandboxer/cli.py | 14 ++++ src/sandboxer/core/manager.py | 21 ++++- src/sandboxer/models.py | 3 + src/sandboxer/reachability/__init__.py | 5 ++ src/sandboxer/reachability/enrich.py | 68 ++++++++++++++++ src/sandboxer/secrets/__init__.py | 5 ++ src/sandboxer/secrets/resolver.py | 41 ++++++++++ tests/test_consumer_profiles.py | 20 +++++ tests/test_reachability.py | 78 +++++++++++++++++++ tests/test_secrets.py | 51 ++++++++++++ ...0011-reachability-and-consumer-profiles.md | 47 ++++++----- 20 files changed, 522 insertions(+), 34 deletions(-) create mode 100644 profiles/profile.agent-dev.yaml create mode 100644 profiles/profile.build.yaml create mode 100755 scripts/smoke-agent-dev.sh create mode 100755 scripts/smoke-build-profile.sh create mode 100644 src/sandboxer/reachability/__init__.py create mode 100644 src/sandboxer/reachability/enrich.py create mode 100644 src/sandboxer/secrets/__init__.py create mode 100644 src/sandboxer/secrets/resolver.py create mode 100644 tests/test_consumer_profiles.py create mode 100644 tests/test_reachability.py create mode 100644 tests/test_secrets.py diff --git a/SCOPE.md b/SCOPE.md index 0ac08df..aa9228d 100644 --- a/SCOPE.md +++ b/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 sandboxer inspect host / inspect stale / reap-stale [--apply] +sandboxer reachability show +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) diff --git a/docs/integrations/glas-harness.md b/docs/integrations/glas-harness.md index 0296eae..f98a74e 100644 --- a/docs/integrations/glas-harness.md +++ b/docs/integrations/glas-harness.md @@ -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 diff --git a/docs/integrations/snuggle-inventor.md b/docs/integrations/snuggle-inventor.md index 0cd75a9..59f0974 100644 --- a/docs/integrations/snuggle-inventor.md +++ b/docs/integrations/snuggle-inventor.md @@ -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= +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 diff --git a/docs/meta-framework.md b/docs/meta-framework.md index 4218471..4413e87 100644 --- a/docs/meta-framework.md +++ b/docs/meta-framework.md @@ -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 `. + +| 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. --- diff --git a/profiles/profile.agent-dev.yaml b/profiles/profile.agent-dev.yaml new file mode 100644 index 0000000..ef3df5a --- /dev/null +++ b/profiles/profile.agent-dev.yaml @@ -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 \ No newline at end of file diff --git a/profiles/profile.build.yaml b/profiles/profile.build.yaml new file mode 100644 index 0000000..57a7c28 --- /dev/null +++ b/profiles/profile.build.yaml @@ -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 \ No newline at end of file diff --git a/scripts/smoke-agent-dev.sh b/scripts/smoke-agent-dev.sh new file mode 100755 index 0000000..a9e9f6b --- /dev/null +++ b/scripts/smoke-agent-dev.sh @@ -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" \ No newline at end of file diff --git a/scripts/smoke-build-profile.sh b/scripts/smoke-build-profile.sh new file mode 100755 index 0000000..c27f3b3 --- /dev/null +++ b/scripts/smoke-build-profile.sh @@ -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" \ No newline at end of file diff --git a/src/sandboxer/api/app.py b/src/sandboxer/api/app.py index 1ef147d..38d1c94 100644 --- a/src/sandboxer/api/app.py +++ b/src/sandboxer/api/app.py @@ -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() diff --git a/src/sandboxer/cli.py b/src/sandboxer/cli.py index f8ec401..535c060 100644 --- a/src/sandboxer/cli.py +++ b/src/sandboxer/cli.py @@ -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.""" diff --git a/src/sandboxer/core/manager.py b/src/sandboxer/core/manager.py index f0ee8fd..14f2745 100644 --- a/src/sandboxer/core/manager.py +++ b/src/sandboxer/core/manager.py @@ -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() diff --git a/src/sandboxer/models.py b/src/sandboxer/models.py index 6963161..d8cba64 100644 --- a/src/sandboxer/models.py +++ b/src/sandboxer/models.py @@ -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): diff --git a/src/sandboxer/reachability/__init__.py b/src/sandboxer/reachability/__init__.py new file mode 100644 index 0000000..e618af0 --- /dev/null +++ b/src/sandboxer/reachability/__init__.py @@ -0,0 +1,5 @@ +"""Reachability descriptor enrichment.""" + +from sandboxer.reachability.enrich import build_reachability_report, enrich_reachability + +__all__ = ["enrich_reachability", "build_reachability_report"] \ No newline at end of file diff --git a/src/sandboxer/reachability/enrich.py b/src/sandboxer/reachability/enrich.py new file mode 100644 index 0000000..6fde9ed --- /dev/null +++ b/src/sandboxer/reachability/enrich.py @@ -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 \ No newline at end of file diff --git a/src/sandboxer/secrets/__init__.py b/src/sandboxer/secrets/__init__.py new file mode 100644 index 0000000..fd15a42 --- /dev/null +++ b/src/sandboxer/secrets/__init__.py @@ -0,0 +1,5 @@ +"""Setup secret resolution at provision boundary.""" + +from sandboxer.secrets.resolver import resolve_setup_secrets + +__all__ = ["resolve_setup_secrets"] \ No newline at end of file diff --git a/src/sandboxer/secrets/resolver.py b/src/sandboxer/secrets/resolver.py new file mode 100644 index 0000000..6a981e7 --- /dev/null +++ b/src/sandboxer/secrets/resolver.py @@ -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 \ No newline at end of file diff --git a/tests/test_consumer_profiles.py b/tests/test_consumer_profiles.py new file mode 100644 index 0000000..1ff5f6a --- /dev/null +++ b/tests/test_consumer_profiles.py @@ -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" \ No newline at end of file diff --git a/tests/test_reachability.py b/tests/test_reachability.py new file mode 100644 index 0000000..b936221 --- /dev/null +++ b/tests/test_reachability.py @@ -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 \ No newline at end of file diff --git a/tests/test_secrets.py b/tests/test_secrets.py new file mode 100644 index 0000000..d62f9f7 --- /dev/null +++ b/tests/test_secrets.py @@ -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) == {} \ No newline at end of file diff --git a/workplans/SAND-WP-0011-reachability-and-consumer-profiles.md b/workplans/SAND-WP-0011-reachability-and-consumer-profiles.md index 890b7fc..80ef336 100644 --- a/workplans/SAND-WP-0011-reachability-and-consumer-profiles.md +++ b/workplans/SAND-WP-0011-reachability-and-consumer-profiles.md @@ -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 ` (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 ` 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_` 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. ---