diff --git a/docs/site/reference/command-reference.md b/docs/site/reference/command-reference.md index dbdc4d268..458b9b7c5 100644 --- a/docs/site/reference/command-reference.md +++ b/docs/site/reference/command-reference.md @@ -84,7 +84,7 @@ An unsandboxed bootstrap launch carries no safehouse isolation, so per-action pe Both take `--host claude|codex|pi` (default `claude`). When `doctor` reports the plugin is out of date, refresh it with `spacedock install`. When the plugin is still contract-compatible but a newer one is available, `doctor` and the front-door launch print an opt-in upgrade hint (`run spacedock install --host to refresh`); the hint never blocks the launch. See [Install Spacedock](../get-started/install.md) for the full setup path. -The launcher and plugin are a version-gated bundle. During a gate lifecycle, the real `gate prepare` invocation is the capability check: a nonzero result halts before presentation or later state effects. Refresh the installed bundle or build the current checkout and select that executable with `SPACEDOCK_BIN`; do not scrape help output or hand-edit gate frontmatter as a fallback. Relative selected-source and room inputs resolve from the launch working directory. +The launcher and plugin are a version-gated bundle. During a gate lifecycle, the real `gate prepare` invocation is the capability check: a nonzero result halts before presentation or later state effects. Refresh the installed bundle or build the current checkout and select that executable with `SPACEDOCK_BIN`; do not scrape help output or hand-edit gate frontmatter as a fallback. Selected-source inputs may be absolute, launch-working-directory-relative, or state-checkout-relative. A relative spelling that names different existing paths under the launch directory and state checkout is refused; use an absolute path. Relative room inputs continue to resolve from the launch working directory. ## Workflow diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 0c06876b4..9e8f39745 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -303,6 +303,7 @@ func newGateCommand(dir string, stdout, stderr io.Writer) *cobra.Command { } if args[0] == "prepare" { prepareInput.WorkflowDir = definitionDir + prepareInput.LaunchDir = dir result, err := gates.Prepare(path, prepareInput) if err != nil { fmt.Fprintln(stderr, "Error:", err) diff --git a/internal/cli/gate_test.go b/internal/cli/gate_test.go index d6ef8b7e4..9ff713609 100644 --- a/internal/cli/gate_test.go +++ b/internal/cli/gate_test.go @@ -155,6 +155,39 @@ func TestGatePrepareCLIPassesStateRelativeArtifactWithoutCwdJoin(t *testing.T) { } } +func TestGatePrepareCLIResolvesLaunchRelativeSelectedSources(t *testing.T) { + for _, flag := range []string{"--artifact", "--reference"} { + t.Run(flag, func(t *testing.T) { + workflow, state, artifact := gatePrepareCLIFixture(t) + selected := filepath.Join(state, "selected", "review.md") + if err := os.MkdirAll(filepath.Dir(selected), 0o755); err != nil { + t.Fatal(err) + } + writeFile(t, selected, "# Selected review\n") + git(t, state, "add", "selected") + git(t, state, "commit", "-q", "-m", "selected source") + + launchDir := filepath.Dir(filepath.Dir(workflow)) + relative := filepath.ToSlash(filepath.Join("docs", "dev", ".state", "selected", "review.md")) + args := []string{"gate", "prepare", "task", "--question", "Advance?", "--artifact", artifact, + "--summary", "launch-relative source", "--workflow-dir", workflow} + if flag == "--artifact" { + args[6] = relative + } else { + args = append(args, "--reference", relative) + } + var out, errOut bytes.Buffer + code := run(context.Background(), args, nil, launchDir, nil, &out, &errOut, &status.NativeRunner{}, nil) + if code != 0 || errOut.Len() != 0 || !strings.Contains(out.String(), "state=open") { + t.Fatalf("prepare exit=%d stdout=%q stderr=%q", code, out.String(), errOut.String()) + } + if _, err := os.Stat(filepath.Join(state, "docs")); !os.IsNotExist(err) { + t.Fatalf("prepare created doubled path: %v", err) + } + }) + } +} + func TestGatePrepareCLIRejectsSummaryCardinalityAndEncodingBeforeMutation(t *testing.T) { for _, tc := range []struct { name string diff --git a/internal/ensigncycle/shared_fixtures_test.go b/internal/ensigncycle/shared_fixtures_test.go index d13a87ce1..665d97c26 100644 --- a/internal/ensigncycle/shared_fixtures_test.go +++ b/internal/ensigncycle/shared_fixtures_test.go @@ -39,6 +39,7 @@ func writePreGateWorkflow(t *testing.T, root string) recordedGateFixture { t.Helper() fixture := writePreparedRecordedGateFixtureAt(t, root) writeFile(t, filepath.Join(fixture.root, "README.md"), strings.Replace(strings.Replace(recordedGateReadme(), " - name: implementation\n initial: true\n", " - name: queued\n initial: true\n - name: implementation\n", 1), "### validation", "### implementation\n\nAppend exactly one `## Stage Report: implementation`, then return completion.\n\n### validation", 1)) + gitCommitPathScoped(t, fixture.root, "README.md", "queue coherent workflow definition") writeFile(t, fixture.entity, strings.Replace(strings.Split(recordedGateEntity(), "\n## Stage Report: validation\n")[0]+"\n", "status: validation", "status: queued", 1)) writeFile(t, fixture.references[0], "# Entity snapshot\n\nThe retained package is ready for implementation.\n") git(t, fixture.stateRoot, "add", "--", "recorded-gate-task/index.md", "recorded-gate-task/selected/entity-snapshot.md") diff --git a/internal/gates/prepare.go b/internal/gates/prepare.go index 89c85ef90..dcb5843ce 100644 --- a/internal/gates/prepare.go +++ b/internal/gates/prepare.go @@ -5,11 +5,13 @@ package gates import ( "bytes" "encoding/json" + "errors" "fmt" "os" "path/filepath" "strconv" "strings" + "syscall" "unicode/utf8" "github.com/spacedock-dev/spacedock/internal/gitsource" @@ -34,9 +36,11 @@ const archivedBriefingLocator = "briefing.json" var preparedLocators = []string{preparedBriefingLocator, legacyPreparedLocator} var prepareWriteBinding = writeDocument +var selectedSourceLstat = os.Lstat type PrepareInput struct { WorkflowDir string + LaunchDir string Question string Artifact string Summary string @@ -97,7 +101,7 @@ func Prepare(entityPath string, input PrepareInput) (PrepareResult, error) { normalized := make([]string, 0, len(paths)) seen := map[string]bool{} for i, selected := range paths { - path, err := resolveSelectedSource(selected, entityRoot, i == 0) + path, err := resolveSelectedSource(selected, input.LaunchDir, entityRoot, i == 0) if err != nil { return PrepareResult{}, fmt.Errorf("resolve selected source: %w", err) } @@ -845,49 +849,62 @@ func entityResolveRoot(workflowDir string) (string, error) { return filepath.Join(workflowDir, cleaned), nil } -// resolveSelectedSource makes a selected source path absolute. Relative paths -// resolve against the entity root (the state-checkout root in split-root, the -// workflow dir in single-root); absolute paths pass through cleaned. This is -// the single resolution site — the CLI passes relative paths through unchanged. -// -// A relative path that already carries the state-checkout basename (e.g. -// ".spacedock-state/auto-continue-task/index.md" in a split-root workflow where -// entityRoot is "/.spacedock-state") is workflow-rooted, not -// entity-rooted: joining it under entityRoot would double the basename. Resolve -// it against the workflow directory (entityRoot's parent) instead. -// -// The artifact resolves strictly against the entity root — a wrong-root -// artifact is rejected (TestPrepareWrongRootRelativeArtifactFails). A -// reference may legitimately live at the workflow root (e.g. -// recorder-contract.md alongside a split-root state checkout), so references -// fall back to the workflow directory (entityRoot's parent) when the entity-root -// join does not exist. A genuinely missing file reports the entity-root seek -// path so the error shape is preserved. -func resolveSelectedSource(selected, entityRoot string, isArtifact bool) (string, error) { +// resolveSelectedSource selects one cleaned lexical interpretation. Presence +// chooses a candidate; gitsource.Inspect remains the only source validator. +func resolveSelectedSource(selected, launchDir, entityRoot string, isArtifact bool) (string, error) { if filepath.IsAbs(selected) { return filepath.Clean(selected), nil } selected = filepath.Clean(selected) + flag := "--reference" + if isArtifact { + flag = "--artifact" + } + var candidates []string + add := func(root string) error { + path, err := filepath.Abs(filepath.Join(root, selected)) + if err != nil { + return err + } + path = filepath.Clean(path) + for _, candidate := range candidates { + if candidate == path { + return nil + } + } + candidates = append(candidates, path) + return nil + } + if err := add(entityRoot); err != nil { + return "", fmt.Errorf("%s %q: resolve state-relative path: %w", flag, selected, err) + } + if strings.TrimSpace(launchDir) != "" { + if err := add(launchDir); err != nil { + return "", fmt.Errorf("%s %q: resolve launch-relative path: %w", flag, selected, err) + } + } base := filepath.Base(entityRoot) - // A path carrying the state-checkout basename is workflow-rooted; resolve - // against the workflow directory so the basename is not doubled. This - // applies to both artifact and reference. - if base != "." && strings.HasPrefix(selected, base+string(filepath.Separator)) { - return filepath.Clean(filepath.Join(filepath.Dir(entityRoot), selected)), nil + if !isArtifact || (base != "." && strings.HasPrefix(selected, base+string(filepath.Separator))) { + if err := add(filepath.Dir(entityRoot)); err != nil { + return "", fmt.Errorf("%s %q: resolve workflow-relative path: %w", flag, selected, err) + } } - entityJoin := filepath.Clean(filepath.Join(entityRoot, selected)) - if isArtifact { - return entityJoin, nil + + var present []string + for _, candidate := range candidates { + if _, err := selectedSourceLstat(candidate); err == nil { + present = append(present, candidate) + } else if !errors.Is(err, syscall.ENOENT) { + return "", fmt.Errorf("%s %q: inspect candidate %s: %w", flag, selected, candidate, err) + } } - // Reference: fall back to the workflow root if the entity-root join is absent. - if info, err := os.Lstat(entityJoin); err == nil && info.Mode().IsRegular() { - return entityJoin, nil + if len(present) == 1 { + return present[0], nil } - workflowJoin := filepath.Clean(filepath.Join(filepath.Dir(entityRoot), selected)) - if info, err := os.Lstat(workflowJoin); err == nil && info.Mode().IsRegular() { - return workflowJoin, nil + if len(present) > 1 { + return "", fmt.Errorf("%s %q resolves to different paths: %s; use an absolute path", flag, selected, strings.Join(present, ", ")) } - return entityJoin, nil // not found; report the entity-root seek path + return "", fmt.Errorf("%s %q was not found; attempted paths: %s; use an absolute, launch-cwd-relative, or state-relative path", flag, selected, strings.Join(candidates, ", ")) } func isMarkdownPath(path string) bool { diff --git a/internal/gates/prepare_test.go b/internal/gates/prepare_test.go index 10daa5873..b7ec06c45 100644 --- a/internal/gates/prepare_test.go +++ b/internal/gates/prepare_test.go @@ -9,6 +9,7 @@ import ( "path/filepath" "reflect" "strings" + "syscall" "testing" "github.com/spacedock-dev/spacedock/internal/testgit" @@ -1189,6 +1190,7 @@ func TestPrepareWrongRootRelativeArtifactFails(t *testing.T) { } _, err := Prepare(entity, PrepareInput{ WorkflowDir: workflow, + LaunchDir: "", Question: "Advance?", Artifact: "gate-review.md", Summary: "wrong root", @@ -1196,8 +1198,138 @@ func TestPrepareWrongRootRelativeArtifactFails(t *testing.T) { if err == nil { t.Fatal("wrong-root relative artifact succeeded; resolution reverted to the workflow root") } - if !strings.Contains(err.Error(), "no such file or directory") { - t.Fatalf("wrong-root error=%v want no such file or directory", err) + if !strings.Contains(err.Error(), resolved) || !strings.Contains(err.Error(), "was not found") { + t.Fatalf("wrong-root error=%v want state-root absence", err) + } +} + +func TestPrepareSelectedSourceFormsShareImmutableBinding(t *testing.T) { + for _, flag := range []string{"--artifact", "--reference"} { + t.Run(flag, func(t *testing.T) { + workflow, state, entity, artifact, _ := prepareFixture(t, "flat") + selected := filepath.Join(state, "selected", "review.md") + if err := os.MkdirAll(filepath.Dir(selected), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(selected, []byte("# Review\n"), 0o644); err != nil { + t.Fatal(err) + } + prepareGitRun(t, state, "add", "selected") + prepareGitRun(t, state, "commit", "-q", "-m", "selected source") + launch := filepath.Dir(filepath.Dir(workflow)) + forms := []string{selected, filepath.Join("selected", "review.md"), filepath.Join("docs", "dev", ".state", "selected", "review.md")} + var first PrepareResult + for i, form := range forms { + input := PrepareInput{WorkflowDir: workflow, LaunchDir: launch, Question: "Advance?", Artifact: artifact, Summary: "same source"} + if flag == "--artifact" { + input.Artifact = form + } else { + input.References = []string{form} + } + result, err := Prepare(entity, input) + if err != nil { + t.Fatalf("form %q: %v", form, err) + } + if i == 0 { + first = result + } else if result != first { + t.Fatalf("form %q binding=%#v want %#v", form, result, first) + } + } + }) + } +} + +func TestResolveSelectedSourceRefusesAmbiguityAbsenceAndProbeErrors(t *testing.T) { + workflow, state, _, _, _ := prepareFixture(t, "flat") + launch := filepath.Dir(filepath.Dir(workflow)) + rel := filepath.Join("shared", "review.md") + for _, root := range []string{state, launch} { + if err := os.MkdirAll(filepath.Join(root, "shared"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, rel), []byte(root), 0o644); err != nil { + t.Fatal(err) + } + } + for _, artifact := range []bool{true, false} { + _, err := resolveSelectedSource(rel, launch, state, artifact) + flag := "--reference" + if artifact { + flag = "--artifact" + } + if err == nil || !strings.Contains(err.Error(), flag) || !strings.Contains(err.Error(), filepath.Join(state, rel)) || + !strings.Contains(err.Error(), filepath.Join(launch, rel)) || !strings.Contains(err.Error(), "use an absolute path") { + t.Fatalf("ambiguity error=%v", err) + } + _, err = resolveSelectedSource("missing.md", launch, state, artifact) + if err == nil || !strings.Contains(err.Error(), "attempted paths") || !strings.Contains(err.Error(), "launch-cwd-relative") { + t.Fatalf("absence error=%v", err) + } + } + if err := os.Remove(filepath.Join(launch, rel)); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(state, rel), filepath.Join(launch, rel)); err != nil { + t.Fatal(err) + } + if _, err := resolveSelectedSource(rel, launch, state, true); err == nil || !strings.Contains(err.Error(), "different paths") { + t.Fatalf("lexical alias was collapsed: %v", err) + } + + original := selectedSourceLstat + defer func() { selectedSourceLstat = original }() + probes := 0 + selectedSourceLstat = func(path string) (os.FileInfo, error) { + probes++ + return nil, &os.PathError{Op: "lstat", Path: path, Err: syscall.EACCES} + } + _, err := resolveSelectedSource("denied.md", launch, state, true) + if err == nil || probes != 1 || !strings.Contains(err.Error(), "--artifact") || !strings.Contains(err.Error(), filepath.Join(state, "denied.md")) || !strings.Contains(err.Error(), "permission denied") { + t.Fatalf("non-ENOENT error=%v probes=%d", err, probes) + } +} + +func TestPrepareSelectedSourceGuardsRemainByteClean(t *testing.T) { + workflow, state, entity, artifact, _ := prepareFixture(t, "flat") + directory := filepath.Join(state, "directory.md") + if err := os.Mkdir(directory, 0o755); err != nil { + t.Fatal(err) + } + link := filepath.Join(state, "link.md") + if err := os.Symlink(artifact, link); err != nil { + t.Fatal(err) + } + unreadable := filepath.Join(state, "unreadable.md") + if err := os.WriteFile(unreadable, []byte("# Unreadable\n"), 0o644); err != nil { + t.Fatal(err) + } + prepareGitRun(t, state, "add", "unreadable.md") + prepareGitRun(t, state, "commit", "-q", "-m", "unreadable source") + if err := os.Chmod(unreadable, 0); err != nil { + t.Fatal(err) + } + defer os.Chmod(unreadable, 0o644) + foreign := t.TempDir() + testgit.InitRepo(t, foreign, "-q") + if err := os.WriteFile(filepath.Join(foreign, "foreign.md"), []byte("# Foreign\n"), 0o644); err != nil { + t.Fatal(err) + } + prepareGitRun(t, foreign, "add", ".") + prepareGitRun(t, foreign, "commit", "-q", "-m", "foreign") + before, _ := os.ReadFile(entity) + for _, tc := range []struct{ selected, launch, want string }{ + {"directory.md", "", "non-symlink regular file"}, + {"link.md", "", "non-symlink regular file"}, + {"unreadable.md", "", "read selected source"}, + {"foreign.md", foreign, "not owned by a workflow Git root"}, + } { + statusBefore := prepareGitOutput(t, state, "status", "--porcelain") + _, err := Prepare(entity, PrepareInput{WorkflowDir: workflow, LaunchDir: tc.launch, Question: "Advance?", Artifact: tc.selected, Summary: "guard"}) + after, _ := os.ReadFile(entity) + if err == nil || !strings.Contains(err.Error(), tc.want) || !bytes.Equal(before, after) || prepareGitOutput(t, state, "status", "--porcelain") != statusBefore { + t.Fatalf("selected=%q error=%v changed bytes or status", tc.selected, err) + } } } diff --git a/skills/fo-gate-lifecycle/SKILL.md b/skills/fo-gate-lifecycle/SKILL.md index e97caf6e5..b45891d12 100644 --- a/skills/fo-gate-lifecycle/SKILL.md +++ b/skills/fo-gate-lifecycle/SKILL.md @@ -14,7 +14,7 @@ The binary owns preparation, withdrawal, recording, and one-use consume; this sk **Boot.** `status --boot --identify --json` `ready_gates` gives `definition_dir`, `entity_dir`, slug/stage/readiness. Engage slug via `status --read --json`, never search. `needs-preparation`: review report, or at an `initial: true` stage the committed seed itself; `awaiting-captain`: open; `withdrawn-awaiting-prepare`: successor; approved: unblocked; malformed/ambiguous: fail closed. -**Prepare.** Resolve `${SPACEDOCK_BIN:-spacedock}`; keep supplied paths. Else, per distinct applicable retained absolute R=`definition_dir`/`entity_dir`, resolve intended root-relative P; state R uses the engaged task directory, never `.`. Run: `git -C "" ls-tree -r --name-only HEAD -- "

" | awk 'tolower($0)~/\.(md|markdown)$/{print}'`. `/

`: shell-quoted values. Read/use once; summarize. No harness logs/history/status/help probes. Supply judgment/cwd paths; no binary JSON/ids/digests/Git locators/room coords. +**Prepare.** Resolve `${SPACEDOCK_BIN:-spacedock}`; keep paths. For each retained absolute R (`definition_dir`, `entity_dir`), derive relative P; state R is the engaged task directory, never `.`. Run `git -C "" ls-tree -r --name-only HEAD -- "

" | awk 'tolower($0)~/\.(md|markdown)$/{print}'`, quoted. Read once; summarize. No logs/history/status/help probes. Supply absolute, launch-cwd-relative, or state-relative judgment paths; use absolute before the one prepare if a spelling names different cwd/state paths. No binary JSON/IDs/digests/Git locators/room coordinates. Run this sequence once and in this order: