diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dbea5fb..a47795e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,12 +9,19 @@ jobs: build-test-lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-go@v5 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 with: - go-version: "1.23" + go-version: "1.26.5" + - name: Install reviewer containment + run: | + sudo apt-get update + sudo apt-get install --no-install-recommends -y bubblewrap + sudo chmod 4755 "$(command -v bwrap)" - run: go build ./... - run: go test ./... - - uses: golangci/golangci-lint-action@v6 + - run: go test -race -shuffle=on -count=1 ./... + - run: go vet ./... + - uses: golangci/golangci-lint-action@9fae48acfc02a90574d7c304a1758ef9895495fa # v7.0.1 with: - version: latest + version: v2.11.2 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a039d75 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,16 @@ +# Project agent memory + +This file is the project's committed home for project-intrinsic agent knowledge: build, test, release, architecture, and sharp-edge notes that should travel with the code. + +- Add durable project-specific notes here as they are discovered through real work. + +- The versioned runtime contract is implemented at `cmd/made/runcommands.go` and `internal/daemon`; validate it through `made capabilities --json` and the `made run ... --json` commands. +- `.made.yml` is decoded strictly with `version: 1`; the authoritative loader is `internal/config/config.go`. +- Local git fixtures can inherit SSH commit signing from the host; use process-local `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=commit.gpgsign GIT_CONFIG_VALUE_0=false` when running the Go test suite. + +## Maintaining this file + +Keep this file for knowledge useful to almost every future agent session in this project. +Do not repeat what the codebase already shows; point to the authoritative file or command instead. +Prefer rewriting or pruning existing entries over appending new ones. +When updating this file, preserve this bar for all agents and keep entries concise. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/README.md b/README.md index 092bedb..dfb2f0f 100644 --- a/README.md +++ b/README.md @@ -7,3 +7,21 @@ A personal Go rewrite of [no-mistakes](https://github.com/kunchenguid/no-mistake made is an independent synthesis, not a dependency bundle or a one-to-one copy of any source. See `plans/made-rewrite.md` for the full design and build plan. + +## Versioned daemon contract + +`made capabilities --json` reports the public protocol and command schema. + +Use `made run submit --json --gate --ref refs/heads/ --old-sha --input-sha ` to create a run. + +Use `made run status --json ` for one run, `made run list --json --active` for the active batch, and `made run cancel --json ` for idempotent cancellation. + +Use `made review decide --json --stage --decision ` for an exact review decision. + +Use `made doctor --json` for the fixed health schema. + +Run states are `queued`, `running`, `awaiting_review`, `awaiting_merge`, `succeeded`, `failed`, `canceled`, and `superseded`. + +Run state is persisted in a fsync-backed local WAL, and gate submissions use an idempotent fsync-backed spool keyed by gate, ref, and input SHA. + +The daemon acquires its singleton before touching the Unix socket path, removes only stale sockets, and refuses regular files, symlinks, and directories. diff --git a/cmd/made/daemon.go b/cmd/made/daemon.go index b300f48..cd55668 100644 --- a/cmd/made/daemon.go +++ b/cmd/made/daemon.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "strings" + "sync" "time" "github.com/douglasjarquin/made/internal/api" @@ -16,6 +17,7 @@ import ( "github.com/douglasjarquin/made/internal/exec" "github.com/douglasjarquin/made/internal/gitgate" "github.com/douglasjarquin/made/internal/orchestrator" + "golang.org/x/sys/unix" ) const defaultIdleTimeout = 30 * time.Minute @@ -39,7 +41,7 @@ func runDaemonCommand(args []string, stdout, stderr *os.File) int { case "start": return daemonStart(args[1:], home, lockPath, stdout, stderr) case "stop": - return daemonStop(lockPath, stdout, stderr) + return daemonStop(api.SocketPath(home), stdout, stderr) case "status": return daemonStatus(lockPath, stdout, stderr) default: @@ -92,14 +94,50 @@ func daemonStart(args []string, home, lockPath string, stdout, stderr *os.File) // The returned channel receives daemon.Run's final error exactly once, after // the socket server has also been shut down. func startDaemon(ctx context.Context, home, lockPath string, idle time.Duration, onReady func(pid int)) (*daemon.RunManager, <-chan error) { - rm := daemon.NewRunManager() + validatedHome, err := ensureMadeHome(home) + if err != nil { + done := make(chan error, 1) + done <- err + return daemon.NewRunManager(), done + } + home = validatedHome + ownedLock, err := daemon.AcquireLock(lockPath) + if err != nil { + done := make(chan error, 1) + done <- err + return daemon.NewRunManager(), done + } + spool, err := daemon.OpenGateSpool(filepath.Join(home, "gate.spool")) + if err != nil { + _ = ownedLock.Release() + done := make(chan error, 1) + done <- err + return daemon.NewRunManager(), done + } + rm, err := daemon.NewPersistentRunManager(filepath.Join(home, "runs.wal")) + if err != nil { + _ = ownedLock.Release() + done := make(chan error, 1) + done <- err + return daemon.NewRunManager(), done + } + socketPath := api.SocketPath(home) + if err := api.PrepareSocket(socketPath); err != nil { + _ = ownedLock.Release() + done := make(chan error, 1) + done <- err + return rm, done + } reviewStore := daemon.NewReviewDecisions() - srv := api.NewServer(api.SocketPath(home)) - registerDaemonHandlers(srv, rm, reviewStore) + admission := &sync.Mutex{} + runCtx, cancelRun := context.WithCancel(ctx) + srv := api.NewServer(socketPath) + registerDaemonHandlers(srv, rm, reviewStore, spool, cancelRun, admission) done := make(chan error, 1) if err := srv.Listen(); err != nil { + _ = ownedLock.Release() done <- fmt.Errorf("listen on socket: %w", err) return rm, done } @@ -109,32 +147,88 @@ func startDaemon(ctx context.Context, home, lockPath string, idle time.Duration, go func() { serveErr <- srv.Serve(serveCtx) }() go func() { - runErr := daemon.Run(ctx, daemon.Options{ - LockPath: lockPath, - IdleTimeout: idle, - OnReady: onReady, - ActivityCh: rm.ActivitySignal(), + runErr := daemon.Run(runCtx, daemon.Options{ + LockPath: lockPath, + Lock: ownedLock, + IdleTimeout: idle, + OnReady: onReady, + ActivityCh: rm.ActivitySignal(), + ActiveFunc: rm.HasActive, + UndrainedFunc: spool.HasPending, }) - cancelInFlightRuns(rm, shutdownCancelTimeout) + admission.Lock() + rm.StopAccepting() + admission.Unlock() + if cancelErr := cancelInFlightRuns(rm, shutdownCancelTimeout); cancelErr != nil { + runErr = errors.Join(runErr, cancelErr) + } + cancelRun() cancelServe() <-serveErr _ = srv.Close() done <- runErr }() + go replayPendingSubmissions(runCtx, rm, reviewStore, spool, admission) + return rm, done } +func replayPendingSubmissions(ctx context.Context, rm *daemon.RunManager, reviewDecisions *daemon.ReviewDecisions, spool *daemon.GateSpool, admission ...*sync.Mutex) { + handler := gateNotifyPushHandler(rm, reviewDecisions, spool, admission...) + for { + pending := spool.Pending() + if len(pending) == 0 { + return + } + for _, submission := range pending { + params, err := json.Marshal(gateNotifyPushParams{ + GatePath: submission.Gate, + Ref: submission.Ref, + NewSHA: submission.SHA, + RunID: submission.RunID, + OutputSHA: submission.OutputSHA, + Replay: true, + }) + if err != nil { + return + } + if _, err := handler(ctx, params); err != nil { + continue + } + } + if !spool.HasPending() { + return + } + timer := time.NewTimer(5 * time.Second) + select { + case <-ctx.Done(): + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + return + case <-timer.C: + } + } +} + // cancelInFlightRuns runs between daemon.Run returning (SIGTERM, ctx // cancellation, or idle timeout) and the socket server closing, so that // `made daemon stop` never leaves an orphaned pipeline goroutine behind: a // run's WorkFunc is only cooperative with cancellation, not instantly // killable, so this blocks (up to timeout) for it to actually observe // ctx.Done() and return before shutdown proceeds. -func cancelInFlightRuns(rm *daemon.RunManager, timeout time.Duration) { + +func cancelInFlightRuns(rm *daemon.RunManager, timeout time.Duration) error { + var firstErr error for _, snap := range rm.List() { if !isTerminalRunStatus(snap.Status) { - _ = rm.Cancel(snap.ID) + if err := rm.Cancel(snap.ID); err != nil && firstErr == nil { + firstErr = fmt.Errorf("cancel run %q during shutdown: %w", snap.ID, err) + } } } @@ -148,24 +242,33 @@ func cancelInFlightRuns(rm *daemon.RunManager, timeout time.Duration) { } } if allTerminal { - return + return firstErr } time.Sleep(10 * time.Millisecond) } + for _, snap := range rm.List() { + if !isTerminalRunStatus(snap.Status) { + return errors.Join(firstErr, fmt.Errorf("shutdown: timed out waiting for run %q to finish", snap.ID)) + } + } + return firstErr } func isTerminalRunStatus(s daemon.RunStatus) bool { - return s == daemon.RunCompleted || s == daemon.RunFailed + return s == daemon.RunSucceeded || s == daemon.RunFailed || s == daemon.RunCanceled || s == daemon.RunSuperseded } const debugHandlersEnv = "MADE_DEBUG_HANDLERS" -func registerDaemonHandlers(srv *api.Server, rm *daemon.RunManager, store *daemon.ReviewDecisions) { - srv.Handle("status", statusHandler(rm)) - srv.Handle("review.decide", reviewDecideHandler(store)) - srv.Handle("review.decision", reviewDecisionHandler(store)) +func registerDaemonHandlers(srv *api.Server, rm *daemon.RunManager, store *daemon.ReviewDecisions, spool *daemon.GateSpool, cancel context.CancelFunc, admission ...*sync.Mutex) { + srv.Handle("run.status", runStatusHandler(rm)) + srv.Handle("run.submit", runSubmitHandler(rm, store, spool, admission...)) + srv.Handle("run.list", runListHandler(rm)) + srv.Handle("run.cancel", runCancelHandler(rm)) + srv.Handle("review.decide", reviewDecideRunHandler(rm, store)) + srv.Handle("daemon.shutdown", daemonShutdownHandler(rm, spool, cancel, admission...)) srv.Handle("gate.admitPush", gateAdmitPushHandler()) - srv.Handle("gate.notifyPush", gateNotifyPushHandler(rm, store)) + srv.Handle("gate.notifyPush", gateNotifyPushHandler(rm, store, spool, admission...)) if os.Getenv(debugHandlersEnv) == "1" { srv.Handle("debug.submitCancellableRun", debugSubmitCancellableRunHandler(rm)) } @@ -190,7 +293,7 @@ type gateAdmitPushResult struct { func gateAdmitPushHandler() api.HandlerFunc { return func(_ context.Context, params json.RawMessage) (any, error) { var p gateAdmitPushParams - if err := json.Unmarshal(params, &p); err != nil { + if err := decodeStrictParams(params, &p); err != nil { return nil, fmt.Errorf("gate.admitPush: invalid params: %w", err) } if p.GatePath == "" { @@ -234,14 +337,18 @@ func validateBareGateRepo(path string) error { const gateNotifyPushDefaultBranchTimeout = 10 * time.Second type gateNotifyPushParams struct { - GatePath string `json:"gate_path"` - OldSHA string `json:"old_sha"` - NewSHA string `json:"new_sha"` - Ref string `json:"ref"` + GatePath string `json:"gate_path"` + OldSHA string `json:"old_sha"` + NewSHA string `json:"new_sha"` + Ref string `json:"ref"` + RunID string `json:"run_id,omitempty"` + OutputSHA string `json:"output_sha,omitempty"` + Replay bool `json:"replay,omitempty"` } type gateNotifyPushResult struct { - RunID string `json:"run_id,omitempty"` + RunID string `json:"run_id,omitempty"` + Snapshot daemon.RunSnapshot `json:"-"` } // gateNotifyPushHandler is the post-receive-driven counterpart to @@ -251,15 +358,39 @@ type gateNotifyPushResult struct { // still-queued run for the same branch before submitting this push's own // run, so a rapid second push always wins over a first one that hasn't // started yet - never over one already running. -func gateNotifyPushHandler(rm *daemon.RunManager, reviewDecisions *daemon.ReviewDecisions) api.HandlerFunc { +func gateNotifyPushHandler(rm *daemon.RunManager, reviewDecisions *daemon.ReviewDecisions, spool *daemon.GateSpool, admission ...*sync.Mutex) api.HandlerFunc { return func(ctx context.Context, params json.RawMessage) (any, error) { var p gateNotifyPushParams - if err := json.Unmarshal(params, &p); err != nil { + if err := decodeStrictParams(params, &p); err != nil { return nil, fmt.Errorf("gate.notifyPush: invalid params: %w", err) } if p.GatePath == "" || p.Ref == "" || p.NewSHA == "" { return nil, fmt.Errorf("gate.notifyPush: gate_path, ref, and new_sha are required") } + if p.OldSHA != "" && !validSHA(p.OldSHA) { + return nil, fmt.Errorf("gate.notifyPush: old_sha must be a valid 40-character SHA") + } + if p.OutputSHA != "" && !validSHA(p.OutputSHA) { + return nil, fmt.Errorf("gate.notifyPush: output_sha must be a valid 40-character SHA") + } + if err := validateGateSubmission(ctx, spool.Path(), p.GatePath, p.Ref, p.NewSHA); err != nil { + return nil, fmt.Errorf("gate.notifyPush: %w", err) + } + + var submission daemon.GateSubmission + var created bool + if p.NewSHA != gitZeroSHAValue { + if p.RunID == "" { + p.RunID = rm.NewRunID() + } + var err error + submission, created, err = spool.Enqueue(daemon.GateSubmission{ + Gate: p.GatePath, Ref: p.Ref, SHA: p.NewSHA, RunID: p.RunID, OutputSHA: p.OutputSHA, + }) + if err != nil { + return nil, fmt.Errorf("gate.notifyPush: enqueue submission: %w", err) + } + } branchCtx, cancel := context.WithTimeout(ctx, gateNotifyPushDefaultBranchTimeout) defer cancel() @@ -267,35 +398,150 @@ func gateNotifyPushHandler(rm *daemon.RunManager, reviewDecisions *daemon.Review if err != nil { return nil, fmt.Errorf("gate.notifyPush: resolve default branch: %w", err) } + refspec := fmt.Sprintf("%s:refs/heads/%s", defaultBranch, defaultBranch) + if err := runGit(branchCtx, p.GatePath, "fetch", "origin", refspec); err != nil { + return nil, fmt.Errorf("gate.notifyPush: refresh default branch %s: %w", defaultBranch, err) + } decision := gitgate.ClassifyRef(p.Ref, defaultBranch, p.OldSHA, p.NewSHA) if !decision.Accept { + if submission.RunID != "" { + if err := spool.Drain(submission); err != nil { + return nil, fmt.Errorf("gate.notifyPush: drain rejected replay: %w", err) + } + } + if p.Replay { + return gateNotifyPushResult{}, nil + } return nil, fmt.Errorf("gate.notifyPush: %s", decision.Message) } if !decision.CreateRun { return gateNotifyPushResult{}, nil } + unlock := lockAdmission(admission) + defer unlock() + if !rm.Accepting() { + return nil, daemon.ErrRunSubmissionClosed + } + branch := strings.TrimPrefix(p.Ref, "refs/heads/") repo := gateRepoIdentifier(p.GatePath) - rm.SupersedeQueued(repo, branch) gatePath := p.GatePath worktreesDir := gitgate.WorktreesDir(gatePath) newSHA := p.NewSHA - runID := rm.NewRunID() + runID := submission.RunID + if !created { + if existingSnapshot, ok := rm.Snapshot(submission.RunID); ok { + if err := rm.AppendSubmissionEvent(submission.RunID, daemon.SubmissionEvent{Gate: p.GatePath, Ref: p.Ref, InputSHA: p.NewSHA, Kind: "push"}); err != nil { + return nil, fmt.Errorf("gate.notifyPush: persist replayed submission event: %w", err) + } + if err := spool.Drain(submission); err != nil { + return nil, fmt.Errorf("gate.notifyPush: drain replayed submission: %w", err) + } + return gateNotifyPushResult{RunID: submission.RunID, Snapshot: existingSnapshot}, nil + } + runID = submission.RunID + } + if err := rm.SupersedeQueued(repo, branch); err != nil { + return nil, fmt.Errorf("gate.notifyPush: supersede queued runs: %w", err) + } work := func(workCtx context.Context, emit func(daemon.Event)) error { return orchestrator.Run(workCtx, gatePath, defaultBranch, worktreesDir, runID, newSHA, orchestrator.NewWorkFunc(rm, reviewDecisions, emit, runID, defaultBranch, branch, orchestrator.Options{})) } - if _, err := rm.Submit(runID, repo, branch, work); err != nil { + snapshot, err := rm.SubmitWithMetadata(runID, repo, branch, p.NewSHA, p.OutputSHA, work) + if err != nil { return nil, fmt.Errorf("gate.notifyPush: submit run: %w", err) } + if err := rm.AppendSubmissionEvent(runID, daemon.SubmissionEvent{Gate: p.GatePath, Ref: p.Ref, InputSHA: p.NewSHA, Kind: "push"}); err != nil { + return nil, fmt.Errorf("gate.notifyPush: persist submission event: %w", err) + } + if err := spool.Drain(submission); err != nil { + return nil, fmt.Errorf("gate.notifyPush: drain submission: %w", err) + } + + return gateNotifyPushResult{RunID: runID, Snapshot: snapshot}, nil + } +} + +func validateGateSubmission(ctx context.Context, spoolPath, gatePath, ref, newSHA string) error { + if !validSHA(newSHA) { + return fmt.Errorf("new_sha must be a valid 40-character SHA") + } + if !strings.HasPrefix(ref, "refs/heads/") { + return fmt.Errorf("ref %q is not a branch ref", ref) + } + home, err := filepath.Abs(filepath.Dir(spoolPath)) + if err != nil { + return fmt.Errorf("resolve Made home: %w", err) + } + absGate, err := filepath.Abs(gatePath) + if err != nil { + return fmt.Errorf("resolve gate path: %w", err) + } + rel, err := filepath.Rel(filepath.Join(home, "gates"), absGate) + if err != nil { + return fmt.Errorf("inspect gate layout: %w", err) + } + parts := strings.Split(filepath.Clean(rel), string(filepath.Separator)) + if len(parts) != 2 || parts[1] != "gate.git" || len(parts[0]) != 64 || !hexString(parts[0]) { + return fmt.Errorf("gate path %q is outside the Made-owned gate layout", gatePath) + } + info, err := os.Lstat(absGate) + if err != nil { + return fmt.Errorf("inspect gate path: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return fmt.Errorf("gate path %q is not an owned directory", gatePath) + } + realHome, err := filepath.EvalSymlinks(home) + if err != nil { + return fmt.Errorf("resolve Made home: %w", err) + } + realGate, err := filepath.EvalSymlinks(absGate) + if err != nil { + return fmt.Errorf("resolve gate path: %w", err) + } + realRel, err := filepath.Rel(filepath.Join(realHome, "gates"), realGate) + if err != nil { + return fmt.Errorf("inspect resolved gate layout: %w", err) + } + if filepath.Clean(realRel) != filepath.Clean(rel) { + return fmt.Errorf("gate path %q resolves outside its Made-owned layout", gatePath) + } + if err := validateBareGateRepo(absGate); err != nil { + return err + } + if newSHA == gitZeroSHAValue { + return nil + } + res, err := exec.Run(ctx, exec.Command{ + Name: "git", + Args: []string{"-C", absGate, "rev-parse", "--verify", ref + "^{commit}"}, + }) + if err != nil { + return fmt.Errorf("inspect pushed head: %w", err) + } + if res.ExitCode != 0 { + return fmt.Errorf("inspect pushed head failed: %s", strings.TrimSpace(string(res.Stderr))) + } + if strings.TrimSpace(string(res.Stdout)) != newSHA { + return fmt.Errorf("input SHA %s does not match gate head %s", newSHA, strings.TrimSpace(string(res.Stdout))) + } + return nil +} - return gateNotifyPushResult{RunID: runID}, nil +func hexString(value string) bool { + for _, r := range value { + if (r < '0' || r > '9') && (r < 'a' || r > 'f') { + return false + } } + return true } type debugSubmitCancellableRunParams struct { @@ -312,7 +558,7 @@ type debugSubmitCancellableRunParams struct { func debugSubmitCancellableRunHandler(rm *daemon.RunManager) api.HandlerFunc { return func(_ context.Context, params json.RawMessage) (any, error) { var p debugSubmitCancellableRunParams - if err := json.Unmarshal(params, &p); err != nil { + if err := decodeStrictParams(params, &p); err != nil { return nil, fmt.Errorf("decode params: %w", err) } work := func(workCtx context.Context, _ func(daemon.Event)) error { @@ -326,13 +572,29 @@ func debugSubmitCancellableRunHandler(rm *daemon.RunManager) api.HandlerFunc { } } -func daemonStop(lockPath string, stdout, stderr *os.File) int { - if err := daemon.Stop(lockPath, 10*time.Second); err != nil { +func daemonStop(socketPath string, stdout, stderr *os.File) int { + client, err := api.Dial(socketPath) + if err != nil { + _, _ = fmt.Fprintln(stderr, "made daemon: dial shutdown socket:", err) + return 1 + } + defer func() { _ = client.Close() }() + if _, err := client.Call("daemon.shutdown", nil); err != nil { _, _ = fmt.Fprintln(stderr, "made daemon:", err) return 1 } - _, _ = fmt.Fprintln(stdout, "made daemon: stopped") - return 0 + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + probe, probeErr := api.Dial(socketPath) + if probeErr != nil { + _, _ = fmt.Fprintln(stdout, "made daemon: stopped") + return 0 + } + _ = probe.Close() + time.Sleep(20 * time.Millisecond) + } + _, _ = fmt.Fprintln(stderr, "made daemon: shutdown timed out") + return 1 } func daemonStatus(lockPath string, stdout, stderr *os.File) int { @@ -358,8 +620,33 @@ func madeHome() (string, error) { } dir = filepath.Join(home, ".made") } - if err := os.MkdirAll(dir, 0o700); err != nil { - return "", fmt.Errorf("create made home %s: %w", dir, err) + return ensureMadeHome(dir) +} + +func ensureMadeHome(dir string) (string, error) { + abs, err := filepath.Abs(dir) + if err != nil { + return "", fmt.Errorf("resolve made home %s: %w", dir, err) + } + if err := os.MkdirAll(abs, 0o700); err != nil { + return "", fmt.Errorf("create made home %s: %w", abs, err) + } + fd, err := unix.Open(abs, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if err != nil { + return "", fmt.Errorf("open made home %s: %w", abs, err) + } + defer func() { _ = unix.Close(fd) }() + var stat unix.Stat_t + if err := unix.Fstat(fd, &stat); err != nil { + return "", fmt.Errorf("inspect made home %s: %w", abs, err) + } + if uint32(stat.Uid) != uint32(os.Getuid()) { + return "", fmt.Errorf("made home %s is not owned by the current user", abs) + } + if stat.Mode&0o077 != 0 { + if err := unix.Fchmod(fd, 0o700); err != nil { + return "", fmt.Errorf("restrict made home %s: %w", abs, err) + } } - return dir, nil + return abs, nil } diff --git a/cmd/made/daemon_test.go b/cmd/made/daemon_test.go index 41b08ac..75d5056 100644 --- a/cmd/made/daemon_test.go +++ b/cmd/made/daemon_test.go @@ -95,7 +95,7 @@ func TestDaemonStop_CancelsInFlightRunBeforeProcessExits(t *testing.T) { deadline := time.Now().Add(10 * time.Second) for { var report StatusReport - if err := client.CallInto("status", statusParams{RunID: runID}, &report); err != nil { + if err := client.CallInto("run.status", statusParams{RunID: runID}, &report); err != nil { t.Fatalf("status: %v", err) } if report.State == "running" { @@ -109,8 +109,16 @@ func TestDaemonStop_CancelsInFlightRunBeforeProcessExits(t *testing.T) { stopCmd := exec.Command(binPath, "daemon", "stop") stopCmd.Env = env + if out, err := stopCmd.CombinedOutput(); err == nil || !strings.Contains(string(out), "active or awaiting") { + t.Fatalf("daemon stop should refuse active work: %v\n%s", err, out) + } + if err := client.CallInto("run.cancel", map[string]string{"run_id": runID}, nil); err != nil { + t.Fatalf("cancel exact run: %v", err) + } + stopCmd = exec.Command(binPath, "daemon", "stop") + stopCmd.Env = env if out, err := stopCmd.CombinedOutput(); err != nil { - t.Fatalf("daemon stop failed: %v\n%s", err, out) + t.Fatalf("daemon stop after cancellation failed: %v\n%s", err, out) } waitErr := make(chan error, 1) diff --git a/cmd/made/doctor.go b/cmd/made/doctor.go index 2bf7100..16922f5 100644 --- a/cmd/made/doctor.go +++ b/cmd/made/doctor.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "flag" "fmt" "os" @@ -19,6 +20,7 @@ const doctorCheckTimeout = 5 * time.Second func runDoctorCommand(args []string, stdout, stderr *os.File) int { fs := flag.NewFlagSet("made doctor", flag.ContinueOnError) fs.SetOutput(stderr) + asJSON := fs.Bool("json", false, "output structured JSON") if err := fs.Parse(args); err != nil { return 2 } @@ -30,6 +32,9 @@ func runDoctorCommand(args []string, stdout, stderr *os.File) int { if fs.NArg() == 1 { targetPath = fs.Arg(0) } + if *asJSON { + return runDoctorJSON(targetPath, stdout, stderr) + } home, err := madeHome() if err != nil { @@ -72,6 +77,53 @@ func runDoctorCommand(args []string, stdout, stderr *os.File) int { return 0 } +type doctorReport struct { + SchemaVersion int `json:"schema_version"` + ProtocolVersion int `json:"protocol_version"` + Healthy bool `json:"healthy"` + Checks map[string]string `json:"checks"` +} + +func runDoctorJSON(targetPath string, stdout, stderr *os.File) int { + home, err := madeHome() + if err != nil { + _, _ = fmt.Fprintln(stderr, "made doctor:", err) + return 1 + } + ctx, cancel := context.WithTimeout(context.Background(), doctorCheckTimeout) + defer cancel() + checks := make(map[string]string) + healthy := true + if err := checkDaemon(api.SocketPath(home)); err != nil { + checks["daemon"] = "unreachable" + healthy = false + } else { + checks["daemon"] = "reachable" + } + ghClient := &github.Client{Timeout: doctorCheckTimeout} + if err := ghClient.AuthStatus(ctx); err != nil { + checks["github"] = "unavailable" + healthy = false + } else { + checks["github"] = "authenticated" + } + checks["herdr"] = herdrclient.Connect(ctx).State.String() + if gatePath, gateErr := resolveGatePath(home, targetPath); gateErr == nil && gateInitialized(gatePath) { + checks["gate"] = "initialized" + } else { + checks["gate"] = "not_initialized" + } + encoder := json.NewEncoder(stdout) + if err := encoder.Encode(doctorReport{SchemaVersion: 1, ProtocolVersion: api.Version, Healthy: healthy, Checks: checks}); err != nil { + _, _ = fmt.Fprintln(stderr, "made doctor:", err) + return 1 + } + if !healthy { + return 1 + } + return 0 +} + // resolveGatePath mirrors gateInit's target-path resolution (gate.go) so // doctor's gate check resolves to the exact same bare-repo path a prior // `made gate init` for the same directory would have created. diff --git a/cmd/made/gate.go b/cmd/made/gate.go index 1786d11..8900eef 100644 --- a/cmd/made/gate.go +++ b/cmd/made/gate.go @@ -10,12 +10,15 @@ import ( "time" "github.com/douglasjarquin/made/internal/api" + "github.com/douglasjarquin/made/internal/daemon" "github.com/douglasjarquin/made/internal/exec" "github.com/douglasjarquin/made/internal/gitgate" ) const gateCommandTimeout = 30 * time.Second +const gitZeroSHAValue = "0000000000000000000000000000000000000000" + func runGateCommand(args []string, stdout, stderr *os.File) int { if len(args) < 1 { _, _ = fmt.Fprintln(stderr, "usage: made gate init ") @@ -68,7 +71,14 @@ func runGateNotifyPushCommand(args []string, stdout, stderr *os.File) int { client, err := api.Dial(api.SocketPath(home)) if err != nil { - _, _ = fmt.Fprintln(stderr, "gate notify-push: dial daemon:", err) + queueErr := queueOfflineGateSubmission(home, *gatePath, *ref, *newSHA) + if queueErr != nil { + _, _ = fmt.Fprintln(stderr, "gate notify-push: dial daemon:", err, "; durable queue:", queueErr) + } else if *newSHA == gitZeroSHAValue { + _, _ = fmt.Fprintln(stderr, "gate notify-push: ref deletion does not require a run:", err) + } else { + _, _ = fmt.Fprintln(stderr, "gate notify-push: daemon unavailable; submission durably queued:", err) + } return 0 } defer func() { _ = client.Close() }() @@ -80,7 +90,16 @@ func runGateNotifyPushCommand(args []string, stdout, stderr *os.File) int { NewSHA: *newSHA, Ref: *ref, }, &result); err != nil { - _, _ = fmt.Fprintln(stderr, "gate notify-push:", err) + if *newSHA == gitZeroSHAValue { + _, _ = fmt.Fprintln(stderr, "gate notify-push:", err, "; ref deletion does not require a run") + return 0 + } + queueErr := queueOfflineGateSubmission(home, *gatePath, *ref, *newSHA) + if queueErr != nil { + _, _ = fmt.Fprintln(stderr, "gate notify-push:", err, "; durable queue:", queueErr) + } else { + _, _ = fmt.Fprintln(stderr, "gate notify-push:", err, "; submission durably queued") + } return 0 } @@ -92,6 +111,18 @@ func runGateNotifyPushCommand(args []string, stdout, stderr *os.File) int { return 0 } +func queueOfflineGateSubmission(home, gatePath, ref, newSHA string) error { + if newSHA == gitZeroSHAValue { + return nil + } + spool, err := daemon.OpenGateSpool(filepath.Join(home, "gate.spool")) + if err != nil { + return err + } + _, _, err = spool.Enqueue(daemon.GateSubmission{Gate: gatePath, Ref: ref, SHA: newSHA, RunID: daemon.NewRunID()}) + return err +} + func runGateAdmitPushCommand(args []string, stdout, stderr *os.File) int { fs := flag.NewFlagSet("made gate admit-push", flag.ContinueOnError) fs.SetOutput(stderr) diff --git a/cmd/made/gate_notify_push_test.go b/cmd/made/gate_notify_push_test.go index f7bd5d9..69edbd4 100644 --- a/cmd/made/gate_notify_push_test.go +++ b/cmd/made/gate_notify_push_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "errors" "os" "path/filepath" @@ -62,7 +63,7 @@ func waitForRunTerminal(t *testing.T, rm *daemon.RunManager, id string, timeout deadline := time.After(timeout) for { snap, ok := rm.Snapshot(id) - if ok && (snap.Status == daemon.RunCompleted || snap.Status == daemon.RunFailed) { + if ok && (snap.Status == daemon.RunSucceeded || snap.Status == daemon.RunFailed) { return snap } select { @@ -143,6 +144,63 @@ func TestGateNotifyPushRPC_RejectedRefCreatesNoRun(t *testing.T) { } } +func TestGateNotifyPushRPC_RejectsInputSHAThatIsNotGateHead(t *testing.T) { + home := shortTempDir(t) + rm, client := startTestDaemon(t, home) + barePath, sourceDir := setupGateFixture(t, home) + testGit(t, sourceDir, "checkout", "-b", "feature-head") + sha := pushFeatureCommit(t, sourceDir, "feature-head", "v1\n", "feature head") + wrongSHA := strings.Repeat("b", 40) + if wrongSHA == sha { + t.Fatal("test setup bug: wrong SHA unexpectedly equals pushed SHA") + } + + _, err := client.Call("gate.notifyPush", gateNotifyPushParams{ + GatePath: barePath, + OldSHA: gitZeroSHA, + NewSHA: wrongSHA, + Ref: "refs/heads/feature-head", + }) + if err == nil || !strings.Contains(err.Error(), "does not match gate head") { + t.Fatalf("gate.notifyPush accepted an input SHA that is not the gate head: %v", err) + } + if len(rm.List()) != 0 { + t.Fatalf("unauthorized input SHA created a run: %+v", rm.List()) + } +} + +func TestGateNotifyPushRPC_RetainsSubmissionWhenRemoteRefreshFails(t *testing.T) { + home := shortTempDir(t) + rm, _ := startTestDaemon(t, home) + barePath, sourceDir := setupGateFixture(t, home) + testGit(t, sourceDir, "checkout", "-b", "feature-offline") + sha := pushFeatureCommit(t, sourceDir, "feature-offline", "v1\n", "feature offline") + missingRemote := filepath.Join(home, "missing-remote.git") + testGit(t, barePath, "remote", "set-url", "origin", missingRemote) + spool, err := daemon.OpenGateSpool(filepath.Join(home, "gate.spool")) + if err != nil { + t.Fatalf("OpenGateSpool: %v", err) + } + handler := gateNotifyPushHandler(rm, daemon.NewReviewDecisions(), spool) + params, marshalErr := json.Marshal(gateNotifyPushParams{ + GatePath: barePath, + OldSHA: gitZeroSHA, + NewSHA: sha, + Ref: "refs/heads/feature-offline", + }) + if marshalErr != nil { + t.Fatalf("marshal gate.notifyPush params: %v", marshalErr) + } + _, err = handler(context.Background(), params) + if err == nil { + t.Fatal("gate.notifyPush succeeded despite an unavailable remote") + } + pending := spool.Pending() + if len(pending) != 1 || pending[0].Gate != barePath || pending[0].SHA != sha || pending[0].RunID == "" { + t.Fatalf("accepted submission was not retained in the durable spool: %+v", pending) + } +} + func TestGateNotifyPushRPC_RefDeletionCreatesNoRun(t *testing.T) { home := shortTempDir(t) rm, client := startTestDaemon(t, home) @@ -173,10 +231,6 @@ func TestGateNotifyPushRPC_SupersededPushValidatesNewestSHA(t *testing.T) { testGit(t, sourceDir, "checkout", "-b", "feature-x") sha1 := pushFeatureCommit(t, sourceDir, "feature-x", "v1\n", "feature commit 1") - sha2 := pushFeatureCommit(t, sourceDir, "feature-x", "v2\n", "feature commit 2") - if sha1 == sha2 { - t.Fatal("test setup bug: expected two distinct commits") - } repo := gateRepoIdentifier(barePath) @@ -209,6 +263,11 @@ func TestGateNotifyPushRPC_SupersededPushValidatesNewestSHA(t *testing.T) { t.Fatalf("expected first run still queued behind the blocker, got %+v (ok=%v)", snap, ok) } + sha2 := pushFeatureCommit(t, sourceDir, "feature-x", "v2\n", "feature commit 2") + if sha1 == sha2 { + t.Fatal("test setup bug: expected two distinct commits") + } + var result2 gateNotifyPushResult if err := client.CallInto("gate.notifyPush", gateNotifyPushParams{ GatePath: barePath, @@ -260,8 +319,8 @@ waitLoop: if !ok { t.Fatal("expected the first (superseded) run to remain tracked") } - if final1.Status != daemon.RunFailed || !errors.Is(final1.Err, daemon.ErrRunSuperseded) { - t.Fatalf("expected first run superseded (Failed/ErrRunSuperseded), got status=%v err=%v", final1.Status, final1.Err) + if final1.Status != daemon.RunSuperseded || !errors.Is(final1.Err, daemon.ErrRunSuperseded) { + t.Fatalf("expected first run superseded, got status=%v err=%v", final1.Status, final1.Err) } if !final1.StartedAt.IsZero() { t.Fatal("superseded run must never have started") @@ -309,6 +368,42 @@ func TestGateNotifyPushCLI_AlwaysExitsZeroEvenWhenDaemonUnreachable(t *testing.T } } +func TestGateNotifyPushCLI_DurablyQueuesWhenDaemonUnreachable(t *testing.T) { + home := shortTempDir(t) + t.Setenv("MADE_HOME", home) + gatePath := filepath.Join(home, "gates", "repo", "gate.git") + args := []string{ + "gate", "notify-push", + "--gate", gatePath, + "--old", gitZeroSHA, + "--new", strings.Repeat("c", 40), + "--ref", "refs/heads/feature-x", + } + if _, _, code := runCapture(t, args); code != 0 { + t.Fatalf("offline gate notify-push exit code = %d, want 0", code) + } + spool, err := daemon.OpenGateSpool(filepath.Join(home, "gate.spool")) + if err != nil { + t.Fatalf("OpenGateSpool: %v", err) + } + pending := spool.Pending() + if len(pending) != 1 || pending[0].Gate != gatePath || pending[0].Ref != "refs/heads/feature-x" || pending[0].SHA != strings.Repeat("c", 40) || pending[0].RunID == "" { + t.Fatalf("offline submission spool = %+v, want one complete durable identity", pending) + } + firstRunID := pending[0].RunID + if _, _, code := runCapture(t, args); code != 0 { + t.Fatalf("duplicate offline gate notify-push exit code = %d, want 0", code) + } + reopened, err := daemon.OpenGateSpool(filepath.Join(home, "gate.spool")) + if err != nil { + t.Fatalf("reopen GateSpool: %v", err) + } + pending = reopened.Pending() + if len(pending) != 1 || pending[0].RunID != firstRunID { + t.Fatalf("duplicate offline submission spool = %+v, want original idempotent record", pending) + } +} + func TestGateNotifyPushCLI_NormalPushExitsZero(t *testing.T) { home := shortTempDir(t) t.Setenv("MADE_HOME", home) diff --git a/cmd/made/main.go b/cmd/made/main.go index fe29fde..0f12324 100644 --- a/cmd/made/main.go +++ b/cmd/made/main.go @@ -16,14 +16,18 @@ func run(args []string, stdout, stderr *os.File) int { } switch args[0] { + case "capabilities": + return runCapabilitiesCommand(args[1:], stdout, stderr) + case "run": + return runRunCommand(args[1:], stdout, stderr) case "daemon": return runDaemonCommand(args[1:], stdout, stderr) - case "status": - return runStatusCommand(args[1:], stdout, stderr) case "review": - return runReviewCommand(args[1:], os.Stdin, stdout, stderr) - case "pr": - return runPRCommand(args[1:], stdout, stderr) + if len(args) > 1 && args[1] == "decide" { + return runReviewDecideCommand(args[2:], stdout, stderr) + } + _, _ = fmt.Fprintln(stderr, "made review: use the versioned decide subcommand") + return 2 case "doctor": return runDoctorCommand(args[1:], stdout, stderr) case "gate": diff --git a/cmd/made/pr.go b/cmd/made/pr.go deleted file mode 100644 index dfb5c8b..0000000 --- a/cmd/made/pr.go +++ /dev/null @@ -1,48 +0,0 @@ -package main - -import ( - "context" - "flag" - "fmt" - "os" - - "github.com/douglasjarquin/made/internal/github" - "github.com/douglasjarquin/made/internal/pipeline/pr" -) - -// runPRCommand is a stopgap: it wires internal/github.Client and -// internal/pipeline/pr.Run directly, in-process, because no orchestrator -// exists yet (Task 9's run manager is not tied to a real pipeline run) to -// submit a PR-stage run through the daemon. Once that orchestration lands, -// `made pr` should submit a run via the socket API like a real pipeline -// stage instead of calling the stage function from the CLI process. -func runPRCommand(args []string, stdout, stderr *os.File) int { - fs := flag.NewFlagSet("made pr", flag.ContinueOnError) - fs.SetOutput(stderr) - title := fs.String("title", "", "pull request title (required)") - base := fs.String("base", "", "base branch (required)") - head := fs.String("head", "", "head branch (required)") - evidenceRef := fs.String("evidence", "", "evidence reference embedded in the PR body (required)") - dir := fs.String("dir", "", "git repository directory (default: current directory)") - if err := fs.Parse(args); err != nil { - return 2 - } - - ghClient := &github.Client{Dir: *dir} - result, err := pr.Run(context.Background(), ghClient, pr.Options{ - Title: *title, - Base: *base, - Head: *head, - EvidenceRef: *evidenceRef, - }) - if err != nil { - _, _ = fmt.Fprintln(stderr, "made pr:", err) - return 1 - } - if !result.OK { - _, _ = fmt.Fprintln(stderr, "made pr:", result.Message) - return 1 - } - _, _ = fmt.Fprintln(stdout, result.Message) - return 0 -} diff --git a/cmd/made/remediation_contract_test.go b/cmd/made/remediation_contract_test.go new file mode 100644 index 0000000..f4194f7 --- /dev/null +++ b/cmd/made/remediation_contract_test.go @@ -0,0 +1,330 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/douglasjarquin/made/internal/api" + "github.com/douglasjarquin/made/internal/daemon" +) + +func TestRun_CapabilitiesJSONIsVersionedAndListsStructuredCommands(t *testing.T) { + code, stdout, stderr := captureRun(t, "capabilities", "--json") + if code != 0 { + t.Fatalf("capabilities exit=%d stderr=%q", code, stderr) + } + var payload struct { + SchemaVersion int `json:"schema_version"` + ProtocolVersion int `json:"protocol_version"` + Commands []string `json:"commands"` + } + if err := json.Unmarshal([]byte(stdout), &payload); err != nil { + t.Fatalf("decode capabilities JSON: %v; stdout=%q", err, stdout) + } + if payload.SchemaVersion != 1 || payload.ProtocolVersion != 1 { + t.Fatalf("capability versions = schema %d protocol %d, want 1/1", payload.SchemaVersion, payload.ProtocolVersion) + } + for _, want := range []string{"run.submit", "run.status", "run.list", "run.cancel", "review.decide", "doctor"} { + if !containsString(payload.Commands, want) { + t.Fatalf("capabilities missing structured command %q: %v", want, payload.Commands) + } + } +} + +func TestEnsureMadeHome_RepairsGroupAndOtherPermissions(t *testing.T) { + home := filepath.Join(t.TempDir(), "made") + if err := os.Mkdir(home, 0o755); err != nil { + t.Fatalf("create made home: %v", err) + } + if _, err := ensureMadeHome(home); err != nil { + t.Fatalf("ensureMadeHome: %v", err) + } + info, err := os.Stat(home) + if err != nil { + t.Fatalf("stat made home: %v", err) + } + if got := info.Mode().Perm(); got != 0o700 { + t.Fatalf("made home permissions = %o, want 700", got) + } +} + +func TestEnsureMadeHome_RejectsSymlink(t *testing.T) { + root := t.TempDir() + target := filepath.Join(root, "target") + if err := os.Mkdir(target, 0o700); err != nil { + t.Fatalf("create target: %v", err) + } + link := filepath.Join(root, "made") + if err := os.Symlink(target, link); err != nil { + t.Fatalf("create made home symlink: %v", err) + } + if _, err := ensureMadeHome(link); err == nil { + t.Fatal("ensureMadeHome accepted a symlink") + } +} + +func TestRun_SubmitJSONReturnsExactRunIDAndImmutableInputHead(t *testing.T) { + home := shortTempDir(t) + t.Setenv("MADE_HOME", home) + ctx, cancel := context.WithCancel(context.Background()) + ready := make(chan int, 1) + _, done := startDaemon(ctx, home, filepath.Join(home, "daemon.lock"), time.Hour, func(pid int) { ready <- pid }) + t.Cleanup(func() { + cancel() + select { + case err := <-done: + if err != nil { + t.Errorf("daemon cleanup: %v", err) + } + case <-time.After(2 * time.Second): + t.Error("daemon did not stop during cleanup") + } + }) + select { + case <-ready: + case err := <-done: + t.Fatalf("daemon stopped before submit: %v", err) + case <-time.After(2 * time.Second): + t.Fatal("daemon did not become ready") + } + + barePath, sourceDir := setupGateFixture(t, home) + testGit(t, sourceDir, "checkout", "-b", "feature-submit-cli") + inputSHA := pushFeatureCommit(t, sourceDir, "feature-submit-cli", "v1\n", "run submit cli") + code, stdout, stderr := captureRun(t, "run", "submit", "--json", "--gate", barePath, "--ref", "refs/heads/feature-submit-cli", "--old-sha", gitZeroSHA, "--input-sha", inputSHA) + if code != 0 { + t.Fatalf("run submit exit=%d stderr=%q", code, stderr) + } + var payload struct { + RunID string `json:"run_id"` + State string `json:"state"` + InputSHA string `json:"input_sha"` + } + if err := json.Unmarshal([]byte(stdout), &payload); err != nil { + t.Fatalf("decode submit JSON: %v; stdout=%q", err, stdout) + } + if payload.RunID == "" || (payload.State != "queued" && payload.State != "running") || payload.InputSHA != inputSHA { + t.Fatalf("submit payload = %+v, want exact queued run identity", payload) + } +} + +func TestRunSubmit_RejectsInvalidOutputSHA(t *testing.T) { + rm := daemon.NewRunManager() + _, err := runSubmitHandler(rm, nil, nil)(context.Background(), []byte(`{"repo":"/repo","branch":"feature","input_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","output_sha":"not-a-sha"}`)) + if err == nil { + t.Fatal("run.submit accepted an invalid output_sha") + } +} + +func TestRunSubmit_RequiresExecutableGateDescriptor(t *testing.T) { + rm := daemon.NewRunManager() + _, err := runSubmitHandler(rm, nil, nil)(context.Background(), []byte(`{"repo":"/repo","branch":"feature","input_sha":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}`)) + if err == nil || !strings.Contains(err.Error(), "gate_path") { + t.Fatalf("run.submit accepted a submission without executable gate metadata: %v", err) + } +} + +func TestRunSubmit_ExecutesGatePipeline(t *testing.T) { + home := shortTempDir(t) + barePath, sourceDir := setupGateFixture(t, home) + testGit(t, sourceDir, "checkout", "-b", "feature-submit") + inputSHA := pushFeatureCommit(t, sourceDir, "feature-submit", "v1\n", "run submit") + + spool, err := daemon.OpenGateSpool(filepath.Join(home, "gate.spool")) + if err != nil { + t.Fatalf("OpenGateSpool: %v", err) + } + rm := daemon.NewRunManager() + params, err := json.Marshal(runSubmitParams{ + GatePath: barePath, + Ref: "refs/heads/feature-submit", + OldSHA: gitZeroSHA, + InputSHA: inputSHA, + }) + if err != nil { + t.Fatalf("marshal run.submit params: %v", err) + } + result, err := runSubmitHandler(rm, daemon.NewReviewDecisions(), spool)(context.Background(), params) + if err != nil { + t.Fatalf("run.submit: %v", err) + } + report := result.(runActionReport) + if report.RunID == "" || report.InputSHA != inputSHA || (report.State != string(daemon.RunQueued) && report.State != string(daemon.RunRunning)) { + t.Fatalf("run.submit report = %+v, want exact queued immutable identity", report) + } + snapshot := waitForRunTerminal(t, rm, report.RunID, 10*time.Second) + if snapshot.StartedAt.IsZero() || len(snapshot.Stages) == 0 { + t.Fatalf("run.submit did not execute the gate pipeline: %+v", snapshot) + } +} + +func TestStatusHandler_RequiresExactRunID(t *testing.T) { + rm := daemon.NewRunManager() + started := make(chan struct{}) + id := rm.NewRunID() + if _, err := rm.Submit(id, "repo", "branch", func(ctx context.Context, _ func(daemon.Event)) error { + close(started) + <-ctx.Done() + return ctx.Err() + }); err != nil { + t.Fatalf("Submit: %v", err) + } + <-started + t.Cleanup(func() { _ = rm.Cancel(id) }) + + _, err := statusHandler(rm)(context.Background(), nil) + if err == nil { + t.Fatal("status handler resolved a global latest run without an exact run ID") + } +} + +func TestStatusReport_JSONHasFixedDurableRunSchema(t *testing.T) { + raw, err := json.Marshal(newStatusReport(daemon.RunSnapshot{ + ID: "123e4567-e89b-12d3-a456-426614174000", + Repo: "/repo", + Branch: "feature", + Status: daemon.RunStatus("awaiting_merge"), + })) + if err != nil { + t.Fatalf("marshal status report: %v", err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + t.Fatalf("decode status report: %v", err) + } + for _, field := range []string{ + "schema_version", "protocol_version", "run_id", "repo", "branch", "state", + "input_sha", "output_sha", "execution_finished", "findings", "decisions", + "pr_url", "errors", "superseded_by", "cancel_requested", "submission_events", + } { + if _, ok := fields[field]; !ok { + t.Fatalf("status schema missing durable field %q: %s", field, raw) + } + } +} + +func TestReviewDecide_RejectsUnknownExactRunID(t *testing.T) { + store := daemon.NewReviewDecisions() + _, err := reviewDecideRunHandler(daemon.NewRunManager(), store)(context.Background(), []byte(`{"run_id":"missing","stage":"review","decision":"approved"}`)) + if err == nil { + t.Fatal("review.decide accepted a decision for an unknown exact run ID") + } +} + +func TestReviewDecide_ReturnsVersionedStructuredResult(t *testing.T) { + rm := daemon.NewRunManager() + store := daemon.NewReviewDecisions() + id := rm.NewRunID() + if _, err := rm.Submit(id, "repo", "branch", func(context.Context, func(daemon.Event)) error { return nil }); err != nil { + t.Fatalf("Submit: %v", err) + } + result, err := reviewDecideRunHandler(rm, store)(context.Background(), []byte(`{"run_id":"`+id+`","stage":"review","decision":"approved"}`)) + if err != nil { + t.Fatalf("review.decide: %v", err) + } + report, ok := result.(reviewDecisionReport) + if !ok || report.SchemaVersion != 1 || report.ProtocolVersion != api.Version || report.RunID != id || report.Decision != ReviewApproved { + t.Fatalf("review.decide result = %#v, want versioned exact decision", result) + } +} + +func TestRun_ListJSONExposesBatchActiveRunQuery(t *testing.T) { + home := shortTempDir(t) + t.Setenv("MADE_HOME", home) + ctx, cancel := context.WithCancel(context.Background()) + ready := make(chan int, 1) + _, done := startDaemon(ctx, home, filepath.Join(home, "daemon.lock"), time.Hour, func(pid int) { ready <- pid }) + t.Cleanup(func() { + cancel() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Error("daemon did not stop during cleanup") + } + }) + select { + case <-ready: + case err := <-done: + t.Fatalf("daemon stopped before list: %v", err) + case <-time.After(2 * time.Second): + t.Fatal("daemon did not become ready") + } + + code, stdout, stderr := captureRun(t, "run", "list", "--json", "--active") + if code != 0 { + t.Fatalf("run list exit=%d stderr=%q", code, stderr) + } + var payload struct { + SchemaVersion int `json:"schema_version"` + Runs []struct { + RunID string `json:"run_id"` + } `json:"runs"` + } + if err := json.Unmarshal([]byte(stdout), &payload); err != nil { + t.Fatalf("decode run list JSON: %v; stdout=%q", err, stdout) + } + if payload.SchemaVersion != 1 || payload.Runs == nil { + t.Fatalf("run list payload = %+v, want versioned active batch", payload) + } +} + +func TestRunListHandlerRejectsUnknownParams(t *testing.T) { + _, err := runListHandler(daemon.NewRunManager())(context.Background(), []byte(`{"active":true,"unexpected":true}`)) + if err == nil || !strings.Contains(err.Error(), "unknown field") { + t.Fatalf("run.list accepted unknown parameter field: %v", err) + } +} + +func TestDaemonShutdownHandlerRejectsUnknownParams(t *testing.T) { + spool, err := daemon.OpenGateSpool(filepath.Join(t.TempDir(), "gate.spool")) + if err != nil { + t.Fatalf("OpenGateSpool: %v", err) + } + _, err = daemonShutdownHandler(daemon.NewRunManager(), spool, func() {})(context.Background(), []byte(`{"unexpected":true}`)) + if err == nil || !strings.Contains(err.Error(), "unknown field") { + t.Fatalf("daemon.shutdown accepted unknown parameter field: %v", err) + } +} + +func captureRun(t *testing.T, args ...string) (int, string, string) { + t.Helper() + outFile, err := os.CreateTemp(t.TempDir(), "stdout-") + if err != nil { + t.Fatalf("create stdout fixture: %v", err) + } + errFile, err := os.CreateTemp(t.TempDir(), "stderr-") + if err != nil { + t.Fatalf("create stderr fixture: %v", err) + } + code := run(args, outFile, errFile) + if _, err := outFile.Seek(0, io.SeekStart); err != nil { + t.Fatalf("seek stdout fixture: %v", err) + } + stdout, err := io.ReadAll(outFile) + if err != nil { + t.Fatalf("read stdout fixture: %v", err) + } + if _, err := errFile.Seek(0, io.SeekStart); err != nil { + t.Fatalf("seek stderr fixture: %v", err) + } + stderr, err := io.ReadAll(errFile) + if err != nil { + t.Fatalf("read stderr fixture: %v", err) + } + return code, string(stdout), string(stderr) +} + +func containsString(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} diff --git a/cmd/made/remediation_process_contract_test.go b/cmd/made/remediation_process_contract_test.go new file mode 100644 index 0000000..8c688a8 --- /dev/null +++ b/cmd/made/remediation_process_contract_test.go @@ -0,0 +1,273 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/douglasjarquin/made/internal/api" + "github.com/douglasjarquin/made/internal/daemon" +) + +func TestRunStateSurvivesDaemonRestart(t *testing.T) { + t.Setenv(debugHandlersEnv, "1") + home := shortTempDir(t) + t.Setenv("MADE_HOME", home) + firstCtx, firstCancel := context.WithCancel(context.Background()) + firstReady := make(chan int, 1) + _, firstDone := startDaemon(firstCtx, home, filepath.Join(home, "daemon.lock"), time.Hour, func(pid int) { firstReady <- pid }) + select { + case <-firstReady: + case err := <-firstDone: + t.Fatalf("first daemon stopped: %v", err) + case <-time.After(2 * time.Second): + t.Fatal("first daemon did not become ready") + } + client, err := api.Dial(api.SocketPath(home)) + if err != nil { + t.Fatalf("dial first daemon: %v", err) + } + defer func() { _ = client.Close() }() + runID := "123e4567-e89b-12d3-a456-426614174001" + var submitted daemon.RunSnapshot + if err := client.CallInto("debug.submitCancellableRun", map[string]string{ + "id": runID, "repo": "/repo", "branch": "feature", + }, &submitted); err != nil { + t.Fatalf("submit debug run: %v", err) + } + firstCancel() + select { + case <-firstDone: + case <-time.After(2 * time.Second): + t.Fatal("first daemon did not stop") + } + + secondCtx, secondCancel := context.WithCancel(context.Background()) + secondReady := make(chan int, 1) + _, secondDone := startDaemon(secondCtx, home, filepath.Join(home, "daemon.lock"), time.Hour, func(pid int) { secondReady <- pid }) + t.Cleanup(func() { + secondCancel() + select { + case <-secondDone: + case <-time.After(2 * time.Second): + t.Error("second daemon did not stop during cleanup") + } + }) + select { + case <-secondReady: + case err := <-secondDone: + t.Fatalf("second daemon stopped: %v", err) + case <-time.After(2 * time.Second): + t.Fatal("second daemon did not become ready") + } + client2, err := api.Dial(api.SocketPath(home)) + if err != nil { + t.Fatalf("dial second daemon: %v", err) + } + defer func() { _ = client2.Close() }() + var status StatusReport + if err := client2.CallInto("run.status", statusParams{RunID: runID}, &status); err != nil { + t.Fatalf("status after restart: %v", err) + } + if status.RunID != runID { + t.Fatalf("status after restart run ID = %q, want %q", status.RunID, runID) + } +} + +func TestGateSubmissionSpoolReplaysAfterDaemonRestart(t *testing.T) { + home := shortTempDir(t) + barePath, sourceDir := setupGateFixture(t, home) + testGit(t, sourceDir, "checkout", "-b", "feature-replay") + sha := pushFeatureCommit(t, sourceDir, "feature-replay", "replayed\n", "replayed gate submission") + runID := "123e4567-e89b-12d3-a456-426614174004" + + spoolPath := filepath.Join(home, "gate.spool") + spool, err := daemon.OpenGateSpool(spoolPath) + if err != nil { + t.Fatalf("OpenGateSpool: %v", err) + } + if _, created, err := spool.Enqueue(daemon.GateSubmission{ + Gate: barePath, Ref: "refs/heads/feature-replay", SHA: sha, RunID: runID, + }); err != nil || !created { + t.Fatalf("seed pending gate submission: created=%v err=%v", created, err) + } + + ctx, cancel := context.WithCancel(context.Background()) + ready := make(chan int, 1) + rm, done := startDaemon(ctx, home, filepath.Join(home, "daemon.lock"), time.Minute, func(pid int) { ready <- pid }) + t.Cleanup(func() { + cancel() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Error("daemon did not stop after replay test") + } + }) + select { + case <-ready: + case err := <-done: + t.Fatalf("daemon stopped before replay: %v", err) + case <-time.After(2 * time.Second): + t.Fatal("daemon did not become ready for replay") + } + + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + if snapshot, ok := rm.Snapshot(runID); ok && snapshot.Branch == "feature-replay" { + reopened, err := daemon.OpenGateSpool(spoolPath) + if err != nil { + t.Fatalf("reopen gate spool: %v", err) + } + if !reopened.HasPending() { + return + } + } + time.Sleep(10 * time.Millisecond) + } + t.Fatalf("pending gate submission was not replayed, runs=%+v", rm.List()) +} + +func TestStartDaemon_DuplicatePreservesOriginalSocketOwner(t *testing.T) { + home := shortTempDir(t) + firstCtx, firstCancel := context.WithCancel(context.Background()) + firstReady := make(chan int, 1) + _, firstDone := startDaemon(firstCtx, home, filepath.Join(home, "daemon.lock"), time.Hour, func(pid int) { firstReady <- pid }) + select { + case <-firstReady: + case err := <-firstDone: + t.Fatalf("first daemon stopped: %v", err) + case <-time.After(2 * time.Second): + t.Fatal("first daemon did not become ready") + } + firstClient, err := api.Dial(api.SocketPath(home)) + if err != nil { + t.Fatalf("dial first daemon: %v", err) + } + t.Cleanup(func() { + _ = firstClient.Close() + firstCancel() + select { + case <-firstDone: + case <-time.After(2 * time.Second): + t.Error("first daemon did not stop during cleanup") + } + }) + if _, err := firstClient.Call("ping", nil); err != nil { + t.Fatalf("first daemon ping: %v", err) + } + + secondCtx, secondCancel := context.WithCancel(context.Background()) + secondReady := make(chan int, 1) + _, secondDone := startDaemon(secondCtx, home, filepath.Join(home, "daemon.lock"), time.Hour, func(pid int) { secondReady <- pid }) + secondStopped := false + t.Cleanup(func() { + secondCancel() + if secondStopped { + return + } + select { + case <-secondDone: + case <-time.After(2 * time.Second): + t.Error("duplicate daemon did not stop during cleanup") + } + }) + select { + case err := <-secondDone: + secondStopped = true + if !errors.Is(err, daemon.ErrAlreadyRunning) { + t.Fatalf("duplicate daemon error = %v, want ErrAlreadyRunning", err) + } + case <-secondReady: + secondStopped = true + t.Fatal("duplicate daemon reported ready and could damage the original owner") + case <-time.After(2 * time.Second): + t.Fatal("duplicate daemon did not fail promptly") + } + if _, err := firstClient.Call("ping", nil); err != nil { + t.Fatalf("original daemon became unreachable after duplicate start: %v", err) + } +} + +func TestDaemonRejectsObsoleteUnversionedRPCs(t *testing.T) { + home := shortTempDir(t) + ctx, cancel := context.WithCancel(context.Background()) + ready := make(chan int, 1) + _, done := startDaemon(ctx, home, filepath.Join(home, "daemon.lock"), time.Hour, func(pid int) { ready <- pid }) + t.Cleanup(func() { + cancel() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Error("daemon did not stop during cleanup") + } + }) + select { + case <-ready: + case err := <-done: + t.Fatalf("daemon stopped before RPC probe: %v", err) + case <-time.After(2 * time.Second): + t.Fatal("daemon did not become ready") + } + client, err := api.Dial(api.SocketPath(home)) + if err != nil { + t.Fatalf("dial daemon: %v", err) + } + defer func() { _ = client.Close() }() + for _, method := range []string{"status", "review.decision"} { + if _, err := client.Call(method, nil); err == nil { + t.Fatalf("obsolete RPC %q was still registered", method) + } + } +} + +func TestHermeticCompatibility_RealMadeBinaryThroughConsigliereScript(t *testing.T) { + root := repoRoot(t) + consigliereRoot := os.Getenv("MADE_CONSIGLIERE_ROOT") + if consigliereRoot == "" { + t.Skip("MADE_CONSIGLIERE_ROOT is required for the real Consigliere script compatibility test") + } + script := filepath.Join(consigliereRoot, "bin", "cs-made-lib.sh") + if _, err := os.Stat(script); err != nil { + t.Fatalf("real Consigliere script unavailable: %v", err) + } + + binDir := t.TempDir() + madePath := filepath.Join(binDir, "made") + build := exec.Command("go", "build", "-o", madePath, "./cmd/made") + build.Dir = root + build.Env = append(os.Environ(), "GIT_CONFIG_COUNT=1", "GIT_CONFIG_KEY_0=commit.gpgsign", "GIT_CONFIG_VALUE_0=false") + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build Made binary: %v\n%s", err, output) + } + fakeGH := filepath.Join(binDir, "gh") + if err := os.WriteFile(fakeGH, []byte("#!/bin/sh\nset -eu\n[ \"$1\" = auth ] && [ \"$2\" = status ]\nprintf '%s\\n' 'Logged in to github.com account fake'\n"), 0o700); err != nil { + t.Fatalf("write strict fake gh: %v", err) + } + + cmd := exec.Command("bash", "-c", `. "$1"; cs_made doctor --json`, "compat", script) + cmd.Env = append(os.Environ(), + "MADE_HOME="+t.TempDir(), + "PATH="+binDir+":"+os.Getenv("PATH"), + "HERDR_SOCKET_PATH="+filepath.Join(t.TempDir(), "herdr.sock"), + ) + output, err := cmd.CombinedOutput() + if !json.Valid([]byte(strings.TrimSpace(string(output)))) { + t.Fatalf("real Consigliere script did not receive a JSON doctor contract: err=%v output=%q", err, output) + } +} + +func repoRoot(t *testing.T) string { + t.Helper() + _, filename, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + return filepath.Clean(filepath.Join(filepath.Dir(filename), "../..")) +} diff --git a/cmd/made/review.go b/cmd/made/review.go index 726724e..efdd994 100644 --- a/cmd/made/review.go +++ b/cmd/made/review.go @@ -1,14 +1,11 @@ package main import ( - "bufio" "context" "encoding/json" "flag" "fmt" - "io" "os" - "strings" "github.com/douglasjarquin/made/internal/api" "github.com/douglasjarquin/made/internal/daemon" @@ -19,40 +16,24 @@ const ( ReviewRejected = daemon.ReviewRejected ) -// reviewDecisions now lives in internal/daemon (co-located with RunManager, -// since a decision is per-run state) so Task 12's orchestrator can reach the -// same store these RPC handlers use; this alias keeps the RPC-facing code -// below unchanged. -type reviewDecisions = daemon.ReviewDecisions - -func newReviewDecisions() *reviewDecisions { - return daemon.NewReviewDecisions() -} - type reviewDecideParams struct { RunID string `json:"run_id"` Stage string `json:"stage"` Decision string `json:"decision"` } -type reviewDecideResult struct { - OK bool `json:"ok"` -} - -type reviewDecisionParams struct { - RunID string `json:"run_id"` - Stage string `json:"stage"` +type reviewDecisionReport struct { + SchemaVersion int `json:"schema_version"` + ProtocolVersion int `json:"protocol_version"` + RunID string `json:"run_id"` + Stage string `json:"stage"` + Decision string `json:"decision"` } -type reviewDecisionResult struct { - Decision string `json:"decision"` - Found bool `json:"found"` -} - -func reviewDecideHandler(store *reviewDecisions) api.HandlerFunc { - return func(ctx context.Context, params json.RawMessage) (any, error) { +func reviewDecideRunHandler(rm *daemon.RunManager, store *daemon.ReviewDecisions) api.HandlerFunc { + return func(_ context.Context, params json.RawMessage) (any, error) { var p reviewDecideParams - if err := json.Unmarshal(params, &p); err != nil { + if err := decodeStrictParams(params, &p); err != nil { return nil, fmt.Errorf("review.decide: invalid params: %w", err) } if p.RunID == "" || p.Stage == "" { @@ -61,100 +42,48 @@ func reviewDecideHandler(store *reviewDecisions) api.HandlerFunc { if p.Decision != ReviewApproved && p.Decision != ReviewRejected { return nil, fmt.Errorf("review.decide: decision must be %q or %q", ReviewApproved, ReviewRejected) } - store.Set(p.RunID, p.Stage, p.Decision) - return reviewDecideResult{OK: true}, nil - } -} - -func reviewDecisionHandler(store *reviewDecisions) api.HandlerFunc { - return func(ctx context.Context, params json.RawMessage) (any, error) { - var p reviewDecisionParams - if err := json.Unmarshal(params, &p); err != nil { - return nil, fmt.Errorf("review.decision: invalid params: %w", err) + if _, ok := rm.Snapshot(p.RunID); !ok { + return nil, fmt.Errorf("review.decide: exact run_id %q was not found", p.RunID) + } + if err := rm.SetDecision(p.RunID, p.Stage, p.Decision); err != nil { + return nil, err } - decision, found := store.Get(p.RunID, p.Stage) - return reviewDecisionResult{Decision: decision, Found: found}, nil + store.Set(p.RunID, p.Stage, p.Decision) + return reviewDecisionReport{ + SchemaVersion: 1, ProtocolVersion: api.Version, + RunID: p.RunID, Stage: p.Stage, Decision: p.Decision, + }, nil } } -func runReviewCommand(args []string, stdin io.Reader, stdout, stderr *os.File) int { - fs := flag.NewFlagSet("made review", flag.ContinueOnError) +func runReviewDecideCommand(args []string, stdout, stderr *os.File) int { + fs := flag.NewFlagSet("made review decide", flag.ContinueOnError) fs.SetOutput(stderr) - runID := fs.String("run", "", "run ID to review (default: most recent run)") + jsonOutput := fs.Bool("json", false, "output JSON") + stage := fs.String("stage", "", "stage name") + decision := fs.String("decision", "", "approved or rejected") if err := fs.Parse(args); err != nil { return 2 } - + if !*jsonOutput || fs.NArg() != 1 || *stage == "" || (*decision != ReviewApproved && *decision != ReviewRejected) { + _, _ = fmt.Fprintln(stderr, "usage: made review decide --json --stage --decision ") + return 2 + } home, err := madeHome() if err != nil { - _, _ = fmt.Fprintln(stderr, "made review:", err) + _, _ = fmt.Fprintln(stderr, "made review decide:", err) return 1 } - client, err := api.Dial(api.SocketPath(home)) if err != nil { - _, _ = fmt.Fprintln(stderr, "made review: daemon not reachable:", err) + _, _ = fmt.Fprintln(stderr, "made review decide: daemon not reachable:", err) return 1 } defer func() { _ = client.Close() }() - - var report StatusReport - if err := client.CallInto("status", statusParams{RunID: *runID}, &report); err != nil { - _, _ = fmt.Fprintln(stderr, "made review:", err) + var report reviewDecisionReport + if err := client.CallInto("review.decide", reviewDecideParams{RunID: fs.Arg(0), Stage: *stage, Decision: *decision}, &report); err != nil { + _, _ = fmt.Fprintln(stderr, "made review decide:", err) return 1 } - - if len(report.PendingFindings) == 0 { - _, _ = fmt.Fprintln(stdout, "made review: no pending findings") - return 0 - } - - scanner := bufio.NewScanner(stdin) - anyRejected := false - for _, f := range report.PendingFindings { - _, _ = fmt.Fprintf(stdout, "[%s] %s\n", f.Stage, f.Message) - _, _ = fmt.Fprint(stdout, "approve/reject? [a/r]: ") - - decision, err := readDecision(scanner) - if err != nil { - _, _ = fmt.Fprintln(stderr, "made review:", err) - return 1 - } - - if err := client.CallInto("review.decide", reviewDecideParams{ - RunID: report.RunID, - Stage: f.Stage, - Decision: decision, - }, nil); err != nil { - _, _ = fmt.Fprintln(stderr, "made review:", err) - return 1 - } - - _, _ = fmt.Fprintf(stdout, "%s: %s\n", decision, f.Stage) - if decision == ReviewRejected { - anyRejected = true - } - } - - if anyRejected { - _, _ = fmt.Fprintln(stdout, "made review: one or more findings rejected; pipeline halted") - return 1 - } - _, _ = fmt.Fprintln(stdout, "made review: all findings approved; pipeline resumed") - return 0 -} - -func readDecision(scanner *bufio.Scanner) (string, error) { - for scanner.Scan() { - switch strings.TrimSpace(strings.ToLower(scanner.Text())) { - case "a", "approve": - return ReviewApproved, nil - case "r", "reject": - return ReviewRejected, nil - } - } - if err := scanner.Err(); err != nil { - return "", fmt.Errorf("read decision: %w", err) - } - return "", fmt.Errorf("no approve/reject decision provided") + return writeJSON(stdout, report, stderr, "made review decide") } diff --git a/cmd/made/review_test.go b/cmd/made/review_test.go deleted file mode 100644 index 910598c..0000000 --- a/cmd/made/review_test.go +++ /dev/null @@ -1,171 +0,0 @@ -package main - -import ( - "context" - "encoding/json" - "io" - "os" - "strings" - "testing" - - "github.com/douglasjarquin/made/internal/api" -) - -// startReviewTestServer fakes only the "status" handler's PendingFindings -// (real runs never populate that field yet, per status.go) while wiring the -// real review.decide/review.decision handlers, so the round trip under test -// is genuine except for the one field no orchestrator produces yet. -func startReviewTestServer(t *testing.T, fixture StatusReport) string { - t.Helper() - - home := shortTempDir(t) - socketPath := api.SocketPath(home) - - srv := api.NewServer(socketPath) - srv.Handle("status", func(ctx context.Context, params json.RawMessage) (any, error) { - return fixture, nil - }) - store := newReviewDecisions() - srv.Handle("review.decide", reviewDecideHandler(store)) - srv.Handle("review.decision", reviewDecisionHandler(store)) - - if err := srv.Listen(); err != nil { - t.Fatalf("Listen: %v", err) - } - ctx, cancel := context.WithCancel(context.Background()) - done := make(chan error, 1) - go func() { done <- srv.Serve(ctx) }() - t.Cleanup(func() { - cancel() - <-done - _ = srv.Close() - }) - - t.Setenv("MADE_HOME", home) - return home -} - -func queryDecision(t *testing.T, home, runID, stage string) (string, bool) { - t.Helper() - client, err := api.Dial(api.SocketPath(home)) - if err != nil { - t.Fatalf("Dial: %v", err) - } - defer func() { _ = client.Close() }() - - var result reviewDecisionResult - if err := client.CallInto("review.decision", reviewDecisionParams{RunID: runID, Stage: stage}, &result); err != nil { - t.Fatalf("review.decision: %v", err) - } - return result.Decision, result.Found -} - -func runReviewCapture(t *testing.T, args []string, stdin string) (stdout, stderr []byte, code int) { - t.Helper() - - outR, outW, err := os.Pipe() - if err != nil { - t.Fatalf("pipe: %v", err) - } - errR, errW, err := os.Pipe() - if err != nil { - t.Fatalf("pipe: %v", err) - } - - outCh := make(chan []byte, 1) - errCh := make(chan []byte, 1) - go func() { - b, _ := io.ReadAll(outR) - outCh <- b - }() - go func() { - b, _ := io.ReadAll(errR) - errCh <- b - }() - - code = runReviewCommand(args, strings.NewReader(stdin), outW, errW) - _ = outW.Close() - _ = errW.Close() - - return <-outCh, <-errCh, code -} - -func TestReview_ApproveResumes(t *testing.T) { - fixture := StatusReport{ - SchemaVersion: statusSchemaVersion, - RunID: "run-approve-1", - Repo: "example/repo", - Branch: "feature-x", - State: "running", - Stages: []StageResult{{Name: "review", Result: StageResultPending}}, - PendingFindings: []AskUserFinding{ - {Stage: "review", Message: "Should this helper be exported?"}, - }, - } - home := startReviewTestServer(t, fixture) - - out, errOut, code := runReviewCapture(t, []string{"--run", "run-approve-1"}, "a\n") - if code != 0 { - t.Fatalf("exit code = %d, want 0; stdout=%s stderr=%s", code, out, errOut) - } - if !strings.Contains(string(out), "review") || !strings.Contains(string(out), "Should this helper be exported?") { - t.Errorf("stdout missing finding text: %s", out) - } - - decision, found := queryDecision(t, home, "run-approve-1", "review") - if !found { - t.Fatal("decision not recorded") - } - if decision != ReviewApproved { - t.Errorf("decision = %q, want %q", decision, ReviewApproved) - } -} - -func TestReview_RejectHalts(t *testing.T) { - fixture := StatusReport{ - SchemaVersion: statusSchemaVersion, - RunID: "run-reject-1", - Repo: "example/repo", - Branch: "feature-x", - State: "running", - Stages: []StageResult{{Name: "document", Result: StageResultPending}}, - PendingFindings: []AskUserFinding{ - {Stage: "document", Message: "Does this doc change need a changelog entry?"}, - }, - } - home := startReviewTestServer(t, fixture) - - out, errOut, code := runReviewCapture(t, []string{"--run", "run-reject-1"}, "r\n") - if code == 0 { - t.Fatalf("exit code = %d, want non-zero on rejection; stdout=%s stderr=%s", code, out, errOut) - } - - decision, found := queryDecision(t, home, "run-reject-1", "document") - if !found { - t.Fatal("decision not recorded") - } - if decision != ReviewRejected { - t.Errorf("decision = %q, want %q", decision, ReviewRejected) - } -} - -func TestReview_NoPendingFindings(t *testing.T) { - fixture := StatusReport{ - SchemaVersion: statusSchemaVersion, - RunID: "run-clean-1", - Repo: "example/repo", - Branch: "feature-x", - State: "completed", - Stages: []StageResult{{Name: "review", Result: StageResultPass}}, - PendingFindings: []AskUserFinding{}, - } - startReviewTestServer(t, fixture) - - out, errOut, code := runReviewCapture(t, []string{"--run", "run-clean-1"}, "") - if code != 0 { - t.Fatalf("exit code = %d, want 0; stdout=%s stderr=%s", code, out, errOut) - } - if !strings.Contains(string(out), "no pending findings") { - t.Errorf("stdout = %s, want mention of no pending findings", out) - } -} diff --git a/cmd/made/runcommands.go b/cmd/made/runcommands.go new file mode 100644 index 0000000..cfe5d95 --- /dev/null +++ b/cmd/made/runcommands.go @@ -0,0 +1,213 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/douglasjarquin/made/internal/api" +) + +type capabilitiesReport struct { + SchemaVersion int `json:"schema_version"` + ProtocolVersion int `json:"protocol_version"` + Commands []string `json:"commands"` +} + +func runCapabilitiesCommand(args []string, stdout, stderr *os.File) int { + fs := flag.NewFlagSet("made capabilities", flag.ContinueOnError) + fs.SetOutput(stderr) + jsonOutput := fs.Bool("json", false, "output JSON") + if err := fs.Parse(args); err != nil { + return 2 + } + if !*jsonOutput { + _, _ = fmt.Fprintln(stderr, "made capabilities: --json is required") + return 2 + } + return writeJSON(stdout, capabilitiesReport{ + SchemaVersion: 1, ProtocolVersion: api.Version, + Commands: []string{"run.submit", "run.status", "run.list", "run.cancel", "review.decide", "doctor"}, + }, stderr, "made capabilities") +} + +type runSubmitParams struct { + RunID string `json:"run_id,omitempty"` + GatePath string `json:"gate_path"` + Ref string `json:"ref"` + OldSHA string `json:"old_sha,omitempty"` + Repo string `json:"repo,omitempty"` + Branch string `json:"branch,omitempty"` + InputSHA string `json:"input_sha"` + OutputSHA string `json:"output_sha,omitempty"` +} + +type runCancelParams struct { + RunID string `json:"run_id"` +} + +type runListParams struct { + Active bool `json:"active"` +} + +type runListReport struct { + SchemaVersion int `json:"schema_version"` + ProtocolVersion int `json:"protocol_version"` + Runs []StatusReport `json:"runs"` +} + +func runRunCommand(args []string, stdout, stderr *os.File) int { + if len(args) == 0 { + _, _ = fmt.Fprintln(stderr, "usage: made run ") + return 2 + } + switch args[0] { + case "submit": + return runSubmitCommand(args[1:], stdout, stderr) + case "status": + return runExactStatusCommand(args[1:], stdout, stderr) + case "list": + return runListCommand(args[1:], stdout, stderr) + case "cancel": + return runCancelCommand(args[1:], stdout, stderr) + default: + _, _ = fmt.Fprintf(stderr, "made run: unknown subcommand %q\n", args[0]) + return 2 + } +} + +func dialMade(home string, stderr *os.File, label string) (*api.Client, bool) { + client, err := api.Dial(api.SocketPath(home)) + if err != nil { + _, _ = fmt.Fprintln(stderr, label+": daemon not reachable:", err) + return nil, false + } + return client, true +} + +func runSubmitCommand(args []string, stdout, stderr *os.File) int { + fs := flag.NewFlagSet("made run submit", flag.ContinueOnError) + fs.SetOutput(stderr) + jsonOutput := fs.Bool("json", false, "output JSON") + gatePath := fs.String("gate", "", "bare Made gate repository path") + ref := fs.String("ref", "", "immutable branch ref") + oldSHA := fs.String("old-sha", "", "previous branch head SHA") + repo := fs.String("repo", "", "repository identity") + branch := fs.String("branch", "", "input branch") + inputSHA := fs.String("input-sha", "", "immutable input commit SHA") + outputSHA := fs.String("output-sha", "", "expected output commit SHA") + if err := fs.Parse(args); err != nil { + return 2 + } + if !*jsonOutput { + _, _ = fmt.Fprintln(stderr, "made run submit: --json is required") + return 2 + } + home, err := madeHome() + if err != nil { + _, _ = fmt.Fprintln(stderr, "made run submit:", err) + return 1 + } + client, ok := dialMade(home, stderr, "made run submit") + if !ok { + return 1 + } + defer func() { _ = client.Close() }() + var result runActionReport + if err := client.CallInto("run.submit", runSubmitParams{ + GatePath: *gatePath, Ref: *ref, OldSHA: *oldSHA, + Repo: *repo, Branch: *branch, InputSHA: *inputSHA, OutputSHA: *outputSHA, + }, &result); err != nil { + _, _ = fmt.Fprintln(stderr, "made run submit:", err) + return 1 + } + return writeJSON(stdout, result, stderr, "made run submit") +} + +func runExactStatusCommand(args []string, stdout, stderr *os.File) int { + fs := flag.NewFlagSet("made run status", flag.ContinueOnError) + fs.SetOutput(stderr) + jsonOutput := fs.Bool("json", false, "output JSON") + if err := fs.Parse(args); err != nil { + return 2 + } + if !*jsonOutput || fs.NArg() != 1 { + _, _ = fmt.Fprintln(stderr, "usage: made run status --json ") + return 2 + } + home, err := madeHome() + if err != nil { + _, _ = fmt.Fprintln(stderr, "made run status:", err) + return 1 + } + client, ok := dialMade(home, stderr, "made run status") + if !ok { + return 1 + } + defer func() { _ = client.Close() }() + var report StatusReport + if err := client.CallInto("run.status", statusParams{RunID: fs.Arg(0)}, &report); err != nil { + _, _ = fmt.Fprintln(stderr, "made run status:", err) + return 1 + } + return writeJSON(stdout, report, stderr, "made run status") +} + +func runListCommand(args []string, stdout, stderr *os.File) int { + fs := flag.NewFlagSet("made run list", flag.ContinueOnError) + fs.SetOutput(stderr) + jsonOutput := fs.Bool("json", false, "output JSON") + active := fs.Bool("active", false, "only active runs") + if err := fs.Parse(args); err != nil { + return 2 + } + if !*jsonOutput || fs.NArg() != 0 { + _, _ = fmt.Fprintln(stderr, "usage: made run list --json [--active]") + return 2 + } + home, err := madeHome() + if err != nil { + _, _ = fmt.Fprintln(stderr, "made run list:", err) + return 1 + } + client, ok := dialMade(home, stderr, "made run list") + if !ok { + return 1 + } + defer func() { _ = client.Close() }() + var report runListReport + if err := client.CallInto("run.list", runListParams{Active: *active}, &report); err != nil { + _, _ = fmt.Fprintln(stderr, "made run list:", err) + return 1 + } + return writeJSON(stdout, report, stderr, "made run list") +} + +func runCancelCommand(args []string, stdout, stderr *os.File) int { + fs := flag.NewFlagSet("made run cancel", flag.ContinueOnError) + fs.SetOutput(stderr) + jsonOutput := fs.Bool("json", false, "output JSON") + if err := fs.Parse(args); err != nil { + return 2 + } + if !*jsonOutput || fs.NArg() != 1 { + _, _ = fmt.Fprintln(stderr, "usage: made run cancel --json ") + return 2 + } + home, err := madeHome() + if err != nil { + _, _ = fmt.Fprintln(stderr, "made run cancel:", err) + return 1 + } + client, ok := dialMade(home, stderr, "made run cancel") + if !ok { + return 1 + } + defer func() { _ = client.Close() }() + var report runActionReport + if err := client.CallInto("run.cancel", runCancelParams{RunID: fs.Arg(0)}, &report); err != nil { + _, _ = fmt.Fprintln(stderr, "made run cancel:", err) + return 1 + } + return writeJSON(stdout, report, stderr, "made run cancel") +} diff --git a/cmd/made/runhandlers.go b/cmd/made/runhandlers.go new file mode 100644 index 0000000..20d157d --- /dev/null +++ b/cmd/made/runhandlers.go @@ -0,0 +1,203 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + "sync" + "time" + + "github.com/douglasjarquin/made/internal/api" + "github.com/douglasjarquin/made/internal/daemon" +) + +type runActionReport struct { + SchemaVersion int `json:"schema_version"` + ProtocolVersion int `json:"protocol_version"` + RunID string `json:"run_id"` + State string `json:"state"` + InputSHA string `json:"input_sha"` + OutputSHA string `json:"output_sha"` +} + +func runStatusHandler(rm *daemon.RunManager) api.HandlerFunc { + return func(ctx context.Context, params json.RawMessage) (any, error) { + return statusHandler(rm)(ctx, params) + } +} + +func runSubmitHandler(rm *daemon.RunManager, reviewDecisions *daemon.ReviewDecisions, spool *daemon.GateSpool, admission ...*sync.Mutex) api.HandlerFunc { + return func(ctx context.Context, params json.RawMessage) (any, error) { + var p runSubmitParams + if err := decodeStrictParams(params, &p); err != nil { + return nil, fmt.Errorf("run.submit: invalid params: %w", err) + } + if strings.TrimSpace(p.GatePath) == "" || strings.TrimSpace(p.Ref) == "" { + return nil, fmt.Errorf("run.submit: gate_path and ref are required to execute a gate pipeline") + } + branch, ok := strings.CutPrefix(p.Ref, "refs/heads/") + if !ok || branch == "" { + return nil, fmt.Errorf("run.submit: ref must be a non-empty refs/heads branch") + } + if p.Branch != "" && p.Branch != branch { + return nil, fmt.Errorf("run.submit: branch %q does not match ref %q", p.Branch, p.Ref) + } + if p.Repo != "" && p.Repo != gateRepoIdentifier(p.GatePath) { + return nil, fmt.Errorf("run.submit: repo %q does not match the gate identity", p.Repo) + } + if !validSHA(p.InputSHA) || (p.OutputSHA != "" && !validSHA(p.OutputSHA)) || (p.OldSHA != "" && !validSHA(p.OldSHA)) { + return nil, fmt.Errorf("run.submit: input_sha, old_sha, and optional output_sha must use valid 40-character SHAs") + } + if p.OldSHA == "" { + p.OldSHA = gitZeroSHAValue + } + if reviewDecisions == nil || spool == nil { + return nil, fmt.Errorf("run.submit: executable gate dependencies are unavailable") + } + request, err := json.Marshal(gateNotifyPushParams{ + GatePath: p.GatePath, + OldSHA: p.OldSHA, + NewSHA: p.InputSHA, + Ref: p.Ref, + RunID: p.RunID, + OutputSHA: p.OutputSHA, + }) + if err != nil { + return nil, fmt.Errorf("run.submit: encode gate request: %w", err) + } + result, err := gateNotifyPushHandler(rm, reviewDecisions, spool, admission...)(ctx, request) + if err != nil { + return nil, err + } + gateResult, ok := result.(gateNotifyPushResult) + if !ok { + return nil, fmt.Errorf("run.submit: unexpected gate submission response %T", result) + } + if gateResult.RunID == "" { + return nil, fmt.Errorf("run.submit: gate submission did not create a run") + } + snapshot := gateResult.Snapshot + if snapshot.ID == "" { + var ok bool + snapshot, ok = rm.Snapshot(gateResult.RunID) + if !ok { + return nil, fmt.Errorf("run.submit: submitted run %q was not persisted", gateResult.RunID) + } + } + return runActionReport{ + SchemaVersion: 1, ProtocolVersion: api.Version, RunID: snapshot.ID, + State: string(snapshot.Status), InputSHA: snapshot.InputSHA, OutputSHA: snapshot.OutputSHA, + }, nil + } +} + +func runListHandler(rm *daemon.RunManager) api.HandlerFunc { + return func(_ context.Context, params json.RawMessage) (any, error) { + var p runListParams + if len(params) > 0 { + if err := decodeStrictParams(params, &p); err != nil { + return nil, fmt.Errorf("run.list: invalid params: %w", err) + } + } + runs := rm.List() + report := runListReport{SchemaVersion: 1, ProtocolVersion: api.Version, Runs: make([]StatusReport, 0, len(runs))} + for _, snapshot := range runs { + if p.Active && !activeRunStatus(snapshot.Status) { + continue + } + report.Runs = append(report.Runs, newStatusReport(snapshot)) + } + return report, nil + } +} + +func runCancelHandler(rm *daemon.RunManager) api.HandlerFunc { + return func(ctx context.Context, params json.RawMessage) (any, error) { + var p runCancelParams + if err := decodeStrictParams(params, &p); err != nil { + return nil, fmt.Errorf("run.cancel: invalid params: %w", err) + } + if p.RunID == "" { + return nil, fmt.Errorf("run.cancel: run_id is required") + } + if err := rm.Cancel(p.RunID); err != nil { + return nil, err + } + waitCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + for { + snapshot, ok := rm.Snapshot(p.RunID) + if !ok { + return nil, fmt.Errorf("run.cancel: no run %q", p.RunID) + } + if snapshot.Status == daemon.RunCanceled && snapshot.ExecutionFinished { + return runActionReport{SchemaVersion: 1, ProtocolVersion: api.Version, RunID: p.RunID, State: string(snapshot.Status), InputSHA: snapshot.InputSHA, OutputSHA: snapshot.OutputSHA}, nil + } + select { + case <-waitCtx.Done(): + return nil, fmt.Errorf("run.cancel: cancellation of %q did not finish: %w", p.RunID, waitCtx.Err()) + case <-time.After(5 * time.Millisecond): + } + } + } +} + +func daemonShutdownHandler(rm *daemon.RunManager, spool *daemon.GateSpool, cancel context.CancelFunc, admission ...*sync.Mutex) api.HandlerFunc { + return func(_ context.Context, params json.RawMessage) (any, error) { + var noParams struct{} + if err := decodeStrictParams(params, &noParams); err != nil { + return nil, fmt.Errorf("daemon.shutdown: invalid params: %w", err) + } + unlock := lockAdmission(admission) + defer unlock() + if spool.HasPending() { + return nil, fmt.Errorf("daemon.shutdown: active or awaiting runs remain") + } + if err := rm.BeginShutdown(); err != nil { + return nil, fmt.Errorf("daemon.shutdown: active or awaiting runs remain: %w", err) + } + cancel() + return map[string]any{"ok": true, "schema_version": 1, "protocol_version": api.Version}, nil + } +} + +func lockAdmission(admission []*sync.Mutex) func() { + if len(admission) == 0 || admission[0] == nil { + return func() {} + } + admission[0].Lock() + return admission[0].Unlock +} + +func activeRunStatus(status daemon.RunStatus) bool { + switch status { + case daemon.RunQueued, daemon.RunRunning, daemon.RunAwaitingReview, daemon.RunAwaitingMerge: + return true + default: + return false + } +} + +func validSHA(value string) bool { + if len(value) != 40 { + return false + } + for _, r := range value { + if (r < '0' || r > '9') && (r < 'a' || r > 'f') && (r < 'A' || r > 'F') { + return false + } + } + return true +} + +func writeJSON(stdout *os.File, value any, stderr *os.File, label string) int { + encoder := json.NewEncoder(stdout) + encoder.SetIndent("", " ") + if err := encoder.Encode(value); err != nil { + _, _ = fmt.Fprintln(stderr, label+":", err) + return 1 + } + return 0 +} diff --git a/cmd/made/status.go b/cmd/made/status.go index 7c52a07..15ac67a 100644 --- a/cmd/made/status.go +++ b/cmd/made/status.go @@ -3,13 +3,12 @@ package main import ( "context" "encoding/json" - "flag" "fmt" - "os" "time" "github.com/douglasjarquin/made/internal/api" "github.com/douglasjarquin/made/internal/daemon" + "github.com/douglasjarquin/made/internal/evidence" ) const statusSchemaVersion = 1 @@ -27,25 +26,34 @@ var pipelineStages = []string{ "intent", "rebase", "review", "test", "document", "lint", "push", "pr", "ci", } -// StatusReport is the schema for `made status --json`, replacing -// no-mistakes' TOON status output for downstream consumers (Task 26). Stages -// and PendingFindings come straight from daemon.RunSnapshot; until an -// orchestrator (this plan's Task 12) actually calls UpdateStages/ -// UpdatePendingFindings on a run, Stages falls back to an all-"pending" list -// over the fixed 9-stage order and PendingFindings falls back to empty, so -// callers can integrate against the shape before real orchestration lands. +// StatusReport is the schema for `made run status --json`. Stages and +// PendingFindings come straight from daemon.RunSnapshot; until an orchestrator +// calls UpdateStages/UpdatePendingFindings on a run, Stages falls back to an +// all-"pending" list over the fixed 9-stage order and PendingFindings falls +// back to empty. type StatusReport struct { - SchemaVersion int `json:"schema_version"` - RunID string `json:"run_id"` - Repo string `json:"repo"` - Branch string `json:"branch"` - State string `json:"state"` - QueuedAt *time.Time `json:"queued_at,omitempty"` - StartedAt *time.Time `json:"started_at,omitempty"` - EndedAt *time.Time `json:"ended_at,omitempty"` - Error string `json:"error,omitempty"` - Stages []StageResult `json:"stages"` - PendingFindings []AskUserFinding `json:"pending_findings"` + SchemaVersion int `json:"schema_version"` + ProtocolVersion int `json:"protocol_version"` + RunID string `json:"run_id"` + Repo string `json:"repo"` + Branch string `json:"branch"` + State string `json:"state"` + InputSHA string `json:"input_sha"` + OutputSHA string `json:"output_sha"` + ExecutionFinished bool `json:"execution_finished"` + Findings []daemon.RunFinding `json:"findings"` + Decisions map[string]string `json:"decisions"` + PRURL string `json:"pr_url"` + Errors []string `json:"errors"` + SupersededBy string `json:"superseded_by"` + CancelRequested bool `json:"cancel_requested"` + SubmissionEvents []daemon.SubmissionEvent `json:"submission_events"` + QueuedAt *time.Time `json:"queued_at,omitempty"` + StartedAt *time.Time `json:"started_at,omitempty"` + EndedAt *time.Time `json:"ended_at,omitempty"` + Error string `json:"error,omitempty"` + Stages []StageResult `json:"stages"` + PendingFindings []AskUserFinding `json:"pending_findings"` } type StageResult = daemon.StageResult @@ -60,39 +68,22 @@ func statusHandler(rm *daemon.RunManager) api.HandlerFunc { return func(ctx context.Context, params json.RawMessage) (any, error) { var p statusParams if len(params) > 0 { - if err := json.Unmarshal(params, &p); err != nil { + if err := decodeStrictParams(params, &p); err != nil { return nil, fmt.Errorf("status: invalid params: %w", err) } } - snap, ok := resolveRun(rm, p.RunID) + if p.RunID == "" { + return nil, fmt.Errorf("run.status: run_id is required") + } + snap, ok := rm.Snapshot(p.RunID) if !ok { - if p.RunID != "" { - return nil, fmt.Errorf("status: no run %q", p.RunID) - } - return nil, fmt.Errorf("status: no runs found") + return nil, fmt.Errorf("run.status: exact run_id %q was not found", p.RunID) } return newStatusReport(snap), nil } } -func resolveRun(rm *daemon.RunManager, runID string) (daemon.RunSnapshot, bool) { - if runID != "" { - return rm.Snapshot(runID) - } - runs := rm.List() - if len(runs) == 0 { - return daemon.RunSnapshot{}, false - } - latest := runs[0] - for _, r := range runs[1:] { - if r.QueuedAt.After(latest.QueuedAt) { - latest = r - } - } - return latest, true -} - func newStatusReport(snap daemon.RunSnapshot) StatusReport { stages := snap.Stages if len(stages) == 0 { @@ -105,94 +96,106 @@ func newStatusReport(snap daemon.RunSnapshot) StatusReport { pendingFindings := snap.PendingFindings if pendingFindings == nil { pendingFindings = []AskUserFinding{} + } else { + pendingFindings = make([]AskUserFinding, len(snap.PendingFindings)) + for i, finding := range snap.PendingFindings { + finding.Stage = evidence.RedactString(finding.Stage) + finding.Message = evidence.RedactString(finding.Message) + pendingFindings[i] = finding + } } errMsg := "" if snap.Err != nil { - errMsg = snap.Err.Error() + errMsg = evidence.RedactString(snap.Err.Error()) } return StatusReport{ - SchemaVersion: statusSchemaVersion, - RunID: snap.ID, - Repo: snap.Repo, - Branch: snap.Branch, - State: string(snap.Status), - QueuedAt: timePtr(snap.QueuedAt), - StartedAt: timePtr(snap.StartedAt), - EndedAt: timePtr(snap.EndedAt), - Error: errMsg, - Stages: stages, - PendingFindings: pendingFindings, - } -} - -func timePtr(t time.Time) *time.Time { - if t.IsZero() { - return nil + SchemaVersion: statusSchemaVersion, + ProtocolVersion: api.Version, + RunID: snap.ID, + Repo: snap.Repo, + Branch: snap.Branch, + State: string(snap.Status), + InputSHA: snap.InputSHA, + OutputSHA: snap.OutputSHA, + ExecutionFinished: snap.ExecutionFinished, + Findings: redactedFindings(snap.Findings), + Decisions: nonNilDecisions(snap.Decisions), + PRURL: evidence.RedactString(snap.PRURL), + Errors: redactedErrors(snap.Errors, snap.Err), + SupersededBy: snap.SupersededBy, + CancelRequested: snap.CancelRequested, + SubmissionEvents: nonNilSubmissionEvents(snap.SubmissionEvents), + QueuedAt: timePtr(snap.QueuedAt), + StartedAt: timePtr(snap.StartedAt), + EndedAt: timePtr(snap.EndedAt), + Error: errMsg, + Stages: stages, + PendingFindings: pendingFindings, } - return &t } -func runStatusCommand(args []string, stdout, stderr *os.File) int { - fs := flag.NewFlagSet("made status", flag.ContinueOnError) - fs.SetOutput(stderr) - asJSON := fs.Bool("json", false, "output structured JSON matching the StatusReport schema") - if err := fs.Parse(args); err != nil { - return 2 +func redactedFindings(findings []daemon.RunFinding) []daemon.RunFinding { + if findings == nil { + return []daemon.RunFinding{} } - runID := "" - if fs.NArg() > 0 { - runID = fs.Arg(0) + redacted := make([]daemon.RunFinding, len(findings)) + for i, finding := range findings { + redacted[i] = finding + redacted[i].Paths = make([]string, len(finding.Paths)) + for j, path := range finding.Paths { + redacted[i].Paths[j] = evidence.RedactString(path) + } } - - home, err := madeHome() - if err != nil { - _, _ = fmt.Fprintln(stderr, "made status:", err) - return 1 + for i := range redacted { + redacted[i].Message = evidence.RedactString(redacted[i].Message) } + return redacted +} - client, err := api.Dial(api.SocketPath(home)) - if err != nil { - _, _ = fmt.Fprintln(stderr, "made status: daemon not reachable:", err) - return 1 +func nonNilDecisions(decisions map[string]string) map[string]string { + if decisions == nil { + return map[string]string{} } - defer func() { _ = client.Close() }() - - var report StatusReport - if err := client.CallInto("status", statusParams{RunID: runID}, &report); err != nil { - _, _ = fmt.Fprintln(stderr, "made status:", err) - return 1 + redacted := make(map[string]string, len(decisions)) + for key, value := range decisions { + redacted[key] = evidence.RedactString(value) } + return redacted +} - if *asJSON { - enc := json.NewEncoder(stdout) - enc.SetIndent("", " ") - if err := enc.Encode(report); err != nil { - _, _ = fmt.Fprintln(stderr, "made status:", err) - return 1 +func redactedErrors(values []string, runErr error) []string { + if len(values) == 0 { + if runErr == nil { + return []string{} } - return 0 + return []string{evidence.RedactString(runErr.Error())} } + redacted := make([]string, len(values)) + for i, value := range values { + redacted[i] = evidence.RedactString(value) + } + return redacted +} - _, _ = fmt.Fprintf(stdout, "run: %s\n", report.RunID) - _, _ = fmt.Fprintf(stdout, "repo: %s\n", report.Repo) - _, _ = fmt.Fprintf(stdout, "branch: %s\n", report.Branch) - _, _ = fmt.Fprintf(stdout, "state: %s\n", report.State) - if report.Error != "" { - _, _ = fmt.Fprintf(stdout, "error: %s\n", report.Error) +func nonNilSubmissionEvents(events []daemon.SubmissionEvent) []daemon.SubmissionEvent { + if events == nil { + return []daemon.SubmissionEvent{} } - _, _ = fmt.Fprintln(stdout, "stages:") - for _, s := range report.Stages { - _, _ = fmt.Fprintf(stdout, " %-10s %s\n", s.Name+":", s.Result) + redacted := make([]daemon.SubmissionEvent, len(events)) + for i, event := range events { + event.Gate = evidence.RedactString(event.Gate) + event.Ref = evidence.RedactString(event.Ref) + event.Kind = evidence.RedactString(event.Kind) + redacted[i] = event } - if len(report.PendingFindings) == 0 { - _, _ = fmt.Fprintln(stdout, "findings: none pending") - } else { - _, _ = fmt.Fprintln(stdout, "findings:") - for _, f := range report.PendingFindings { - _, _ = fmt.Fprintf(stdout, " [%s] %s\n", f.Stage, f.Message) - } + return redacted +} + +func timePtr(t time.Time) *time.Time { + if t.IsZero() { + return nil } - return 0 + return &t } diff --git a/cmd/made/status_test.go b/cmd/made/status_test.go index 48163d4..9bde657 100644 --- a/cmd/made/status_test.go +++ b/cmd/made/status_test.go @@ -6,6 +6,7 @@ import ( "io" "os" "path/filepath" + "strings" "testing" "time" @@ -46,7 +47,7 @@ func TestStatusJSON_SchemaValidity(t *testing.T) { deadline := time.After(2 * time.Second) for { snap, ok := rm.Snapshot("run-test-1") - if ok && (snap.Status == daemon.RunCompleted || snap.Status == daemon.RunFailed) { + if ok && (snap.Status == daemon.RunSucceeded || snap.Status == daemon.RunFailed) { break } select { @@ -56,7 +57,7 @@ func TestStatusJSON_SchemaValidity(t *testing.T) { } } - out, errOut, code := runCapture(t, []string{"status", "--json"}) + out, errOut, code := runCapture(t, []string{"run", "status", "--json", "run-test-1"}) if code != 0 { t.Fatalf("exit code = %d, want 0; stdout=%s stderr=%s", code, out, errOut) } @@ -79,7 +80,7 @@ func TestStatusJSON_SchemaValidity(t *testing.T) { t.Errorf("Branch = %q, want %q", report.Branch, "feature-x") } switch report.State { - case "queued", "running", "completed", "failed": + case "queued", "running", "awaiting_review", "awaiting_merge", "succeeded", "failed", "canceled", "superseded": default: t.Errorf("State = %q, not one of the documented run states", report.State) } @@ -106,6 +107,55 @@ func TestStatusJSON_SchemaValidity(t *testing.T) { } } +func TestNewStatusReportRedactsSensitiveRunText(t *testing.T) { + secret := "token=status-secret" + report := newStatusReport(daemon.RunSnapshot{ + ID: "run-sensitive", + Errors: []string{secret}, + Findings: []daemon.RunFinding{{Message: secret}}, + }) + encoded, err := json.Marshal(report) + if err != nil { + t.Fatalf("marshal report: %v", err) + } + if strings.Contains(string(encoded), "status-secret") { + t.Fatalf("status report retained sensitive text: %s", encoded) + } +} + +func TestNewStatusReportRedactsAllExternallySuppliedRunFields(t *testing.T) { + secret := "token=public-secret" + report := newStatusReport(daemon.RunSnapshot{ + ID: "run-sensitive-fields", + InputSHA: "0123456789abcdef0123456789abcdef01234567", + OutputSHA: "89abcdef0123456789abcdef0123456789abcdef", + PRURL: "https://user:public-secret@example.com/repo/pull/1", + Findings: []daemon.RunFinding{{Message: "finding", Paths: []string{secret}}}, + Decisions: map[string]string{"review": secret}, + SubmissionEvents: []daemon.SubmissionEvent{{ + Gate: secret, + Ref: secret, + InputSHA: "0123456789abcdef0123456789abcdef01234567", + OutputSHA: "89abcdef0123456789abcdef0123456789abcdef", + Kind: secret, + }}, + PendingFindings: []daemon.AskUserFinding{{Message: secret}}, + }) + encoded, err := json.Marshal(report) + if err != nil { + t.Fatalf("marshal report: %v", err) + } + if strings.Contains(string(encoded), "public-secret") { + t.Fatalf("status report retained sensitive externally supplied text: %s", encoded) + } + if report.RunID != "run-sensitive-fields" { + t.Fatalf("status report rewrote exact run ID: %q", report.RunID) + } + if report.InputSHA != "0123456789abcdef0123456789abcdef01234567" || report.OutputSHA != "89abcdef0123456789abcdef0123456789abcdef" { + t.Fatalf("status report rewrote exact SHA identity: input=%q output=%q", report.InputSHA, report.OutputSHA) + } +} + func TestStatusJSON_ReflectsRealStageUpdate(t *testing.T) { home := shortTempDir(t) t.Setenv("MADE_HOME", home) @@ -156,7 +206,7 @@ func TestStatusJSON_ReflectsRealStageUpdate(t *testing.T) { t.Fatalf("UpdatePendingFindings: %v", err) } - out, errOut, code := runCapture(t, []string{"status", "--json", "run-real-stage-1"}) + out, errOut, code := runCapture(t, []string{"run", "status", "--json", "run-real-stage-1"}) if code != 0 { t.Fatalf("exit code = %d, want 0; stdout=%s stderr=%s", code, out, errOut) } @@ -207,7 +257,7 @@ func TestStatus_NoRunsReportsError(t *testing.T) { } }) - _, _, code := runCapture(t, []string{"status", "--json"}) + _, _, code := runCapture(t, []string{"run", "status", "--json", "missing"}) if code == 0 { t.Fatal("expected non-zero exit when no runs have been submitted") } diff --git a/cmd/made/strictjson.go b/cmd/made/strictjson.go new file mode 100644 index 0000000..f60a7b0 --- /dev/null +++ b/cmd/made/strictjson.go @@ -0,0 +1,31 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" +) + +func decodeStrictParams(params json.RawMessage, out any) error { + trimmed := bytes.TrimSpace(params) + if len(trimmed) == 0 { + return nil + } + if trimmed[0] != '{' { + return fmt.Errorf("params must be a JSON object") + } + decoder := json.NewDecoder(bytes.NewReader(trimmed)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(out); err != nil { + return err + } + var extra json.RawMessage + if err := decoder.Decode(&extra); err != io.EOF { + if err == nil { + return fmt.Errorf("params contain multiple JSON values") + } + return err + } + return nil +} diff --git a/docs/remediation/made-remediation-p1p3b.md b/docs/remediation/made-remediation-p1p3b.md new file mode 100644 index 0000000..461c9e6 --- /dev/null +++ b/docs/remediation/made-remediation-p1p3b.md @@ -0,0 +1,347 @@ +# Made remediation phases 1-3 delivery report + +This report records the Made-owned remediation delivery from custody base `3e19ed9d598a68149da5a73949533e8095ca4403` through the direct-PR handoff. + +The work was performed only in `/Users/douglasjarquin/.herdr/worktrees/made/cs-made-remediation-p1p3b` on branch `cs/made-remediation-p1p3b`. + +The failed-launch custody branch remained untouched and the shared Made daemon was never restarted or stopped. + +## Isolation and baseline + +The isolation commands were `pwd -P`, `git rev-parse --show-toplevel`, `git branch --show-current`, and `git rev-parse HEAD`. + +They resolved to the disposable Herdr worktree, branch `cs/made-remediation-p1p3b`, and base SHA `3e19ed9d598a68149da5a73949533e8095ca4403`. + +The baseline toolchain was Go `1.26.6 darwin/arm64` and golangci-lint `2.11.2`. + +The committed module and CI pin Go `1.26.5`; the local Go `1.26.6` toolchain was used only for this validation run. + +The baseline normal, race, vet, and lint commands passed after applying the process-local signing override `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=commit.gpgsign GIT_CONFIG_VALUE_0=false`. + +The signing override was needed because the inherited global SSH signing configuration requires an unavailable 1Password socket. + +The installed Codex CLI was `/opt/homebrew/bin/codex` version `codex-cli 0.147.0`. + +The supported invocation is `codex exec --cd --json --output-schema -`. + +## Phase 1 RED contract + +The red command was `GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=commit.gpgsign GIT_CONFIG_VALUE_0=false GOTOOLCHAIN=local go test -count=1 ./...`. + +The command exited `1` before production changes and its output was captured in `/tmp/made-remediation-p1p3b-red.log`. + +The red failures covered unknown versioned commands, global-latest status fallback, missing schema fields, unknown decisions, restart loss, missing doctor JSON, obsolete Codex invocation, destructive socket cleanup, unversioned and zero-value configuration, predecessor lifecycle values, restart-unsafe IDs, incorrect idle handling for `awaiting_merge`, stale PID shutdown, evidence traversal and size bounds, PR URLs used where workflow run IDs are required, authentication classified as a failed check, duplicate PR creation, incorrect rebase conflict classification, and dirty auto-fixes. + +Each failure exercised a production boundary with a strict assertion on the required observable behavior, so the failure was a missing implementation contract rather than a permissive fixture mismatch. + +The later config replacement RED regression exited at compile time because the opened-descriptor reader did not yet exist; `/tmp/made-remediation-p1p3b-config-boundary-red.log` records that missing production behavior before the fix. + +The exact-cap durable RED regression failed on replay because the newline delimiter was counted against the payload cap; `/tmp/made-remediation-p1p3b-durable-cap-red.log` records that missing recovery behavior before the fix. + +The stalled-input RED regression timed out against the unbounded reader; `/tmp/made-remediation-p1p3b-stalled-input-red.log` records that missing resource bound before the fix. + +The security RED regression for direct review-agent edits was a behavioral failure because the fake agent created an untracked worktree file and `review.Run` still returned success. + +The final review-isolation RED regression was behavioral because the strict Codex fake received the delivery worktree and the fake reviewer left `unreviewed.txt` in that worktree even though the stage returned an error. + +The review-setup injection RED regression was behavioral because an inherited `GIT_TEMPLATE_DIR` and `GIT_CONFIG_*` hook configuration executed a `post-checkout` hook during temporary clone setup. + +The security RED regressions for evidence publication were behavioral failures because `PublishEvidence` staged a symlink, published an injected secret unchanged, and accepted an injected file beyond the configured retention bound. + +The security RED regressions for status and API errors were behavioral failures because public JSON retained an externally supplied token in paths, decisions, submission fields, and the PR URL, while the socket error response retained a token in its handler message. + +Those failures prove missing production boundaries rather than fixture defects because each strict fixture supplied an adversarial value through the same public or filesystem boundary used by the real pipeline, and each assertion checked the observable output or committed artifact. + +The compatibility fake GitHub boundary rejected PR URLs where workflow run IDs were required and modeled check status, conclusion, workflow run ID, and details URL. + +The fake Codex boundary accepted only the installed `codex exec --cd --json --output-schema` invocation and strict structured output. + +The old mocks were updated or removed so they do not authorize commands that the real Made binary does not implement. + +## Phase 2 implementation and GREEN + +The initial implementation commit was `f92baaf345dea88a907e29e8727aa6d937902df9` with subject `feat: deliver versioned durable remediation contract`. + +The durability follow-up commit was `deea4ff0a37c7ac2118a2125a487316b65162d8b` with subject `fix: close remediation durability review gaps`. + +The review-boundary follow-up commit was `3b387b78c9b3a6ec393d29fc866be0ff138a871b` with subject `fix: harden remediation review boundaries`. + +The final validation-fix commit is `d866545b1391e25f738930200566ab7dcff5c4e5` with subject `fix: close final validation findings`. + +The final boundary-hardening commit is `1f8055eeab3fb93b34bf764911f0aec7bfb54767` with subject `fix: close remediation boundary review gaps`. + +The evidence-only QA commit is `af5010d7bd910bfa829e030c0198cae909188e69` with subject `docs: record final remediation QA evidence`. + +The final executable boundary-fix commit is `d45f5c518664db5f73f42d1d4db595216331f24b` with subject `fix: close final remediation boundary gaps`. + +The final boundary-completion commit is `da8f5653bc3e13877480728bc3dd2daf296e7dd2` with subject `fix: harden final remediation boundaries`. + +The final input-boundary commit is `8d196c4af539c6cae53fb308c029fb7c700b992f` with subject `fix: bound durable and socket inputs`. + +The final config descriptor-boundary commit is `6cab7c9603dc8f0d1fce1c7b114282867ff64c95` with subject `fix: close config read race`. + +The final durable replay-boundary commit is `5cf18b3d491f4f244f9c586907ab70509b978317` with subject `fix: replay exact-cap durable records`. + +The final stalled-input-boundary commit is `8b4ab6c98190f3c304ff0518c86e9bdf9097166f` with subject `fix: bound stalled API connections`. + +The security-boundary follow-up commit is `42ddaef20e59bc42ede3863aecd1d7b2ef59fc94` with subject `fix: close review and evidence security boundaries`. + +The exact-identity follow-up commit is `e9aa0ddd72e38d2625cd37e95e7968d8c1d7dc61` with subject `fix: preserve exact run identities during redaction`. + +The review-isolation commit is `6f7d25458177f8b17271a28e7953ebc5c69a9fac` with subject `fix: isolate review agents from delivery worktrees`. + +The review-setup injection follow-up is `a51dbec4e97a924a503389c13c1e9aef089a31e6` with subject `fix: close review setup injection boundary`. + +The controlled auto-fix Git follow-up is `5e263785b7313523fdeec648ea3475ac9d446543` with subject `fix: sanitize controlled review Git commands`. + +The repository-hook follow-up is `a81179eae674b45e4086e8d758e8aac36bd4f92c` with subject `fix: disable repository hooks for auto-fixes`. + +The clean-filter follow-up is `738cc55b2d4b4dbdaadc05eb351f612be0eafcd5` with subject `fix: neutralize repository clean filters`. + +The portability follow-up is `0c3af42fd75d6f02412cde82b33c8341305243e3` with subject `fix: harden portable rebase validation`. + +The final review-boundary follow-up is `c3b002e1faa4fccb20fc4f9f63600a425b5c5e52` with subject `fix: harden evidence and API boundaries`. + +The final strict-boundary follow-up is `3fc98f031613e7be77abf0152cb1eb5b3d1baeaf` with subject `fix: isolate rebase and no-param API`. + +The review-containment follow-up is `f94306655d32e74c4cdff73c5ebbd349b73ad2c9` with subject `fix: contain review agents at OS boundary`. + +The final lint follow-up is `ed9009251e9754b137dc0719352b525aad880e1b` with subject `fix: satisfy containment lint`. + +The CI containment portability follow-up is `7e61074e03da212da04c517ccbb23a5e0899d05f` with subject `fix: make reviewer containment portable in CI`. + +The read-only reviewer-mask follow-up is `0e8f4c41b1104c20bc4e40604ded49c37d65e9fb` with subject `fix: make reviewer masks read-only`. + +The final containment and submission-identity follow-up is `f2bac112fd2b91eb3bd1396878da21273961cbe0` with subject `fix: harden containment and submit identity`. + +The lint-action compatibility follow-up is `463e5805d8ba4eac8d6e72e5315cfc43f2c7782b` with subject `fix: pin golangci action for lint v2`. + +Those follow-ups harden Made home ownership and permissions, private evidence permissions, version-only configuration rejection, disabled-stage representation, environment-injected real Consigliere compatibility testing, final review/API boundaries, bounded configuration and socket input, torn-tail WAL recovery, replacement-safe config reads, exact-cap durable replay, stalled-input resource bounds, review-agent isolation, evidence publication, subprocess timeouts, and public-field redaction. + +The implementation acquires the singleton before socket preparation, uses `lstat`, removes only a stale owner-owned Unix socket, rejects regular files, symlinks, and directories, preserves duplicate owners, and authorizes shutdown through the owner-only socket. + +The daemon persists complete run snapshots in an fsync-backed append-only WAL and persists idempotent gate submissions in an fsync-backed spool keyed by gate, ref, and SHA. + +Run snapshots remain retained in the local WAL until explicit operator archival outside this task, while evidence is bounded to 1 MiB per file and 4 MiB per run. + +Submission admission is closed under the same mutex as the shutdown check, so a concurrent run or gate submission cannot arrive after a successful shutdown decision. + +The public surface is versioned and structured through `made capabilities --json`, `made run submit`, `made run status`, `made run list`, `made run cancel`, `made review decide`, and `made doctor --json`. + +The Unix-socket envelope and every public daemon parameter object now reject unknown fields and non-object parameter values instead of silently accepting an unversioned shape. + +The lifecycle states are `queued`, `running`, `awaiting_review`, `awaiting_merge`, `succeeded`, `failed`, `canceled`, and `superseded`. + +Execution completion is represented separately by `execution_finished`. + +Cancellation requires an exact run ID, is idempotent for an already canceled run, waits for cooperative execution to finish at the CLI boundary, and refuses unknown or unrelated runs. + +Restored queued, running, and awaiting-review snapshots are reconciled to durable failed state after a daemon restart because no worker can safely resume execution without a durable work specification. + +Review agents run against a detached clone made without local hardlinks, with the exact source HEAD verified before launch, the clone and Git metadata made non-writable, escaping symlinks rejected, and delivery-path Git environment variables removed. + +Review processes are additionally contained with macOS `sandbox-exec` or Linux `bubblewrap`, denying reads and writes to the source worktree and common Git directory while binding the detached review tree read-only. + +Review setup removes all inherited `GIT_*` injection variables, disables global and system Git configuration for clone and checkout, and tests template hooks, injected config, exact HEAD, cleanup, and escaping symlinks. + +Controlled auto-fix Git commands use the same bounded execution contract with all ambient `GIT_*` routing and hook configuration removed before status, apply, add, commit, and validation operations. + +Auto-fix commits explicitly set `core.hooksPath=/dev/null`, so repository-local hooks cannot execute during controlled mutations. + +Controlled Git also neutralizes repository-local clean, smudge, and process filters before any auto-fix status, apply, add, commit, or validation command. + +Pending gate submissions are replayed on daemon startup and remain undrained when their external boundary is unavailable. + +The orchestrator records stages, disabled stages as `skipped`, findings, decisions, PR URL, errors, supersession, cancellation, and submission events. + +The direct `run.submit` API requires a Made-owned gate path, branch ref, and immutable input head, then executes the same real gate pipeline as `gate.notify-push`. + +Gate submissions are fsync-enqueued before default-branch refresh, so a reachable daemon cannot lose an accepted update when the external remote is unavailable. + +Gate RPCs validate the Made-owned gate layout, bare-repository identity, and exact pushed ref head before creating a run. + +The run manager persists the actual prepared output SHA before pushing, while the in-repository evidence mode commits bounded redacted evidence into the pushed branch for later access. + +## Phase 3 implementation and GREEN + +The `.made.yml` boundary is versioned, strictly decoded, and rejects unknown or zero-value configuration. + +The pipeline refreshes the real remote default branch before trusted policy or rebase decisions and fails closed for unavailable remotes, while treating an absent default ref as an empty trusted-policy case. + +The review adapter validates a Made-owned schema and uses the installed structured Codex invocation. + +Auto-fixes require a clean state, require explicitly returned tracked paths, reject forbidden or untracked paths and forbidden patch headers before apply, record pre-fix and post-fix SHAs, and rerun relevant validation. + +Rebase failures are classified as conflicts only when unmerged paths exist. + +Evidence is run- and stage-specific, bounded, redacted for common credential assignments and URLs, symlink-safe, and published only through accessible paths. + +Every evidence Git command runs with inherited `GIT_*` and SSH-agent routing removed, global and system configuration disabled, hooks and fsmonitor disabled, external diff disabled, and repository-local clean/process/smudge filters overridden. + +Rebase Git commands use the same bounded, sanitized environment and filter overrides, so ambient `GIT_DIR`, hooks, configuration, and filters cannot redirect or execute during trusted-branch preparation. + +In-repository evidence is committed into the pushed branch before the push stage completes, while orphan evidence remains on its dedicated evidence branch. + +When a remote default ref disappears, Made deletes the cached trusted ref before resolving policy, preventing stale trusted configuration from surviving refresh. + +Trusted review and CI required settings control whether disabled review and CI stages block delivery, and `no_ci` explicitly disables the CI requirement. + +Pull request creation is idempotent by repository, base, and head. + +CI polling uses actual check status, conclusion, workflow run ID, and details URL, while authentication and API failures are infrastructure failures. + +Pull-request GitHub authentication and API failures now remain infrastructure errors instead of being represented as failed checks. + +The Made CI workflow validates the pinned Go version with race, vet, and pinned lint jobs. + +The CI job installs the pinned-environment reviewer containment prerequisite `bubblewrap` and enables its documented setuid execution mode before running the Made tests, while the pinned lint action uses v7.0.1 for golangci-lint v2. + +The CI portability fix uses Go's platform-independent Unix-socket mode inspection instead of Darwin-only `stat -f` flags, and clean rebase execution supplies deterministic committer identity, disabled signing, and disabled hooks at the Git boundary. + +## Validation evidence + +The final executable source SHA covered by this validation section is `f2bac112fd2b91eb3bd1396878da21273961cbe0`. + +The config descriptor-boundary commit adds replacement-safe reads from one opened descriptor and a regression proving a replaced path cannot bypass the byte cap. + +The durable replay-boundary commit permits the newline delimiter for exact-cap payloads and adds WAL and spool reopen regressions for the exact append limit. + +The stalled-input-boundary commit caps concurrent socket handlers and closes connections whose first request does not arrive within the bounded read deadline. + +The security-boundary commit rejects direct agent worktree edits, filters secret-bearing review environment variables, rechecks and redacts every in-repo evidence file before staging, bounds evidence Git subprocesses with stage context and output caps, and redacts status, API, and durable event fields. + +The exact-identity follow-up preserves valid run IDs, repository identity, refs, and SHA fields while still redacting untrusted messages, paths, decisions, event labels, PR URLs, and errors, and it rejects symlinked configured evidence roots before publication. + +The review-isolation commit adds the final RED/GREEN contract for the supported Codex invocation, delivery-worktree preservation, detached exact-HEAD review input, non-writable review files, and scrubbed inherited Git path state. + +The review-setup injection follow-up adds the RED/GREEN contract for Git template and config hook suppression, review-clone cleanup, exact-HEAD verification, and escaping-symlink rejection, while splitting the isolation implementation into its own module. + +The controlled auto-fix Git follow-up adds the RED/GREEN contract for ambient `GIT_DIR` routing and pre-commit hook suppression, and bounds all auto-fix Git subprocesses. + +The repository-hook follow-up adds the RED/GREEN contract for local `core.hooksPath` suppression during auto-fix commits. + +The clean-filter follow-up adds the RED/GREEN contract for repository-local executable `filter.*.clean` suppression during staging. + +The validation shell exported `GIT_CONFIG_COUNT=1`, `GIT_CONFIG_KEY_0=commit.gpgsign`, `GIT_CONFIG_VALUE_0=false`, `SSH_AUTH_SOCK=`, and `GOTOOLCHAIN=local` for deterministic fixture commits and toolchain selection. + +It ran `gofmt -l internal cmd`, `go build ./...`, `go test -count=1 -timeout=10m ./...`, `go test -race -shuffle=on -count=1 -timeout=10m ./...`, `go vet ./...`, and `golangci-lint run --timeout=5m --max-issues-per-linter=0 --max-same-issues=0 ./...` at the exact final source SHA. + +All final validation commands exited `0`, and golangci-lint reported `0 issues`. + +The full validation transcript for the unchanged executable ancestor is `/tmp/made-remediation-p1p3b-8b4ab6c-validation.log`. + +The same full validation command was rerun after the exact-identity follow-up at executable SHA `e9aa0ddd72e38d2625cd37e95e7968d8c1d7dc61`, and `/tmp/made-remediation-p1p3b-e9aa0dd-validation.log` ends with `validation-e9aa0dd=PASS`. + +The full validation command was rerun at the final executable SHA `6f7d25458177f8b17271a28e7953ebc5c69a9fac`, and `/tmp/made-remediation-p1p3b-6f7d254-validation.log` ends with `validation-6f7d254=PASS`. + +The full validation command was rerun at the final executable SHA `a51dbec4e97a924a503389c13c1e9aef089a31e6`, and `/tmp/made-remediation-p1p3b-a51dbec-validation.log` ends with `validation-a51dbec=PASS`. + +The full validation command was rerun at the final executable SHA `5e263785b7313523fdeec648ea3475ac9d446543`, and `/tmp/made-remediation-p1p3b-5e26378-validation.log` ends with `validation-5e26378=PASS`. + +The full validation command was rerun at the final executable SHA `a81179eae674b45e4086e8d758e8aac36bd4f92c`, and `/tmp/made-remediation-p1p3b-a81179e-validation.log` ends with `validation-a81179e=PASS`. + +The full validation command was rerun at the final executable SHA `738cc55b2d4b4dbdaadc05eb351f612be0eafcd5`, and `/tmp/made-remediation-p1p3b-738cc55-validation.log` ends with `validation-738cc55=PASS`. + +The full validation command was rerun at the final executable SHA `0c3af42fd75d6f02412cde82b33c8341305243e3`, and `/tmp/made-remediation-p1p3b-0c3af42-validation-clean.log` ends with the pinned lint result `0 issues.` and exit `0`. + +The full validation command was rerun at the final executable SHA `c3b002e1faa4fccb20fc4f9f63600a425b5c5e52`, and `/tmp/made-remediation-p1p3b-c3b002e-validation.log` ends with `validation-c3b002e=PASS` and the pinned lint result `0 issues.`. + +The full validation command was rerun at the final executable SHA `3fc98f031613e7be77abf0152cb1eb5b3d1baeaf`, and `/tmp/made-remediation-p1p3b-3fc98f0-validation.log` ends with `validation-3fc98f0=PASS` and the pinned lint result `0 issues.`. + +The full validation command was rerun at the final executable SHA `ed9009251e9754b137dc0719352b525aad880e1b`, and `/tmp/made-remediation-p1p3b-ed90092-validation.log` ends with `validation-ed90092=PASS` and the pinned lint result `0 issues.`. + +At executable source SHA `f2bac112fd2b91eb3bd1396878da21273961cbe0`, the deterministic full suite, race suite, build, vet, and pinned lint all passed; the focused containment, API, evidence, rebase, and review suites passed; `TestRunSubmit_ExecutesGatePipeline` passed with a stable queued identity; and `TestHermeticCompatibility_RealMadeBinaryThroughConsigliereScript` passed with the real Consigliere script and strict external fakes. + +The final CI configuration pins `golangci/golangci-lint-action` v7.0.1 at `9fae48acfc02a90574d7c304a1758ef9895495fa` so the workflow accepts the required golangci-lint v2.11.2. + +The final Linux containment correction uses read-only binds from private empty directories inside the detached review clone, preventing a reviewer from recreating files at a masked source path or reaching a writable mask through `/tmp`. + +The fresh real-process manual QA transcript for the final executable SHA is `/tmp/made-remediation-p1p3b-manual-e9aa0dd.log`, and its final marker was `manual-qa-e9aa0dd=PASS` at that full SHA. + +The exact current lifecycle and restart contract transcript is `/tmp/made-remediation-p1p3b-manual-contract-e9aa0dd.log`, and its final marker was `manual-contract-e9aa0dd=PASS`. + +The fresh real-process manual transcript at the final executable SHA is `/tmp/made-remediation-p1p3b-manual-6f7d254.log`, and its final marker was `manual-qa-6f7d254=PASS`. + +The fresh review-isolation manual contract transcript is `/tmp/made-remediation-p1p3b-manual-review-6f7d254.log`, and its final marker was `manual-review-6f7d254=PASS`. + +The fresh real-process manual transcript at the final executable SHA is `/tmp/made-remediation-p1p3b-manual-a51dbec.log`, and its final marker was `manual-qa-a51dbec=PASS`. + +The fresh review-setup isolation transcript is `/tmp/made-remediation-p1p3b-manual-review-a51dbec.log`, and its final marker was `manual-review-a51dbec=PASS`. + +The fresh real-process manual transcript at the final executable SHA is `/tmp/made-remediation-p1p3b-manual-5e26378.log`, and its final marker was `manual-qa-5e26378=PASS`. + +The fresh controlled auto-fix transcript is `/tmp/made-remediation-p1p3b-manual-review-5e26378.log`, and its final marker was `manual-review-5e26378=PASS`. + +The fresh real-process manual transcript at the final executable SHA is `/tmp/made-remediation-p1p3b-manual-a81179e.log`, and its final marker was `manual-qa-a81179e=PASS`. + +The fresh controlled auto-fix transcript is `/tmp/made-remediation-p1p3b-manual-review-a81179e.log`, and its final marker was `manual-review-a81179e=PASS`. + +The fresh real-process manual transcript at the final executable SHA is `/tmp/made-remediation-p1p3b-manual-738cc55.log`, and its final marker was `manual-qa-738cc55=PASS`. + +The fresh controlled auto-fix transcript is `/tmp/made-remediation-p1p3b-manual-review-738cc55.log`, and its final marker was `manual-review-738cc55=PASS`. + +The fresh exact-tip real-process transcript is `/tmp/made-remediation-p1p3b-manual-0c3af42.log`, and its final marker is `manual-qa-0c3af42=PASS`. + +That exact-tip scenario observed process-level cancellation, WAL restart persistence, gate-spool replay, duplicate singleton ownership, obsolete RPC rejection, and the hermetic real Made binary against the real Consigliere script with strict fake GitHub and unavailable Herdr boundaries. + +The final exact-tip focused evidence and review transcript is `/tmp/made-remediation-p1p3b-manual-review-c3b002e.log`, and its final marker is `manual-review-c3b002e=PASS`. + +The final executable exact-tip transcript is `/tmp/made-remediation-p1p3b-manual-3fc98f0.log`, and its final marker is `manual-qa-3fc98f0=PASS`. + +The final executable exact-tip focused API, evidence, agent, rebase, and review transcript is `/tmp/made-remediation-p1p3b-manual-review-3fc98f0.log`, and its final marker is `manual-review-3fc98f0=PASS`. + +The final executable real-process transcript is `/tmp/made-remediation-p1p3b-manual-ed90092.log`, and its final marker is `manual-qa-ed90092=PASS`. + +The final executable focused API, evidence, containment, agent, rebase, and review transcript is `/tmp/made-remediation-p1p3b-manual-review-ed90092.log`, and its final marker is `manual-review-ed90092=PASS`. + +The exact-tip API and evidence RED log is `/tmp/made-remediation-p1p3b-strict-boundary-red.log`, and the green rerun is covered by `/tmp/made-remediation-p1p3b-c3b002e-validation.log` plus the evidence-focused `/tmp/made-remediation-p1p3b-manual-review-c3b002e.log`. + +The no-parameter API and ambient-rebase RED log is `/tmp/made-remediation-p1p3b-strict-no-params-red.log` plus `/tmp/made-remediation-p1p3b-rebase-boundary-red.log`, and the focused green rerun is the exact-tip rebase/API test pass immediately before commit `3fc98f031613e7be77abf0152cb1eb5b3d1baeaf`. + +The reviewer-containment RED log is `/tmp/made-remediation-p1p3b-review-containment-red.log`, where the fake reviewer escaped through the source worktree before the OS boundary was added; `TestSpawn_ContainsReviewerFromSourceWorktree` passes at the final executable SHA. + +That exact-SHA scenario used a fresh final binary and task-local Made homes to observe daemon cancellation through a real process and doctor through the real Consigliere script. + +The companion exact-SHA contract scenario observed restart durability, gate-spool replay, duplicate singleton ownership, obsolete RPC rejection, oversized and stalled socket rejection, raw protocol-version rejection, preserved socket ownership, replacement-safe config reads, torn-tail recovery, and exact-cap durable replay. + +The preceding full-pipeline manual scenario at `a724f6c857e903ce52d62d803c540b27a221d6f3` observed real gate initialization and hook execution, native `run.submit` pipeline execution, durable offline gate spooling and replay, exact submission and SHA preservation, exact status and active-list queries, versioned review decision output, WAL restart, duplicate singleton start, and predecessor command rejection. + +The `8d196c4` changes are limited to input bounds and durable tail recovery, the `6cab7c9` change is limited to replacement-safe config reads, the `5cf18b3` change is limited to exact-cap durable replay, and the `8b4ab6c` change is limited to stalled-input resource bounds, while the exact-SHA focused scenario covers all changed runtime surfaces and preserves the full-pipeline evidence from the unchanged executable ancestor. + +The compatibility subscenario used the real `bin/cs-made-lib.sh` script, the real Made binary, a strict fake `gh auth status` boundary, a task-local unavailable Herdr socket, and accepted the expected nonzero health exit only after asserting valid versioned JSON and authenticated GitHub state. + +Final QA also observed that the real Consigliere `cs_made_status` helper still invokes the intentionally removed predecessor command `made status --json` at `/Users/douglasjarquin/github/consigliere/bin/cs-made-lib.sh:57-63`. + +That helper returns exit `2` with `made: unknown command "status"`, while `cs_made_doctor --json` passes. + +This is an external, out-of-scope compatibility finding: this task forbids Consigliere edits and forbids a Made compatibility layer for obsolete commands, so the Made implementation deliberately preserves the rejection. + +The first manual cancellation run returned `running` before the worker completed, which falsified the CLI response contract. + +The cancellation wait fix returned `canceled` with `execution_finished=true` in the counterfactual rerun. + +The first full validation exposed a WAL replay ordering race where a stale `succeeded` snapshot could be appended after a newer `awaiting_merge` snapshot. + +Serializing snapshot capture with WAL append removed that race, and `go test -shuffle=on -count=10 ./internal/daemon -run '^TestPersistentRunStateIncludesSubmissionAndDecisionData$'` passed afterward. + +The final changed-file authority through the delivery implementation and CI SHA is `git diff --name-status 3e19ed9d598a68149da5a73949533e8095ca4403..463e5805d8ba4eac8d6e72e5315cfc43f2c7782b`, which reports the Made-only paths from the custody base. + +At directory level, the base-to-final diff is limited to `.github/workflows/ci.yml`, `AGENTS.md`, `CLAUDE.md`, `README.md`, `cmd/made`, `docs/remediation`, `internal/agent`, `internal/api`, `internal/config`, `internal/daemon`, `internal/evidence`, `internal/exec`, `internal/github`, `internal/orchestrator`, `internal/pipeline`, `internal/skill`, and `skills/made/SKILL.md`. + +The final API-boundary commit-only diff is `git diff --name-status 5cf18b3d491f4f244f9c586907ab70509b978317..e9aa0ddd72e38d2625cd37e95e7968d8c1d7dc61` and contains only API implementation and contract-test files. + +The separate Made project plan `plans/made-rewrite.md` retains its broader F3 checkbox because that criterion requires running the full Consigliere `--mode made` soldier flow and changing shared Herdr lifecycle state. + +This task explicitly forbids running `/made`, editing the Consigliere repository, and stopping or restarting the shared Made daemon, so the Made-specific manual QA above does not claim completion of that broader plan item. + +No Consigliere repository file, GitHub issue, default branch, merge, or shared daemon state was changed. + +## Delivery receipt + +The application and CI delivery head immediately before this report-only receipt is `1e5cafb3ae945b50fe5057cbafc2e4b9733244a3` on `cs/made-remediation-p1p3b`. + +Direct PR [#1](https://github.com/douglasjarquin/made/pull/1) is open, non-draft, unmerged, and targets `main` at `3e19ed9d598a68149da5a73949533e8095ca4403`. + +Live CI run `31995872679` passed its required `build-test-lint` job at that exact delivery head. + +No merge, default-branch push, Consigliere edit, GitHub issue, or shared Made-daemon lifecycle action was performed. diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 9f54cab..26db893 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -25,17 +26,43 @@ func writeScenario(t *testing.T, findings agent.Findings) string { return path } +func agentWorktree(t *testing.T) string { + t.Helper() + dir := t.TempDir() + gitAgent(t, dir, "init", "-q") + gitAgent(t, dir, "commit", "-q", "--allow-empty", "-m", "initial commit") + return dir +} + +func gitAgent(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd.Env = append(os.Environ(), + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_SYSTEM=/dev/null", + "GIT_AUTHOR_NAME=agent-test", + "GIT_AUTHOR_EMAIL=agent-test@example.com", + "GIT_COMMITTER_NAME=agent-test", + "GIT_COMMITTER_EMAIL=agent-test@example.com", + ) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v: %s", args, err, output) + } + return string(output) +} + func TestSpawn_ParsesFindingsFromFakeAgent(t *testing.T) { bin := agenttest.Build(t) scenarioPath := writeScenario(t, agent.Findings{ Findings: []agent.Finding{ - {Kind: agent.FindingAutoFixable, Description: "fix formatting", Patch: "diff --git a/x b/x\n"}, + {Kind: agent.FindingAutoFixable, Description: "fix formatting", Patch: "diff --git a/x b/x\n", Paths: []string{"x"}}, {Kind: agent.FindingAskUser, Description: "consider renaming Foo"}, }, }) findings, err := agent.Spawn(context.Background(), agent.KindClaude, agent.SpawnParams{ - WorktreePath: t.TempDir(), + WorktreePath: agentWorktree(t), BinaryPath: bin, ExtraEnv: []string{"FAKE_AGENT_SCENARIO=" + scenarioPath}, }) @@ -57,7 +84,7 @@ func TestSpawn_NonZeroExitReturnsError(t *testing.T) { bin := agenttest.Build(t) _, err := agent.Spawn(context.Background(), agent.KindCodex, agent.SpawnParams{ - WorktreePath: t.TempDir(), + WorktreePath: agentWorktree(t), BinaryPath: bin, ExtraEnv: []string{"FAKE_AGENT_EXIT_CODE=1"}, }) @@ -75,7 +102,7 @@ func TestSpawn_LogsInvocation(t *testing.T) { logPath := filepath.Join(t.TempDir(), "invocations.log") if _, err := agent.Spawn(context.Background(), agent.KindClaude, agent.SpawnParams{ - WorktreePath: t.TempDir(), + WorktreePath: agentWorktree(t), BinaryPath: bin, ExtraEnv: []string{ "FAKE_AGENT_SCENARIO=" + scenarioPath, diff --git a/internal/agent/containment.go b/internal/agent/containment.go new file mode 100644 index 0000000..97f0263 --- /dev/null +++ b/internal/agent/containment.go @@ -0,0 +1,73 @@ +package agent + +import ( + "fmt" + "os" + stdexec "os/exec" + "runtime" + "sort" + "strconv" + "strings" +) + +func containedInvocation(binary string, args []string, reviewPath string, protectedPaths, maskPaths []string) (string, []string, error) { + switch runtime.GOOS { + case "darwin": + const sandboxExec = "/usr/bin/sandbox-exec" + if _, err := os.Stat(sandboxExec); err != nil { + return "", nil, fmt.Errorf("%s is required for reviewer containment: %w", sandboxExec, err) + } + profile := darwinReviewProfile(protectedPaths) + commandArgs := []string{"-p", profile, binary} + return sandboxExec, append(commandArgs, args...), nil + case "linux": + bwrap, err := stdexec.LookPath("bwrap") + if err != nil { + return "", nil, fmt.Errorf("bubblewrap is required for reviewer containment: %w", err) + } + return bwrap, bubblewrapReviewArgs(binary, args, reviewPath, protectedPaths, maskPaths), nil + default: + return "", nil, fmt.Errorf("reviewer containment is unsupported on %s", runtime.GOOS) + } +} + +func darwinReviewProfile(protectedPaths []string) string { + var profile strings.Builder + profile.WriteString("(version 1)\n(allow default)\n") + for _, path := range protectedPaths { + quoted := strconv.Quote(path) + profile.WriteString("(deny file-read* (subpath ") + profile.WriteString(quoted) + profile.WriteString("))\n") + profile.WriteString("(deny file-write* (subpath ") + profile.WriteString(quoted) + profile.WriteString("))\n") + } + return profile.String() +} + +func bubblewrapReviewArgs(binary string, args []string, reviewPath string, protectedPaths, maskPaths []string) []string { + paths := append([]string(nil), protectedPaths...) + maskByPath := make(map[string]string, len(protectedPaths)) + for index, path := range protectedPaths { + maskByPath[path] = maskPaths[index] + } + sort.Slice(paths, func(i, j int) bool { return len(paths[i]) > len(paths[j]) }) + commandArgs := []string{ + "--die-with-parent", + "--unshare-pid", + "--unshare-ipc", + "--unshare-uts", + "--ro-bind", "/", "/", + "--bind", "/tmp", "/tmp", + "--dev", "/dev", + "--proc", "/proc", + "--ro-bind", reviewPath, reviewPath, + } + for _, path := range paths { + commandArgs = append(commandArgs, "--ro-bind", maskByPath[path], path) + } + commandArgs = append(commandArgs, "--chdir", reviewPath) + commandArgs = append(commandArgs, "--", binary) + return append(commandArgs, args...) +} diff --git a/internal/agent/containment_test.go b/internal/agent/containment_test.go new file mode 100644 index 0000000..31c982c --- /dev/null +++ b/internal/agent/containment_test.go @@ -0,0 +1,16 @@ +package agent + +import ( + "slices" + "testing" +) + +func TestBubblewrapReviewArgsAvoidSetuidIncompatibleUserNamespaceFlag(t *testing.T) { + args := bubblewrapReviewArgs("/bin/agent", []string{"--review"}, "/tmp/review", []string{"/tmp/source"}, []string{"/tmp/mask"}) + if slices.Contains(args, "--unshare-user") || slices.Contains(args, "--unshare-user-try") { + t.Fatalf("bubblewrap arguments request an incompatible user namespace mode: %v", args) + } + if !slices.Contains(args, "--ro-bind") || !slices.Contains(args, "/tmp/mask") { + t.Fatalf("bubblewrap protected path is not masked with a read-only bind: %v", args) + } +} diff --git a/internal/agent/findings.go b/internal/agent/findings.go index 9742692..541609b 100644 --- a/internal/agent/findings.go +++ b/internal/agent/findings.go @@ -12,6 +12,7 @@ type Finding struct { Kind FindingKind `json:"kind"` Description string `json:"description"` Patch string `json:"patch,omitempty"` + Paths []string `json:"paths,omitempty"` } type Findings struct { diff --git a/internal/agent/remediation_contract_test.go b/internal/agent/remediation_contract_test.go new file mode 100644 index 0000000..7c77b1e --- /dev/null +++ b/internal/agent/remediation_contract_test.go @@ -0,0 +1,173 @@ +package agent_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/douglasjarquin/made/internal/agent" + "github.com/douglasjarquin/made/internal/agent/agenttest" +) + +func TestSpawn_CodexUsesSupportedExecStructuredContract(t *testing.T) { + worktree := agentWorktree(t) + head := strings.TrimSpace(gitAgent(t, worktree, "rev-parse", "HEAD")) + t.Setenv("MADE_REVIEW_SECRET", "must-not-reach-agent") + templateDir := t.TempDir() + hooksDir := filepath.Join(templateDir, "hooks") + if err := os.MkdirAll(hooksDir, 0o700); err != nil { + t.Fatalf("mkdir Git template hooks: %v", err) + } + hookMarker := filepath.Join(t.TempDir(), "template-hook-fired") + hookPath := filepath.Join(hooksDir, "post-checkout") + hook := "#!/bin/sh\nprintf fired > " + shellQuote(hookMarker) + "\n" + if err := os.WriteFile(hookPath, []byte(hook), 0o700); err != nil { + t.Fatalf("write Git template hook: %v", err) + } + t.Setenv("GIT_TEMPLATE_DIR", templateDir) + t.Setenv("GIT_CONFIG_COUNT", "1") + t.Setenv("GIT_CONFIG_KEY_0", "core.hooksPath") + t.Setenv("GIT_CONFIG_VALUE_0", hooksDir) + logPath := filepath.Join(t.TempDir(), "invocation.log") + script := filepath.Join(t.TempDir(), "strict-codex") + contents := strings.Join([]string{ + "#!/bin/sh", + "set -eu", + "printf '%s\\n' \"$@\" > \"$STRICT_CODEX_LOG\"", + "[ \"$1\" = \"exec\" ]", + "[ \"$2\" = \"--cd\" ]", + "[ \"$3\" != \"$STRICT_CODEX_WORKTREE\" ]", + "[ -d \"$3\" ]", + "[ \"$(git -C \"$3\" rev-parse HEAD)\" = \"$STRICT_CODEX_HEAD\" ]", + "if (umask 077; : > \"$3/.agent-write-probe\") 2>/dev/null; then exit 1; fi", + "shift 3", + "has_json=0", + "has_schema=0", + "while [ \"$#\" -gt 0 ]; do", + " case \"$1\" in", + " --json) has_json=1 ;;", + " --output-schema) has_schema=1; shift; test -f \"$1\" ;;", + " esac", + " shift", + "done", + "[ \"$has_json\" -eq 1 ]", + "[ \"$has_schema\" -eq 1 ]", + "test -z \"${MADE_REVIEW_SECRET:-}\"", + "printf '%s\\n' '{\"findings\":[]}'", + "", + }, "\n") + if err := os.WriteFile(script, []byte(contents), 0o700); err != nil { + t.Fatalf("write strict Codex fake: %v", err) + } + + findings, err := agent.Spawn(context.Background(), agent.KindCodex, agent.SpawnParams{ + WorktreePath: worktree, + BinaryPath: script, + ExtraEnv: []string{ + "STRICT_CODEX_LOG=" + logPath, + "STRICT_CODEX_WORKTREE=" + worktree, + "STRICT_CODEX_HEAD=" + head, + }, + }) + if err != nil { + t.Fatalf("Spawn: %v", err) + } + if len(findings.Findings) != 0 { + t.Fatalf("expected empty structured findings, got %+v", findings) + } + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("read invocation log: %v", err) + } + args := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(args) < 3 { + t.Fatalf("expected Codex invocation arguments, got %q", data) + } + if len(args) > 0 && args[0] == "review" { + t.Fatalf("Codex invocation used obsolete review command: %s", data) + } + if _, err := os.Stat(args[2]); !os.IsNotExist(err) { + t.Fatalf("review clone was not cleaned up: %v", err) + } + if _, err := os.Stat(hookMarker); !os.IsNotExist(err) { + t.Fatalf("Git template or injected config hook ran during review setup: %v", err) + } +} + +func TestSpawn_RejectsReviewSymlinkThatEscapesClone(t *testing.T) { + worktree := agentWorktree(t) + outside := filepath.Join(t.TempDir(), "outside.txt") + if err := os.WriteFile(outside, []byte("outside\n"), 0o600); err != nil { + t.Fatalf("write outside target: %v", err) + } + if err := os.Symlink(outside, filepath.Join(worktree, "escape.txt")); err != nil { + t.Fatalf("create escaping symlink: %v", err) + } + gitAgent(t, worktree, "add", "escape.txt") + gitAgent(t, worktree, "commit", "-q", "-m", "add escaping symlink") + scenarioPath := filepath.Join(t.TempDir(), "scenario.json") + if err := os.WriteFile(scenarioPath, []byte(`{"findings":[]}`), 0o600); err != nil { + t.Fatalf("write scenario: %v", err) + } + + _, err := agent.Spawn(context.Background(), agent.KindClaude, agent.SpawnParams{ + WorktreePath: worktree, + BinaryPath: agenttest.Build(t), + ExtraEnv: []string{"FAKE_AGENT_SCENARIO=" + scenarioPath}, + }) + if err == nil || !strings.Contains(err.Error(), "escapes review worktree") { + t.Fatalf("expected escaping review symlink rejection, got %v", err) + } +} + +func TestSpawn_ContainsReviewerFromSourceWorktree(t *testing.T) { + worktree := agentWorktree(t) + sourceFile := filepath.Join(worktree, "source.txt") + if err := os.WriteFile(sourceFile, []byte("source-only\n"), 0o600); err != nil { + t.Fatalf("write source fixture: %v", err) + } + gitAgent(t, worktree, "add", "source.txt") + gitAgent(t, worktree, "commit", "-q", "-m", "add source fixture") + marker := filepath.Join(t.TempDir(), "source-mutated") + script := filepath.Join(t.TempDir(), "containment-codex") + contents := strings.Join([]string{ + "#!/bin/sh", + "set -eu", + "if cat \"$STRICT_CODEX_SOURCE/source.txt\" >/dev/null 2>&1; then exit 41; fi", + "if chmod -R u+w \"$STRICT_CODEX_SOURCE\" 2>/dev/null; then", + " if : > \"$STRICT_CODEX_SOURCE/source-mutated\" 2>/dev/null; then printf mutated > \"$STRICT_CODEX_MARKER\"; exit 42; fi", + "fi", + "printf '%s\\n' '{\"findings\":[]}'", + "", + }, "\n") + if err := os.WriteFile(script, []byte(contents), 0o700); err != nil { + t.Fatalf("write containment Codex fake: %v", err) + } + + findings, err := agent.Spawn(context.Background(), agent.KindCodex, agent.SpawnParams{ + WorktreePath: worktree, + BinaryPath: script, + ExtraEnv: []string{ + "STRICT_CODEX_SOURCE=" + worktree, + "STRICT_CODEX_MARKER=" + marker, + }, + }) + if err != nil { + t.Fatalf("Spawn should contain reviewer without changing source: %v", err) + } + if len(findings.Findings) != 0 { + t.Fatalf("expected empty findings, got %+v", findings) + } + if _, err := os.Stat(marker); !os.IsNotExist(err) { + t.Fatalf("reviewer escaped into source worktree: %v", err) + } + if _, err := os.Stat(filepath.Join(worktree, "source-mutated")); !os.IsNotExist(err) { + t.Fatalf("reviewer modified source worktree: %v", err) + } +} + +func shellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" +} diff --git a/internal/agent/reviewworktree.go b/internal/agent/reviewworktree.go new file mode 100644 index 0000000..263f73b --- /dev/null +++ b/internal/agent/reviewworktree.go @@ -0,0 +1,208 @@ +package agent + +import ( + "context" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "time" + + "github.com/douglasjarquin/made/internal/evidence" + "github.com/douglasjarquin/made/internal/exec" +) + +const ( + reviewPreparationTimeout = 2 * time.Minute + reviewPreparationLimit = 1 << 20 +) + +func prepareReviewWorktree(ctx context.Context, source string) (string, []string, []string, func(), error) { + source, err := filepath.Abs(source) + if err != nil { + return "", nil, nil, nil, fmt.Errorf("resolve source worktree: %w", err) + } + headResult, err := runReviewGit(ctx, source, "rev-parse", "--verify", "HEAD^{commit}") + if err != nil { + return "", nil, nil, nil, fmt.Errorf("read source HEAD: %w", err) + } + if headResult.ExitCode != 0 { + return "", nil, nil, nil, commandFailure("read source HEAD", headResult) + } + head := strings.TrimSpace(string(headResult.Stdout)) + if head == "" { + return "", nil, nil, nil, fmt.Errorf("read source HEAD returned an empty SHA") + } + commonResult, err := runReviewGit(ctx, source, "rev-parse", "--git-common-dir") + if err != nil { + return "", nil, nil, nil, fmt.Errorf("read source Git common directory: %w", err) + } + if commonResult.ExitCode != 0 { + return "", nil, nil, nil, commandFailure("read source Git common directory", commonResult) + } + protectedPaths, err := reviewProtectedPaths(source, strings.TrimSpace(string(commonResult.Stdout))) + if err != nil { + return "", nil, nil, nil, fmt.Errorf("resolve review protected paths: %w", err) + } + + tempRoot, err := os.MkdirTemp("", "made-review-worktree-") + if err != nil { + return "", nil, nil, nil, fmt.Errorf("create review worktree directory: %w", err) + } + reviewPath := filepath.Join(tempRoot, "repo") + cleanupTemp := func() { _ = os.RemoveAll(tempRoot) } + + cloneResult, err := runReviewGit(ctx, "", "clone", "--no-local", "--no-hardlinks", "--no-checkout", source, reviewPath) + if err != nil { + cleanupTemp() + return "", nil, nil, nil, fmt.Errorf("clone review worktree: %w", err) + } + if cloneResult.ExitCode != 0 { + cleanupTemp() + return "", nil, nil, nil, commandFailure("clone review worktree", cloneResult) + } + checkoutResult, err := runReviewGit(ctx, reviewPath, "checkout", "--detach", "--quiet", head) + if err != nil { + cleanupTemp() + return "", nil, nil, nil, fmt.Errorf("checkout review HEAD: %w", err) + } + if checkoutResult.ExitCode != 0 { + cleanupTemp() + return "", nil, nil, nil, commandFailure("checkout review HEAD", checkoutResult) + } + clonedHead, err := runReviewGit(ctx, reviewPath, "rev-parse", "--verify", "HEAD^{commit}") + if err != nil { + cleanupTemp() + return "", nil, nil, nil, fmt.Errorf("verify review HEAD: %w", err) + } + if clonedHead.ExitCode != 0 || strings.TrimSpace(string(clonedHead.Stdout)) != head { + cleanupTemp() + return "", nil, nil, nil, fmt.Errorf("review clone HEAD %q does not match source HEAD %q", strings.TrimSpace(string(clonedHead.Stdout)), head) + } + if err := rejectEscapingSymlinks(reviewPath); err != nil { + cleanupTemp() + return "", nil, nil, nil, fmt.Errorf("validate review worktree links: %w", err) + } + maskRoot, err := os.MkdirTemp(reviewPath, ".made-review-masks-") + if err != nil { + cleanupTemp() + return "", nil, nil, nil, fmt.Errorf("create review mask directory: %w", err) + } + maskPaths := make([]string, 0, len(protectedPaths)) + for index := range protectedPaths { + maskPath := filepath.Join(maskRoot, fmt.Sprintf("%d", index)) + if err := os.Mkdir(maskPath, 0o700); err != nil { + cleanupTemp() + return "", nil, nil, nil, fmt.Errorf("create review mask %d: %w", index, err) + } + maskPaths = append(maskPaths, maskPath) + } + restoreModes, err := makeReviewTreeReadOnly(reviewPath) + if err != nil { + cleanupTemp() + return "", nil, nil, nil, fmt.Errorf("make review worktree read-only: %w", err) + } + cleanup := func() { + restoreModes() + cleanupTemp() + } + return reviewPath, protectedPaths, maskPaths, cleanup, nil +} + +func reviewProtectedPaths(source, commonDir string) ([]string, error) { + paths := make([]string, 0, 2) + for _, path := range []string{source, commonDir} { + if path == "" { + return nil, fmt.Errorf("git common directory is empty") + } + if !filepath.IsAbs(path) { + path = filepath.Join(source, path) + } + resolved, err := filepath.EvalSymlinks(path) + if err != nil { + return nil, err + } + paths = append(paths, resolved) + } + return paths, nil +} + +func runReviewGit(ctx context.Context, dir string, args ...string) (*exec.Result, error) { + if dir != "" { + args = append([]string{"-C", dir}, args...) + } + return exec.Run(ctx, exec.Command{ + Name: "git", + Args: args, + Env: reviewEnvironmentForDir(nil, dir), + Timeout: reviewPreparationTimeout, + OutputLimit: reviewPreparationLimit, + }) +} + +func commandFailure(label string, result *exec.Result) error { + return fmt.Errorf("%s exited %d: stdout=%s stderr=%s", label, result.ExitCode, evidence.RedactString(string(result.Stdout)), evidence.RedactString(string(result.Stderr))) +} + +func rejectEscapingSymlinks(root string) error { + root, err := filepath.EvalSymlinks(root) + if err != nil { + return err + } + return filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.Type()&os.ModeSymlink == 0 { + return nil + } + target, err := filepath.EvalSymlinks(path) + if err != nil { + return fmt.Errorf("resolve symlink %q: %w", path, err) + } + rel, err := filepath.Rel(root, target) + if err != nil { + return fmt.Errorf("relativize symlink %q: %w", path, err) + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return fmt.Errorf("symlink %q escapes review worktree", path) + } + return nil + }) +} + +func makeReviewTreeReadOnly(root string) (func(), error) { + originalModes := make(map[string]os.FileMode) + restore := func() { + for path, mode := range originalModes { + _ = os.Chmod(path, mode.Perm()) + } + } + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.Type()&os.ModeSymlink != 0 { + return nil + } + info, err := os.Lstat(path) + if err != nil { + return err + } + originalModes[path] = info.Mode() + mode := info.Mode().Perm() &^ 0o222 + if entry.IsDir() { + mode = 0o555 + } + if err := os.Chmod(path, mode); err != nil { + return err + } + return nil + }) + if err != nil { + restore() + return nil, err + } + return restore, nil +} diff --git a/internal/agent/spawn.go b/internal/agent/spawn.go index 6c60fa6..f542f6c 100644 --- a/internal/agent/spawn.go +++ b/internal/agent/spawn.go @@ -1,12 +1,17 @@ package agent import ( + "bufio" + "bytes" "context" "encoding/json" "fmt" "os" + "path/filepath" + "strings" "time" + "github.com/douglasjarquin/made/internal/evidence" "github.com/douglasjarquin/made/internal/exec" ) @@ -17,29 +22,175 @@ type SpawnParams struct { Timeout time.Duration } +const defaultSpawnTimeout = 30 * time.Minute + func Spawn(ctx context.Context, kind Kind, params SpawnParams) (Findings, error) { binary := params.BinaryPath if binary == "" { binary = kind.binaryName() } + reviewPath, protectedPaths, maskPaths, cleanupReview, err := prepareReviewWorktree(ctx, params.WorktreePath) + if err != nil { + return Findings{}, fmt.Errorf("agent: prepare read-only review worktree: %w", err) + } + defer cleanupReview() + + args, cleanup, err := invocation(kind, reviewPath) + if err != nil { + return Findings{}, err + } + defer cleanup() + commandName, commandArgs, err := containedInvocation(binary, args, reviewPath, protectedPaths, maskPaths) + if err != nil { + return Findings{}, fmt.Errorf("agent: contain review process: %w", err) + } + timeout := params.Timeout + if timeout <= 0 { + timeout = defaultSpawnTimeout + } result, err := exec.Run(ctx, exec.Command{ - Name: binary, - Args: []string{"review", "--worktree", params.WorktreePath}, - Dir: params.WorktreePath, - Env: append(os.Environ(), params.ExtraEnv...), - Timeout: params.Timeout, + Name: commandName, + Args: commandArgs, + Dir: reviewPath, + Env: reviewEnvironmentForDir(params.ExtraEnv, reviewPath), + Stdin: []byte("Return only the Made review JSON object matching the supplied schema.\n"), + Timeout: timeout, }) if err != nil { return Findings{}, fmt.Errorf("agent: spawn %s (%s): %w", kind, binary, err) } if result.ExitCode != 0 { - return Findings{}, fmt.Errorf("agent: %s (%s) exited %d: %s", kind, binary, result.ExitCode, result.Stderr) + return Findings{}, fmt.Errorf("agent: %s (%s) exited %d: %s", kind, binary, result.ExitCode, evidence.RedactString(string(result.Stderr))) + } + + findings, err := decodeFindings(result.Stdout) + if err != nil { + return Findings{}, fmt.Errorf("agent: parse findings from %s: %w: stdout=%s", kind, err, evidence.RedactString(string(result.Stdout))) + } + return findings, nil +} + +func reviewEnvironmentForDir(extra []string, dir string) []string { + filtered := make([]string, 0, len(os.Environ())+len(extra)) + for _, entry := range os.Environ() { + name, _, ok := strings.Cut(entry, "=") + if ok && !sensitiveEnvironmentName(name) && !reviewPathEnvironmentName(name) && (dir == "" || name != "PWD") { + filtered = append(filtered, entry) + } + } + for _, entry := range extra { + name, _, ok := strings.Cut(entry, "=") + if ok && !sensitiveEnvironmentName(name) && !reviewPathEnvironmentName(name) && (dir == "" || name != "PWD") { + filtered = append(filtered, entry) + } + } + if dir != "" { + filtered = append(filtered, "PWD="+dir) } + filtered = append(filtered, "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null") + return filtered +} +func reviewPathEnvironmentName(name string) bool { + return name == "OLDPWD" || strings.HasPrefix(name, "GIT_") +} + +func sensitiveEnvironmentName(name string) bool { + upper := strings.ToUpper(name) + if upper == "SSH_AUTH_SOCK" || upper == "COOKIE" { + return true + } + for _, marker := range []string{"TOKEN", "SECRET", "PASSWORD", "PASSWD", "API_KEY", "PRIVATE_KEY", "CREDENTIAL"} { + if strings.Contains(upper, marker) { + return true + } + } + return false +} + +func invocation(kind Kind, worktree string) ([]string, func(), error) { + if kind != KindCodex { + return []string{"review", "--worktree", worktree}, func() {}, nil + } + dir, err := os.MkdirTemp("", "made-codex-schema-") + if err != nil { + return nil, nil, fmt.Errorf("agent: create Codex schema directory: %w", err) + } + path := filepath.Join(dir, "output.json") + if err := os.WriteFile(path, []byte(reviewSchema), 0o600); err != nil { + _ = os.RemoveAll(dir) + return nil, nil, fmt.Errorf("agent: write Codex output schema: %w", err) + } + return []string{"exec", "--cd", worktree, "--json", "--output-schema", path, "-"}, func() { _ = os.RemoveAll(dir) }, nil +} + +func decodeFindings(data []byte) (Findings, error) { + var direct Findings + if err := json.Unmarshal(data, &direct); err == nil { + var envelope map[string]json.RawMessage + if json.Unmarshal(data, &envelope) == nil { + if raw, ok := envelope["findings"]; ok { + if string(raw) == "null" { + return Findings{Findings: []Finding{}}, nil + } + if values, err := strictFindings(data); err == nil { + return values, nil + } + } + } + } + scanner := bufio.NewScanner(bytes.NewReader(data)) + scanner.Buffer(make([]byte, 4096), 4*1024*1024) + var last string + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "{") { + var event struct { + Item struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"item"` + } + if json.Unmarshal([]byte(line), &event) == nil && event.Item.Type == "agent_message" { + last = event.Item.Text + } + } + } + if err := scanner.Err(); err != nil { + return Findings{}, err + } + if last == "" { + return Findings{}, fmt.Errorf("structured findings payload was not found") + } + return strictFindings([]byte(last)) +} + +func strictFindings(data []byte) (Findings, error) { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() var findings Findings - if err := json.Unmarshal(result.Stdout, &findings); err != nil { - return Findings{}, fmt.Errorf("agent: parse findings from %s: %w: stdout=%s", kind, err, result.Stdout) + if err := decoder.Decode(&findings); err != nil { + return Findings{}, err + } + for _, finding := range findings.Findings { + if finding.Description == "" { + return Findings{}, fmt.Errorf("finding description is required") + } + switch finding.Kind { + case FindingAutoFixable: + if strings.TrimSpace(finding.Patch) == "" { + return Findings{}, fmt.Errorf("auto-fixable finding patch is required") + } + if len(finding.Paths) == 0 { + return Findings{}, fmt.Errorf("auto-fixable finding paths are required") + } + case FindingAskUser, FindingBlocking: + default: + return Findings{}, fmt.Errorf("unknown finding kind %q", finding.Kind) + } } return findings, nil } + +const reviewSchema = `{"type":"object","additionalProperties":false,"required":["findings"],"properties":{"findings":{"type":"array","items":{"type":"object","additionalProperties":false,"required":["kind","description"],"properties":{"kind":{"type":"string","enum":["auto-fixable","ask-user","blocking"]},"description":{"type":"string"},"patch":{"type":"string"},"paths":{"type":"array","items":{"type":"string"}}}}}}}` diff --git a/internal/agent/testdata/fakeagent/main.go b/internal/agent/testdata/fakeagent/main.go index 308f70a..0d0e546 100644 --- a/internal/agent/testdata/fakeagent/main.go +++ b/internal/agent/testdata/fakeagent/main.go @@ -22,6 +22,14 @@ func main() { os.Exit(1) } + if path := os.Getenv("FAKE_AGENT_WRITE_PATH"); path != "" { + data := []byte(os.Getenv("FAKE_AGENT_WRITE_DATA")) + if err := os.WriteFile(path, data, 0o600); err != nil { + fmt.Fprintf(os.Stderr, "fakeagent: write requested path %s: %v\n", path, err) + os.Exit(1) + } + } + scenarioPath := os.Getenv("FAKE_AGENT_SCENARIO") if scenarioPath == "" { fmt.Fprintln(os.Stderr, "fakeagent: FAKE_AGENT_SCENARIO env var is required") diff --git a/internal/api/envelope.go b/internal/api/envelope.go index 0e7ab46..aaa1f0f 100644 --- a/internal/api/envelope.go +++ b/internal/api/envelope.go @@ -10,6 +10,7 @@ const Version = 1 const ( ErrProtocolMismatch = "protocol_mismatch" + ErrInvalidRequest = "invalid_request" ErrUnknownMethod = "unknown_method" ErrHandlerFailed = "handler_error" ErrInternal = "internal_error" diff --git a/internal/api/remediation_contract_test.go b/internal/api/remediation_contract_test.go new file mode 100644 index 0000000..a5fd710 --- /dev/null +++ b/internal/api/remediation_contract_test.go @@ -0,0 +1,330 @@ +package api_test + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "net" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/douglasjarquin/made/internal/api" +) + +func TestServer_RefusesExistingNonSocketPaths(t *testing.T) { + cases := []struct { + name string + make func(t *testing.T, path string) + keep func(t *testing.T, path string) + }{ + { + name: "regular file", + make: func(t *testing.T, path string) { + t.Helper() + if err := os.WriteFile(path, []byte("owner data"), 0o600); err != nil { + t.Fatalf("write regular file: %v", err) + } + }, + keep: func(t *testing.T, path string) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read preserved regular file: %v", err) + } + if string(data) != "owner data" { + t.Fatalf("regular file contents changed to %q", data) + } + }, + }, + { + name: "symlink", + make: func(t *testing.T, path string) { + t.Helper() + target := filepath.Join(filepath.Dir(path), "socket-target") + if err := os.WriteFile(target, []byte("target"), 0o600); err != nil { + t.Fatalf("write symlink target: %v", err) + } + if err := os.Symlink(target, path); err != nil { + t.Fatalf("create symlink: %v", err) + } + }, + keep: func(t *testing.T, path string) { + t.Helper() + info, err := os.Lstat(path) + if err != nil { + t.Fatalf("lstat preserved symlink: %v", err) + } + if info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("path mode %v is no longer a symlink", info.Mode()) + } + }, + }, + { + name: "directory", + make: func(t *testing.T, path string) { + t.Helper() + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatalf("make directory: %v", err) + } + if err := os.WriteFile(filepath.Join(path, "owner-data"), []byte("keep"), 0o600); err != nil { + t.Fatalf("write directory marker: %v", err) + } + }, + keep: func(t *testing.T, path string) { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat preserved directory: %v", err) + } + if !info.IsDir() { + t.Fatalf("path mode %v is no longer a directory", info.Mode()) + } + if _, err := os.Stat(filepath.Join(path, "owner-data")); err != nil { + t.Fatalf("directory marker was removed: %v", err) + } + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "daemon.sock") + tc.make(t, path) + + srv := api.NewServer(path) + err := srv.Listen() + if err == nil { + _ = srv.Close() + t.Fatal("Listen accepted an existing non-socket path") + } + tc.keep(t, path) + }) + } +} + +func TestServer_RedactsHandlerErrors(t *testing.T) { + server, client := startTestServer(t) + server.Handle("secret-error", func(context.Context, json.RawMessage) (any, error) { + return nil, errors.New("request failed: token=handler-secret") + }) + + _, err := client.Call("secret-error", nil) + if err == nil { + t.Fatal("expected handler error") + } + if strings.Contains(err.Error(), "handler-secret") { + t.Fatalf("API error response retained sensitive handler text: %v", err) + } +} + +func TestServerRejectsUnknownEnvelopeFields(t *testing.T) { + path := filepath.Join(tempSocketDir(t), "daemon.sock") + server := api.NewServer(path) + if err := server.Listen(); err != nil { + t.Fatalf("Listen: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + defer func() { _ = server.Close() }() + go func() { _ = server.Serve(ctx) }() + + conn, err := net.DialTimeout("unix", path, time.Second) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer func() { _ = conn.Close() }() + if _, err := conn.Write([]byte(`{"made.protocol":1,"id":"strict-1","method":"ping","unexpected":true}`)); err != nil { + t.Fatalf("write request: %v", err) + } + if err := conn.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatalf("set read deadline: %v", err) + } + var response api.Response + if err := json.NewDecoder(conn).Decode(&response); err != nil { + t.Fatalf("decode strict rejection response: %v", err) + } + if response.Error == nil || response.Error.Code != api.ErrInvalidRequest { + t.Fatalf("unknown envelope field response = %+v, want %q", response, api.ErrInvalidRequest) + } +} + +func TestServerRejectsUnknownPingParams(t *testing.T) { + path := filepath.Join(tempSocketDir(t), "daemon.sock") + server := api.NewServer(path) + if err := server.Listen(); err != nil { + t.Fatalf("Listen: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + defer func() { _ = server.Close() }() + go func() { _ = server.Serve(ctx) }() + + conn, err := net.DialTimeout("unix", path, time.Second) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer func() { _ = conn.Close() }() + if _, err := conn.Write([]byte(`{"made.protocol":1,"id":"ping-strict","method":"ping","params":{"unexpected":true}}`)); err != nil { + t.Fatalf("write request: %v", err) + } + var response api.Response + if err := json.NewDecoder(conn).Decode(&response); err != nil { + t.Fatalf("decode strict rejection response: %v", err) + } + if response.Error == nil { + t.Fatalf("ping accepted unknown params: %+v", response) + } +} + +func TestServerRejectsOversizedRequestLine(t *testing.T) { + path := filepath.Join(tempSocketDir(t), "daemon.sock") + server := api.NewServer(path) + if err := server.Listen(); err != nil { + t.Fatalf("Listen: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + defer func() { _ = server.Close() }() + go func() { _ = server.Serve(ctx) }() + + conn, err := net.DialTimeout("unix", path, time.Second) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer func() { _ = conn.Close() }() + request := "{\"protocol\":1,\"id\":\"oversized\",\"method\":\"ping\",\"params\":\"" + strings.Repeat("x", 2<<20) + "\"}\n" + if _, err := conn.Write([]byte(request)); err != nil { + return + } + if err := conn.SetReadDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatalf("set read deadline: %v", err) + } + var response api.Response + if err := json.NewDecoder(conn).Decode(&response); err == nil { + t.Fatal("server accepted an oversized request line") + } +} + +func TestServerClosesStalledInputConnection(t *testing.T) { + path := filepath.Join(tempSocketDir(t), "daemon.sock") + server := api.NewServer(path) + if err := server.Listen(); err != nil { + t.Fatalf("Listen: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + defer func() { _ = server.Close() }() + go func() { _ = server.Serve(ctx) }() + + conn, err := net.DialTimeout("unix", path, time.Second) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer func() { _ = conn.Close() }() + if err := conn.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { + t.Fatalf("set read deadline: %v", err) + } + var response [1]byte + if _, err := conn.Read(response[:]); err == nil || errors.Is(err, os.ErrDeadlineExceeded) { + t.Fatalf("stalled input connection was not closed by server: %v", err) + } +} + +func TestServer_DuplicateListenPreservesOriginalOwner(t *testing.T) { + path := filepath.Join(tempSocketDir(t), "daemon.sock") + first := api.NewServer(path) + if err := first.Listen(); err != nil { + t.Fatalf("first Listen: %v", err) + } + defer func() { _ = first.Close() }() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + go func() { _ = first.Serve(ctx) }() + + if err := waitForPing(path); err != nil { + t.Fatalf("first server did not answer ping: %v", err) + } + + second := api.NewServer(path) + if err := second.Listen(); err == nil { + _ = second.Close() + t.Fatal("duplicate Listen unexpectedly acquired the original socket path") + } + if err := waitForPing(path); err != nil { + t.Fatalf("original server became unreachable after duplicate Listen: %v", err) + } +} + +func TestPrepareSocket_PreservesLiveOwnerSocket(t *testing.T) { + path := filepath.Join(tempSocketDir(t), "daemon.sock") + listener, err := net.Listen("unix", path) + if err != nil { + t.Fatalf("listen live owner socket: %v", err) + } + defer func() { _ = listener.Close() }() + + if err := api.PrepareSocket(path); err == nil { + t.Fatal("PrepareSocket replaced a live owner socket") + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("live owner socket was not preserved: %v", err) + } + conn, err := net.DialTimeout("unix", path, 100*time.Millisecond) + if err != nil { + t.Fatalf("preserved live owner socket is not reachable: %v", err) + } + _ = conn.Close() +} + +func TestServer_CloseDoesNotRemoveSuccessorSocket(t *testing.T) { + path := filepath.Join(tempSocketDir(t), "daemon.sock") + first := api.NewServer(path) + if err := first.Listen(); err != nil { + t.Fatalf("first Listen: %v", err) + } + if err := os.Remove(path); err != nil { + t.Fatalf("remove first socket for handoff fixture: %v", err) + } + second := api.NewServer(path) + if err := second.Listen(); err != nil { + t.Fatalf("successor Listen: %v", err) + } + defer func() { _ = second.Close() }() + secondCtx, secondCancel := context.WithTimeout(context.Background(), time.Second) + defer secondCancel() + go func() { _ = second.Serve(secondCtx) }() + + if err := first.Close(); err != nil { + t.Fatalf("first Close: %v", err) + } + if err := waitForPing(path); err != nil { + t.Fatalf("successor socket was removed by first Close: %v", err) + } +} + +func waitForPing(path string) error { + conn, err := net.DialTimeout("unix", path, 100*time.Millisecond) + if err != nil { + return err + } + defer func() { _ = conn.Close() }() + if err := conn.SetDeadline(time.Now().Add(100 * time.Millisecond)); err != nil { + return err + } + if err := json.NewEncoder(conn).Encode(api.Request{Protocol: api.Version, ID: "ping", Method: "ping"}); err != nil { + return err + } + var response api.Response + if err := json.NewDecoder(bufio.NewReader(conn)).Decode(&response); err != nil { + return err + } + if response.Error != nil { + return errors.New(response.Error.Error()) + } + return nil +} diff --git a/internal/api/scenario_demo_test.go b/internal/api/scenario_demo_test.go index dccd87f..0d82c07 100644 --- a/internal/api/scenario_demo_test.go +++ b/internal/api/scenario_demo_test.go @@ -4,9 +4,8 @@ import ( "encoding/json" "fmt" "net" - "os/exec" + "os" "path/filepath" - "strings" "testing" "github.com/douglasjarquin/made/internal/api" @@ -22,14 +21,14 @@ func TestScenarioDemo_SocketPermissions(t *testing.T) { } t.Cleanup(func() { _ = srv.Close() }) - out, err := exec.Command("stat", "-f", "%Sp %Su", socketPath).CombinedOutput() + info, err := os.Stat(socketPath) if err != nil { - t.Fatalf("stat: %v: %s", err, out) + t.Fatalf("stat: %v", err) } - fmt.Printf("$ stat -f \"%%Sp %%Su\" %s\n%s", socketPath, out) + fmt.Printf("$ inspect Unix socket %s\nmode=%#o type=%s\n", socketPath, info.Mode().Perm(), info.Mode().Type()) - if !strings.HasPrefix(strings.TrimSpace(string(out)), "srw-------") { - t.Fatalf("expected owner-only socket permissions srw-------, got: %s", out) + if info.Mode()&os.ModeSocket == 0 || info.Mode().Perm() != 0o600 { + t.Fatalf("expected owner-only Unix socket mode 0600, got mode=%#o type=%s", info.Mode().Perm(), info.Mode().Type()) } fmt.Println("=== RESULT ===") fmt.Println("PASS: socket created with mode 0600 (srw-------), owner-only") diff --git a/internal/api/server.go b/internal/api/server.go index 5dcbada..2239831 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -1,13 +1,26 @@ package api import ( + "bufio" + "bytes" "context" "encoding/json" "errors" "fmt" + "io" "net" "os" "sync" + "syscall" + "time" + + "github.com/douglasjarquin/made/internal/evidence" +) + +const ( + maxRequestBytes = 1 << 20 + maxConcurrentConnections = 64 + requestReadTimeout = time.Second ) type HandlerFunc func(ctx context.Context, params json.RawMessage) (any, error) @@ -15,15 +28,18 @@ type HandlerFunc func(ctx context.Context, params json.RawMessage) (any, error) type Server struct { socketPath string ln net.Listener + socketInfo os.FileInfo mu sync.RWMutex handlers map[string]HandlerFunc + slots chan struct{} } func NewServer(socketPath string) *Server { s := &Server{ socketPath: socketPath, handlers: make(map[string]HandlerFunc), + slots: make(chan struct{}, maxConcurrentConnections), } s.Handle("ping", handlePing) return s @@ -39,8 +55,10 @@ func (s *Server) Handle(method string, h HandlerFunc) { // is made's entire auth model for this socket, matching herdr's own // filesystem-permission-only model, so there is no separate credential check. func (s *Server) Listen() error { - if err := os.RemoveAll(s.socketPath); err != nil && !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("remove stale socket %s: %w", s.socketPath, err) + if info, err := os.Lstat(s.socketPath); err == nil { + return fmt.Errorf("api: socket path %s already exists as %s", s.socketPath, info.Mode()) + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("api: inspect socket path %s: %w", s.socketPath, err) } ln, err := net.Listen("unix", s.socketPath) @@ -51,11 +69,86 @@ func (s *Server) Listen() error { _ = ln.Close() return fmt.Errorf("chmod %s: %w", s.socketPath, err) } + if unixListener, ok := ln.(*net.UnixListener); ok { + unixListener.SetUnlinkOnClose(false) + } s.ln = ln + info, err := os.Lstat(s.socketPath) + if err != nil { + _ = ln.Close() + return fmt.Errorf("stat listened socket %s: %w", s.socketPath, err) + } + s.socketInfo = info + return nil +} + +func PrepareSocket(socketPath string) error { + info, err := os.Lstat(socketPath) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("api: inspect stale socket %s: %w", socketPath, err) + } + if info.Mode()&os.ModeSymlink != 0 || info.IsDir() { + return fmt.Errorf("api: refusing non-owned socket path %s with mode %s", socketPath, info.Mode()) + } + if info.Mode()&os.ModeSocket == 0 { + return fmt.Errorf("api: refusing regular socket path %s", socketPath) + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || uint32(stat.Uid) != uint32(os.Getuid()) { + return fmt.Errorf("api: refusing socket path %s owned by another user", socketPath) + } + live, err := socketIsLive(socketPath) + if err != nil { + return fmt.Errorf("api: cannot prove owner socket %s is stale: %w", socketPath, err) + } + if live { + return fmt.Errorf("api: refusing to replace live owner socket %s", socketPath) + } + current, err := os.Lstat(socketPath) + if err != nil { + return fmt.Errorf("api: recheck stale socket %s: %w", socketPath, err) + } + if !os.SameFile(info, current) { + return fmt.Errorf("api: refusing to remove replaced socket path %s", socketPath) + } + quarantine := fmt.Sprintf("%s.stale-%d", socketPath, time.Now().UnixNano()) + if _, err := os.Lstat(quarantine); err == nil { + return fmt.Errorf("api: refusing occupied stale-socket quarantine path %s", quarantine) + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("api: inspect stale-socket quarantine path %s: %w", quarantine, err) + } + if err := os.Rename(socketPath, quarantine); err != nil { + return fmt.Errorf("api: quarantine stale owner socket %s: %w", socketPath, err) + } + quarantined, err := os.Lstat(quarantine) + if err != nil { + return fmt.Errorf("api: inspect quarantined socket %s: %w", quarantine, err) + } + if !os.SameFile(info, quarantined) { + return fmt.Errorf("api: refusing to remove replaced socket path %s", socketPath) + } + if err := os.Remove(quarantine); err != nil { + return fmt.Errorf("api: remove quarantined stale owner socket %s: %w", quarantine, err) + } return nil } +func socketIsLive(socketPath string) (bool, error) { + conn, err := net.DialTimeout("unix", socketPath, 100*time.Millisecond) + if err == nil { + _ = conn.Close() + return true, nil + } + if errors.Is(err, syscall.ECONNREFUSED) { + return false, nil + } + return false, err +} + func (s *Server) Serve(ctx context.Context) error { if s.ln == nil { return errors.New("api: Listen must be called before Serve") @@ -76,7 +169,12 @@ func (s *Server) Serve(ctx context.Context) error { return fmt.Errorf("accept: %w", err) } } - go s.serveConn(ctx, conn) + select { + case s.slots <- struct{}{}: + go s.serveConn(ctx, conn) + default: + _ = conn.Close() + } } } @@ -85,26 +183,160 @@ func (s *Server) Close() error { return nil } err := s.ln.Close() - _ = os.Remove(s.socketPath) + if info, statErr := os.Lstat(s.socketPath); statErr == nil && os.SameFile(s.socketInfo, info) && info.Mode()&os.ModeSocket != 0 { + _ = os.Remove(s.socketPath) + } return err } func (s *Server) serveConn(ctx context.Context, conn net.Conn) { - defer func() { _ = conn.Close() }() + defer func() { + _ = conn.Close() + <-s.slots + }() - dec := json.NewDecoder(conn) + reader := bufio.NewReader(conn) enc := json.NewEncoder(conn) for { - var req Request - if err := dec.Decode(&req); err != nil { + if err := conn.SetReadDeadline(time.Now().Add(requestReadTimeout)); err != nil { + return + } + value, err := readRequestValue(reader, maxRequestBytes) + if err != nil { + return + } + if err := conn.SetReadDeadline(time.Time{}); err != nil { return } + if len(value) == 0 { + continue + } + req, err := decodeRequest(value) + if err != nil { + var envelope struct { + ID string `json:"id"` + } + _ = json.Unmarshal(value, &envelope) + if err := enc.Encode(errorResponse(envelope.ID, ErrInvalidRequest, "invalid request")); err != nil { + return + } + continue + } if err := enc.Encode(s.dispatch(ctx, req)); err != nil { return } } } +func decodeRequest(value []byte) (Request, error) { + decoder := json.NewDecoder(bytes.NewReader(value)) + decoder.DisallowUnknownFields() + var req Request + if err := decoder.Decode(&req); err != nil { + return Request{}, err + } + return req, nil +} + +func readRequestValue(reader *bufio.Reader, maxBytes int) ([]byte, error) { + value := make([]byte, 0, 4096) + started := false + depth := 0 + inString := false + escaped := false + scalar := false + for { + b, err := reader.ReadByte() + if err != nil { + if errors.Is(err, io.EOF) && len(value) > 0 { + return nil, io.ErrUnexpectedEOF + } + return nil, err + } + value = append(value, b) + if len(value) > maxBytes { + return nil, fmt.Errorf("api: request exceeds %d bytes", maxBytes) + } + if !started { + if isJSONSpace(b) { + continue + } + started = true + switch b { + case '{', '[': + depth = 1 + case '"': + inString = true + scalar = true + default: + scalar = true + } + continue + } + if inString { + if escaped { + escaped = false + } else if b == '\\' { + escaped = true + } else if b == '"' { + inString = false + if scalar { + return value, nil + } + } + continue + } + if scalar { + if isJSONSpace(b) { + return value[:len(value)-1], nil + } + continue + } + switch b { + case '"': + inString = true + case '{', '[': + depth++ + case '}', ']': + depth-- + if depth == 0 { + return value, nil + } + } + } +} + +func validateEmptyParams(params json.RawMessage) error { + trimmed := bytes.TrimSpace(params) + if len(trimmed) == 0 { + return nil + } + if trimmed[0] != '{' { + return fmt.Errorf("params must be a JSON object") + } + decoder := json.NewDecoder(bytes.NewReader(trimmed)) + decoder.DisallowUnknownFields() + var fields map[string]json.RawMessage + if err := decoder.Decode(&fields); err != nil { + return err + } + var extra json.RawMessage + if err := decoder.Decode(&extra); err != io.EOF { + if err == nil { + return fmt.Errorf("params contain multiple JSON values") + } + return err + } + if len(fields) != 0 { + return fmt.Errorf("params contain unknown fields") + } + return nil +} + +func isJSONSpace(b byte) bool { + return b == ' ' || b == '\t' || b == '\r' || b == '\n' +} + // dispatch enforces made's exact-match protocol version policy - mirroring // herdr's own check_client_version - before it ever looks up a handler. // Client/daemon version skew is a correctness risk made controls @@ -135,13 +367,16 @@ func (s *Server) dispatch(ctx context.Context, req Request) Response { } func errorResponse(id, code, message string) Response { - return Response{Protocol: Version, ID: id, Error: &Error{Code: code, Message: message}} + return Response{Protocol: Version, ID: id, Error: &Error{Code: code, Message: evidence.RedactString(message)}} } type pingResult struct { Message string `json:"message"` } -func handlePing(context.Context, json.RawMessage) (any, error) { +func handlePing(_ context.Context, params json.RawMessage) (any, error) { + if err := validateEmptyParams(params); err != nil { + return nil, fmt.Errorf("ping: invalid params: %w", err) + } return pingResult{Message: "pong"}, nil } diff --git a/internal/config/config.go b/internal/config/config.go index df6ecb2..fd5cfa0 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,26 +1,88 @@ package config import ( + "bytes" + "errors" "fmt" + "io" "os" + "path/filepath" + "strings" + "time" "github.com/douglasjarquin/made/internal/agent" "gopkg.in/yaml.v3" ) -const defaultCIRerunBudget = 2 +const ( + defaultCIRerunBudget = 2 + defaultStageTimeout = 30 * time.Minute + maxStageTimeoutSeconds = 2 * 60 * 60 + defaultEvidenceRetention = 4 << 20 + maxEvidenceRetention = 64 << 20 + maxConfigBytes = 1 << 20 +) + +var validStageNames = map[string]struct{}{ + "intent": {}, "rebase": {}, "review": {}, "test": {}, "document": {}, + "lint": {}, "push": {}, "pr": {}, "ci": {}, +} type Config struct { - Document Document `yaml:"document"` - Review Review `yaml:"review"` - DisableProjectSettings bool `yaml:"disable_project_settings"` - NoCI bool `yaml:"no_ci"` - CI CI `yaml:"ci"` - Test Test `yaml:"test"` - Commands Commands `yaml:"commands"` - Agent string `yaml:"agent"` - Agents []string `yaml:"agents"` - AllowRepoCommands bool `yaml:"allow_repo_commands"` + Version int `yaml:"version"` + Document Document `yaml:"document"` + Review Review `yaml:"review"` + DisableProjectSettings bool `yaml:"disable_project_settings"` + NoCI bool `yaml:"no_ci"` + CI CI `yaml:"ci"` + Test Test `yaml:"test"` + Commands Commands `yaml:"commands"` + Agent string `yaml:"agent"` + Agents []string `yaml:"agents"` + AllowRepoCommands bool `yaml:"allow_repo_commands"` + Stages map[string]Stage `yaml:"stages"` +} + +type Stage struct { + Enabled *bool `yaml:"enabled"` + TimeoutSeconds *int `yaml:"timeout_seconds"` +} + +func (c Config) StageTimeout(name string) time.Duration { + stage := c.Stages[name] + if stage.TimeoutSeconds == nil { + return defaultStageTimeout + } + return time.Duration(*stage.TimeoutSeconds) * time.Second +} + +func (c Config) EvidenceRetentionBytes() int { + if c.Test.Evidence.RetentionBytes == nil { + return defaultEvidenceRetention + } + return *c.Test.Evidence.RetentionBytes +} + +func (c Config) StageResult(name string) string { + if name == "ci" && c.NoCI { + return "skipped" + } + stage, ok := c.Stages[name] + if ok && stage.Enabled != nil && !*stage.Enabled { + return "skipped" + } + return "pending" +} + +func (c Config) StageRequired(name string) bool { + switch name { + case "review": + return c.Review.Required + case "ci": + return c.CI.Required && !c.NoCI + default: + return true + } } type Document struct { @@ -46,9 +108,10 @@ type Test struct { } type Evidence struct { - Branch string `yaml:"branch"` - StoreInRepo bool `yaml:"store_in_repo"` - Dir string `yaml:"dir"` + Branch string `yaml:"branch"` + StoreInRepo bool `yaml:"store_in_repo"` + Dir string `yaml:"dir"` + RetentionBytes *int `yaml:"retention_bytes"` } type Commands struct { @@ -72,12 +135,14 @@ func LoadEffectiveConfig(trustedPath, pushedPath string) (Config, error) { } effective := Config{ + Version: trusted.Version, Document: trusted.Document, Review: trusted.Review, DisableProjectSettings: trusted.DisableProjectSettings, NoCI: trusted.NoCI, CI: trusted.CI, AllowRepoCommands: trusted.AllowRepoCommands, + Stages: trusted.Stages, } effective.Test.Evidence = trusted.Test.Evidence @@ -134,17 +199,91 @@ func loadConfigFile(path string) (cfg Config, exists bool, err error) { return Config{}, false, nil } - data, err := os.ReadFile(path) + data, exists, err := readConfigBytes(path, nil) if err != nil { - if os.IsNotExist(err) { - return Config{}, false, nil - } - return Config{}, false, err + return Config{}, exists, err + } + if !exists { + return Config{}, false, nil } + if filepath.Base(path) == ".made.yml" || strings.HasSuffix(filepath.Base(path), ".made.yml") { + decoder := yaml.NewDecoder(bytes.NewReader(data)) + decoder.KnownFields(true) + if err := decoder.Decode(&cfg); err != nil { + return Config{}, true, err + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + if err == nil { + return Config{}, true, fmt.Errorf("versioned .made.yml must contain one document") + } + return Config{}, true, err + } + if cfg.Version != 1 { + return Config{}, true, fmt.Errorf("versioned .made.yml requires version: 1, got %d", cfg.Version) + } + for name := range cfg.Stages { + if _, ok := validStageNames[name]; !ok { + return Config{}, true, fmt.Errorf("versioned .made.yml has unknown stage %q", name) + } + stage := cfg.Stages[name] + if stage.TimeoutSeconds != nil && (*stage.TimeoutSeconds <= 0 || *stage.TimeoutSeconds > maxStageTimeoutSeconds) { + return Config{}, true, fmt.Errorf("versioned .made.yml stage %q timeout_seconds must be between 1 and %d", name, maxStageTimeoutSeconds) + } + } + if retention := cfg.Test.Evidence.RetentionBytes; retention != nil && (*retention <= 0 || *retention > maxEvidenceRetention) { + return Config{}, true, fmt.Errorf("versioned .made.yml test.evidence.retention_bytes must be between 1 and %d", maxEvidenceRetention) + } + if !cfg.hasConfiguredValue() { + return Config{}, true, fmt.Errorf("versioned .made.yml must configure at least one non-version field") + } + return cfg, true, nil + } if err := yaml.Unmarshal(data, &cfg); err != nil { return Config{}, true, err } return cfg, true, nil } + +func readConfigBytes(path string, beforeRead func()) ([]byte, bool, error) { + file, err := os.Open(path) + if errors.Is(err, os.ErrNotExist) { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + defer func() { _ = file.Close() }() + + info, err := file.Stat() + if err != nil { + return nil, true, err + } + if !info.Mode().IsRegular() { + return nil, true, fmt.Errorf("config: %s is not a regular file", path) + } + if info.Size() > maxConfigBytes { + return nil, true, fmt.Errorf("config: %s exceeds %d bytes", path, maxConfigBytes) + } + if beforeRead != nil { + beforeRead() + } + data, err := io.ReadAll(io.LimitReader(file, maxConfigBytes+1)) + if err != nil { + return nil, true, err + } + if len(data) > maxConfigBytes { + return nil, true, fmt.Errorf("config: %s exceeds %d bytes", path, maxConfigBytes) + } + return data, true, nil +} + +func (c Config) hasConfiguredValue() bool { + return len(c.Document.Rules) > 0 || c.Review.Required || c.DisableProjectSettings || c.NoCI || + c.CI.Required || c.CI.RerunBudget != 0 || len(c.Test.Evidence.Branch) > 0 || c.Test.Evidence.RetentionBytes != nil || + c.Test.Evidence.StoreInRepo || len(c.Test.Evidence.Dir) > 0 || len(c.Commands.Test) > 0 || + len(c.Commands.Lint) > 0 || len(c.Agent) > 0 || len(c.Agents) > 0 || c.AllowRepoCommands || + len(c.Stages) > 0 +} diff --git a/internal/config/remediation_contract_test.go b/internal/config/remediation_contract_test.go new file mode 100644 index 0000000..6537751 --- /dev/null +++ b/internal/config/remediation_contract_test.go @@ -0,0 +1,155 @@ +package config + +import ( + "os" + "strings" + "testing" + "time" +) + +func TestLoadConfig_RejectsUnknownMadeYMLFields(t *testing.T) { + path := writeConfigFile(t, t.TempDir(), ".made.yml", "version: 1\nunknown_field: true\n") + + if _, _, err := loadConfigFile(path); err == nil { + t.Fatal("loadConfigFile accepted an unknown .made.yml field") + } +} + +func TestLoadConfigRejectsOversizedMadeYML(t *testing.T) { + path := writeConfigFile(t, t.TempDir(), ".made.yml", "version: 1\nagent: "+strings.Repeat("a", 1<<20)+"\n") + if _, _, err := loadConfigFile(path); err == nil { + t.Fatal("loadConfigFile accepted an oversized .made.yml") + } +} + +func TestConfigReadUsesOpenedDescriptorWhenPathIsReplaced(t *testing.T) { + dir := t.TempDir() + path := writeConfigFile(t, dir, ".made.yml", "version: 1\nagent: original\n") + replacement := path + ".replacement" + original := "version: 1\nagent: original\n" + oversized := "version: 1\nagent: " + strings.Repeat("x", 1<<20) + "\n" + + data, exists, err := readConfigBytes(path, func() { + if err := os.WriteFile(replacement, []byte(oversized), 0o600); err != nil { + t.Fatalf("write replacement config: %v", err) + } + if err := os.Rename(replacement, path); err != nil { + t.Fatalf("replace config path: %v", err) + } + }) + if err != nil { + t.Fatalf("readConfigBytes: %v", err) + } + if !exists || string(data) != original { + t.Fatalf("readConfigBytes read replaced path: exists=%v data prefix=%q", exists, string(data)[:min(len(data), 32)]) + } +} + +func TestLoadConfig_RejectsUnknownFieldsInTrustedMadeYMLCopy(t *testing.T) { + path := writeConfigFile(t, t.TempDir(), "trusted-copy.made.yml", "version: 1\nunknown_field: true\n") + + if _, _, err := loadConfigFile(path); err == nil { + t.Fatal("loadConfigFile accepted an unknown field in a trusted .made.yml copy") + } +} + +func TestLoadConfig_RejectsZeroValueMadeYML(t *testing.T) { + path := writeConfigFile(t, t.TempDir(), ".made.yml", "") + + if _, _, err := loadConfigFile(path); err == nil { + t.Fatal("loadConfigFile accepted a zero-value .made.yml configuration") + } +} + +func TestLoadConfig_RejectsVersionOnlyMadeYML(t *testing.T) { + path := writeConfigFile(t, t.TempDir(), ".made.yml", "version: 1\n") + + if _, _, err := loadConfigFile(path); err == nil { + t.Fatal("loadConfigFile accepted a version-only .made.yml configuration") + } +} + +func TestConfig_DisabledStagesAreSkipped(t *testing.T) { + path := writeConfigFile(t, t.TempDir(), ".made.yml", "version: 1\nstages:\n review:\n enabled: false\n") + cfg, _, err := loadConfigFile(path) + if err != nil { + t.Fatalf("loadConfigFile: %v", err) + } + if got := cfg.StageResult("review"); got != "skipped" { + t.Fatalf("disabled stage result = %q, want skipped", got) + } +} + +func TestConfig_NoCIIsRepresentedAsSkipped(t *testing.T) { + if got := (Config{NoCI: true}).StageResult("ci"); got != "skipped" { + t.Fatalf("NoCI stage result = %q, want skipped", got) + } +} + +func TestLoadConfig_RejectsUnknownStageKeys(t *testing.T) { + path := writeConfigFile(t, t.TempDir(), ".made.yml", "version: 1\nstages:\n reviw:\n enabled: false\n") + + if _, _, err := loadConfigFile(path); err == nil { + t.Fatal("loadConfigFile accepted an unknown stage key") + } +} + +func TestConfig_RequiredSettingsControlDisabledReviewAndCI(t *testing.T) { + disabled := false + cfg := Config{ + Stages: map[string]Stage{"review": {Enabled: &disabled}, "ci": {Enabled: &disabled}}, + } + if cfg.StageRequired("review") || cfg.StageRequired("ci") { + t.Fatal("review or CI was required without the trusted required setting") + } + cfg.Review.Required = true + cfg.CI.Required = true + if !cfg.StageRequired("review") || !cfg.StageRequired("ci") { + t.Fatal("trusted required settings did not make disabled review and CI stages required") + } + cfg.NoCI = true + if cfg.StageRequired("ci") { + t.Fatal("NoCI did not disable the CI requirement") + } +} + +func TestLoadConfig_StageTimeoutAndEvidenceRetentionAreBounded(t *testing.T) { + path := writeConfigFile(t, t.TempDir(), ".made.yml", `version: 1 +stages: + review: + timeout_seconds: 45 +test: + evidence: + retention_bytes: 8192 +`) + cfg, _, err := loadConfigFile(path) + if err != nil { + t.Fatalf("loadConfigFile: %v", err) + } + if got := cfg.StageTimeout("review"); got != 45*time.Second { + t.Fatalf("review timeout = %s, want 45s", got) + } + if got := cfg.EvidenceRetentionBytes(); got != 8192 { + t.Fatalf("evidence retention = %d, want 8192", got) + } +} + +func TestLoadConfig_RejectsZeroOrUnboundedTimeoutAndRetention(t *testing.T) { + tests := []struct { + name string + body string + }{ + {name: "zero timeout", body: "version: 1\nstages:\n review:\n timeout_seconds: 0\n"}, + {name: "unbounded timeout", body: "version: 1\nstages:\n review:\n timeout_seconds: 7201\n"}, + {name: "zero retention", body: "version: 1\ntest:\n evidence:\n retention_bytes: 0\n"}, + {name: "unbounded retention", body: "version: 1\ntest:\n evidence:\n retention_bytes: 67108865\n"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := writeConfigFile(t, t.TempDir(), ".made.yml", tt.body) + if _, _, err := loadConfigFile(path); err == nil { + t.Fatal("loadConfigFile accepted an invalid bounded setting") + } + }) + } +} diff --git a/internal/daemon/contract.go b/internal/daemon/contract.go new file mode 100644 index 0000000..15139ba --- /dev/null +++ b/internal/daemon/contract.go @@ -0,0 +1,99 @@ +package daemon + +import ( + "fmt" + "time" +) + +func (rm *RunManager) HasActive() bool { + for _, snapshot := range rm.List() { + switch snapshot.Status { + case RunQueued, RunRunning, RunAwaitingReview, RunAwaitingMerge: + return true + } + } + return false +} + +func (rm *RunManager) SetDecision(id, stage, decision string) error { + r, ok := rm.lookupRun(id) + if !ok { + return fmt.Errorf("daemon: no run %q", id) + } + if stage == "" || decision == "" { + return fmt.Errorf("daemon: decision stage and value are required") + } + r.update(func(snapshot *RunSnapshot) { + if snapshot.Decisions == nil { + snapshot.Decisions = make(map[string]string) + } + snapshot.Decisions[stage] = decision + }) + if err := rm.persist(r); err != nil { + return fmt.Errorf("persist decision for run %q: %w", id, err) + } + return nil +} + +func (rm *RunManager) SetPRURL(id, prURL string) error { + r, ok := rm.lookupRun(id) + if !ok { + return fmt.Errorf("daemon: no run %q", id) + } + r.update(func(snapshot *RunSnapshot) { snapshot.PRURL = prURL }) + if err := rm.persist(r); err != nil { + return fmt.Errorf("persist PR URL for run %q: %w", id, err) + } + return nil +} + +func (rm *RunManager) SetOutputSHA(id, outputSHA string) error { + r, ok := rm.lookupRun(id) + if !ok { + return fmt.Errorf("daemon: no run %q", id) + } + if outputSHA == "" { + return fmt.Errorf("daemon: output SHA is required") + } + r.update(func(snapshot *RunSnapshot) { snapshot.OutputSHA = outputSHA }) + if err := rm.persist(r); err != nil { + return fmt.Errorf("persist output SHA for run %q: %w", id, err) + } + return nil +} + +func (rm *RunManager) AddFindings(id string, findings []RunFinding) error { + r, ok := rm.lookupRun(id) + if !ok { + return fmt.Errorf("daemon: no run %q", id) + } + r.update(func(snapshot *RunSnapshot) { + snapshot.Findings = append(snapshot.Findings, findings...) + }) + if err := rm.persist(r); err != nil { + return fmt.Errorf("persist findings for run %q: %w", id, err) + } + return nil +} + +func (rm *RunManager) AppendSubmissionEvent(id string, event SubmissionEvent) error { + r, ok := rm.lookupRun(id) + if !ok { + return fmt.Errorf("daemon: no run %q", id) + } + if event.RecordedAt.IsZero() { + event.RecordedAt = time.Now().UTC() + } + r.update(func(snapshot *RunSnapshot) { + for _, existing := range snapshot.SubmissionEvents { + if existing.Gate == event.Gate && existing.Ref == event.Ref && existing.InputSHA == event.InputSHA { + return + } + } + snapshot.SubmissionEvents = append(snapshot.SubmissionEvents, event) + }) + if err := rm.persist(r); err != nil { + return fmt.Errorf("persist submission event for run %q: %w", id, err) + } + return nil +} diff --git a/internal/daemon/durable_contract_test.go b/internal/daemon/durable_contract_test.go new file mode 100644 index 0000000..3bf37e9 --- /dev/null +++ b/internal/daemon/durable_contract_test.go @@ -0,0 +1,282 @@ +package daemon + +import ( + "context" + "encoding/json" + "os" + "strings" + "testing" + "time" +) + +func TestPersistentRunStateIncludesSubmissionAndDecisionData(t *testing.T) { + path := t.TempDir() + "/runs.wal" + rm, err := NewPersistentRunManager(path) + if err != nil { + t.Fatalf("NewPersistentRunManager: %v", err) + } + id := "123e4567-e89b-12d3-a456-426614174002" + if _, err := rm.SubmitWithMetadata(id, "repo", "feature", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", func(context.Context, func(Event)) error { + return nil + }); err != nil { + t.Fatalf("SubmitWithMetadata: %v", err) + } + if err := rm.SetDecision(id, "review", ReviewApproved); err != nil { + t.Fatalf("SetDecision: %v", err) + } + if err := rm.SetPRURL(id, "https://github.com/example/repo/pull/42"); err != nil { + t.Fatalf("SetPRURL: %v", err) + } + if err := rm.AppendSubmissionEvent(id, SubmissionEvent{Gate: "gate", Ref: "refs/heads/feature", InputSHA: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Kind: "push"}); err != nil { + t.Fatalf("AppendSubmissionEvent: %v", err) + } + if err := rm.Finish(id, RunAwaitingMerge, "PR is open"); err != nil { + t.Fatalf("Finish: %v", err) + } + + restarted, err := NewPersistentRunManager(path) + if err != nil { + t.Fatalf("reopen store: %v", err) + } + snapshot, ok := restarted.Snapshot(id) + if !ok { + t.Fatal("run missing after restart") + } + if snapshot.InputSHA != "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" || snapshot.OutputSHA != "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" { + t.Fatalf("immutable heads lost after restart: %+v", snapshot) + } + if snapshot.Status != RunAwaitingMerge || snapshot.PRURL == "" || snapshot.Decisions["review"] != ReviewApproved || len(snapshot.SubmissionEvents) != 1 { + t.Fatalf("durable state incomplete after restart: %+v", snapshot) + } +} + +func TestRunStoreRedactsDurableFindingAndErrorText(t *testing.T) { + path := t.TempDir() + "/runs.wal" + store, _, err := OpenRunStore(path) + if err != nil { + t.Fatalf("OpenRunStore: %v", err) + } + secret := "token=durable-secret" + if err := store.Append(RunSnapshot{ + ID: "123e4567-e89b-12d3-a456-426614174006", + Message: secret, + Errors: []string{secret}, + Findings: []RunFinding{{Message: secret}}, + }); err != nil { + t.Fatalf("Append: %v", err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read WAL: %v", err) + } + if strings.Contains(string(data), "durable-secret") { + t.Fatalf("WAL retained sensitive text: %s", data) + } +} + +func TestRunStoreIgnoresTornFinalRecord(t *testing.T) { + path := t.TempDir() + "/runs.wal" + store, _, err := OpenRunStore(path) + if err != nil { + t.Fatalf("OpenRunStore: %v", err) + } + seed := RunSnapshot{ID: "123e4567-e89b-12d3-a456-426614174007", Status: RunSucceeded} + if err := store.Append(seed); err != nil { + t.Fatalf("Append seed: %v", err) + } + appendBytes(t, path, []byte(`{"version":1,"kind":"snapshot"`)) + reopened, snapshots, err := OpenRunStore(path) + if err != nil { + t.Fatalf("OpenRunStore with torn tail: %v", err) + } + if reopened == nil || snapshots[seed.ID].Status != RunSucceeded { + t.Fatalf("valid WAL record was lost after torn tail: %+v", snapshots) + } +} + +func TestRunStoreReopensExactCapRecord(t *testing.T) { + path := t.TempDir() + "/runs.wal" + data := exactRunStoreRecord(t) + if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil { + t.Fatalf("write exact-cap WAL record: %v", err) + } + _, snapshots, err := OpenRunStore(path) + if err != nil { + t.Fatalf("OpenRunStore exact-cap record: %v", err) + } + if _, ok := snapshots["123e4567-e89b-12d3-a456-426614174008"]; !ok { + t.Fatalf("exact-cap WAL record was not replayed: %+v", snapshots) + } +} + +func TestGateSpoolIgnoresTornFinalRecord(t *testing.T) { + path := t.TempDir() + "/gate.spool" + spool, err := OpenGateSpool(path) + if err != nil { + t.Fatalf("OpenGateSpool: %v", err) + } + submission := GateSubmission{Gate: "gate", Ref: "refs/heads/main", SHA: "abc", RunID: "run"} + if _, _, err := spool.Enqueue(submission); err != nil { + t.Fatalf("Enqueue: %v", err) + } + appendBytes(t, path, []byte(`{"kind":"enqueue","submission"`)) + reopened, err := OpenGateSpool(path) + if err != nil { + t.Fatalf("OpenGateSpool with torn tail: %v", err) + } + if pending := reopened.Pending(); len(pending) != 1 || pending[0] != submission { + t.Fatalf("valid spool record was lost after torn tail: %+v", pending) + } +} + +func TestGateSpoolReopensExactCapRecord(t *testing.T) { + path := t.TempDir() + "/gate.spool" + data := exactGateSpoolRecord(t) + if err := os.WriteFile(path, append(data, '\n'), 0o600); err != nil { + t.Fatalf("write exact-cap spool record: %v", err) + } + spool, err := OpenGateSpool(path) + if err != nil { + t.Fatalf("OpenGateSpool exact-cap record: %v", err) + } + if !spool.HasPending() { + t.Fatal("exact-cap spool record was not replayed") + } +} + +func exactRunStoreRecord(t *testing.T) []byte { + t.Helper() + snapshot := RunSnapshot{ID: "123e4567-e89b-12d3-a456-426614174008", Status: RunSucceeded, Message: "x"} + record := storeRecord{Version: runStoreRecordVersion, Kind: "snapshot", Snapshot: persistSnapshot(snapshot)} + data, err := json.Marshal(record) + if err != nil { + t.Fatalf("marshal base WAL record: %v", err) + } + snapshot.Message = strings.Repeat("x", maxRunStoreRecordBytes-len(data)+1) + record.Snapshot = persistSnapshot(snapshot) + data, err = json.Marshal(record) + if err != nil { + t.Fatalf("marshal exact-cap WAL record: %v", err) + } + if len(data) != maxRunStoreRecordBytes { + t.Fatalf("exact WAL record length = %d, want %d", len(data), maxRunStoreRecordBytes) + } + return data +} + +func exactGateSpoolRecord(t *testing.T) []byte { + t.Helper() + submission := GateSubmission{Gate: "gate", Ref: "refs/heads/main", SHA: "abc", RunID: "run"} + record := spoolRecord{Kind: "enqueue", Submission: submission} + data, err := json.Marshal(record) + if err != nil { + t.Fatalf("marshal base spool record: %v", err) + } + submission.Gate = strings.Repeat("g", maxGateSpoolRecordBytes-len(data)+len(submission.Gate)) + record.Submission = submission + data, err = json.Marshal(record) + if err != nil { + t.Fatalf("marshal exact-cap spool record: %v", err) + } + if len(data) != maxGateSpoolRecordBytes { + t.Fatalf("exact spool record length = %d, want %d", len(data), maxGateSpoolRecordBytes) + } + return data +} + +func appendBytes(t *testing.T, path string, data []byte) { + t.Helper() + file, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + t.Fatalf("open append fixture: %v", err) + } + if _, err := file.Write(data); err != nil { + _ = file.Close() + t.Fatalf("write append fixture: %v", err) + } + if err := file.Close(); err != nil { + t.Fatalf("close append fixture: %v", err) + } +} + +func TestGateSpoolIsIdempotentAndDurable(t *testing.T) { + path := t.TempDir() + "/gate.spool" + spool, err := OpenGateSpool(path) + if err != nil { + t.Fatalf("OpenGateSpool: %v", err) + } + submission := GateSubmission{Gate: "gate", Ref: "refs/heads/main", SHA: "abc", RunID: "run"} + if _, created, err := spool.Enqueue(submission); err != nil || !created { + t.Fatalf("first Enqueue: created=%v err=%v", created, err) + } + if _, created, err := spool.Enqueue(submission); err != nil || created { + t.Fatalf("duplicate Enqueue: created=%v err=%v", created, err) + } + reopened, err := OpenGateSpool(path) + if err != nil { + t.Fatalf("reopen spool: %v", err) + } + if !reopened.HasPending() { + t.Fatal("pending submission disappeared after restart") + } + if pending := reopened.Pending(); len(pending) != 1 || pending[0] != submission { + t.Fatalf("Pending returned %+v, want %+v", pending, submission) + } + if err := reopened.Drain(submission); err != nil { + t.Fatalf("Drain: %v", err) + } + if reopened.HasPending() { + t.Fatal("drained submission remained pending") + } +} + +func TestPersistentRunManagerReconcilesUnfinishedExecutionAfterRestart(t *testing.T) { + path := t.TempDir() + "/runs.wal" + store, _, err := OpenRunStore(path) + if err != nil { + t.Fatalf("OpenRunStore: %v", err) + } + id := "123e4567-e89b-12d3-a456-426614174003" + if err := store.Append(RunSnapshot{ + ID: id, + Repo: "repo", + Branch: "feature", + Status: RunRunning, + QueuedAt: time.Now().Add(-time.Minute), + StartedAt: time.Now().Add(-30 * time.Second), + }); err != nil { + t.Fatalf("seed unfinished snapshot: %v", err) + } + reviewID := "123e4567-e89b-12d3-a456-426614174005" + if err := store.Append(RunSnapshot{ + ID: reviewID, Repo: "repo", Branch: "review", Status: RunAwaitingReview, + QueuedAt: time.Now().Add(-time.Minute), StartedAt: time.Now().Add(-30 * time.Second), + PendingFindings: []AskUserFinding{{Stage: "review", Message: "approve"}}, + }); err != nil { + t.Fatalf("seed awaiting-review snapshot: %v", err) + } + + rm, err := NewPersistentRunManager(path) + if err != nil { + t.Fatalf("NewPersistentRunManager: %v", err) + } + snapshot, ok := rm.Snapshot(id) + if !ok { + t.Fatal("reconciled run missing") + } + if snapshot.Status != RunFailed || !snapshot.ExecutionFinished || snapshot.Err == nil { + t.Fatalf("unfinished run was not reconciled to durable failure: %+v", snapshot) + } + reviewSnapshot, ok := rm.Snapshot(reviewID) + if !ok || reviewSnapshot.Status != RunFailed || !reviewSnapshot.ExecutionFinished { + t.Fatalf("awaiting-review run was not reconciled to durable failure: %+v", reviewSnapshot) + } + + restarted, err := NewPersistentRunManager(path) + if err != nil { + t.Fatalf("reopen reconciled store: %v", err) + } + if snapshot, _ := restarted.Snapshot(id); snapshot.Status != RunFailed || !snapshot.ExecutionFinished { + t.Fatalf("reconciled failure was not durable: %+v", snapshot) + } +} diff --git a/internal/daemon/lifecycle.go b/internal/daemon/lifecycle.go index 2279e41..6d74adf 100644 --- a/internal/daemon/lifecycle.go +++ b/internal/daemon/lifecycle.go @@ -3,7 +3,6 @@ package daemon import ( "context" "errors" - "fmt" "os" "os/signal" "syscall" @@ -11,10 +10,13 @@ import ( ) type Options struct { - LockPath string - IdleTimeout time.Duration - OnReady func(pid int) - ActivityCh <-chan struct{} + LockPath string + Lock *Lock + IdleTimeout time.Duration + OnReady func(pid int) + ActivityCh <-chan struct{} + ActiveFunc func() bool + UndrainedFunc func() bool } type StatusInfo struct { @@ -23,9 +25,13 @@ type StatusInfo struct { } func Run(ctx context.Context, opts Options) error { - lock, err := AcquireLock(opts.LockPath) - if err != nil { - return err + lock := opts.Lock + if lock == nil { + var err error + lock, err = AcquireLock(opts.LockPath) + if err != nil { + return err + } } defer func() { _ = lock.Release() }() @@ -55,6 +61,14 @@ func Run(ctx context.Context, opts Options) error { case <-sigCh: return nil case <-idleCh: + if opts.ActiveFunc != nil && opts.ActiveFunc() { + timer.Reset(opts.IdleTimeout) + continue + } + if opts.UndrainedFunc != nil && opts.UndrainedFunc() { + timer.Reset(opts.IdleTimeout) + continue + } return nil case <-ctx.Done(): return nil @@ -86,32 +100,5 @@ func Status(lockPath string) (StatusInfo, error) { } func Stop(lockPath string, timeout time.Duration) error { - pid, err := readLockPID(lockPath) - if err != nil { - return err - } - if pid == 0 { - return errors.New("daemon not running") - } - - proc, err := os.FindProcess(pid) - if err != nil { - return fmt.Errorf("find process %d: %w", pid, err) - } - if err := proc.Signal(syscall.SIGTERM); err != nil { - if errors.Is(err, os.ErrProcessDone) { - return nil - } - return fmt.Errorf("signal process %d: %w", pid, err) - } - - deadline := time.Now().Add(timeout) - for time.Now().Before(deadline) { - st, err := Status(lockPath) - if err == nil && !st.Running { - return nil - } - time.Sleep(20 * time.Millisecond) - } - return fmt.Errorf("daemon did not stop within %s", timeout) + return errors.New("daemon: shutdown requires the owner-only socket") } diff --git a/internal/daemon/lifecycle_test.go b/internal/daemon/lifecycle_test.go index d5d2a45..795c783 100644 --- a/internal/daemon/lifecycle_test.go +++ b/internal/daemon/lifecycle_test.go @@ -13,9 +13,11 @@ func TestRun_GracefulStop(t *testing.T) { ready := make(chan int, 1) done := make(chan error, 1) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() go func() { - done <- Run(context.Background(), Options{ + done <- Run(ctx, Options{ LockPath: lockPath, IdleTimeout: time.Minute, OnReady: func(pid int) { ready <- pid }, @@ -36,9 +38,7 @@ func TestRun_GracefulStop(t *testing.T) { t.Fatal("expected daemon to report running before stop") } - if err := Stop(lockPath, 2*time.Second); err != nil { - t.Fatalf("Stop: %v", err) - } + cancel() select { case runErr := <-done: diff --git a/internal/daemon/lock.go b/internal/daemon/lock.go index b2e33ac..a227c6d 100644 --- a/internal/daemon/lock.go +++ b/internal/daemon/lock.go @@ -20,7 +20,7 @@ type Lock struct { // including on a crash - unlike a PID file, whose mere existence proves // nothing about whether its writer is still alive. func AcquireLock(path string) (*Lock, error) { - f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600) + f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR|unix.O_NOFOLLOW, 0o600) if err != nil { return nil, fmt.Errorf("open lock file %s: %w", path, err) } diff --git a/internal/daemon/lock_test.go b/internal/daemon/lock_test.go index 791dce0..92e7aa3 100644 --- a/internal/daemon/lock_test.go +++ b/internal/daemon/lock_test.go @@ -2,6 +2,7 @@ package daemon import ( "errors" + "os" "path/filepath" "testing" ) @@ -21,6 +22,52 @@ func TestAcquireLock_DoubleStartRejected(t *testing.T) { } } +func TestStateFilesRejectSymlinkChildren(t *testing.T) { + dir := t.TempDir() + target := filepath.Join(dir, "target") + if err := os.WriteFile(target, []byte("keep"), 0o600); err != nil { + t.Fatalf("write target: %v", err) + } + cases := []struct { + name string + open func(string) error + }{ + {name: "lock", open: func(path string) error { + lock, err := AcquireLock(path) + if lock != nil { + _ = lock.Release() + } + return err + }}, + {name: "wal", open: func(path string) error { + _, _, err := OpenRunStore(path) + return err + }}, + {name: "spool", open: func(path string) error { + _, err := OpenGateSpool(path) + return err + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + link := filepath.Join(dir, tc.name) + if err := os.Symlink(target, link); err != nil { + t.Fatalf("create symlink: %v", err) + } + if err := tc.open(link); err == nil { + t.Fatal("state opener followed a symlink child") + } + data, err := os.ReadFile(target) + if err != nil { + t.Fatalf("read target: %v", err) + } + if string(data) != "keep" { + t.Fatalf("symlink target was modified: %q", data) + } + }) + } +} + func TestAcquireLock_ReacquireAfterRelease(t *testing.T) { lockPath := filepath.Join(t.TempDir(), "daemon.lock") diff --git a/internal/daemon/remediation_contract_test.go b/internal/daemon/remediation_contract_test.go new file mode 100644 index 0000000..754d7e8 --- /dev/null +++ b/internal/daemon/remediation_contract_test.go @@ -0,0 +1,256 @@ +package daemon + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strconv" + "syscall" + "testing" + "time" +) + +func TestRunManager_SuccessUsesSucceededLifecycle(t *testing.T) { + rm := NewRunManager() + id := rm.NewRunID() + events, unsubscribe := rm.Subscribe(id) + defer unsubscribe() + + if _, err := rm.Submit(id, "repo-success", "feature", func(context.Context, func(Event)) error { + return nil + }); err != nil { + t.Fatalf("Submit: %v", err) + } + + for { + select { + case event := <-events: + if event.Kind == EventRunCompleted { + snapshot, ok := rm.Snapshot(id) + if !ok { + t.Fatal("completed run disappeared from durable query surface") + } + if snapshot.Status != RunStatus("succeeded") { + t.Fatalf("state = %q, want succeeded", snapshot.Status) + } + if snapshot.EndedAt.IsZero() { + t.Fatal("execution-finished timestamp was not recorded separately from lifecycle state") + } + return + } + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for completed execution event") + } + } +} + +func TestRunManager_CancelUsesCanceledLifecycleAndIsIdempotent(t *testing.T) { + rm := NewRunManager() + id := rm.NewRunID() + started := make(chan struct{}) + if _, err := rm.Submit(id, "repo-cancel", "feature", func(ctx context.Context, _ func(Event)) error { + close(started) + <-ctx.Done() + return ctx.Err() + }); err != nil { + t.Fatalf("Submit: %v", err) + } + <-started + + if err := rm.Cancel(id); err != nil { + t.Fatalf("first Cancel: %v", err) + } + if err := rm.Cancel(id); err != nil { + t.Fatalf("second Cancel must be idempotent: %v", err) + } + + deadline := time.After(2 * time.Second) + for { + snapshot, ok := rm.Snapshot(id) + if ok && snapshot.Status == RunStatus("canceled") { + return + } + select { + case <-deadline: + snapshot, _ := rm.Snapshot(id) + t.Fatalf("state = %q, want canceled", snapshot.Status) + case <-time.After(5 * time.Millisecond): + } + } +} + +func TestRunManager_CancelAwaitingReviewStopsBlockedWorker(t *testing.T) { + rm := NewRunManager() + id := rm.NewRunID() + workerStopped := make(chan struct{}) + if _, err := rm.Submit(id, "repo-awaiting-review", "feature", func(ctx context.Context, _ func(Event)) error { + if err := rm.UpdatePendingFindings(id, []AskUserFinding{{Stage: "review", Message: "approve"}}); err != nil { + return err + } + <-ctx.Done() + close(workerStopped) + return ctx.Err() + }); err != nil { + t.Fatalf("Submit: %v", err) + } + waitForStatus(t, rm, id, RunAwaitingReview, 2*time.Second) + if err := rm.Cancel(id); err != nil { + t.Fatalf("Cancel awaiting review: %v", err) + } + select { + case <-workerStopped: + case <-time.After(2 * time.Second): + t.Fatal("awaiting-review worker did not observe cancellation") + } + final := waitForStatus(t, rm, id, RunCanceled, 2*time.Second) + if !final.ExecutionFinished { + t.Fatal("canceled awaiting-review run did not record execution_finished") + } +} + +func TestRunManager_SupersedeUsesSupersededLifecycle(t *testing.T) { + rm := NewRunManager() + const repo = "repo-supersession" + release := make(chan struct{}) + started := make(chan struct{}) + blocker := rm.NewRunID() + if _, err := rm.Submit(blocker, repo, "blocker", func(context.Context, func(Event)) error { + close(started) + <-release + return nil + }); err != nil { + t.Fatalf("Submit blocker: %v", err) + } + <-started + + first := rm.NewRunID() + if _, err := rm.Submit(first, repo, "feature", func(context.Context, func(Event)) error { return nil }); err != nil { + t.Fatalf("Submit first: %v", err) + } + if err := rm.SupersedeQueued(repo, "feature"); err != nil { + t.Fatalf("SupersedeQueued: %v", err) + } + close(release) + + deadline := time.After(2 * time.Second) + for { + snapshot, ok := rm.Snapshot(first) + if ok && snapshot.Status == RunStatus("superseded") { + if !errors.Is(snapshot.Err, ErrRunSuperseded) { + t.Fatalf("superseded error = %v, want ErrRunSuperseded", snapshot.Err) + } + return + } + select { + case <-deadline: + snapshot, _ := rm.Snapshot(first) + t.Fatalf("state = %q, want superseded", snapshot.Status) + case <-time.After(5 * time.Millisecond): + } + } +} + +func TestRunManager_NewRunIDIsRestartSafeUUID(t *testing.T) { + rm := NewRunManager() + first := rm.NewRunID() + second := rm.NewRunID() + if first == "run-1" || second == "run-2" || first == second { + t.Fatalf("run IDs are restart/order-derived: %q and %q", first, second) + } + if len(first) != 36 || len(second) != 36 { + t.Fatalf("run IDs must be UUID-shaped, got %q and %q", first, second) + } +} + +func TestRunManager_BeginShutdownClosesSubmissionAdmission(t *testing.T) { + rm := NewRunManager() + if err := rm.BeginShutdown(); err != nil { + t.Fatalf("BeginShutdown: %v", err) + } + if _, err := rm.Submit(rm.NewRunID(), "repo", "branch", func(context.Context, func(Event)) error { + return nil + }); !errors.Is(err, ErrRunSubmissionClosed) { + t.Fatalf("Submit after BeginShutdown error = %v, want ErrRunSubmissionClosed", err) + } +} + +func TestRun_DoesNotIdleStopWithAwaitingMergeRun(t *testing.T) { + lockPath := filepath.Join(t.TempDir(), "daemon.lock") + rm := NewRunManager() + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + stopped := false + t.Cleanup(func() { + cancel() + if stopped { + return + } + select { + case <-done: + case <-time.After(2 * time.Second): + t.Error("daemon did not stop during cleanup") + } + }) + go func() { + done <- Run(ctx, Options{ + LockPath: lockPath, + IdleTimeout: 100 * time.Millisecond, + ActivityCh: rm.ActivitySignal(), + ActiveFunc: rm.HasActive, + }) + }() + + id := rm.NewRunID() + if _, err := rm.Submit(id, "repo-awaiting-merge", "feature", func(context.Context, func(Event)) error { + if err := rm.Finish(id, RunStatus("awaiting_merge"), "PR is open"); err != nil { + return err + } + return nil + }); err != nil { + t.Fatalf("Submit: %v", err) + } + + deadline := time.After(2 * time.Second) + for { + snapshot, ok := rm.Snapshot(id) + if ok && snapshot.Status == RunStatus("awaiting_merge") { + break + } + select { + case <-deadline: + t.Fatal("run did not reach awaiting_merge") + case <-time.After(5 * time.Millisecond): + } + } + + select { + case err := <-done: + stopped = true + t.Fatalf("daemon idled out while awaiting merge: %v", err) + case <-time.After(250 * time.Millisecond): + } +} + +func TestStop_RefusesUnownedStalePID(t *testing.T) { + lockPath := filepath.Join(t.TempDir(), "daemon.lock") + process := exec.Command("sleep", "60") + if err := process.Start(); err != nil { + t.Fatalf("start owner probe: %v", err) + } + t.Cleanup(func() { + _ = process.Process.Kill() + _ = process.Wait() + }) + if err := os.WriteFile(lockPath, []byte(strconv.Itoa(process.Process.Pid)+"\n"), 0o600); err != nil { + t.Fatalf("write stale PID fixture: %v", err) + } + + if err := Stop(lockPath, 100*time.Millisecond); err == nil { + t.Fatal("Stop trusted an unowned stale PID and signaled the unrelated process") + } + if err := process.Process.Signal(syscall.Signal(0)); err != nil { + t.Fatalf("stale PID target was terminated by unowned shutdown: %v", err) + } +} diff --git a/internal/daemon/reviewdecisions.go b/internal/daemon/reviewdecisions.go index 26975e1..acdb231 100644 --- a/internal/daemon/reviewdecisions.go +++ b/internal/daemon/reviewdecisions.go @@ -16,9 +16,8 @@ type reviewKey struct { } // ReviewDecisions lives alongside RunManager because a decision only ever -// applies to one (run, stage) pair, making it per-run state that both the -// review.decide/review.decision RPC handlers and the orchestrator's WorkFunc -// need to reach from their separate packages. +// applies to one (run, stage) pair, making it per-run state that the versioned +// review.decide handler and orchestrator's WorkFunc share. type ReviewDecisions struct { mu sync.Mutex entries map[reviewKey]string @@ -48,13 +47,6 @@ func (d *ReviewDecisions) Set(runID, stage, decision string) { } } -func (d *ReviewDecisions) Get(runID, stage string) (string, bool) { - d.mu.Lock() - defer d.mu.Unlock() - decision, ok := d.entries[reviewKey{RunID: runID, Stage: stage}] - return decision, ok -} - // Wait blocks until a decision is recorded for (runID, stage) via Set, or // until ctx is done, whichever comes first. It returns immediately if a // decision is already recorded. diff --git a/internal/daemon/runmanager.go b/internal/daemon/runmanager.go index 358ec35..02a5473 100644 --- a/internal/daemon/runmanager.go +++ b/internal/daemon/runmanager.go @@ -2,6 +2,7 @@ package daemon import ( "context" + "crypto/rand" "errors" "fmt" "sync" @@ -12,31 +13,45 @@ import ( type RunStatus string const ( - RunQueued RunStatus = "queued" - RunRunning RunStatus = "running" - RunCompleted RunStatus = "completed" - RunFailed RunStatus = "failed" + RunQueued RunStatus = "queued" + RunRunning RunStatus = "running" + RunAwaitingReview RunStatus = "awaiting_review" + RunAwaitingMerge RunStatus = "awaiting_merge" + RunSucceeded RunStatus = "succeeded" + RunFailed RunStatus = "failed" + RunCanceled RunStatus = "canceled" + RunSuperseded RunStatus = "superseded" ) type RunSnapshot struct { - ID string - Repo string - Branch string - Status RunStatus - QueuedAt time.Time - StartedAt time.Time - EndedAt time.Time - Err error - Message string - Stages []StageResult - PendingFindings []AskUserFinding + ID string + Repo string + Branch string + InputSHA string + OutputSHA string + Status RunStatus + QueuedAt time.Time + StartedAt time.Time + EndedAt time.Time + ExecutionFinished bool + Err error + Errors []string + Message string + Findings []RunFinding + Decisions map[string]string + PRURL string + SupersededBy string + CancelRequested bool + SubmissionEvents []SubmissionEvent + Stages []StageResult + PendingFindings []AskUserFinding // finalized is set by Finish and read by execute: it lets a WorkFunc // declare a run's definitive terminal-or-not Status/Message itself, - // overriding execute's normal "nil error means RunCompleted" inference - + // overriding execute's normal "nil error means RunSucceeded" inference - // needed for the orchestrator's CI-passed-but-awaiting-human-merge case, // where the pipeline finished successfully yet the run must stay - // RunRunning rather than flip to RunCompleted. + // RunAwaitingMerge rather than flip to RunSucceeded. finalized bool } @@ -44,6 +59,8 @@ type WorkFunc func(ctx context.Context, emit func(Event)) error var ErrRunIDExists = errors.New("daemon: run ID already submitted") +var ErrRunSubmissionClosed = errors.New("daemon: run submission is closed") + type run struct { mu sync.Mutex snap RunSnapshot @@ -54,7 +71,7 @@ type run struct { func (r *run) snapshot() RunSnapshot { r.mu.Lock() defer r.mu.Unlock() - return r.snap + return cloneSnapshot(r.snap) } func (r *run) update(fn func(*RunSnapshot)) { @@ -79,22 +96,89 @@ type repoQueue struct { // worktree per bare repo at a time, so a second push against a repo already // running must queue behind it rather than run concurrently or be rejected. type RunManager struct { - mailbox *Mailbox - activity chan struct{} + mailbox *Mailbox + activity chan struct{} + store *RunStore + persistMu sync.Mutex mu sync.Mutex repos map[string]*repoQueue runs map[string]*run - counter uint64 + closing bool } func NewRunManager() *RunManager { - return &RunManager{ + return newRunManager(nil, nil) +} + +func NewRunID() string { + var id [16]byte + if _, err := rand.Read(id[:]); err != nil { + counter := atomic.AddUint64(&fallbackRunIDCounter, 1) + for i := range id { + id[i] = byte(counter >> (uint(i%8) * 8)) + } + } + id[6] = (id[6] & 0x0f) | 0x40 + id[8] = (id[8] & 0x3f) | 0x80 + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", id[0:4], id[4:6], id[6:8], id[8:10], id[10:16]) +} + +func NewPersistentRunManager(path string) (*RunManager, error) { + store, snapshots, err := OpenRunStore(path) + if err != nil { + return nil, err + } + rm := newRunManager(store, snapshots) + if err := rm.reconcileRestoredRuns(); err != nil { + return nil, err + } + return rm, nil +} + +func newRunManager(store *RunStore, snapshots map[string]RunSnapshot) *RunManager { + rm := &RunManager{ mailbox: NewMailbox(), activity: make(chan struct{}, 1), repos: make(map[string]*repoQueue), runs: make(map[string]*run), + store: store, + } + for id, snapshot := range snapshots { + ctx, cancel := context.WithCancel(context.Background()) + rm.runs[id] = &run{ctx: ctx, cancel: cancel, snap: cloneSnapshot(snapshot)} + } + return rm +} + +func (rm *RunManager) persist(r *run) error { + if rm.store == nil { + return nil + } + rm.persistMu.Lock() + defer rm.persistMu.Unlock() + return rm.store.Append(r.snapshot()) +} + +func (rm *RunManager) reconcileRestoredRuns() error { + for _, r := range rm.runs { + snapshot := r.snapshot() + if snapshot.Status != RunQueued && snapshot.Status != RunRunning && snapshot.Status != RunAwaitingReview { + continue + } + restartedErr := errors.New("daemon restarted before execution finished") + r.update(func(s *RunSnapshot) { + s.Status = RunFailed + s.EndedAt = time.Now() + s.ExecutionFinished = true + s.Err = restartedErr + s.Errors = append(s.Errors, restartedErr.Error()) + }) + if err := rm.persist(r); err != nil { + return fmt.Errorf("reconcile restored run %q: %w", snapshot.ID, err) + } } + return nil } func (rm *RunManager) ActivitySignal() <-chan struct{} { @@ -112,25 +196,41 @@ func (rm *RunManager) signalActivity() { } func (rm *RunManager) NewRunID() string { - n := atomic.AddUint64(&rm.counter, 1) - return fmt.Sprintf("run-%d", n) + return NewRunID() } +var fallbackRunIDCounter uint64 + func (rm *RunManager) Submit(id, repo, branch string, work WorkFunc) (RunSnapshot, error) { + return rm.SubmitWithMetadata(id, repo, branch, "", "", work) +} + +func (rm *RunManager) SubmitWithMetadata(id, repo, branch, inputSHA, outputSHA string, work WorkFunc) (RunSnapshot, error) { ctx, cancel := context.WithCancel(context.Background()) r := &run{ ctx: ctx, cancel: cancel, snap: RunSnapshot{ - ID: id, - Repo: repo, - Branch: branch, - Status: RunQueued, - QueuedAt: time.Now(), + ID: id, + Repo: repo, + Branch: branch, + InputSHA: inputSHA, + OutputSHA: outputSHA, + Status: RunQueued, + QueuedAt: time.Now(), + Errors: []string{}, + Findings: []RunFinding{}, + Decisions: make(map[string]string), + SubmissionEvents: []SubmissionEvent{}, }, } rm.mu.Lock() + if rm.closing { + rm.mu.Unlock() + cancel() + return RunSnapshot{}, ErrRunSubmissionClosed + } if _, exists := rm.runs[id]; exists { rm.mu.Unlock() return RunSnapshot{}, ErrRunIDExists @@ -142,18 +242,57 @@ func (rm *RunManager) Submit(id, repo, branch string, work WorkFunc) (RunSnapsho rm.repos[repo] = rq } rm.mu.Unlock() + if err := rm.persist(r); err != nil { + rm.mu.Lock() + delete(rm.runs, id) + if current, ok := rm.repos[repo]; ok && current == rq { + delete(rm.repos, repo) + } + rm.mu.Unlock() + cancel() + return RunSnapshot{}, fmt.Errorf("persist submitted run: %w", err) + } rq.mu.Lock() rq.pending = append(rq.pending, &queuedJob{run: r, work: work}) startDrain := !rq.active rq.active = true rq.mu.Unlock() + queuedSnapshot := r.snapshot() if startDrain { go rm.drain(rq) } - return r.snapshot(), nil + return queuedSnapshot, nil +} + +func (rm *RunManager) BeginShutdown() error { + rm.mu.Lock() + defer rm.mu.Unlock() + if rm.closing { + return ErrRunSubmissionClosed + } + for _, r := range rm.runs { + snapshot := r.snapshot() + if snapshot.Status == RunQueued || snapshot.Status == RunRunning || snapshot.Status == RunAwaitingReview || snapshot.Status == RunAwaitingMerge { + return fmt.Errorf("daemon: active run %q remains in state %s", snapshot.ID, snapshot.Status) + } + } + rm.closing = true + return nil +} + +func (rm *RunManager) StopAccepting() { + rm.mu.Lock() + rm.closing = true + rm.mu.Unlock() +} + +func (rm *RunManager) Accepting() bool { + rm.mu.Lock() + defer rm.mu.Unlock() + return !rm.closing } func (rm *RunManager) drain(rq *repoQueue) { @@ -179,6 +318,15 @@ func (rm *RunManager) execute(r *run, work WorkFunc) { s.Status = RunRunning s.StartedAt = started }) + if err := rm.persist(r); err != nil { + retryErr := rm.recordPersistenceFailure(r, err) + eventErr := err + if retryErr != nil { + eventErr = errors.Join(err, retryErr) + } + rm.mailbox.Publish(Event{RunID: id, Kind: EventRunFailed, Time: time.Now(), Err: eventErr}) + return + } rm.mailbox.Publish(Event{RunID: id, Kind: EventRunStarted, Time: started}) rm.signalActivity() @@ -197,16 +345,31 @@ func (rm *RunManager) execute(r *run, work WorkFunc) { ended := time.Now() r.update(func(s *RunSnapshot) { s.EndedAt = ended + s.ExecutionFinished = true if s.finalized { return } s.Err = err - if err != nil { + if errors.Is(err, context.Canceled) || s.CancelRequested { + s.Status = RunCanceled + if err != nil { + s.Errors = append(s.Errors, err.Error()) + } + } else if err != nil { s.Status = RunFailed + s.Errors = append(s.Errors, err.Error()) } else { - s.Status = RunCompleted + s.Status = RunSucceeded } }) + if persistErr := rm.persist(r); persistErr != nil { + retryErr := rm.recordPersistenceFailure(r, persistErr) + if retryErr != nil { + err = errors.Join(err, persistErr, retryErr) + } else { + err = errors.Join(err, persistErr) + } + } finalKind := EventRunCompleted if err != nil { @@ -215,6 +378,24 @@ func (rm *RunManager) execute(r *run, work WorkFunc) { rm.mailbox.Publish(Event{RunID: id, Kind: finalKind, Time: ended, Err: err}) } +func (rm *RunManager) recordPersistenceFailure(r *run, persistErr error) error { + durabilityErr := fmt.Errorf("daemon: durable run state write failed: %w", persistErr) + r.update(func(s *RunSnapshot) { + s.Status = RunFailed + s.EndedAt = time.Now() + s.ExecutionFinished = true + s.Err = durabilityErr + s.Errors = append(s.Errors, durabilityErr.Error()) + }) + retryErr := rm.persist(r) + if retryErr != nil { + r.update(func(s *RunSnapshot) { + s.Errors = append(s.Errors, fmt.Sprintf("daemon: retry durable run state write failed: %v", retryErr)) + }) + } + return retryErr +} + func (rm *RunManager) Snapshot(id string) (RunSnapshot, bool) { r, ok := rm.lookupRun(id) if !ok { @@ -233,7 +414,7 @@ func (rm *RunManager) List() []RunSnapshot { snaps := make([]RunSnapshot, len(runs)) for i, r := range runs { - snaps[i] = r.snapshot() + snaps[i] = cloneSnapshot(r.snapshot()) } return snaps } @@ -242,30 +423,63 @@ func (rm *RunManager) Subscribe(id string) (<-chan Event, func()) { return rm.mailbox.Subscribe(id) } -// Cancel signals the run's WorkFunc via its context; cancellation surfaces as -// the existing RunFailed status with Err wrapping context.Canceled rather -// than a new status value, since a cooperating WorkFunc returning ctx.Err() -// already distinguishes it from an ordinary failure for any caller checking -// errors.Is(snap.Err, context.Canceled). func (rm *RunManager) Cancel(id string) error { r, ok := rm.lookupRun(id) if !ok { return fmt.Errorf("daemon: no run %q", id) } - if isTerminalRunStatus(r.snapshot().Status) { - return fmt.Errorf("daemon: run %q is already %s", id, r.snapshot().Status) + snapshot := r.snapshot() + if snapshot.Status == RunCanceled { + return nil + } + if isTerminalRunStatus(snapshot.Status) { + return fmt.Errorf("daemon: run %q is already %s", id, snapshot.Status) + } + if snapshot.CancelRequested { + r.cancel() + if snapshot.Status == RunAwaitingMerge || snapshot.Status == RunAwaitingReview { + r.update(func(s *RunSnapshot) { + s.Status = RunCanceled + s.ExecutionFinished = true + s.EndedAt = time.Now() + s.Err = context.Canceled + }) + if err := rm.persist(r); err != nil { + return fmt.Errorf("persist canceled run: %w", err) + } + } + return nil + } + r.update(func(s *RunSnapshot) { s.CancelRequested = true }) + if err := rm.persist(r); err != nil { + r.cancel() + return fmt.Errorf("persist cancellation request: %w", err) + } + if snapshot.Status == RunAwaitingMerge || snapshot.Status == RunAwaitingReview { + r.update(func(s *RunSnapshot) { + s.Status = RunCanceled + s.ExecutionFinished = true + s.EndedAt = time.Now() + s.Err = context.Canceled + s.Errors = append(s.Errors, context.Canceled.Error()) + }) + r.cancel() + if err := rm.persist(r); err != nil { + return fmt.Errorf("persist canceled run: %w", err) + } + return nil } r.cancel() return nil } func isTerminalRunStatus(s RunStatus) bool { - return s == RunCompleted || s == RunFailed + return s == RunSucceeded || s == RunFailed || s == RunCanceled || s == RunSuperseded } // Finish lets a WorkFunc declare a run's definitive Status and a // human-readable Message just before it returns, so execute's normal -// nil-error-means-RunCompleted inference does not overwrite it (see +// nil-error-means-RunSucceeded inference does not overwrite it (see // RunSnapshot.finalized). status may be any RunStatus, including RunRunning // for a run that must stay open pending action made cannot itself take. func (rm *RunManager) Finish(id string, status RunStatus, message string) error { @@ -278,6 +492,9 @@ func (rm *RunManager) Finish(id string, status RunStatus, message string) error s.Message = message s.finalized = true }) + if err := rm.persist(r); err != nil { + return fmt.Errorf("persist finished run: %w", err) + } return nil } @@ -291,12 +508,12 @@ var ErrRunSuperseded = errors.New("daemon: run superseded by a newer push to the // inspected, so a job already popped off the queue - running or terminal - // is left completely alone, matching a fresh push's right to replace a // stale intent that hasn't started yet, but never a run already underway. -func (rm *RunManager) SupersedeQueued(repo, branch string) { +func (rm *RunManager) SupersedeQueued(repo, branch string) error { rm.mu.Lock() rq, ok := rm.repos[repo] rm.mu.Unlock() if !ok { - return + return nil } rq.mu.Lock() @@ -313,13 +530,20 @@ func (rm *RunManager) SupersedeQueued(repo, branch string) { rq.mu.Unlock() now := time.Now() + var firstErr error for _, j := range dropped { j.run.update(func(s *RunSnapshot) { - s.Status = RunFailed + s.Status = RunSuperseded s.Err = ErrRunSuperseded + s.Errors = append(s.Errors, ErrRunSuperseded.Error()) s.EndedAt = now + s.ExecutionFinished = true }) + if err := rm.persist(j.run); err != nil && firstErr == nil { + firstErr = fmt.Errorf("persist superseded run: %w", err) + } rm.mailbox.Publish(Event{RunID: j.run.snapshot().ID, Kind: EventRunFailed, Time: now, Err: ErrRunSuperseded}) rm.signalActivity() } + return firstErr } diff --git a/internal/daemon/runmanager_test.go b/internal/daemon/runmanager_test.go index 20e2931..f5f4dd1 100644 --- a/internal/daemon/runmanager_test.go +++ b/internal/daemon/runmanager_test.go @@ -46,7 +46,7 @@ func TestRunManager_SequentialQueuing(t *testing.T) { deadline := time.After(2 * time.Second) for { s2, ok := rm.Snapshot(id2) - if ok && (s2.Status == RunCompleted || s2.Status == RunFailed) { + if ok && (s2.Status == RunSucceeded || s2.Status == RunFailed) { break } select { @@ -242,7 +242,7 @@ func TestRunManager_CancelStopsRunningWorkFunc(t *testing.T) { t.Fatal("WorkFunc did not unblock within 1s of Cancel") } - final := waitForStatus(t, rm, id, RunFailed, 2*time.Second) + final := waitForStatus(t, rm, id, RunCanceled, 2*time.Second) if final.Err == nil || !errors.Is(final.Err, context.Canceled) { t.Fatalf("expected final error to wrap context.Canceled, got %v", final.Err) } @@ -291,7 +291,9 @@ func TestRunManager_SupersedeQueuedDropsOnlyStillQueuedJobForBranch(t *testing.T t.Fatalf("expected first run still queued behind the blocker before supersession, got %+v (ok=%v)", snap, ok) } - rm.SupersedeQueued(repo, "feature-x") + if err := rm.SupersedeQueued(repo, "feature-x"); err != nil { + t.Fatalf("SupersedeQueued: %v", err) + } id2 := rm.NewRunID() if _, err := rm.Submit(id2, repo, "feature-x", recordWork("second")); err != nil { @@ -300,14 +302,14 @@ func TestRunManager_SupersedeQueuedDropsOnlyStillQueuedJobForBranch(t *testing.T close(blockRelease) - waitForStatus(t, rm, id2, RunCompleted, 2*time.Second) + waitForStatus(t, rm, id2, RunSucceeded, 2*time.Second) final1, ok := rm.Snapshot(id1) if !ok { t.Fatal("expected superseded run to remain tracked, not deleted") } - if final1.Status != RunFailed { - t.Fatalf("expected superseded run status RunFailed, got %v", final1.Status) + if final1.Status != RunSuperseded { + t.Fatalf("expected superseded run status RunSuperseded, got %v", final1.Status) } if !errors.Is(final1.Err, ErrRunSuperseded) { t.Fatalf("expected superseded run's error to wrap ErrRunSuperseded, got %v", final1.Err) @@ -341,14 +343,16 @@ func TestRunManager_SupersedeQueuedLeavesAlreadyStartedRunAlone(t *testing.T) { <-started waitForStatus(t, rm, id, RunRunning, time.Second) - rm.SupersedeQueued(repo, "feature-x") + if err := rm.SupersedeQueued(repo, "feature-x"); err != nil { + t.Fatalf("SupersedeQueued: %v", err) + } if snap, _ := rm.Snapshot(id); snap.Status != RunRunning { t.Fatalf("expected already-started run to stay RunRunning after SupersedeQueued, got %v", snap.Status) } close(release) - final := waitForStatus(t, rm, id, RunCompleted, 2*time.Second) + final := waitForStatus(t, rm, id, RunSucceeded, 2*time.Second) if final.Err != nil { t.Fatalf("expected already-started run to complete normally, got err %v", final.Err) } @@ -362,7 +366,7 @@ func TestRunManager_CancelTerminalRunErrors(t *testing.T) { t.Fatalf("submit: %v", err) } - waitForStatus(t, rm, id, RunCompleted, 2*time.Second) + waitForStatus(t, rm, id, RunSucceeded, 2*time.Second) if err := rm.Cancel(id); err == nil { t.Fatal("expected error cancelling an already-terminal run") diff --git a/internal/daemon/runstate.go b/internal/daemon/runstate.go index 7f69dd2..0137733 100644 --- a/internal/daemon/runstate.go +++ b/internal/daemon/runstate.go @@ -2,6 +2,25 @@ package daemon import "fmt" +func cloneSnapshot(snapshot RunSnapshot) RunSnapshot { + snapshot.Errors = append([]string(nil), snapshot.Errors...) + snapshot.Findings = append([]RunFinding(nil), snapshot.Findings...) + for i := range snapshot.Findings { + snapshot.Findings[i].Paths = append([]string(nil), snapshot.Findings[i].Paths...) + } + snapshot.Stages = append([]StageResult(nil), snapshot.Stages...) + snapshot.PendingFindings = append([]AskUserFinding(nil), snapshot.PendingFindings...) + snapshot.SubmissionEvents = append([]SubmissionEvent(nil), snapshot.SubmissionEvents...) + if snapshot.Decisions != nil { + original := snapshot.Decisions + snapshot.Decisions = make(map[string]string, len(original)) + for key, value := range original { + snapshot.Decisions[key] = value + } + } + return snapshot +} + type StageResult struct { Name string `json:"name"` Result string `json:"result"` @@ -18,8 +37,11 @@ func (rm *RunManager) UpdateStages(id string, stages []StageResult) error { return fmt.Errorf("daemon: no run %q", id) } r.update(func(s *RunSnapshot) { - s.Stages = stages + s.Stages = append([]StageResult(nil), stages...) }) + if err := rm.persist(r); err != nil { + return fmt.Errorf("persist stages for run %q: %w", id, err) + } return nil } @@ -29,8 +51,17 @@ func (rm *RunManager) UpdatePendingFindings(id string, findings []AskUserFinding return fmt.Errorf("daemon: no run %q", id) } r.update(func(s *RunSnapshot) { - s.PendingFindings = findings + s.PendingFindings = append([]AskUserFinding(nil), findings...) + if len(findings) > 0 && s.Status == RunRunning { + s.Status = RunAwaitingReview + } + if len(findings) == 0 && s.Status == RunAwaitingReview { + s.Status = RunRunning + } }) + if err := rm.persist(r); err != nil { + return fmt.Errorf("persist pending findings for run %q: %w", id, err) + } return nil } diff --git a/internal/daemon/runstate_test.go b/internal/daemon/runstate_test.go index 0c9e320..3257950 100644 --- a/internal/daemon/runstate_test.go +++ b/internal/daemon/runstate_test.go @@ -95,7 +95,7 @@ func TestRunManager_UpdateStagesReflectsListToo(t *testing.T) { deadline := time.After(2 * time.Second) for { snap, ok := rm.Snapshot(id) - if ok && (snap.Status == RunCompleted || snap.Status == RunFailed) { + if ok && (snap.Status == RunSucceeded || snap.Status == RunFailed) { break } select { diff --git a/internal/daemon/spool.go b/internal/daemon/spool.go new file mode 100644 index 0000000..0f01fc0 --- /dev/null +++ b/internal/daemon/spool.go @@ -0,0 +1,160 @@ +package daemon + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sync" + + "golang.org/x/sys/unix" +) + +type GateSubmission struct { + Gate string `json:"gate"` + Ref string `json:"ref"` + SHA string `json:"sha"` + RunID string `json:"run_id"` + OutputSHA string `json:"output_sha,omitempty"` +} + +type spoolRecord struct { + Kind string `json:"kind"` + Submission GateSubmission `json:"submission"` +} + +type GateSpool struct { + path string + mu sync.Mutex + pending map[string]GateSubmission + seen map[string]GateSubmission +} + +const maxGateSpoolRecordBytes = 1 << 20 + +func (s *GateSpool) Path() string { + return s.path +} + +func OpenGateSpool(path string) (*GateSpool, error) { + if path == "" { + return nil, errors.New("daemon: gate spool path is required") + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, fmt.Errorf("daemon: create gate spool directory: %w", err) + } + spool := &GateSpool{path: path, pending: make(map[string]GateSubmission), seen: make(map[string]GateSubmission)} + file, err := os.OpenFile(path, os.O_RDONLY|unix.O_NOFOLLOW, 0) + if errors.Is(err, os.ErrNotExist) { + return spool, nil + } + if err != nil { + return nil, fmt.Errorf("daemon: open gate spool: %w", err) + } + defer func() { _ = file.Close() }() + reader := bufio.NewReader(file) + for { + line, readErr := readRecordLine(reader, maxGateSpoolRecordBytes) + if readErr == io.EOF { + break + } + if readErr != nil { + return nil, fmt.Errorf("daemon: read gate spool: %w", readErr) + } + if len(line) == 0 { + continue + } + var record spoolRecord + if err := json.Unmarshal(line, &record); err != nil { + return nil, fmt.Errorf("daemon: decode gate spool: %w", err) + } + key := gateSubmissionKey(record.Submission) + switch record.Kind { + case "enqueue": + spool.pending[key] = record.Submission + spool.seen[key] = record.Submission + case "drain": + delete(spool.pending, key) + default: + return nil, fmt.Errorf("daemon: unknown gate spool record %q", record.Kind) + } + } + return spool, nil +} + +func (s *GateSpool) Enqueue(submission GateSubmission) (GateSubmission, bool, error) { + if submission.Gate == "" || submission.Ref == "" || submission.SHA == "" || submission.RunID == "" { + return GateSubmission{}, false, errors.New("daemon: incomplete gate submission identity") + } + s.mu.Lock() + defer s.mu.Unlock() + key := gateSubmissionKey(submission) + if existing, ok := s.seen[key]; ok { + return existing, false, nil + } + if err := s.appendLocked(spoolRecord{Kind: "enqueue", Submission: submission}); err != nil { + return GateSubmission{}, false, err + } + s.pending[key] = submission + s.seen[key] = submission + return submission, true, nil +} + +func (s *GateSpool) Drain(submission GateSubmission) error { + s.mu.Lock() + defer s.mu.Unlock() + key := gateSubmissionKey(submission) + if _, ok := s.pending[key]; !ok { + return nil + } + if err := s.appendLocked(spoolRecord{Kind: "drain", Submission: submission}); err != nil { + return err + } + delete(s.pending, key) + return nil +} + +func (s *GateSpool) HasPending() bool { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.pending) > 0 +} + +func (s *GateSpool) Pending() []GateSubmission { + s.mu.Lock() + defer s.mu.Unlock() + pending := make([]GateSubmission, 0, len(s.pending)) + for _, submission := range s.pending { + pending = append(pending, submission) + } + return pending +} + +func (s *GateSpool) appendLocked(record spoolRecord) error { + data, err := json.Marshal(record) + if err != nil { + return fmt.Errorf("daemon: encode gate spool record: %w", err) + } + if len(data) > maxGateSpoolRecordBytes { + return fmt.Errorf("daemon: gate spool record exceeds %d bytes", maxGateSpoolRecordBytes) + } + file, err := os.OpenFile(s.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND|unix.O_NOFOLLOW, 0o600) + if err != nil { + return fmt.Errorf("daemon: open gate spool for append: %w", err) + } + defer func() { _ = file.Close() }() + if _, err := file.Write(append(data, '\n')); err != nil { + return fmt.Errorf("daemon: append gate spool: %w", err) + } + if err := file.Sync(); err != nil { + return fmt.Errorf("daemon: sync gate spool: %w", err) + } + return nil +} + +func gateSubmissionKey(submission GateSubmission) string { + return submission.Gate + "\x00" + submission.Ref + "\x00" + submission.SHA +} diff --git a/internal/daemon/store.go b/internal/daemon/store.go new file mode 100644 index 0000000..cf25ae3 --- /dev/null +++ b/internal/daemon/store.go @@ -0,0 +1,276 @@ +package daemon + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sync" + "time" + + "github.com/douglasjarquin/made/internal/evidence" + "golang.org/x/sys/unix" +) + +const runStoreRecordVersion = 1 +const maxRunStoreRecordBytes = 4 << 20 + +// RunFinding is the durable Made-owned representation of a review finding. +// It deliberately contains only data needed by the public run contract. +type RunFinding struct { + Stage string `json:"stage"` + Kind string `json:"kind"` + Message string `json:"message"` + Paths []string `json:"paths,omitempty"` + PreFixSHA string `json:"pre_fix_sha,omitempty"` + PostFixSHA string `json:"post_fix_sha,omitempty"` +} + +type SubmissionEvent struct { + Gate string `json:"gate"` + Ref string `json:"ref"` + InputSHA string `json:"input_sha"` + OutputSHA string `json:"output_sha,omitempty"` + Kind string `json:"kind"` + RecordedAt time.Time `json:"recorded_at"` +} + +type persistedSnapshot struct { + ID string `json:"run_id"` + Repo string `json:"repo"` + Branch string `json:"branch"` + InputSHA string `json:"input_sha"` + OutputSHA string `json:"output_sha"` + Status RunStatus `json:"state"` + QueuedAt time.Time `json:"queued_at"` + StartedAt time.Time `json:"started_at"` + EndedAt time.Time `json:"ended_at"` + ExecutionFinished bool `json:"execution_finished"` + Message string `json:"message,omitempty"` + Errors []string `json:"errors"` + Findings []RunFinding `json:"findings"` + Decisions map[string]string `json:"decisions"` + PRURL string `json:"pr_url"` + SupersededBy string `json:"superseded_by"` + CancelRequested bool `json:"cancel_requested"` + SubmissionEvents []SubmissionEvent `json:"submission_events"` + Stages []StageResult `json:"stages"` + PendingFindings []AskUserFinding `json:"pending_findings"` + Finalized bool `json:"finalized"` +} + +type storeRecord struct { + Version int `json:"version"` + Kind string `json:"kind"` + Snapshot persistedSnapshot `json:"snapshot"` +} + +// RunStore is an append-only JSON WAL. Each state transition is a complete +// snapshot, so replay needs no ordering assumptions beyond file order. +// Every append is synced before it is acknowledged to the caller. +type RunStore struct { + path string + mu sync.Mutex +} + +func OpenRunStore(path string) (*RunStore, map[string]RunSnapshot, error) { + if path == "" { + return nil, nil, errors.New("daemon: run store path is required") + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return nil, nil, fmt.Errorf("daemon: create run store directory: %w", err) + } + store := &RunStore{path: path} + snapshots := make(map[string]RunSnapshot) + file, err := os.OpenFile(path, os.O_RDONLY|unix.O_NOFOLLOW, 0) + if errors.Is(err, os.ErrNotExist) { + return store, snapshots, nil + } + if err != nil { + return nil, nil, fmt.Errorf("daemon: open run store: %w", err) + } + defer func() { _ = file.Close() }() + + reader := bufio.NewReader(file) + for { + line, readErr := readRecordLine(reader, maxRunStoreRecordBytes) + if readErr == io.EOF { + break + } + if readErr != nil { + return nil, nil, fmt.Errorf("daemon: read run store: %w", readErr) + } + if len(line) == 0 { + continue + } + var record storeRecord + if err := json.Unmarshal(line, &record); err != nil { + return nil, nil, fmt.Errorf("daemon: decode run store record: %w", err) + } + if record.Version != runStoreRecordVersion || record.Kind != "snapshot" { + return nil, nil, fmt.Errorf("daemon: unsupported run store record version %d or kind %q", record.Version, record.Kind) + } + snapshots[record.Snapshot.ID] = restoreSnapshot(record.Snapshot) + } + return store, snapshots, nil +} + +func readRecordLine(reader *bufio.Reader, maxBytes int) ([]byte, error) { + maxLineBytes := maxBytes + 1 + var line []byte + for { + chunk, err := reader.ReadSlice('\n') + line = append(line, chunk...) + if len(line) > maxLineBytes { + return nil, fmt.Errorf("record exceeds %d bytes", maxBytes) + } + if err == bufio.ErrBufferFull { + continue + } + if err == io.EOF { + if len(line) > maxBytes { + return nil, fmt.Errorf("record exceeds %d bytes", maxBytes) + } + return nil, io.EOF + } + if err != nil { + return nil, err + } + return line[:len(line)-1], nil + } +} + +func (s *RunStore) Append(snapshot RunSnapshot) error { + if s == nil { + return errors.New("daemon: nil run store") + } + record := storeRecord{Version: runStoreRecordVersion, Kind: "snapshot", Snapshot: persistSnapshot(snapshot)} + data, err := json.Marshal(record) + if err != nil { + return fmt.Errorf("daemon: encode run store record: %w", err) + } + if len(data) > maxRunStoreRecordBytes { + return fmt.Errorf("daemon: run store record exceeds %d bytes", maxRunStoreRecordBytes) + } + s.mu.Lock() + defer s.mu.Unlock() + file, err := os.OpenFile(s.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND|unix.O_NOFOLLOW, 0o600) + if err != nil { + return fmt.Errorf("daemon: open run store for append: %w", err) + } + defer func() { _ = file.Close() }() + if _, err := file.Write(append(data, '\n')); err != nil { + return fmt.Errorf("daemon: append run store: %w", err) + } + if err := file.Sync(); err != nil { + return fmt.Errorf("daemon: sync run store: %w", err) + } + return nil +} + +func persistSnapshot(snapshot RunSnapshot) persistedSnapshot { + errorsList := append([]string(nil), snapshot.Errors...) + if snapshot.Err != nil && len(errorsList) == 0 { + errorsList = []string{snapshot.Err.Error()} + } + for i := range errorsList { + errorsList[i] = evidence.RedactString(errorsList[i]) + } + decisions := make(map[string]string, len(snapshot.Decisions)) + for key, value := range snapshot.Decisions { + decisions[key] = evidence.RedactString(value) + } + findings := redactFindings(snapshot.Findings) + pendingFindings := redactPendingFindings(snapshot.PendingFindings) + return persistedSnapshot{ + ID: snapshot.ID, Repo: snapshot.Repo, Branch: snapshot.Branch, + InputSHA: snapshot.InputSHA, OutputSHA: snapshot.OutputSHA, + Status: snapshot.Status, QueuedAt: snapshot.QueuedAt, StartedAt: snapshot.StartedAt, + EndedAt: snapshot.EndedAt, ExecutionFinished: snapshot.ExecutionFinished, + Message: evidence.RedactString(snapshot.Message), Errors: errorsList, + Findings: findings, Decisions: decisions, + PRURL: evidence.RedactString(snapshot.PRURL), SupersededBy: snapshot.SupersededBy, + CancelRequested: snapshot.CancelRequested, + SubmissionEvents: redactSubmissionEvents(snapshot.SubmissionEvents), + Stages: append([]StageResult(nil), snapshot.Stages...), + PendingFindings: pendingFindings, + Finalized: snapshot.finalized, + } +} + +func restoreSnapshot(snapshot persistedSnapshot) RunSnapshot { + var runErr error + if len(snapshot.Errors) > 0 { + runErr = errors.New(evidence.RedactString(snapshot.Errors[len(snapshot.Errors)-1])) + } + return RunSnapshot{ + ID: snapshot.ID, Repo: snapshot.Repo, Branch: snapshot.Branch, + InputSHA: snapshot.InputSHA, OutputSHA: snapshot.OutputSHA, + Status: snapshot.Status, QueuedAt: snapshot.QueuedAt, StartedAt: snapshot.StartedAt, + EndedAt: snapshot.EndedAt, ExecutionFinished: snapshot.ExecutionFinished, + Err: runErr, Errors: redactStrings(snapshot.Errors), + Message: evidence.RedactString(snapshot.Message), Findings: redactFindings(snapshot.Findings), + Decisions: snapshot.Decisions, PRURL: snapshot.PRURL, + SupersededBy: snapshot.SupersededBy, CancelRequested: snapshot.CancelRequested, + SubmissionEvents: redactSubmissionEvents(snapshot.SubmissionEvents), + Stages: append([]StageResult(nil), snapshot.Stages...), + PendingFindings: redactPendingFindings(snapshot.PendingFindings), + finalized: snapshot.Finalized, + } +} + +func redactStrings(values []string) []string { + if values == nil { + return nil + } + redacted := make([]string, len(values)) + for i, value := range values { + redacted[i] = evidence.RedactString(value) + } + return redacted +} + +func redactFindings(values []RunFinding) []RunFinding { + if values == nil { + return nil + } + redacted := make([]RunFinding, len(values)) + for i, value := range values { + value.Message = evidence.RedactString(value.Message) + for j, path := range value.Paths { + value.Paths[j] = evidence.RedactString(path) + } + redacted[i] = value + } + return redacted +} + +func redactPendingFindings(values []AskUserFinding) []AskUserFinding { + if values == nil { + return nil + } + redacted := make([]AskUserFinding, len(values)) + for i, value := range values { + value.Stage = evidence.RedactString(value.Stage) + value.Message = evidence.RedactString(value.Message) + redacted[i] = value + } + return redacted +} + +func redactSubmissionEvents(values []SubmissionEvent) []SubmissionEvent { + if values == nil { + return nil + } + redacted := make([]SubmissionEvent, len(values)) + for i, value := range values { + value.Gate = evidence.RedactString(value.Gate) + value.Ref = evidence.RedactString(value.Ref) + value.Kind = evidence.RedactString(value.Kind) + redacted[i] = value + } + return redacted +} diff --git a/internal/evidence/git.go b/internal/evidence/git.go new file mode 100644 index 0000000..9469e6c --- /dev/null +++ b/internal/evidence/git.go @@ -0,0 +1,100 @@ +package evidence + +import ( + "context" + "fmt" + "os" + "sort" + "strings" + + execpkg "github.com/douglasjarquin/made/internal/exec" +) + +func evidenceGitArgs(ctx context.Context, repoPath string, args ...string) ([]string, error) { + filterArgs, err := evidenceFilterOverrides(ctx, repoPath) + if err != nil { + return nil, err + } + commandArgs := []string{ + "-C", repoPath, + "-c", "core.hooksPath=/dev/null", + "-c", "core.fsmonitor=false", + "-c", "diff.external=", + } + commandArgs = append(commandArgs, filterArgs...) + return append(commandArgs, args...), nil +} + +func evidenceFilterOverrides(ctx context.Context, repoPath string) ([]string, error) { + result, err := execpkg.Run(ctx, execpkg.Command{ + Name: "git", + Args: []string{ + "-C", repoPath, + "config", "--local", "--name-only", "--get-regexp", + "^filter\\..+\\.(clean|process|smudge)$", + }, + Env: controlledEvidenceGitEnvironment(), + Timeout: evidenceGitTimeout, + OutputLimit: evidenceGitOutputCap, + }) + if err != nil { + return nil, err + } + if result.ExitCode == 1 { + return nil, nil + } + if result.ExitCode != 0 { + return nil, fmt.Errorf("inspect repository Git filters: git exited %d: %s", result.ExitCode, strings.TrimSpace(string(result.Stderr))) + } + if strings.Contains(string(result.Stdout), "[output truncated]") { + return nil, fmt.Errorf("inspect repository Git filters: output exceeded %d bytes", evidenceGitOutputCap) + } + drivers := make(map[string]struct{}) + for _, key := range strings.Fields(string(result.Stdout)) { + prefix := strings.TrimPrefix(key, "filter.") + dot := strings.LastIndexByte(prefix, '.') + if dot <= 0 { + return nil, fmt.Errorf("inspect repository Git filters: invalid key %q", key) + } + switch prefix[dot+1:] { + case "clean", "process", "smudge": + drivers[prefix[:dot]] = struct{}{} + default: + return nil, fmt.Errorf("inspect repository Git filters: invalid key %q", key) + } + } + names := make([]string, 0, len(drivers)) + for name := range drivers { + names = append(names, name) + } + sort.Strings(names) + overrides := make([]string, 0, len(names)*8) + for _, name := range names { + prefix := "filter." + name + "." + overrides = append(overrides, + "-c", prefix+"clean=/bin/cat", + "-c", prefix+"smudge=/bin/cat", + "-c", prefix+"process=", + "-c", prefix+"required=false", + ) + } + return overrides, nil +} + +func controlledEvidenceGitEnvironment(extraEnv ...string) []string { + env := make([]string, 0, len(os.Environ())+4+len(extraEnv)) + for _, entry := range os.Environ() { + name, _, ok := strings.Cut(entry, "=") + if !ok || strings.HasPrefix(name, "GIT_") || name == "SSH_AUTH_SOCK" { + continue + } + env = append(env, entry) + } + env = append(env, + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_SYSTEM=/dev/null", + "GIT_CONFIG_NOSYSTEM=1", + "GIT_TERMINAL_PROMPT=0", + ) + return append(env, extraEnv...) +} diff --git a/internal/evidence/inrepo.go b/internal/evidence/inrepo.go index cbca258..c29e1d7 100644 --- a/internal/evidence/inrepo.go +++ b/internal/evidence/inrepo.go @@ -1,14 +1,24 @@ package evidence import ( + "bytes" + "context" + "errors" "fmt" + "io" + "io/fs" "os" "path/filepath" + "strings" + + execpkg "github.com/douglasjarquin/made/internal/exec" + "golang.org/x/sys/unix" ) type InRepoStore struct { - RepoPath string - Dir string + RepoPath string + Dir string + RetentionBytes int } func (s *InRepoStore) Location(runID string) string { @@ -19,24 +29,402 @@ func (s *InRepoStore) Location(runID string) string { return filepath.Join(dir, runID) } -func (s *InRepoStore) WriteEvidence(runID string, files map[string][]byte) error { - if runID == "" { - return fmt.Errorf("evidence: runID must not be empty") +func (s *InRepoStore) WriteEvidence(runID string, files map[string][]byte) (err error) { + return s.WriteEvidenceContext(context.Background(), runID, files) +} + +func (s *InRepoStore) WriteEvidenceContext(ctx context.Context, runID string, files map[string][]byte) (err error) { + if err := validateEvidenceInput(runID, files, s.RetentionBytes); err != nil { + return err + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("evidence: write canceled: %w", err) } dir := s.Dir if dir == "" { dir = DefaultDir } - runDir := filepath.Join(s.RepoPath, dir, runID) + if _, err := safePathComponents(dir); err != nil { + return fmt.Errorf("evidence: invalid directory: %w", err) + } + repoPath, err := filepath.Abs(s.RepoPath) + if err != nil { + return fmt.Errorf("evidence: resolve repository path: %w", err) + } + repoPath, err = filepath.EvalSymlinks(repoPath) + if err != nil { + return fmt.Errorf("evidence: resolve repository path: %w", err) + } + evidenceRoot := filepath.Join(repoPath, dir) + if !isContainedPath(repoPath, evidenceRoot) { + return fmt.Errorf("evidence: configured directory %q escapes repository", dir) + } + runDir := filepath.Join(evidenceRoot, runID) + if !isContainedPath(evidenceRoot, runDir) { + return fmt.Errorf("evidence: run ID %q escapes evidence directory", runID) + } + dirParts, err := safePathComponents(dir) + if err != nil { + return fmt.Errorf("evidence: invalid directory: %w", err) + } + runParts, err := safePathComponents(runID) + if err != nil { + return fmt.Errorf("evidence: invalid run ID: %w", err) + } + rootFD, err := unix.Open(repoPath, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if err != nil { + return fmt.Errorf("evidence: open repository: %w", err) + } + defer func() { + if closeErr := unix.Close(rootFD); closeErr != nil && err == nil { + err = fmt.Errorf("evidence: close repository: %w", closeErr) + } + }() + runFD, opened, err := openEvidenceDirectory(rootFD, append(dirParts, runParts...)) + if err != nil { + return fmt.Errorf("evidence: open run directory: %w", err) + } + defer closeEvidenceDirectories(opened) - for name, data := range files { - dest := filepath.Join(runDir, name) - if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { - return fmt.Errorf("evidence: create evidence dir for %q: %w", name, err) + for name, content := range files { + if err := ctx.Err(); err != nil { + return fmt.Errorf("evidence: write canceled: %w", err) } - if err := os.WriteFile(dest, data, 0o644); err != nil { - return fmt.Errorf("evidence: write evidence file %q: %w", name, err) + parts, err := safePathComponents(name) + if err != nil { + return fmt.Errorf("evidence: invalid file path %q: %w", name, err) + } + parentFD := runFD + parentOpened := []int(nil) + if len(parts) > 1 { + parentFD, parentOpened, err = openEvidenceDirectory(runFD, parts[:len(parts)-1]) + if err != nil { + return fmt.Errorf("evidence: open parent for %q: %w", name, err) + } + } + fileFD, openErr := unix.Openat(parentFD, parts[len(parts)-1], unix.O_WRONLY|unix.O_CREAT|unix.O_TRUNC|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0o600) + if openErr != nil { + closeEvidenceDirectories(parentOpened) + return fmt.Errorf("evidence: open evidence file %q: %w", name, openErr) + } + if chmodErr := unix.Fchmod(fileFD, 0o600); chmodErr != nil { + _ = unix.Close(fileFD) + closeEvidenceDirectories(parentOpened) + return fmt.Errorf("evidence: restrict evidence file %q: %w", name, chmodErr) + } + redacted := Redact(content) + writeErr := writeEvidenceFile(fileFD, redacted) + closeErr := unix.Close(fileFD) + closeEvidenceDirectories(parentOpened) + if writeErr != nil { + return fmt.Errorf("evidence: write evidence file %q: %w", name, writeErr) + } + if closeErr != nil { + return fmt.Errorf("evidence: close evidence file %q: %w", name, closeErr) } } return nil } + +func (s *InRepoStore) PublishEvidence(runID string) error { + return s.PublishEvidenceContext(context.Background(), runID) +} + +func (s *InRepoStore) PublishEvidenceContext(ctx context.Context, runID string) error { + if err := validateEvidenceInput(runID, nil, s.RetentionBytes); err != nil { + return err + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("evidence: publish canceled: %w", err) + } + dir := s.Dir + if dir == "" { + dir = DefaultDir + } + repoPath, err := filepath.Abs(s.RepoPath) + if err != nil { + return fmt.Errorf("evidence: resolve repository path: %w", err) + } + repoPath, err = filepath.EvalSymlinks(repoPath) + if err != nil { + return fmt.Errorf("evidence: resolve repository path: %w", err) + } + evidenceRoot := filepath.Join(repoPath, dir) + runDir := filepath.Join(evidenceRoot, runID) + if !isContainedPath(repoPath, evidenceRoot) || !isContainedPath(evidenceRoot, runDir) { + return fmt.Errorf("evidence: configured path escapes repository") + } + if _, err := os.Lstat(runDir); errors.Is(err, os.ErrNotExist) { + return nil + } else if err != nil { + return fmt.Errorf("evidence: inspect run directory: %w", err) + } + info, err := os.Lstat(runDir) + if err != nil { + return fmt.Errorf("evidence: inspect run directory: %w", err) + } + if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return fmt.Errorf("evidence: run path is not a directory") + } + resolvedRoot, err := filepath.EvalSymlinks(evidenceRoot) + if err != nil { + return fmt.Errorf("evidence: resolve evidence directory: %w", err) + } + if resolvedRoot != evidenceRoot { + return fmt.Errorf("evidence: refusing symlinked evidence directory") + } + resolvedRun, err := filepath.EvalSymlinks(runDir) + if err != nil { + return fmt.Errorf("evidence: resolve run directory: %w", err) + } + if resolvedRun != runDir { + return fmt.Errorf("evidence: refusing symlinked run directory") + } + if err := sanitizePublishedEvidence(ctx, runDir, s.RetentionBytes); err != nil { + return err + } + relPath := filepath.Join(dir, runID) + if err := runEvidenceGit(ctx, repoPath, "add", "--", relPath); err != nil { + return fmt.Errorf("evidence: stage in-repo evidence: %w", err) + } + diffArgs, err := evidenceGitArgs(ctx, repoPath, "diff", "--cached", "--quiet", "--", relPath) + if err != nil { + return fmt.Errorf("evidence: prepare staged evidence inspection: %w", err) + } + diff, err := execpkg.Run(ctx, execpkg.Command{ + Name: "git", + Args: diffArgs, + Dir: repoPath, + Env: controlledEvidenceGitEnvironment(), + Timeout: evidenceGitTimeout, + OutputLimit: evidenceGitOutputCap, + }) + if err != nil { + return fmt.Errorf("evidence: inspect staged evidence: %w", err) + } + if diff.ExitCode == 0 { + return nil + } else if diff.ExitCode != 1 { + return fmt.Errorf("evidence: inspect staged evidence failed with exit code %d: %s", diff.ExitCode, RedactString(string(diff.Stdout)+string(diff.Stderr))) + } + titleArgs, err := evidenceGitArgs(ctx, repoPath, "log", "-1", "--format=%s") + if err != nil { + return fmt.Errorf("evidence: prepare commit subject inspection: %w", err) + } + titleResult, err := execpkg.Run(ctx, execpkg.Command{ + Name: "git", + Args: titleArgs, + Dir: repoPath, + Env: controlledEvidenceGitEnvironment(), + Timeout: evidenceGitTimeout, + OutputLimit: evidenceGitOutputCap, + }) + if err != nil { + return fmt.Errorf("evidence: derive commit subject: %w", err) + } + if titleResult.ExitCode != 0 { + return fmt.Errorf("evidence: derive commit subject failed with exit code %d: %s", titleResult.ExitCode, RedactString(string(titleResult.Stdout)+string(titleResult.Stderr))) + } + title := strings.TrimSpace(string(titleResult.Stdout)) + if title == "" { + title = "made: publish evidence" + } + if err := runEvidenceGit(ctx, repoPath, "-c", "commit.gpgsign=false", "commit", "--only", "-m", title, "--", relPath); err != nil { + return fmt.Errorf("evidence: commit in-repo evidence: %w", err) + } + return nil +} + +func runEvidenceGit(ctx context.Context, repoPath string, args ...string) error { + commandArgs, err := evidenceGitArgs(ctx, repoPath, args...) + if err != nil { + return fmt.Errorf("prepare git %s: %w", strings.Join(args, " "), err) + } + result, err := execpkg.Run(ctx, execpkg.Command{ + Name: "git", + Args: commandArgs, + Dir: repoPath, + Env: controlledEvidenceGitEnvironment( + "GIT_AUTHOR_NAME=made-evidence", + "GIT_AUTHOR_EMAIL=made-evidence@localhost", + "GIT_COMMITTER_NAME=made-evidence", + "GIT_COMMITTER_EMAIL=made-evidence@localhost", + ), + Timeout: evidenceGitTimeout, + OutputLimit: evidenceGitOutputCap, + }) + if err != nil { + return fmt.Errorf("git %s: %w", strings.Join(args, " "), err) + } + if result.ExitCode != 0 { + return fmt.Errorf("git %s failed with exit code %d: %s", strings.Join(args, " "), result.ExitCode, RedactString(strings.TrimSpace(string(result.Stdout)+"\n"+string(result.Stderr)))) + } + return nil +} + +func sanitizePublishedEvidence(ctx context.Context, runDir string, retentionBytes int) error { + if retentionBytes <= 0 { + retentionBytes = maxEvidenceBytes + } + total := 0 + err := filepath.WalkDir(runDir, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return fmt.Errorf("evidence: inspect published path %q: %w", path, walkErr) + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("evidence: publish canceled: %w", err) + } + info, err := os.Lstat(path) + if err != nil { + return fmt.Errorf("evidence: inspect published path %q: %w", path, err) + } + if info.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("evidence: refusing symlinked published path %q", path) + } + if info.IsDir() { + return nil + } + if !info.Mode().IsRegular() { + return fmt.Errorf("evidence: refusing non-regular published path %q", path) + } + if info.Size() > maxEvidenceFileBytes { + return fmt.Errorf("evidence: published file %q exceeds %d bytes", path, maxEvidenceFileBytes) + } + data, err := readPublishedEvidence(path) + if err != nil { + return fmt.Errorf("evidence: read published file %q: %w", path, err) + } + redacted := Redact(data) + if len(redacted) > maxEvidenceFileBytes || total+len(redacted) > retentionBytes { + return fmt.Errorf("evidence: published evidence exceeds retention at %q", path) + } + if !bytes.Equal(data, redacted) { + if err := writePublishedEvidence(path, redacted); err != nil { + return fmt.Errorf("evidence: redact published file %q: %w", path, err) + } + } + total += len(redacted) + return nil + }) + if err != nil { + return err + } + return nil +} + +func readPublishedEvidence(path string) ([]byte, error) { + file, err := os.OpenFile(path, os.O_RDONLY|unix.O_NOFOLLOW, 0) + if err != nil { + return nil, err + } + defer func() { _ = file.Close() }() + data, err := io.ReadAll(io.LimitReader(file, maxEvidenceFileBytes+1)) + if err != nil { + return nil, err + } + if len(data) > maxEvidenceFileBytes { + return nil, fmt.Errorf("file exceeds %d bytes", maxEvidenceFileBytes) + } + return data, nil +} + +func writePublishedEvidence(path string, data []byte) error { + fd, err := unix.Open(path, unix.O_WRONLY|unix.O_TRUNC|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0o600) + if err != nil { + return err + } + if err := unix.Fchmod(fd, 0o600); err != nil { + _ = unix.Close(fd) + return err + } + writeErr := writeEvidenceFile(fd, data) + closeErr := unix.Close(fd) + if writeErr != nil { + return writeErr + } + return closeErr +} + +func isContainedPath(root, target string) bool { + rel, err := filepath.Rel(root, target) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel) +} + +func safePathComponents(path string) ([]string, error) { + if path == "" || filepath.IsAbs(path) || filepath.VolumeName(path) != "" { + return nil, errors.New("path must be relative") + } + parts := strings.Split(path, string(filepath.Separator)) + clean := make([]string, 0, len(parts)) + for _, part := range parts { + if part == "" { + continue + } + if part == "." || part == ".." { + return nil, errors.New("path traversal is not allowed") + } + clean = append(clean, part) + } + if len(clean) == 0 { + return nil, errors.New("path must not be empty") + } + return clean, nil +} + +func openEvidenceDirectory(rootFD int, parts []string) (int, []int, error) { + current := rootFD + opened := make([]int, 0, len(parts)) + for _, part := range parts { + fd, err := unix.Openat(current, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if errors.Is(err, unix.ENOENT) { + if mkdirErr := unix.Mkdirat(current, part, 0o700); mkdirErr != nil && !errors.Is(mkdirErr, unix.EEXIST) { + closeEvidenceDirectories(opened) + return -1, nil, mkdirErr + } + fd, err = unix.Openat(current, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + } + if err != nil { + closeEvidenceDirectories(opened) + return -1, nil, err + } + if err := restrictEvidenceDirectory(fd); err != nil { + _ = unix.Close(fd) + closeEvidenceDirectories(opened) + return -1, nil, err + } + opened = append(opened, fd) + current = fd + } + return current, opened, nil +} + +func restrictEvidenceDirectory(fd int) error { + var stat unix.Stat_t + if err := unix.Fstat(fd, &stat); err != nil { + return err + } + if stat.Mode&0o077 != 0 { + return unix.Fchmod(fd, 0o700) + } + return nil +} + +func closeEvidenceDirectories(fds []int) { + for i := len(fds) - 1; i >= 0; i-- { + _ = unix.Close(fds[i]) + } +} + +func writeEvidenceFile(fd int, data []byte) error { + for len(data) > 0 { + n, err := unix.Write(fd, data) + if err != nil { + return err + } + if n == 0 { + return errors.New("short write") + } + data = data[n:] + } + return unix.Fsync(fd) +} diff --git a/internal/evidence/orphan.go b/internal/evidence/orphan.go index 392e65a..c5dacb3 100644 --- a/internal/evidence/orphan.go +++ b/internal/evidence/orphan.go @@ -1,18 +1,39 @@ package evidence import ( - "bytes" + "context" "fmt" "os" - "os/exec" "path" "sort" "strings" + + execpkg "github.com/douglasjarquin/made/internal/exec" ) type OrphanBranchStore struct { - RepoPath string - Branch string + RepoPath string + Branch string + RetentionBytes int +} + +func (s *OrphanBranchStore) PublishEvidence(runID string) error { + return s.PublishEvidenceContext(context.Background(), runID) +} + +func (s *OrphanBranchStore) PublishEvidenceContext(ctx context.Context, runID string) error { + if err := validateEvidenceInput(runID, nil, s.RetentionBytes); err != nil { + return err + } + branch := s.Branch + if branch == "" { + branch = DefaultBranch + } + ref := "refs/heads/" + branch + if _, err := s.runGit(ctx, nil, nil, "push", "origin", ref+":"+ref); err != nil { + return fmt.Errorf("evidence: publish branch %s: %w", branch, err) + } + return nil } // Location names where a run's evidence commit lives on the orphan branch, @@ -33,8 +54,12 @@ func (s *OrphanBranchStore) Location(runID string) string { // commit-tree is what gives the branch no shared history with the default // branch. func (s *OrphanBranchStore) WriteEvidence(runID string, files map[string][]byte) error { - if runID == "" { - return fmt.Errorf("evidence: runID must not be empty") + return s.WriteEvidenceContext(context.Background(), runID, files) +} + +func (s *OrphanBranchStore) WriteEvidenceContext(ctx context.Context, runID string, files map[string][]byte) error { + if err := validateEvidenceInput(runID, files, s.RetentionBytes); err != nil { + return err } branch := s.Branch if branch == "" { @@ -49,10 +74,10 @@ func (s *OrphanBranchStore) WriteEvidence(runID string, files map[string][]byte) defer func() { _ = os.RemoveAll(idxDir) }() indexEnv := []string{"GIT_INDEX_FILE=" + idxDir + "/index"} - parent, err := s.runGit(nil, nil, "rev-parse", "--verify", ref) + parent, err := s.runGit(ctx, nil, nil, "rev-parse", "--verify", ref) hasParent := err == nil if hasParent { - if _, err := s.runGit(indexEnv, nil, "read-tree", parent); err != nil { + if _, err := s.runGit(ctx, indexEnv, nil, "read-tree", parent); err != nil { return fmt.Errorf("evidence: seed scratch index from existing evidence branch: %w", err) } } @@ -64,17 +89,17 @@ func (s *OrphanBranchStore) WriteEvidence(runID string, files map[string][]byte) sort.Strings(names) for _, name := range names { - blobSHA, err := s.runGit(indexEnv, files[name], "hash-object", "-w", "--stdin") + blobSHA, err := s.runGit(ctx, indexEnv, Redact(files[name]), "hash-object", "-w", "--stdin") if err != nil { return fmt.Errorf("evidence: hash evidence file %q: %w", name, err) } entryPath := path.Join(runID, name) - if _, err := s.runGit(indexEnv, nil, "update-index", "--add", "--cacheinfo", "100644,"+blobSHA+","+entryPath); err != nil { + if _, err := s.runGit(ctx, indexEnv, nil, "update-index", "--add", "--cacheinfo", "100644,"+blobSHA+","+entryPath); err != nil { return fmt.Errorf("evidence: stage evidence file %q: %w", name, err) } } - treeSHA, err := s.runGit(indexEnv, nil, "write-tree") + treeSHA, err := s.runGit(ctx, indexEnv, nil, "write-tree") if err != nil { return fmt.Errorf("evidence: write evidence tree: %w", err) } @@ -83,7 +108,7 @@ func (s *OrphanBranchStore) WriteEvidence(runID string, files map[string][]byte) if hasParent { commitArgs = append(commitArgs, "-p", parent) } - commitSHA, err := s.runGit(commitAuthorEnv(), nil, commitArgs...) + commitSHA, err := s.runGit(ctx, commitAuthorEnv(), nil, commitArgs...) if err != nil { return fmt.Errorf("evidence: commit evidence tree: %w", err) } @@ -92,26 +117,33 @@ func (s *OrphanBranchStore) WriteEvidence(runID string, files map[string][]byte) if hasParent { updateArgs = append(updateArgs, parent) } - if _, err := s.runGit(nil, nil, updateArgs...); err != nil { + if _, err := s.runGit(ctx, nil, nil, updateArgs...); err != nil { return fmt.Errorf("evidence: update evidence branch ref: %w", err) } return nil } -func (s *OrphanBranchStore) runGit(extraEnv []string, stdin []byte, args ...string) (string, error) { - cmd := exec.Command("git", args...) - cmd.Dir = s.RepoPath - if extraEnv != nil { - cmd.Env = append(os.Environ(), extraEnv...) - } - if stdin != nil { - cmd.Stdin = bytes.NewReader(stdin) - } - out, err := cmd.CombinedOutput() +func (s *OrphanBranchStore) runGit(ctx context.Context, extraEnv []string, stdin []byte, args ...string) (string, error) { + commandArgs, err := evidenceGitArgs(ctx, s.RepoPath, args...) if err != nil { - return "", fmt.Errorf("git %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(out))) + return "", fmt.Errorf("prepare git %s: %w", strings.Join(args, " "), err) + } + result, err := execpkg.Run(ctx, execpkg.Command{ + Name: "git", + Args: commandArgs, + Dir: s.RepoPath, + Env: controlledEvidenceGitEnvironment(extraEnv...), + Stdin: stdin, + Timeout: evidenceGitTimeout, + OutputLimit: evidenceGitOutputCap, + }) + if err != nil { + return "", fmt.Errorf("git %s: %w", strings.Join(args, " "), err) + } + if result.ExitCode != 0 { + return "", fmt.Errorf("git %s failed with exit code %d: %s", strings.Join(args, " "), result.ExitCode, RedactString(strings.TrimSpace(string(result.Stdout)+"\n"+string(result.Stderr)))) } - return strings.TrimSpace(string(out)), nil + return strings.TrimSpace(RedactString(string(result.Stdout))), nil } func commitAuthorEnv() []string { diff --git a/internal/evidence/orphan_test.go b/internal/evidence/orphan_test.go index 8f7fa8e..4eaba77 100644 --- a/internal/evidence/orphan_test.go +++ b/internal/evidence/orphan_test.go @@ -1,6 +1,7 @@ package evidence_test import ( + "path/filepath" "strings" "testing" @@ -74,3 +75,21 @@ func TestOrphanBranchStoreDefaultBranchName(t *testing.T) { t.Fatalf("expected default evidence branch %q to exist: %v", evidence.DefaultBranch, err) } } + +func TestOrphanBranchStorePublishesEvidenceToOrigin(t *testing.T) { + repo := initTargetRepo(t) + remote := filepath.Join(t.TempDir(), "remote.git") + run(t, t.TempDir(), "git", "init", "--bare", "-q", remote) + run(t, repo, "git", "remote", "add", "origin", remote) + store := &evidence.OrphanBranchStore{RepoPath: repo, Branch: "made-evidence"} + if err := store.WriteEvidence("run-remote", map[string][]byte{"summary.txt": []byte("published\n")}); err != nil { + t.Fatalf("WriteEvidence: %v", err) + } + if err := store.PublishEvidence("run-remote"); err != nil { + t.Fatalf("PublishEvidence: %v", err) + } + tree := run(t, remote, "git", "ls-tree", "-r", "--name-only", "refs/heads/made-evidence") + if !strings.Contains(tree, "run-remote/summary.txt") { + t.Fatalf("remote evidence branch lacks published file: %s", tree) + } +} diff --git a/internal/evidence/redact.go b/internal/evidence/redact.go new file mode 100644 index 0000000..22b0100 --- /dev/null +++ b/internal/evidence/redact.go @@ -0,0 +1,33 @@ +package evidence + +import ( + "bytes" + "regexp" +) + +var evidenceSecretPatterns = []*regexp.Regexp{ + regexp.MustCompile(`([a-zA-Z][a-zA-Z0-9+.-]*://)[^\s/@]+@`), + regexp.MustCompile(`(?i)(authorization:\s*(?:bearer|basic)\s+)[^\s]+`), + regexp.MustCompile(`(?i)(\b(?:token|api[_-]?key|secret|password|passwd|access[_-]?token|refresh[_-]?token|client[_-]?secret)\b\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;&}]+)`), + regexp.MustCompile(`(?i)(["']?(?:token|api[_-]?key|secret|password|passwd|access[_-]?token|refresh[_-]?token|client[_-]?secret)["']?\s*:\s*)(?:"[^"]*"|'[^']*'|[^,\s}]+)`), + regexp.MustCompile(`(?i)(x-api-key:\s*)[^\s]+`), + regexp.MustCompile(`(?i)(cookie:\s*)[^\r\n]+`), + regexp.MustCompile(`(?i)(token=|access_token=|refresh_token=|client_secret=)[^&\s]+`), + regexp.MustCompile(`(?i)(\b(?:database[_-]?url|redis[_-]?url)\s*=\s*)(?:"[^"]*"|'[^']*'|[^\r\n\s]+)`), + regexp.MustCompile(`(?i)(\b[A-Z][A-Z0-9_]*(?:token|api[_-]?key|secret|password|passwd|database[_-]?url|redis[_-]?url)[A-Z0-9_]*\s*=\s*)(?:"[^"]*"|'[^']*'|[^\r\n\s]+)`), + regexp.MustCompile(`\b(?:ghp_|github_pat_|sk-|xox[baprs]-)[A-Za-z0-9._-]+`), + regexp.MustCompile(`\bAKIA[0-9A-Z]{16}\b`), + regexp.MustCompile(`(?s)-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----`), +} + +func Redact(data []byte) []byte { + redacted := bytes.Clone(data) + for _, pattern := range evidenceSecretPatterns { + redacted = pattern.ReplaceAll(redacted, []byte("$1[REDACTED]")) + } + return redacted +} + +func RedactString(value string) string { + return string(Redact([]byte(value))) +} diff --git a/internal/evidence/remediation_contract_test.go b/internal/evidence/remediation_contract_test.go new file mode 100644 index 0000000..a7f7b49 --- /dev/null +++ b/internal/evidence/remediation_contract_test.go @@ -0,0 +1,250 @@ +package evidence_test + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/douglasjarquin/made/internal/evidence" +) + +func TestInRepoStore_RejectsPathTraversalAndOversizedEvidence(t *testing.T) { + store := &evidence.InRepoStore{RepoPath: t.TempDir(), Dir: ".made/evidence"} + + if err := store.WriteEvidence("run-1", map[string][]byte{ + "../escaped.txt": []byte("must stay inside the run"), + }); err == nil { + t.Fatal("evidence store accepted a path traversal outside the run evidence directory") + } + + if err := store.WriteEvidence("run-1", map[string][]byte{ + "large.log": []byte(strings.Repeat("x", 2<<20)), + }); err == nil { + t.Fatal("evidence store accepted output beyond the bounded retention limit") + } +} + +func TestNewStoreAppliesConfiguredRetentionBound(t *testing.T) { + store, ok := evidence.NewStore(t.TempDir(), evidence.Config{StoreInRepo: true, RetentionBytes: 8}).(*evidence.InRepoStore) + if !ok { + t.Fatal("NewStore returned the wrong store type") + } + if store.RetentionBytes != 8 { + t.Fatalf("retention bytes = %d, want 8", store.RetentionBytes) + } + if err := store.WriteEvidence("run-1", map[string][]byte{"log.txt": []byte("123456789")}); err == nil { + t.Fatal("configured evidence retention bound was not enforced") + } +} + +func TestInRepoStore_RedactsPublishedSecrets(t *testing.T) { + repo := t.TempDir() + store := &evidence.InRepoStore{RepoPath: repo, Dir: ".made/evidence"} + input := "Authorization: Bearer bearer-secret\napi_key=api-secret\n\"access_token\": \"json-secret\"\nx-api-key: header-secret\ntoken=query-secret&ok=1\nAWS_SECRET_ACCESS_KEY=aws-secret\nOPENAI_API_KEY=openai-secret\nDATABASE_URL=postgres://user:password@db.example/app\nghp_1234567890abcdef\nAKIA1234567890ABCDEF\n-----BEGIN RSA PRIVATE KEY-----\nprivate-secret\n-----END RSA PRIVATE KEY-----\n" + if err := store.WriteEvidence("run-1", map[string][]byte{"log.txt": []byte(input)}); err != nil { + t.Fatalf("WriteEvidence: %v", err) + } + data, err := os.ReadFile(filepath.Join(repo, ".made/evidence", "run-1", "log.txt")) + if err != nil { + t.Fatalf("read evidence: %v", err) + } + for _, secret := range []string{"bearer-secret", "api-secret", "json-secret", "header-secret", "query-secret", "aws-secret", "openai-secret", "postgres://user:password@db.example/app", "ghp_1234567890abcdef", "AKIA1234567890ABCDEF", "private-secret"} { + if strings.Contains(string(data), secret) { + t.Fatalf("published evidence retained %q: %q", secret, data) + } + } +} + +func TestInRepoStore_PublishesEvidenceInAccessibleCommit(t *testing.T) { + repo := t.TempDir() + runEvidenceGit(t, repo, "init", "-q", "-b", "main") + runEvidenceGit(t, repo, "config", "user.name", "evidence-test") + runEvidenceGit(t, repo, "config", "user.email", "evidence-test@example.com") + if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("fixture\n"), 0o600); err != nil { + t.Fatalf("write fixture: %v", err) + } + runEvidenceGit(t, repo, "add", "README.md") + runEvidenceGit(t, repo, "commit", "-q", "-m", "fixture") + + store := &evidence.InRepoStore{RepoPath: repo, Dir: ".made/evidence"} + if err := store.WriteEvidence("run-1", map[string][]byte{"log.txt": []byte("visible evidence\n")}); err != nil { + t.Fatalf("WriteEvidence: %v", err) + } + if err := store.PublishEvidence("run-1"); err != nil { + t.Fatalf("PublishEvidence: %v", err) + } + if got := strings.TrimSpace(string(runEvidenceGit(t, repo, "show", "--format=", "--name-only", "HEAD"))); !strings.Contains(got, ".made/evidence/run-1/log.txt") { + t.Fatalf("published commit does not contain evidence path: %q", got) + } +} + +func TestInRepoStore_PublishSuppressesRepositoryHooksAndAmbientGitConfig(t *testing.T) { + repo := initTargetRepo(t) + store := &evidence.InRepoStore{RepoPath: repo, Dir: ".made/evidence"} + if err := store.WriteEvidence("run-hooks", map[string][]byte{"log.txt": []byte("visible evidence\n")}); err != nil { + t.Fatalf("WriteEvidence: %v", err) + } + hooksDir := t.TempDir() + hookMarker := filepath.Join(t.TempDir(), "hook-fired") + if err := os.WriteFile(filepath.Join(hooksDir, "pre-commit"), []byte("#!/bin/sh\nprintf fired > '"+hookMarker+"'\n"), 0o700); err != nil { + t.Fatalf("write pre-commit hook: %v", err) + } + t.Setenv("GIT_CONFIG_COUNT", "2") + t.Setenv("GIT_CONFIG_KEY_0", "core.hooksPath") + t.Setenv("GIT_CONFIG_VALUE_0", hooksDir) + t.Setenv("GIT_CONFIG_KEY_1", "commit.gpgsign") + t.Setenv("GIT_CONFIG_VALUE_1", "false") + + if err := store.PublishEvidence("run-hooks"); err != nil { + t.Fatalf("PublishEvidence: %v", err) + } + if _, err := os.Stat(hookMarker); !os.IsNotExist(err) { + t.Fatalf("evidence publication executed an inherited Git hook: %v", err) + } +} + +func TestInRepoStore_PublishRejectsSymlinkedEvidenceFile(t *testing.T) { + repo := initTargetRepo(t) + store := &evidence.InRepoStore{RepoPath: repo, Dir: ".made/evidence"} + if err := store.WriteEvidence("run-symlink", map[string][]byte{"safe.log": []byte("safe\n")}); err != nil { + t.Fatalf("WriteEvidence: %v", err) + } + outside := filepath.Join(t.TempDir(), "outside.log") + if err := os.WriteFile(outside, []byte("token=outside-secret\n"), 0o600); err != nil { + t.Fatalf("write outside file: %v", err) + } + leakPath := filepath.Join(repo, ".made", "evidence", "run-symlink", "leak.log") + if err := os.Symlink(outside, leakPath); err != nil { + t.Fatalf("create evidence symlink: %v", err) + } + + if err := store.PublishEvidence("run-symlink"); err == nil { + t.Fatal("PublishEvidence followed or published a symlinked evidence file") + } + if _, err := os.Stat(outside); err != nil { + t.Fatalf("outside evidence target disappeared: %v", err) + } +} + +func TestInRepoStore_PublishRedactsInjectedEvidence(t *testing.T) { + repo := initTargetRepo(t) + store := &evidence.InRepoStore{RepoPath: repo, Dir: ".made/evidence", RetentionBytes: 128} + if err := store.WriteEvidence("run-injected", map[string][]byte{"safe.log": []byte("safe\n")}); err != nil { + t.Fatalf("WriteEvidence: %v", err) + } + injectedPath := filepath.Join(repo, ".made", "evidence", "run-injected", "injected.log") + if err := os.WriteFile(injectedPath, []byte("token=injected-secret\n"), 0o600); err != nil { + t.Fatalf("write injected evidence: %v", err) + } + + if err := store.PublishEvidence("run-injected"); err != nil { + t.Fatalf("PublishEvidence: %v", err) + } + data := run(t, repo, "git", "show", "HEAD:.made/evidence/run-injected/injected.log") + if strings.Contains(data, "injected-secret") { + t.Fatalf("published injected evidence retained secret: %q", data) + } +} + +func TestInRepoStore_PublishRejectsOversizedInjectedEvidence(t *testing.T) { + repo := initTargetRepo(t) + store := &evidence.InRepoStore{RepoPath: repo, Dir: ".made/evidence", RetentionBytes: 16} + if err := store.WriteEvidence("run-large", map[string][]byte{"safe.log": []byte("safe\n")}); err != nil { + t.Fatalf("WriteEvidence: %v", err) + } + largePath := filepath.Join(repo, ".made", "evidence", "run-large", "large.log") + if err := os.WriteFile(largePath, []byte(strings.Repeat("x", 17)), 0o600); err != nil { + t.Fatalf("write oversized evidence: %v", err) + } + + if err := store.PublishEvidence("run-large"); err == nil { + t.Fatal("PublishEvidence accepted evidence beyond the configured retention bound") + } +} + +func runEvidenceGit(t *testing.T, dir string, args ...string) []byte { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v: %s", args, err, out) + } + return out +} + +func TestInRepoStore_UsesPrivateEvidencePermissions(t *testing.T) { + repo := t.TempDir() + store := &evidence.InRepoStore{RepoPath: repo, Dir: ".made/evidence"} + if err := os.MkdirAll(filepath.Join(repo, ".made", "evidence", "run-1"), 0o755); err != nil { + t.Fatalf("create existing evidence directories: %v", err) + } + if err := os.WriteFile(filepath.Join(repo, ".made", "evidence", "run-1", "log.txt"), []byte("old"), 0o644); err != nil { + t.Fatalf("create existing evidence file: %v", err) + } + if err := store.WriteEvidence("run-1", map[string][]byte{"log.txt": []byte("bounded")}); err != nil { + t.Fatalf("WriteEvidence: %v", err) + } + for _, tc := range []struct { + name string + want os.FileMode + }{ + {name: ".made/evidence", want: 0o700}, + {name: ".made/evidence/run-1", want: 0o700}, + {name: ".made/evidence/run-1/log.txt", want: 0o600}, + } { + info, err := os.Stat(filepath.Join(repo, tc.name)) + if err != nil { + t.Fatalf("stat %s: %v", tc.name, err) + } + if got := info.Mode().Perm(); got != tc.want { + t.Errorf("permissions for %s = %o, want %o", tc.name, got, tc.want) + } + } +} + +func TestInRepoStore_RejectsSymlinkedEvidenceDirectory(t *testing.T) { + repo := t.TempDir() + outside := t.TempDir() + if err := os.MkdirAll(filepath.Join(repo, ".made"), 0o755); err != nil { + t.Fatalf("create evidence parent: %v", err) + } + if err := os.Symlink(outside, filepath.Join(repo, ".made", "evidence")); err != nil { + t.Fatalf("create evidence symlink: %v", err) + } + store := &evidence.InRepoStore{RepoPath: repo, Dir: ".made/evidence"} + if err := store.WriteEvidence("run-1", map[string][]byte{"log.txt": []byte("must remain inside")}); err == nil { + t.Fatal("evidence store followed a symlinked evidence directory") + } + entries, err := os.ReadDir(outside) + if err != nil { + t.Fatalf("read outside directory: %v", err) + } + if len(entries) != 0 { + t.Fatalf("evidence escaped through symlink: %+v", entries) + } +} + +func TestInRepoStore_PublishRejectsSymlinkedEvidenceRoot(t *testing.T) { + repo := initTargetRepo(t) + outside := t.TempDir() + if err := os.MkdirAll(filepath.Join(outside, "run-escape"), 0o700); err != nil { + t.Fatalf("create outside run: %v", err) + } + if err := os.WriteFile(filepath.Join(outside, "run-escape", "leak.log"), []byte("token=outside-secret\n"), 0o600); err != nil { + t.Fatalf("write outside evidence: %v", err) + } + if err := os.MkdirAll(filepath.Join(repo, ".made"), 0o700); err != nil { + t.Fatalf("create evidence parent: %v", err) + } + if err := os.Symlink(outside, filepath.Join(repo, ".made", "evidence")); err != nil { + t.Fatalf("create evidence root symlink: %v", err) + } + + store := &evidence.InRepoStore{RepoPath: repo, Dir: ".made/evidence"} + if err := store.PublishEvidence("run-escape"); err == nil { + t.Fatal("PublishEvidence followed a symlinked evidence root") + } +} diff --git a/internal/evidence/store.go b/internal/evidence/store.go index d64d877..025f08f 100644 --- a/internal/evidence/store.go +++ b/internal/evidence/store.go @@ -1,23 +1,71 @@ package evidence +import ( + "context" + "fmt" + "path/filepath" + "strings" + "time" +) + const ( - DefaultBranch = "made-evidence" - DefaultDir = ".made/evidence" + DefaultBranch = "made-evidence" + DefaultDir = ".made/evidence" + maxEvidenceFileBytes = 1 << 20 + maxEvidenceBytes = 4 << 20 + evidenceGitOutputCap = 1 << 20 ) +var evidenceGitTimeout = 30 * time.Second + type Config struct { - StoreInRepo bool - Dir string - Branch string + StoreInRepo bool + Dir string + Branch string + RetentionBytes int +} + +func validateEvidenceInput(runID string, files map[string][]byte, retentionBytes int) error { + cleanRunID := filepath.Clean(runID) + if runID == "" || cleanRunID != runID || filepath.IsAbs(runID) || cleanRunID == "." || cleanRunID == ".." || strings.HasPrefix(cleanRunID, ".."+string(filepath.Separator)) { + return fmt.Errorf("evidence: invalid runID %q", runID) + } + total := 0 + if retentionBytes <= 0 { + retentionBytes = maxEvidenceBytes + } + for name, data := range files { + clean := filepath.Clean(name) + if name == "" || filepath.IsAbs(name) || clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) || clean == ".git" || strings.HasPrefix(clean, ".git"+string(filepath.Separator)) { + return fmt.Errorf("evidence: path %q escapes run evidence directory", name) + } + if len(data) > maxEvidenceFileBytes || total+len(data) > retentionBytes { + return fmt.Errorf("evidence: retention limit exceeded for %q", name) + } + total += len(data) + } + return nil } type Store interface { WriteEvidence(runID string, files map[string][]byte) error } +type ContextStore interface { + WriteEvidenceContext(ctx context.Context, runID string, files map[string][]byte) error +} + +type Publisher interface { + PublishEvidence(runID string) error +} + +type ContextPublisher interface { + PublishEvidenceContext(ctx context.Context, runID string) error +} + func NewStore(repoPath string, cfg Config) Store { if cfg.StoreInRepo { - return &InRepoStore{RepoPath: repoPath, Dir: cfg.Dir} + return &InRepoStore{RepoPath: repoPath, Dir: cfg.Dir, RetentionBytes: cfg.RetentionBytes} } - return &OrphanBranchStore{RepoPath: repoPath, Branch: cfg.Branch} + return &OrphanBranchStore{RepoPath: repoPath, Branch: cfg.Branch, RetentionBytes: cfg.RetentionBytes} } diff --git a/internal/evidence/timeout_test.go b/internal/evidence/timeout_test.go new file mode 100644 index 0000000..73455c1 --- /dev/null +++ b/internal/evidence/timeout_test.go @@ -0,0 +1,69 @@ +package evidence + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestInRepoStore_PublishHonorsGitTimeout(t *testing.T) { + repo := t.TempDir() + runEvidenceGitTest(t, repo, "init", "-q", "-b", "main") + runEvidenceGitTest(t, repo, "config", "user.name", "evidence-test") + runEvidenceGitTest(t, repo, "config", "user.email", "evidence-test@example.com") + if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("fixture\n"), 0o600); err != nil { + t.Fatalf("write fixture: %v", err) + } + runEvidenceGitTest(t, repo, "add", "README.md") + runEvidenceGitTest(t, repo, "commit", "-q", "-m", "fixture") + + store := &InRepoStore{RepoPath: repo, Dir: ".made/evidence"} + if err := store.WriteEvidence("run-timeout", map[string][]byte{"log.txt": []byte("evidence\n")}); err != nil { + t.Fatalf("WriteEvidence: %v", err) + } + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatalf("locate real git: %v", err) + } + shimDir := t.TempDir() + shimPath := filepath.Join(shimDir, "git") + shim := fmt.Sprintf("#!/bin/sh\ncase \"$*\" in\n *' add '*) sleep 5 ;;\n *) exec %q \"$@\" ;;\nesac\n", realGit) + if err := os.WriteFile(shimPath, []byte(shim), 0o700); err != nil { + t.Fatalf("write blocking git shim: %v", err) + } + t.Setenv("PATH", shimDir+string(os.PathListSeparator)+os.Getenv("PATH")) + + originalTimeout := evidenceGitTimeout + evidenceGitTimeout = 50 * time.Millisecond + t.Cleanup(func() { evidenceGitTimeout = originalTimeout }) + started := time.Now() + err = store.PublishEvidenceContext(context.Background(), "run-timeout") + if err == nil { + t.Fatal("PublishEvidenceContext returned nil despite a blocking git command") + } + if elapsed := time.Since(started); elapsed > time.Second { + t.Fatalf("PublishEvidenceContext exceeded bounded timeout: %s", elapsed) + } +} + +func runEvidenceGitTest(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_CONFIG_GLOBAL=/dev/null", + "SSH_AUTH_SOCK=", + "GIT_AUTHOR_NAME=evidence-test", + "GIT_AUTHOR_EMAIL=evidence-test@example.com", + "GIT_COMMITTER_NAME=evidence-test", + "GIT_COMMITTER_EMAIL=evidence-test@example.com", + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v: %s", args, err, strings.TrimSpace(string(output))) + } +} diff --git a/internal/exec/exec.go b/internal/exec/exec.go index 0079079..a63db0f 100644 --- a/internal/exec/exec.go +++ b/internal/exec/exec.go @@ -11,11 +11,13 @@ import ( ) type Command struct { - Name string - Args []string - Dir string - Env []string - Timeout time.Duration + Name string + Args []string + Dir string + Env []string + Stdin []byte + Timeout time.Duration + OutputLimit int } type Result struct { @@ -36,9 +38,18 @@ func Run(ctx context.Context, cmd Command) (*Result, error) { c.Env = cmd.Env c.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - var stdout, stderr bytes.Buffer + limit := cmd.OutputLimit + if limit <= 0 { + limit = defaultOutputLimit + } + var stdout, stderr boundedBuffer + stdout.limit = limit + stderr.limit = limit c.Stdout = &stdout c.Stderr = &stderr + if cmd.Stdin != nil { + c.Stdin = bytes.NewReader(cmd.Stdin) + } if err := c.Start(); err != nil { return nil, fmt.Errorf("start %s: %w", cmd.Name, err) @@ -65,6 +76,42 @@ func Run(ctx context.Context, cmd Command) (*Result, error) { } } +const defaultOutputLimit = 4 << 20 + +type boundedBuffer struct { + data []byte + limit int + truncated bool +} + +func (b *boundedBuffer) Write(data []byte) (int, error) { + remaining := b.limit - len(b.data) + if remaining > 0 { + if len(data) > remaining { + b.data = append(b.data, data[:remaining]...) + b.truncated = true + } else { + b.data = append(b.data, data...) + } + } else if len(data) > 0 { + b.truncated = true + } + return len(data), nil +} + +func (b *boundedBuffer) Bytes() []byte { + data := append([]byte(nil), b.data...) + if !b.truncated { + return data + } + marker := []byte("\n[output truncated]\n") + if len(marker) >= b.limit { + return append([]byte(nil), marker[:b.limit]...) + } + data = data[:b.limit-len(marker)] + return append(data, marker...) +} + // killGroup signals the process's entire group, not just the direct child: // a negative pid tells the kernel to deliver the signal to every process // sharing that group ID, which is how a backgrounded grandchild gets reaped diff --git a/internal/exec/exec_test.go b/internal/exec/exec_test.go index 2f6b142..fd8c70f 100644 --- a/internal/exec/exec_test.go +++ b/internal/exec/exec_test.go @@ -41,6 +41,24 @@ func TestRunCapturesStderr(t *testing.T) { } } +func TestRunBoundsStdoutAndStderrWhileProcessRuns(t *testing.T) { + const limit = 64 + res, err := exec.Run(context.Background(), exec.Command{ + Name: "sh", + Args: []string{"-c", "printf '%*s' 10000 x; printf '%*s' 10000 y >&2"}, + OutputLimit: limit, + }) + if err != nil { + t.Fatalf("Run returned error: %v", err) + } + if len(res.Stdout) > limit || len(res.Stderr) > limit { + t.Fatalf("output exceeded limit: stdout=%d stderr=%d limit=%d", len(res.Stdout), len(res.Stderr), limit) + } + if !strings.Contains(string(res.Stdout), "output truncated") || !strings.Contains(string(res.Stderr), "output truncated") { + t.Fatalf("bounded output omitted truncation markers: stdout=%q stderr=%q", res.Stdout, res.Stderr) + } +} + func TestRunCancellationReapsGrandchildren(t *testing.T) { if _, err := osexec.LookPath("pgrep"); err != nil { t.Skip("pgrep not available on this system") diff --git a/internal/github/client.go b/internal/github/client.go index c4717da..8ebd0d7 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "os" + "strconv" "strings" "time" @@ -18,6 +19,8 @@ type Client struct { Timeout time.Duration } +const maxCheckLogBytes = 64 * 1024 + type AuthError struct { Detail string } @@ -33,6 +36,39 @@ type CreatePROptions struct { Head string } +type Check struct { + Name string `json:"name"` + Status string `json:"state"` + Conclusion string `json:"conclusion"` + WorkflowRunID string `json:"workflowRunId"` + DetailsURL string `json:"detailsUrl"` +} + +func (c *Check) UnmarshalJSON(data []byte) error { + var wire struct { + Name string `json:"name"` + Status string `json:"state"` + Conclusion string `json:"conclusion"` + WorkflowRunID json.RawMessage `json:"workflowRunId"` + DetailsURL string `json:"detailsUrl"` + } + if err := json.Unmarshal(data, &wire); err != nil { + return err + } + var runID string + if len(wire.WorkflowRunID) > 0 && string(wire.WorkflowRunID) != "null" { + if err := json.Unmarshal(wire.WorkflowRunID, &runID); err != nil { + var numeric json.Number + if err := json.Unmarshal(wire.WorkflowRunID, &numeric); err != nil { + return fmt.Errorf("github: parse workflow run ID: %w", err) + } + runID = numeric.String() + } + } + *c = Check{Name: wire.Name, Status: wire.Status, Conclusion: wire.Conclusion, WorkflowRunID: runID, DetailsURL: wire.DetailsURL} + return nil +} + func (c *Client) AuthStatus(ctx context.Context) error { res, err := c.run(ctx, "auth", "status") if err != nil { @@ -48,6 +84,11 @@ func (c *Client) CreatePR(ctx context.Context, opts CreatePROptions) (string, er if err := c.AuthStatus(ctx); err != nil { return "", err } + if existing, err := c.findOpenPR(ctx, opts); err != nil { + return "", err + } else if existing != "" { + return existing, nil + } args := []string{"pr", "create", "--title", opts.Title, "--body", opts.Body} if opts.Base != "" { @@ -64,7 +105,32 @@ func (c *Client) CreatePR(ctx context.Context, opts CreatePROptions) (string, er if res.ExitCode != 0 { return "", fmt.Errorf("github: gh pr create failed: %s", strings.TrimSpace(string(res.Stderr))) } - return lastLine(res.Stdout), nil + url := lastLine(res.Stdout) + if url == "" { + return "", fmt.Errorf("github: gh pr create returned an empty URL") + } + return url, nil +} + +func (c *Client) findOpenPR(ctx context.Context, opts CreatePROptions) (string, error) { + args := []string{"pr", "list", "--state", "open", "--base", opts.Base, "--head", opts.Head, "--json", "url"} + res, err := c.run(ctx, args...) + if err != nil { + return "", fmt.Errorf("github: run gh pr list: %w", err) + } + if res.ExitCode != 0 { + return "", fmt.Errorf("github: gh pr list failed: %s", strings.TrimSpace(string(res.Stderr))) + } + var entries []struct { + URL string `json:"url"` + } + if err := json.Unmarshal(res.Stdout, &entries); err != nil { + return "", fmt.Errorf("github: parse gh pr list output: %w", err) + } + if len(entries) == 0 { + return "", nil + } + return entries[0].URL, nil } func (c *Client) MergeableState(ctx context.Context, prURL string) (string, error) { @@ -90,6 +156,9 @@ func (c *Client) MergeableState(ctx context.Context, prURL string) (string, erro } func (c *Client) CheckLogs(ctx context.Context, runID string) (string, error) { + if err := validateWorkflowRunID(runID); err != nil { + return "", err + } if err := c.AuthStatus(ctx); err != nil { return "", err } @@ -101,10 +170,17 @@ func (c *Client) CheckLogs(ctx context.Context, runID string) (string, error) { if res.ExitCode != 0 { return "", fmt.Errorf("github: gh run view failed: %s", strings.TrimSpace(string(res.Stderr))) } - return string(res.Stdout), nil + output := res.Stdout + if len(output) > maxCheckLogBytes { + output = append(append([]byte(nil), output[:maxCheckLogBytes]...), []byte("\n[truncated]\n")...) + } + return string(output), nil } func (c *Client) RerunCheck(ctx context.Context, runID string) error { + if err := validateWorkflowRunID(runID); err != nil { + return err + } if err := c.AuthStatus(ctx); err != nil { return err } @@ -119,6 +195,38 @@ func (c *Client) RerunCheck(ctx context.Context, runID string) error { return nil } +func (c *Client) Checks(ctx context.Context, prURL string) ([]Check, error) { + if strings.TrimSpace(prURL) == "" { + return nil, fmt.Errorf("github: pull request URL is required for checks") + } + if err := c.AuthStatus(ctx); err != nil { + return nil, err + } + res, err := c.run(ctx, "pr", "checks", prURL, "--json", "name,state,conclusion,workflowRunId,detailsUrl") + if err != nil { + return nil, fmt.Errorf("github: run gh pr checks: %w", err) + } + if res.ExitCode != 0 { + return nil, fmt.Errorf("github: gh pr checks failed: %s", strings.TrimSpace(string(res.Stderr))) + } + var checks []Check + if err := json.Unmarshal(res.Stdout, &checks); err != nil { + return nil, fmt.Errorf("github: parse gh pr checks output: %w", err) + } + return checks, nil +} + +func validateWorkflowRunID(value string) error { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return fmt.Errorf("github: workflow run ID is required") + } + if _, err := strconv.ParseInt(trimmed, 10, 64); err != nil { + return fmt.Errorf("github: workflow run ID must be numeric, got %q", value) + } + return nil +} + func (c *Client) run(ctx context.Context, args ...string) (*exec.Result, error) { binary := c.Binary if binary == "" { diff --git a/internal/github/remediation_contract_test.go b/internal/github/remediation_contract_test.go new file mode 100644 index 0000000..9f209a4 --- /dev/null +++ b/internal/github/remediation_contract_test.go @@ -0,0 +1,19 @@ +package github_test + +import ( + "context" + "strings" + "testing" +) + +func TestCheckLogs_RejectsPRURLWhenWorkflowRunIDIsRequired(t *testing.T) { + c := newClient(t, []string{"FAKE_GH_RUN_LOG=secret log"}, "") + + _, err := c.CheckLogs(context.Background(), "https://github.com/example/repo/pull/42") + if err == nil { + t.Fatal("CheckLogs accepted a PR URL where a workflow run ID is required") + } + if !strings.Contains(err.Error(), "workflow") && !strings.Contains(err.Error(), "run") { + t.Fatalf("error does not explain the workflow-run-ID boundary: %v", err) + } +} diff --git a/internal/github/testdata/fakegh/main.go b/internal/github/testdata/fakegh/main.go index 134b7f3..72c3557 100644 --- a/internal/github/testdata/fakegh/main.go +++ b/internal/github/testdata/fakegh/main.go @@ -36,6 +36,10 @@ func main() { switch { case len(args) >= 2 && args[0] == "pr" && args[1] == "create": fmt.Fprintln(os.Stdout, envOr("FAKE_GH_PR_URL", "https://github.com/example/repo/pull/1")) + case len(args) >= 2 && args[0] == "pr" && args[1] == "list": + fmt.Fprint(os.Stdout, envOr("FAKE_GH_PR_LIST_JSON", "[]")) + case len(args) >= 2 && args[0] == "pr" && args[1] == "checks": + fmt.Fprint(os.Stdout, checksResponse()) case len(args) >= 2 && args[0] == "pr" && args[1] == "view": fmt.Fprint(os.Stdout, prViewResponse()) case len(args) >= 2 && args[0] == "run" && args[1] == "view": @@ -64,6 +68,23 @@ func prViewResponse() string { return fmt.Sprintf(`{"mergeStateStatus":%q}`, strings.TrimSpace(list[idx])) } +func checksResponse() string { + if value := os.Getenv("FAKE_GH_CHECKS_JSON"); value != "" { + return value + } + states := os.Getenv("FAKE_GH_PR_VIEW_STATES") + if states == "" { + return `[{"name":"ci","state":"SUCCESS","conclusion":"SUCCESS","workflowRunId":1,"detailsUrl":"https://github.com/example/repo/actions/runs/1"}]` + } + list := strings.Split(states, ",") + idx := nextSequenceIndex("checks", len(list)) + state := strings.ToUpper(strings.TrimSpace(list[idx])) + if state == "CLEAN" { + return `[{"name":"ci","state":"SUCCESS","conclusion":"SUCCESS","workflowRunId":1,"detailsUrl":"https://github.com/example/repo/actions/runs/1"}]` + } + return `[{"name":"ci","state":"COMPLETED","conclusion":"FAILURE","workflowRunId":1,"detailsUrl":"https://github.com/example/repo/actions/runs/1"}]` +} + // nextSequenceIndex lets one scripted state sequence (e.g. "fails twice then // passes") span multiple fakegh invocations, since gh polling means each // status check is a brand-new process with no memory of the last one. It diff --git a/internal/orchestrator/params.go b/internal/orchestrator/params.go index 5b96d04..4a3a0a7 100644 --- a/internal/orchestrator/params.go +++ b/internal/orchestrator/params.go @@ -26,6 +26,21 @@ func derivePRTitle(worktreePath string) (string, error) { return strings.TrimSpace(string(res.Stdout)), nil } +func deriveOutputSHA(worktreePath string) (string, error) { + res, err := execpkg.Run(context.Background(), execpkg.Command{ + Name: "git", + Args: []string{"rev-parse", "HEAD"}, + Dir: worktreePath, + }) + if err != nil { + return "", fmt.Errorf("orchestrator: run git rev-parse HEAD: %w", err) + } + if res.ExitCode != 0 { + return "", fmt.Errorf("orchestrator: git rev-parse HEAD failed: %s", strings.TrimSpace(string(res.Stderr))) + } + return strings.TrimSpace(string(res.Stdout)), nil +} + func deriveEvidenceRef(store evidence.Store, runID string) string { switch s := store.(type) { case *evidence.OrphanBranchStore: diff --git a/internal/orchestrator/scaffold.go b/internal/orchestrator/scaffold.go index ce76fa8..1eb0b7d 100644 --- a/internal/orchestrator/scaffold.go +++ b/internal/orchestrator/scaffold.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strings" "time" "github.com/douglasjarquin/made/internal/config" @@ -71,9 +72,10 @@ func Setup(ctx context.Context, gatePath, defaultBranch, worktreesDir, runID, pu setupTestHook() store := evidence.NewStore(wt.Path, evidence.Config{ - StoreInRepo: cfg.Test.Evidence.StoreInRepo, - Dir: cfg.Test.Evidence.Dir, - Branch: cfg.Test.Evidence.Branch, + StoreInRepo: cfg.Test.Evidence.StoreInRepo, + Dir: cfg.Test.Evidence.Dir, + Branch: cfg.Test.Evidence.Branch, + RetentionBytes: cfg.EvidenceRetentionBytes(), }) return &RunContext{ @@ -114,6 +116,9 @@ func Run(ctx context.Context, gatePath, defaultBranch, worktreesDir, runID, push } func resolveConfig(ctx context.Context, gatePath, defaultBranch, worktreePath string) (config.Config, error) { + if err := refreshDefaultBranch(ctx, gatePath, defaultBranch); err != nil { + return config.Config{}, err + } trustedPath, cleanup, err := extractTrustedConfig(ctx, gatePath, defaultBranch) if err != nil { return config.Config{}, err @@ -134,6 +139,39 @@ func resolveConfig(ctx context.Context, gatePath, defaultBranch, worktreePath st return cfg, nil } +func refreshDefaultBranch(ctx context.Context, gatePath, defaultBranch string) error { + remote, err := execpkg.Run(ctx, execpkg.Command{Name: "git", Args: []string{"remote", "get-url", "origin"}, Dir: gatePath}) + if err != nil { + return fmt.Errorf("orchestrator: inspect origin remote: %w", err) + } + if remote.ExitCode != 0 { + return fmt.Errorf("orchestrator: origin remote is unavailable: %s", string(remote.Stderr)) + } + refspec := fmt.Sprintf("%s:refs/heads/%s", defaultBranch, defaultBranch) + fetch, err := execpkg.Run(ctx, execpkg.Command{Name: "git", Args: []string{"fetch", "origin", refspec}, Dir: gatePath}) + if err != nil { + return fmt.Errorf("orchestrator: refresh default branch %s: %w", defaultBranch, err) + } + if fetch.ExitCode != 0 { + if strings.Contains(string(fetch.Stderr), "couldn't find remote ref") { + clear, clearErr := execpkg.Run(ctx, execpkg.Command{ + Name: "git", + Args: []string{"update-ref", "-d", "refs/heads/" + defaultBranch}, + Dir: gatePath, + }) + if clearErr != nil { + return fmt.Errorf("orchestrator: clear deleted default branch %s: %w", defaultBranch, clearErr) + } + if clear.ExitCode != 0 { + return fmt.Errorf("orchestrator: clear deleted default branch %s failed: %s", defaultBranch, string(clear.Stderr)) + } + return nil + } + return fmt.Errorf("orchestrator: refresh default branch %s failed: %s", defaultBranch, string(fetch.Stderr)) + } + return nil +} + func extractTrustedConfig(ctx context.Context, gatePath, defaultBranch string) (path string, cleanup func(), err error) { res, err := execpkg.Run(ctx, execpkg.Command{ Name: "git", @@ -151,7 +189,7 @@ func extractTrustedConfig(ctx context.Context, gatePath, defaultBranch string) ( return "", nil, nil } - f, err := os.CreateTemp("", "made-trusted-config-*.yml") + f, err := os.CreateTemp("", "made-trusted-config-*.made.yml") if err != nil { return "", nil, fmt.Errorf("orchestrator: create temp file for trusted config: %w", err) } diff --git a/internal/orchestrator/scaffold_test.go b/internal/orchestrator/scaffold_test.go index c09a43d..917de89 100644 --- a/internal/orchestrator/scaffold_test.go +++ b/internal/orchestrator/scaffold_test.go @@ -16,10 +16,11 @@ func TestSetupResolvesTrustedConfigWhenPresentOnDefaultBranch(t *testing.T) { if err := gitgate.InitBare(barePath); err != nil { t.Fatalf("InitBare: %v", err) } + runGit(t, barePath, "remote", "add", "origin", barePath) src := filepath.Join(dir, "src") initSourceRepo(t, src) - writeFile(t, src, ".made.yml", "no_ci: true\nallow_repo_commands: true\ncommands:\n test: \"go test ./...\"\n") + writeFile(t, src, ".made.yml", "version: 1\nno_ci: true\nallow_repo_commands: true\ncommands:\n test: \"go test ./...\"\n") commit(t, src, "add made.yml") sha := pushBranch(t, src, barePath, "main") @@ -47,6 +48,7 @@ func TestSetupResolvesEmptyTrustedConfigWhenMadeYmlMissingFromDefaultBranch(t *t if err := gitgate.InitBare(barePath); err != nil { t.Fatalf("InitBare: %v", err) } + runGit(t, barePath, "remote", "add", "origin", barePath) src := filepath.Join(dir, "src") initSourceRepo(t, src) @@ -70,6 +72,7 @@ func TestSetupResolvesEmptyTrustedConfigWhenDefaultBranchNeverFetched(t *testing if err := gitgate.InitBare(barePath); err != nil { t.Fatalf("InitBare: %v", err) } + runGit(t, barePath, "remote", "add", "origin", barePath) src := filepath.Join(dir, "src") initSourceRepo(t, src) @@ -87,12 +90,44 @@ func TestSetupResolvesEmptyTrustedConfigWhenDefaultBranchNeverFetched(t *testing } } +func TestRefreshDefaultBranchClearsDeletedRemotePolicyRef(t *testing.T) { + dir := t.TempDir() + gatePath := filepath.Join(dir, "gate.git") + remotePath := filepath.Join(dir, "remote.git") + runGit(t, "", "init", "--bare", "-q", "-b", "main", remotePath) + if err := gitgate.InitBare(gatePath); err != nil { + t.Fatalf("InitBare gate: %v", err) + } + runGit(t, gatePath, "remote", "add", "origin", remotePath) + + src := filepath.Join(dir, "src") + initSourceRepo(t, src) + sha := pushBranch(t, src, remotePath, "main") + if err := refreshDefaultBranch(context.Background(), gatePath, "main"); err != nil { + t.Fatalf("initial refreshDefaultBranch: %v", err) + } + if got := revParse(t, gatePath, "refs/heads/main"); got != sha { + t.Fatalf("fetched trusted ref = %s, want %s", got, sha) + } + runGit(t, remotePath, "update-ref", "-d", "refs/heads/main") + + if err := refreshDefaultBranch(context.Background(), gatePath, "main"); err != nil { + t.Fatalf("refreshDefaultBranch after remote deletion: %v", err) + } + cmd := exec.Command("git", "rev-parse", "--verify", "refs/heads/main") + cmd.Dir = gatePath + if err := cmd.Run(); err == nil { + t.Fatal("refreshDefaultBranch retained a deleted remote trusted ref") + } +} + func TestSetupCutsWorktreeAtExactPushedSHANotBranchTip(t *testing.T) { dir := t.TempDir() barePath := filepath.Join(dir, "gate.git") if err := gitgate.InitBare(barePath); err != nil { t.Fatalf("InitBare: %v", err) } + runGit(t, barePath, "remote", "add", "origin", barePath) src := filepath.Join(dir, "src") initSourceRepo(t, src) @@ -134,6 +169,7 @@ func TestSetupRecoversFromMidSetupPanicAndCleansUp(t *testing.T) { if err := gitgate.InitBare(barePath); err != nil { t.Fatalf("InitBare: %v", err) } + runGit(t, barePath, "remote", "add", "origin", barePath) src := filepath.Join(dir, "src") initSourceRepo(t, src) @@ -168,6 +204,7 @@ func TestRunWrapsSetupWorkAndCleanupWithPanicRecovery(t *testing.T) { if err := gitgate.InitBare(barePath); err != nil { t.Fatalf("InitBare: %v", err) } + runGit(t, barePath, "remote", "add", "origin", barePath) src := filepath.Join(dir, "src") initSourceRepo(t, src) diff --git a/internal/orchestrator/workfunc.go b/internal/orchestrator/workfunc.go index 78f1dea..b344de2 100644 --- a/internal/orchestrator/workfunc.go +++ b/internal/orchestrator/workfunc.go @@ -7,6 +7,7 @@ import ( "github.com/douglasjarquin/made/internal/agent" "github.com/douglasjarquin/made/internal/daemon" + "github.com/douglasjarquin/made/internal/evidence" "github.com/douglasjarquin/made/internal/pipeline/ci" "github.com/douglasjarquin/made/internal/pipeline/document" "github.com/douglasjarquin/made/internal/pipeline/intent" @@ -32,7 +33,6 @@ const ( stageNamePR = "pr" stageNameCI = "ci" - ciStageTimeout = 30 * time.Minute ciPollInterval = 10 * time.Second pushRemoteName = "origin" @@ -95,41 +95,91 @@ type chain struct { } func (c *chain) run() error { - if err := c.intentStage(); err != nil { + if err := c.runStage(stageNameIntent, c.intentStage); err != nil { return err } - if err := c.rebaseStage(); err != nil { + if err := c.runStage(stageNameRebase, c.rebaseStage); err != nil { return err } - if err := c.reviewStage(); err != nil { + if err := c.runStage(stageNameReview, c.reviewStage); err != nil { return err } - if err := c.testStage(); err != nil { + if err := c.runStage(stageNameTest, c.testStage); err != nil { return err } - if err := c.documentStage(); err != nil { + if err := c.runStage(stageNameDocument, c.documentStage); err != nil { return err } - if err := c.lintStage(); err != nil { + if err := c.runStage(stageNameLint, c.lintStage); err != nil { return err } - if err := c.pushStage(); err != nil { + if err := c.requireDeliveryStages(); err != nil { return err } - prResult, err := c.prStage() + if err := c.runStage(stageNamePush, c.pushStage); err != nil { + return err + } + var prResult pr.Result + var err error + if c.rc.Config.StageResult(stageNamePR) == "skipped" { + if err := c.finish(stageNamePR, "skipped", "stage disabled"); err != nil { + return err + } + } else { + prResult, err = c.prStage() + } if err != nil { return err } - if err := c.ciStage(prResult.PRURL); err != nil { + if err := c.runCIStage(prResult.PRURL); err != nil { return err } // A passing CI stage validates the branch and leaves a PR open, but // merging it is a human decision made cannot observe - so the run's - // final status stays RunRunning rather than RunCompleted, with the PR + // final status stays RunAwaitingMerge rather than RunSucceeded, with the PR // URL surfaced in the message instead of a terminal "done" state. message := fmt.Sprintf("all stages passed, PR open, awaiting merge: %s", prResult.PRURL) - return c.rm.Finish(c.runID, daemon.RunRunning, message) + return c.rm.Finish(c.runID, daemon.RunAwaitingMerge, message) +} + +func (c *chain) requireDeliveryStages() error { + for _, name := range []string{ + stageNameIntent, stageNameRebase, stageNameReview, stageNameTest, + stageNameDocument, stageNameLint, stageNamePush, stageNamePR, stageNameCI, + } { + if c.rc.Config.StageResult(name) != "skipped" { + continue + } + if !c.rc.Config.StageRequired(name) { + continue + } + if err := c.finish(name, "skipped", "stage disabled"); err != nil { + return err + } + return fmt.Errorf("orchestrator: refusing delivery because required stage %q is disabled", name) + } + return nil +} + +func (c *chain) runStage(name string, stage func() error) error { + if c.rc.Config.StageResult(name) == "skipped" { + return c.finish(name, "skipped", "stage disabled") + } + stageCtx, cancel := context.WithTimeout(c.ctx, c.rc.Config.StageTimeout(name)) + previous := c.ctx + c.ctx = stageCtx + err := stage() + c.ctx = previous + cancel() + return err +} + +func (c *chain) runCIStage(prURL string) error { + if c.rc.Config.StageResult(stageNameCI) == "skipped" || prURL == "" { + return c.finish(stageNameCI, "skipped", "stage disabled") + } + return c.ciStage(prURL) } func (c *chain) start(stage string) { @@ -138,12 +188,15 @@ func (c *chain) start(stage string) { } } -func (c *chain) finish(stage, result, message string) { +func (c *chain) finish(stage, result, message string) error { c.stages = append(c.stages, daemon.StageResult{Name: stage, Result: result}) - _ = c.rm.UpdateStages(c.runID, append([]daemon.StageResult(nil), c.stages...)) + if err := c.rm.UpdateStages(c.runID, append([]daemon.StageResult(nil), c.stages...)); err != nil { + return err + } if c.emit != nil { c.emit(daemon.Event{Kind: daemon.EventStageFinished, Stage: stage, Message: message}) } + return nil } func (c *chain) stageFailure(stage, message string) error { @@ -162,30 +215,32 @@ func (c *chain) stageFailure(stage, message string) error { func (c *chain) intentStage() error { c.start(stageNameIntent) - result, err := intent.Check(c.rc.Worktree.Path) + result, err := intent.CheckContext(c.ctx, c.rc.Worktree.Path) if err != nil { return err } if !result.OK { - c.finish(stageNameIntent, stageResultFail, result.Message) + if err := c.finish(stageNameIntent, stageResultFail, result.Message); err != nil { + return err + } return c.stageFailure(stageNameIntent, result.Message) } - c.finish(stageNameIntent, stageResultPass, result.Message) - return nil + return c.finish(stageNameIntent, stageResultPass, result.Message) } func (c *chain) rebaseStage() error { c.start(stageNameRebase) - result, err := rebase.Run(c.rc.Worktree.Path, c.defaultBranch) + result, err := rebase.RunContext(c.ctx, c.rc.Worktree.Path, c.defaultBranch) if err != nil { return err } if !result.OK { - c.finish(stageNameRebase, stageResultFail, result.Message) + if err := c.finish(stageNameRebase, stageResultFail, result.Message); err != nil { + return err + } return c.stageFailure(stageNameRebase, result.Message) } - c.finish(stageNameRebase, stageResultPass, result.Message) - return nil + return c.finish(stageNameRebase, stageResultPass, result.Message) } func (c *chain) reviewStage() error { @@ -200,8 +255,24 @@ func (c *chain) reviewStage() error { if err != nil { return err } + durableFindings := make([]daemon.RunFinding, 0, len(result.Findings)) + autoFixIndex := 0 + for _, finding := range result.Findings { + record := daemon.RunFinding{Stage: stageNameReview, Kind: string(finding.Kind), Message: finding.Description, Paths: append([]string(nil), finding.Paths...)} + if finding.Kind == agent.FindingAutoFixable && autoFixIndex < len(result.PreFixSHAs) { + record.PreFixSHA = result.PreFixSHAs[autoFixIndex] + record.PostFixSHA = result.PostFixSHAs[autoFixIndex] + autoFixIndex++ + } + durableFindings = append(durableFindings, record) + } + if err := c.rm.AddFindings(c.runID, durableFindings); err != nil { + return err + } if !result.OK { - c.finish(stageNameReview, stageResultFail, result.Message) + if err := c.finish(stageNameReview, stageResultFail, result.Message); err != nil { + return err + } return c.stageFailure(stageNameReview, result.Message) } @@ -211,8 +282,7 @@ func (c *chain) reviewStage() error { } } - c.finish(stageNameReview, stageResultPass, result.Message) - return nil + return c.finish(stageNameReview, stageResultPass, result.Message) } func (c *chain) testStage() error { @@ -222,21 +292,31 @@ func (c *chain) testStage() error { return err } if !result.OK { - c.finish(stageNameTest, stageResultFail, result.Message) + if err := c.finish(stageNameTest, stageResultFail, result.Message); err != nil { + return err + } return c.stageFailure(stageNameTest, result.Message) } - c.finish(stageNameTest, stageResultPass, result.Message) - return nil + return c.finish(stageNameTest, stageResultPass, result.Message) } func (c *chain) documentStage() error { c.start(stageNameDocument) - result, err := document.Run(c.rc.Worktree.Path, c.defaultBranch, deriveDocumentRules(c.rc.Config)) + result, err := document.RunContext(c.ctx, c.rc.Worktree.Path, c.defaultBranch, deriveDocumentRules(c.rc.Config)) if err != nil { return err } + durableFindings := make([]daemon.RunFinding, 0, len(result.Findings)) + for _, finding := range result.Findings { + durableFindings = append(durableFindings, daemon.RunFinding{Stage: stageNameDocument, Kind: string(finding.Kind), Message: finding.Description, Paths: append([]string(nil), finding.Paths...)}) + } + if err := c.rm.AddFindings(c.runID, durableFindings); err != nil { + return err + } if !result.OK { - c.finish(stageNameDocument, stageResultFail, result.Message) + if err := c.finish(stageNameDocument, stageResultFail, result.Message); err != nil { + return err + } return c.stageFailure(stageNameDocument, result.Message) } @@ -246,8 +326,7 @@ func (c *chain) documentStage() error { } } - c.finish(stageNameDocument, stageResultPass, result.Message) - return nil + return c.finish(stageNameDocument, stageResultPass, result.Message) } func (c *chain) lintStage() error { @@ -257,31 +336,56 @@ func (c *chain) lintStage() error { return err } if !result.OK { - c.finish(stageNameLint, stageResultFail, result.Message) + if err := c.finish(stageNameLint, stageResultFail, result.Message); err != nil { + return err + } return c.stageFailure(stageNameLint, result.Message) } - c.finish(stageNameLint, stageResultPass, result.Message) - return nil + return c.finish(stageNameLint, stageResultPass, result.Message) } func (c *chain) pushStage() error { c.start(stageNamePush) + if publisher, ok := c.rc.Evidence.(evidence.Publisher); ok { + var publishErr error + if contextual, contextOK := c.rc.Evidence.(evidence.ContextPublisher); contextOK { + publishErr = contextual.PublishEvidenceContext(c.ctx, c.runID) + } else { + publishErr = publisher.PublishEvidence(c.runID) + } + if publishErr != nil { + if finishErr := c.finish(stageNamePush, stageResultFail, publishErr.Error()); finishErr != nil { + return finishErr + } + return c.stageFailure(stageNamePush, publishErr.Error()) + } + } + outputSHA, err := deriveOutputSHA(c.rc.Worktree.Path) + if err != nil { + if finishErr := c.finish(stageNamePush, stageResultFail, err.Error()); finishErr != nil { + return finishErr + } + return c.stageFailure(stageNamePush, err.Error()) + } + if err := c.rm.SetOutputSHA(c.runID, outputSHA); err != nil { + return err + } result, err := push.Run(c.ctx, c.rc.Worktree.Path, pushRemoteName, c.branch) if err != nil { return err } if !result.OK { - c.finish(stageNamePush, stageResultFail, result.Message) + if err := c.finish(stageNamePush, stageResultFail, result.Message); err != nil { + return err + } return c.stageFailure(stageNamePush, result.Message) } c.pushed = true - c.finish(stageNamePush, stageResultPass, result.Message) - return nil + return c.finish(stageNamePush, stageResultPass, result.Message) } func (c *chain) prStage() (pr.Result, error) { c.start(stageNamePR) - title, err := derivePRTitle(c.rc.Worktree.Path) if err != nil { return pr.Result{}, fmt.Errorf("orchestrator: derive PR title: %w", err) @@ -294,19 +398,29 @@ func (c *chain) prStage() (pr.Result, error) { EvidenceRef: deriveEvidenceRef(c.rc.Evidence, c.runID), }) if err != nil { - return pr.Result{}, err + if finishErr := c.finish(stageNamePR, stageResultFail, err.Error()); finishErr != nil { + return pr.Result{}, finishErr + } + return pr.Result{}, c.stageFailure(stageNamePR, err.Error()) } if !result.OK { - c.finish(stageNamePR, stageResultFail, result.Message) + if err := c.finish(stageNamePR, stageResultFail, result.Message); err != nil { + return pr.Result{}, err + } return pr.Result{}, c.stageFailure(stageNamePR, result.Message) } - c.finish(stageNamePR, stageResultPass, result.Message) + if err := c.rm.SetPRURL(c.runID, result.PRURL); err != nil { + return pr.Result{}, err + } + if err := c.finish(stageNamePR, stageResultPass, result.Message); err != nil { + return pr.Result{}, err + } return result, nil } func (c *chain) ciStage(prURL string) error { c.start(stageNameCI) - ciCtx, cancel := context.WithTimeout(c.ctx, ciStageTimeout) + ciCtx, cancel := context.WithTimeout(c.ctx, c.rc.Config.StageTimeout(stageNameCI)) defer cancel() result, err := ci.Run(ciCtx, c.rc.GitHub, prURL, c.rc.Config.CI.RerunBudget, ciPollInterval) @@ -314,11 +428,12 @@ func (c *chain) ciStage(prURL string) error { return err } if !result.OK { - c.finish(stageNameCI, stageResultFail, result.Message) + if err := c.finish(stageNameCI, stageResultFail, result.Message); err != nil { + return err + } return c.stageFailure(stageNameCI, result.Message) } - c.finish(stageNameCI, stageResultPass, result.Message) - return nil + return c.finish(stageNameCI, stageResultPass, result.Message) } // parkForApproval records findings and blocks on a single decision per @@ -328,14 +443,21 @@ func (c *chain) ciStage(prURL string) error { // report success while still carrying findings a human must weigh in on, so // OK alone is never sufficient to proceed past them. func (c *chain) parkForApproval(stage string, findings []daemon.AskUserFinding) error { - _ = c.rm.UpdatePendingFindings(c.runID, findings) + if err := c.rm.UpdatePendingFindings(c.runID, findings); err != nil { + return err + } decision, err := c.reviewDecisions.Wait(c.ctx, c.runID, stage) - _ = c.rm.UpdatePendingFindings(c.runID, nil) + clearErr := c.rm.UpdatePendingFindings(c.runID, nil) if err != nil { return fmt.Errorf("orchestrator: wait for %s decision: %w", stage, err) } + if clearErr != nil { + return clearErr + } if decision == daemon.ReviewRejected { - c.finish(stage, stageResultFail, "rejected by reviewer") + if err := c.finish(stage, stageResultFail, "rejected by reviewer"); err != nil { + return err + } return c.stageFailure(stage, "rejected by reviewer") } return nil diff --git a/internal/orchestrator/workfunc_test.go b/internal/orchestrator/workfunc_test.go index 422843b..4f462b7 100644 --- a/internal/orchestrator/workfunc_test.go +++ b/internal/orchestrator/workfunc_test.go @@ -30,6 +30,31 @@ type wfFixture struct { defaultBranch string } +func TestChain_RefusesDeliveryWhenRequiredStageDisabled(t *testing.T) { + disabled := false + rm := daemon.NewRunManager() + runID := rm.NewRunID() + if _, err := rm.Submit(runID, "repo", "branch", func(context.Context, func(daemon.Event)) error { return nil }); err != nil { + t.Fatalf("Submit: %v", err) + } + c := &chain{rc: &RunContext{Config: config.Config{ + Review: config.Review{Required: true}, + Stages: map[string]config.Stage{stageNameReview: {Enabled: &disabled}}, + }}, rm: rm, runID: runID} + + err := c.requireDeliveryStages() + if err == nil { + t.Fatal("requireDeliveryStages allowed delivery with a disabled required review stage") + } + if !strings.Contains(err.Error(), `required stage "review" is disabled`) { + t.Fatalf("requireDeliveryStages error = %q, want disabled review stage", err) + } + snapshot, ok := rm.Snapshot(runID) + if !ok || len(snapshot.Stages) != 1 || snapshot.Stages[0] != (daemon.StageResult{Name: stageNameReview, Result: "skipped"}) { + t.Fatalf("disabled stage snapshot = %+v, want review/skipped", snapshot.Stages) + } +} + func newWFFixture(t *testing.T) *wfFixture { t.Helper() dir := t.TempDir() @@ -219,12 +244,15 @@ func TestNewWorkFunc_FullPassEndsRunningWithAwaitingMergeMessage(t *testing.T) { submitWorkFunc(t, rm, runID, "repo-full-pass", branch, wf, rc) snap := waitForRunEnded(t, rm, runID, 30*time.Second) - if snap.Status != daemon.RunRunning { - t.Fatalf("expected final status RunRunning (awaiting merge), got %v (err=%v)", snap.Status, snap.Err) + if snap.Status != daemon.RunAwaitingMerge { + t.Fatalf("expected final status RunAwaitingMerge, got %v (err=%v)", snap.Status, snap.Err) } if !strings.Contains(snap.Message, "awaiting merge") { t.Fatalf("expected final message to mention awaiting merge, got %q", snap.Message) } + if len(snap.OutputSHA) != 40 { + t.Fatalf("expected durable output SHA after push preparation, got %q", snap.OutputSHA) + } assertAllStagesPassed(t, snap.Stages) if !f.branchOnRealRemote(t, branch) { @@ -258,8 +286,8 @@ func TestNewWorkFunc_FullPassPRTitleMatchesPushedCommitSubject(t *testing.T) { submitWorkFunc(t, rm, runID, "repo-pr-title", branch, wf, rc) snap := waitForRunEnded(t, rm, runID, 30*time.Second) - if snap.Status != daemon.RunRunning { - t.Fatalf("expected final status RunRunning (awaiting merge), got %v (err=%v)", snap.Status, snap.Err) + if snap.Status != daemon.RunAwaitingMerge { + t.Fatalf("expected final status RunAwaitingMerge, got %v (err=%v)", snap.Status, snap.Err) } assertAllStagesPassed(t, snap.Stages) @@ -378,8 +406,8 @@ func TestNewWorkFunc_DocumentFindingParksThenRejectedFailsRun(t *testing.T) { submitWorkFunc(t, rm, runID, "repo-doc-reject", branch, wf, rc) parked := waitForPendingFindings(t, rm, runID, 30*time.Second) - if parked.Status != daemon.RunRunning { - t.Fatalf("expected parked run to stay RunRunning, got %v", parked.Status) + if parked.Status != daemon.RunAwaitingReview { + t.Fatalf("expected parked run to stay RunAwaitingReview, got %v", parked.Status) } if len(parked.PendingFindings) != 1 || parked.PendingFindings[0].Stage != stageNameDocument { t.Fatalf("expected one pending finding on stage %q, got %+v", stageNameDocument, parked.PendingFindings) @@ -424,15 +452,15 @@ func TestNewWorkFunc_DocumentFindingParksThenApprovedResumesToCompletion(t *test submitWorkFunc(t, rm, runID, "repo-doc-approve", branch, wf, rc) parked := waitForPendingFindings(t, rm, runID, 30*time.Second) - if parked.Status != daemon.RunRunning { - t.Fatalf("expected parked run to stay RunRunning, got %v", parked.Status) + if parked.Status != daemon.RunAwaitingReview { + t.Fatalf("expected parked run to stay RunAwaitingReview, got %v", parked.Status) } reviewDecisions.Set(runID, stageNameDocument, daemon.ReviewApproved) snap := waitForRunEnded(t, rm, runID, 30*time.Second) - if snap.Status != daemon.RunRunning { - t.Fatalf("expected final status RunRunning (awaiting merge) after resume, got %v (err=%v)", snap.Status, snap.Err) + if snap.Status != daemon.RunAwaitingMerge { + t.Fatalf("expected final status RunAwaitingMerge after resume, got %v (err=%v)", snap.Status, snap.Err) } if !strings.Contains(snap.Message, "awaiting merge") { t.Fatalf("expected final message to mention awaiting merge, got %q", snap.Message) diff --git a/internal/pipeline/ci/ci.go b/internal/pipeline/ci/ci.go index 3a29c45..724eb3b 100644 --- a/internal/pipeline/ci/ci.go +++ b/internal/pipeline/ci/ci.go @@ -16,13 +16,6 @@ import ( const ( defaultPollInterval = 2 * time.Second - - // passingMergeState is the gh pr view mergeStateStatus value that means - // "all checks passed and the PR is clear to proceed". internal/github's - // Client exposes no separate check-listing endpoint, so this stage - // treats PR mergeability status as its check-status signal; any other - // state is treated as a (possibly transient) check failure. - passingMergeState = "CLEAN" ) type Result struct { @@ -58,40 +51,83 @@ func Run(ctx context.Context, ghClient *github.Client, prURL string, rerunBudget reruns := 0 for { - state, err := ghClient.MergeableState(ctx, prURL) + checks, err := ghClient.Checks(ctx, prURL) if err != nil { - return Result{OK: false, Message: err.Error(), RerunsUsed: reruns}, nil + return Result{}, err + } + if len(checks) == 0 { + return Result{}, fmt.Errorf("ci: GitHub returned no checks for %s", prURL) } - if state == passingMergeState { + allPassed := true + var failing *github.Check + pending := false + for i := range checks { + check := checks[i] + if checkPassed(check) { + continue + } + allPassed = false + if checkPending(check) { + pending = true + continue + } + if failing == nil { + failing = &check + } + } + if allPassed { return Result{ OK: true, Message: fmt.Sprintf("checks passed for %s after %d rerun(s)", prURL, reruns), RerunsUsed: reruns, }, nil } + if pending && failing == nil { + select { + case <-ctx.Done(): + return Result{}, ctx.Err() + case <-time.After(pollInterval): + } + continue + } + if failing == nil { + return Result{}, fmt.Errorf("ci: check state was neither passing, pending, nor failing") + } if reruns >= rerunBudget { - excerpt, logErr := ghClient.CheckLogs(ctx, prURL) + excerpt, logErr := ghClient.CheckLogs(ctx, failing.WorkflowRunID) if logErr != nil { - excerpt = fmt.Sprintf("(failed to fetch check logs: %s)", logErr.Error()) + return Result{}, logErr } return Result{ OK: false, - Message: fmt.Sprintf("checks still failing (%s) for %s after exhausting rerun budget (%d)", state, prURL, rerunBudget), + Message: fmt.Sprintf("check %s still failing for %s after exhausting rerun budget (%d)", failing.Name, prURL, rerunBudget), RerunsUsed: reruns, LogExcerpt: excerpt, }, nil } - if err := ghClient.RerunCheck(ctx, prURL); err != nil { - return Result{OK: false, Message: err.Error(), RerunsUsed: reruns}, nil + if err := ghClient.RerunCheck(ctx, failing.WorkflowRunID); err != nil { + return Result{}, err } reruns++ select { case <-ctx.Done(): - return Result{OK: false, Message: ctx.Err().Error(), RerunsUsed: reruns}, nil + return Result{}, ctx.Err() case <-time.After(pollInterval): } } } + +func checkPassed(check github.Check) bool { + status := strings.ToUpper(strings.TrimSpace(check.Status)) + conclusion := strings.ToUpper(strings.TrimSpace(check.Conclusion)) + return (status == "SUCCESS" || status == "COMPLETED") && (conclusion == "SUCCESS" || conclusion == "SUCCESSFUL" || conclusion == "NEUTRAL") +} + +func checkPending(check github.Check) bool { + status := strings.ToUpper(strings.TrimSpace(check.Status)) + conclusion := strings.TrimSpace(check.Conclusion) + return conclusion == "" || status == "QUEUED" || status == "IN_PROGRESS" || status == "PENDING" +} diff --git a/internal/pipeline/ci/remediation_contract_test.go b/internal/pipeline/ci/remediation_contract_test.go new file mode 100644 index 0000000..8c74f56 --- /dev/null +++ b/internal/pipeline/ci/remediation_contract_test.go @@ -0,0 +1,21 @@ +package ci_test + +import ( + "context" + "testing" + "time" + + "github.com/douglasjarquin/made/internal/pipeline/ci" +) + +func TestRun_AuthenticationFailureIsInfrastructureError(t *testing.T) { + c := newClient(t, []string{ + "FAKE_GH_AUTH_EXIT_CODE=1", + "FAKE_GH_AUTH_STDERR=not authenticated", + }, "") + + result, err := ci.Run(context.Background(), c, "https://github.com/example/repo/pull/42", 0, time.Millisecond) + if err == nil { + t.Fatalf("authentication failure was reported as a failed check: result=%+v", result) + } +} diff --git a/internal/pipeline/document/document.go b/internal/pipeline/document/document.go index 596893e..bbc5b35 100644 --- a/internal/pipeline/document/document.go +++ b/internal/pipeline/document/document.go @@ -8,6 +8,7 @@ package document import ( + "context" "fmt" "os/exec" "path/filepath" @@ -31,7 +32,11 @@ type Result struct { // failing, worktreePath unreadable, etc); a policy violation is a normal // outcome reported via Result.Findings, not an error. func Run(worktreePath, baseBranch string, rules []Rule) (Result, error) { - changed, err := changedFiles(worktreePath, baseBranch) + return RunContext(context.Background(), worktreePath, baseBranch, rules) +} + +func RunContext(ctx context.Context, worktreePath, baseBranch string, rules []Rule) (Result, error) { + changed, err := changedFiles(ctx, worktreePath, baseBranch) if err != nil { return Result{}, fmt.Errorf("document: %w", err) } @@ -77,8 +82,8 @@ func Run(worktreePath, baseBranch string, rules []Rule) (Result, error) { }, nil } -func changedFiles(worktreePath, baseBranch string) ([]string, error) { - cmd := exec.Command("git", "-C", worktreePath, "diff", "--name-only", baseBranch+"...HEAD") +func changedFiles(ctx context.Context, worktreePath, baseBranch string) ([]string, error) { + cmd := exec.CommandContext(ctx, "git", "-C", worktreePath, "diff", "--name-only", baseBranch+"...HEAD") out, err := cmd.CombinedOutput() if err != nil { return nil, fmt.Errorf("git diff --name-only %s...HEAD: %w: %s", baseBranch, err, strings.TrimSpace(string(out))) diff --git a/internal/pipeline/intent/intent.go b/internal/pipeline/intent/intent.go index 42fde2d..2d91bc3 100644 --- a/internal/pipeline/intent/intent.go +++ b/internal/pipeline/intent/intent.go @@ -4,6 +4,7 @@ package intent import ( + "context" "fmt" "os/exec" "strings" @@ -18,12 +19,16 @@ type Result struct { // unreadable, git missing, etc); a missing/empty Intent trailer is a normal // outcome reported via Result.OK, not an error. func Check(repoPath string) (Result, error) { - message, err := commitMessage(repoPath) + return CheckContext(context.Background(), repoPath) +} + +func CheckContext(ctx context.Context, repoPath string) (Result, error) { + message, err := commitMessage(ctx, repoPath) if err != nil { return Result{}, err } - value, err := intentTrailerValue(repoPath, message) + value, err := intentTrailerValue(ctx, repoPath, message) if err != nil { return Result{}, err } @@ -41,8 +46,8 @@ func Check(repoPath string) (Result, error) { }, nil } -func commitMessage(repoPath string) (string, error) { - cmd := exec.Command("git", "-C", repoPath, "log", "-1", "--format=%B", "HEAD") +func commitMessage(ctx context.Context, repoPath string) (string, error) { + cmd := exec.CommandContext(ctx, "git", "-C", repoPath, "log", "-1", "--format=%B", "HEAD") out, err := cmd.Output() if err != nil { return "", fmt.Errorf("intent: read tip commit message: %w", err) @@ -50,8 +55,8 @@ func commitMessage(repoPath string) (string, error) { return string(out), nil } -func intentTrailerValue(repoPath, message string) (string, error) { - cmd := exec.Command("git", "-C", repoPath, "interpret-trailers", "--parse") +func intentTrailerValue(ctx context.Context, repoPath, message string) (string, error) { + cmd := exec.CommandContext(ctx, "git", "-C", repoPath, "interpret-trailers", "--parse") cmd.Stdin = strings.NewReader(message) out, err := cmd.Output() if err != nil { diff --git a/internal/pipeline/lint/lint.go b/internal/pipeline/lint/lint.go index 017c933..e372d53 100644 --- a/internal/pipeline/lint/lint.go +++ b/internal/pipeline/lint/lint.go @@ -43,10 +43,17 @@ func Run(ctx context.Context, worktreePath, runID string, lintCommand []string, return Result{}, fmt.Errorf("lint: run %q: %w", strings.Join(lintCommand, " "), err) } - if evErr := store.WriteEvidence(runID, map[string][]byte{ + evidenceFiles := map[string][]byte{ "stdout.log": res.Stdout, "stderr.log": res.Stderr, - }); evErr != nil { + } + var evErr error + if contextual, ok := store.(evidence.ContextStore); ok { + evErr = contextual.WriteEvidenceContext(ctx, runID, evidenceFiles) + } else { + evErr = store.WriteEvidence(runID, evidenceFiles) + } + if evErr != nil { return Result{}, fmt.Errorf("lint: write evidence: %w", evErr) } diff --git a/internal/pipeline/pr/pr.go b/internal/pipeline/pr/pr.go index 1f19628..c493146 100644 --- a/internal/pipeline/pr/pr.go +++ b/internal/pipeline/pr/pr.go @@ -36,11 +36,6 @@ type Result struct { PRURL string } -// Run's error return is reserved for infrastructure/configuration failures -// (a nil client, missing required options); a PR request rejected by GitHub -// itself - auth failure, branch protection, network error - is a normal -// outcome reported via Result.OK, not an error, following the push stage's -// convention (internal/pipeline/push). func Run(ctx context.Context, ghClient *github.Client, opts Options) (Result, error) { if ghClient == nil { return Result{}, fmt.Errorf("pr: ghClient must not be nil") @@ -65,10 +60,7 @@ func Run(ctx context.Context, ghClient *github.Client, opts Options) (Result, er Head: opts.Head, }) if err != nil { - return Result{ - OK: false, - Message: err.Error(), - }, nil + return Result{}, fmt.Errorf("pr: GitHub API failure: %w", err) } return Result{ diff --git a/internal/pipeline/pr/pr_test.go b/internal/pipeline/pr/pr_test.go index 88d29c4..801d24b 100644 --- a/internal/pipeline/pr/pr_test.go +++ b/internal/pipeline/pr/pr_test.go @@ -94,22 +94,19 @@ func TestRun_RejectsEmptyEvidenceRef(t *testing.T) { } } -func TestRun_ReportsGHFailureAsResultNotError(t *testing.T) { +func TestRun_ReportsGHFailureAsInfrastructureError(t *testing.T) { c := newClient(t, []string{"FAKE_GH_EXIT_CODE=1", "FAKE_GH_STDERR=pr create failed: branch protection"}, "") - result, err := pr.Run(context.Background(), c, pr.Options{ + _, err := pr.Run(context.Background(), c, pr.Options{ Title: "made: automated change", Base: "main", Head: "made/run-999", EvidenceRef: "evidence/run-999/summary.txt", }) - if err != nil { - t.Fatalf("Run: expected a reported failure, not an error, got: %v", err) - } - if result.OK { - t.Fatalf("expected OK=false, got %+v", result) + if err == nil { + t.Fatal("expected GitHub API failure to be returned as infrastructure error") } - if result.Message == "" { - t.Fatal("expected a non-empty failure message") + if !strings.Contains(err.Error(), "GitHub API failure") { + t.Fatalf("expected infrastructure classification, got %v", err) } } diff --git a/internal/pipeline/pr/remediation_contract_test.go b/internal/pipeline/pr/remediation_contract_test.go new file mode 100644 index 0000000..68bf9fa --- /dev/null +++ b/internal/pipeline/pr/remediation_contract_test.go @@ -0,0 +1,46 @@ +package pr_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/douglasjarquin/made/internal/github" + "github.com/douglasjarquin/made/internal/pipeline/pr" +) + +func TestRun_CreatePRIsIdempotentByRepositoryBaseAndHead(t *testing.T) { + logPath := filepath.Join(t.TempDir(), "gh.log") + statePath := filepath.Join(t.TempDir(), "created") + bin := filepath.Join(t.TempDir(), "strict-gh") + script := strings.Join([]string{ + "#!/bin/sh", + "set -eu", + "printf '%s\\n' \"$*\" >> \"$STRICT_GH_LOG\"", + "if [ \"$1\" = auth ] && [ \"$2\" = status ]; then exit 0; fi", + "if [ \"$1\" = pr ] && [ \"$2\" = list ]; then if [ -f \"$STRICT_GH_STATE\" ]; then printf '%s\\n' '[{\"url\":\"https://github.com/example/repo/pull/42\"}]'; else printf '%s\\n' '[]'; fi; exit 0; fi", + "if [ \"$1\" = pr ] && [ \"$2\" = create ]; then touch \"$STRICT_GH_STATE\"; printf '%s\\n' 'https://github.com/example/repo/pull/42'; exit 0; fi", + "exit 1", + "", + }, "\n") + if err := os.WriteFile(bin, []byte(script), 0o700); err != nil { + t.Fatalf("write strict gh fake: %v", err) + } + c := &github.Client{Binary: bin, Dir: t.TempDir(), ExtraEnv: []string{"STRICT_GH_LOG=" + logPath, "STRICT_GH_STATE=" + statePath}} + opts := pr.Options{Title: "title", Base: "main", Head: "feature", EvidenceRef: "run-1"} + for i := 0; i < 2; i++ { + result, err := pr.Run(context.Background(), c, opts) + if err != nil || !result.OK { + t.Fatalf("Run %d: result=%+v err=%v", i, result, err) + } + } + data, err := os.ReadFile(logPath) + if err != nil { + t.Fatalf("read gh log: %v", err) + } + if count := strings.Count(string(data), "pr create"); count != 1 { + t.Fatalf("expected one idempotent pr create call, got %d\n%s", count, data) + } +} diff --git a/internal/pipeline/push/push.go b/internal/pipeline/push/push.go index 492885b..fa85757 100644 --- a/internal/pipeline/push/push.go +++ b/internal/pipeline/push/push.go @@ -19,6 +19,14 @@ type Result struct { Message string } +type InfrastructureError struct { + Detail string +} + +func (e *InfrastructureError) Error() string { + return "push: infrastructure failure: " + e.Detail +} + // credentialInURL matches the userinfo component of a URL (scheme://user:pass@host) // so it can be stripped from anything made surfaces in a Result or error - git // itself prints the real remote URL verbatim into its own error output when a @@ -26,10 +34,8 @@ type Result struct { // and that string must never end up in made's own logs, DB, or evidence. var credentialInURL = regexp.MustCompile(`([a-zA-Z][a-zA-Z0-9+.-]*://)[^\s/@]+@`) -// Run's error return is reserved for infrastructure failures (empty -// arguments, git failing to start); a push rejected by the remote - auth -// failure, unreachable host, non-fast-forward - is a normal outcome reported -// via Result.OK, not an error, following the lint/test stage convention. +// Run returns infrastructure errors for a remote that cannot accept the push, +// including authentication, transport, and policy-hook failures. // // Run never reads or constructs the remote's URL itself: it shells out to // `git push ` inside the worktree, so git resolves the @@ -65,10 +71,7 @@ func Run(ctx context.Context, worktreePath, remoteName, branch string) (Result, if res.ExitCode != 0 { output := redact(strings.TrimSpace(string(res.Stdout) + "\n" + string(res.Stderr))) - return Result{ - OK: false, - Message: fmt.Sprintf("git push %s %s failed with exit code %d: %s", remoteName, refspec, res.ExitCode, output), - }, nil + return Result{}, &InfrastructureError{Detail: fmt.Sprintf("git push %s %s failed with exit code %d: %s", remoteName, refspec, res.ExitCode, output)} } return Result{ diff --git a/internal/pipeline/push/push_test.go b/internal/pipeline/push/push_test.go index dd87a14..90f68c9 100644 --- a/internal/pipeline/push/push_test.go +++ b/internal/pipeline/push/push_test.go @@ -61,14 +61,11 @@ func TestRun_UnreachableRemoteFailsClean(t *testing.T) { beforeHead := strings.TrimSpace(run(t, wt.Path, "rev-parse", "HEAD")) result, err := push.Run(context.Background(), wt.Path, "origin", f.branch) - if err != nil { - t.Fatalf("Run: expected a reported failure, not an error, got: %v", err) - } - if result.OK { - t.Fatalf("expected OK=false for a push to an unreachable remote, got %+v", result) + if err == nil { + t.Fatalf("Run: expected unreachable push to be classified as infrastructure error, result=%+v", result) } - if result.Message == "" { - t.Fatalf("expected a non-empty failure message") + if !strings.Contains(err.Error(), "infrastructure") { + t.Fatalf("expected infrastructure classification, got %v", err) } status := run(t, wt.Path, "status", "--porcelain") diff --git a/internal/pipeline/rebase/rebase.go b/internal/pipeline/rebase/rebase.go index ce34ec4..ee06d33 100644 --- a/internal/pipeline/rebase/rebase.go +++ b/internal/pipeline/rebase/rebase.go @@ -6,11 +6,20 @@ package rebase import ( + "context" "fmt" "os" - "os/exec" "path/filepath" + "sort" "strings" + "time" + + madeexec "github.com/douglasjarquin/made/internal/exec" +) + +const ( + rebaseGitTimeout = 30 * time.Second + rebaseGitOutputCap = 1 << 20 ) type Result struct { @@ -24,27 +33,40 @@ type Result struct { // etc); a rebase conflict is a normal outcome reported via Result.OK, not an // error. func Run(worktreePath, defaultBranch string) (Result, error) { - cmd := exec.Command("git", "-C", worktreePath, "rebase", defaultBranch) - out, rebaseErr := cmd.CombinedOutput() - if rebaseErr == nil { + return RunContext(context.Background(), worktreePath, defaultBranch) +} + +func RunContext(ctx context.Context, worktreePath, defaultBranch string) (Result, error) { + result, err := runGit(ctx, worktreePath, "rebase", defaultBranch) + if err != nil { + return Result{}, fmt.Errorf("rebase: git rebase %s: %w", defaultBranch, err) + } + out := append(append([]byte(nil), result.Stdout...), result.Stderr...) + if result.ExitCode == 0 { return Result{ OK: true, Message: fmt.Sprintf("rebased cleanly onto %s", defaultBranch), }, nil } - if !rebaseInProgress(worktreePath) { - return Result{}, fmt.Errorf("rebase: git rebase %s: %w: %s", defaultBranch, rebaseErr, strings.TrimSpace(string(out))) + if !rebaseInProgress(ctx, worktreePath) { + return Result{}, fmt.Errorf("rebase: git rebase %s failed without unmerged paths: %s", defaultBranch, strings.TrimSpace(string(out))) } - files, err := conflictingFiles(worktreePath) + files, err := conflictingFiles(ctx, worktreePath) if err != nil { return Result{}, fmt.Errorf("rebase: list conflicting files after failed rebase onto %s: %w", defaultBranch, err) } + if len(files) == 0 { + if err := abortRebase(ctx, worktreePath); err != nil { + return Result{}, fmt.Errorf("rebase: failed without unmerged paths and abort failed: %w", err) + } + return Result{}, fmt.Errorf("rebase: git rebase %s failed without unmerged paths: %s", defaultBranch, strings.TrimSpace(string(out))) + } // A halted stage must never leave the worktree mid-rebase, so whatever // runs next (a retry, another stage) always starts from a clean state. - if err := abortRebase(worktreePath); err != nil { + if err := abortRebase(ctx, worktreePath); err != nil { return Result{}, fmt.Errorf("rebase: abort after conflict onto %s: %w", defaultBranch, err) } @@ -55,15 +77,17 @@ func Run(worktreePath, defaultBranch string) (Result, error) { }, nil } -func conflictingFiles(worktreePath string) ([]string, error) { - cmd := exec.Command("git", "-C", worktreePath, "diff", "--name-only", "--diff-filter=U") - out, err := cmd.Output() +func conflictingFiles(ctx context.Context, worktreePath string) ([]string, error) { + result, err := runGit(ctx, worktreePath, "diff", "--name-only", "--diff-filter=U") if err != nil { return nil, err } + if result.ExitCode != 0 { + return nil, fmt.Errorf("git diff --name-only --diff-filter=U exited %d: %s", result.ExitCode, strings.TrimSpace(string(result.Stderr))) + } var files []string - for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + for _, line := range strings.Split(strings.TrimSpace(string(result.Stdout)), "\n") { if line != "" { files = append(files, line) } @@ -71,22 +95,24 @@ func conflictingFiles(worktreePath string) ([]string, error) { return files, nil } -func abortRebase(worktreePath string) error { - cmd := exec.Command("git", "-C", worktreePath, "rebase", "--abort") - out, err := cmd.CombinedOutput() +func abortRebase(ctx context.Context, worktreePath string) error { + result, err := runGit(ctx, worktreePath, "rebase", "--abort") if err != nil { - return fmt.Errorf("git rebase --abort: %w: %s", err, strings.TrimSpace(string(out))) + return fmt.Errorf("git rebase --abort: %w", err) + } + if result.ExitCode != 0 { + return fmt.Errorf("git rebase --abort exited %d: %s", result.ExitCode, strings.TrimSpace(string(result.Stderr))) } return nil } -func rebaseInProgress(worktreePath string) bool { - out, err := exec.Command("git", "-C", worktreePath, "rev-parse", "--git-dir").Output() - if err != nil { +func rebaseInProgress(ctx context.Context, worktreePath string) bool { + result, err := runGit(ctx, worktreePath, "rev-parse", "--git-dir") + if err != nil || result.ExitCode != 0 { return false } - gitDir := strings.TrimSpace(string(out)) + gitDir := strings.TrimSpace(string(result.Stdout)) if !filepath.IsAbs(gitDir) { gitDir = filepath.Join(worktreePath, gitDir) } @@ -98,3 +124,108 @@ func rebaseInProgress(worktreePath string) bool { } return false } + +func runGit(ctx context.Context, worktreePath string, args ...string) (*madeexec.Result, error) { + commandArgs, err := rebaseGitArgs(ctx, worktreePath, args...) + if err != nil { + return nil, err + } + return madeexec.Run(ctx, madeexec.Command{ + Name: "git", + Args: commandArgs, + Env: controlledRebaseGitEnvironment(), + Timeout: rebaseGitTimeout, + OutputLimit: rebaseGitOutputCap, + }) +} + +func rebaseGitArgs(ctx context.Context, worktreePath string, args ...string) ([]string, error) { + filterArgs, err := repositoryFilterOverrides(ctx, worktreePath) + if err != nil { + return nil, err + } + commandArgs := []string{ + "-C", worktreePath, + "-c", "user.name=made-rebase", + "-c", "user.email=made-rebase@local", + "-c", "commit.gpgsign=false", + "-c", "core.hooksPath=/dev/null", + "-c", "core.fsmonitor=false", + "-c", "diff.external=", + } + commandArgs = append(commandArgs, filterArgs...) + return append(commandArgs, args...), nil +} + +func repositoryFilterOverrides(ctx context.Context, worktreePath string) ([]string, error) { + result, err := madeexec.Run(ctx, madeexec.Command{ + Name: "git", + Args: []string{ + "-C", worktreePath, + "config", "--local", "--name-only", "--get-regexp", + "^filter\\..+\\.(clean|process|smudge)$", + }, + Env: controlledRebaseGitEnvironment(), + Timeout: rebaseGitTimeout, + OutputLimit: rebaseGitOutputCap, + }) + if err != nil { + return nil, err + } + if result.ExitCode == 1 { + return nil, nil + } + if result.ExitCode != 0 { + return nil, fmt.Errorf("inspect repository Git filters: git exited %d: %s", result.ExitCode, strings.TrimSpace(string(result.Stderr))) + } + if strings.Contains(string(result.Stdout), "[output truncated]") { + return nil, fmt.Errorf("inspect repository Git filters: output exceeded %d bytes", rebaseGitOutputCap) + } + drivers := make(map[string]struct{}) + for _, key := range strings.Fields(string(result.Stdout)) { + prefix := strings.TrimPrefix(key, "filter.") + dot := strings.LastIndexByte(prefix, '.') + if dot <= 0 { + return nil, fmt.Errorf("inspect repository Git filters: invalid key %q", key) + } + switch prefix[dot+1:] { + case "clean", "process", "smudge": + drivers[prefix[:dot]] = struct{}{} + default: + return nil, fmt.Errorf("inspect repository Git filters: invalid key %q", key) + } + } + names := make([]string, 0, len(drivers)) + for name := range drivers { + names = append(names, name) + } + sort.Strings(names) + overrides := make([]string, 0, len(names)*8) + for _, name := range names { + prefix := "filter." + name + "." + overrides = append(overrides, + "-c", prefix+"clean=/bin/cat", + "-c", prefix+"smudge=/bin/cat", + "-c", prefix+"process=", + "-c", prefix+"required=false", + ) + } + return overrides, nil +} + +func controlledRebaseGitEnvironment() []string { + env := make([]string, 0, len(os.Environ())+4) + for _, entry := range os.Environ() { + name, _, ok := strings.Cut(entry, "=") + if !ok || strings.HasPrefix(name, "GIT_") || name == "SSH_AUTH_SOCK" { + continue + } + env = append(env, entry) + } + return append(env, + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_CONFIG_SYSTEM=/dev/null", + "GIT_CONFIG_NOSYSTEM=1", + "GIT_TERMINAL_PROMPT=0", + ) +} diff --git a/internal/pipeline/rebase/rebase_test.go b/internal/pipeline/rebase/rebase_test.go index 183b2e4..9bc20f5 100644 --- a/internal/pipeline/rebase/rebase_test.go +++ b/internal/pipeline/rebase/rebase_test.go @@ -10,6 +10,9 @@ import ( ) func TestRun_CleanRebaseProceeds(t *testing.T) { + t.Setenv("GIT_CONFIG_GLOBAL", "/dev/null") + t.Setenv("GIT_CONFIG_SYSTEM", "/dev/null") + t.Setenv("SSH_AUTH_SOCK", "") f := setupFixture(t, "", "", "") wt := f.addWorktree(t) defer func() { @@ -42,6 +45,28 @@ func TestRun_CleanRebaseProceeds(t *testing.T) { } } +func TestRun_CleanRebaseIgnoresAmbientGitRouting(t *testing.T) { + f := setupFixture(t, "", "", "") + wt := f.addWorktree(t) + defer func() { + if err := wt.Remove(); err != nil { + t.Errorf("Remove: %v", err) + } + }() + + ambientGitDir := t.TempDir() + run(t, ambientGitDir, "init", "--bare", "-q") + t.Setenv("GIT_DIR", ambientGitDir) + + result, err := rebase.Run(wt.Path, f.defaultBranch) + if err != nil { + t.Fatalf("Run with ambient GIT_DIR: %v", err) + } + if !result.OK { + t.Fatalf("expected OK=true despite ambient GIT_DIR, got %+v", result) + } +} + func TestRun_ConflictingRebaseHalts(t *testing.T) { f := setupFixture(t, "shared.txt", "main version\n", "feature version\n") wt := f.addWorktree(t) diff --git a/internal/pipeline/rebase/remediation_contract_test.go b/internal/pipeline/rebase/remediation_contract_test.go new file mode 100644 index 0000000..621ae67 --- /dev/null +++ b/internal/pipeline/rebase/remediation_contract_test.go @@ -0,0 +1,31 @@ +package rebase_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/douglasjarquin/made/internal/pipeline/rebase" +) + +func TestRun_DoesNotClassifyRebaseFailureAsConflictWithoutUnmergedPaths(t *testing.T) { + worktree := t.TempDir() + if err := os.Mkdir(filepath.Join(worktree, ".git"), 0o700); err != nil { + t.Fatalf("make fake git dir: %v", err) + } + binDir := t.TempDir() + fakeGit := filepath.Join(binDir, "git") + script := "#!/bin/sh\nset -eu\ncd \"$2\"\nshift 2\ncase \"$*\" in\n *'rev-parse --git-dir'*) printf '.git\\n' ;;\n *'rebase --abort'*) rm -rf .git/rebase-merge ;;\n *'diff --name-only --diff-filter=U'*) : ;;\n *'rebase upstream'*) mkdir -p .git/rebase-merge; exit 1 ;;\n *) exit 1 ;;\nesac\n" + if err := os.WriteFile(fakeGit, []byte(script), 0o700); err != nil { + t.Fatalf("write fake git: %v", err) + } + t.Setenv("PATH", binDir+":"+os.Getenv("PATH")) + + result, err := rebase.Run(worktree, "upstream") + if err == nil { + if !result.OK && len(result.ConflictingFiles) == 0 { + t.Fatalf("rebase failure was labeled conflict without unmerged paths: %+v", result) + } + t.Fatalf("rebase failure without unmerged paths returned a normal conflict result: %+v", result) + } +} diff --git a/internal/pipeline/review/git.go b/internal/pipeline/review/git.go new file mode 100644 index 0000000..e9adbca --- /dev/null +++ b/internal/pipeline/review/git.go @@ -0,0 +1,124 @@ +package review + +import ( + "context" + "fmt" + "os" + "sort" + "strings" + "time" + + "github.com/douglasjarquin/made/internal/exec" +) + +const ( + reviewGitTimeout = 30 * time.Second + reviewGitLimit = 1 << 20 +) + +func gitOutput(ctx context.Context, worktreePath string, args ...string) (string, error) { + result, err := runGit(ctx, worktreePath, args, nil) + if err != nil { + return "", err + } + return strings.TrimSpace(string(result.Stdout)), nil +} + +func runGit(ctx context.Context, worktreePath string, args []string, stdin []byte) (*exec.Result, error) { + filterArgs, err := repositoryFilterOverrides(ctx, worktreePath) + if err != nil { + return nil, err + } + commandArgs := []string{ + "-C", worktreePath, + "-c", "core.hooksPath=/dev/null", + "-c", "core.fsmonitor=false", + "-c", "diff.external=", + } + commandArgs = append(commandArgs, filterArgs...) + commandArgs = append(commandArgs, args...) + result, err := exec.Run(ctx, exec.Command{ + Name: "git", + Args: commandArgs, + Env: controlledGitEnvironment(), + Stdin: stdin, + Timeout: reviewGitTimeout, + OutputLimit: reviewGitLimit, + }) + if err != nil { + return nil, err + } + if result.ExitCode != 0 { + return result, fmt.Errorf("git exited %d: %s", result.ExitCode, strings.TrimSpace(string(result.Stderr))) + } + return result, nil +} + +func repositoryFilterOverrides(ctx context.Context, worktreePath string) ([]string, error) { + result, err := exec.Run(ctx, exec.Command{ + Name: "git", + Args: []string{ + "-C", worktreePath, + "config", "--local", "--name-only", "--get-regexp", + "^filter\\..+\\.(clean|process|smudge)$", + }, + Env: controlledGitEnvironment(), + Timeout: reviewGitTimeout, + OutputLimit: reviewGitLimit, + }) + if err != nil { + return nil, err + } + if result.ExitCode == 1 { + return nil, nil + } + if result.ExitCode != 0 { + return nil, fmt.Errorf("inspect repository Git filters: git exited %d: %s", result.ExitCode, strings.TrimSpace(string(result.Stderr))) + } + if strings.Contains(string(result.Stdout), "[output truncated]") { + return nil, fmt.Errorf("inspect repository Git filters: output exceeded %d bytes", reviewGitLimit) + } + drivers := make(map[string]struct{}) + for _, key := range strings.Fields(string(result.Stdout)) { + prefix := strings.TrimPrefix(key, "filter.") + dot := strings.LastIndexByte(prefix, '.') + if dot <= 0 { + return nil, fmt.Errorf("inspect repository Git filters: invalid key %q", key) + } + switch prefix[dot+1:] { + case "clean", "process", "smudge": + drivers[prefix[:dot]] = struct{}{} + default: + return nil, fmt.Errorf("inspect repository Git filters: invalid key %q", key) + } + } + names := make([]string, 0, len(drivers)) + for name := range drivers { + names = append(names, name) + } + sort.Strings(names) + overrides := make([]string, 0, len(names)*8) + for _, name := range names { + prefix := "filter." + name + "." + overrides = append(overrides, + "-c", prefix+"clean=/bin/cat", + "-c", prefix+"smudge=/bin/cat", + "-c", prefix+"process=", + "-c", prefix+"required=false", + ) + } + return overrides, nil +} + +func controlledGitEnvironment() []string { + env := make([]string, 0, len(os.Environ())+3) + for _, entry := range os.Environ() { + name, _, ok := strings.Cut(entry, "=") + if !ok || strings.HasPrefix(name, "GIT_") || name == "SSH_AUTH_SOCK" { + continue + } + env = append(env, entry) + } + env = append(env, "GIT_CONFIG_GLOBAL=/dev/null", "GIT_CONFIG_SYSTEM=/dev/null", "GIT_TERMINAL_PROMPT=0") + return env +} diff --git a/internal/pipeline/review/remediation_contract_test.go b/internal/pipeline/review/remediation_contract_test.go new file mode 100644 index 0000000..d7d2c0d --- /dev/null +++ b/internal/pipeline/review/remediation_contract_test.go @@ -0,0 +1,264 @@ +package review_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/douglasjarquin/made/internal/agent" + "github.com/douglasjarquin/made/internal/agent/agenttest" + "github.com/douglasjarquin/made/internal/pipeline/review" +) + +func TestRun_AutoFixRequiresCleanStateBeforeApplyingReturnedPatch(t *testing.T) { + bin := agenttest.Build(t) + f := setupFixture(t) + wt := f.addWorktree(t) + t.Cleanup(func() { _ = wt.Remove() }) + + dirtyPath := filepath.Join(wt.Path, "unrelated.txt") + if err := os.WriteFile(dirtyPath, []byte("unrelated user work\n"), 0o600); err != nil { + t.Fatalf("write dirty fixture: %v", err) + } + patch := autoFixPatch(t, wt.Path) + scenarioPath := writeScenario(t, agent.Findings{Findings: []agent.Finding{ + {Kind: agent.FindingAutoFixable, Description: "clean-state fix", Patch: patch, Paths: []string{"reviewed.txt"}}, + }}) + + if _, err := review.Run(t.Context(), wt.Path, agent.KindCodex, review.Options{ + BinaryPath: bin, + ExtraEnv: []string{"FAKE_AGENT_SCENARIO=" + scenarioPath}, + }); err == nil { + t.Fatal("review auto-fix mutated a dirty worktree instead of refusing before apply") + } +} + +func TestRun_AutoFixIgnoresAmbientGitRoutingAndHooks(t *testing.T) { + bin := agenttest.Build(t) + f := setupFixture(t) + wt := f.addWorktree(t) + t.Cleanup(func() { _ = wt.Remove() }) + patch := autoFixPatch(t, wt.Path) + scenarioPath := writeScenario(t, agent.Findings{Findings: []agent.Finding{ + {Kind: agent.FindingAutoFixable, Description: "ignore ambient Git state", Patch: patch, Paths: []string{"reviewed.txt"}}, + }}) + + alternateDir := t.TempDir() + run(t, alternateDir, "init", "--bare", "-q") + hooksDir := t.TempDir() + hookMarker := filepath.Join(t.TempDir(), "hook-fired") + if err := os.WriteFile(filepath.Join(hooksDir, "pre-commit"), []byte("#!/bin/sh\nprintf fired > '"+hookMarker+"'\n"), 0o700); err != nil { + t.Fatalf("write auto-fix hook: %v", err) + } + t.Setenv("GIT_DIR", alternateDir) + t.Setenv("GIT_CONFIG_COUNT", "1") + t.Setenv("GIT_CONFIG_KEY_0", "core.hooksPath") + t.Setenv("GIT_CONFIG_VALUE_0", hooksDir) + + result, err := review.Run(t.Context(), wt.Path, agent.KindCodex, review.Options{ + BinaryPath: bin, + ExtraEnv: []string{"FAKE_AGENT_SCENARIO=" + scenarioPath}, + }) + if err != nil { + t.Fatalf("review auto-fix inherited ambient Git routing/configuration: %v", err) + } + if !result.OK || len(result.AutoFixed) != 1 { + t.Fatalf("expected one controlled auto-fix, got %+v", result) + } + for _, name := range []string{"GIT_DIR", "GIT_CONFIG_COUNT", "GIT_CONFIG_KEY_0", "GIT_CONFIG_VALUE_0"} { + _ = os.Unsetenv(name) + } + if got := run(t, wt.Path, "status", "--porcelain"); got != "" { + t.Fatalf("controlled auto-fix left worktree dirty: %q", got) + } + if _, err := os.Stat(hookMarker); !os.IsNotExist(err) { + t.Fatalf("ambient auto-fix hook ran: %v", err) + } +} + +func TestRun_AutoFixSuppressesAmbientGitHook(t *testing.T) { + bin := agenttest.Build(t) + f := setupFixture(t) + wt := f.addWorktree(t) + t.Cleanup(func() { _ = wt.Remove() }) + patch := autoFixPatch(t, wt.Path) + scenarioPath := writeScenario(t, agent.Findings{Findings: []agent.Finding{ + {Kind: agent.FindingAutoFixable, Description: "suppress ambient hook", Patch: patch, Paths: []string{"reviewed.txt"}}, + }}) + hooksDir := t.TempDir() + hookMarker := filepath.Join(t.TempDir(), "hook-fired") + if err := os.WriteFile(filepath.Join(hooksDir, "pre-commit"), []byte("#!/bin/sh\nprintf fired > '"+hookMarker+"'\n"), 0o700); err != nil { + t.Fatalf("write auto-fix hook: %v", err) + } + t.Setenv("GIT_CONFIG_COUNT", "1") + t.Setenv("GIT_CONFIG_KEY_0", "core.hooksPath") + t.Setenv("GIT_CONFIG_VALUE_0", hooksDir) + + result, err := review.Run(t.Context(), wt.Path, agent.KindCodex, review.Options{ + BinaryPath: bin, + ExtraEnv: []string{"FAKE_AGENT_SCENARIO=" + scenarioPath}, + }) + if err != nil { + t.Fatalf("review auto-fix inherited ambient Git hook configuration: %v", err) + } + if !result.OK || len(result.AutoFixed) != 1 { + t.Fatalf("expected one controlled auto-fix, got %+v", result) + } + if _, err := os.Stat(hookMarker); !os.IsNotExist(err) { + t.Fatalf("ambient auto-fix hook ran: %v", err) + } +} + +func TestRun_AutoFixSuppressesRepositoryLocalHook(t *testing.T) { + bin := agenttest.Build(t) + f := setupFixture(t) + wt := f.addWorktree(t) + t.Cleanup(func() { _ = wt.Remove() }) + patch := autoFixPatch(t, wt.Path) + scenarioPath := writeScenario(t, agent.Findings{Findings: []agent.Finding{ + {Kind: agent.FindingAutoFixable, Description: "suppress repository hook", Patch: patch, Paths: []string{"reviewed.txt"}}, + }}) + hooksDir := t.TempDir() + hookMarker := filepath.Join(t.TempDir(), "hook-fired") + if err := os.WriteFile(filepath.Join(hooksDir, "pre-commit"), []byte("#!/bin/sh\nprintf fired > '"+hookMarker+"'\n"), 0o700); err != nil { + t.Fatalf("write repository hook: %v", err) + } + run(t, wt.Path, "config", "core.hooksPath", hooksDir) + + result, err := review.Run(t.Context(), wt.Path, agent.KindCodex, review.Options{ + BinaryPath: bin, + ExtraEnv: []string{"FAKE_AGENT_SCENARIO=" + scenarioPath}, + }) + if err != nil { + t.Fatalf("review auto-fix inherited repository-local hook configuration: %v", err) + } + if !result.OK || len(result.AutoFixed) != 1 { + t.Fatalf("expected one controlled auto-fix, got %+v", result) + } + if _, err := os.Stat(hookMarker); !os.IsNotExist(err) { + t.Fatalf("repository-local auto-fix hook ran: %v", err) + } +} + +func TestRun_AutoFixSuppressesRepositoryLocalCleanFilter(t *testing.T) { + bin := agenttest.Build(t) + f := setupFixture(t) + wt := f.addWorktree(t) + t.Cleanup(func() { _ = wt.Remove() }) + writeFile(t, wt.Path, ".gitattributes", "reviewed.txt filter=malicious\n") + run(t, wt.Path, "add", ".gitattributes") + run(t, wt.Path, "commit", "-q", "-m", "add filter attributes") + filterDir := t.TempDir() + filterPath := filepath.Join(filterDir, "clean-filter") + filterMarker := filepath.Join(t.TempDir(), "filter-fired") + filter := "#!/bin/sh\nprintf fired > '" + filterMarker + "'\ncat\n" + if err := os.WriteFile(filterPath, []byte(filter), 0o700); err != nil { + t.Fatalf("write clean filter: %v", err) + } + run(t, wt.Path, "config", "filter.malicious.clean", filterPath) + run(t, wt.Path, "config", "filter.malicious.smudge", "cat") + run(t, wt.Path, "config", "filter.malicious.required", "true") + patch := autoFixPatch(t, wt.Path) + scenarioPath := writeScenario(t, agent.Findings{Findings: []agent.Finding{ + {Kind: agent.FindingAutoFixable, Description: "suppress repository filter", Patch: patch, Paths: []string{"reviewed.txt"}}, + }}) + + result, err := review.Run(t.Context(), wt.Path, agent.KindCodex, review.Options{ + BinaryPath: bin, + ExtraEnv: []string{"FAKE_AGENT_SCENARIO=" + scenarioPath}, + }) + if err != nil { + t.Fatalf("review auto-fix inherited repository-local clean filter: %v", err) + } + if !result.OK || len(result.AutoFixed) != 1 { + t.Fatalf("expected one controlled auto-fix, got %+v", result) + } + if _, err := os.Stat(filterMarker); !os.IsNotExist(err) { + t.Fatalf("repository-local clean filter ran: %v", err) + } +} + +func TestRun_RejectsDirectAgentWorktreeEdits(t *testing.T) { + bin := agenttest.Build(t) + f := setupFixture(t) + wt := f.addWorktree(t) + t.Cleanup(func() { _ = wt.Remove() }) + scenarioPath := writeScenario(t, agent.Findings{}) + + _, err := review.Run(t.Context(), wt.Path, agent.KindCodex, review.Options{ + BinaryPath: bin, + ExtraEnv: []string{ + "FAKE_AGENT_SCENARIO=" + scenarioPath, + "FAKE_AGENT_WRITE_PATH=unreviewed.txt", + "FAKE_AGENT_WRITE_DATA=agent must remain read-only", + }, + }) + if err == nil { + t.Fatal("review accepted direct agent edits to the worktree") + } + if _, statErr := os.Stat(filepath.Join(wt.Path, "unreviewed.txt")); !os.IsNotExist(statErr) { + t.Fatalf("direct agent edit escaped review isolation: %v", statErr) + } + if got := run(t, wt.Path, "status", "--porcelain", "--untracked-files=all"); got != "" { + t.Fatalf("direct agent edit left delivery worktree dirty: %q", got) + } +} + +func TestRun_AutoFixRejectsUnauthorizedDeletionBeforeApplyingPatch(t *testing.T) { + bin := agenttest.Build(t) + f := setupFixture(t) + wt := f.addWorktree(t) + t.Cleanup(func() { _ = wt.Remove() }) + + writeFile(t, wt.Path, "unauthorized.txt", "must survive\n") + run(t, wt.Path, "add", "unauthorized.txt") + run(t, wt.Path, "commit", "-q", "-m", "seed unauthorized path") + patch := deletionAndAllowedPatch(t, wt.Path) + scenarioPath := writeScenario(t, agent.Findings{Findings: []agent.Finding{ + {Kind: agent.FindingAutoFixable, Description: "reject unauthorized deletion", Patch: patch, Paths: []string{"reviewed.txt"}}, + }}) + + if _, err := review.Run(t.Context(), wt.Path, agent.KindCodex, review.Options{ + BinaryPath: bin, + ExtraEnv: []string{"FAKE_AGENT_SCENARIO=" + scenarioPath}, + }); err == nil { + t.Fatal("review auto-fix accepted a patch deleting an unreturned tracked path") + } + if _, err := os.Stat(filepath.Join(wt.Path, "unauthorized.txt")); err != nil { + t.Fatalf("unauthorized tracked file was removed before rejection: %v", err) + } + if got := run(t, wt.Path, "status", "--porcelain"); got != "" { + t.Fatalf("rejected auto-fix left worktree dirty: %q", got) + } +} + +func TestRun_AutoFixRejectsForbiddenPatchHeaderBeforeApplyingPatch(t *testing.T) { + bin := agenttest.Build(t) + f := setupFixture(t) + wt := f.addWorktree(t) + t.Cleanup(func() { _ = wt.Remove() }) + + patch := strings.Join([]string{ + "diff --git a/reviewed.txt b/../../unauthorized.txt", + "--- a/reviewed.txt", + "+++ b/../../unauthorized.txt", + "@@ -1 +1 @@", + "-line one", + "+changed", + "", + }, "\n") + scenarioPath := writeScenario(t, agent.Findings{Findings: []agent.Finding{ + {Kind: agent.FindingAutoFixable, Description: "reject forbidden header", Patch: patch, Paths: []string{"reviewed.txt"}}, + }}) + + if _, err := review.Run(t.Context(), wt.Path, agent.KindCodex, review.Options{ + BinaryPath: bin, + ExtraEnv: []string{"FAKE_AGENT_SCENARIO=" + scenarioPath}, + }); err == nil { + t.Fatal("review auto-fix accepted a forbidden patch header") + } + if got := run(t, wt.Path, "status", "--porcelain"); got != "" { + t.Fatalf("forbidden auto-fix left worktree dirty: %q", got) + } +} diff --git a/internal/pipeline/review/review.go b/internal/pipeline/review/review.go index 8f34425..c6c4a14 100644 --- a/internal/pipeline/review/review.go +++ b/internal/pipeline/review/review.go @@ -9,7 +9,7 @@ package review import ( "context" "fmt" - "os/exec" + "path/filepath" "strings" "time" @@ -25,7 +25,10 @@ type Options struct { type Result struct { OK bool Message string + Findings []agent.Finding AutoFixed []string + PreFixSHAs []string + PostFixSHAs []string PendingFindings []agent.Finding } @@ -34,6 +37,9 @@ type Result struct { // etc); ask-user and blocking findings are normal outcomes reported via // Result, not errors. func Run(ctx context.Context, worktreePath string, agentKind agent.Kind, opts Options) (Result, error) { + if err := requireCleanWorktree(ctx, worktreePath); err != nil { + return Result{}, fmt.Errorf("review: inspect worktree before agent: %w", err) + } findings, err := agent.Spawn(ctx, agentKind, agent.SpawnParams{ WorktreePath: worktreePath, BinaryPath: opts.BinaryPath, @@ -43,19 +49,26 @@ func Run(ctx context.Context, worktreePath string, agentKind agent.Kind, opts Op if err != nil { return Result{}, fmt.Errorf("review: spawn %s: %w", agentKind, err) } + if err := requireCleanWorktree(ctx, worktreePath); err != nil { + return Result{}, fmt.Errorf("review: agent modified worktree: %w", err) + } var autoFixed []string + var preFixSHAs []string + var postFixSHAs []string var pending []agent.Finding var blockingMessages []string for _, finding := range findings.Findings { switch finding.Kind { case agent.FindingAutoFixable: - sha, applyErr := applyAutoFix(worktreePath, finding) + preSHA, postSHA, applyErr := applyAutoFix(ctx, worktreePath, finding) if applyErr != nil { return Result{}, fmt.Errorf("review: apply auto-fix %q: %w", finding.Description, applyErr) } - autoFixed = append(autoFixed, sha) + autoFixed = append(autoFixed, postSHA) + preFixSHAs = append(preFixSHAs, preSHA) + postFixSHAs = append(postFixSHAs, postSHA) case agent.FindingBlocking: blockingMessages = append(blockingMessages, finding.Description) pending = append(pending, finding) @@ -68,7 +81,10 @@ func Run(ctx context.Context, worktreePath string, agentKind agent.Kind, opts Op return Result{ OK: false, Message: fmt.Sprintf("review halted by blocking finding(s): %s", strings.Join(blockingMessages, "; ")), + Findings: findings.Findings, AutoFixed: autoFixed, + PreFixSHAs: preFixSHAs, + PostFixSHAs: postFixSHAs, PendingFindings: pending, }, nil } @@ -76,42 +92,178 @@ func Run(ctx context.Context, worktreePath string, agentKind agent.Kind, opts Op return Result{ OK: true, Message: fmt.Sprintf("review passed: %d auto-fix(es) applied, %d finding(s) await human approval", len(autoFixed), len(pending)), + Findings: findings.Findings, AutoFixed: autoFixed, + PreFixSHAs: preFixSHAs, + PostFixSHAs: postFixSHAs, PendingFindings: pending, }, nil } -func applyAutoFix(worktreePath string, finding agent.Finding) (string, error) { +func applyAutoFix(ctx context.Context, worktreePath string, finding agent.Finding) (string, string, error) { if strings.TrimSpace(finding.Patch) == "" { - return "", fmt.Errorf("auto-fixable finding has no patch") + return "", "", fmt.Errorf("auto-fixable finding has no patch") + } + if err := requireCleanWorktree(ctx, worktreePath); err != nil { + return "", "", err + } + preSHA, err := gitOutput(ctx, worktreePath, "rev-parse", "HEAD") + if err != nil { + return "", "", fmt.Errorf("record pre-fix SHA: %w", err) + } + paths, err := patchPaths(finding.Patch) + if err != nil { + return "", "", err + } + allowed := make(map[string]struct{}, len(finding.Paths)) + for _, path := range finding.Paths { + clean, err := cleanReturnedPath(path) + if err != nil { + return "", "", err + } + if _, err := gitOutput(ctx, worktreePath, "ls-files", "--error-unmatch", "--", clean); err != nil { + return "", "", fmt.Errorf("auto-fix returned untracked or unauthorized path %q", clean) + } + allowed[clean] = struct{}{} + } + if len(allowed) == 0 { + return "", "", fmt.Errorf("auto-fixable finding must return paths") + } + for _, path := range paths { + if _, ok := allowed[path]; !ok { + return "", "", fmt.Errorf("auto-fix patch changes path %q outside returned paths", path) + } } - applyCmd := exec.Command("git", "-C", worktreePath, "apply", "--whitespace=fix", "-") - applyCmd.Stdin = strings.NewReader(finding.Patch) - if out, err := applyCmd.CombinedOutput(); err != nil { - return "", fmt.Errorf("git apply: %w: %s", err, strings.TrimSpace(string(out))) + if _, err := runGit(ctx, worktreePath, []string{"apply", "--whitespace=fix", "-"}, []byte(finding.Patch)); err != nil { + return "", "", fmt.Errorf("git apply: %w", err) } - addCmd := exec.Command("git", "-C", worktreePath, "add", "-A") - if out, err := addCmd.CombinedOutput(); err != nil { - return "", fmt.Errorf("git add -A: %w: %s", err, strings.TrimSpace(string(out))) + status, err := gitOutput(ctx, worktreePath, "status", "--porcelain", "--untracked-files=all") + if err != nil { + return "", "", fmt.Errorf("inspect post-fix paths: %w", err) + } + changed := statusPaths(status) + for _, path := range changed { + if _, ok := allowed[path]; !ok { + return "", "", fmt.Errorf("auto-fix changed forbidden or unreturned path %q", path) + } + } + addArgs := []string{"-C", worktreePath, "add", "--"} + for path := range allowed { + addArgs = append(addArgs, path) + } + if _, err := runGit(ctx, worktreePath, addArgs[2:], nil); err != nil { + return "", "", fmt.Errorf("git add returned paths: %w", err) } message := finding.Description if message == "" { message = "made review: auto-fix" } - commitCmd := exec.Command("git", "-C", worktreePath, + if _, err := runGit(ctx, worktreePath, []string{ "-c", "user.name=made-review", "-c", "user.email=made-review@local", - "commit", "-m", message) - if out, err := commitCmd.CombinedOutput(); err != nil { - return "", fmt.Errorf("git commit: %w: %s", err, strings.TrimSpace(string(out))) + "-c", "commit.gpgsign=false", + "-c", "core.hooksPath=/dev/null", + "commit", "-m", message, + }, nil); err != nil { + return "", "", fmt.Errorf("git commit: %w", err) } - shaOut, err := exec.Command("git", "-C", worktreePath, "rev-parse", "HEAD").Output() + shaOut, err := gitOutput(ctx, worktreePath, "rev-parse", "HEAD") if err != nil { - return "", fmt.Errorf("git rev-parse HEAD: %w", err) + return "", "", fmt.Errorf("git rev-parse HEAD: %w", err) + } + if _, err := gitOutput(ctx, worktreePath, "diff", "--check", preSHA, shaOut); err != nil { + return "", "", fmt.Errorf("rerun review validation: %w", err) + } + return preSHA, shaOut, nil +} + +func requireCleanWorktree(ctx context.Context, worktreePath string) error { + status, err := gitOutput(ctx, worktreePath, "status", "--porcelain", "--untracked-files=all") + if err != nil { + return fmt.Errorf("inspect clean worktree: %w", err) + } + if strings.TrimSpace(status) != "" { + return fmt.Errorf("auto-fix requires a clean worktree") + } + return nil +} + +func patchPaths(patch string) ([]string, error) { + seen := make(map[string]struct{}) + var oldPath string + for _, line := range strings.Split(patch, "\n") { + if strings.HasPrefix(line, "--- ") { + var err error + oldPath, err = patchHeaderPath(strings.TrimPrefix(line, "--- ")) + if err != nil { + return nil, err + } + continue + } + if !strings.HasPrefix(line, "+++ ") { + continue + } + newPath, err := patchHeaderPath(strings.TrimPrefix(line, "+++ ")) + if err != nil { + return nil, err + } + if oldPath != "" { + seen[oldPath] = struct{}{} + } + if newPath != "" { + seen[newPath] = struct{}{} + } + oldPath = "" + } + if len(seen) == 0 { + return nil, fmt.Errorf("auto-fix patch contains no returned paths") + } + paths := make([]string, 0, len(seen)) + for path := range seen { + paths = append(paths, path) + } + return paths, nil +} + +func patchHeaderPath(path string) (string, error) { + fields := strings.Fields(path) + if len(fields) == 0 { + return "", fmt.Errorf("auto-fix patch contains an empty file header") + } + path = fields[0] + if path == "/dev/null" { + return "", nil + } + if strings.HasPrefix(path, "a/") || strings.HasPrefix(path, "b/") { + path = path[2:] + } + return cleanReturnedPath(path) +} + +func cleanReturnedPath(path string) (string, error) { + clean := filepath.Clean(path) + if clean == "." || filepath.IsAbs(path) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) || clean == ".git" || strings.HasPrefix(clean, ".git"+string(filepath.Separator)) { + return "", fmt.Errorf("auto-fix returned forbidden path %q", path) + } + return filepath.ToSlash(clean), nil +} + +func statusPaths(status string) []string { + var paths []string + for _, line := range strings.Split(status, "\n") { + line = strings.TrimSpace(line) + if len(line) < 4 { + continue + } + path := strings.TrimSpace(line[2:]) + if strings.Contains(path, " -> ") { + path = strings.TrimSpace(strings.SplitN(path, " -> ", 2)[1]) + } + paths = append(paths, filepath.ToSlash(path)) } - return strings.TrimSpace(string(shaOut)), nil + return paths } diff --git a/internal/pipeline/review/review_test.go b/internal/pipeline/review/review_test.go index d823741..0421dfa 100644 --- a/internal/pipeline/review/review_test.go +++ b/internal/pipeline/review/review_test.go @@ -25,7 +25,7 @@ func TestRun_AutoFixApplied(t *testing.T) { scenarioPath := writeScenario(t, agent.Findings{ Findings: []agent.Finding{ - {Kind: agent.FindingAutoFixable, Description: "append auto-fixed line", Patch: patch}, + {Kind: agent.FindingAutoFixable, Description: "append auto-fixed line", Patch: patch, Paths: []string{"reviewed.txt"}}, }, }) diff --git a/internal/pipeline/review/testhelpers_test.go b/internal/pipeline/review/testhelpers_test.go index 12d4507..564fe32 100644 --- a/internal/pipeline/review/testhelpers_test.go +++ b/internal/pipeline/review/testhelpers_test.go @@ -111,3 +111,14 @@ func autoFixPatch(t *testing.T, worktreePath string) string { writeFile(t, scratch, "reviewed.txt", "line one\nauto-fixed line\n") return run(t, scratch, "diff") } + +func deletionAndAllowedPatch(t *testing.T, worktreePath string) string { + t.Helper() + scratch := t.TempDir() + run(t, scratch, "clone", "-q", worktreePath, ".") + writeFile(t, scratch, "reviewed.txt", "line one\nauto-fixed line\n") + if err := os.Remove(filepath.Join(scratch, "unauthorized.txt")); err != nil { + t.Fatalf("remove unauthorized patch fixture: %v", err) + } + return run(t, scratch, "diff") +} diff --git a/internal/pipeline/test/test.go b/internal/pipeline/test/test.go index cccb806..eab172f 100644 --- a/internal/pipeline/test/test.go +++ b/internal/pipeline/test/test.go @@ -41,10 +41,17 @@ func Run(ctx context.Context, worktreePath, runID string, testCommand []string, // Evidence must be written before Result is returned regardless of pass // or fail, so a blocked pipeline still leaves a durable record of what // the test command produced. - if evErr := store.WriteEvidence(runID, map[string][]byte{ + evidenceFiles := map[string][]byte{ "stdout.log": res.Stdout, "stderr.log": res.Stderr, - }); evErr != nil { + } + var evErr error + if contextual, ok := store.(evidence.ContextStore); ok { + evErr = contextual.WriteEvidenceContext(ctx, runID, evidenceFiles) + } else { + evErr = store.WriteEvidence(runID, evidenceFiles) + } + if evErr != nil { return Result{}, fmt.Errorf("test: write evidence: %w", evErr) } diff --git a/internal/skill/skill.go b/internal/skill/skill.go index e9fdc69..cc4e35c 100644 --- a/internal/skill/skill.go +++ b/internal/skill/skill.go @@ -68,21 +68,23 @@ along with the command: - The daemon starts on demand, but ` + "`made doctor`" + ` is the fastest way to confirm the daemon, ` + "`gh`" + ` authentication, and (optionally) a running herdr server are all reachable before you push. + Use ` + "`made doctor --json`" + ` for the versioned health response. ## Running the gate ` + "```sh" + ` made gate init # once per repo: create the bare gate + remote git push made # admits the push and returns immediately -made status --json # poll structured run state, per-stage - # results, and any pending ask-user findings +made capabilities --json # inspect the versioned public command contract +made run list --json --active # list active runs and exact run IDs +made run status --json # poll one exact run ID ` + "```" + ` ` + "`git push made `" + ` only blocks long enough to admit the push; it returns as soon as the push is accepted, before the pipeline has run a single stage. The 9-stage pipeline then runs in the background against a -daemon-managed worktree. Poll ` + "`made status --json`" + ` (it is safe to call -repeatedly) to watch the run progress and to read its eventual outcome - +daemon-managed worktree. Poll ` + "`made run status --json `" + ` (it is safe to call +repeatedly) to watch that run's progress and to read its eventual outcome - the push itself never reports pass or fail. ## Findings and approval @@ -92,10 +94,7 @@ Review and Document can surface findings while the pipeline runs: - Auto-fixable findings are applied automatically as new commits; nothing to do. - **ask-user** findings are queued, never silently applied or dropped. Run - ` + "`made review`" + ` to see them and approve or reject each one from a plain - stdin/stdout prompt - relay the finding to the user verbatim rather than - paraphrasing it, since it challenges something about their intent or the - product behavior of the change. + ` + "`made review decide --json --stage --decision `" + ` after the decision is authorized. A rejected finding halts the pipeline at that stage; an approved one applies and the run resumes. @@ -122,16 +121,15 @@ and the run resumes. ## Outcomes -` + "`made status --json`" + `'s ` + "`state`" + ` field is one of ` + "`queued`" + `, -` + "`running`" + `, ` + "`completed`" + `, or ` + "`failed`" + `: +` + "`made run status --json `" + `'s ` + "`state`" + ` field is one of ` + "`queued`" + `, +` + "`running`" + `, ` + "`awaiting_review`" + `, ` + "`awaiting_merge`" + `, ` + "`succeeded`" + `, ` + "`failed`" + `, ` + "`canceled`" + `, or ` + "`superseded`" + `: -- **All 9 stages pass** - the run's state stays ` + "`running`" + `, not - ` + "`completed`" + `. made opened a real PR and watched its checks go green, +- **All 9 stages pass** - the run's state becomes ` + "`awaiting_merge`" + `. made opened a real PR and watched its checks go green, but merging that PR is a human decision made cannot observe, so it never marks the run done on its own. The run's message names the open PR and says it is awaiting merge - tell the user it is ready for their review, and do not wait for the merge yourself. -- ` + "`failed`" + ` - a stage blocked the run. Read ` + "`made status --json`" + ` for +- ` + "`failed`" + ` - a stage blocked the run. Read ` + "`made run status --json `" + ` for the failing stage's result and message, fix it, commit the fix on the same branch, and push to ` + "`made`" + ` again. If PR or CI fails after Push already succeeded, the message says so explicitly - e.g. "push @@ -140,9 +138,19 @@ and the run resumes. real remote, so that message is your signal the branch is already live there even though the run itself failed - check whether it needs manual cleanup before you push a fix. -- An ask-user finding parks the run rather than failing it: state stays - ` + "`running`" + ` and ` + "`pending_findings`" + ` is populated until - ` + "`made review`" + ` resolves it (see above). +- An ask-user finding parks the run rather than failing it: state becomes + ` + "`awaiting_review`" + ` and ` + "`pending_findings`" + ` is populated until + ` + "`made review decide`" + ` resolves it (see above). + +## Durable contract + +The daemon acquires its singleton before inspecting or changing the Unix socket path. + +Only a stale Unix socket can be removed, and regular files, symlinks, and directories are preserved and rejected. + +Run state is stored in a fsync-backed local WAL, and gate submissions use an idempotent fsync-backed spool keyed by gate, ref, and input SHA. + +Shutdown is authorized through the owner-only Unix socket and is refused while active, awaiting, or undrained work remains. ## herdr visibility diff --git a/internal/skill/skill_test.go b/internal/skill/skill_test.go index b201ed6..dc9e57d 100644 --- a/internal/skill/skill_test.go +++ b/internal/skill/skill_test.go @@ -54,7 +54,7 @@ func TestCommittedSkillFileMatchesGenerator(t *testing.T) { // The real pipeline is asynchronous (a push is admitted and returns // immediately; the 9-stage pipeline runs in the background and is observed -// via `made status --json`), so the body must never regress to claiming a +// via `made run status --json `), so the body must never regress to claiming a // push blocks until the pipeline finishes. func TestBodyDoesNotClaimPushBlocks(t *testing.T) { if strings.Contains(skill.Markdown(), "blocks until") { diff --git a/skills/made/SKILL.md b/skills/made/SKILL.md index c661298..19af6c4 100644 --- a/skills/made/SKILL.md +++ b/skills/made/SKILL.md @@ -38,21 +38,23 @@ along with the command: - The daemon starts on demand, but `made doctor` is the fastest way to confirm the daemon, `gh` authentication, and (optionally) a running herdr server are all reachable before you push. + Use `made doctor --json` for the versioned health response. ## Running the gate ```sh made gate init # once per repo: create the bare gate + remote git push made # admits the push and returns immediately -made status --json # poll structured run state, per-stage - # results, and any pending ask-user findings +made capabilities --json # inspect the versioned public command contract +made run list --json --active # list active runs and exact run IDs +made run status --json # poll one exact run ID ``` `git push made ` only blocks long enough to admit the push; it returns as soon as the push is accepted, before the pipeline has run a single stage. The 9-stage pipeline then runs in the background against a -daemon-managed worktree. Poll `made status --json` (it is safe to call -repeatedly) to watch the run progress and to read its eventual outcome - +daemon-managed worktree. Poll `made run status --json ` (it is safe to call +repeatedly) to watch that run's progress and to read its eventual outcome - the push itself never reports pass or fail. ## Findings and approval @@ -62,10 +64,7 @@ Review and Document can surface findings while the pipeline runs: - Auto-fixable findings are applied automatically as new commits; nothing to do. - **ask-user** findings are queued, never silently applied or dropped. Run - `made review` to see them and approve or reject each one from a plain - stdin/stdout prompt - relay the finding to the user verbatim rather than - paraphrasing it, since it challenges something about their intent or the - product behavior of the change. + `made review decide --json --stage --decision ` after the decision is authorized. A rejected finding halts the pipeline at that stage; an approved one applies and the run resumes. @@ -92,16 +91,15 @@ and the run resumes. ## Outcomes -`made status --json`'s `state` field is one of `queued`, -`running`, `completed`, or `failed`: +`made run status --json `'s `state` field is one of `queued`, +`running`, `awaiting_review`, `awaiting_merge`, `succeeded`, `failed`, `canceled`, or `superseded`: -- **All 9 stages pass** - the run's state stays `running`, not - `completed`. made opened a real PR and watched its checks go green, +- **All 9 stages pass** - the run's state becomes `awaiting_merge`. made opened a real PR and watched its checks go green, but merging that PR is a human decision made cannot observe, so it never marks the run done on its own. The run's message names the open PR and says it is awaiting merge - tell the user it is ready for their review, and do not wait for the merge yourself. -- `failed` - a stage blocked the run. Read `made status --json` for +- `failed` - a stage blocked the run. Read `made run status --json ` for the failing stage's result and message, fix it, commit the fix on the same branch, and push to `made` again. If PR or CI fails after Push already succeeded, the message says so explicitly - e.g. "push @@ -110,9 +108,19 @@ and the run resumes. real remote, so that message is your signal the branch is already live there even though the run itself failed - check whether it needs manual cleanup before you push a fix. -- An ask-user finding parks the run rather than failing it: state stays - `running` and `pending_findings` is populated until - `made review` resolves it (see above). +- An ask-user finding parks the run rather than failing it: state becomes + `awaiting_review` and `pending_findings` is populated until + `made review decide` resolves it (see above). + +## Durable contract + +The daemon acquires its singleton before inspecting or changing the Unix socket path. + +Only a stale Unix socket can be removed, and regular files, symlinks, and directories are preserved and rejected. + +Run state is stored in a fsync-backed local WAL, and gate submissions use an idempotent fsync-backed spool keyed by gate, ref, and input SHA. + +Shutdown is authorized through the owner-only Unix socket and is refused while active, awaiting, or undrained work remains. ## herdr visibility