Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions docs/site/get-started/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,13 @@ Codex has no such flag on its own CLI, so `spacedock codex --plugin-dir
local marketplace from the checkout and install it under the binary's own
channel (`spacedock` stable / `spacedock-edge` edge — matching whatever
`spacedock codex` would otherwise install), then launch. This IS a persistent
install, replacing whatever Codex plugin was previously configured, and it is a
install and Spacedock makes it exclusive across Codex channels: the selected
channel replaces any existing stable or edge Spacedock Codex plugin so
`$spacedock:*` skills resolve from the selected install. It is also a
point-in-time snapshot: editing the checkout afterward has no effect until the
command is re-run. The command prints an advisory that the reported version
reflects the checkout's checked-in manifest, not necessarily its current HEAD.
command is re-run. The command prints an advisory that names the selected channel
and notes that the reported version reflects the checkout's checked-in manifest,
not necessarily its current HEAD.

## Sandboxing

Expand Down
13 changes: 8 additions & 5 deletions internal/cli/channel_selection_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,10 @@ func TestClaudeChannelInstallArgvSequence(t *testing.T) {
}

// TestCodexChannelInstallArgvSequence is AC-3's codex half: the codex install argv
// adds the BARE marketplace-repo source (no `--ref`, since the channel is the
// marketplace name, not a branch ref) and adds the channel-correct id. The
// marketplace-remove cleanup targets the channel's marketplace name.
// removes both Spacedock channel providers before it adds the BARE marketplace-repo
// source (no `--ref`, since the channel is the marketplace name, not a branch ref)
// and adds the channel-correct id. Removing both channels keeps Codex's global
// `spacedock:*` skill namespace authoritative for the selected install.
func TestCodexChannelInstallArgvSequence(t *testing.T) {
cases := []struct {
channel string
Expand All @@ -96,8 +97,10 @@ func TestCodexChannelInstallArgvSequence(t *testing.T) {
for _, tc := range cases {
t.Run(tc.channel, func(t *testing.T) {
want := []installStep{
{argv: []string{"plugin", "remove", tc.wantID}, tolerateExit: true},
{argv: []string{"plugin", "marketplace", "remove", tc.wantMarketplace}, tolerateExit: true},
{argv: []string{"plugin", "remove", "spacedock@spacedock"}, tolerateExit: true},
{argv: []string{"plugin", "marketplace", "remove", "spacedock"}, tolerateExit: true},
{argv: []string{"plugin", "remove", "spacedock@spacedock-edge"}, tolerateExit: true},
{argv: []string{"plugin", "marketplace", "remove", "spacedock-edge"}, tolerateExit: true},
{argv: []string{"plugin", "marketplace", "add", "spacedock-dev/marketplace"}},
{argv: []string{"plugin", "add", tc.wantID}},
}
Expand Down
5 changes: 3 additions & 2 deletions internal/cli/codex_marketplace.go
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,10 @@ func installCodexLocalPluginDir(ops hostOps, checkout string, stderr io.Writer)
return fmt.Errorf("install from local marketplace: %w", err)
}
fmt.Fprintf(stderr,
"Installed codex plugin from %s.\n"+
"Installed codex plugin from %s as %s.\n"+
"Removed other Spacedock Codex channels so $spacedock:* resolves from this install.\n"+
"version-masquerade advisory: the reported version reflects the checkout's "+
"checked-in .codex-plugin/plugin.json, not necessarily its current HEAD.\n",
checkout)
checkout, channelPluginID(devBranch))
return nil
}
84 changes: 84 additions & 0 deletions internal/cli/codex_plugin_dir_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,12 @@ func TestCodexPluginDirAdvisoryPresenceAndAbsence(t *testing.T) {
if !strings.Contains(stderr.String(), advisory) {
t.Fatalf("stderr missing the version-masquerade advisory: %q", stderr.String())
}
if !strings.Contains(stderr.String(), "as "+channelPluginID(devBranch)) {
t.Fatalf("advisory must name the selected codex plugin id: %q", stderr.String())
}
if !strings.Contains(stderr.String(), "Removed other Spacedock Codex channels") {
t.Fatalf("advisory must name the sibling-provider cleanup: %q", stderr.String())
}
if !strings.Contains(stderr.String(), "not necessarily its current HEAD") {
t.Fatalf("advisory lost its meaning-bearing clause: %q", stderr.String())
}
Expand Down Expand Up @@ -139,6 +145,73 @@ func TestInstallCodexPluginDirInstallsViaSharedHelper(t *testing.T) {
if !strings.Contains(stderr.String(), "version-masquerade advisory") {
t.Fatalf("install --host codex --plugin-dir missing the advisory: %q", stderr.String())
}
if !strings.Contains(stderr.String(), "as "+channelPluginID(devBranch)) {
t.Fatalf("install --host codex --plugin-dir must name the selected plugin id: %q", stderr.String())
}
if !strings.Contains(stderr.String(), "Removed other Spacedock Codex channels") {
t.Fatalf("install --host codex --plugin-dir must name the sibling-provider cleanup: %q", stderr.String())
}
}

// TestInstallCodexLocalPluginDirLeavesOnePromptInputProvider is AC-1's hermetic
// host smoke. It seeds a fresh CODEX_HOME with BOTH Spacedock channels using raw
// codex commands, then runs the production --plugin-dir install helper for the
// selected edge checkout. `codex debug prompt-input` is Codex's own model-visible
// skill list renderer, so the assertion observes provider resolution without an
// LLM call: exactly one `spacedock:first-officer` entry remains, and it is the
// selected checkout's provider.
func TestInstallCodexLocalPluginDirLeavesOnePromptInputProvider(t *testing.T) {
codexBin, err := exec.LookPath("codex")
if err != nil {
t.Skip("codex not on PATH; prompt-input provider smoke requires the host CLI")
}
saved := devBranch
devBranch = "next"
defer func() { devBranch = saved }()

tmp := t.TempDir()
codexHomeDir := filepath.Join(tmp, "codexhome")
mustMkdir(t, codexHomeDir)
t.Setenv("CODEX_HOME", codexHomeDir)

stableCheckout := buildCodexFirstOfficerCheckout(t, filepath.Join(tmp, "stable-checkout"), "1.0.0", "STABLE_PROVIDER_SPIKE")
oldEdgeCheckout := buildCodexFirstOfficerCheckout(t, filepath.Join(tmp, "old-edge-checkout"), "2.0.0", "OLD_EDGE_PROVIDER_SPIKE")
selectedCheckout := buildCodexFirstOfficerCheckout(t, filepath.Join(tmp, "selected-checkout"), "2.0.1", "SELECTED_PROVIDER_SPIKE")

stableInstall, err := WriteCodexLocalMarketplace(filepath.Join(tmp, "stable-marketplace"), stableCheckout, "spacedock")
if err != nil {
t.Fatalf("build stable local marketplace: %v", err)
}
oldEdgeInstall, err := WriteCodexLocalMarketplace(filepath.Join(tmp, "old-edge-marketplace"), oldEdgeCheckout, "spacedock-edge")
if err != nil {
t.Fatalf("build old edge local marketplace: %v", err)
}

runHost(t, codexBin, os.Environ(), "plugin", "marketplace", "add", stableInstall.MarketplaceRoot)
runHost(t, codexBin, os.Environ(), "plugin", "add", "spacedock@spacedock")
runHost(t, codexBin, os.Environ(), "plugin", "marketplace", "add", oldEdgeInstall.MarketplaceRoot)
runHost(t, codexBin, os.Environ(), "plugin", "add", "spacedock@spacedock-edge")

if err := installCodexLocalPluginDir(execHost{}, selectedCheckout, io.Discard); err != nil {
t.Fatalf("install selected --plugin-dir checkout: %v", err)
}

cmd := exec.Command(codexBin, "debug", "prompt-input", "probe")
cmd.Env = os.Environ()
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("codex debug prompt-input failed: %v\n%s", err, out)
}
rendered := string(out)
if got := strings.Count(rendered, "spacedock:first-officer:"); got != 1 {
t.Fatalf("prompt input has %d spacedock:first-officer providers, want exactly 1\n%s", got, rendered)
}
if strings.Contains(rendered, "STABLE_PROVIDER_SPIKE") || strings.Contains(rendered, "OLD_EDGE_PROVIDER_SPIKE") {
t.Fatalf("prompt input still exposes stale Spacedock providers:\n%s", rendered)
}
if !strings.Contains(rendered, "SELECTED_PROVIDER_SPIKE") {
t.Fatalf("prompt input missing the selected checkout provider:\n%s", rendered)
}
}

// TestInstallCodexLocalPluginDirResolvesOnEdgeChannel is AC-2: a --plugin-dir codex
Expand Down Expand Up @@ -192,3 +265,14 @@ func buildCodexPluginCheckout(t *testing.T, root, version string) string {
mustWrite(t, filepath.Join(root, "skills", "demo", "SKILL.md"), "---\nname: demo\ndescription: demo skill\n---\ndemo\n")
return root
}

func buildCodexFirstOfficerCheckout(t *testing.T, root, version, description string) string {
t.Helper()
mustMkdir(t, filepath.Join(root, ".codex-plugin"))
mustMkdir(t, filepath.Join(root, "skills", "first-officer"))
mustWrite(t, filepath.Join(root, ".codex-plugin", "plugin.json"),
`{ "name": "spacedock", "version": "`+version+`", "requires-contract": ">=3,<4", "skills": "./skills/" }
`)
mustWrite(t, filepath.Join(root, "skills", "first-officer", "SKILL.md"), "---\nname: first-officer\ndescription: "+description+"\n---\n"+description+"\n")
return root
}
40 changes: 21 additions & 19 deletions internal/cli/host_exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -347,26 +347,26 @@ func installArgvSequence(source, devBranch string) []installStep {
}
}

// codexInstallArgvSequence is the codex analog of installArgvSequence: the same
// 4-command cleanup-then-pin shape, but in codex's verb vocabulary (`plugin
// remove` / `plugin add`, not claude's `uninstall` / `install`). The marketplace
// add carries the source channelMarketplaceSource resolved (no `--ref` flag — any
// channel ref is part of the source string): the bare repo for stable (root
// marketplace.json named `spacedock`), `<repo>@edge` for edge (the `edge` branch
// whose root marketplace.json is named `spacedock-edge`, so the add registers
// `spacedock-edge` and the channel id `spacedock@spacedock-edge` resolves). The
// entry is always `spacedock`, and the version pin lives in the marketplace
// manifest. The tolerance asymmetry matches claude:
// BOTH cleanup steps (plugin remove + marketplace remove) are tolerated — on a
// fresh box `plugin remove` exits 0 (idempotent) but `marketplace remove` exits 1
// ("marketplace is not configured or installed"), and neither is a real failure.
// BOTH pinning steps (marketplace add + plugin add) stay fail-fast — they are the
// real-failure backstops.
// codexInstallArgvSequence is the codex analog of installArgvSequence, but Codex's
// global skill namespace means Spacedock channels must be exclusive: remove BOTH
// stable and edge before adding the selected channel. The marketplace add carries
// the source channelMarketplaceSource resolved (no `--ref` flag — any channel ref
// is part of the source string): the bare repo for stable (root marketplace.json
// named `spacedock`), `<repo>@edge` for edge (the `edge` branch whose root
// marketplace.json is named `spacedock-edge`, so the add registers `spacedock-edge`
// and the channel id `spacedock@spacedock-edge` resolves). The entry is always
// `spacedock`, and the version pin lives in the marketplace manifest. Cleanup
// steps are tolerated — on a fresh box `plugin remove` exits 0 (idempotent) but
// `marketplace remove` exits 1 ("marketplace is not configured or installed"), and
// neither is a real failure. The pinning steps (marketplace add + plugin add) stay
// fail-fast as real-failure backstops.
func codexInstallArgvSequence(source, devBranch string) []installStep {
id := channelPluginID(devBranch)
return []installStep{
{argv: []string{"plugin", "remove", id}, tolerateExit: true},
{argv: []string{"plugin", "marketplace", "remove", channelMarketplace(devBranch)}, tolerateExit: true},
{argv: []string{"plugin", "remove", "spacedock@spacedock"}, tolerateExit: true},
{argv: []string{"plugin", "marketplace", "remove", "spacedock"}, tolerateExit: true},
{argv: []string{"plugin", "remove", "spacedock@spacedock-edge"}, tolerateExit: true},
{argv: []string{"plugin", "marketplace", "remove", "spacedock-edge"}, tolerateExit: true},
{argv: []string{"plugin", "marketplace", "add", source}},
{argv: []string{"plugin", "add", id}},
}
Expand All @@ -378,8 +378,10 @@ func codexInstallArgvSequence(source, devBranch string) []installStep {
// installArgvSequence / codexInstallArgvSequence; devBranch selects the channel
// entry both install. The two cleanup steps are tolerated — their non-zero exits
// on a fresh-box ("not installed" / "not found") are appended to combined output
// and the loop continues. The two pinning steps (marketplace add, plugin
// install/add) are fail-fast and surface real install failures.
// and the loop continues. Codex has four cleanup steps because it removes both
// Spacedock channels before pinning the selected one. The pinning steps
// (marketplace add, plugin install/add) are fail-fast and surface real install
// failures.
func (execHost) Install(host, source, devBranch string) (string, error) {
var steps []installStep
switch host {
Expand Down
30 changes: 19 additions & 11 deletions internal/cli/install_tolerance_codex_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ import (
)

// TestCodexInstallIssuesSequenceInOrder locks AC-1a: a stub codex that exits 0 on
// every step Install("codex", "spacedock-dev/marketplace", "next") returns nil
// and the combined output carries all four step markers, including the bare
// marketplace-repo `plugin marketplace add spacedock-dev/marketplace` (no --ref)
// and the edge channel's `plugin add spacedock@spacedock-edge`. The stub's echoed
// argv is the independent source of truth.
// every step -> Install("codex", "spacedock-dev/marketplace", "next") returns nil
// and the combined output carries all six step markers. Both Spacedock channels
// are removed before the selected marketplace source is re-added, so Codex cannot
// keep a stale sibling `spacedock:*` provider enabled beside the selected install.
// The stub's echoed argv is the independent source of truth.
func TestCodexInstallIssuesSequenceInOrder(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("stub script uses /bin/sh; not portable to Windows")
Expand All @@ -27,6 +27,8 @@ func TestCodexInstallIssuesSequenceInOrder(t *testing.T) {
t.Fatalf("Install returned error on all-zero codex stub: %v\nout=%q", err, out)
}
wantOrder := []string{
"stub:plugin remove spacedock@spacedock:exit=0",
"stub:plugin marketplace remove spacedock:exit=0",
"stub:plugin remove spacedock@spacedock-edge:exit=0",
"stub:plugin marketplace remove spacedock-edge:exit=0",
"stub:plugin marketplace add spacedock-dev/marketplace:exit=0",
Expand All @@ -49,8 +51,8 @@ func TestCodexInstallIssuesSequenceInOrder(t *testing.T) {
// TestCodexInstallToleratesMarketplaceRemoveFailure locks AC-1b: the fresh-box
// path where `codex plugin marketplace remove <marketplace>` exits 1 ("is not
// configured or installed") and every other step exits 0. Install MUST return a
// nil error and the combined output MUST carry all four step markers — the
// marketplace remove is a tolerated cleanup step.
// nil error and the combined output MUST carry both channel cleanup markers before
// the add markers — marketplace remove is a tolerated cleanup step.
func TestCodexInstallToleratesMarketplaceRemoveFailure(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("stub script uses /bin/sh; not portable to Windows")
Expand All @@ -63,6 +65,8 @@ func TestCodexInstallToleratesMarketplaceRemoveFailure(t *testing.T) {
t.Fatalf("Install returned error on tolerated marketplace-remove failure: %v\nout=%q", err, out)
}
for _, want := range []string{
"stub:plugin remove spacedock@spacedock:exit=0",
"stub:plugin marketplace remove spacedock:exit=1",
"stub:plugin remove spacedock@spacedock-edge:exit=0",
"stub:plugin marketplace remove spacedock-edge:exit=1",
"stub:plugin marketplace add spacedock-dev/marketplace:exit=0",
Expand All @@ -75,10 +79,10 @@ func TestCodexInstallToleratesMarketplaceRemoveFailure(t *testing.T) {
}

// TestCodexInstallToleratesPluginRemoveFailure locks the cleanup half of AC-1b:
// when `codex plugin remove spacedock@spacedock` exits 1 and every other step
// exits 0, Install MUST still return nil — the plugin remove is a tolerated
// cleanup step (idempotent on a fresh box per the spike, exit 0; tolerated here
// so a future codex that exits 1 on a fresh box does not break the install).
// when `codex plugin remove <spacedock channel id>` exits 1 and every other step
// exits 0, Install MUST still return nil — plugin remove is a tolerated cleanup
// step (idempotent on a fresh box per the spike, exit 0; tolerated here so a
// future codex that exits 1 on a fresh box does not break the install).
func TestCodexInstallToleratesPluginRemoveFailure(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("stub script uses /bin/sh; not portable to Windows")
Expand All @@ -91,6 +95,7 @@ func TestCodexInstallToleratesPluginRemoveFailure(t *testing.T) {
t.Fatalf("Install returned error on tolerated plugin-remove failure: %v\nout=%q", err, out)
}
for _, want := range []string{
"stub:plugin remove spacedock@spacedock:exit=1",
"stub:plugin remove spacedock@spacedock-edge:exit=1",
"stub:plugin marketplace add spacedock-dev/marketplace:exit=0",
"stub:plugin add spacedock@spacedock-edge:exit=0",
Expand Down Expand Up @@ -165,6 +170,9 @@ func TestCodexInstallStableEntryOmitsRef(t *testing.T) {
if !strings.Contains(out, "stub:plugin marketplace add spacedock-dev/marketplace:exit=0") {
t.Errorf("combined output missing bare-source add marker; out=%q", out)
}
if !strings.Contains(out, "stub:plugin remove spacedock@spacedock-edge:exit=0") {
t.Errorf("stable install must still remove the edge sibling; out=%q", out)
}
if !strings.Contains(out, "stub:plugin add spacedock@spacedock:exit=0") {
t.Errorf("combined output missing stable-channel plugin add marker; out=%q", out)
}
Expand Down
Loading
Loading