From 7a6c3e025c273fa5bd4f4c4273e755b00f2107c7 Mon Sep 17 00:00:00 2001 From: CL Kao Date: Fri, 12 Jun 2026 23:07:41 -0700 Subject: [PATCH] feat(cli): show sandbox posture + per-runtime status on startup Three startup surfaces gain sandbox/runtime visibility (AC-1..AC-5): - launcher banner: a `Sandbox:` line after the workflow line, rendering the three-way state (enabled / available-not-enabled / unavailable) from the launcher's own wrap decision and safehouse-binary availability. - status --boot: a SANDBOX: section (and a `sandbox` field in --boot --json), sourced once in gatherBoot from a .safehouse profile at the repo root and a PATH scan for the safehouse binary. - --version: the load-bearing first line stays `spacedock (contract )` (the FO/ensign skill token); a Sandbox line and a per-runtime block are appended below it. claude/codex enablement reads the plugin-list `enabled` field; pi reuses checkPiRuntime; a probe that errors renders `installed, enablement unknown`, never silent `not installed`. The three-way state strings live once in safehouse.State so all surfaces read identically. claude pluginListEntry gains the `enabled` field. Detection is behind injected probe seams so no live host CLI runs in the test path; the PATH-dependent boot SANDBOX line is stripped from the byte-compared goldens like STATE_BACKEND, with its state-from-inputs behavior pinned separately. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/cli/cli.go | 21 ++- internal/cli/cli_test.go | 6 +- internal/cli/frontdoor.go | 17 +- internal/cli/host_exec.go | 7 +- internal/cli/host_runtime.go | 174 ++++++++++++++++++ internal/cli/launch_banner_sandbox_test.go | 45 +++++ internal/cli/launch_banner_test.go | 10 +- internal/cli/launch_banner_wording_test.go | 8 +- internal/cli/pi.go | 4 +- internal/cli/version_claude_enabled_test.go | 47 +++++ internal/cli/version_runtime_test.go | 110 +++++++++++ internal/safehouse/state.go | 25 +++ internal/safehouse/state_test.go | 37 ++++ internal/status/boot.go | 16 ++ internal/status/boot_sandbox_test.go | 130 +++++++++++++ internal/status/harness_test.go | 13 ++ internal/status/json_boot_test.go | 1 + internal/status/json_commands.go | 4 + internal/status/native_read_test.go | 2 +- internal/status/nextid_boot_test.go | 5 +- internal/status/zz_independent_parity_test.go | 3 + 21 files changed, 658 insertions(+), 27 deletions(-) create mode 100644 internal/cli/host_runtime.go create mode 100644 internal/cli/launch_banner_sandbox_test.go create mode 100644 internal/cli/version_claude_enabled_test.go create mode 100644 internal/cli/version_runtime_test.go create mode 100644 internal/safehouse/state.go create mode 100644 internal/safehouse/state_test.go create mode 100644 internal/status/boot_sandbox_test.go diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 47c63e820..0b29cac30 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -16,6 +16,7 @@ import ( "github.com/spacedock-dev/spacedock/internal/claudeteam" "github.com/spacedock-dev/spacedock/internal/contract" "github.com/spacedock-dev/spacedock/internal/dispatch" + "github.com/spacedock-dev/spacedock/internal/safehouse" "github.com/spacedock-dev/spacedock/internal/status" ) @@ -105,7 +106,7 @@ func newRootCommand(ctx context.Context, rawArgs []string, env []string, dir str CompletionOptions: cobra.CompletionOptions{DisableDefaultCmd: true}, RunE: func(cmd *cobra.Command, args []string) error { if versionFlag { - printVersion(stdout) + printVersion(stdout, dir, execRuntimeProbe{}, exec.LookPath) return nil } // No subcommand and no recognized flag: an unknown command token @@ -496,12 +497,20 @@ func cwd() string { return dir } -// printVersion emits the version line with the contract token. The token is -// load-bearing: the FO/ensign skills read `(contract N)` from `spacedock -// --version`, so cobra's auto version-flag (a bare version string, plus a command -// row in help) is deliberately NOT used. -func printVersion(w io.Writer) { +// printVersion emits the version line with the contract token, then the sandbox +// posture and a per-runtime install/enablement block. The FIRST line is unchanged +// — `spacedock (contract )` — and load-bearing: the FO/ensign skills read +// `(contract N)` from it, so everything new is appended BELOW line 1 (cobra's auto +// version-flag, a bare version string, is deliberately NOT used). The Sandbox line +// renders the shared three-way state for dir; the per-runtime block reports each +// host's binary install and spacedock-plugin enablement from the injected probe. +func printVersion(w io.Writer, dir string, probe runtimeProbe, lookPath func(string) (string, error)) { fmt.Fprintf(w, "spacedock %s (contract %d)\n", Version, contract.CONTRACT_VERSION) + available, _ := safehouse.Available(lookPath) + fmt.Fprintf(w, "Sandbox: %s\n", safehouse.State(safehouse.Present(dir), available)) + for _, host := range []string{"claude", "codex", "pi"} { + fmt.Fprintln(w, runtimeLine(host, probe.ProbeRuntime(host))) + } } // runCompletion emits a static shell-completion script for bash or zsh to diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index dba4575ab..68a68490f 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -88,9 +88,11 @@ func TestVersion(t *testing.T) { if code != 0 { t.Fatalf("Run returned %d, want 0", code) } + // The FIRST line is the load-bearing, FO-parsed version+contract line; the + // sandbox + per-runtime block follows it (asserted in version_runtime_test.go). want := "spacedock " + Version + " (contract " + strconv.Itoa(contract.CONTRACT_VERSION) + ")" - if got := strings.TrimSpace(stdout.String()); got != want { - t.Fatalf("version output = %q, want %q", got, want) + if got := strings.SplitN(stdout.String(), "\n", 2)[0]; got != want { + t.Fatalf("version first line = %q, want %q", got, want) } if stderr.Len() != 0 { t.Fatalf("stderr = %q, want empty", stderr.String()) diff --git a/internal/cli/frontdoor.go b/internal/cli/frontdoor.go index 77b5081c3..6417d8580 100644 --- a/internal/cli/frontdoor.go +++ b/internal/cli/frontdoor.go @@ -132,13 +132,18 @@ func noPluginRemedy(host string) string { } // launchBanner writes a short pre-launch orientation banner to w before the host -// is handed control: the spacedock version, the workflow detected from dir, and a -// one-line orientation pointer. Callers suppress it on a resume (the operator is -// continuing a session, not starting one). -func launchBanner(host, dir string, w io.Writer) { +// is handed control: the spacedock version, the workflow detected from dir, the +// sandbox posture, and a one-line orientation pointer. The Sandbox: line renders +// the shared three-way state from `selected` (whether this launch would be wrapped +// — a .safehouse profile or a --safehouse* flag) and whether the safehouse binary +// resolves via lookPath (injected so tests pin it). Callers suppress the banner on +// a resume (the operator is continuing a session, not starting one). +func launchBanner(host, dir string, selected bool, lookPath func(string) (string, error), w io.Writer) { label, value := detectedWorkflow(dir) + available, _ := safehouse.Available(lookPath) fmt.Fprintf(w, "spacedock %s · launching %s as your first officer\n", Version, host) fmt.Fprintf(w, "%s: %s\n", label, value) + fmt.Fprintf(w, "Sandbox: %s\n", safehouse.State(selected, available)) fmt.Fprintf(w, "%s is your first officer — ask it for the queue and next steps.\n", host) } @@ -294,7 +299,7 @@ func runClaude(ctx context.Context, args []string, dir string, ops hostOps, look wrap := safehouse.Present(dir) || fd.forceSafehouse || len(fd.safehouseFlags) > 0 resume := containsResume(fd.passthrough) if !resume { - launchBanner("claude", dir, stderr) + launchBanner("claude", dir, wrap, lookPath, stderr) } inner := []string{"claude"} if wrap { @@ -452,7 +457,7 @@ func runCodex(ctx context.Context, args []string, dir string, ops hostOps, lookP wrap := safehouse.Present(dir) || fd.forceSafehouse || len(fd.safehouseFlags) > 0 resume := codexResume(fd.passthrough) if !resume { - launchBanner("codex", dir, stderr) + launchBanner("codex", dir, wrap, lookPath, stderr) } inner := []string{"codex"} if wrap { diff --git a/internal/cli/host_exec.go b/internal/cli/host_exec.go index cd99510f8..c3427dcd6 100644 --- a/internal/cli/host_exec.go +++ b/internal/cli/host_exec.go @@ -19,11 +19,14 @@ type execHost struct{} var _ hostOps = execHost{} // pluginListEntry is the subset of ` plugin list --json` this binary -// reads: the `plugin@marketplace` id and the resolved install path. (Observed -// schema: the entry carries `id`, not separate name/marketplace fields.) +// reads: the `plugin@marketplace` id, the resolved install path, and whether the +// plugin is enabled in the host. (Observed schema: the entry carries `id`, not +// separate name/marketplace fields; `enabled` is distinct from `installPath` — +// an installed plugin can be present-but-disabled.) type pluginListEntry struct { ID string `json:"id"` InstallPath string `json:"installPath"` + Enabled bool `json:"enabled"` } // ResolveManifest returns the installed spacedock@spacedock plugin manifest path diff --git a/internal/cli/host_runtime.go b/internal/cli/host_runtime.go new file mode 100644 index 000000000..12eaf0385 --- /dev/null +++ b/internal/cli/host_runtime.go @@ -0,0 +1,174 @@ +// ABOUTME: Per-runtime install/enablement detection for --version — claude/codex +// ABOUTME: plugin-list enablement and pi readiness, behind an injectable probe seam. +package cli + +import ( + "encoding/json" + "fmt" + "os/exec" + "strings" +) + +// enablement is a host's spacedock-plugin enablement posture. The zero value is +// notEnabled (the safe default: a host we could read but found no enabled +// spacedock entry is not enabled). enablementUnknown is reserved for the probe +// that could not determine enablement — a binary that resolves but whose +// enablement read errored (the sandboxed `codex plugin list` "Operation not +// permitted" mode), distinct from a confidently not-enabled host. +type enablement int + +const ( + enablementNotEnabled enablement = iota + enablementEnabled + enablementUnknown +) + +// runtimeStatus is a host's --version posture: whether the host binary resolves, +// and (when it does) its spacedock-plugin enablement. enablement is meaningless +// when installed is false. +type runtimeStatus struct { + installed bool + enablement enablement +} + +// runtimeProbe reports a host's install + enablement posture. Production backs it +// with the real host CLIs (execRuntimeProbe); tests back it with a fake that pins +// each host's outcome, so --version never shells a live host CLI in the test path. +type runtimeProbe interface { + ProbeRuntime(host string) runtimeStatus +} + +// runtimeLine renders one host's --version line from its status. An absent binary +// is `not installed`; a probe that could not read enablement is `installed, +// enablement unknown` (never silently not installed); an enabled plugin is +// `installed, spacedock enabled`; otherwise the bare `installed`. +func runtimeLine(host string, s runtimeStatus) string { + if !s.installed { + return host + ": not installed" + } + switch s.enablement { + case enablementEnabled: + return host + ": installed, spacedock enabled" + case enablementUnknown: + return host + ": installed, enablement unknown" + default: + return host + ": installed" + } +} + +// claudeEnablement reads a `claude plugin list --json` body and resolves the +// spacedock@spacedock entry's enablement from its `enabled` boolean (AC-4): an +// entry with `enabled:true` is enabled, `enabled:false` (or no spacedock entry at +// all) is not enabled. A body that does not parse is an error so the caller renders +// `enablement unknown` rather than silently downgrading to not-enabled. +func claudeEnablement(body []byte) (enablement, error) { + var entries []pluginListEntry + if err := json.Unmarshal(body, &entries); err != nil { + return enablementUnknown, fmt.Errorf("parse claude plugin list --json: %w", err) + } + for _, e := range entries { + if e.ID == "spacedock@spacedock" { + if e.Enabled { + return enablementEnabled, nil + } + return enablementNotEnabled, nil + } + } + return enablementNotEnabled, nil +} + +// codexEntryEnabled reports whether the `codex plugin list` text output marks the +// given plugin id as enabled. It mirrors codexEntryInstalled's field-based parse: +// the id must be a whitespace-delimited field, and a following field (within the +// row, stripped of surrounding `()` and a trailing `,`) must equal `enabled`. The +// codex status renders as `installed, enabled` (table form) or `(installed, +// enabled)` (legacy paren form), so `enabled` is the field after `installed`. +func codexEntryEnabled(listing, id string) bool { + for _, line := range strings.Split(listing, "\n") { + fields := strings.Fields(line) + idIdx := -1 + for i, f := range fields { + if f == id { + idIdx = i + break + } + } + if idIdx < 0 { + continue + } + for _, f := range fields[idIdx+1:] { + if strings.Trim(f, "(),") == "enabled" { + return true + } + } + } + return false +} + +// execRuntimeProbe backs runtimeProbe with the real host CLIs and exec. It is the +// production seam --version uses; the test path injects a fake instead. +type execRuntimeProbe struct{} + +var _ runtimeProbe = execRuntimeProbe{} + +// ProbeRuntime resolves the host binary on PATH, then (when present) reads its +// spacedock-plugin enablement. A binary that does not resolve is not installed; a +// binary that resolves but whose enablement read errors is `enablement unknown` +// (the sandbox-denied probe), never silently downgraded to not-installed. +func (execRuntimeProbe) ProbeRuntime(host string) runtimeStatus { + if _, err := exec.LookPath(host); err != nil { + return runtimeStatus{installed: false} + } + switch host { + case "claude": + return runtimeStatus{installed: true, enablement: probeClaudeEnablement()} + case "codex": + return runtimeStatus{installed: true, enablement: probeCodexEnablement()} + case "pi": + return runtimeStatus{installed: true, enablement: probePiEnablement()} + default: + return runtimeStatus{installed: true, enablement: enablementUnknown} + } +} + +// probeClaudeEnablement shells `claude plugin list --json` and reads the spacedock +// entry's `enabled` field. A failed command or unparseable body is `enablement +// unknown` (the host resolved, but enablement could not be determined). +func probeClaudeEnablement() enablement { + out, err := exec.Command("claude", "plugin", "list", "--json").Output() + if err != nil { + return enablementUnknown + } + state, err := claudeEnablement(out) + if err != nil { + return enablementUnknown + } + return state +} + +// probeCodexEnablement shells `codex plugin list` and reads the spacedock entry's +// `enabled` status from the text listing. A failed command (the sandbox-denied +// "Operation not permitted" mode) is `enablement unknown`; an installed-but-not- +// enabled entry is not enabled. +func probeCodexEnablement() enablement { + out, err := exec.Command("codex", "plugin", "list").CombinedOutput() + if err != nil { + return enablementUnknown + } + if codexEntryEnabled(string(out), "spacedock@spacedock") { + return enablementEnabled + } + return enablementNotEnabled +} + +// probePiEnablement runs the existing pi-runtime readiness check (pi has no +// plugin-list model — verified in the spike), so pi's "spacedock enabled" reuses +// piRuntimeLaunchReady (skills + extension present), NOT a plugin probe. Pi +// readiness is a boolean, so it never resolves to `enablement unknown`. +func probePiEnablement() enablement { + cfg := piRuntimeConfigFromEnv(nil, cwd(), "") + if piRuntimeLaunchReady(checkPiRuntime(execPiRuntimeOps{}, cfg)) { + return enablementEnabled + } + return enablementNotEnabled +} diff --git a/internal/cli/launch_banner_sandbox_test.go b/internal/cli/launch_banner_sandbox_test.go new file mode 100644 index 000000000..657f5e9ca --- /dev/null +++ b/internal/cli/launch_banner_sandbox_test.go @@ -0,0 +1,45 @@ +// ABOUTME: AC-1 oracle — the launcher banner carries a Sandbox: line whose text +// ABOUTME: matches the three-way state from (profile-present, binary-available). +package cli + +import ( + "bytes" + "testing" +) + +// renderBannerSandbox exercises the real launchBanner for host from dir against a +// pinned lookPath stub (so the safehouse-binary availability is controlled, not +// read from the machine PATH) and returns the rendered stderr bytes. `selected` +// mirrors the launcher's wrap decision (a profile present, or a --safehouse* flag). +func renderBannerSandbox(host, dir string, selected bool, lookPath func(string) (string, error)) string { + var buf bytes.Buffer + launchBanner(host, dir, selected, lookPath, &buf) + return buf.String() +} + +// TestLaunchBannerSandboxLine (AC-1) drives launchBanner over each of the three +// (selected, available) input combinations and asserts the exact rendered Sandbox: +// line. The expected state strings are written here independently of the source, +// so a reverted-wording edit fails this assertion. `selected` is the launcher's +// own wrap decision; availability is pinned via lookFound / lookMissing. +func TestLaunchBannerSandboxLine(t *testing.T) { + cases := []struct { + name string + selected bool + lookPath func(string) (string, error) + want string + }{ + {"enabled", true, lookFound, "Sandbox: enabled (safehouse)"}, + {"available-not-enabled", false, lookFound, "Sandbox: available, not enabled (no .safehouse profile)"}, + {"unavailable", false, lookMissing, "Sandbox: unavailable (safehouse not on PATH)"}, + {"unavailable-even-when-selected", true, lookMissing, "Sandbox: unavailable (safehouse not on PATH)"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + out := renderBannerSandbox("claude", t.TempDir(), tc.selected, tc.lookPath) + if !lineEquals(out, tc.want) { + t.Fatalf("banner sandbox line = %q, want a whole line %q", out, tc.want) + } + }) + } +} diff --git a/internal/cli/launch_banner_test.go b/internal/cli/launch_banner_test.go index 3f7408448..213c2580a 100644 --- a/internal/cli/launch_banner_test.go +++ b/internal/cli/launch_banner_test.go @@ -81,7 +81,7 @@ func TestLaunchBannerNamesDetectedWorkflow(t *testing.T) { t.Run("from the repo root names the real workflow, not the .claude/.worktrees noise", func(t *testing.T) { repo, _ := repoWithRealWorkflowAndNoise(t) var buf bytes.Buffer - launchBanner("claude", repo, &buf) + launchBanner("claude", repo, false, lookMissing, &buf) out := buf.String() if !strings.Contains(out, "spacedock "+Version) { @@ -105,7 +105,7 @@ func TestLaunchBannerNamesDetectedWorkflow(t *testing.T) { t.Fatal(err) } var buf bytes.Buffer - launchBanner("codex", sub, &buf) + launchBanner("codex", sub, false, lookMissing, &buf) out := buf.String() if !strings.Contains(out, "Workflow: "+filepath.Join("docs", "dev")+"\n") { @@ -118,7 +118,7 @@ func TestLaunchBannerNamesDetectedWorkflow(t *testing.T) { commissionWorkflowAt(t, filepath.Join(repo, "docs", "dev")) commissionWorkflowAt(t, filepath.Join(repo, "ops", "release")) var buf bytes.Buffer - launchBanner("claude", repo, &buf) + launchBanner("claude", repo, false, lookMissing, &buf) out := buf.String() wantLine := "Workflows: " + filepath.Join("docs", "dev") + " " + filepath.Join("ops", "release") + "\n" @@ -136,7 +136,7 @@ func TestLaunchBannerNamesDetectedWorkflow(t *testing.T) { workflow := t.TempDir() commissionWorkflowAt(t, workflow) var buf bytes.Buffer - launchBanner("codex", workflow, &buf) + launchBanner("codex", workflow, false, lookMissing, &buf) out := buf.String() if !strings.Contains(out, "Workflow: "+filepath.Base(workflow)+"\n") { @@ -153,7 +153,7 @@ func TestLaunchBannerNamesDetectedWorkflow(t *testing.T) { t.Run("outside any workflow", func(t *testing.T) { bare := t.TempDir() // no commissioned README in or above this temp dir var buf bytes.Buffer - launchBanner("codex", bare, &buf) + launchBanner("codex", bare, false, lookMissing, &buf) out := buf.String() if !strings.Contains(out, "spacedock "+Version) { diff --git a/internal/cli/launch_banner_wording_test.go b/internal/cli/launch_banner_wording_test.go index 3ce5bd11d..7a448715a 100644 --- a/internal/cli/launch_banner_wording_test.go +++ b/internal/cli/launch_banner_wording_test.go @@ -13,10 +13,13 @@ import ( // renderBanner exercises the real launchBanner for host from dir and returns the // rendered stderr bytes — the proof surface for AC-1/2/3/4/5 (the bytes the -// operator actually sees), never a source-grep of the constant. +// operator actually sees), never a source-grep of the constant. The sandbox +// inputs are pinned (not selected, binary not found) so the Sandbox: line is a +// stable `unavailable` for these workflow-line oracles; the sandbox three-way +// states are proven independently by TestLaunchBannerSandboxLine. func renderBanner(host, dir string) string { var buf bytes.Buffer - launchBanner(host, dir, &buf) + launchBanner(host, dir, false, lookMissing, &buf) return buf.String() } @@ -125,6 +128,7 @@ func TestLaunchBannerSingleWorkflowGolden(t *testing.T) { want := "spacedock " + Version + " · launching claude as your first officer\n" + "Workflow: " + filepath.Join("docs", "dev") + "\n" + + "Sandbox: unavailable (safehouse not on PATH)\n" + "claude is your first officer — ask it for the queue and next steps.\n" if got := renderBanner("claude", repo); got != want { t.Fatalf("single-workflow banner golden mismatch:\n got=%q\nwant=%q", got, want) diff --git a/internal/cli/pi.go b/internal/cli/pi.go index 33cc0c748..3203246c5 100644 --- a/internal/cli/pi.go +++ b/internal/cli/pi.go @@ -10,6 +10,8 @@ import ( "strings" "github.com/spf13/pflag" + + "github.com/spacedock-dev/spacedock/internal/safehouse" ) const piBootstrapPrompt = "Use $spacedock:first-officer for this whole Pi session." @@ -77,7 +79,7 @@ func runPi(ctx context.Context, args []string, dir string, env []string, ops piR return 1 } - launchBanner("pi", dir, stderr) + launchBanner("pi", dir, safehouse.Present(dir), ops.LookPath, stderr) argv := []string{ "pi", diff --git a/internal/cli/version_claude_enabled_test.go b/internal/cli/version_claude_enabled_test.go new file mode 100644 index 000000000..df231f95a --- /dev/null +++ b/internal/cli/version_claude_enabled_test.go @@ -0,0 +1,47 @@ +// ABOUTME: AC-4 oracle — claude spacedock-enablement reflects the plugin list +// ABOUTME: `enabled` boolean, not mere presence; a false entry is not "enabled". +package cli + +import "testing" + +// TestClaudeEnablementFromListJSON (AC-4) feeds the enablement reader a `claude +// plugin list --json` body with the spacedock entry's `enabled` set true vs false +// (and absent) and asserts the resolved enablement. The fixture JSON — shaped from +// the live spike capture (id + installPath + enabled) — is the independent source +// of truth: an `enabled:false` entry must NOT resolve to enabled. +func TestClaudeEnablementFromListJSON(t *testing.T) { + const enabledTrue = `[{"id":"spacedock@spacedock","installPath":"/p/0.19.9","enabled":true}, + {"id":"other@market","installPath":"/q","enabled":true}]` + const enabledFalse = `[{"id":"spacedock@spacedock","installPath":"/p/0.19.9","enabled":false}]` + const noSpacedock = `[{"id":"other@market","installPath":"/q","enabled":true}]` + + cases := []struct { + name string + body string + want enablement + }{ + {"enabled-true", enabledTrue, enablementEnabled}, + {"enabled-false", enabledFalse, enablementNotEnabled}, + {"no-spacedock-entry", noSpacedock, enablementNotEnabled}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := claudeEnablement([]byte(tc.body)) + if err != nil { + t.Fatalf("claudeEnablement(%s) err = %v, want nil", tc.name, err) + } + if got != tc.want { + t.Fatalf("claudeEnablement(%s) = %v, want %v", tc.name, got, tc.want) + } + }) + } +} + +// TestClaudeEnablementMalformedJSONErrors (AC-5 feeder) asserts a body that does +// not parse is an error, so the caller renders `enablement unknown` rather than +// silently downgrading to not-enabled. +func TestClaudeEnablementMalformedJSONErrors(t *testing.T) { + if _, err := claudeEnablement([]byte("not json")); err == nil { + t.Fatalf("claudeEnablement(malformed) err = nil, want a parse error so the caller renders enablement unknown") + } +} diff --git a/internal/cli/version_runtime_test.go b/internal/cli/version_runtime_test.go new file mode 100644 index 000000000..a06992778 --- /dev/null +++ b/internal/cli/version_runtime_test.go @@ -0,0 +1,110 @@ +// ABOUTME: AC-3/AC-5 oracles for --version — the load-bearing first line, the +// ABOUTME: Sandbox line, and the per-runtime install/enablement block from stubs. +package cli + +import ( + "bytes" + "regexp" + "strconv" + "strings" + "testing" + + "github.com/spacedock-dev/spacedock/internal/contract" +) + +// fakeRuntimeProbe pins each host's install/enablement outcome so the per-runtime +// block renders from injected state, never a live host CLI. +type fakeRuntimeProbe struct { + status map[string]runtimeStatus +} + +func (f fakeRuntimeProbe) ProbeRuntime(host string) runtimeStatus { + return f.status[host] +} + +// renderVersion captures printVersion over an injected probe and a pinned lookPath +// (so the Sandbox line is a stable `unavailable`, asserted on its own elsewhere) +// and returns stdout. +func renderVersion(probe runtimeProbe) string { + var buf bytes.Buffer + printVersion(&buf, t1TempDir, probe, lookMissing) + return buf.String() +} + +// t1TempDir is a directory with no .safehouse profile, so the version Sandbox line +// renders deterministically as unavailable under lookMissing. A literal keeps the +// helper allocation-free; the path need not exist for safehouse.Present. +const t1TempDir = "/nonexistent-version-dir" + +// TestVersionFirstLineUnchanged (AC-3 part 1) pins the load-bearing invariant: the +// FIRST line still matches `^spacedock .* \(contract \d+\)$`, the token the FO and +// ensign skills parse. Appending the sandbox + per-runtime block must not disturb +// line 1. +func TestVersionFirstLineUnchanged(t *testing.T) { + probe := fakeRuntimeProbe{status: map[string]runtimeStatus{ + "claude": {installed: true, enablement: enablementEnabled}, + "codex": {installed: true, enablement: enablementNotEnabled}, + "pi": {installed: false}, + }} + out := renderVersion(probe) + firstLine := strings.SplitN(out, "\n", 2)[0] + re := regexp.MustCompile(`^spacedock .* \(contract \d+\)$`) + if !re.MatchString(firstLine) { + t.Fatalf("version first line %q does not match the FO-parsed pattern", firstLine) + } + want := "spacedock " + Version + " (contract " + strconv.Itoa(contract.CONTRACT_VERSION) + ")" + if firstLine != want { + t.Fatalf("version first line = %q, want %q", firstLine, want) + } +} + +// TestVersionPerRuntimeBlock (AC-3 part 2) drives each install/enablement outcome +// through the injected probe and asserts the exact per-runtime line, plus a Sandbox +// line. The expected strings are independent test-supplied values. +func TestVersionPerRuntimeBlock(t *testing.T) { + probe := fakeRuntimeProbe{status: map[string]runtimeStatus{ + "claude": {installed: true, enablement: enablementEnabled}, + "codex": {installed: true, enablement: enablementNotEnabled}, + "pi": {installed: false}, + }} + out := renderVersion(probe) + + for _, want := range []string{ + "Sandbox: unavailable (safehouse not on PATH)", + "claude: installed, spacedock enabled", + "codex: installed", + "pi: not installed", + } { + if !lineEquals(out, want) { + t.Fatalf("version output missing whole line %q:\n%s", want, out) + } + } +} + +// TestVersionEnablementUnknown (AC-5) injects a probe whose enablement read errored +// (binary resolves, but the probe could not determine enablement — the sandboxed +// `codex plugin list` "Operation not permitted" mode). The line must read +// `installed, enablement unknown`, never silently `not installed`. A separate +// binary-absent case asserts `not installed`. +func TestVersionEnablementUnknown(t *testing.T) { + probe := fakeRuntimeProbe{status: map[string]runtimeStatus{ + "claude": {installed: true, enablement: enablementUnknown}, + "codex": {installed: false}, // binary absent → not installed + "pi": {installed: true, enablement: enablementUnknown}, + }} + out := renderVersion(probe) + + for _, want := range []string{ + "claude: installed, enablement unknown", + "codex: not installed", + "pi: installed, enablement unknown", + } { + if !lineEquals(out, want) { + t.Fatalf("version output missing whole line %q:\n%s", want, out) + } + } + // AC-5 guard: an enablement-unknown host must NOT be rendered as not installed. + if lineEquals(out, "claude: not installed") { + t.Fatalf("enablement-unknown claude silently rendered as not installed:\n%s", out) + } +} diff --git a/internal/safehouse/state.go b/internal/safehouse/state.go new file mode 100644 index 000000000..7e23e0efd --- /dev/null +++ b/internal/safehouse/state.go @@ -0,0 +1,25 @@ +// ABOUTME: The shared three-way sandbox-state render strings (enabled / +// ABOUTME: available-not-enabled / unavailable) the startup surfaces all source. +package safehouse + +// State renders the three-way sandbox posture from the two inputs the launch +// decision already turns on: `selected` is whether a launch would be wrapped (a +// .safehouse profile is present, or a --safehouse* flag forced it), and +// `available` is whether the safehouse binary resolves on PATH. The launcher +// banner, `status --boot`, and `--version` all read these strings so the posture +// reads identically across surfaces. +// +// - unavailable — the binary is not on PATH; nothing can wrap the launch, so a +// present profile cannot take effect. This dominates even when selected. +// - enabled — available and selected: the launch will be wrapped through safehouse. +// - available, not enabled — available but nothing selects it: the binary is +// installed, this launch is not sandboxed. +func State(selected, available bool) string { + if !available { + return "unavailable (safehouse not on PATH)" + } + if selected { + return "enabled (safehouse)" + } + return "available, not enabled (no .safehouse profile)" +} diff --git a/internal/safehouse/state_test.go b/internal/safehouse/state_test.go new file mode 100644 index 000000000..52200d4f5 --- /dev/null +++ b/internal/safehouse/state_test.go @@ -0,0 +1,37 @@ +// ABOUTME: Unit tests for the shared three-way sandbox-state render strings the +// ABOUTME: launcher banner, status --boot, and --version all source from State. +package safehouse + +import "testing" + +// TestStateThreeWay pins the three rendered sandbox-state strings against their +// (selected, available) inputs. `selected` means a launch would be wrapped (a +// .safehouse profile is present, or a --safehouse* flag forced it); `available` +// means the safehouse binary resolves on PATH. The three surfaces (banner, boot, +// --version) all read these strings, so they are the single source of truth. +func TestStateThreeWay(t *testing.T) { + cases := []struct { + name string + selected bool + available bool + want string + }{ + // available and selected → the launch will be wrapped through safehouse. + {"enabled", true, true, "enabled (safehouse)"}, + // available but nothing selected it → the binary is installed, this launch + // is not sandboxed. + {"available-not-enabled", false, true, "available, not enabled (no .safehouse profile)"}, + // the binary is not on PATH → a present profile cannot take effect. + {"unavailable", false, false, "unavailable (safehouse not on PATH)"}, + // the binary is absent even with a profile selected → still unavailable; the + // missing binary dominates because nothing can wrap the launch. + {"unavailable-even-when-selected", true, false, "unavailable (safehouse not on PATH)"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := State(tc.selected, tc.available); got != tc.want { + t.Fatalf("State(%v, %v) = %q, want %q", tc.selected, tc.available, got, tc.want) + } + }) + } +} diff --git a/internal/status/boot.go b/internal/status/boot.go index bd240af87..0e3cee702 100644 --- a/internal/status/boot.go +++ b/internal/status/boot.go @@ -13,6 +13,7 @@ import ( "time" "github.com/spacedock-dev/spacedock/internal/claudeteam" + "github.com/spacedock-dev/spacedock/internal/safehouse" ) // teamStateNeutralHint is the boot TEAM_STATE present:false hint on a host with @@ -150,6 +151,11 @@ type bootData struct { definitionDir string entityDir string entityDirPresent bool + // Sandbox posture: the three-way safehouse state (enabled / available, not + // enabled / unavailable) computed from a .safehouse profile at the repo root and + // whether the safehouse binary resolves on PATH, so the operator sees the + // execution-isolation posture before dispatching work. + sandbox string } // gatherBoot runs every boot probe once and returns the result. NEXT_ID is @@ -200,6 +206,13 @@ func gatherBoot(probe claudeteam.TeamStateProbe, entities []*entity, stages []St if info, err := os.Stat(entityDir); err == nil && info.IsDir() { d.entityDirPresent = true } + + // SANDBOX: the .safehouse profile lives at the repo root (the launch convention), + // and the safehouse binary is resolved against the request PATH via the existing + // executable scan — no exec, no live host CLI. boot is a read, so nothing here + // selects the sandbox beyond a present profile. + available := lookupExecutable("safehouse", e.get("PATH")) != "" + d.sandbox = safehouse.State(safehouse.Present(gitRoot), available) return d, nil } @@ -282,5 +295,8 @@ func printBoot(probe claudeteam.TeamStateProbe, w io.Writer, entities []*entity, // STATE_BACKEND fmt.Fprintf(w, "STATE_BACKEND: %s (entity_dir: %s, present: %t)\n", d.stateBackend, d.entityDir, d.entityDirPresent) + + // SANDBOX: appended last so every prior section's order is preserved. + fmt.Fprintf(w, "SANDBOX: %s\n", d.sandbox) return nil } diff --git a/internal/status/boot_sandbox_test.go b/internal/status/boot_sandbox_test.go new file mode 100644 index 000000000..68e6aa75e --- /dev/null +++ b/internal/status/boot_sandbox_test.go @@ -0,0 +1,130 @@ +// ABOUTME: AC-2 — status --boot emits a SANDBOX: section (and --boot --json a +// ABOUTME: sandbox field) whose state matches the profile-present/binary inputs. +package status + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +const sandboxBootReadme = `--- +commissioned-by: spacedock@1 +id-style: slug +stages: + states: + - name: build + initial: true +--- + +# Sandbox Boot Workflow +` + +// sandboxBootFixture builds a git-rooted workflow, optionally writing a .safehouse +// profile at the repo root, and returns the workflow dir plus a PATH whose +// safehouse-binary resolution is pinned by `available`. The .safehouse profile and +// the safehouse binary are the two independent inputs the SANDBOX state turns on. +func sandboxBootFixture(t *testing.T, profilePresent, available bool) (root string, env []string) { + t.Helper() + root = t.TempDir() + gitC(t, root, "init") + writeFile(t, filepath.Join(root, "README.md"), sandboxBootReadme) + if profilePresent { + writeFile(t, filepath.Join(root, ".safehouse"), "profile") + } + pathDir := t.TempDir() + if available { + bin := filepath.Join(pathDir, "safehouse") + if err := os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + } + env = append(pinnedEnv(t), "") + // Replace PATH so safehouse resolution is controlled, not read from the machine. + for i, kv := range env { + if strings.HasPrefix(kv, "PATH=") { + env[i] = "PATH=" + pathDir + } + } + if env[len(env)-1] == "" { + env = env[:len(env)-1] + } + return root, env +} + +// sandboxLineOf returns the whole SANDBOX: line from a --boot text body. +func sandboxLineOf(t *testing.T, boot string) string { + t.Helper() + for _, line := range strings.Split(boot, "\n") { + if strings.HasPrefix(line, "SANDBOX:") { + return line + } + } + t.Fatalf("--boot output has no SANDBOX: line:\n%s", boot) + return "" +} + +// TestBootSandboxSection (AC-2) drives --boot over a fixture with the .safehouse +// profile present/absent and the binary found/not-found, and asserts the exact +// SANDBOX: line for each combination. The expected state strings are independent +// test-supplied values. +func TestBootSandboxSection(t *testing.T) { + cases := []struct { + name string + profilePresent bool + available bool + want string + }{ + {"enabled", true, true, "SANDBOX: enabled (safehouse)"}, + {"available-not-enabled", false, true, "SANDBOX: available, not enabled (no .safehouse profile)"}, + {"unavailable-with-profile", true, false, "SANDBOX: unavailable (safehouse not on PATH)"}, + {"unavailable-no-profile", false, false, "SANDBOX: unavailable (safehouse not on PATH)"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + root, env := sandboxBootFixture(t, tc.profilePresent, tc.available) + out, errOut, code := runNative(t, root, env, "--workflow-dir", root, "--boot") + if code != 0 { + t.Fatalf("--boot exit=%d stderr=%q", code, errOut) + } + if got := sandboxLineOf(t, out); got != tc.want { + t.Fatalf("SANDBOX line = %q, want %q", got, tc.want) + } + }) + } +} + +// TestBootSandboxJSONField (AC-2) asserts the --boot --json form gains a `sandbox` +// string field carrying the same three-way state, for the present+available and +// absent+unavailable corners. +func TestBootSandboxJSONField(t *testing.T) { + cases := []struct { + name string + profilePresent bool + available bool + want string + }{ + {"enabled", true, true, "enabled (safehouse)"}, + {"unavailable", false, false, "unavailable (safehouse not on PATH)"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + root, env := sandboxBootFixture(t, tc.profilePresent, tc.available) + out, errOut, code := runNative(t, root, env, "--workflow-dir", root, "--boot", "--json") + if code != 0 { + t.Fatalf("--boot --json exit=%d stderr=%q", code, errOut) + } + var boot struct { + Sandbox string `json:"sandbox"` + } + if err := json.Unmarshal([]byte(out), &boot); err != nil { + t.Fatalf("parse --boot --json: %v\n%s", err, out) + } + if boot.Sandbox != tc.want { + t.Fatalf("sandbox field = %q, want %q", boot.Sandbox, tc.want) + } + }) + } +} diff --git a/internal/status/harness_test.go b/internal/status/harness_test.go index c8fd6f1b3..8553bba9d 100644 --- a/internal/status/harness_test.go +++ b/internal/status/harness_test.go @@ -52,6 +52,19 @@ func stripStateBackend(s string) string { return stateBackendLineRe.ReplaceAllString(s, "") } +// sandboxLineRe matches the native-only SANDBOX boot banner. Its rendered state +// depends on whether the safehouse binary resolves on the runner's PATH, so it is +// machine-dependent (red-by-construction on a machine with safehouse installed); +// the boot-text goldens strip it the same way they strip STATE_BACKEND. The +// state-from-inputs behavior is pinned deterministically by boot_sandbox_test.go. +var sandboxLineRe = regexp.MustCompile(`SANDBOX: [^\n]*\n`) + +// stripSandbox removes the native-only SANDBOX boot banner so the PATH-dependent +// line never enters a byte-compared golden. +func stripSandbox(s string) string { + return sandboxLineRe.ReplaceAllString(s, "") +} + // bootNextIDLineRe matches ONLY the boot `NEXT_ID:` line's sd-b32 value — the // minted candidate, which hashes the realpath'd workflow dir and so varies per // checkout path. Anchored to the line prefix so it does NOT touch a stored diff --git a/internal/status/json_boot_test.go b/internal/status/json_boot_test.go index 62ccdb8c4..25737cc73 100644 --- a/internal/status/json_boot_test.go +++ b/internal/status/json_boot_test.go @@ -18,6 +18,7 @@ var bootJSONKeys = []string{ "command", "mods", "id_style", "next_id", "min_prefix", "orphans", "pr_state", "dispatchable", "team_state", "state_backend", "definition_dir", "entity_dir", "entity_dir_present", + "sandbox", } // TestBootJSONStructure (AC-1 oracle e) mirrors nextid_boot_test.go for the JSON diff --git a/internal/status/json_commands.go b/internal/status/json_commands.go index c241e47d9..ac013c03d 100644 --- a/internal/status/json_commands.go +++ b/internal/status/json_commands.go @@ -197,6 +197,10 @@ func bootJSON(d *bootData) *jsonObj { out.set("entity_dir", d.entityDir) out.set("entity_dir_present", strconv.FormatBool(d.entityDirPresent)) + // sandbox: the three-way safehouse posture, appended AFTER the state-backend keys + // so every existing key's relative order is preserved for the FO's key-order parse. + out.set("sandbox", d.sandbox) + return out } diff --git a/internal/status/native_read_test.go b/internal/status/native_read_test.go index b306d4b9c..d974ed871 100644 --- a/internal/status/native_read_test.go +++ b/internal/status/native_read_test.go @@ -99,6 +99,6 @@ func TestNativeBootMatchesOracle(t *testing.T) { if nativeCode != 0 { t.Fatalf("native --boot exit=%d stderr=%q", nativeCode, nativeErr) } - normNative := maskBootNextID(stripStateBackend(normalize(nativeOut, root))) + normNative := maskBootNextID(stripSandbox(stripStateBackend(normalize(nativeOut, root)))) assertTextGolden(t, "native-boot-sdb32", normNative) } diff --git a/internal/status/nextid_boot_test.go b/internal/status/nextid_boot_test.go index 598d77d37..8c3f6397c 100644 --- a/internal/status/nextid_boot_test.go +++ b/internal/status/nextid_boot_test.go @@ -87,8 +87,9 @@ func TestSDB32CandidateDerivationVector(t *testing.T) { // bootSections are the --boot section headers in their required order. The FO // parses --boot by section at startup, so order and presence are load-bearing. +// SANDBOX is appended last (after STATE_BACKEND), so the order check pins it there. var bootSections = []string{ - "MODS:", "ID_STYLE:", "NEXT_ID:", "ORPHANS:", "PR_STATE:", "DISPATCHABLE", "TEAM_STATE", + "MODS:", "ID_STYLE:", "NEXT_ID:", "ORPHANS:", "PR_STATE:", "DISPATCHABLE", "TEAM_STATE", "STATE_BACKEND:", "SANDBOX:", } // AC-1: --boot is verified structurally (headers present and in order) and the @@ -130,6 +131,6 @@ func TestBootStructuralParity(t *testing.T) { // Structural --boot frozen against the certified native output: the minted // NEXT_ID line is masked (path-dependent), every other token — including the // fixed fixture id in the DISPATCHABLE table — freezes literally. - normNative := maskBootNextID(normalize(nativeOut, root)) + normNative := maskBootNextID(stripSandbox(normalize(nativeOut, root))) assertTextGolden(t, "boot-structural", normNative) } diff --git a/internal/status/zz_independent_parity_test.go b/internal/status/zz_independent_parity_test.go index e67b7eb34..33e0a92a3 100644 --- a/internal/status/zz_independent_parity_test.go +++ b/internal/status/zz_independent_parity_test.go @@ -66,6 +66,9 @@ func indNormalize(s, root string) string { // line) — the same documented native/oracle divergence the in-tree harness // strips; the backend keys are byte-pinned in json_boot_test.go instead. s = stripStateBackend(s) + // SANDBOX is likewise native-only and PATH-dependent — strip it from the + // parity body; the state-from-inputs behavior is pinned by boot_sandbox_test.go. + s = stripSandbox(s) if root != "" { if real, err := filepath.EvalSymlinks(root); err == nil && real != root { s = strings.ReplaceAll(s, real, "")