diff --git a/docs/site/reference/command-reference.md b/docs/site/reference/command-reference.md index c53777e49..066ec6c53 100644 --- a/docs/site/reference/command-reference.md +++ b/docs/site/reference/command-reference.md @@ -39,7 +39,7 @@ The `Sandbox:` line answers one question — is this process sandboxed? Sandboxe `spacedock status --boot` reports the same three. The pre-launch banner answers the neighbouring but different question — whether the launch it is about to perform will be wrapped — so its `Sandbox:` line reads in terms of that launch. -The trailing `contract 3` is a frozen compatibility sentinel read only by skill versions predating the current version gate. It prints inside a session only. For what is installed for each host — plugin versions and enablement — use `spacedock doctor`. +The trailing `contract 3` is a frozen compatibility sentinel read only by skill versions predating the current version gate. It prints inside a session only. Use `spacedock doctor --host ` to compare this binary with the selected channel plugin. If another channel is enabled, doctor names both plugins and reports the load conflict without changing it. A normal launch repairs that conflict before starting the host; under `--no-install`, run `spacedock install --host ` manually. ## Launch diff --git a/internal/cli/frontdoor.go b/internal/cli/frontdoor.go index afc760d09..b6fe500c8 100644 --- a/internal/cli/frontdoor.go +++ b/internal/cli/frontdoor.go @@ -35,6 +35,8 @@ type hostOps interface { // when no plugin is installed (a distinct, non-error state). A non-nil error // means the host CLI itself failed. ResolveManifest(host string) (string, error) + // PluginInventory supplies the launch gate and doctor's sibling-channel view. + PluginInventory(host string) ([]pluginInventoryEntry, error) // Launch spawns argv with env as a resident child and waits (production) or // records it (test), returning the host's propagated exit code. The error is // reserved for a launch failure (host binary not found, fork failure), not a @@ -51,6 +53,14 @@ type hostOps interface { InstallCodexLocalPluginDir(source string) (string, error) } +// pluginInventoryEntry normalizes the Claude and Codex plugin-list schemas. +type pluginInventoryEntry struct { + ID string + Version string + Installed bool + Enabled bool +} + // devBranch is the binary's channel stamp: it selects which marketplace entry the // install targets — `main` installs the stable `spacedock` entry, any other value // (default `next`) installs the `spacedock-edge` entry tracking next HEAD (see @@ -322,8 +332,8 @@ func gateHost(ops hostOps, host string, stderr io.Writer) contract.Result { } } -// resolveHealableGate runs the version gate for host and, for its two healable -// verdicts (NoPluginFound, TooOldPlugin), either auto-installs the plugin and +// resolveHealableGate runs the version gate for host and, for its healable +// states (NoPluginFound, TooOldPlugin, or an enabled sibling), auto-installs and // re-gates ONCE (the default) or refuses with the host-correct remedy // (--no-install) — D6's shared "a single command yields a working session" // contract for both front doors. A too-old-plugin heal announces "Refreshing" @@ -334,34 +344,58 @@ func gateHost(ops hostOps, host string, stderr io.Writer) contract.Result { // already on stderr). func resolveHealableGate(ops hostOps, host string, noInstall bool, stderr io.Writer) bool { res := gateHost(ops, host, stderr) + announce := "" switch res.Verdict { case contract.Compatible: - return true - case contract.NoPluginFound, contract.TooOldPlugin: - if noInstall { - printHealableRemedy(host, res, stderr) + inventory, err := ops.PluginInventory(host) + if err != nil { + fmt.Fprintf(stderr, "Spacedock: could not verify the %s plugin enablement state: %v.\n", host, err) + fmt.Fprintf(stderr, "Run `spacedock install --host %s` before launching.\n", host) return false } - announce := "Installing the " + host + " plugin…" + if _, conflict := enabledSiblingPlugin(inventory); !conflict { + return true + } + announce = "Refreshing the " + host + " plugin to remove its enabled sibling…" + case contract.NoPluginFound, contract.TooOldPlugin: + announce = "Installing the " + host + " plugin…" if res.Verdict == contract.TooOldPlugin { announce = "Refreshing the " + host + " plugin…" } - fmt.Fprintln(stderr, announce) - if _, err := ops.Install(host, channelMarketplaceSource(devBranch), devBranch); err != nil { - fmt.Fprintf(stderr, "spacedock %s: auto-install failed: %v\n", host, err) - return false - } - regate := gateHost(ops, host, stderr) - if regate.Verdict != contract.Compatible { - printHealableRemedy(host, regate, stderr) - return false - } - return true default: // too-old-binary / malformed-version: gateHost already printed the // remedy. Fail fast — auto-installing would not fix an incompatibility. return false } + if noInstall { + if res.Verdict == contract.Compatible { + printSiblingRemedy(host, stderr) + } else { + printHealableRemedy(host, res, stderr) + } + return false + } + fmt.Fprintln(stderr, announce) + if _, err := ops.Install(host, channelMarketplaceSource(devBranch), devBranch); err != nil { + fmt.Fprintf(stderr, "spacedock %s: auto-install failed: %v\n", host, err) + return false + } + regate := gateHost(ops, host, stderr) + if regate.Verdict != contract.Compatible { + printHealableRemedy(host, regate, stderr) + return false + } + inventory, err := ops.PluginInventory(host) + if _, conflict := enabledSiblingPlugin(inventory); err != nil || conflict { + fmt.Fprintf(stderr, "Spacedock: the %s plugin repair did not leave one enabled channel.\n", host) + printSiblingRemedy(host, stderr) + return false + } + return true +} + +func printSiblingRemedy(host string, stderr io.Writer) { + fmt.Fprintf(stderr, "Run `spacedock install --host %s` to keep only the %s channel.\n", host, selectedChannelWord()) } // printHealableRemedy prints the caller-owned remedy for a NoPluginFound or diff --git a/internal/cli/frontdoor_test.go b/internal/cli/frontdoor_test.go index d68ccbdfb..fe51a3130 100644 --- a/internal/cli/frontdoor_test.go +++ b/internal/cli/frontdoor_test.go @@ -9,6 +9,7 @@ import ( "fmt" "os" "path/filepath" + "reflect" "regexp" "strings" "testing" @@ -27,15 +28,19 @@ type fakeHost struct { // empty (the default), ResolveManifest keeps returning the pre-install // manifest even after Install — simulating an install that does NOT change // resolution, the "second miss" case. - manifestAfterInstall string - installed bool - resolveErr error - launchedArg []string // argv captured by Launch - launchedEnv []string // env captured by Launch - launchCode int // host exit code Launch returns (default 0) - launchErr error - installCmds []string // host commands captured by Install - installOut string + manifestAfterInstall string + installed bool + resolveErr error + launchedArg []string // argv captured by Launch + launchedEnv []string // env captured by Launch + launchCode int // host exit code Launch returns (default 0) + launchErr error + installCmds []string // host commands captured by Install + installOut string + inventory []pluginInventoryEntry + inventoryAfterInstall []pluginInventoryEntry + inventoryErr error + inventoryCalls int } func (f *fakeHost) ResolveManifest(host string) (string, error) { @@ -45,6 +50,14 @@ func (f *fakeHost) ResolveManifest(host string) (string, error) { return f.manifest, f.resolveErr } +func (f *fakeHost) PluginInventory(host string) ([]pluginInventoryEntry, error) { + f.inventoryCalls++ + if f.installed && f.inventoryAfterInstall != nil { + return f.inventoryAfterInstall, f.inventoryErr + } + return f.inventory, f.inventoryErr +} + func (f *fakeHost) Launch(argv []string, env []string) (int, error) { f.launchedArg = argv f.launchedEnv = env @@ -63,6 +76,143 @@ func (f *fakeHost) InstallCodexLocalPluginDir(source string) (string, error) { return f.installOut, nil } +func stableInventory(selected, sibling bool) []pluginInventoryEntry { + return []pluginInventoryEntry{ + {ID: "spacedock@spacedock", Version: "0.27.1", Installed: true, Enabled: selected}, + {ID: "spacedock@spacedock-edge", Version: "0.28.0-pre0", Installed: true, Enabled: sibling}, + } +} + +func repeatedStableInventory() []pluginInventoryEntry { + return append(stableInventory(true, false), pluginInventoryEntry{ + ID: "spacedock@spacedock-edge", Version: "0.28.0-pre0", Installed: true, Enabled: true, + }) +} + +func TestStable0271FrontDoorsHealEnabledSiblingBeforeLaunch(t *testing.T) { + withVersion(t, "0.27.1") + savedBranch := devBranch + devBranch = "main" + t.Cleanup(func() { devBranch = savedBranch }) + + frontDoors := []struct { + name string + run func([]string, *fakeHost, *bytes.Buffer) int + }{ + {name: "claude", run: func(args []string, host *fakeHost, stderr *bytes.Buffer) int { + var stdout bytes.Buffer + return runClaude(context.Background(), args, t.TempDir(), host, lookFound, &stdout, stderr) + }}, + {name: "codex", run: func(args []string, host *fakeHost, stderr *bytes.Buffer) int { + var stdout bytes.Buffer + return runCodex(context.Background(), args, t.TempDir(), host, lookFound, &stdout, stderr) + }}, + } + + for _, door := range frontDoors { + t.Run(door.name+"/repeated-scope-auto-heal", func(t *testing.T) { + host := &fakeHost{manifest: writeVersionedManifest(t, "0.27.1"), inventory: repeatedStableInventory(), inventoryAfterInstall: stableInventory(true, false)[:1]} + var stderr bytes.Buffer + if code := door.run(nil, host, &stderr); code != 0 || host.launchedArg == nil { + t.Fatalf("exit=%d launched=%v stderr=%q", code, host.launchedArg != nil, stderr.String()) + } + if len(host.installCmds) != 3 || host.inventoryCalls != 2 { + t.Fatalf("install=%v inventoryCalls=%d, want one heal and verification", host.installCmds, host.inventoryCalls) + } + }) + t.Run(door.name+"/auto-heal", func(t *testing.T) { + host := &fakeHost{manifest: writeVersionedManifest(t, "0.27.1"), inventory: stableInventory(true, true), inventoryAfterInstall: stableInventory(true, false)[:1]} + var stderr bytes.Buffer + if code := door.run(nil, host, &stderr); code != 0 || host.launchedArg == nil { + t.Fatalf("exit=%d launched=%v stderr=%q", code, host.launchedArg != nil, stderr.String()) + } + if len(host.installCmds) != 3 || host.inventoryCalls != 2 { + t.Fatalf("install=%v inventoryCalls=%d, want one heal and verification", host.installCmds, host.inventoryCalls) + } + }) + t.Run(door.name+"/no-install", func(t *testing.T) { + host := &fakeHost{manifest: writeVersionedManifest(t, "0.27.1"), inventory: stableInventory(true, true)} + var stderr bytes.Buffer + if code := door.run([]string{"--no-install"}, host, &stderr); code == 0 || host.launchedArg != nil || len(host.installCmds) != 0 { + t.Fatalf("exit=%d launched=%v install=%v", code, host.launchedArg != nil, host.installCmds) + } + want := "Run `spacedock install --host " + door.name + "` to keep only the stable channel.\n" + if stderr.String() != want { + t.Fatalf("stderr=%q, want %q", stderr.String(), want) + } + }) + t.Run(door.name+"/inventory-failure", func(t *testing.T) { + host := &fakeHost{manifest: writeVersionedManifest(t, "0.27.1"), inventoryErr: errors.New("host unavailable")} + var stderr bytes.Buffer + if code := door.run(nil, host, &stderr); code == 0 || host.launchedArg != nil || len(host.installCmds) != 0 { + t.Fatalf("exit=%d launched=%v install=%v", code, host.launchedArg != nil, host.installCmds) + } + want := "Spacedock: could not verify the " + door.name + " plugin enablement state: host unavailable.\n" + + "Run `spacedock install --host " + door.name + "` before launching.\n" + if stderr.String() != want { + t.Fatalf("stderr=%q, want %q", stderr.String(), want) + } + }) + } +} + +func TestDoctorSiblingInventory(t *testing.T) { + withVersion(t, "0.27.1") + savedBranch := devBranch + devBranch = "main" + t.Cleanup(func() { devBranch = savedBranch }) + conflicts := []struct { + name, host string + inventory []pluginInventoryEntry + }{{"claude conflict", "claude", stableInventory(true, true)}, {"codex conflict", "codex", stableInventory(true, true)}, {"repeated sibling scopes", "claude", repeatedStableInventory()}} + for _, conflict := range conflicts { + t.Run(conflict.name, func(t *testing.T) { + host := &fakeHost{manifest: writeVersionedManifest(t, "0.27.1"), inventory: conflict.inventory} + var stdout, stderr bytes.Buffer + if code := runDoctor(context.Background(), []string{"--host", conflict.host}, host, &stdout, &stderr); code != 0 { + t.Fatalf("exit=%d stderr=%q", code, stderr.String()) + } + want := "OK: spacedock binary 0.27.1 and plugin 0.27.1 are compatible.\n" + + "CONFLICT: " + conflict.host + " can load a different Spacedock plugin than doctor checked.\n" + + " checked: spacedock@spacedock 0.27.1 (installed, enabled)\n" + + " sibling: spacedock@spacedock-edge 0.28.0-pre0 (installed, enabled)\n" + + "Run `spacedock install --host " + conflict.host + "` to keep only the stable channel.\n" + if stdout.String() != want || len(host.installCmds) != 0 { + t.Fatalf("stdout=%q, want %q; doctor installs=%v", stdout.String(), want, host.installCmds) + } + }) + } + t.Run("disabled sibling", func(t *testing.T) { + host := &fakeHost{manifest: writeVersionedManifest(t, "0.27.1"), inventory: stableInventory(true, false)} + var stdout, stderr bytes.Buffer + want := "OK: spacedock binary 0.27.1 and plugin 0.27.1 are compatible.\n" + if code := runDoctor(context.Background(), nil, host, &stdout, &stderr); code != 0 || stdout.String() != want { + t.Fatalf("exit=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + }) + t.Run("inventory failure", func(t *testing.T) { + host := &fakeHost{manifest: writeVersionedManifest(t, "0.27.1"), inventoryErr: errors.New("host unavailable")} + var stdout, stderr bytes.Buffer + want := "OK: spacedock binary 0.27.1 and plugin 0.27.1 are compatible.\n" + + "INCOMPLETE: doctor checked compatibility but did not read the claude plugin enablement state: host unavailable\n" + if code := runDoctor(context.Background(), nil, host, &stdout, &stderr); code != 0 || stdout.String() != want { + t.Fatalf("exit=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + }) +} + +func TestParsePluginInventoryLiveSchemas(t *testing.T) { + want := stableInventory(false, true) + claude := `[{"id":"spacedock@spacedock","version":"0.27.1","enabled":false,"installPath":"/stable"},{"id":"spacedock@spacedock-edge","version":"0.28.0-pre0","enabled":true,"installPath":"/edge"}]` + codex := `{"installed":[{"pluginId":"spacedock@spacedock","version":"0.27.1","installed":true,"enabled":false},{"pluginId":"spacedock@spacedock-edge","version":"0.28.0-pre0","installed":true,"enabled":true}]}` + for _, fixture := range []struct{ host, data string }{{"claude", claude}, {"codex", codex}} { + got, err := parsePluginInventory(fixture.host, []byte(fixture.data)) + if err != nil || !reflect.DeepEqual(got, want) { + t.Errorf("%s inventory=%#v err=%v, want %#v", fixture.host, got, err, want) + } + } +} + // testBinaryVersion is the deterministic binary version a handful of gating // tests pin via withVersion when they need an exact, self-chosen relationship to // a fixture (e.g. the upgrade-hint patch-skew cases). It shares its minor (19) diff --git a/internal/cli/host_exec.go b/internal/cli/host_exec.go index 90d81d0e7..e330c769a 100644 --- a/internal/cli/host_exec.go +++ b/internal/cli/host_exec.go @@ -24,10 +24,55 @@ var _ hostOps = execHost{} // an installed plugin can be present-but-disabled.) type pluginListEntry struct { ID string `json:"id"` + PluginID string `json:"pluginId"` + Version string `json:"version"` InstallPath string `json:"installPath"` + Installed bool `json:"installed"` Enabled bool `json:"enabled"` } +// PluginInventory normalizes the host's JSON listing for doctor. +func (execHost) PluginInventory(host string) ([]pluginInventoryEntry, error) { + out, err := exec.Command(host, "plugin", "list", "--json").CombinedOutput() + if err != nil { + return nil, fmt.Errorf("%s plugin list --json: %w (%s)", host, err, strings.TrimSpace(string(out))) + } + return parsePluginInventory(host, out) +} + +func parsePluginInventory(host string, data []byte) ([]pluginInventoryEntry, error) { + var raw []pluginListEntry + switch host { + case "claude": + if err := json.Unmarshal(data, &raw); err != nil { + return nil, fmt.Errorf("parse claude plugin list --json: %w", err) + } + case "codex": + var envelope struct { + Installed []pluginListEntry `json:"installed"` + } + if err := json.Unmarshal(data, &envelope); err != nil { + return nil, fmt.Errorf("parse codex plugin list --json: %w", err) + } + if envelope.Installed == nil { + return nil, fmt.Errorf("parse codex plugin list --json: missing installed inventory") + } + raw = envelope.Installed + default: + return nil, fmt.Errorf("plugin inventory is unsupported for host %q", host) + } + inventory := make([]pluginInventoryEntry, 0, len(raw)) + for _, entry := range raw { + if host == "claude" { + entry.Installed = entry.InstallPath != "" + } else { + entry.ID = entry.PluginID + } + inventory = append(inventory, pluginInventoryEntry{entry.ID, entry.Version, entry.Installed, entry.Enabled}) + } + return inventory, nil +} + // ResolveManifest returns the installed spacedock@spacedock plugin manifest path // for host, or "" (no error) when no plugin is installed. The two hosts resolve // differently: Claude reports an installPath in `claude plugin list --json`; diff --git a/internal/cli/init.go b/internal/cli/init.go index 54c54ad2b..0881949c2 100644 --- a/internal/cli/init.go +++ b/internal/cli/init.go @@ -51,12 +51,7 @@ func runInit(ctx context.Context, args []string, ops hostOps, stdout, stderr io. return runDoctor(ctx, []string{"--host", "claude"}, ops, stdout, stderr) case "codex": if check { - resolved, err := ops.ResolveManifest("codex") - if err != nil { - fmt.Fprintf(stderr, "spacedock init: could not resolve the installed codex plugin: %v\n", err) - return 1 - } - return contract.RunDoctor(resolved, "codex", displayVersion(), runningEdgeCask(), stdout, stderr) + return runDoctor(ctx, []string{"--host", "codex"}, ops, stdout, stderr) } // `install --host codex` drives the install seam (marketplace add + plugin // add, re-pinning the source) and runs doctor — the same programmatic path @@ -100,8 +95,64 @@ func runDoctor(ctx context.Context, args []string, ops hostOps, stdout, stderr i return 1 } // An empty resolved path is the no-plugin-found report; RunDoctor renders it - // from a non-existent path as a non-fatal report. - return contract.RunDoctor(resolved, host, displayVersion(), runningEdgeCask(), stdout, stderr) + // from a non-existent path as a non-fatal report. Inventory is deliberately + // queried afterward, and its report never changes the compatibility exit code. + compatCode := contract.RunDoctor(resolved, host, displayVersion(), runningEdgeCask(), stdout, stderr) + inventory, err := ops.PluginInventory(host) + if err != nil { + fmt.Fprintf(stdout, "INCOMPLETE: doctor checked compatibility but did not read the %s plugin enablement state: %v\n", host, err) + return compatCode + } + printSiblingConflict(host, inventory, stdout) + return compatCode +} + +func printSiblingConflict(host string, inventory []pluginInventoryEntry, stdout io.Writer) { + selectedID := channelPluginID(devBranch) + selected, selectedOK := installedPlugin(inventory, selectedID) + sibling, siblingOK := enabledSiblingPlugin(inventory) + if !selectedOK || !siblingOK { + return + } + selectedState := "disabled" + if selected.Enabled { + selectedState = "enabled" + } + + fmt.Fprintf(stdout, "CONFLICT: %s can load a different Spacedock plugin than doctor checked.\n", host) + fmt.Fprintf(stdout, " checked: %s %s (installed, %s)\n", selected.ID, selected.Version, selectedState) + fmt.Fprintf(stdout, " sibling: %s %s (installed, enabled)\n", sibling.ID, sibling.Version) + fmt.Fprintf(stdout, "Run `spacedock install --host %s` to keep only the %s channel.\n", host, selectedChannelWord()) +} + +func selectedChannelWord() string { + if devBranch == "main" { + return "stable" + } + return "edge" +} + +func enabledSiblingPlugin(inventory []pluginInventoryEntry) (pluginInventoryEntry, bool) { + siblingBranch := "main" + if devBranch == "main" { + siblingBranch = "next" + } + siblingID := channelPluginID(siblingBranch) + for _, sibling := range inventory { + if sibling.ID == siblingID && sibling.Installed && sibling.Enabled { + return sibling, true + } + } + return pluginInventoryEntry{}, false +} + +func installedPlugin(inventory []pluginInventoryEntry, id string) (pluginInventoryEntry, bool) { + for _, entry := range inventory { + if entry.ID == id && entry.Installed { + return entry, true + } + } + return pluginInventoryEntry{}, false } // parseInitArgs reads `--host claude|codex` (default claude) and `--check`. A diff --git a/internal/ensigncycle/claude_live_runner_test.go b/internal/ensigncycle/claude_live_runner_test.go index fe43ec585..e5df1fc1b 100644 --- a/internal/ensigncycle/claude_live_runner_test.go +++ b/internal/ensigncycle/claude_live_runner_test.go @@ -33,8 +33,8 @@ const antiShutdownOverride = "Do not shut down your team or prepare your final " // real `spacedock claude` launch and returns the (before, after, observed) state // the shared assertions consume — the same assertions the Codex runner feeds. The // ONLY Claude-specific surface is auth/HOME isolation (isolatedClaudeEnv: clean -// HOME + OAuth benchmark-token / ANTHROPIC_API_KEY), the --plugin-dir local -// checkout install, the `spacedock claude -- -p --output-format +// HOME + OAuth benchmark-token / ANTHROPIC_API_KEY), the stable release install, +// the `spacedock claude -- -p --output-format // stream-json` launch, and the observed-extract: the final message comes from the // stream's result/success event (the front-door analog of Codex // --output-last-message) via extractClaudeFinalMessage. The common declarations, @@ -43,7 +43,6 @@ const antiShutdownOverride = "Do not shut down your team or prepare your final " type claudeLiveRunner struct { t *testing.T binary string - pluginDir string env []string modelName string artifactRoot string @@ -145,10 +144,9 @@ func runClaudeRecordedGateLifecycleScenario(t *testing.T, runner liveDriver, sce durableSemantic("recorded-gate-lifecycle-violation", assert(observation))) } -func newClaudeLiveRunner(t *testing.T) claudeLiveRunner { +func newClaudeLiveRunner(t *testing.T, setupIDs ...string) claudeLiveRunner { t.Helper() - binary := buildRecordedGateBinary(t) - pluginDir := livePluginDir(t) + binary, marketplace := stableLiveRelease(t) model := envOr("SPACEDOCK_LIVE_MODEL", "sonnet") // isolatedClaudeEnv resolves the credential (OAuth benchmark-token locally, @@ -158,12 +156,24 @@ func newClaudeLiveRunner(t *testing.T) claudeLiveRunner { // binary. Both are reused verbatim from the full-cycle live test. env := isolatedClaudeEnv(t, os.Getenv("HOME")) env = withBinaryOnPath(env, binary) + env = withRecordedGateEnv(env, "SPACEDOCK_MARKETPLACE_SOURCE", marketplace) + setupID := t.Name() + if len(setupIDs) > 0 { + setupID = setupIDs[0] + } + if base, ok := envValue(env, "CLAUDE_CONFIG_DIR"); ok { + env = withClaudeConfigDir(env, filepath.Join(base, setupID)) + } homeDir, _ := envValue(env, "HOME") + setupDir := codexLiveSetupArtifactDir(claudeLiveArtifactDir(t, "claude-shared-scenarios"), setupID) + if err := os.MkdirAll(setupDir, 0o755); err != nil { + t.Fatal(err) + } + runCodexLiveCommand(t, setupDir, "stable-plugin-install.txt", "", env, binary, "install", "--host", "claude") return claudeLiveRunner{ t: t, binary: binary, - pluginDir: pluginDir, env: env, modelName: model, artifactRoot: claudeLiveArtifactDir(t, "claude-shared-scenarios"), @@ -200,16 +210,7 @@ func (r claudeLiveRunner) gradeShallowBootObservation(t *testing.T, result liveR emitShallowBootWindowMetrics(t, result.stream, r.modelName) } func (r claudeLiveRunner) prepareRecordedGate(t *testing.T) (liveDriver, func(liveResult)) { - source := r.pluginDir - r.pluginDir = t.TempDir() - if err := copyTree(source, r.pluginDir); err != nil { - t.Fatal(err) - } - return r, func(result liveResult) { - if !strings.Contains(result.stream, r.pluginDir) || !strings.Contains(result.stream, "# First Officer Gate Lifecycle") { - t.Fatalf("recorded gate lifecycle did not load the copied skill body\nArtifacts: %s", result.artifactDir) - } - } + return r, noLiveGrade } // withStubPATH returns a runner copy whose launched FO subprocess resolves a stub @@ -601,8 +602,8 @@ func runClaudeShallowBootScenario(t *testing.T, runner liveDriver, scenario shar // run launches the real `spacedock claude` front door for one shared scenario and // returns the (finalMessage, full stream) the shared assertions consume. The -// launch shape is the spike WINNER: --plugin-dir + --skip-compat-check are the -// spacedock-owned flags BEFORE `--`; every host flag (-p with the scenario prompt, +// launch shape uses the installed stable package through the ordinary front door; +// every host flag (-p with the scenario prompt, // --permission-mode, --output-format stream-json, --verbose, --model) rides AFTER // `--` and forwards verbatim to claude. The observed source is the stream's // result/success event via extractClaudeFinalMessage — a 401/is_error result is a @@ -625,29 +626,17 @@ func (r claudeLiveRunner) run(t *testing.T, scenario sharedRuntimeScenario, work streamPath := filepath.Join(artifactDir, "claude-stream.jsonl") finalPath := filepath.Join(artifactDir, "claude-final-message.txt") - cmd := exec.Command(r.binary, "claude", - "--plugin-dir", r.pluginDir, - "--skip-compat-check", - "--", - "-p", prompt+" "+antiShutdownOverride, + frontDoorArgs := []string{"claude", "--", + "-p", prompt + " " + antiShutdownOverride, "--permission-mode", "bypassPermissions", "--output-format", "stream-json", "--verbose", "--model", r.modelName, - ) + } + cmd := exec.Command(r.binary, frontDoorArgs...) cmd.Dir = workflowRoot - // Per-scenario CLAUDE_CONFIG_DIR so parallel scenarios never share claude's - // session/config state. It nests under the runner's base config dir (the - // archivable CI path), so the artifact upload — which grabs the whole - // per-model config dir — still captures each scenario's projects/*.jsonl. A - // fresh slice (never a mutation of the shared r.env) keeps the parallel - // invocations race-free. cmd.Env = r.env configDir, _ := envValue(r.env, "CLAUDE_CONFIG_DIR") - if base, ok := envValue(r.env, "CLAUDE_CONFIG_DIR"); ok { - configDir = filepath.Join(base, scenario.name) - cmd.Env = withClaudeConfigDir(r.env, configDir) - } if err := seedStoredLoginCredential(configDir); err == nil { cmd.Env = withoutEnvKey(cmd.Env, "CLAUDE_CODE_OAUTH_TOKEN") } diff --git a/internal/ensigncycle/codex_live_runner_test.go b/internal/ensigncycle/codex_live_runner_test.go index 4eb4623ad..ed093821c 100644 --- a/internal/ensigncycle/codex_live_runner_test.go +++ b/internal/ensigncycle/codex_live_runner_test.go @@ -15,7 +15,7 @@ import ( // real `spacedock codex` launch and returns the (before, after, observed) state // the shared assertions consume. Auth/HOME isolation (isolated CODEX_HOME + // minimal config plus copied auth.json / OPENAI_API_KEY), Spacedock-owned local -// plugin setup, and the `--output-last-message` observed-extract are the ONLY +// stable release plugin setup, and the `--output-last-message` observed-extract are the ONLY // Codex-specific surface; the common declarations, fixtures, prompts, and assertions // are shared with the Claude runner. type codexLiveRunner struct { @@ -107,7 +107,7 @@ func newCodexLiveRunner(t *testing.T, setupIDs ...string) codexLiveRunner { t.Fatal("codex not on PATH; install Codex CLI before running the live Codex suite") } - binary := spacedockBinary(t) + binary, marketplace := stableLiveRelease(t) repo := repoRoot(t) artifactRoot := codexLiveArtifactDir(t, "codex-shared-scenarios") codexHome := newCodexLiveIsolatedHome(t, repo, artifactRoot) @@ -126,6 +126,7 @@ func newCodexLiveRunner(t *testing.T, setupIDs ...string) codexLiveRunner { } } env := codexLiveEnv(codexHome, cleanHome, filepath.Dir(binary), openAIAPIKey, decision.mode) + env = withRecordedGateEnv(env, "SPACEDOCK_MARKETPLACE_SOURCE", marketplace) setupID := "" if len(setupIDs) > 0 { @@ -141,6 +142,7 @@ func newCodexLiveRunner(t *testing.T, setupIDs ...string) codexLiveRunner { case codexAuthOAuth, codexAuthLocal: runCodexLiveCommand(t, setupDir, "codex-login-status.txt", "", env, codexBin, "login", "status") } + runCodexLiveCommand(t, setupDir, "stable-plugin-install.txt", "", env, binary, "install", "--host", "codex") adapterPath := filepath.Join(repo, "skills", "first-officer", "references", "codex-first-officer-runtime.md") if _, err := os.Stat(adapterPath); err != nil { @@ -154,7 +156,7 @@ func newCodexLiveRunner(t *testing.T, setupIDs ...string) codexLiveRunner { t.Fatal("current-checkout source HEAD is empty") } - return codexLiveRunner{binary: binary, pluginDir: repo, codexBin: codexBin, codexHome: codexHome, env: env, artifactRoot: artifactRoot} + return codexLiveRunner{binary: binary, codexBin: codexBin, codexHome: codexHome, env: env, artifactRoot: artifactRoot} } func codexLiveSetupArtifactDir(artifactRoot, setupID string) string { diff --git a/internal/ensigncycle/live_test.go b/internal/ensigncycle/live_test.go index f72a0990b..cdb101966 100644 --- a/internal/ensigncycle/live_test.go +++ b/internal/ensigncycle/live_test.go @@ -5,6 +5,8 @@ package ensigncycle import ( + "encoding/json" + "fmt" "os" "os/exec" "path/filepath" @@ -65,7 +67,8 @@ func repoRoot(t *testing.T) string { // discoverable `docs/dev` workflow (with live entities), so an FO that anchors its // `git rev-parse --show-toplevel` + `status --discover` on the plugin path — instead // of its isolated cwd fixture — finds and drives the REAL workflow. Staging copies -// ONLY the plugin scaffolding (`.claude-plugin/`, `skills/`, `agents/`) into a temp +// ONLY the plugin scaffolding (`.claude-plugin/`, `.codex-plugin/`, `skills/`, +// `agents/`) into a temp // dir with NO `docs/dev` sibling, then `git init`s it so a `rev-parse` from the // plugin path resolves to a workflow-free root. An FO that boots from here discovers // zero workflows and falls back to the cwd fixture. The result is cached per repo @@ -86,12 +89,13 @@ func cachedLivePluginDir(t *testing.T, repo string) string { livePluginOnce.Do(func() { // MkdirTemp (not t.TempDir) so the staged plugin outlives the first test's // cleanup and the cached path stays valid for every scenario in the run. - staged, err := os.MkdirTemp("", "spacedock-live-plugin-") + marketplace, err := os.MkdirTemp("", "spacedock-live-plugin-") if err != nil { livePluginErr = err return } - for _, sub := range []string{".claude-plugin", "skills", "agents"} { + staged := filepath.Join(marketplace, "spacedock") + for _, sub := range []string{".claude-plugin", ".codex-plugin", "skills", "agents"} { src := filepath.Join(repo, sub) if _, statErr := os.Stat(src); statErr != nil { continue // optional members (e.g. a layout without a top-level agents/) @@ -101,6 +105,16 @@ func cachedLivePluginDir(t *testing.T, repo string) string { return } } + manifestDir := filepath.Join(marketplace, ".claude-plugin") + if err := os.MkdirAll(manifestDir, 0o755); err != nil { + livePluginErr = err + return + } + manifest := []byte("{\n \"name\": \"spacedock\",\n \"owner\": { \"name\": \"Spacedock live suite\" },\n \"plugins\": [\n { \"name\": \"spacedock\", \"source\": \"./spacedock\", \"description\": \"release candidate\", \"category\": \"workflow\" }\n ]\n}\n") + if err := os.WriteFile(filepath.Join(manifestDir, "marketplace.json"), manifest, 0o644); err != nil { + livePluginErr = err + return + } // git init so the FO's `git rev-parse --show-toplevel` resolves to this // workflow-free root, not an enclosing checkout that has a docs/dev. testgit.InitRepo(t, staged, "-q") @@ -112,6 +126,50 @@ func cachedLivePluginDir(t *testing.T, repo string) string { return livePluginPath } +var ( + stableLiveBinaryOnce sync.Once + stableLiveBinaryPath string + stableLiveBinaryErr error +) + +// stableLiveRelease returns the current plugin packaged as the stable channel +// plus a binary stamped with that package's release version and channel. Every +// common live journey installs this package before using the ordinary front door. +func stableLiveRelease(t *testing.T) (binary, marketplace string) { + t.Helper() + plugin := livePluginDir(t) + stableLiveBinaryOnce.Do(func() { + manifestData, err := os.ReadFile(filepath.Join(plugin, ".claude-plugin", "plugin.json")) + if err != nil { + stableLiveBinaryErr = err + return + } + var manifest struct { + Version string `json:"version"` + } + if err := json.Unmarshal(manifestData, &manifest); err != nil { + stableLiveBinaryErr = err + return + } + buildDir, err := os.MkdirTemp("", "spacedock-live-stable-") + if err != nil { + stableLiveBinaryErr = err + return + } + stableLiveBinaryPath = filepath.Join(buildDir, "spacedock") + stamp := fmt.Sprintf("-X github.com/spacedock-dev/spacedock/internal/cli.Version=%s -X github.com/spacedock-dev/spacedock/internal/cli.devBranch=main", manifest.Version) + cmd := exec.Command("go", "build", "-ldflags", stamp, "-o", stableLiveBinaryPath, "./cmd/spacedock") + cmd.Dir = repoRoot(t) + if out, err := cmd.CombinedOutput(); err != nil { + stableLiveBinaryErr = fmt.Errorf("build stable live binary: %w: %s", err, out) + } + }) + if stableLiveBinaryErr != nil { + t.Fatal(stableLiveBinaryErr) + } + return stableLiveBinaryPath, filepath.Dir(plugin) +} + // copyTree recursively copies src to dst, preserving file modes. Symlinks are // resolved to real files so the staged plugin has no path back into the real repo. func copyTree(src, dst string) error { diff --git a/internal/ensigncycle/team_capability_test.go b/internal/ensigncycle/team_capability_test.go index 17e269a31..0c28ce06b 100644 --- a/internal/ensigncycle/team_capability_test.go +++ b/internal/ensigncycle/team_capability_test.go @@ -64,8 +64,6 @@ func TestCleanupKeepMovingRootRetainsOnlyFailures(t *testing.T) { func codexLiveFrontDoorArgv(pluginDir, workflowRoot, finalPath, prompt string) []string { return []string{ "codex", - "--plugin-dir", pluginDir, - "--skip-compat-check", prompt, "--", "exec", @@ -108,12 +106,12 @@ func TestCodexLiveRunnerUsesSpacedockFrontDoorBeforeHostArgs(t *testing.T) { if fence < 0 { t.Fatalf("Codex live argv has no host-argument fence: %v", args) } - if args[0] != "codex" || !argvHasAdjacent(args[:fence], "--plugin-dir", "/tmp/plugin") { - t.Fatalf("Spacedock-owned Codex setup is not before host args: %v", args) + if args[0] != "codex" { + t.Fatalf("Codex front door is not first: %v", args) } - for _, arg := range args[fence+1:] { + for _, arg := range args { if arg == "--plugin-dir" || arg == "/tmp/plugin" || arg == "--skip-compat-check" { - t.Fatalf("Spacedock-owned argument leaked after host fence: %v", args) + t.Fatalf("common live runner bypassed the installed stable package: %v", args) } } if args[fence+1] != "exec" {