Skip to content

Commit 53b2802

Browse files
clkaoSpike Test
andauthored
Codex --plugin-dir prevents stale sibling Spacedock skill providers
* codex: make plugin-dir installs exclusive across channels * codex-live: credit multi-agent dispatch evidence --------- Co-authored-by: Spike Test <spike@example.com>
1 parent 570de04 commit 53b2802

10 files changed

Lines changed: 403 additions & 40 deletions

docs/site/get-started/install.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,13 @@ Codex has no such flag on its own CLI, so `spacedock codex --plugin-dir
7878
local marketplace from the checkout and install it under the binary's own
7979
channel (`spacedock` stable / `spacedock-edge` edge — matching whatever
8080
`spacedock codex` would otherwise install), then launch. This IS a persistent
81-
install, replacing whatever Codex plugin was previously configured, and it is a
81+
install and Spacedock makes it exclusive across Codex channels: the selected
82+
channel replaces any existing stable or edge Spacedock Codex plugin so
83+
`$spacedock:*` skills resolve from the selected install. It is also a
8284
point-in-time snapshot: editing the checkout afterward has no effect until the
83-
command is re-run. The command prints an advisory that the reported version
84-
reflects the checkout's checked-in manifest, not necessarily its current HEAD.
85+
command is re-run. The command prints an advisory that names the selected channel
86+
and notes that the reported version reflects the checkout's checked-in manifest,
87+
not necessarily its current HEAD.
8588

8689
## Sandboxing
8790

internal/cli/channel_selection_test.go

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,10 @@ func TestClaudeChannelInstallArgvSequence(t *testing.T) {
8080
}
8181

8282
// TestCodexChannelInstallArgvSequence is AC-3's codex half: the codex install argv
83-
// adds the BARE marketplace-repo source (no `--ref`, since the channel is the
84-
// marketplace name, not a branch ref) and adds the channel-correct id. The
85-
// marketplace-remove cleanup targets the channel's marketplace name.
83+
// removes both Spacedock channel providers before it adds the BARE marketplace-repo
84+
// source (no `--ref`, since the channel is the marketplace name, not a branch ref)
85+
// and adds the channel-correct id. Removing both channels keeps Codex's global
86+
// `spacedock:*` skill namespace authoritative for the selected install.
8687
func TestCodexChannelInstallArgvSequence(t *testing.T) {
8788
cases := []struct {
8889
channel string
@@ -96,8 +97,10 @@ func TestCodexChannelInstallArgvSequence(t *testing.T) {
9697
for _, tc := range cases {
9798
t.Run(tc.channel, func(t *testing.T) {
9899
want := []installStep{
99-
{argv: []string{"plugin", "remove", tc.wantID}, tolerateExit: true},
100-
{argv: []string{"plugin", "marketplace", "remove", tc.wantMarketplace}, tolerateExit: true},
100+
{argv: []string{"plugin", "remove", "spacedock@spacedock"}, tolerateExit: true},
101+
{argv: []string{"plugin", "marketplace", "remove", "spacedock"}, tolerateExit: true},
102+
{argv: []string{"plugin", "remove", "spacedock@spacedock-edge"}, tolerateExit: true},
103+
{argv: []string{"plugin", "marketplace", "remove", "spacedock-edge"}, tolerateExit: true},
101104
{argv: []string{"plugin", "marketplace", "add", "spacedock-dev/marketplace"}},
102105
{argv: []string{"plugin", "add", tc.wantID}},
103106
}

internal/cli/codex_marketplace.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -135,9 +135,10 @@ func installCodexLocalPluginDir(ops hostOps, checkout string, stderr io.Writer)
135135
return fmt.Errorf("install from local marketplace: %w", err)
136136
}
137137
fmt.Fprintf(stderr,
138-
"Installed codex plugin from %s.\n"+
138+
"Installed codex plugin from %s as %s.\n"+
139+
"Removed other Spacedock Codex channels so $spacedock:* resolves from this install.\n"+
139140
"version-masquerade advisory: the reported version reflects the checkout's "+
140141
"checked-in .codex-plugin/plugin.json, not necessarily its current HEAD.\n",
141-
checkout)
142+
checkout, channelPluginID(devBranch))
142143
return nil
143144
}

internal/cli/codex_plugin_dir_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,12 @@ func TestCodexPluginDirAdvisoryPresenceAndAbsence(t *testing.T) {
9494
if !strings.Contains(stderr.String(), advisory) {
9595
t.Fatalf("stderr missing the version-masquerade advisory: %q", stderr.String())
9696
}
97+
if !strings.Contains(stderr.String(), "as "+channelPluginID(devBranch)) {
98+
t.Fatalf("advisory must name the selected codex plugin id: %q", stderr.String())
99+
}
100+
if !strings.Contains(stderr.String(), "Removed other Spacedock Codex channels") {
101+
t.Fatalf("advisory must name the sibling-provider cleanup: %q", stderr.String())
102+
}
97103
if !strings.Contains(stderr.String(), "not necessarily its current HEAD") {
98104
t.Fatalf("advisory lost its meaning-bearing clause: %q", stderr.String())
99105
}
@@ -139,6 +145,73 @@ func TestInstallCodexPluginDirInstallsViaSharedHelper(t *testing.T) {
139145
if !strings.Contains(stderr.String(), "version-masquerade advisory") {
140146
t.Fatalf("install --host codex --plugin-dir missing the advisory: %q", stderr.String())
141147
}
148+
if !strings.Contains(stderr.String(), "as "+channelPluginID(devBranch)) {
149+
t.Fatalf("install --host codex --plugin-dir must name the selected plugin id: %q", stderr.String())
150+
}
151+
if !strings.Contains(stderr.String(), "Removed other Spacedock Codex channels") {
152+
t.Fatalf("install --host codex --plugin-dir must name the sibling-provider cleanup: %q", stderr.String())
153+
}
154+
}
155+
156+
// TestInstallCodexLocalPluginDirLeavesOnePromptInputProvider is AC-1's hermetic
157+
// host smoke. It seeds a fresh CODEX_HOME with BOTH Spacedock channels using raw
158+
// codex commands, then runs the production --plugin-dir install helper for the
159+
// selected edge checkout. `codex debug prompt-input` is Codex's own model-visible
160+
// skill list renderer, so the assertion observes provider resolution without an
161+
// LLM call: exactly one `spacedock:first-officer` entry remains, and it is the
162+
// selected checkout's provider.
163+
func TestInstallCodexLocalPluginDirLeavesOnePromptInputProvider(t *testing.T) {
164+
codexBin, err := exec.LookPath("codex")
165+
if err != nil {
166+
t.Skip("codex not on PATH; prompt-input provider smoke requires the host CLI")
167+
}
168+
saved := devBranch
169+
devBranch = "next"
170+
defer func() { devBranch = saved }()
171+
172+
tmp := t.TempDir()
173+
codexHomeDir := filepath.Join(tmp, "codexhome")
174+
mustMkdir(t, codexHomeDir)
175+
t.Setenv("CODEX_HOME", codexHomeDir)
176+
177+
stableCheckout := buildCodexFirstOfficerCheckout(t, filepath.Join(tmp, "stable-checkout"), "1.0.0", "STABLE_PROVIDER_SPIKE")
178+
oldEdgeCheckout := buildCodexFirstOfficerCheckout(t, filepath.Join(tmp, "old-edge-checkout"), "2.0.0", "OLD_EDGE_PROVIDER_SPIKE")
179+
selectedCheckout := buildCodexFirstOfficerCheckout(t, filepath.Join(tmp, "selected-checkout"), "2.0.1", "SELECTED_PROVIDER_SPIKE")
180+
181+
stableInstall, err := WriteCodexLocalMarketplace(filepath.Join(tmp, "stable-marketplace"), stableCheckout, "spacedock")
182+
if err != nil {
183+
t.Fatalf("build stable local marketplace: %v", err)
184+
}
185+
oldEdgeInstall, err := WriteCodexLocalMarketplace(filepath.Join(tmp, "old-edge-marketplace"), oldEdgeCheckout, "spacedock-edge")
186+
if err != nil {
187+
t.Fatalf("build old edge local marketplace: %v", err)
188+
}
189+
190+
runHost(t, codexBin, os.Environ(), "plugin", "marketplace", "add", stableInstall.MarketplaceRoot)
191+
runHost(t, codexBin, os.Environ(), "plugin", "add", "spacedock@spacedock")
192+
runHost(t, codexBin, os.Environ(), "plugin", "marketplace", "add", oldEdgeInstall.MarketplaceRoot)
193+
runHost(t, codexBin, os.Environ(), "plugin", "add", "spacedock@spacedock-edge")
194+
195+
if err := installCodexLocalPluginDir(execHost{}, selectedCheckout, io.Discard); err != nil {
196+
t.Fatalf("install selected --plugin-dir checkout: %v", err)
197+
}
198+
199+
cmd := exec.Command(codexBin, "debug", "prompt-input", "probe")
200+
cmd.Env = os.Environ()
201+
out, err := cmd.CombinedOutput()
202+
if err != nil {
203+
t.Fatalf("codex debug prompt-input failed: %v\n%s", err, out)
204+
}
205+
rendered := string(out)
206+
if got := strings.Count(rendered, "spacedock:first-officer:"); got != 1 {
207+
t.Fatalf("prompt input has %d spacedock:first-officer providers, want exactly 1\n%s", got, rendered)
208+
}
209+
if strings.Contains(rendered, "STABLE_PROVIDER_SPIKE") || strings.Contains(rendered, "OLD_EDGE_PROVIDER_SPIKE") {
210+
t.Fatalf("prompt input still exposes stale Spacedock providers:\n%s", rendered)
211+
}
212+
if !strings.Contains(rendered, "SELECTED_PROVIDER_SPIKE") {
213+
t.Fatalf("prompt input missing the selected checkout provider:\n%s", rendered)
214+
}
142215
}
143216

144217
// TestInstallCodexLocalPluginDirResolvesOnEdgeChannel is AC-2: a --plugin-dir codex
@@ -192,3 +265,14 @@ func buildCodexPluginCheckout(t *testing.T, root, version string) string {
192265
mustWrite(t, filepath.Join(root, "skills", "demo", "SKILL.md"), "---\nname: demo\ndescription: demo skill\n---\ndemo\n")
193266
return root
194267
}
268+
269+
func buildCodexFirstOfficerCheckout(t *testing.T, root, version, description string) string {
270+
t.Helper()
271+
mustMkdir(t, filepath.Join(root, ".codex-plugin"))
272+
mustMkdir(t, filepath.Join(root, "skills", "first-officer"))
273+
mustWrite(t, filepath.Join(root, ".codex-plugin", "plugin.json"),
274+
`{ "name": "spacedock", "version": "`+version+`", "requires-contract": ">=3,<4", "skills": "./skills/" }
275+
`)
276+
mustWrite(t, filepath.Join(root, "skills", "first-officer", "SKILL.md"), "---\nname: first-officer\ndescription: "+description+"\n---\n"+description+"\n")
277+
return root
278+
}

internal/cli/host_exec.go

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -347,26 +347,26 @@ func installArgvSequence(source, devBranch string) []installStep {
347347
}
348348
}
349349

350-
// codexInstallArgvSequence is the codex analog of installArgvSequence: the same
351-
// 4-command cleanup-then-pin shape, but in codex's verb vocabulary (`plugin
352-
// remove` / `plugin add`, not claude's `uninstall` / `install`). The marketplace
353-
// add carries the source channelMarketplaceSource resolved (no `--ref` flag — any
354-
// channel ref is part of the source string): the bare repo for stable (root
355-
// marketplace.json named `spacedock`), `<repo>@edge` for edge (the `edge` branch
356-
// whose root marketplace.json is named `spacedock-edge`, so the add registers
357-
// `spacedock-edge` and the channel id `spacedock@spacedock-edge` resolves). The
358-
// entry is always `spacedock`, and the version pin lives in the marketplace
359-
// manifest. The tolerance asymmetry matches claude:
360-
// BOTH cleanup steps (plugin remove + marketplace remove) are tolerated — on a
361-
// fresh box `plugin remove` exits 0 (idempotent) but `marketplace remove` exits 1
362-
// ("marketplace is not configured or installed"), and neither is a real failure.
363-
// BOTH pinning steps (marketplace add + plugin add) stay fail-fast — they are the
364-
// real-failure backstops.
350+
// codexInstallArgvSequence is the codex analog of installArgvSequence, but Codex's
351+
// global skill namespace means Spacedock channels must be exclusive: remove BOTH
352+
// stable and edge before adding the selected channel. The marketplace add carries
353+
// the source channelMarketplaceSource resolved (no `--ref` flag — any channel ref
354+
// is part of the source string): the bare repo for stable (root marketplace.json
355+
// named `spacedock`), `<repo>@edge` for edge (the `edge` branch whose root
356+
// marketplace.json is named `spacedock-edge`, so the add registers `spacedock-edge`
357+
// and the channel id `spacedock@spacedock-edge` resolves). The entry is always
358+
// `spacedock`, and the version pin lives in the marketplace manifest. Cleanup
359+
// steps are tolerated — on a fresh box `plugin remove` exits 0 (idempotent) but
360+
// `marketplace remove` exits 1 ("marketplace is not configured or installed"), and
361+
// neither is a real failure. The pinning steps (marketplace add + plugin add) stay
362+
// fail-fast as real-failure backstops.
365363
func codexInstallArgvSequence(source, devBranch string) []installStep {
366364
id := channelPluginID(devBranch)
367365
return []installStep{
368-
{argv: []string{"plugin", "remove", id}, tolerateExit: true},
369-
{argv: []string{"plugin", "marketplace", "remove", channelMarketplace(devBranch)}, tolerateExit: true},
366+
{argv: []string{"plugin", "remove", "spacedock@spacedock"}, tolerateExit: true},
367+
{argv: []string{"plugin", "marketplace", "remove", "spacedock"}, tolerateExit: true},
368+
{argv: []string{"plugin", "remove", "spacedock@spacedock-edge"}, tolerateExit: true},
369+
{argv: []string{"plugin", "marketplace", "remove", "spacedock-edge"}, tolerateExit: true},
370370
{argv: []string{"plugin", "marketplace", "add", source}},
371371
{argv: []string{"plugin", "add", id}},
372372
}
@@ -378,8 +378,10 @@ func codexInstallArgvSequence(source, devBranch string) []installStep {
378378
// installArgvSequence / codexInstallArgvSequence; devBranch selects the channel
379379
// entry both install. The two cleanup steps are tolerated — their non-zero exits
380380
// on a fresh-box ("not installed" / "not found") are appended to combined output
381-
// and the loop continues. The two pinning steps (marketplace add, plugin
382-
// install/add) are fail-fast and surface real install failures.
381+
// and the loop continues. Codex has four cleanup steps because it removes both
382+
// Spacedock channels before pinning the selected one. The pinning steps
383+
// (marketplace add, plugin install/add) are fail-fast and surface real install
384+
// failures.
383385
func (execHost) Install(host, source, devBranch string) (string, error) {
384386
var steps []installStep
385387
switch host {

internal/cli/install_tolerance_codex_test.go

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@ import (
1010
)
1111

1212
// TestCodexInstallIssuesSequenceInOrder locks AC-1a: a stub codex that exits 0 on
13-
// every step Install("codex", "spacedock-dev/marketplace", "next") returns nil
14-
// and the combined output carries all four step markers, including the bare
15-
// marketplace-repo `plugin marketplace add spacedock-dev/marketplace` (no --ref)
16-
// and the edge channel's `plugin add spacedock@spacedock-edge`. The stub's echoed
17-
// argv is the independent source of truth.
13+
// every step -> Install("codex", "spacedock-dev/marketplace", "next") returns nil
14+
// and the combined output carries all six step markers. Both Spacedock channels
15+
// are removed before the selected marketplace source is re-added, so Codex cannot
16+
// keep a stale sibling `spacedock:*` provider enabled beside the selected install.
17+
// The stub's echoed argv is the independent source of truth.
1818
func TestCodexInstallIssuesSequenceInOrder(t *testing.T) {
1919
if runtime.GOOS == "windows" {
2020
t.Skip("stub script uses /bin/sh; not portable to Windows")
@@ -27,6 +27,8 @@ func TestCodexInstallIssuesSequenceInOrder(t *testing.T) {
2727
t.Fatalf("Install returned error on all-zero codex stub: %v\nout=%q", err, out)
2828
}
2929
wantOrder := []string{
30+
"stub:plugin remove spacedock@spacedock:exit=0",
31+
"stub:plugin marketplace remove spacedock:exit=0",
3032
"stub:plugin remove spacedock@spacedock-edge:exit=0",
3133
"stub:plugin marketplace remove spacedock-edge:exit=0",
3234
"stub:plugin marketplace add spacedock-dev/marketplace:exit=0",
@@ -49,8 +51,8 @@ func TestCodexInstallIssuesSequenceInOrder(t *testing.T) {
4951
// TestCodexInstallToleratesMarketplaceRemoveFailure locks AC-1b: the fresh-box
5052
// path where `codex plugin marketplace remove <marketplace>` exits 1 ("is not
5153
// configured or installed") and every other step exits 0. Install MUST return a
52-
// nil error and the combined output MUST carry all four step markers — the
53-
// marketplace remove is a tolerated cleanup step.
54+
// nil error and the combined output MUST carry both channel cleanup markers before
55+
// the add markers — marketplace remove is a tolerated cleanup step.
5456
func TestCodexInstallToleratesMarketplaceRemoveFailure(t *testing.T) {
5557
if runtime.GOOS == "windows" {
5658
t.Skip("stub script uses /bin/sh; not portable to Windows")
@@ -63,6 +65,8 @@ func TestCodexInstallToleratesMarketplaceRemoveFailure(t *testing.T) {
6365
t.Fatalf("Install returned error on tolerated marketplace-remove failure: %v\nout=%q", err, out)
6466
}
6567
for _, want := range []string{
68+
"stub:plugin remove spacedock@spacedock:exit=0",
69+
"stub:plugin marketplace remove spacedock:exit=1",
6670
"stub:plugin remove spacedock@spacedock-edge:exit=0",
6771
"stub:plugin marketplace remove spacedock-edge:exit=1",
6872
"stub:plugin marketplace add spacedock-dev/marketplace:exit=0",
@@ -75,10 +79,10 @@ func TestCodexInstallToleratesMarketplaceRemoveFailure(t *testing.T) {
7579
}
7680

7781
// TestCodexInstallToleratesPluginRemoveFailure locks the cleanup half of AC-1b:
78-
// when `codex plugin remove spacedock@spacedock` exits 1 and every other step
79-
// exits 0, Install MUST still return nil — the plugin remove is a tolerated
80-
// cleanup step (idempotent on a fresh box per the spike, exit 0; tolerated here
81-
// so a future codex that exits 1 on a fresh box does not break the install).
82+
// when `codex plugin remove <spacedock channel id>` exits 1 and every other step
83+
// exits 0, Install MUST still return nil — plugin remove is a tolerated cleanup
84+
// step (idempotent on a fresh box per the spike, exit 0; tolerated here so a
85+
// future codex that exits 1 on a fresh box does not break the install).
8286
func TestCodexInstallToleratesPluginRemoveFailure(t *testing.T) {
8387
if runtime.GOOS == "windows" {
8488
t.Skip("stub script uses /bin/sh; not portable to Windows")
@@ -91,6 +95,7 @@ func TestCodexInstallToleratesPluginRemoveFailure(t *testing.T) {
9195
t.Fatalf("Install returned error on tolerated plugin-remove failure: %v\nout=%q", err, out)
9296
}
9397
for _, want := range []string{
98+
"stub:plugin remove spacedock@spacedock:exit=1",
9499
"stub:plugin remove spacedock@spacedock-edge:exit=1",
95100
"stub:plugin marketplace add spacedock-dev/marketplace:exit=0",
96101
"stub:plugin add spacedock@spacedock-edge:exit=0",
@@ -165,6 +170,9 @@ func TestCodexInstallStableEntryOmitsRef(t *testing.T) {
165170
if !strings.Contains(out, "stub:plugin marketplace add spacedock-dev/marketplace:exit=0") {
166171
t.Errorf("combined output missing bare-source add marker; out=%q", out)
167172
}
173+
if !strings.Contains(out, "stub:plugin remove spacedock@spacedock-edge:exit=0") {
174+
t.Errorf("stable install must still remove the edge sibling; out=%q", out)
175+
}
168176
if !strings.Contains(out, "stub:plugin add spacedock@spacedock:exit=0") {
169177
t.Errorf("combined output missing stable-channel plugin add marker; out=%q", out)
170178
}

0 commit comments

Comments
 (0)