diff --git a/.github/actions/nix-bun/action.yml b/.github/actions/nix-bun/action.yml new file mode 100644 index 00000000..869c2989 --- /dev/null +++ b/.github/actions/nix-bun/action.yml @@ -0,0 +1,21 @@ +name: Bun from Nix +description: > + Install Nix if needed and put this flake's Bun on PATH. The version is + pkgs.bun (the overlay in nix/flake/overlays.nix). Do not pass a version. + +runs: + using: composite + steps: + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@main + with: + extra-conf: | + accept-flake-config = true + + - name: Add the flake's Bun to PATH + shell: bash + run: | + set -euo pipefail + out="$(nix build --no-link --print-out-paths .#bun)" + echo "$out/bin" >> "$GITHUB_PATH" + echo "bun $($out/bin/bun --version) ($out)" diff --git a/.github/workflows/ci-checks.yml b/.github/workflows/ci-checks.yml index 27bf0b6f..7d27e2c7 100644 --- a/.github/workflows/ci-checks.yml +++ b/.github/workflows/ci-checks.yml @@ -38,9 +38,7 @@ jobs: - uses: actions/checkout@v7 - name: Set up Bun - uses: oven-sh/setup-bun@v2 - with: - bun-version: "1.3.2" + uses: ./.github/actions/nix-bun - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/deploy-api.yaml b/.github/workflows/deploy-api.yaml index 779f1c97..d1fe2c32 100644 --- a/.github/workflows/deploy-api.yaml +++ b/.github/workflows/deploy-api.yaml @@ -75,9 +75,8 @@ jobs: - name: Stage Fly secrets from SOPS run: bash apps/api/scripts/push-secrets.sh - - uses: oven-sh/setup-bun@v2 - with: - bun-version: 1.3.11 + - name: Set up Bun + uses: ./.github/actions/nix-bun - name: Build api bundle (produces apps/api/.output for the container) if: inputs.skip_build != true diff --git a/.github/workflows/deploy-docs.yaml b/.github/workflows/deploy-docs.yaml index 8f4206d8..4319fe7b 100644 --- a/.github/workflows/deploy-docs.yaml +++ b/.github/workflows/deploy-docs.yaml @@ -80,9 +80,8 @@ jobs: url: ${{ steps.deploy.outputs.url }} steps: - uses: actions/checkout@v7 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: "1.3.2" + - name: Set up Bun + uses: ./.github/actions/nix-bun - name: Install dependencies run: bun install --frozen-lockfile - name: Stamp build info for memo invalidation @@ -179,9 +178,8 @@ jobs: pull-requests: write # sticky comment steps: - uses: actions/checkout@v7 - - uses: oven-sh/setup-bun@v2 - with: - bun-version: "1.3.2" + - name: Set up Bun + uses: ./.github/actions/nix-bun - name: Install dependencies run: bun install --frozen-lockfile - name: Destroy PR preview diff --git a/.github/workflows/deploy-web.yaml b/.github/workflows/deploy-web.yaml index 34852fc6..3304b393 100644 --- a/.github/workflows/deploy-web.yaml +++ b/.github/workflows/deploy-web.yaml @@ -73,7 +73,8 @@ jobs: url: ${{ steps.deploy.outputs.url }} steps: - uses: actions/checkout@v7 - - uses: oven-sh/setup-bun@v2 + - name: Set up Bun + uses: ./.github/actions/nix-bun - name: Install dependencies run: bun install --frozen-lockfile # Invalidate Cloudflare.Vite's content-hash memo on every push. @@ -145,7 +146,8 @@ jobs: pull-requests: write # sticky comment steps: - uses: actions/checkout@v7 - - uses: oven-sh/setup-bun@v2 + - name: Set up Bun + uses: ./.github/actions/nix-bun - name: Install dependencies run: bun install --frozen-lockfile - name: Destroy PR preview diff --git a/apps/docs/content/docs/cli/setup.mdx b/apps/docs/content/docs/cli/setup.mdx index 2097d3a3..30d98ecb 100644 --- a/apps/docs/content/docs/cli/setup.mdx +++ b/apps/docs/content/docs/cli/setup.mdx @@ -58,6 +58,41 @@ stack setup --experimental-agent=codex --yes # accept its onboarding plan autom ## Experimental agent onboarding +### Embedded Pi Go experiment + +Use the pinned `sky-valley/pi` Go library directly, without launching an agent +CLI. Select a provider/model explicitly and supply its API key in the environment: + +```bash +# OPENAI_API_KEY must be set in your environment (API usage is metered). +nix run .#stackpanel-go -- setup --experimental-agent=pi \ + --agent-model=openai/gpt-5 --tmp --template minimal --restart + +# Alternatively, set ANTHROPIC_API_KEY and select an Anthropic model. +stack setup --experimental-agent=pi --agent-model=anthropic/claude-sonnet-4-5 +``` + +Pi reuses the same questions, plan review, deterministic doctor, and Studio +connection flow. Its conversation and tool results are checkpointed into the +private setup manifest; rerun the same command to resume. `--restart` discards +the conversation and reviews a fresh plan. The current setup instructions are +supplied as a system prompt instead of repeated in each saved user message. + +This experiment exposes Pi's `read`, `ls`, `write`, and `edit` tools, with checks +for paths outside the repository, symlinks, private state, and protected user +files. It does not expose shell tools or load Pi hooks, skills, or parent +configuration automatically. These checks are application policy, not an OS +sandbox. Stackpanel runs Nix and doctor on the host. + +The Go port currently requires Go 1.26 and does not implement Codex subscription +transport or OAuth login. This backend reads `OPENAI_API_KEY` or +`ANTHROPIC_API_KEY`; it never imports subscription tokens or saves API keys in the +manifest. Choose `--experimental-agent=codex` or `claude` explicitly to fall back +to an installed CLI. Plain `auto` retains CLI discovery; `auto` with +`--agent-model` selects Pi. There is no automatic backend switch after edits. + +### Installed CLI backends + `--experimental-agent` delegates repository inspection and configuration to an installed coding agent, then verifies the result with `stack doctor`. Codex and Claude Code are supported when their installed versions expose the @@ -70,11 +105,13 @@ With `auto`, a single supported agent is selected automatically. Multiple agents produce a picker in an interactive terminal; noninteractive runs must select one explicitly. `--yes` and `--non-interactive` accept the proposed onboarding plan. -The wizard uses the CLI's shared terminal theme, with a stage indicator, elapsed -time, and a scrollable plan. Use arrow keys to choose, Space to toggle multiple -selections, and Enter to continue. Text answers support cursor editing and paste. -PgUp/PgDn scroll the plan while Apply, Revise, and Cancel remain visible. Ctrl+D -shows recent agent activity during setup; Esc or Ctrl+C cancels. +The wizard runs full screen, centered in the terminal, and uses the CLI's shared +terminal theme, with a stage indicator, elapsed time, and a scrollable plan. Use +arrow keys to choose, Space to toggle multiple selections, and Enter to continue. +Text answers support cursor editing and paste. PgUp/PgDn scroll the plan while +Apply, Revise, and Cancel remain visible. Ctrl+D shows recent agent activity +during setup; Esc or Ctrl+C cancels. When the wizard closes, the terminal returns +to its previous contents, followed by the outcome and any warnings. The wizard asks questions when choices are unresolved and lets you apply, revise, or cancel a concrete plan. Question rounds preserve @@ -112,13 +149,22 @@ independently of the target repository, so a broken configuration can still be repaired. Older saved sessions gain this schema before the next agent invocation; their answers, accepted plan, template, and framework revision are retained. -Stackpanel then enters a fresh Nix shell, reconciles generated files, and runs: +A plan can list host preparation commands, such as `bun install`, for the +dependencies and lockfiles a sandboxed agent cannot produce. The plan review shows +them, and accepting the plan approves them. Stackpanel runs them unchanged in the +repository devshell, with network access, after reconciliation and before every +doctor run. Lockfiles they create become visible to Git-backed Nix evaluation when +the plan lists them as files. Doctor itself never installs anything, so +`doctor --onboarding` does not run them. + +Stackpanel then enters a fresh Nix shell, reconciles generated files, runs the +plan's host preparation, and runs: ```bash stack doctor --strict --scope repo,build --build --expectations /path/to/expectations.json --json ``` -Only doctor determines success. Failed input locking, reconciliation, or verification is sent +Only doctor determines success. Failed input locking, reconciliation, host preparation, or verification is sent back for one repair attempt, followed by another fresh reconciliation and doctor run. Checks observed on the first doctor run are also required during repair, so removing a failing check cannot make repair pass. Unavailable required permissions, @@ -265,7 +311,8 @@ denials stop the experiment with the provider's diagnostic. | `--no-browser` | `bool` | `false` | Verify runtime without opening the browser | | `--no-runtime` | `bool` | `false` | Verify repository only | | `--agent-log` | `string` | _none_ | Private raw provider debug log; path must not exist | -| `--experimental-agent` | `string` | _none_ | Experimental repository onboarding using `auto`, `codex`, or `claude` | +| `--experimental-agent` | `string` | _none_ | Experimental repository onboarding using `auto`, `codex`, `claude`, or `pi` | +| `--agent-model` | `string` | _none_ | Pi API provider/model, saved for resume; requires an API key | | `--restart` | `bool` | `false` | Review a new agent onboarding plan; with `--tmp`, create a fresh repository | ## The discovery ledger diff --git a/apps/stackpanel-go/cmd/cli/setup.go b/apps/stackpanel-go/cmd/cli/setup.go index 0d1b0853..6ad555ae 100644 --- a/apps/stackpanel-go/cmd/cli/setup.go +++ b/apps/stackpanel-go/cmd/cli/setup.go @@ -53,6 +53,7 @@ type setupFlags struct { addonValues []string build bool experimentalAgent string + agentModel string agentPort int newDir string studioURL string @@ -101,7 +102,8 @@ Examples: func init() { f := setupCmd.Flags() - f.StringVar(&setupOpts.experimentalAgent, "experimental-agent", "", "Use an installed coding agent for repository onboarding (auto, codex, claude)") + f.StringVar(&setupOpts.experimentalAgent, "experimental-agent", "", "Repository onboarding backend (auto, codex, claude, pi)") + f.StringVar(&setupOpts.agentModel, "agent-model", "", "Pi API model: openai/ or anthropic/ (requires an API key)") f.IntVar(&setupOpts.agentPort, "agent-port", 0, "Local agent port (defaults to agent configuration)") f.StringVar(&setupOpts.newDir, "new", "", "Create a new repository in an absent or empty directory (requires --experimental-agent)") f.StringVar(&setupOpts.studioURL, "studio-url", "", "Studio URL to open after experimental setup") @@ -200,6 +202,9 @@ func runSetup(cmd *cobra.Command, args []string) error { // runSetupWith is the body of `stack setup`, parameterized by flags so tests // can drive it. func runSetupWith(cmd *cobra.Command, opts setupFlags) error { + if opts.agentModel != "" && opts.experimentalAgent == "" { + return errors.New("--agent-model requires --experimental-agent=pi") + } if opts.restart && (opts.experimentalAgent == "" || opts.json || opts.dryRun) { return errors.New("--restart requires --experimental-agent and cannot be combined with --json or --dry-run") } diff --git a/apps/stackpanel-go/cmd/cli/setup_agent.go b/apps/stackpanel-go/cmd/cli/setup_agent.go index 39955b49..2bec141c 100644 --- a/apps/stackpanel-go/cmd/cli/setup_agent.go +++ b/apps/stackpanel-go/cmd/cli/setup_agent.go @@ -35,6 +35,9 @@ func runAgentSetup(cmd *cobra.Command, opts setupFlags) (retErr error) { if len(opts.only) > 0 || len(opts.skip) > 0 || opts.reconsider { return errors.New("--experimental-agent cannot be combined with --only, --skip or --reconsider") } + if opts.agentModel != "" && opts.experimentalAgent != "pi" && opts.experimentalAgent != "auto" { + return errors.New("--agent-model requires --experimental-agent=pi") + } noTUI, _ := cmd.Flags().GetBool("no-tui") daemon, _ := cmd.Flags().GetBool("daemon") interactive := tui.IsInteractiveStdio() && !opts.yes && !opts.nonInteractive && !noTUI && !daemon @@ -71,23 +74,45 @@ func runAgentSetup(cmd *cobra.Command, opts setupFlags) (retErr error) { } } ui.Progress("Setup manifest: " + state.path) + if opts.experimentalAgent == "auto" && opts.agentModel != "" { + opts.experimentalAgent = "pi" + } var agent setupagent.Agent + if opts.experimentalAgent == "pi" && (state.Stage == "inspection" || state.Stage == "apply") { + agent, err = setupagent.NewPiAgent(opts.agentModel) + if err != nil { + return err + } + } + agentReady := false ensureCodingAgent := func() error { - if agent.Path != "" { + if agentReady { return nil } + if opts.experimentalAgent == "pi" && agent.ID == "" { + agent, err = setupagent.NewPiAgent(opts.agentModel) + if err != nil { + return err + } + } ui.Stage(tui.SetupInspect, "Loading the pinned Stackpanel configuration schema…") if err := ensureSetupOptionContext(cmd.Context(), &state.Request); err != nil { return err } ui.Stage(tui.SetupInspect, "Finding available coding agents…") var err error - agent, err = selectSetupAgent(cmd.Context(), opts.experimentalAgent, interactive, ui) + if agent.ID == "" { + agent, err = selectSetupAgent(cmd.Context(), opts.experimentalAgent, interactive, ui) + } if err != nil { return err } state.Agent = agent.ID + agentReady = true ui.Identify(state.Root, agent.ID) + if agent.ID == "pi" { + ui.Progress("Pi · " + opts.agentModel + " · direct API (metered usage)") + } return state.save() } root := state.Root @@ -349,6 +374,21 @@ func verifyAgentSetup(ctx context.Context, root, work, executable string, plan * if err := state.checkpoint(ctx, guard); err != nil { return nil, err } + for _, step := range plan.Prepare { + ui.Progress(fmt.Sprintf("Preparing %s · %s", step.ID, setupCommandLabel(step.Argv))) + if err := runSetupPrepare(ctx, root, step, debug); err != nil { + return nil, fmt.Errorf("host preparation %s failed: %w", step.ID, err) + } + } + if len(plan.Prepare) > 0 { + // Lockfiles and manifests the commands created are Nix inputs too. + if err := guard.AddNixInputs(ctx, plan.Expectations.Files...); err != nil { + return nil, err + } + if err := state.checkpoint(ctx, guard); err != nil { + return nil, err + } + } // Never trust a file the coding agent could have modified during its turn. frozen, err := json.Marshal(plan.Expectations) if err != nil { @@ -541,6 +581,18 @@ func runFreshReconciliation(ctx context.Context, root, stackExecutable string, o return runSetupShell(ctx, root, out, stackExecutable, "setup", "--yes", "--only", "codegen,files,fileops") } +// runSetupPrepare runs one plan-approved command unchanged in a fresh devshell, +// like doctor's acceptance commands. Setup owns it so doctor stays a verifier. +func runSetupPrepare(ctx context.Context, root string, step setupagent.PrepareCommand, out io.Writer) error { + dir, err := reconcile.AcceptancePath(root, step.Dir) + if err != nil { + return err + } + // The directory is a positional argument, never interpolated into shell code. + args := []string{"bash", "--noprofile", "--norc", "-c", `cd -- "$1" && shift && exec "$@"`, "stackpanel-prepare", dir} + return runSetupShell(ctx, root, out, append(args, step.Argv...)...) +} + // runSetupLock owns the daemon-dependent operation. A separate output file // prevents Nix from staging flake.lock and lets us preserve existing user edits. func runSetupLock(ctx context.Context, root, work string, guard *setupGitGuard, out io.Writer) error { diff --git a/apps/stackpanel-go/cmd/cli/setup_agent_conversation.go b/apps/stackpanel-go/cmd/cli/setup_agent_conversation.go index 5403b20c..4b613f87 100644 --- a/apps/stackpanel-go/cmd/cli/setup_agent_conversation.go +++ b/apps/stackpanel-go/cmd/cli/setup_agent_conversation.go @@ -16,6 +16,9 @@ func runAgentPhase(ctx context.Context, agent setupagent.Agent, request *setupag if state == nil { state = &setupManifest{} } + if agent.ID == "pi" && state.Pi == nil { + state.Pi = &setupagent.PiState{} + } for round := 0; round < 8; round++ { if err := answerSetupQuestions(state, request, ui); err != nil { return nil, err @@ -25,6 +28,14 @@ func runAgentPhase(ctx context.Context, agent setupagent.Agent, request *setupag Dir: request.Root, Env: freshSetupEnvironment(os.Environ()), ReadOnly: phase == setupagent.Inspection, Prompt: setupagent.BuildPrompt(*request, phase, plan, failure), Timeout: setupStageTimeout, Stdout: debug, Stderr: debug, + ProtectedPaths: request.ProtectedPaths, + PiState: state.Pi, + SavePiState: func() error { + if state.path != "" { + return state.save() + } + return nil + }, OnEvent: func(event setupagent.Event) { if event.Kind == "warning" { ui.Warning(event.Text) @@ -111,6 +122,12 @@ func renderSetupPlan(plan *setupagent.Plan, opts setupFlags) string { fmt.Fprintf(&s, " %s%s\n", strings.Join(c.Path, "."), description) } } + if len(plan.Prepare) > 0 { + s.WriteString("\nHost preparation · runs on this machine before doctor\n") + for _, c := range plan.Prepare { + fmt.Fprintf(&s, " %s · %s\n %s\n", c.ID, c.Dir, setupCommandLabel(c.Argv)) + } + } if len(plan.Expectations.Commands) > 0 { s.WriteString("\nBuild & test\n") for _, c := range plan.Expectations.Commands { diff --git a/apps/stackpanel-go/cmd/cli/setup_agent_conversation_test.go b/apps/stackpanel-go/cmd/cli/setup_agent_conversation_test.go index 3340992c..5d2081b4 100644 --- a/apps/stackpanel-go/cmd/cli/setup_agent_conversation_test.go +++ b/apps/stackpanel-go/cmd/cli/setup_agent_conversation_test.go @@ -77,6 +77,11 @@ func TestSetupPlanDescribesVerificationScope(t *testing.T) { t.Fatalf("plan promises the wrong verification scope: %s", got) } } + prepared := renderSetupPlan(&setupagent.Plan{Summary: "Create the app", Prepare: []setupagent.PrepareCommand{ + {ID: "deps", Dir: "apps/web", Argv: []string{"bun", "install", "--cwd", "a b"}}}}, setupFlags{}) + if !strings.Contains(prepared, "Host preparation") || !strings.Contains(prepared, "deps · apps/web\n bun install --cwd \"a b\"") { + t.Fatalf("review hides the commands the host will run: %s", prepared) + } if got := setupCommandLabel([]string{"go", "test", "./...", "a b"}); got != `go test ./... "a b"` { t.Fatalf("command argument boundaries lost in review: %s", got) } diff --git a/apps/stackpanel-go/cmd/cli/setup_agent_reply.go b/apps/stackpanel-go/cmd/cli/setup_agent_reply.go index 7dc978d7..5d607259 100644 --- a/apps/stackpanel-go/cmd/cli/setup_agent_reply.go +++ b/apps/stackpanel-go/cmd/cli/setup_agent_reply.go @@ -20,7 +20,7 @@ func runSetupAgentReply(ctx context.Context, agent setupagent.Agent, phase setup result, err := setupagent.Run(ctx, agent, request) var formatErr *setupagent.ReplyFormatError if err != nil && !errors.As(err, &formatErr) { - return nil, fmt.Errorf("%s %s: %w (check the CLI's login and permissions; --agent-log records diagnostics)", agent.ID, phase, err) + return nil, fmt.Errorf("%s %s: %w (check the backend's credentials and permissions; --agent-log records diagnostics)", agent.ID, phase, err) } reply, parseErr := setupagent.ParseReply(result.Message) if parseErr == nil { diff --git a/apps/stackpanel-go/cmd/cli/setup_agent_state.go b/apps/stackpanel-go/cmd/cli/setup_agent_state.go index c35fa10a..0670c033 100644 --- a/apps/stackpanel-go/cmd/cli/setup_agent_state.go +++ b/apps/stackpanel-go/cmd/cli/setup_agent_state.go @@ -32,6 +32,7 @@ type setupManifest struct { Pending []setupagent.Question `json:"pendingQuestions,omitempty"` PendingReply *setupReplyRecovery `json:"pendingReply,omitempty"` Conversation []setupagent.Exchange `json:"conversation,omitempty"` + Pi *setupagent.PiState `json:"pi,omitempty"` Failure string `json:"verificationFailure,omitempty"` LastError string `json:"lastError,omitempty"` StudioSession string `json:"studioSession,omitempty"` @@ -52,6 +53,7 @@ type setupSavedOptions struct { NoBrowser bool `json:"noBrowser"` StudioURL string `json:"studioURL,omitempty"` AgentPort int `json:"agentPort,omitempty"` + AgentModel string `json:"agentModel,omitempty"` } type setupGitCheckpoint struct { @@ -134,7 +136,7 @@ func loadSetupManifest(root string) (*setupManifest, error) { return nil, fmt.Errorf("missing saved plan in %s", s.path) } if s.Plan != nil { - if err := reconcile.ValidateExpectations(s.Plan.Expectations); err != nil { + if err := errors.Join(reconcile.ValidateExpectations(s.Plan.Expectations), setupagent.ValidatePrepare(s.Plan.Prepare)); err != nil { return nil, fmt.Errorf("invalid saved plan: %w", err) } } @@ -308,7 +310,7 @@ func openSetupManifest(ctx context.Context, opts setupFlags) (*setupManifest, fu func savedSetupOptions(opts setupFlags) setupSavedOptions { return setupSavedOptions{opts.flake, opts.template, opts.with, opts.without, opts.addonValues, opts.force, - opts.noRuntime, opts.noBrowser, opts.studioURL, opts.agentPort} + opts.noRuntime, opts.noBrowser, opts.studioURL, opts.agentPort, opts.agentModel} } func (s *setupManifest) restoreOptions(cmd *cobra.Command, opts *setupFlags) error { @@ -321,12 +323,14 @@ func (s *setupManifest) restoreOptions(cmd *cobra.Command, opts *setupFlags) err {"flake", opts.flake, s.Options.Flake}, {"template", opts.template, s.Options.Template}, {"with", opts.with, s.Options.With}, {"without", opts.without, s.Options.Without}, {"addon", opts.addonValues, s.Options.AddonValues}, {"force", opts.force, s.Options.Force}, + {"agent-model", opts.agentModel, s.Options.AgentModel}, } { if cmd.Flags().Changed(flag.name) && !reflect.DeepEqual(flag.current, flag.saved) { return fmt.Errorf("--%s differs from the saved setup selections; use --restart to review a new plan", flag.name) } } opts.flake, opts.template = s.Options.Flake, s.Options.Template + opts.agentModel = s.Options.AgentModel opts.with, opts.without, opts.addonValues, opts.force = s.Options.With, s.Options.Without, s.Options.AddonValues, s.Options.Force if !cmd.Flags().Changed("no-runtime") { opts.noRuntime = s.Options.NoRuntime diff --git a/apps/stackpanel-go/cmd/cli/setup_agent_state_test.go b/apps/stackpanel-go/cmd/cli/setup_agent_state_test.go index ea835f58..ac42818e 100644 --- a/apps/stackpanel-go/cmd/cli/setup_agent_state_test.go +++ b/apps/stackpanel-go/cmd/cli/setup_agent_state_test.go @@ -93,7 +93,9 @@ func TestSetupManifestRejectsCorruption(t *testing.T) { if err := s.save(); err != nil { t.Fatal(err) } - for _, content := range []string{`{"version":`, `{"version":99,"root":` + jsonString(root) + `,"stage":"inspection"}`, `{"version":1,"root":"/wrong","stage":"inspection"}`, `{"version":1,"root":` + jsonString(root) + `,"stage":"apply"}`} { + prepared := `{"version":1,"root":` + jsonString(root) + `,"stage":"apply","plan":{"summary":"x","expectations":{"version":1,"config":[{"path":["enable"],"equals":true}],"requiredChecks":[]},"prepare":[{"id":"deps","dir":".","argv":["bun","install"]}]}}` + for _, content := range []string{`{"version":`, `{"version":99,"root":` + jsonString(root) + `,"stage":"inspection"}`, `{"version":1,"root":"/wrong","stage":"inspection"}`, `{"version":1,"root":` + jsonString(root) + `,"stage":"apply"}`, + strings.Replace(prepared, `"dir":"."`, `"dir":"../outside"`, 1)} { if err := os.WriteFile(s.path, []byte(content), 0o600); err != nil { t.Fatal(err) } @@ -101,6 +103,12 @@ func TestSetupManifestRejectsCorruption(t *testing.T) { t.Fatalf("accepted invalid manifest: %s", content) } } + if err := os.WriteFile(s.path, []byte(prepared), 0o600); err != nil { + t.Fatal(err) + } + if loaded, err := loadSetupManifest(root); err != nil || len(loaded.Plan.Prepare) != 1 { + t.Fatalf("saved host preparation was not restored: %+v, %v", loaded, err) + } } func jsonString(s string) string { b, _ := json.Marshal(s); return string(b) } @@ -224,6 +232,62 @@ func TestSetupResumeRestoresOptionsWithoutWeakeningPlan(t *testing.T) { } } +func TestSetupResumeRestoresPiModelAndConversation(t *testing.T) { + setupStateTestEnvironment(t) + root := t.TempDir() + s := &setupManifest{Version: 1, Root: root, Agent: "pi", Stage: "inspection", path: setupStatePath(root), + Options: setupSavedOptions{AgentModel: "openai/gpt-5"}, + Pi: &setupagent.PiState{Model: "openai/gpt-5", Messages: []json.RawMessage{json.RawMessage(`{"role":"user","content":"saved choice","timestamp":1}`)}}, + } + if err := s.save(); err != nil { + t.Fatal(err) + } + loaded, err := loadSetupManifest(root) + if err != nil { + t.Fatal(err) + } + cmd := &cobra.Command{} + cmd.Flags().String("agent-model", "", "") + opts := setupFlags{experimentalAgent: "auto"} + if err := loaded.restoreOptions(cmd, &opts); err != nil { + t.Fatal(err) + } + if opts.experimentalAgent != "pi" || opts.agentModel != "openai/gpt-5" || len(loaded.Pi.Messages) != 1 { + t.Fatal("Pi conversation or selection was lost on resume") + } + cmd.Flags().Set("agent-model", "openai/another-model") + opts.agentModel = "openai/another-model" + if err := loaded.restoreOptions(cmd, &opts); err == nil { + t.Fatal("changed model accepted without restart") + } + info, err := os.Stat(s.path) + if err != nil || info.Mode().Perm() != 0600 { + t.Fatalf("private manifest: %v", err) + } +} + +func TestPiMissingKeyStopsBeforeScaffolding(t *testing.T) { + setupStateTestEnvironment(t) + t.Setenv("OPENAI_API_KEY", "") + root := filepath.Join(t.TempDir(), "new-project") + cmd := &cobra.Command{} + cmd.SetContext(context.Background()) + var output bytes.Buffer + cmd.SetOut(&output) + cmd.SetErr(&output) + err := runAgentSetup(cmd, setupFlags{experimentalAgent: "pi", agentModel: "openai/gpt-5", newDir: root, yes: true, noRuntime: true}) + if err == nil || !strings.Contains(err.Error(), "OPENAI_API_KEY") { + t.Fatalf("credential preflight: %v", err) + } + if _, err := os.Stat(filepath.Join(root, "flake.nix")); !os.IsNotExist(err) { + t.Fatalf("scaffold ran without credentials: %v", err) + } + state, err := loadSetupManifest(root) + if err != nil || state.Options.AgentModel != "openai/gpt-5" { + t.Fatalf("selection not saved: %v", err) + } +} + func TestSetupResumeStudioReceipt(t *testing.T) { setupStateTestEnvironment(t) root := t.TempDir() diff --git a/apps/stackpanel-go/cmd/cli/setup_agent_test.go b/apps/stackpanel-go/cmd/cli/setup_agent_test.go index 48afec7c..acdf85c7 100644 --- a/apps/stackpanel-go/cmd/cli/setup_agent_test.go +++ b/apps/stackpanel-go/cmd/cli/setup_agent_test.go @@ -13,6 +13,7 @@ import ( "testing" "github.com/darkmatter/stackpanel/stackpanel-go/internal/reconcile" + "github.com/darkmatter/stackpanel/stackpanel-go/internal/setupagent" "github.com/spf13/cobra" ) @@ -167,6 +168,36 @@ func TestFreshReconciliationReturnsDiagnosticTail(t *testing.T) { } } +func TestSetupPrepareRunsPlannedCommandInsideRepository(t *testing.T) { + root, _ := setupShellFixture(t) + app := filepath.Join(root, "apps", "web app") + if err := os.MkdirAll(app, 0o755); err != nil { + t.Fatal(err) + } + step := setupagent.PrepareCommand{ID: "deps", Dir: "apps/web app", Argv: []string{"sh", "-c", `pwd > lock; printf '%s' "$1" >> lock`, "deps", "a $b 'c'"}} + if err := runSetupPrepare(context.Background(), root, step, io.Discard); err != nil { + t.Fatal(err) + } + resolved, err := filepath.EvalSymlinks(app) + if err != nil { + t.Fatal(err) + } + if data, _ := os.ReadFile(filepath.Join(app, "lock")); string(data) != resolved+"\na $b 'c'" { + t.Fatalf("command did not run unchanged in its directory: %q", data) + } + step.Argv = []string{"sh", "-c", "echo registry unreachable >&2; exit 3"} + if err := runSetupPrepare(context.Background(), root, step, io.Discard); err == nil || !strings.Contains(err.Error(), "registry unreachable") { + t.Fatalf("repair needs the command's own diagnostic: %v", err) + } + if err := os.Symlink(t.TempDir(), filepath.Join(root, "outside")); err != nil { + t.Fatal(err) + } + step.Dir, step.Argv = "outside", []string{"sh", "-c", "touch ran"} + if err := runSetupPrepare(context.Background(), root, step, io.Discard); err == nil || !strings.Contains(err.Error(), "escapes repository") { + t.Fatalf("command ran through a symlink out of the repository: %v", err) + } +} + func TestSetupLockPreservesGitAndUserEdits(t *testing.T) { for _, mode := range []string{"new", "unchanged", "conflicting"} { t.Run(mode, func(t *testing.T) { diff --git a/apps/stackpanel-go/go.mod b/apps/stackpanel-go/go.mod index 5f50a5bd..b5fd58dd 100644 --- a/apps/stackpanel-go/go.mod +++ b/apps/stackpanel-go/go.mod @@ -1,6 +1,6 @@ module github.com/darkmatter/stackpanel/stackpanel-go -go 1.25 +go 1.26 require ( connectrpc.com/connect v1.19.1 @@ -9,6 +9,7 @@ require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/glamour v0.10.0 github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 + github.com/charmbracelet/x/ansi v0.10.1 github.com/darkmatter/stackpanel/packages/proto/gen/gopb v0.0.0-00010101000000-000000000000 github.com/fatih/color v1.18.0 github.com/fsnotify/fsnotify v1.9.0 @@ -17,7 +18,10 @@ require ( github.com/golangci/golangci-lint v1.64.8 github.com/gorilla/websocket v1.5.3 github.com/mattn/go-isatty v0.0.20 + github.com/muesli/termenv v0.16.0 + github.com/pelletier/go-toml/v2 v2.2.4 github.com/rs/zerolog v1.34.0 + github.com/sky-valley/pi v0.85.21 github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.7 github.com/stretchr/testify v1.11.1 @@ -70,7 +74,6 @@ require ( github.com/charithe/durationcheck v0.0.10 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/harmonica v0.2.0 // indirect - github.com/charmbracelet/x/ansi v0.10.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/term v0.2.1 // indirect @@ -155,7 +158,6 @@ require ( github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/reflow v0.3.0 // indirect - github.com/muesli/termenv v0.16.0 // indirect github.com/nakabonne/nestif v0.3.1 // indirect github.com/nishanths/exhaustive v0.12.0 // indirect github.com/nishanths/predeclared v0.2.2 // indirect @@ -164,7 +166,6 @@ require ( github.com/olekukonko/ll v0.0.9 // indirect github.com/olekukonko/tablewriter v1.0.9 // indirect github.com/pelletier/go-toml v1.9.5 // indirect - github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/polyfloyd/go-errorlint v1.7.1 // indirect github.com/prometheus/client_golang v1.12.1 // indirect @@ -222,16 +223,16 @@ require ( go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/multierr v1.10.0 // indirect go.uber.org/zap v1.27.1 // indirect + golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac // indirect - golang.org/x/mod v0.29.0 // indirect - golang.org/x/net v0.46.0 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/sys v0.37.0 // indirect - golang.org/x/term v0.36.0 // indirect - golang.org/x/text v0.30.0 // indirect - golang.org/x/tools v0.38.0 // indirect - golang.org/x/tools/go/expect v0.1.1-deprecated // indirect - golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated // indirect + golang.org/x/image v0.41.0 // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/term v0.42.0 // indirect + golang.org/x/text v0.37.0 // indirect + golang.org/x/tools v0.44.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect honnef.co/go/tools v0.6.1 // indirect diff --git a/apps/stackpanel-go/go.sum b/apps/stackpanel-go/go.sum index 233cdf2f..cb4b39c6 100644 --- a/apps/stackpanel-go/go.sum +++ b/apps/stackpanel-go/go.sum @@ -664,6 +664,8 @@ github.com/sivchari/containedctx v1.0.3 h1:x+etemjbsh2fB5ewm5FeLNi5bUjK0V8n0RB+W github.com/sivchari/containedctx v1.0.3/go.mod h1:c1RDvCbnJLtH4lLcYD/GqwiBSSf4F5Qk0xld2rBqzJ4= github.com/sivchari/tenv v1.12.1 h1:+E0QzjktdnExv/wwsnnyk4oqZBUfuh89YMQT1cyuvSY= github.com/sivchari/tenv v1.12.1/go.mod h1:1LjSOUCc25snIr5n3DtGGrENhX3LuWefcplwVGC24mw= +github.com/sky-valley/pi v0.85.21 h1:DxCxyx6Hz6gn0GUqEb5RPwLeyftEAMp9OJXM6+CM7qs= +github.com/sky-valley/pi v0.85.21/go.mod h1:JSHhfku7juhYGBXWtNN9VzqnKHTYLVGQrnIf+oYCfeQ= github.com/sonatard/noctx v0.1.0 h1:JjqOc2WN16ISWAjAk8M5ej0RfExEXtkEyExl2hLW+OM= github.com/sonatard/noctx v0.1.0/go.mod h1:0RvBxqY8D4j9cTTTWE8ylt2vqj2EPI8fHmrxHdsaZ2c= github.com/sourcegraph/go-diff v0.7.0 h1:9uLlrd5T46OXs5qpp8L/MTltk0zikUGi0sNNyCpA8G0= @@ -833,8 +835,8 @@ golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac h1:TSSpLIG4v+p0rP golang.org/x/exp/typeparams v0.0.0-20250210185358-939b2ce775ac/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= -golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4= -golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c= +golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= +golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= @@ -862,8 +864,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.9.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= -golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -904,8 +906,8 @@ golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= -golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -927,8 +929,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= -golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= -golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -982,8 +984,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= @@ -992,8 +994,8 @@ golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= -golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= -golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1006,8 +1008,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -1067,8 +1069,8 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.7.0/go.mod h1:4pg6aUX35JBAogB10C9AtvVL+qowtN4pT3CGSQex14s= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= diff --git a/apps/stackpanel-go/gomod2nix.toml b/apps/stackpanel-go/gomod2nix.toml index 68e1c0b0..a5b85d8e 100644 --- a/apps/stackpanel-go/gomod2nix.toml +++ b/apps/stackpanel-go/gomod2nix.toml @@ -697,6 +697,10 @@ schema = 3 version = 'v1.12.1' hash = 'sha256-94c+769Jes4k1sZge1UwG4xNIKnkrPMkQR1YANtpHt0=' + [mod.'github.com/sky-valley/pi'] + version = 'v0.85.21' + hash = 'sha256-5jqUsRZ3qegaBS+1gPaSoFY6y93OfXKkzOOo7muzwfI=' + [mod.'github.com/sonatard/noctx'] version = 'v0.1.0' hash = 'sha256-UD7cqS7IUGYduy2vAftFjcDnjQ2ft/HqNKq2PTuOkcI=' @@ -853,33 +857,37 @@ schema = 3 version = 'v0.0.0-20250210185358-939b2ce775ac' hash = 'sha256-rwnrzYkeyMB+E3Q+CjrXRoEQRhRwQmVv5FVB/Rv2oCg=' + [mod.'golang.org/x/image'] + version = 'v0.41.0' + hash = 'sha256-kAqfrX6aS2f+j7fyEsk8YZt2b/VG/m3zGWRNbXnlUzA=' + [mod.'golang.org/x/mod'] - version = 'v0.29.0' - hash = 'sha256-UaJLv8CdfUpnT4nArFSt8lLpFVAGHYmT7jaZ6vPGuEQ=' + version = 'v0.35.0' + hash = 'sha256-ICEQxokHywOFInDPqoP+go9l1tZSz3roknF5SXPtNV4=' [mod.'golang.org/x/net'] - version = 'v0.46.0' - hash = 'sha256-GkAUXwqEJZF2t5dmxJVEE+t58EDzO24KtihqHqsrZH0=' + version = 'v0.53.0' + hash = 'sha256-G9gKLmyaf6lIV429NKX+YlL6oUPJwlv+BrG6qGhzvmU=' [mod.'golang.org/x/sync'] - version = 'v0.17.0' - hash = 'sha256-M85lz4hK3/fzmcUViAp/CowHSxnr3BHSO7pjHp1O6i0=' + version = 'v0.20.0' + hash = 'sha256-ybcjhCfK6lroUM0yswUvWooW8MOQZBXyiSqoxG6Uy0Y=' [mod.'golang.org/x/sys'] - version = 'v0.37.0' - hash = 'sha256-5aT0xP02sW1o9sfJHtWoGGNVYDdwb9FyiX/n6RAlzPo=' + version = 'v0.43.0' + hash = 'sha256-aDQXqSTZES2l/132PBxhZN4ywldpPyfm7LByYCHzzwM=' [mod.'golang.org/x/term'] - version = 'v0.36.0' - hash = 'sha256-5YLOE1v+5zEXG62rGrJKXyuDM/ylDnJQW/hLwbMLrY8=' + version = 'v0.42.0' + hash = 'sha256-FCiDvAfq7dgBGQuiDYDFJbj/JPawhrmPF2qdUEftQ1c=' [mod.'golang.org/x/text'] - version = 'v0.30.0' - hash = 'sha256-VPT1Y2zzgdk+Q3Gx8hdwKdxbVh0/Zn/HwDpILCW3ZNA=' + version = 'v0.37.0' + hash = 'sha256-8XDOnlPIybcDRy89fkjG5VqtIt5Ku+LmaqYhgKl7i1E=' [mod.'golang.org/x/tools'] - version = 'v0.38.0' - hash = 'sha256-GeIkpuKtUfxxrOZLikNlZA3lMKhdyO++PVvXppBGmbw=' + version = 'v0.44.0' + hash = 'sha256-xuj5FLtSJsAojLLTLXtPdLAIFNTKoVFbDMuqRXmj2W4=' [mod.'golang.org/x/tools/go/expect'] version = 'v0.1.1-deprecated' diff --git a/apps/stackpanel-go/internal/reconcile/acceptance.go b/apps/stackpanel-go/internal/reconcile/acceptance.go index 25df0a19..bd18e192 100644 --- a/apps/stackpanel-go/internal/reconcile/acceptance.go +++ b/apps/stackpanel-go/internal/reconcile/acceptance.go @@ -22,8 +22,8 @@ func relativeAcceptancePath(path string) bool { return path != "" && filepath.IsLocal(path) } -// acceptancePath checks symlinks as well as lexical traversal. -func acceptancePath(root, path string) (string, error) { +// AcceptancePath checks symlinks as well as lexical traversal. +func AcceptancePath(root, path string) (string, error) { if !relativeAcceptancePath(path) { return "", fmt.Errorf("acceptance path must be inside the repository: %q", path) } @@ -60,7 +60,7 @@ func CheckAcceptance(ctx *Context, expected Expectations) []CheckResult { if selected("repo") { for _, file := range expected.Files { result := CheckResult{ID: "file:" + file, Module: "onboarding", Scope: "repo", Status: "pass"} - path, err := acceptancePath(ctx.ProjectRoot, file) + path, err := AcceptancePath(ctx.ProjectRoot, file) if err == nil { var stat os.FileInfo stat, err = os.Stat(path) @@ -92,7 +92,7 @@ func CheckAcceptance(ctx *Context, expected Expectations) []CheckResult { results = append(results, result) continue } - dir, err := acceptancePath(ctx.ProjectRoot, check.Dir) + dir, err := AcceptancePath(ctx.ProjectRoot, check.Dir) if err == nil { ctx.progress(fmt.Sprintf("Running acceptance check %s: %s (timeout 5m)", check.ID, strings.Join(check.Argv, " "))) runCtx, cancel := context.WithTimeout(ctx.Ctx, 5*time.Minute) diff --git a/apps/stackpanel-go/internal/setupagent/agent.go b/apps/stackpanel-go/internal/setupagent/agent.go index 90195e63..5e2c8b94 100644 --- a/apps/stackpanel-go/internal/setupagent/agent.go +++ b/apps/stackpanel-go/internal/setupagent/agent.go @@ -1,4 +1,4 @@ -// Package setupagent runs an installed coding agent for repository onboarding. +// Package setupagent runs an embedded or installed agent for repository onboarding. // An agent's completion is never evidence that onboarding passed verification. package setupagent @@ -25,6 +25,7 @@ const ( type Agent struct { ID string `json:"id"` Path string `json:"path"` + pi *piBackend } type Capabilities struct { @@ -42,8 +43,11 @@ type RunRequest struct { OnEvent func(Event) // Env defaults to the current process environment. Authentication and model // selection remain the installed CLI's responsibility. - Env []string - Timeout time.Duration + Env []string + Timeout time.Duration + ProtectedPaths []string + PiState *PiState + SavePiState func() error } // Event is provider-independent progress. Provider JSON stays in the debug log. @@ -159,6 +163,9 @@ func commandFor(agent Agent, req RunRequest) ([]string, error) { // and bounds execution time and individual event size. It does not approve shell commands // denied by the CLI's configured policy. func Run(ctx context.Context, agent Agent, req RunRequest) (RunResult, error) { + if agent.ID == "pi" { + return runPi(ctx, agent.pi, req) + } result := RunResult{ExitCode: -1} if !filepath.IsAbs(agent.Path) || !filepath.IsAbs(req.Dir) { return result, fmt.Errorf("agent executable and repository directory must be absolute") diff --git a/apps/stackpanel-go/internal/setupagent/agent_test.go b/apps/stackpanel-go/internal/setupagent/agent_test.go index 4c272c5c..801e5051 100644 --- a/apps/stackpanel-go/internal/setupagent/agent_test.go +++ b/apps/stackpanel-go/internal/setupagent/agent_test.go @@ -308,6 +308,31 @@ func TestParsePlanRejectsInvalidContracts(t *testing.T) { } } +func TestPlanPreparationIsValidated(t *testing.T) { + withPrepare := func(prepare string) string { + return strings.Replace(validPlan, `"expectations"`, `"prepare":`+prepare+`,"expectations"`, 1) + } + reply, err := ParseReply(`{"status":"plan","plan":` + withPrepare(`[{"id":"deps","dir":".","argv":["bun","install"]}]`) + `}`) + if err != nil || len(reply.Plan.Prepare) != 1 || reply.Plan.Prepare[0].Argv[1] != "install" { + t.Fatalf("plan preparation was not retained: %+v, %v", reply, err) + } + for _, prepare := range []string{ + `[{"id":"","dir":".","argv":["bun","install"]}]`, + `[{"id":"deps","dir":"../outside","argv":["bun","install"]}]`, + `[{"id":"deps","dir":"/tmp","argv":["bun","install"]}]`, + `[{"id":"deps","dir":".","argv":[]}]`, + `[{"id":"deps","dir":".","argv":["bun"]},{"id":"deps","dir":".","argv":["bun2nix"]}]`, + `[{"id":"deps","dir":".","argv":["bun"],"shell":"bun install"}]`, + } { + if _, err := ParsePlan(withPrepare(prepare)); err == nil { + t.Fatalf("accepted invalid preparation: %s", prepare) + } + if _, err := ParseReply(`{"status":"plan","plan":` + withPrepare(prepare) + `}`); err == nil { + t.Fatalf("accepted invalid preparation in a reply: %s", prepare) + } + } +} + func TestBuildPromptKeepsNixOperationsOnHost(t *testing.T) { plan, err := ParsePlan(validPlan) if err != nil { @@ -321,8 +346,12 @@ func TestBuildPromptKeepsNixOperationsOnHost(t *testing.T) { if !strings.Contains(inspection, "stackpanel/nixpkgs") || !strings.Contains(inspection, "Preserve deliberate pins") { t.Fatal("new flakes must use compatible framework inputs without replacing existing pins") } + if !strings.Contains(inspection, `"prepare":[]`) || !strings.Contains(inspection, "never write\nthose files yourself") { + t.Fatal("inspection prompt does not explain plan-approved host preparation") + } + plan.Prepare = []PrepareCommand{{ID: "deps", Dir: ".", Argv: []string{"bun", "install"}}} repair := BuildPrompt(req, Repair, plan, "missing web app") - for _, required := range []string{"Frozen onboarding plan", "apps", "missing web app", "single repair attempt", req.Constraints, "Never manually edit .stack/gen", "explicitly set inputs.stackpanel", "retain only options needed", "Leave the Git index unchanged", "Do not invoke stack setup, nix, direnv", "Do not create or edit flake.lock yourself", "already\nwritten missing scaffold files"} { + for _, required := range []string{"Frozen onboarding plan", "apps", "missing web app", "single repair attempt", req.Constraints, "Never manually edit .stack/gen", "explicitly set inputs.stackpanel", "retain only options needed", "Leave the Git index unchanged", "Do not invoke stack setup, nix, direnv", "Do not create or edit flake.lock yourself", "already\nwritten missing scaffold files", `"prepare"`, "never write the\nlockfiles"} { if !strings.Contains(repair, required) { t.Fatalf("repair prompt missing %q", required) } diff --git a/apps/stackpanel-go/internal/setupagent/pi.go b/apps/stackpanel-go/internal/setupagent/pi.go new file mode 100644 index 00000000..d5f9f8a0 --- /dev/null +++ b/apps/stackpanel-go/internal/setupagent/pi.go @@ -0,0 +1,201 @@ +package setupagent + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + piagent "github.com/sky-valley/pi/agent" + "github.com/sky-valley/pi/ai" + "github.com/sky-valley/pi/coding" +) + +// PiState lives in the host's private setup manifest. Authentication is never +// serialized. The current setup prompt is supplied once as the system prompt, +// rather than appended to the conversation on every question or repair. +type PiState struct { + Model string `json:"model"` + Messages []json.RawMessage `json:"messages,omitempty"` +} + +type piBackend struct { + model *ai.Model + key string +} + +// NewPiAgent resolves a pinned catalog model and API credential without making +// a network request. This experiment deliberately leaves subscription OAuth to +// the existing native CLI backends. +func NewPiAgent(spec string) (Agent, error) { + if !strings.HasPrefix(spec, "openai/") && !strings.HasPrefix(spec, "anthropic/") { + return Agent{}, errors.New("Pi requires --agent-model=openai/ or --agent-model=anthropic/") + } + model, err := coding.ResolveModel(spec) + if err != nil { + return Agent{}, err + } + env := "OPENAI_API_KEY" + if model.Provider == "anthropic" { + env = "ANTHROPIC_API_KEY" + } + key := strings.TrimSpace(os.Getenv(env)) + if key == "" { + return Agent{}, fmt.Errorf("Pi needs %s for direct API access (metered usage); subscription sign-ins are not supported by this Go port. Use --experimental-agent=codex or claude for CLI fallback", env) + } + return Agent{ID: "pi", pi: &piBackend{model: model, key: key}}, nil +} + +func runPi(ctx context.Context, backend *piBackend, req RunRequest) (result RunResult, retErr error) { + result.ExitCode = -1 + if backend == nil || !filepath.IsAbs(req.Dir) { + return result, errors.New("Pi requires a configured provider and absolute repository directory") + } + // Provider errors can echo request headers. Keep the selected credential out + // of errors, diagnostics, and the manifest even in that case. + defer func() { + if retErr != nil && strings.Contains(retErr.Error(), backend.key) { + retErr = errors.New(strings.ReplaceAll(retErr.Error(), backend.key, "[redacted]")) + } + }() + timeout := req.Timeout + if timeout <= 0 { + timeout = defaultRunTimeout + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + state := req.PiState + if state == nil { + state = &PiState{} + } + spec := string(backend.model.Provider) + "/" + backend.model.ID + if state.Model != "" && state.Model != spec { + return result, errors.New("Pi model differs from the saved conversation; use --restart to start a new plan") + } + state.Model = spec + messages, err := decodePiMessages(state.Messages) + if err != nil { + return result, err + } + tools, err := piTools(req.Dir, req.ReadOnly, req.ProtectedPaths) + if err != nil { + return result, err + } + turns := 0 + a := piagent.NewAgent(piagent.AgentOptions{ + InitialState: &piagent.AgentState{Model: backend.model, SystemPrompt: req.Prompt, + Messages: messages, Tools: tools, ThinkingLevel: piagent.ThinkLow}, + GetApiKey: func(string) string { return backend.key }, + ToolExecution: piagent.ToolSequential, + MaxRetries: 1, + StreamFn: func(ctx context.Context, model *ai.Model, input ai.Context, opts *ai.SimpleStreamOptions) *ai.AssistantMessageEventStream { + // Do not let ambient subscription tokens replace the selected API key. + opts.Env = map[string]string{"ANTHROPIC_AUTH_TOKEN": "", "ANTHROPIC_OAUTH_TOKEN": ""} + return ai.StreamSimple(ctx, model, input, opts) + }, + ShouldStopAfterTurn: func(context.Context, piagent.ShouldStopAfterTurnContext) bool { + turns++ + return turns >= 32 + }, + }) + var saveErr error + a.Subscribe(func(_ context.Context, event piagent.AgentEvent) error { + activity := "" + switch event.Type { + case piagent.EvTurnStart: + activity = "Pi · waiting for " + spec + case piagent.EvToolExecutionStart: + activity = "Pi · " + event.ToolName + case piagent.EvMessageEnd: + data, err := json.Marshal(a.State().Messages) + data = bytes.ReplaceAll(data, []byte(backend.key), []byte("[redacted]")) + if err == nil && len(data) > 8<<20 { + err = errors.New("Pi conversation exceeded 8 MiB; start a new plan with --restart") + } + if err == nil { + err = json.Unmarshal(data, &state.Messages) + } + if err == nil && req.SavePiState != nil { + err = req.SavePiState() + } + if err != nil { + saveErr = err + return err + } + } + if activity != "" { + if req.OnEvent != nil { + req.OnEvent(Event{Kind: "activity", Text: activity}) + } + if req.Stdout != nil { + fmt.Fprintln(req.Stdout, activity) + } + } + return nil + }) + err = a.Prompt(ctx, "Continue from the current repository state using the setup request and saved answers above. Return the required setup JSON reply.") + if saveErr != nil { + return result, fmt.Errorf("save Pi conversation: %w", saveErr) + } + if ctx.Err() != nil { + return result, ctx.Err() + } + if err != nil { + return result, err + } + if a.State().ErrorMessage != "" { + return result, errors.New(a.State().ErrorMessage) + } + if turns >= 32 { + return result, errors.New("Pi exceeded 32 model turns; progress was saved") + } + completed := a.State().Messages + if len(completed) == 0 { + return result, errors.New("Pi ended without a response") + } + data, err := json.Marshal(completed[len(completed)-1]) + if err != nil { + return result, err + } + message, err := ai.UnmarshalMessage(data) + if err != nil { + return result, err + } + assistant, ok := message.(ai.AssistantMessage) + if !ok || assistant.StopReason != ai.StopStop { + return result, fmt.Errorf("Pi did not finish its response (stop reason %q); progress was saved", assistant.StopReason) + } + for _, content := range assistant.Content { + if text, ok := content.(ai.TextContent); ok { + result.Message += text.Text + } + } + if strings.TrimSpace(result.Message) == "" { + return result, errors.New("Pi ended without a complete setup reply") + } + result.ExitCode = 0 + reply, err := ParseReply(result.Message) + if err != nil { + return result, err + } + if !req.ReadOnly && reply.Status != "complete" && reply.Status != "needs_input" { + return result, fmt.Errorf("Pi did not complete (%s): %s", reply.Status, reply.Summary) + } + return result, nil +} + +func decodePiMessages(raw []json.RawMessage) ([]piagent.AgentMessage, error) { + messages := make([]piagent.AgentMessage, 0, len(raw)) + for _, data := range raw { + message, err := ai.UnmarshalMessage(data) + if err != nil { + return nil, err + } + messages = append(messages, message) + } + return messages, nil +} diff --git a/apps/stackpanel-go/internal/setupagent/pi_test.go b/apps/stackpanel-go/internal/setupagent/pi_test.go new file mode 100644 index 00000000..fd2f4ccd --- /dev/null +++ b/apps/stackpanel-go/internal/setupagent/pi_test.go @@ -0,0 +1,216 @@ +package setupagent + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// These fixtures exercise Pi's actual HTTP/SSE adapter and agent/tool loop; +// they never invoke installed agents or send credentials to a real provider. +func piFixture(t *testing.T, handler http.HandlerFunc) Agent { + t.Helper() + t.Setenv("OPENAI_API_KEY", "test-pi-secret") + server := httptest.NewServer(handler) + t.Cleanup(server.Close) + agent, err := NewPiAgent("openai/gpt-5") + if err != nil { + t.Fatal(err) + } + model := *agent.pi.model + model.BaseURL = server.URL + agent.pi.model = &model + return agent +} + +func piSSE(w http.ResponseWriter, tool string, args any, reply string) { + w.Header().Set("Content-Type", "text/event-stream") + emit := func(v any) { data, _ := json.Marshal(v); fmt.Fprintf(w, "data: %s\n\n", data) } + emit(map[string]any{"type": "response.created", "response": map[string]any{"id": "resp_1"}}) + if tool != "" { + data, _ := json.Marshal(args) + item := map[string]any{"type": "function_call", "id": "fc_1", "call_id": "call_1", "name": tool, "arguments": ""} + emit(map[string]any{"type": "response.output_item.added", "item": item}) + emit(map[string]any{"type": "response.function_call_arguments.delta", "delta": string(data)}) + item["arguments"] = string(data) + emit(map[string]any{"type": "response.output_item.done", "item": item}) + } else { + item := map[string]any{"type": "message", "id": "msg_1"} + emit(map[string]any{"type": "response.output_item.added", "item": item}) + emit(map[string]any{"type": "response.content_part.added", "part": map[string]string{"type": "output_text", "text": ""}}) + emit(map[string]any{"type": "response.output_text.delta", "delta": reply}) + item["content"] = []any{map[string]string{"type": "output_text", "text": reply}} + emit(map[string]any{"type": "response.output_item.done", "item": item}) + } + emit(map[string]any{"type": "response.completed", "response": map[string]any{"id": "resp_1", "status": "completed", "usage": map[string]int{"input_tokens": 10, "output_tokens": 10}}}) +} + +func TestPiConversationResumesAndUsesNativeTools(t *testing.T) { + root := t.TempDir() + var mu sync.Mutex + var bodies []string + a := piFixture(t, func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer test-pi-secret" { + t.Error("API key not sent") + } + data, _ := io.ReadAll(r.Body) + mu.Lock() + defer mu.Unlock() + bodies = append(bodies, string(data)) + switch len(bodies) { + case 1: + piSSE(w, "", nil, `{"status":"needs_input","questions":[{"id":"name","prompt":"Project name?","kind":"text","required":true}]}`) + case 2: + piSSE(w, "write", map[string]string{"path": "hello.txt", "content": "hello from Pi"}, "") + case 3: + piSSE(w, "edit", map[string]any{"path": "hello.txt", "edits": []any{map[string]string{"oldText": "hello from Pi", "newText": "resumed project"}}}, "") + default: + piSSE(w, "", nil, `{"status":"complete","summary":"Created the project"}`) + } + }) + state := &PiState{} + saves := 0 + req := RunRequest{Dir: root, Prompt: "schema-marker inspect only", ReadOnly: true, PiState: state, SavePiState: func() error { saves++; return nil }} + result, err := Run(context.Background(), a, req) + if err != nil || !strings.Contains(result.Message, "needs_input") { + t.Fatalf("inspection: %v %s", err, result.Message) + } + // Reconstruct both the host manifest and embedded backend, as on a rerun. + data, err := json.Marshal(state) + if err != nil { + t.Fatal(err) + } + state = &PiState{} + if err := json.Unmarshal(data, state); err != nil { + t.Fatal(err) + } + a = Agent{ID: "pi", pi: &piBackend{model: a.pi.model, key: a.pi.key}} + req.PiState, req.ReadOnly, req.Prompt = state, false, "schema-marker accepted name=resumed project" + result, err = Run(context.Background(), a, req) + if err != nil || !strings.Contains(result.Message, "complete") { + t.Fatalf("setup: %v %s", err, result.Message) + } + data, err = os.ReadFile(filepath.Join(root, "hello.txt")) + if err != nil || string(data) != "resumed project" { + t.Fatalf("file: %s %v", data, err) + } + mu.Lock() + defer mu.Unlock() + if len(bodies) != 4 || !strings.Contains(bodies[1], "Project name?") { + t.Fatalf("conversation not resumed: %d requests", len(bodies)) + } + for _, body := range bodies { + if strings.Count(body, "schema-marker") != 1 { + t.Error("setup schema duplicated in history") + } + if strings.Contains(body, `"name":"bash"`) { + t.Error("shell tool exposed") + } + } + if strings.Contains(bodies[0], `"name":"write"`) { + t.Error("inspection exposed writes") + } + if saves < 6 { + t.Fatalf("missing message checkpoints: %d", saves) + } +} + +func TestPiCancellationAndCheckpointFailure(t *testing.T) { + t.Run("cancel HTTP request", func(t *testing.T) { + a := piFixture(t, func(w http.ResponseWriter, r *http.Request) { + io.Copy(io.Discard, r.Body) + select { + case <-r.Context().Done(): + case <-time.After(2 * time.Second): + } + }) + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + _, err := Run(ctx, a, RunRequest{Dir: t.TempDir(), Prompt: "inspect", ReadOnly: true}) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("cancellation: %v", err) + } + }) + t.Run("failed checkpoint prevents request", func(t *testing.T) { + a := piFixture(t, func(http.ResponseWriter, *http.Request) { t.Error("request after failed checkpoint") }) + _, err := Run(context.Background(), a, RunRequest{Dir: t.TempDir(), Prompt: "inspect", ReadOnly: true, SavePiState: func() error { return errors.New("disk full") }}) + if err == nil || !strings.Contains(err.Error(), "disk full") { + t.Fatalf("checkpoint: %v", err) + } + }) +} + +func TestPiCredentialsAndModel(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "") + t.Setenv("ANTHROPIC_API_KEY", "") + for _, spec := range []string{"", "openai-codex/gpt-5", "openai/gpt-5", "anthropic/claude-sonnet-4-5"} { + if _, err := NewPiAgent(spec); err == nil { + t.Fatalf("accepted unavailable model %q", spec) + } + } + a := piFixture(t, func(http.ResponseWriter, *http.Request) { t.Error("request with mismatched saved model") }) + _, err := Run(context.Background(), a, RunRequest{Dir: t.TempDir(), PiState: &PiState{Model: "anthropic/old-model"}}) + if err == nil || !strings.Contains(err.Error(), "--restart") { + t.Fatalf("model mismatch: %v", err) + } +} + +func TestPiProviderErrorsDoNotLeakCredentials(t *testing.T) { + a := piFixture(t, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "rejected test-pi-secret", http.StatusUnauthorized) + }) + state := &PiState{} + var log bytes.Buffer + _, err := Run(context.Background(), a, RunRequest{Dir: t.TempDir(), ReadOnly: true, PiState: state, Stdout: &log}) + data, _ := json.Marshal(state) + if err == nil { + t.Fatal("accepted authentication failure") + } + if strings.Contains(err.Error()+string(data)+log.String(), "test-pi-secret") { + t.Fatal("credential leaked") + } +} + +func TestPiFileToolPolicy(t *testing.T) { + root, outside := t.TempDir(), t.TempDir() + if err := os.WriteFile(filepath.Join(root, "user.txt"), []byte("keep"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(outside, "secret.txt"), []byte("private"), 0600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(root, "link")); err != nil { + t.Fatal(err) + } + tools, err := piTools(root, false, []string{"user.txt"}) + if err != nil { + t.Fatal(err) + } + for _, tool := range tools { + for _, path := range []string{"../escape", filepath.Join(outside, "secret.txt"), "link/secret.txt", ".git/config", ".env", ".stack/secrets/key", ".stack/state/data", "packages/gen/env/src/web.ts"} { + t.Run(tool.Name+"/"+path, func(t *testing.T) { + _, err := tool.Execute(context.Background(), "test", map[string]any{"path": path, "content": "bad"}, nil) + if err == nil { + t.Fatalf("allowed %s %s", tool.Name, path) + } + }) + } + if tool.Name == "write" { + _, err := tool.Execute(context.Background(), "test", map[string]any{"path": "user.txt", "content": "bad"}, nil) + if err == nil { + t.Fatal("overwrote protected user file") + } + } + } +} diff --git a/apps/stackpanel-go/internal/setupagent/pi_tools.go b/apps/stackpanel-go/internal/setupagent/pi_tools.go new file mode 100644 index 00000000..4b49672f --- /dev/null +++ b/apps/stackpanel-go/internal/setupagent/pi_tools.go @@ -0,0 +1,120 @@ +package setupagent + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + piagent "github.com/sky-valley/pi/agent" + "github.com/sky-valley/pi/coding" +) + +// Reuse Pi's coding tools, with an application policy around each execution. +// Shell, recursive search, hooks, and automatic resource discovery are excluded +// from this experiment. Nix, installation and verification remain host actions. +func piTools(root string, readOnly bool, protected []string) ([]piagent.AgentTool, error) { + root, err := filepath.EvalSymlinks(root) + if err != nil { + return nil, err + } + names := []string{"read", "ls"} + if !readOnly { + names = append(names, "write", "edit") + } + var tools []piagent.AgentTool + for _, name := range names { + tool, err := coding.CreateTool(name, root) + if err != nil { + return nil, err + } + execute := tool.Execute + tool.Execute = func(ctx context.Context, id string, args map[string]any, update piagent.ToolUpdateFunc) (piagent.AgentToolResult, error) { + if err := ctx.Err(); err != nil { + return piagent.AgentToolResult{}, err + } + path, _ := args["path"].(string) + if path == "" && name == "ls" { + path = "." + } + writable := name == "write" || name == "edit" + absolute, err := piToolPath(root, path, writable, protected) + if err != nil { + return piagent.AgentToolResult{}, err + } + // Pi normalizes @, tilde, file:// and Unicode spaces. Supply the + // already validated absolute path to avoid a second interpretation. + args["path"] = absolute + return execute(ctx, id, args, update) + } + tools = append(tools, tool) + } + return tools, nil +} + +func piToolPath(root, path string, write bool, protected []string) (string, error) { + if path == "" || strings.ContainsAny(path, "\x00\u00a0\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000") { + return "", errors.New("tool requires an ordinary repository path") + } + abs := path + if !filepath.IsAbs(abs) { + abs = filepath.Join(root, abs) + } + abs = filepath.Clean(abs) + rel, err := filepath.Rel(root, abs) + if err != nil || !filepath.IsLocal(rel) { + return "", errors.New("tool path must stay inside the repository") + } + parts := strings.Split(filepath.ToSlash(rel), "/") + for _, part := range parts { + if part == ".git" || part == ".direnv" || part == "node_modules" || + part == ".aws" || part == ".ssh" || part == ".codex" || part == ".claude" || part == ".pi" || + part == ".env" || strings.HasPrefix(part, ".env.") || strings.HasSuffix(part, ".pem") || strings.HasSuffix(part, ".key") { + return "", errors.New("tool path is private or managed state") + } + } + for _, prefix := range []string{".stack/state", ".stack/secrets", ".stack/profile", ".stack/gen", ".stack/bin", "packages/gen/env"} { + if piPathWithin(filepath.ToSlash(rel), prefix) { + return "", errors.New("tool path is private or generated state") + } + } + if write { + for _, path := range protected { + if piPathWithin(rel, filepath.Clean(path)) { + return "", fmt.Errorf("preserve existing user file: %s", rel) + } + } + } + // No tool can create a symlink. Reject existing links (including ancestors) + // before invoking Pi, so a repository link cannot redirect file operations. + current := root + for _, part := range strings.Split(rel, string(filepath.Separator)) { + current = filepath.Join(current, part) + info, err := os.Lstat(current) + if errors.Is(err, os.ErrNotExist) { + if write { + break + } + return "", err + } + if err != nil { + return "", err + } + if info.Mode()&os.ModeSymlink != 0 { + return "", errors.New("Pi setup tools do not follow repository symlinks") + } + if !info.IsDir() && !info.Mode().IsRegular() { + return "", errors.New("Pi setup tools only access ordinary files and directories") + } + if info.Mode().IsRegular() && info.Size() > 1<<20 { + return "", errors.New("Pi setup file exceeds 1 MiB") + } + } + return abs, nil +} + +func piPathWithin(path, prefix string) bool { + return path == prefix || strings.HasPrefix(path, prefix+"/") +} diff --git a/apps/stackpanel-go/internal/setupagent/prompt.go b/apps/stackpanel-go/internal/setupagent/prompt.go index 2fcf6c08..982629e4 100644 --- a/apps/stackpanel-go/internal/setupagent/prompt.go +++ b/apps/stackpanel-go/internal/setupagent/prompt.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "io" + "path/filepath" "strings" "github.com/darkmatter/stackpanel/stackpanel-go/internal/reconcile" @@ -39,6 +40,27 @@ type Plan struct { Summary string `json:"summary"` Expectations reconcile.Expectations `json:"expectations"` Services []string `json:"services,omitempty"` + Prepare []PrepareCommand `json:"prepare,omitempty"` +} + +// PrepareCommand is a host step the user approves with the plan: dependency +// installation or lockfile generation the sandboxed agent cannot perform. Setup +// runs it before doctor; doctor itself never installs anything. +type PrepareCommand struct { + ID string `json:"id"` + Dir string `json:"dir"` + Argv []string `json:"argv"` +} + +func ValidatePrepare(commands []PrepareCommand) error { + ids := map[string]bool{} + for _, c := range commands { + if strings.TrimSpace(c.ID) == "" || ids[c.ID] || !filepath.IsLocal(c.Dir) || len(c.Argv) == 0 || strings.TrimSpace(c.Argv[0]) == "" { + return fmt.Errorf("invalid preparation command %q", c.ID) + } + ids[c.ID] = true + } + return nil } type Question struct { @@ -121,6 +143,9 @@ func ParseReply(message string) (*Reply, error) { if err := reconcile.ValidateExpectations(reply.Plan.Expectations); err != nil { return nil, err } + if err := ValidatePrepare(reply.Plan.Prepare); err != nil { + return nil, err + } case "complete", "blocked": if strings.TrimSpace(reply.Summary) == "" { return nil, fmt.Errorf("%s requires a summary", reply.Status) @@ -246,7 +271,7 @@ stackpanel/flake-parts unless the user requested different versions. The framewo pinned inputs are tested together; independently selecting nixos-unstable can break configuration evaluation. Preserve deliberate pins in existing repositories. Your final response must be exactly one JSON object, without Markdown fences or prose: -{"status":"plan","plan":{"summary":"concrete intended changes and files","services":[],"expectations":{"version":1,"config":[{"path":["enable"],"equals":true}],"requiredChecks":[],"files":[],"commands":[]}}} +{"status":"plan","plan":{"summary":"concrete intended changes and files","services":[],"prepare":[],"expectations":{"version":1,"config":[{"path":["enable"],"equals":true}],"requiredChecks":[],"files":[],"commands":[]}}} List selected local services to start in services (only supported Stackpanel services). For new apps, include every source file and manifest needed by pure Nix evaluation or builds in files (repo-relative file paths); only these accepted new files and @@ -254,10 +279,24 @@ Stackpanel configuration become visible to Git-backed Nix evaluation. Include concrete acceptance commands as {"id":"web-test","scope":"build","dir":"apps/web","argv":["bun","test"]}. New-repository plans must include files and at least one meaningful build or test command. These commands will run unchanged under doctor; do not use shell command strings. +Doctor only verifies and never installs. If those commands or Nix evaluation need +installed dependencies or a generated lockfile, plan the host steps in prepare, in +order, as {"id":"deps","dir":".","argv":["bun","install"]}. After your edits the host +runs them unchanged in the repository devshell, with network access, before every +doctor run. You cannot run them yourself and repair cannot add any. List each lockfile +or manifest they create that Nix evaluation or builds read in files, and never write +those files yourself. Where a lockfile already exists, use the package manager's +frozen install so it is preserved. +An app with bun.enable is packaged from bun.nix: after bun install, also plan +{"id":"bun-nix","dir":".","argv":["bun2nix","-o","bun.nix"]} in the directory that owns +bun.lock, and list bun.lock and bun.nix in files. Set apps..bun.generateFiles = false +for an app whose package.json already exists or is written by you; otherwise Stackpanel +replaces its scripts and dependencies. Config paths are segment arrays relative to the evaluated Stackpanel configuration. For each expected app add {"path":["apps","APP"],"exists":true}; for each selected module add {"path":["modules","MODULE","enable"],"equals":true}. Add assertions for important app commands when available. Each assertion must have exactly one of exists or equals. +Attributes named bun are omitted from the evaluated configuration; do not assert them. Only add required check IDs if concrete check metadata was supplied; addon offers alone do not identify checks. Otherwise leave requiredChecks empty; doctor still runs all selected declared checks. Never invent IDs. @@ -278,6 +317,8 @@ cannot access the Nix daemon. Stackpanel will create/update flake.lock on the ho after your edits, then reconcile and verify. Do not create or edit flake.lock yourself. Use file inspection and editing tools; defer dependency installation, builds, tests, generation, and other daemon/network-dependent commands to the host verifier. +The host runs the frozen plan's prepare commands after your edits; never write the +lockfiles or manifests they generate. Integrate existing flake and repository configuration instead of replacing it wholesale. For a newly scaffolded flake, use the framework's pinned nixpkgs and flake-parts inputs via follows, as planned, instead of independently updating their branches. @@ -317,6 +358,9 @@ func ParsePlan(message string) (*Plan, error) { if err := reconcile.ValidateExpectations(plan.Expectations); err != nil { return nil, fmt.Errorf("invalid onboarding expectations: %w", err) } + if err := ValidatePrepare(plan.Prepare); err != nil { + return nil, err + } return &plan, nil } diff --git a/apps/stackpanel-go/internal/tui/setup.go b/apps/stackpanel-go/internal/tui/setup.go index bbf751e5..0fd1575a 100644 --- a/apps/stackpanel-go/internal/tui/setup.go +++ b/apps/stackpanel-go/internal/tui/setup.go @@ -40,6 +40,7 @@ var setupStages = []string{"Discover", "Plan", "Set up", "Doctor", "Studio"} type setupProgress string type setupActivity string +type setupWarning string type setupDocument string type setupFinished struct { text string @@ -70,6 +71,7 @@ type setupModel struct { stage SetupStage progress string activity []string + warnings []string details bool document string question *setupQuestion @@ -123,6 +125,9 @@ func (m setupModel) Update(message tea.Msg) (tea.Model, tea.Cmd) { if len(m.activity) > 40 { m.activity = m.activity[len(m.activity)-40:] } + case setupWarning: + m.warnings = append(m.warnings, string(msg)) + m.viewport.GotoTop() case setupDocument: m.document = string(msg) m.viewport.GotoTop() @@ -230,8 +235,17 @@ func NewSetupUI(ctx context.Context, interactive bool, out io.Writer) *SetupUI { ctx, cancel := context.WithCancel(ctx) u := &SetupUI{ctx: ctx, cancel: cancel, out: out, done: make(chan struct{})} if interactive { - u.program = tea.NewProgram(newSetupModel(), tea.WithOutput(out), tea.WithContext(ctx)) - go func() { _, _ = u.program.Run(); cancel(); close(u.done) }() + u.program = tea.NewProgram(newSetupModel(), tea.WithOutput(out), tea.WithContext(ctx), tea.WithAltScreen()) + go func() { + final, _ := u.program.Run() + // The alternate screen takes the wizard with it, so leave the + // outcome and any warnings behind before unblocking callers. + if m, ok := final.(setupModel); ok { + fmt.Fprint(out, m.transcript()) + } + cancel() + close(u.done) + }() } else { close(u.done) } @@ -278,10 +292,11 @@ func (u *SetupUI) Progress(text string) { fmt.Fprintln(u.out, text) } -// Warning stays in terminal scrollback instead of disappearing with a stage. +// Warning stays on screen instead of disappearing with a stage, and remains in +// terminal scrollback after the wizard closes. func (u *SetupUI) Warning(text string) { if u.program != nil { - u.program.Println(RenderWarning(setupDisplayText(text))) + u.program.Send(setupWarning(setupDisplayText(text))) } else { u.Progress("Warning: " + text) } diff --git a/apps/stackpanel-go/internal/tui/setup_test.go b/apps/stackpanel-go/internal/tui/setup_test.go index ae29434d..2fe57ea3 100644 --- a/apps/stackpanel-go/internal/tui/setup_test.go +++ b/apps/stackpanel-go/internal/tui/setup_test.go @@ -8,6 +8,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" ) func setupUpdate(m setupModel, messages ...tea.Msg) setupModel { @@ -64,6 +65,86 @@ func TestSetupPlanScrollKeepsApprovalVisible(t *testing.T) { } } +func TestSetupCentersOpenSidedColumn(t *testing.T) { + size := tea.WindowSizeMsg{Width: 120, Height: 40} + view := ansi.Strip(setupUpdate(newSetupModel(), size, setupProgress("Preparing files")).View()) + if w, h := lipgloss.Size(view); w != size.Width || h != size.Height { + t.Fatalf("UI does not fill the %dx%d terminal: %dx%d", size.Width, size.Height, w, h) + } + if strings.ContainsAny(view, "│╭╮╰╯") { + t.Fatal("panel still draws side borders") + } + lines := strings.Split(view, "\n") + indent := func(line string) int { return len(line) - len(strings.TrimLeft(line, " ")) } + top, bottom := 0, 0 + for strings.TrimSpace(lines[top]) == "" { + top++ + } + for strings.TrimSpace(lines[len(lines)-1-bottom]) == "" { + bottom++ + } + rule := lines[top] + for _, line := range lines { + if strings.Contains(line, "─") { + rule = line + break + } + } + left := indent(rule) + right := size.Width - lipgloss.Width(strings.TrimRight(rule, " ")) + if top == 0 || top-bottom > 1 || bottom-top > 1 || left == 0 || left-right > 1 || right-left > 1 { + t.Fatalf("column is not centered: margins top %d, bottom %d, left %d, right %d", top, bottom, left, right) + } + if indent(lines[top]) != left { + t.Fatal("header is centered on its own instead of sharing the column's left edge") + } +} + +func TestSetupPanelKeepsDefaultHeight(t *testing.T) { + panelHeight := func(m setupModel) int { + var rules []int + for i, line := range strings.Split(ansi.Strip(m.View()), "\n") { + if strings.Contains(line, "─") { + rules = append(rules, i) + } + } + if len(rules) != 2 { + t.Fatalf("expected one rule above and one below the panel, found %d", len(rules)) + } + return rules[1] - rules[0] - 1 + } + working := setupUpdate(newSetupModel(), tea.WindowSizeMsg{Width: 120, Height: 50}, setupProgress("Preparing files")) + asking := setupUpdate(working, setupQuestion{prompt: "Which agent?", kind: "single", options: []string{"codex", "claude"}, answer: make(chan setupAnswer, 1)}) + if w, a := panelHeight(working), panelHeight(asking); w != 30 || a != 30 { + t.Fatalf("panel does not keep its default height: %d lines working, %d asking", w, a) + } + done := setupUpdate(working, setupFinished{text: "Repository verified."}) + if h := lipgloss.Height(done.transcript()); h > 10 { + t.Fatalf("summary left in scrollback is padded to %d lines", h) + } +} + +func TestSetupWarningsStayVisibleAndReachScrollback(t *testing.T) { + if m := newSetupModel(); m.transcript() != "" { + t.Fatalf("a quiet run left output behind: %q", m.transcript()) + } + m := setupUpdate(newSetupModel(), setupWarning("claude returned malformed setup JSON"), setupStage{SetupApply, "Writing files"}) + if !strings.Contains(m.View(), "malformed setup JSON") { + t.Fatal("warning disappeared with the stage") + } + closed := setupUpdate(m, setupClosed{}) + if closed.View() != "" || !strings.Contains(closed.transcript(), "malformed setup JSON") { + t.Fatal("warning did not outlive the closed wizard") + } + done := setupUpdate(m, setupVerified("Repository · 3 doctor checks passed"), setupFinished{text: "Repository verified."}) + transcript := done.transcript() + for _, want := range []string{"malformed setup JSON", "Setup complete", "3 doctor checks passed", "Repository verified."} { + if !strings.Contains(transcript, want) { + t.Fatalf("scrollback is missing %q: %s", want, transcript) + } + } +} + func TestSetupDetailsAndCancellation(t *testing.T) { answer := make(chan setupAnswer, 1) m := setupUpdate(newSetupModel(), setupProgress("Preparing files"), setupActivity("a long shell command")) diff --git a/apps/stackpanel-go/internal/tui/setup_view.go b/apps/stackpanel-go/internal/tui/setup_view.go index 2030e1a1..dcb50cb5 100644 --- a/apps/stackpanel-go/internal/tui/setup_view.go +++ b/apps/stackpanel-go/internal/tui/setup_view.go @@ -9,21 +9,39 @@ import ( "github.com/charmbracelet/x/ansi" ) -func (m setupModel) View() string { return m.render() } +// Rules above and below only: open sides keep the centered column light. +var setupPanelStyle = BoxStyle.BorderLeft(false).BorderRight(false) -func (m *setupModel) render() string { +func (m setupModel) View() string { if m.quitting { return "" } + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, m.render()) +} + +// transcript is what remains in terminal scrollback once the alternate screen +// closes: warnings raised along the way, then the outcome. +func (m *setupModel) transcript() string { + var s strings.Builder + for _, warning := range m.warnings { + s.WriteString(RenderWarning(warning) + "\n") + } + if m.result != "" { + s.WriteString("\n" + m.render() + "\n") + } + return s.String() +} + +// render lays the wizard out as one column of equal-width lines. Place centers +// each line on its own, so ragged lines would lose their left alignment. +func (m *setupModel) render() string { width := max(12, min(90, m.width-2)) - inner := max(6, width-6) + inner := max(6, width-4) wrap := func(s string) string { return ansi.Wrap(s, inner, "") } if m.result != "" { result := TextBold.Foreground(ColorSecondary).Render("Setup complete") - box := BoxSuccessStyle if m.resultWarning { result = RenderWarning("Setup needs attention") - box = BoxStyle.BorderForeground(ColorWarning) } for _, verified := range m.verified { result += "\n" + RenderSuccess(wrap(verified)) @@ -32,7 +50,7 @@ func (m *setupModel) render() string { if m.identity.root != "" { result += "\n\n" + TextSubtle.Render("Repository") + "\n" + wrap(m.identity.root) } - return "\n" + box.Width(width-2).Render(result) + "\n" + return setupPanelStyle.Width(width).Render(result) } header := TitleStyle.MarginBottom(0).Render("STACKPANEL") + " " + TextSubtle.Render("Repository setup") if m.identity.root != "" { @@ -127,23 +145,39 @@ func (m *setupModel) render() string { body += "\n\n" + TextSubtle.Render("Recent activity") + "\n" + strings.Join(m.activity, "\n") } body = wrap(body) - if m.document != "" || m.details { + if len(m.warnings) > 0 { + // Wrap before styling so each line carries its own color when scrolled. + notices := make([]string, len(m.warnings)) + for i, warning := range m.warnings { + notices[i] = RenderWarning(ansi.Wrap(warning, inner-2, "")) + } + if body != "" { + notices = append(notices, "", body) + } + body = strings.Join(notices, "\n") + } + if m.document != "" || m.details || len(m.warnings) > 0 { help = "PgUp/PgDn scroll · " + help } help = ansi.Wrap(help, width, "") + // Lines between the rules once the header, the gap below it and the help fit. + room := m.height - lipgloss.Height(header) - lipgloss.Height(help) - 3 panel := status if body != "" { - available := m.height - lipgloss.Height(header) - lipgloss.Height(status) - lipgloss.Height(help) - 9 + // The panel's padding, the status and the gap below it come out of that. + available := room - lipgloss.Height(status) - 3 if controls != "" { available -= lipgloss.Height(controls) + 1 } m.viewport.Width = inner - m.viewport.Height = max(1, min(12, min(available, lipgloss.Height(body)))) + m.viewport.Height = max(1, min(available, lipgloss.Height(body))) m.viewport.SetContent(body) panel += "\n\n" + m.viewport.View() } if controls != "" { panel += "\n\n" + controls } - return "\n" + header + "\n\n" + BoxActiveStyle.Padding(1, 2).Width(width-2).Render(panel) + "\n" + TextDim.Render(help) + "\n" + // A default height keeps the column from resizing as content comes and goes. + panel = setupPanelStyle.Width(width).Height(min(30, room)).Render(panel) + return lipgloss.JoinVertical(lipgloss.Left, header, "", panel, TextDim.Render(help)) } diff --git a/flake.nix b/flake.nix index 81542d87..a8a1fa2d 100644 --- a/flake.nix +++ b/flake.nix @@ -94,7 +94,12 @@ }; flake = { - inherit (exports) lib templates flakeModules; + inherit (exports) + lib + templates + flakeModules + nixosModules + ; # Re-export the pinned Prelude input for power users who want # `nix run` / docs against the same revision Stackpanel ships. inherit (exports) prelude; diff --git a/nix/flake/exports.nix b/nix/flake/exports.nix index 640fad75..872d6c3c 100644 --- a/nix/flake/exports.nix +++ b/nix/flake/exports.nix @@ -139,28 +139,11 @@ let } ); in - flakeOutputs - // { - lib = exported.lib // (flakeOutputs.lib or { }); - templates = exported.templates // (flakeOutputs.templates or { }); - flakeModules = exported.flakeModules // (flakeOutputs.flakeModules or { }); - nixosModules = exported.nixosModules // (flakeOutputs.nixosModules or { }); - # Same Prelude pin the Stackpanel flake module closes over. - inherit (exported) prelude; - }; + flakeOutputs; # Required overlays for stackpanel. requiredOverlays = stackpanelOverlays; - # AWS credential helpers. - mkAwsCredScripts = import ../stackpanel/integrations/services/aws/lib.nix; - - # Step CA certificate helpers. - mkStepScripts = import ../stackpanel/lib/services/step.nix; - - # Fly.io OIDC to AWS authentication. - flyOidc = import ../stackpanel/lib/services/fly-oidc.nix; - # Get stackpanel module options for introspection. inherit getOptions; @@ -185,15 +168,10 @@ let # ========================================================================== # NIXOS MODULES (for NixOS users) + # Project flakes publish only the modules generated from their own config + # (see global-outputs.nix); these are Stackpanel's own. # ========================================================================== nixosModules = { - default = ../stackpanel/default.nix; - aws = ../stackpanel/integrations/services/aws; - network = ../stackpanel/network/network.nix; - secrets = ../stackpanel/secrets/default.nix; - theme = ../stackpanel/lib/theme.nix; - caddy = ../stackpanel/integrations/services/caddy.nix; - ci = ../stackpanel/apps/ci.nix; web-service = ../stackpanel/nixos/web-service.nix; }; diff --git a/nix/flake/flake-outputs.nix b/nix/flake/flake-outputs.nix index 51911f0f..20bc18b6 100644 --- a/nix/flake/flake-outputs.nix +++ b/nix/flake/flake-outputs.nix @@ -14,17 +14,6 @@ primarySystem, }: let - baseNixosModules = { - default = ../stackpanel/default.nix; - aws = ../stackpanel/integrations/services/aws; - network = ../stackpanel/network/network.nix; - secrets = ../stackpanel/secrets/default.nix; - theme = ../stackpanel/lib/theme.nix; - caddy = ../stackpanel/integrations/services/caddy.nix; - ci = ../stackpanel/apps/ci.nix; - web-service = ../stackpanel/nixos/web-service.nix; - }; - globalOutputs = import ./global-outputs.nix { inherit inputs self; inherit stackpanelImports; @@ -63,7 +52,7 @@ in { flake = { flakeInputs = builtins.removeAttrs inputs [ "self" ]; - nixosModules = baseNixosModules // globalOutputs.nixosModules; + inherit (globalOutputs) nixosModules; inherit (globalOutputs) nixosConfigurations colmenaHive; stackpanelConfig = withSystem primarySystem ( diff --git a/nix/flake/overlays.nix b/nix/flake/overlays.nix index 8b7daed8..438b396e 100644 --- a/nix/flake/overlays.nix +++ b/nix/flake/overlays.nix @@ -74,4 +74,20 @@ }; }); }) + + # bun2nix: Bun 1.4 (pinned above) writes new lockfiles as lockfileVersion 2, + # which bun2nix still rejects, so a fresh project could never produce bun.nix + # (existing lockfiles keep version 1). Version 2 only parses integrity and git + # tags more strictly; the package layout bun2nix reads is unchanged + # (nix-community/bun2nix#110, not merged yet). + # --replace-fail breaks this build once upstream changes the check: drop the + # override then. + (_final: prev: { + bun2nix = prev.bun2nix.overrideAttrs (old: { + postPatch = (old.postPatch or "") + '' + substituteInPlace src/lib.rs \ + --replace-fail 'lockfile.lockfile_version != 1' '!matches!(lockfile.lockfile_version, 1 | 2)' + ''; + }); + }) ] diff --git a/nix/flake/packages.nix b/nix/flake/packages.nix index bac6d2d3..7706b6da 100644 --- a/nix/flake/packages.nix +++ b/nix/flake/packages.nix @@ -22,6 +22,10 @@ in # Alias for backwards compatibility stackpanel-cli = stackpanel; + # The Bun the devshell uses. CI installs this (`nix build .#bun`) instead of + # pinning a version in GitHub Actions. The version lives in the bun overlay. + bun = pkgs.bun; + # Default package default = stackpanel; } diff --git a/nix/stackpanel/integrations/deployment/fly/README.md b/nix/stackpanel/integrations/deployment/fly/README.md index a618d011..3282e478 100644 --- a/nix/stackpanel/integrations/deployment/fly/README.md +++ b/nix/stackpanel/integrations/deployment/fly/README.md @@ -14,8 +14,7 @@ Container-based deployment to [Fly.io](https://fly.io) with generated `fly.toml` Container builds for Fly-deployed apps are produced by `stackpanel.containers` (see `nix/stackpanel/containers/`) — this module just contributes per-app entries to `stackpanel.containers.images`. The -shared `flyOidc` helpers live at `nix/stackpanel/lib/services/fly-oidc.nix` -and are re-exported as `inputs.stackpanel.lib.flyOidc`. +shared `flyOidc` helpers live at `nix/stackpanel/lib/services/fly-oidc.nix`. ## Usage diff --git a/nix/stackpanel/modules/playwright/default.nix b/nix/stackpanel/modules/playwright/default.nix index a6dc3749..5d2ddeb1 100644 --- a/nix/stackpanel/modules/playwright/default.nix +++ b/nix/stackpanel/modules/playwright/default.nix @@ -83,7 +83,20 @@ in runs-on = "ubuntu-latest"; steps = [ { uses = "actions/checkout@v4"; } - { uses = "oven-sh/setup-bun@v2"; } + { + uses = "DeterminateSystems/nix-installer-action@main"; + "with".extra-conf = '' + accept-flake-config = true + ''; + } + { + name = "Install Bun from the devshell"; + run = '' + set -euo pipefail + eval "$(nix print-dev-env)" + echo "$(dirname "$(command -v bun)")" >> "$GITHUB_PATH" + ''; + } { name = "Install"; run = "bun install --frozen-lockfile"; diff --git a/package.json b/package.json index d4240f8b..bbf36d90 100644 --- a/package.json +++ b/package.json @@ -103,7 +103,7 @@ "@effect/platform-node-shared": "4.0.0-rc.112", "effect": "4.0.0-rc.112" }, - "packageManager": "bun@1.3.2", + "packageManager": "bun@1.4.2", "patchedDependencies": { "@distilled.cloud/fly-io@0.21.6": "patches/@distilled.cloud%2Ffly-io@0.21.6.patch" } diff --git a/tests/test-agent-setup.sh b/tests/test-agent-setup.sh index f7c41a08..421454c3 100755 --- a/tests/test-agent-setup.sh +++ b/tests/test-agent-setup.sh @@ -117,10 +117,17 @@ if tool in ("codex", "claude"): expected = { "version": 1, "config": [{"path": ["apps", "web"], "exists": True}], "requiredChecks": []} + plan = {"summary": "Configure the web app", "expectations": expected} if new_repo: - expected.update({"files": ["app.py", "test_app.py"], "commands": [{ + expected.update({"files": ["app.py", "test_app.py", "deps.lock"], "commands": [{ "id": "app-test", "scope": "build", "dir": ".", "argv": [sys.executable, "test_app.py"]}]}) - emit_message({"status": "plan", "plan": {"summary": "Configure the web app", "expectations": expected}}) + plan["prepare"] = [{"id": "deps", "dir": ".", "argv": [ + sys.executable, "-c", "open('deps.lock', 'w').write('locked\\n')"]}] + if state.name == "prepare-repair": + plan["prepare"] = [{"id": "deps", "dir": ".", "argv": [sys.executable, "-c", + "import os, sys\nmarker = os.environ['AGENT_SETUP_TEST_STATE'] + '/prepare-attempted'\n" + "if not os.path.exists(marker):\n open(marker, 'w').close()\n sys.exit('fixture registry error')"]}] + emit_message({"status": "plan", "plan": plan}) else: assert "Do not invoke stack setup, nix, direnv" in prompt assert "Do not create or edit flake.lock yourself" in prompt @@ -129,12 +136,16 @@ if tool in ("codex", "claude"): if new_repo: assert not (root / "flake.lock").exists(), "locking must wait for the app's input edits" (root / "app.py").write_text('def greet(name):\n return "Hello, " + name\n') - (root / "test_app.py").write_text('from app import greet\nassert greet("world") == "Hello, world"\n') + assert not (root / "deps.lock").exists(), "lockfiles belong to host preparation" + (root / "test_app.py").write_text('from app import greet\nassert greet("world") == "Hello, world"\n' + 'assert open("deps.lock").read() == "locked\\n"\n') (root / ".stack/onboarded.nix").write_text("{ onboarded = true; }\n") if state.name == "user-edit-violation": (root / "unrelated.txt").write_text("model overwrote existing user edits\n") if phase == "repair" and state.name == "lock-repair": assert "fixture lock error" in prompt + elif phase == "repair" and state.name == "prepare-repair": + assert "host preparation deps failed" in prompt and "fixture registry error" in prompt elif phase == "repair": assert "config:apps.web" in prompt, "repair did not receive doctor failure" # Try to weaken the external file. The orchestrator must replace @@ -150,7 +161,7 @@ if tool in ("codex", "claude"): assert "Resuming saved onboarding" in prompt if new_repo or state.name == "resume-inspection": assert '\"values\":[\"Python\"]' in prompt - configured = state.name in ("malformed-plan", "malformed-complete", "malformed-permanent", "claude-malformed-complete") or phase == "repair" and state.name == "malformed-repair" or new_repo or state.name in ("resume-agent", "resume-inspection", "resume-doctor", "resume-kill", "resume-complete") or phase == "repair" and state.name in ("repair-success", "lock-repair", "claude-read-denial") or state.name == "resume-contract" and phase == "repair" and (state / "retry").exists() + configured = state.name in ("malformed-plan", "malformed-complete", "malformed-permanent", "claude-malformed-complete") or phase == "repair" and state.name == "malformed-repair" or new_repo or state.name in ("resume-agent", "resume-inspection", "resume-doctor", "resume-kill", "resume-complete") or phase == "repair" and state.name in ("repair-success", "lock-repair", "prepare-repair", "claude-read-denial") or state.name == "resume-contract" and phase == "repair" and (state / "retry").exists() (root / ".stack/config.nix").write_text( "{ enable = true; " + ("apps.web = {}; " if configured else "") + "}\n") emit_message({"status": "complete", "summary": "Setup is complete"}) @@ -210,7 +221,8 @@ elif args and args[0] == "develop": assert flags & 0x20000000, "new Nix input is not visible through intent-to-add" command = args[5:] doctor = "--expectations" in command - record({"tool": tool, "args": args, "stage": "doctor" if doctor else "reconcile"}) + record({"tool": tool, "args": args, + "stage": "doctor" if doctor else "prepare" if "stackpanel-prepare" in command else "reconcile"}) if doctor and state.name in ("resume-doctor", "resume-kill") and not (state / "interrupted").exists(): import signal import time @@ -230,7 +242,7 @@ elif args and args[0] == "develop": {"path": ["apps", "web"], "exists": True}, {"path": ["enable"], "equals": True}], "requiredChecks": required} if new_repo: - contract.update({"files": ["app.py", "test_app.py"], "commands": [{ + contract.update({"files": ["app.py", "test_app.py", "deps.lock"], "commands": [{ "id": "app-test", "scope": "build", "dir": ".", "argv": [sys.executable, "test_app.py"]}]}) for path in contract["files"]: subprocess.run(["git", "ls-files", "--error-unmatch", path], cwd=root, check=True, stdout=subprocess.DEVNULL) @@ -261,7 +273,7 @@ for name in ("codex", "claude", "nix", "write-files"): for name, expected_success in (("repair-success", True), ("permanent-failure", False), ("user-edit-violation", False), ("new-repository", True), - ("lock-repair", True), ("claude-read-denial", True), + ("lock-repair", True), ("prepare-repair", True), ("claude-read-denial", True), ("claude-read-denial-failure", False), ("resume-agent", False), ("resume-inspection", False), ("resume-doctor", False), ("resume-new", False), ("resume-tmp", False), ("resume-contract", False), @@ -450,6 +462,20 @@ for name, expected_success in (("repair-success", True), ("permanent-failure", F elif name != "resume-new": assert git("diff", "--cached", "--", "unrelated.txt") == staged assert git("diff", "--", "unrelated.txt") == unstaged + if name == "resume-complete": + # A Pi-backed session can verify current files without provider + # credentials, just like the CLI backends. Doctor never calls Pi. + completed["agent"] = "pi" + completed["options"]["agentModel"] = "openai/gpt-5" + completed["pi"] = {"model": "openai/gpt-5"} + manifest_path.write_text(json.dumps(completed)) + pi_env = dict(env, OPENAI_API_KEY="", ANTHROPIC_API_KEY="") + pi_args = ["--experimental-agent=pi" if arg.startswith("--experimental-agent=") else arg + for arg in retry_args] + pi_retry = subprocess.run(pi_args, cwd=invocation_dir, env=pi_env, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=30) + assert pi_retry.returncode == 0, pi_retry.stdout + pi_retry.stderr + print("PASS: pi-verification-resume (real doctor, no API key or coding agent)") print("PASS: " + name + " (separate-process retry, saved choices, current-repository doctor)") continue if provider == "claude": @@ -460,13 +486,14 @@ for name, expected_success in (("repair-success", True), ("permanent-failure", F assert sum(bool(call.get("lock")) for call in calls) == expected_locks if name == "new-repository": assert [call["phase"] for call in calls if call["tool"] == provider] == ["inspection", "inspection", "setup"] - assert [call["stage"] for call in calls if "stage" in call] == ["reconcile", "doctor"] + assert [call["stage"] for call in calls if "stage" in call] == ["reconcile", "prepare", "doctor"] report = json.loads((state / "doctor-1.json").read_text()) statuses = {entry["id"]: entry["status"] for entry in report["checkResults"]} assert statuses == {"fixture-repo": "pass", "fixture-build": "pass", "file:app.py": "pass", - "file:test_app.py": "pass", "acceptance:app-test": "pass"}, statuses + "file:test_app.py": "pass", "file:deps.lock": "pass", "acceptance:app-test": "pass"}, statuses + assert "Host preparation" in result.stderr and "Preparing deps" in result.stderr assert "turn.completed" not in result.stderr and "item.completed" not in result.stderr - print("PASS: new-repository (question round, empty target, source visibility and real acceptance command)") + print("PASS: new-repository (question round, empty target, host preparation, source visibility and real acceptance command)") continue visibility = git("ls-files", "--stage", "--debug", "-z", "--", ".stack/onboarded.nix") if expected_success or warning_only: @@ -482,6 +509,12 @@ for name, expected_success in (("repair-success", True), ("permanent-failure", F assert not (state / "doctor-2.json").exists(), "doctor ran before the lock was repaired" print("PASS: lock-repair (host Nix error reached the single repair attempt)") continue + if name == "prepare-repair": + assert [call["phase"] for call in calls if call["tool"] == provider] == ["inspection", "setup", "repair"] + assert [call["stage"] for call in calls if "stage" in call] == ["reconcile", "prepare", "reconcile", "prepare", "doctor"] + assert not (state / "doctor-2.json").exists(), "doctor ran before host preparation succeeded" + print("PASS: prepare-repair (failed host preparation reached the single repair attempt)") + continue if name == "user-edit-violation": assert [call["phase"] for call in calls if call["tool"] == provider] == ["inspection", "setup"] assert not any("stage" in call for call in calls), "reconciliation ran after user edits were overwritten"