diff --git a/cmd/acp.go b/cmd/acp.go index c38c02d4..f0cc3f3c 100644 --- a/cmd/acp.go +++ b/cmd/acp.go @@ -4,12 +4,15 @@ import ( "io" "os" "os/signal" + "path/filepath" "syscall" "github.com/GrayCodeAI/hawk/internal/acp" + "github.com/GrayCodeAI/hawk/internal/attachment" hawkconfig "github.com/GrayCodeAI/hawk/internal/config" "github.com/GrayCodeAI/hawk/internal/engine" "github.com/GrayCodeAI/hawk/internal/observability/logger" + "github.com/GrayCodeAI/hawk/internal/storage" "github.com/spf13/cobra" ) @@ -43,5 +46,14 @@ func runACP(cmd *cobra.Command, _ []string) error { defer stop() srv := acp.NewServer(factory) + + // Mount a durable attachment store for inline image admission, gated on + // the resolved active model's vision support. When no deployment (or a + // non-vision model) is configured, image capability stays false and the + // server rejects image prompts rather than advertising support. + store := attachment.NewFSStore(filepath.Join(storage.StateDir(), "attachments")) + effectiveModel, _ := effectiveModelAndProvider(settings) + srv.SetAttachmentStore(store, engine.ModelSupportsVision(effectiveModel)) + return srv.ServeStdio(ctx) } diff --git a/cmd/chat_print.go b/cmd/chat_print.go index a7fb3df1..996c4697 100644 --- a/cmd/chat_print.go +++ b/cmd/chat_print.go @@ -306,6 +306,14 @@ func runRepl() error { return err } + if recordPath != "" { + restoreRec, recErr := startRecording(recordPath) + if recErr != nil { + return fmt.Errorf("record: %w", recErr) + } + defer restoreRec() + } + ctx := context.Background() var countdown bool if timeout > 0 { @@ -350,7 +358,7 @@ func runRepl() error { continue } if output != "" { - _, _ = fmt.Fprintln(os.Stdout, output) + _, _ = fmt.Fprintln(replOut, output) } continue } @@ -369,7 +377,7 @@ func runRepl() error { switch ev.Type { case "content": if outputFormat == "text" { - fmt.Print(ev.Content) + _, _ = fmt.Fprint(replOut, ev.Content) } else if outputFormat == "stream-json" { writePrintEvent(sessionID, "content", ev.Content, "") } diff --git a/cmd/issue.go b/cmd/issue.go new file mode 100644 index 00000000..cb3af952 --- /dev/null +++ b/cmd/issue.go @@ -0,0 +1,132 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "strings" + + "github.com/spf13/cobra" +) + +var ( + issueTitle string + issueBody string + issueAssign string + issueLabels []string + issueDryRun bool + issueJSON bool +) + +var issueCmd = &cobra.Command{ + Use: "issue [context]", + Short: "Draft or publish a GitHub issue (fx issue parity)", + Long: `Draft or publish a GitHub issue for the current repository, mirroring +fx's "issue" command. + +A title and body are generated from the optional — describe a +problem, paste a stack trace, or leave it empty. Publishing creates the issue +through the GitHub CLI ("gh"), so that must be installed and authenticated. + +Use --dry-run to preview the title and body without publishing anything.`, + Args: cobra.MaximumNArgs(1), + RunE: runIssue, +} + +func init() { + issueCmd.Flags().StringVar(&issueTitle, "title", "", "issue title (default: generated from context)") + issueCmd.Flags().StringVar(&issueBody, "body", "", "issue body (default: generated from context)") + issueCmd.Flags().StringVar(&issueAssign, "assign", "", "add an assignee") + issueCmd.Flags().StringSliceVar(&issueLabels, "label", nil, "apply a label (repeatable)") + issueCmd.Flags().BoolVar(&issueDryRun, "dry-run", false, "preview the issue without publishing") + issueCmd.Flags().BoolVar(&issueJSON, "json", false, "output the draft as JSON (requires --dry-run)") + rootCmd.AddCommand(issueCmd) +} + +func runIssue(cmd *cobra.Command, args []string) error { + ctx := strings.TrimSpace(strings.Join(args, " ")) + + title := issueTitle + if title == "" { + title = generateIssueTitle(ctx) + } + body := issueBody + if body == "" { + body = generateIssueBody(ctx) + } + + if issueDryRun { + out := struct { + Title string `json:"title"` + Body string `json:"body"` + Assignee string `json:"assignee,omitempty"` + Labels []string `json:"labels,omitempty"` + }{Title: title, Body: body, Assignee: issueAssign, Labels: issueLabels} + if issueJSON { + raw, err := json.MarshalIndent(out, "", " ") + if err != nil { + return fmt.Errorf("issue: marshal json: %w", err) + } + _, _ = cmd.OutOrStdout().Write(raw) + _, _ = fmt.Fprintln(cmd.OutOrStdout()) + return nil + } + cmd.Println("Issue preview (dry run — not published)") + cmd.Println("Title: " + title) + cmd.Println() + cmd.Print(body) + if len(issueLabels) > 0 { + cmd.Println() + cmd.Println("Labels: " + strings.Join(issueLabels, ", ")) + } + return nil + } + + if err := requireGH(); err != nil { + return err + } + + ghArgs := []string{"issue", "create", "--title", title, "--body", body} + if issueAssign != "" { + ghArgs = append(ghArgs, "--assignee", issueAssign) + } + for _, l := range issueLabels { + ghArgs = append(ghArgs, "--label", l) + } + + cc := exec.CommandContext(context.Background(), "gh", ghArgs...) // #nosec G204 -- fixed command 'gh' with args; title/body are data arguments, not the executable + cc.Stderr = os.Stderr + out, err := cc.Output() + if err != nil { + return fmt.Errorf("gh issue create failed: %w", err) + } + cmd.Println("Issue created: " + strings.TrimSpace(string(out))) + return nil +} + +// generateIssueTitle derives a short title from the supplied context. +func generateIssueTitle(ctx string) string { + first := ctx + if i := strings.IndexByte(first, '\n'); i >= 0 { + first = first[:i] + } + first = strings.TrimSpace(first) + if first == "" { + return "Untitled report" + } + for _, c := range []string{"#", "##", "###", ">", "-", "*"} { + first = strings.TrimPrefix(first, c) + } + return strings.TrimSpace(first) +} + +// generateIssueBody wraps the context in a fenced block so trace text is +// preserved verbatim. +func generateIssueBody(ctx string) string { + if ctx == "" { + return "No additional context was provided." + } + return "**Reported via hawk**\n\n```\n" + ctx + "\n```\n" +} diff --git a/cmd/issue_test.go b/cmd/issue_test.go new file mode 100644 index 00000000..c8c6265e --- /dev/null +++ b/cmd/issue_test.go @@ -0,0 +1,34 @@ +package cmd + +import ( + "strings" + "testing" +) + +func TestGenerateIssueTitle(t *testing.T) { + cases := []struct { + in string + want string + }{ + {"", "Untitled report"}, + {"panic: nil pointer dereference\n\ngoroutine 1", "panic: nil pointer dereference"}, + {"# Crash on startup\n\nDetails here", "Crash on startup"}, + {" \n\n\n", "Untitled report"}, + {" - flaky test in parser", "flaky test in parser"}, + } + for _, c := range cases { + if got := generateIssueTitle(c.in); got != c.want { + t.Errorf("generateIssueTitle(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestGenerateIssueBody(t *testing.T) { + if got := generateIssueBody(""); !strings.Contains(got, "No additional context") { + t.Errorf("empty context body = %q, want 'No additional context'", got) + } + body := generateIssueBody("stack\nline 2") + if !strings.Contains(body, "```") || !strings.Contains(body, "stack\nline 2") { + t.Errorf("context body = %q, want fenced original text", body) + } +} diff --git a/cmd/record.go b/cmd/record.go new file mode 100644 index 00000000..88e80d6b --- /dev/null +++ b/cmd/record.go @@ -0,0 +1,44 @@ +package cmd + +import ( + "io" + "os" + + "github.com/GrayCodeAI/hawk/internal/terminal/tape" +) + +// recordPath is set by --record; when non-empty, interactive REPL output is +// captured to an fxtape file. +var recordPath string + +// replOut is where interactive REPL output is written. It defaults to +// os.Stdout and is swapped for a tape Recorder while --record is active so the +// live stream is both shown and captured. +var replOut io.Writer = os.Stdout + +// startRecording begins capturing interactive REPL output to path as an +// fxtape (fx `--record` parity): stdout bytes are recorded as frames along +// with terminal resize events. It returns a cleanup function that restores the +// default writer and closes the tape. +func startRecording(path string) (func(), error) { + w, h := TermSize() + f, err := os.Create(path) + if err != nil { + return nil, err + } + rec, err := tape.NewRecorder(f, replOut, uint16(w), uint16(h), nil) + if err != nil { + _ = f.Close() + return nil, err + } + prev := replOut + replOut = rec + stopResize := watchTerminalResize(rec) + + return func() { + stopResize() + replOut = prev + _ = rec.Close() + _ = f.Close() + }, nil +} diff --git a/cmd/record_test.go b/cmd/record_test.go new file mode 100644 index 00000000..7ee56f42 --- /dev/null +++ b/cmd/record_test.go @@ -0,0 +1,38 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/GrayCodeAI/hawk/internal/terminal/tape" +) + +func TestStartRecordingCapturesOutput(t *testing.T) { + path := filepath.Join(t.TempDir(), "rec.fxtape") + restore, err := startRecording(path) + if err != nil { + t.Fatalf("startRecording: %v", err) + } + if _, err := fmt.Fprint(replOut, "live bytes"); err != nil { + t.Fatalf("Fprint: %v", err) + } + restore() + + if replOut != os.Stdout { + t.Errorf("replOut not restored to os.Stdout after cleanup") + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read tape: %v", err) + } + parsed, err := tape.Parse(data) + if err != nil { + t.Fatalf("parse tape: %v", err) + } + if len(parsed.Frames) != 1 || parsed.Frames[0].Kind != tape.KindStdout || string(parsed.Frames[0].Payload) != "live bytes" { + t.Errorf("frames = %+v, want single stdout frame 'live bytes'", parsed.Frames) + } +} diff --git a/cmd/replay.go b/cmd/replay.go new file mode 100644 index 00000000..3fde35d8 --- /dev/null +++ b/cmd/replay.go @@ -0,0 +1,151 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + + "github.com/GrayCodeAI/hawk/internal/terminal/tape" +) + +var ( + replayJSON bool + replayFrames bool + replayGolden string + replayFramesDir string +) + +var replayCmd = &cobra.Command{ + Use: "replay ", + Short: "Replay a recorded terminal capture (fxtape)", + Long: `Replay a terminal capture recorded by the FX_RECORD tape writer (the +binary fxtape format from vercel-labs/fx, which hawk's tape package reads +byte-for-byte compatibly). Feeds the recorded stdout bytes into a virtual +terminal grid and prints the final visible snapshot. + +Flags: + --json emit a JSON summary of the tape (header + frame list). + --frames print a snapshot after every stdout/resize frame. + --golden FILE write the final snapshot to FILE instead of printing it. + --frames-dir DIR + export per-frame artifacts (frames/NNNN.json + NNNN.grid.txt) + plus manifest.json into DIR (fx replay --frames-dir parity).`, + Args: cobra.ExactArgs(1), + RunE: runReplay, +} + +func init() { + replayCmd.Flags().BoolVar(&replayJSON, "json", false, "emit JSON summary") + replayCmd.Flags().BoolVar(&replayFrames, "frames", false, "print a snapshot per frame") + replayCmd.Flags().StringVar(&replayGolden, "golden", "", "write final snapshot to file") + replayCmd.Flags().StringVar(&replayFramesDir, "frames-dir", "", "write per-frame artifacts (frames/NNNN.json + NNNN.grid.txt) and manifest.json to DIR") + rootCmd.AddCommand(replayCmd) +} + +// replayJSONSummary mirrors fx's --json output fields. +type replayJSONSummary struct { + Cols uint16 `json:"cols"` + Rows uint16 `json:"rows"` + EpochMS int64 `json:"epoch_ms"` + Version string `json:"version"` + Frames []replayJSONFrame `json:"frames"` + FrameCount int `json:"frame_count"` + ResizeCount int `json:"resize_count"` + StdoutBytes int `json:"stdout_bytes"` +} + +type replayJSONFrame struct { + DeltaMS int32 `json:"delta_ms"` + Kind string `json:"kind"` + Len int `json:"len"` +} + +func runReplay(cmd *cobra.Command, args []string) error { + path := args[0] + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("replay: cannot read %s: %w", path, err) + } + t, err := tape.Parse(data) + if err != nil { + return fmt.Errorf("replay: bad tape %s: %w", path, err) + } + + if replayFramesDir != "" { + if _, err := tape.ExportFramesDir(replayFramesDir, t); err != nil { + return fmt.Errorf("replay: --frames-dir %s: %w", replayFramesDir, err) + } + } + + if replayJSON { + return emitReplayJSON(cmd, t) + } + + replay, final := tape.ReplayTape(t) + + if replayGolden != "" { + if err := os.WriteFile(replayGolden, []byte(final+"\n"), 0o644); err != nil { + return fmt.Errorf("replay: cannot write golden %s: %w", replayGolden, err) + } + return nil + } + + if replayFrames { + // Replay frame-by-frame, printing a snapshot after each non-marker + // frame (mirrors fx's --frames output). + grid := tape.NewGrid(int(t.Header.Cols), int(t.Header.Rows)) + for i, f := range t.Frames { + switch f.Kind { + case tape.KindStdout: + grid.Feed(f.Payload) + case tape.KindResize: + if len(f.Payload) >= 4 { + grid.Resize(int(f.Payload[0])|int(f.Payload[1])<<8, int(f.Payload[2])|int(f.Payload[3])<<8) + } + case tape.KindMarker: + continue + } + if f.Kind != tape.KindMarker { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "\n--- frame %d (%s, +%dms) ---\n", i+1, f.Kind, f.DeltaMS) + _, _ = fmt.Fprintln(cmd.OutOrStdout(), grid.Snapshot()) + } + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "replay: %d frames, %d bytes stdout\n", replay.Frames, replay.Stdout) + return nil + } + + _, _ = fmt.Fprintln(cmd.OutOrStdout(), final) + return nil +} + +func emitReplayJSON(cmd *cobra.Command, t *tape.Tape) error { + replay, _ := tape.ReplayTape(t) + sum := replayJSONSummary{ + Cols: t.Header.Cols, + Rows: t.Header.Rows, + EpochMS: t.Header.EpochMS, + Version: t.Header.Version, + FrameCount: len(t.Frames), + StdoutBytes: replay.Stdout, + } + for _, f := range t.Frames { + if f.Kind == tape.KindResize { + sum.ResizeCount++ + } + sum.Frames = append(sum.Frames, replayJSONFrame{ + DeltaMS: f.DeltaMS, + Kind: strings.ToLower(f.Kind.String()), + Len: len(f.Payload), + }) + } + out, err := json.MarshalIndent(sum, "", " ") + if err != nil { + return fmt.Errorf("replay: marshal json: %w", err) + } + _, _ = cmd.OutOrStdout().Write(out) + _, _ = fmt.Fprintln(cmd.OutOrStdout()) + return nil +} diff --git a/cmd/resize_unix.go b/cmd/resize_unix.go new file mode 100644 index 00000000..33482420 --- /dev/null +++ b/cmd/resize_unix.go @@ -0,0 +1,28 @@ +//go:build !windows + +package cmd + +import ( + "os" + "os/signal" + "syscall" + + "github.com/GrayCodeAI/hawk/internal/terminal/tape" +) + +// watchTerminalResize records terminal resize (SIGWINCH) events into the tape +// recorder while --record is active. It returns a stop function that +// deregisters the signal handler. +func watchTerminalResize(rec *tape.Recorder) func() { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGWINCH) + go func() { + for range sigCh { + w, h := TermSize() + _ = rec.Resize(uint16(w), uint16(h)) + } + }() + return func() { + signal.Stop(sigCh) + } +} diff --git a/cmd/resize_windows.go b/cmd/resize_windows.go new file mode 100644 index 00000000..11038a9c --- /dev/null +++ b/cmd/resize_windows.go @@ -0,0 +1,10 @@ +//go:build windows + +package cmd + +import "github.com/GrayCodeAI/hawk/internal/terminal/tape" + +// watchTerminalResize is a no-op on Windows, where SIGWINCH is not defined. +func watchTerminalResize(rec *tape.Recorder) func() { + return func() {} +} diff --git a/cmd/root.go b/cmd/root.go index ac747543..2b08c511 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -250,6 +250,7 @@ func init() { rootCmd.Flags().BoolVar(&repoMapFlag, "repo-map", false, "inject an AST-ranked repository map (Aider-style) into the system prompt") rootCmd.Flags().IntVar(&mapTokensFlag, "map-tokens", 1024, "token budget for the --repo-map overview") rootCmd.Flags().BoolVar(&replFlag, "repl", false, "start interactive REPL mode (like aider) for multi-turn conversation without TUI") + rootCmd.Flags().StringVar(&recordPath, "record", "", "record interactive REPL output to an fxtape file (fx --record parity)") rootCmd.Flags().BoolVar(&vibeMode, "vibe", false, "vibe coding mode: auto-apply, auto-run, no confirmations") rootCmd.Flags().IntVar(&powerLevel, "power", 5, "power level 1-10 (auto-configures model, context, review depth)") rootCmd.Flags().DurationVar(&timeout, "timeout", 0, "time budget for the operation (e.g., 2m, 5m, 1h)") diff --git a/cmd/session_migrate.go b/cmd/session_migrate.go new file mode 100644 index 00000000..c8b4c32b --- /dev/null +++ b/cmd/session_migrate.go @@ -0,0 +1,70 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/GrayCodeAI/hawk/internal/session" + "github.com/spf13/cobra" +) + +var ( + sessionMigrateID string + sessionMigrateAllowLarge bool + sessionMigrateJSON bool +) + +var sessionMigrateCmd = &cobra.Command{ + Use: "migrate |--id ", + Short: "Migrate a saved session to the current format (fx session migrate parity)", + Long: `Upgrade a saved session to the current on-disk JSONL format, mirroring fx's +"session migrate" command. + +Legacy .json sessions are loaded and re-persisted in the current format; +.jsonl sessions have their format_version header bumped. Oversized sessions are +refused unless --allow-large is set. + +Use --json to emit the migration result machine-readably.`, + Args: cobra.MaximumNArgs(1), + RunE: runSessionMigrate, +} + +func init() { + sessionMigrateCmd.Flags().StringVar(&sessionMigrateID, "id", "", "session id to migrate") + sessionMigrateCmd.Flags().BoolVar(&sessionMigrateAllowLarge, "allow-large", false, "permit migrating an oversized session") + sessionMigrateCmd.Flags().BoolVar(&sessionMigrateJSON, "json", false, "output the result as JSON") + sessionsCmd.AddCommand(sessionMigrateCmd) +} + +func runSessionMigrate(cmd *cobra.Command, args []string) error { + id := strings.TrimSpace(sessionMigrateID) + if len(args) > 0 { + id = strings.TrimSpace(args[0]) + } + if id == "" { + return fmt.Errorf("session migrate: specify a session id as an argument or with --id") + } + + res, err := session.MigrateSession(id, sessionMigrateAllowLarge) + if err != nil { + return err + } + + if sessionMigrateJSON { + raw, err := json.MarshalIndent(res, "", " ") + if err != nil { + return fmt.Errorf("session migrate: marshal json: %w", err) + } + _, _ = cmd.OutOrStdout().Write(raw) + _, _ = fmt.Fprintln(cmd.OutOrStdout()) + return nil + } + + if res.FromVersion >= res.ToVersion { + cmd.Println(fmt.Sprintf("Session %s is already at the current format (v%d).", res.ID, res.ToVersion)) + } else { + cmd.Println(fmt.Sprintf("Migrated session %s from v%d to v%d (%d bytes).", res.ID, res.FromVersion, res.ToVersion, res.SizeBytes)) + } + return nil +} diff --git a/cmd/session_migrate_test.go b/cmd/session_migrate_test.go new file mode 100644 index 00000000..1596166c --- /dev/null +++ b/cmd/session_migrate_test.go @@ -0,0 +1,74 @@ +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRunSessionMigrate(t *testing.T) { + state := t.TempDir() + t.Setenv("HAWK_STATE_DIR", state) + id := "migrate-cmd-test" + sessDir := filepath.Join(state, "sessions") + if err := os.MkdirAll(sessDir, 0o700); err != nil { + t.Fatal(err) + } + body := `{"id":"` + id + `","messages":[{"role":"user","content":"hi"}]}` + if err := os.WriteFile(filepath.Join(sessDir, id+".json"), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + + var sb strings.Builder + c := sessionMigrateCmd + c.SetOut(&sb) + c.SetErr(&sb) + if err := runSessionMigrate(c, []string{id}); err != nil { + t.Fatalf("runSessionMigrate: %v", err) + } + if !strings.Contains(sb.String(), "Migrated session "+id) { + t.Errorf("output = %q, want migration message", sb.String()) + } + if _, err := os.Stat(filepath.Join(sessDir, id+".json")); !os.IsNotExist(err) { + t.Errorf("legacy .json not removed") + } + if _, err := os.Stat(filepath.Join(sessDir, id+".jsonl")); err != nil { + t.Errorf("migrated .jsonl missing: %v", err) + } +} + +func TestRunSessionMigrateJSON(t *testing.T) { + state := t.TempDir() + t.Setenv("HAWK_STATE_DIR", state) + id := "migrate-cmd-json" + sessDir := filepath.Join(state, "sessions") + _ = os.MkdirAll(sessDir, 0o700) + body := `{"id":"` + id + `","messages":[]}` + _ = os.WriteFile(filepath.Join(sessDir, id+".json"), []byte(body), 0o600) + + old := sessionMigrateJSON + sessionMigrateJSON = true + defer func() { sessionMigrateJSON = old }() + + var sb strings.Builder + c := sessionMigrateCmd + c.SetOut(&sb) + c.SetErr(&sb) + if err := runSessionMigrate(c, []string{id}); err != nil { + t.Fatalf("runSessionMigrate: %v", err) + } + if !strings.Contains(sb.String(), `"from_version": 0`) { + t.Errorf("json output missing from_version:\n%s", sb.String()) + } +} + +func TestRunSessionMigrateRequiresID(t *testing.T) { + var sb strings.Builder + c := sessionMigrateCmd + c.SetOut(&sb) + c.SetErr(&sb) + if err := runSessionMigrate(c, nil); err == nil { + t.Fatal("expected error when no id supplied") + } +} diff --git a/cmd/tape.go b/cmd/tape.go new file mode 100644 index 00000000..479060db --- /dev/null +++ b/cmd/tape.go @@ -0,0 +1,119 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/GrayCodeAI/hawk/internal/terminal/tape" + "github.com/spf13/cobra" +) + +var ( + tapeStatusJSON bool + tapeCommitName string + tapeCommitDir string +) + +var tapeCmd = &cobra.Command{ + Use: "tape", + Short: "Inspect and checkpoint recorded terminal captures (fxtape)", + Long: `tape inspects and checkpoints recorded terminal captures in the binary +fxtape format. "tape status" summarizes a tape's header, frame mix, and +footprint; "tape commit" copies a validated tape into a named location with a +content hash so a session can be recalled as an immutable artifact.`, +} + +var tapeStatusCmd = &cobra.Command{ + Use: "status ", + Short: "Summarize a tape's header, frames, and footprint", + Long: `Print tape metrics: terminal size, capture time, version, frame count, +per-kind frame breakdown, stdout bytes, and total duration.`, + Args: cobra.ExactArgs(1), + RunE: runTapeStatus, +} + +var tapeCommitCmd = &cobra.Command{ + Use: "commit ", + Short: "Checkpoint a tape to a named immutable artifact", + Long: `Copy a validated tape into the commit store under a name, forbidding +overwrites, and write a sidecar meta.json with the content hash and commit ID.`, + Args: cobra.ExactArgs(1), + RunE: runTapeCommit, +} + +func init() { + tapeStatusCmd.Flags().BoolVar(&tapeStatusJSON, "json", false, "output status as JSON") + tapeCommitCmd.Flags().StringVar(&tapeCommitName, "name", "", "commit name (default: source basename)") + tapeCommitCmd.Flags().StringVar(&tapeCommitDir, "dir", "", "commit directory (default: HAWK_TAPES_DIR or user config dir)") + tapeCmd.AddCommand(tapeStatusCmd, tapeCommitCmd) + rootCmd.AddCommand(tapeCmd) +} + +func runTapeStatus(cmd *cobra.Command, args []string) error { + st, err := tape.InspectFile(args[0]) + if err != nil { + return err + } + if tapeStatusJSON { + out, err := json.MarshalIndent(st, "", " ") + if err != nil { + return fmt.Errorf("tape status: marshal json: %w", err) + } + _, _ = cmd.OutOrStdout().Write(out) + _, _ = fmt.Fprintln(cmd.OutOrStdout()) + return nil + } + + w := cmd.OutOrStdout() + _, _ = fmt.Fprintf(w, "path: %s\n", st.Path) + _, _ = fmt.Fprintf(w, "size: %d bytes\n", st.Size) + _, _ = fmt.Fprintf(w, "terminal: %dx%d\n", st.Cols, st.Rows) + _, _ = fmt.Fprintf(w, "captured: %s\n", time.UnixMilli(st.EpochMS).UTC().Format(time.RFC3339)) + _, _ = fmt.Fprintf(w, "version: %s\n", st.Version) + _, _ = fmt.Fprintf(w, "frames: %d\n", st.FrameCount) + _, _ = fmt.Fprintf(w, "stdout: %d bytes\n", st.StdoutBytes) + _, _ = fmt.Fprintf(w, "duration: %s\n", tapeDuration(st.DurationMS)) + for _, k := range []string{"stdout", "stdin", "resize", "sigint", "marker"} { + if n := st.Kinds[k]; n > 0 { + _, _ = fmt.Fprintf(w, " %-7s %d\n", k+":", n) + } + } + return nil +} + +func runTapeCommit(cmd *cobra.Command, args []string) error { + src := args[0] + name := tapeCommitName + if name == "" { + base := filepath.Base(src) + name = strings.TrimSuffix(base, filepath.Ext(base)) + if !tape.ValidCommitName(name) { + return fmt.Errorf("cannot derive a commit name from %q; use --name", src) + } + } + c, err := tape.CommitFile(src, name, tapeCommitDir) + if err != nil { + return err + } + w := cmd.OutOrStdout() + _, _ = fmt.Fprintf(w, "committed %s\n", c.Name) + _, _ = fmt.Fprintf(w, " id: %s\n", c.CommitID) + _, _ = fmt.Fprintf(w, " tape: %s\n", c.Path) + _, _ = fmt.Fprintf(w, " meta: %s\n", c.MetaPath) + return nil +} + +// tapeDuration renders a millisecond span compactly. +func tapeDuration(ms int64) string { + switch { + case ms < 1000: + return fmt.Sprintf("%d ms", ms) + case ms < 60_000: + return fmt.Sprintf("%.1f s", float64(ms)/1000) + default: + return fmt.Sprintf("%.1f min", float64(ms)/60_000) + } +} diff --git a/cmd/trace_report.go b/cmd/trace_report.go new file mode 100644 index 00000000..f38e4b0c --- /dev/null +++ b/cmd/trace_report.go @@ -0,0 +1,211 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "runtime" + "strings" + "time" + + "github.com/GrayCodeAI/hawk/internal/config" + "github.com/GrayCodeAI/hawk/internal/permissions" + "github.com/GrayCodeAI/hawk/internal/trace" + "github.com/spf13/cobra" +) + +// traceReportOut forces writing to an explicit path instead of the temp dir. +var traceReportOut string + +// traceReportLog optionally appends a tail of a log file to the report. +var traceReportLog string + +// traceReportNoCopy disables the clipboard-copy attempt on macOS. +var traceReportNoCopy bool + +var traceReportCmd = &cobra.Command{ + Use: "trace-report", + Short: "Write a private diagnostic trace report (fx /trace parity)", + Long: `Snapshot current session context, permissions, and recent activity into a +single private, redactable markdown document — ported from fx's /trace +slash command. + +The report is written to a private (0600) file in the temp directory with a +uniquely named file, then (on macOS) an attempt is made to copy it to the +clipboard. Obvious secrets are masked in the output; review and redact before +sharing.`, + RunE: runTraceReport, +} + +func init() { + traceReportCmd.Flags().StringVar(&traceReportOut, "out", "", "write report to this exact path instead of the temp dir") + traceReportCmd.Flags().StringVar(&traceReportLog, "log", "", "append the tail of this log file to the report") + traceReportCmd.Flags().BoolVar(&traceReportNoCopy, "no-copy", false, "do not attempt a clipboard copy") + rootCmd.AddCommand(traceReportCmd) +} + +func runTraceReport(cmd *cobra.Command, _ []string) error { + cwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("get working directory: %w", err) + } + + s := trace.Snapshot{ + Timestamp: time.Now(), + Version: DisplayVersion(), + Build: buildDateOrDev(), + Platform: runtime.GOOS + "/" + runtime.GOARCH, + Model: config.ActiveModel(context.Background()), + Workspace: cwd, + SessionID: os.Getenv("HAWK_SESSION_ID"), + PID: os.Getpid(), + Terminal: terminalSize(), + Env: selectedEnv(), + } + + s.StableRules = stableRules(cwd) + + if traceReportLog != "" { + s.LogTail = readLogTail(traceReportLog) + } + + var path string + if traceReportOut != "" { + if err := trace.WriteReportToPath(traceReportOut, &s); err != nil { + return err + } + path = traceReportOut + } else { + path, err = trace.WriteReportFile(&s) + if err != nil { + return err + } + } + + // Mirror fx: attempt clipboard copy; on failure print a review-and-redact + // notice pointing at the saved path. + if !traceReportNoCopy && trace.TryClipboard(trace.Build(&s)) { + cmd.Println("Trace report copied to clipboard. Saved at " + path + " (review and redact before sharing).") + } else { + cmd.Println("Trace saved at " + path + ". Review and redact it before sharing.") + } + return nil +} + +// stableRules loads the project's persisted exact permission rules. +func stableRules(projectDir string) []trace.StableRule { + store := permissions.NewStableRuleStore(permissions.DefaultStableRulesPath(projectDir)) + if err := store.Load(); err != nil { + return nil + } + rules := store.List() + if len(rules) == 0 { + return nil + } + out := make([]trace.StableRule, 0, len(rules)) + for _, r := range rules { + out = append(out, trace.StableRule{ + ID: r.ID, + Kind: r.Key.Kind.String(), + Identity: r.Key.Canonical, + Decision: r.Decision.String(), + }) + } + return out +} + +func buildDateOrDev() string { + d := strings.TrimSpace(buildDate) + if d == "" || d == "unknown" { + return "dev" + } + return d +} + +func terminalSize() string { + cols := os.Getenv("COLUMNS") + rows := os.Getenv("LINES") + if cols == "" || rows == "" { + return "" + } + return cols + "x" + rows +} + +// selectedEnv returns a small set of non-sensitive environment variables that +// are useful for diagnosis. Secrets are never selected. +func selectedEnv() []string { + var out []string + for _, kv := range os.Environ() { + key := kv + if i := strings.IndexByte(kv, '='); i >= 0 { + key = kv[:i] + } + if isEnvSecretKey(key) { + continue + } + out = append(out, kv) + if len(out) >= 40 { + break + } + } + return out +} + +// isEnvSecretKey reports whether an env var name is likely to carry a secret. +func isEnvSecretKey(key string) bool { + lower := strings.ToLower(key) + for _, needle := range []string{"token", "secret", "password", "passwd", "api_key", "apikey", "key", "auth", "credential", "pem"} { + if strings.Contains(lower, needle) { + return true + } + } + return false +} + +// readLogTail reads up to trace.MaxLogTailBytes trailing bytes and up to +// trace.MaxLogTailLines non-empty lines from a log file. +func readLogTail(path string) []trace.LogEntry { + info, err := os.Stat(path) + if err != nil { + return nil + } + total := info.Size() + if total <= 0 { + return nil + } + readSize := int64(trace.MaxLogTailBytes) + offset := total - readSize + if offset < 0 { + offset = 0 + readSize = total + } + data := make([]byte, readSize) + f, err := os.Open(path) + if err != nil { + return nil + } + n, rerr := f.ReadAt(data, offset) + _ = f.Close() + if rerr != nil && !errors.Is(rerr, io.EOF) { + return nil + } + text := string(data[:n]) + var lines []string + for _, line := range strings.Split(text, "\n") { + line = strings.TrimRight(line, " \t\r") + if line == "" { + continue + } + lines = append(lines, line) + } + if len(lines) > trace.MaxLogTailLines { + lines = lines[len(lines)-trace.MaxLogTailLines:] + } + out := make([]trace.LogEntry, 0, len(lines)) + for _, l := range lines { + out = append(out, trace.LogEntry{Line: l, Sensitive: true}) + } + return out +} diff --git a/cmd/usage.go b/cmd/usage.go new file mode 100644 index 00000000..e0f884a3 --- /dev/null +++ b/cmd/usage.go @@ -0,0 +1,93 @@ +package cmd + +import ( + "encoding/json" + "fmt" + + "github.com/GrayCodeAI/hawk/internal/usage" + "github.com/spf13/cobra" +) + +var ( + usagePeriod string + usageJSON bool + usageLedger string // override ledger path for tests; empty uses the default +) + +var usageCmd = &cobra.Command{ + Use: "usage [--period <24h|7d|30d>]", + Short: "Show local LLM token usage and spend (fx usage parity)", + Long: `Show per-model token usage and spend from the local usage ledger, +mirroring fx's "usage" command. + +Each model generation is recorded to the ledger as it completes. This command +summarizes the ledger over a rolling window: + --period 24h the last 24 hours (default) + --period 7d the last 7 days + --period 30d the last 30 days + +Use --json to emit the summary in machine-readable form.`, + Args: cobra.NoArgs, + RunE: runUsage, +} + +func init() { + usageCmd.Flags().StringVar(&usagePeriod, "period", "24h", "window: 24h, 7d, or 30d") + usageCmd.Flags().BoolVar(&usageJSON, "json", false, "output the summary as JSON") + rootCmd.AddCommand(usageCmd) +} + +func runUsage(cmd *cobra.Command, _ []string) error { + sinceMS, _, err := usage.ParsePeriod(usagePeriod) + if err != nil { + return err + } + + records, err := readUsageLedger() + if err != nil { + return err + } + sum := usage.Summarize(records, sinceMS) + + if usageJSON { + raw, err := json.MarshalIndent(sum, "", " ") + if err != nil { + return fmt.Errorf("usage: marshal json: %w", err) + } + _, _ = cmd.OutOrStdout().Write(raw) + _, _ = fmt.Fprintln(cmd.OutOrStdout()) + return nil + } + + if sum.Generations == 0 { + cmd.Println("No usage recorded in the last " + usagePeriod + ".") + cmd.Println("The ledger lives at " + usage.LedgerPath()) + return nil + } + + cmd.Println(fmt.Sprintf("Usage (last %s)", usagePeriod)) + cmd.Println(fmt.Sprintf("%-28s %10s %10s %8s %12s", "model", "in", "out", "gen", "cost")) + for _, m := range sum.ByModel { + cmd.Println(fmt.Sprintf("%-28s %10d %10d %8d %10.4f$", + truncateModel(m.Model), m.InputTokens, m.OutputTokens, m.Generations, m.TotalCostUSD)) + } + cmd.Println("------------------------------------------------------------") + cmd.Println(fmt.Sprintf("%-28s %10d %10s %8d %10.4f$", + "total", sum.TotalTokens, "", sum.Generations, sum.TotalCostUSD)) + return nil +} + +func truncateModel(m string) string { + if len(m) > 28 { + return m[:27] + "…" + } + return m +} + +// readUsageLedger returns ledger records, honoring an optional test override. +func readUsageLedger() ([]usage.Record, error) { + if usageLedger != "" { + return usage.ReadFrom(usageLedger) + } + return usage.Read() +} diff --git a/cmd/usage_test.go b/cmd/usage_test.go new file mode 100644 index 00000000..0764b949 --- /dev/null +++ b/cmd/usage_test.go @@ -0,0 +1,92 @@ +package cmd + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +func TestRunUsageSummarizesLedger(t *testing.T) { + path := filepath.Join(t.TempDir(), "usage.jsonl") + now := time.Now().UnixMilli() + const hourMS = int64(60 * 60 * 1000) + const dayMS = 24 * hourMS + content := `{"schema_version":1,"kind":"coverage","started_at_ms":` + strconv.FormatInt(now, 10) + `} +{"schema_version":1,"kind":"generation","fact":{"created_at_ms":` + strconv.FormatInt(now-2*hourMS, 10) + `,"model":"acme/m1","input_tokens":100,"output_tokens":20,"total_cost":0.0012}} +{"schema_version":1,"kind":"generation","fact":{"created_at_ms":` + strconv.FormatInt(now-10*dayMS, 10) + `,"model":"old/m1","input_tokens":999,"output_tokens":999,"total_cost":9.0}} +` + _ = os.WriteFile(path, []byte(content), 0o600) + + oldLedger := usageLedger + usageLedger = path + defer func() { usageLedger = oldLedger }() + + var sb strings.Builder + cmd := usageCmd + cmd.SetOut(&sb) + cmd.SetErr(&sb) + if err := runUsage(cmd, nil); err != nil { + t.Fatalf("runUsage: %v", err) + } + out := sb.String() + if !strings.Contains(out, "acme/m1") { + t.Errorf("output missing acme/m1:\n%s", out) + } + if strings.Contains(out, "old/m1") { + t.Errorf("output includes old/m1, want excluded by period:\n%s", out) + } + if !strings.Contains(out, "total") { + t.Errorf("output missing total row:\n%s", out) + } +} + +func TestRunUsageJSON(t *testing.T) { + path := filepath.Join(t.TempDir(), "usage.jsonl") + now := time.Now().UnixMilli() + content := `{"schema_version":1,"kind":"coverage","started_at_ms":` + strconv.FormatInt(now, 10) + `} +{"schema_version":1,"kind":"generation","fact":{"created_at_ms":` + strconv.FormatInt(now, 10) + `,"model":"m","input_tokens":2,"output_tokens":1,"total_cost":0.0003}} +` + _ = os.WriteFile(path, []byte(content), 0o600) + + oldLedger := usageLedger + usageLedger = path + defer func() { usageLedger = oldLedger }() + oldJSON := usageJSON + usageJSON = true + defer func() { usageJSON = oldJSON }() + + var sb strings.Builder + cmd := usageCmd + cmd.SetOut(&sb) + cmd.SetErr(&sb) + if err := runUsage(cmd, nil); err != nil { + t.Fatalf("runUsage: %v", err) + } + out := sb.String() + if !strings.Contains(out, "\"total_cost_usd\": 0.0003") { + t.Errorf("json output missing expected cost:\n%s", out) + } + if !strings.Contains(out, "\"total_tokens\": 3") { + t.Errorf("json output missing expected tokens:\n%s", out) + } +} + +func TestRunUsageEmpty(t *testing.T) { + oldLedger := usageLedger + usageLedger = filepath.Join(t.TempDir(), "absent.jsonl") + defer func() { usageLedger = oldLedger }() + + var sb strings.Builder + cmd := usageCmd + cmd.SetOut(&sb) + cmd.SetErr(&sb) + if err := runUsage(cmd, nil); err != nil { + t.Fatalf("runUsage: %v", err) + } + if !strings.Contains(sb.String(), "No usage recorded") { + t.Errorf("empty output = %q, want 'No usage recorded'", sb.String()) + } +} diff --git a/docs/plans/dsh-harness-port-plan.md b/docs/plans/dsh-harness-port-plan.md index 4b84fe87..6e087926 100644 --- a/docs/plans/dsh-harness-port-plan.md +++ b/docs/plans/dsh-harness-port-plan.md @@ -222,20 +222,19 @@ Highest-value remaining work, in order: types. `Validate` enforces surface-op placement invariant. 30. `request/context` payload fix — Delivered: changed from `{messages, tokens}` to DSH's `{provider, model, contextWindow}`. -31. Consider ACP protocol — `packages/acp` (Agent Communication Protocol) for - inter-agent communication. +31. ACP protocol — Delivered: `internal/acp/server.go` + `client.go` (initialize, + session/new/load/list, setMode/setIsolation, prompt, cancel, permission), + `internal/eventlog/acp_codec.go` (`TurnEndToStopReason`), and ACP content + admission (`internal/acp/content.go`, `content_test.go`). ## Remaining (future PRs) -- Consider ACP protocol — `packages/acp` (Agent Communication Protocol) for - inter-agent communication. - All 44 DSH event types are now wired at call sites with full StreamChunk union support, surface operations, ignorable markers, format version enforcement, and session header parity. The port is functionally complete. Remaining optional depth: -- DSH `surface.ts` `SurfaceManager`/`SurfaceReplacePlan` (live correction protocol — replacement operations). +- ~~DSH `surface.ts` `SurfaceManager`/`SurfaceReplacePlan` (live correction protocol — replacement operations).~~ Delivered: `internal/eventlog/surface.go` — `FoldSurface` (complete replay → surface nodes + replacement history) and an incremental `SurfaceManager` (bound to a `*Log`, with `Nodes`/`ReplaceGeneration`/`ValidateNext` atomic pre-flight), mirroring DSH's surface provenance, replacement-range, contiguity, and tool-result-rewrite invariants. +- ~~Zstd compression in persistence (DSH `session-persistence-jsonl/src/zstd.ts`).~~ Delivered: `internal/eventlog/zstdz/zstd.go` + `internal/session/session.go` (`.jsonl.zstd` saves) — see Phase 9. - DSH `packages/llm/llm/src/assembler.ts` (response normalization — covered by Eyrie facade). -- Zstd compression in persistence (DSH `session-persistence-jsonl/src/zstd.ts`) — optional physical encoding layer. This matrix is the honest anchor: the skeleton is ported; the deep fidelity is the actual remaining work. @@ -388,6 +387,38 @@ Delivered on this branch on top of Phase 11: - `internal/engine/journal.go` — `SetWriteBehind()`/`WriteBehind()`/`FlushWriteBehind()` methods + imports for session package +## Phase 13 — ACP content admission + +Ported from DSH `packages/acp/acp/src/content.ts` so the ACP server admits +real inline multimodal content: + +- `internal/acp/content.go` — Go-native admission module: + - `AcpContentBlock` (wire shape: `type`/`text`/`mimeType`/`data`/`name`/`uri`), + `ContentBlock` (durable core content with `*attachment.Ref`), and + `ContentError{Kind,Msg,Err}` with `FailureInvalid`/`FailureInternal`. + - `AdmitAcpPrompt(ctx, store, prompt, imageEnabled, signal)` — validates all + blocks first (rejects audio/resource/unknown as invalid; rejects images + when `imageEnabled` is false, base64 is non-canonical, or mimeType is + non-raster), persists the image batch atomically via `store.SaveImages`, + reconstructs ordered content, and rejects empty prompts. + - `decodeImage`/`imageMediaType`/`resourceLinkText`/`checkAborted`, + `SupportsAcpImagePrompts`, `AssistantBlockToAcp`. +- `internal/acp/server.go` — wired into the server: + - `Server.store` + `imageCapable` fields; `SetAttachmentStore(store, modelSupportsImage)`. + - `initialize` advertises `promptCapabilities.image` truthfully from `imageCapable`. + - `handlePrompt` runs `AdmitAcpPrompt` before queuing any user message (so + no late message races a persisted image), maps `failure-invalid`→invalid-params + / `failure-internal`→internal-error, coalesces consecutive text into one + `AddUser`, and attaches images via `session.AddUserWithAttachment`. +- `cmd/acp.go` — production wiring: mounts an `attachment.NewFSStore` under + `storage.StateDir()/attachments` and calls `srv.SetAttachmentStore(store, + engine.ModelSupportsVision(effectiveModel))`. With no configured deployment + (or a non-vision model) the gate resolves false, so `image` is advertised + truthfully and image prompts are rejected rather than silently dropped. +- `internal/acp/content_test.go` — 13 admission tests + 3 server integration + tests (capability advertisement, inline image admission end-to-end, and + rejection of unadvertised images). + ## Latest DSH clone check Cloned the latest `deepseek-ai/deepseek-harness` (rc.7, Aug 17) and compared against diff --git a/internal/acp/content.go b/internal/acp/content.go new file mode 100644 index 00000000..ce2bd2db --- /dev/null +++ b/internal/acp/content.go @@ -0,0 +1,312 @@ +// ACP wire-content admission and projection owned by the ACP adapter. +// +// Go-native port of DSH `packages/acp/acp/src/content.ts` +// (dsh-v0.1.0-rc.7): it narrows untrusted ACP prompt content blocks to the +// durable attachment vocabulary, strictly decodes inline images, admits an +// ordered batch to the attachment store, and projects committed assistant +// blocks back onto ACP wire content. Content-admission failures carry a +// stable category (`invalid` | `internal`) with no raw binary payload. +package acp + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "regexp" + "strings" + + "github.com/GrayCodeAI/hawk/internal/attachment" +) + +// Raster formats shared by ACP image blocks and the core attachment +// vocabulary (DSH `IMAGE_MEDIA_TYPES`). +var acpImageMediaTypes = []attachment.ImageMediaType{ + attachment.MediaTypePNG, + attachment.MediaTypeJPEG, + attachment.MediaTypeWebP, + attachment.MediaTypeGIF, +} + +// canonicalBase64 is RFC 4648 standard base64, excluding whitespace and +// URL-safe alphabet variants (DSH `CANONICAL_BASE64`). +var canonicalBase64 = regexp.MustCompile(`^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$`) + +// ContentFailureKind is the content-admission failure category used by the +// protocol handler (DSH `AcpContentFailureKind`). +type ContentFailureKind string + +const ( + // FailureInvalid reports caller-correctable request content, mapped to + // ACP invalid-params. + FailureInvalid ContentFailureKind = "invalid" + // FailureInternal reports an adapter/storage fault, mapped to ACP + // internal-error. + FailureInternal ContentFailureKind = "internal" +) + +// ContentError is an ACP content-admission failure with a stable category and +// no inline raw binary payload (DSH `AcpContentError`). +type ContentError struct { + Kind ContentFailureKind + Msg string + Err error +} + +func (e *ContentError) Error() string { + if e.Err != nil { + return fmt.Sprintf("acp content: %s: %s: %v", e.Kind, e.Msg, e.Err) + } + return fmt.Sprintf("acp content: %s: %s", e.Kind, e.Msg) +} + +func (e *ContentError) Unwrap() error { return e.Err } + +// AcpContentBlock is one ACP wire content block (the union subset this +// adapter admits). Includes the ACP text/image/resource_link shapes plus the +// audio/resource tags that are explicitly rejected. +type AcpContentBlock struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` + MimeType string `json:"mimeType,omitempty"` + Data string `json:"data,omitempty"` + Name string `json:"name,omitempty"` + URI string `json:"uri,omitempty"` +} + +// ContentBlock is one ordered piece of durable core content admitted from an +// ACP prompt or projected from a committed assistant block. Images carry the +// store-verified attachment reference (DSH `ContentBlock`). +type ContentBlock struct { + Type string + Text string + Attachment *attachment.Ref +} + +// imageMediaType narrows a wire MIME string to the durable raster vocabulary. +func imageMediaType(value string) attachment.ImageMediaType { + for _, mt := range acpImageMediaTypes { + if string(mt) == value { + return mt + } + } + return "" +} + +// decodeImage strictly decodes one ACP inline image without accepting base64 +// aliases (canonical RFC 4648, media type narrowed to the raster vocabulary). +func decodeImage(block AcpContentBlock) (attachment.SaveImage, error) { + mediaType := imageMediaType(block.MimeType) + if mediaType == "" { + return attachment.SaveImage{}, &ContentError{ + Kind: FailureInvalid, + Msg: "image mimeType must be image/png, image/jpeg, image/webp, or image/gif", + } + } + if !canonicalBase64.MatchString(block.Data) { + return attachment.SaveImage{}, &ContentError{ + Kind: FailureInvalid, + Msg: "image data must be canonical base64", + } + } + data, err := base64.StdEncoding.DecodeString(block.Data) + if err != nil { + return attachment.SaveImage{}, &ContentError{ + Kind: FailureInvalid, + Msg: "image data must be canonical base64", + } + } + // Re-encode equality rejects non-canonical encodings the regexp allows + // (e.g. wrong padding width) — exactly DSH's double check. + if base64.StdEncoding.EncodeToString(data) != block.Data { + return attachment.SaveImage{}, &ContentError{ + Kind: FailureInvalid, + Msg: "image data must be canonical base64", + } + } + return attachment.SaveImage{Data: data, MediaType: mediaType}, nil +} + +// resourceLinkText renders one baseline resource link into the current text +// vocabulary. +func resourceLinkText(block AcpContentBlock) string { + name, _ := json.Marshal(block.Name) + uri, _ := json.Marshal(block.URI) + return fmt.Sprintf("\n[resource_link name=%s uri=%s]\n", name, uri) +} + +// AdmitAcpPrompt validates and admits one ACP prompt into ordered durable +// core content. Every wire block and image is validated before the ordered +// image batch starts writing; cancellation after a successful content-addressed +// write may leave an unreachable object but never queues a late user message. +// +// store may be nil only when the prompt contains no image blocks; any image +// admission requires a mounted attachment store. The caller appends the +// returned content to its session spine only after admission returns, so a +// successful content-addressed write never races a user message. +func AdmitAcpPrompt( + ctx context.Context, + store attachment.Store, + prompt []AcpContentBlock, + imageEnabled bool, + signal <-chan struct{}, +) ([]ContentBlock, error) { + images := make([]attachment.SaveImage, 0, len(prompt)) + for _, block := range prompt { + switch block.Type { + case "text", "resource_link": + // validated during reconstruction below. + case "image": + if !imageEnabled { + return nil, &ContentError{ + Kind: FailureInvalid, + Msg: "inline image prompts were not advertised by this connection", + } + } + img, err := decodeImage(block) + if err != nil { + return nil, err + } + images = append(images, img) + case "audio": + return nil, &ContentError{Kind: FailureInvalid, Msg: "audio prompt content is not supported"} + case "resource": + return nil, &ContentError{Kind: FailureInvalid, Msg: "embedded resource prompt content is not supported"} + default: + return nil, &ContentError{Kind: FailureInvalid, Msg: "unsupported ACP prompt content"} + } + } + + refs := make([]attachment.Ref, 0, len(images)) + if len(images) > 0 { + if store == nil { + return nil, &ContentError{Kind: FailureInvalid, Msg: "no attachment store is mounted"} + } + if err := checkAborted(signal); err != nil { + return nil, err + } + saved, err := store.SaveImages(ctx, images) + if err != nil { + if attachment.IsAdmission(err) { + return nil, &ContentError{Kind: FailureInvalid, Msg: err.Error(), Err: err} + } + return nil, &ContentError{Kind: FailureInternal, Msg: "unable to persist the prompt image batch", Err: err} + } + refs = saved + if err := checkAborted(signal); err != nil { + return nil, err + } + } + + content := make([]ContentBlock, 0, len(prompt)) + pendingText := "" + flushText := func() { + if pendingText == "" { + return + } + content = append(content, ContentBlock{Type: "text", Text: pendingText}) + pendingText = "" + } + imageIndex := 0 + for _, block := range prompt { + switch block.Type { + case "text": + pendingText += block.Text + case "resource_link": + pendingText += resourceLinkText(block) + case "image": + flushText() + ref := refs[imageIndex] + imageIndex++ + block := ContentBlock{Type: "image", Attachment: &ref} + content = append(content, block) + case "audio", "resource": + // validated-and-rejected by the first pass above; unreachable. + } + } + flushText() + + nonEmpty := false + for _, block := range content { + if block.Type == "image" || (block.Type == "text" && strings.TrimSpace(block.Text) != "") { + nonEmpty = true + break + } + } + if !nonEmpty { + return nil, &ContentError{Kind: FailureInvalid, Msg: "empty prompt"} + } + return content, nil +} + +func checkAborted(signal <-chan struct{}) error { + if signal == nil { + return nil + } + select { + case <-signal: + return &ContentError{Kind: FailureInternal, Msg: "prompt admission cancelled"} + default: + return nil + } +} + +// SupportsAcpImagePrompts reports whether a mounted attachment store can +// admit the ACP raster vocabulary (DSH `supportsAcpImagePrompts`'s storage +// half). Model-route capability is checked by the caller via the store-less +// model gate and conjoined here. +func SupportsAcpImagePrompts(store attachment.Store, modelSupportsImage bool) bool { + if store == nil || !modelSupportsImage { + return false + } + limits := store.ImageLimits() + for _, mt := range limits.MediaTypes { + if imageMediaType(string(mt)) != "" { + return true + } + } + return false +} + +// AssistantBlockToAcp projects one committed assistant block to ACP wire +// content. Text blocks map directly; image attachments are re-read and +// integrity-verified before inline base64 delivery. Returns nil for +// non-output blocks and for empty text. +func AssistantBlockToAcp(ctx context.Context, store attachment.Store, block ContentBlock) (*AcpContentBlock, error) { + if block.Type == "text" { + if block.Text == "" { + return nil, nil + } + return &AcpContentBlock{Type: "text", Text: block.Text}, nil + } + if block.Type != "image" || block.Attachment == nil { + return nil, nil + } + if store == nil { + return nil, &ContentError{Kind: FailureInternal, Msg: "cannot deliver assistant image: no attachment store is mounted"} + } + stored, err := store.ReadImage(ctx, *block.Attachment) + if err != nil { + return nil, &ContentError{ + Kind: FailureInternal, + Msg: "cannot deliver assistant image: the attachment is unavailable or corrupt", + Err: err, + } + } + return &AcpContentBlock{ + Type: "image", + Data: base64.StdEncoding.EncodeToString(stored.Data), + MimeType: string(stored.Ref.MediaType), + }, nil +} + +// asContentError unwraps a ContentError from a wrapped error chain, for +// RPC-error-category mapping. +func asContentError(err error) (*ContentError, bool) { + var ce *ContentError + if errors.As(err, &ce) { + return ce, true + } + return nil, false +} diff --git a/internal/acp/content_test.go b/internal/acp/content_test.go new file mode 100644 index 00000000..1bf4bba2 --- /dev/null +++ b/internal/acp/content_test.go @@ -0,0 +1,425 @@ +package acp + +import ( + "bufio" + "bytes" + "context" + "encoding/base64" + "encoding/json" + "image" + "image/color" + "image/png" + "io" + "reflect" + "strings" + "sync" + "testing" + "time" + + "github.com/GrayCodeAI/hawk/internal/attachment" +) + +// pngFixture encodes a 4x3 NRGBA raster as PNG bytes. +func pngFixture(t *testing.T) []byte { + t.Helper() + img := image.NewNRGBA(image.Rect(0, 0, 4, 3)) + for y := 0; y < 3; y++ { + for x := 0; x < 4; x++ { + img.Set(x, y, color.NRGBA{R: uint8(20 * x), G: uint8(40 * y), B: 128, A: 255}) + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func canonicalB64(t *testing.T, data []byte) string { + t.Helper() + return base64.StdEncoding.EncodeToString(data) +} + +func newTestStore(t *testing.T) *attachment.FSStore { + t.Helper() + return attachment.NewFSStore(t.TempDir()) +} + +func textBlock(s string) AcpContentBlock { + return AcpContentBlock{Type: "text", Text: s} +} + +func imageBlock(t *testing.T, mt attachment.ImageMediaType) AcpContentBlock { + t.Helper() + return AcpContentBlock{Type: "image", MimeType: string(mt), Data: canonicalB64(t, pngFixture(t))} +} + +func TestAdmitAcpPrompt_TextOnly(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + content, err := AdmitAcpPrompt(ctx, store, []AcpContentBlock{ + textBlock("hello "), + textBlock("world"), + }, false, nil) + if err != nil { + t.Fatal(err) + } + want := []ContentBlock{{Type: "text", Text: "hello world"}} + if !reflect.DeepEqual(content, want) { + t.Fatalf("content = %#v, want %#v", content, want) + } +} + +func TestAdmitAcpPrompt_ResourceLink(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + content, err := AdmitAcpPrompt(ctx, store, []AcpContentBlock{ + textBlock("see: "), + {Type: "resource_link", Name: "doc", URI: "file:///tmp/a.md"}, + }, false, nil) + if err != nil { + t.Fatal(err) + } + if len(content) != 1 || content[0].Type != "text" { + t.Fatalf("content = %#v", content) + } + if content[0].Text != "see: \n[resource_link name=\"doc\" uri=\"file:///tmp/a.md\"]\n" { + t.Fatalf("text = %q", content[0].Text) + } +} + +func TestAdmitAcpPrompt_ImageEnabled(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + content, err := AdmitAcpPrompt(ctx, store, []AcpContentBlock{ + textBlock("pic: "), + imageBlock(t, attachment.MediaTypePNG), + textBlock(" done"), + }, true, nil) + if err != nil { + t.Fatal(err) + } + if len(content) != 3 { + t.Fatalf("content = %#v", content) + } + if content[0].Type != "text" || content[0].Text != "pic: " { + t.Fatalf("first block = %#v", content[0]) + } + if content[1].Type != "image" || content[1].Attachment == nil { + t.Fatalf("image block = %#v", content[1]) + } + if content[2].Type != "text" || content[2].Text != " done" { + t.Fatalf("last block = %#v", content[2]) + } + // Verify the attachment is durable and readable. + stored, err := store.ReadImage(ctx, *content[1].Attachment) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(stored.Data, pngFixture(t)) { + t.Fatalf("round-tripped bytes differ") + } +} + +func TestAdmitAcpPrompt_ImageDisabledRejected(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + _, err := AdmitAcpPrompt(ctx, store, []AcpContentBlock{ + imageBlock(t, attachment.MediaTypePNG), + }, false, nil) + ce, ok := asContentError(err) + if !ok || ce.Kind != FailureInvalid { + t.Fatalf("err = %v, want invalid ContentError", err) + } +} + +func TestAdmitAcpPrompt_AudioAndResourceRejected(t *testing.T) { + ctx := context.Background() + for _, typ := range []string{"audio", "resource"} { + _, err := AdmitAcpPrompt(ctx, nil, []AcpContentBlock{{Type: typ}}, true, nil) + ce, ok := asContentError(err) + if !ok || ce.Kind != FailureInvalid { + t.Fatalf("type %q: err = %v, want invalid ContentError", typ, err) + } + } +} + +func TestAdmitAcpPrompt_EmptyPromptRejected(t *testing.T) { + _, err := AdmitAcpPrompt(context.Background(), newTestStore(t), []AcpContentBlock{ + textBlock(" "), + }, false, nil) + ce, ok := asContentError(err) + if !ok || ce.Kind != FailureInvalid { + t.Fatalf("err = %v, want invalid ContentError", err) + } +} + +func TestAdmitAcpPrompt_UnsupportedType(t *testing.T) { + _, err := AdmitAcpPrompt(context.Background(), nil, []AcpContentBlock{{Type: "embedding"}}, false, nil) + ce, ok := asContentError(err) + if !ok || ce.Kind != FailureInvalid { + t.Fatalf("err = %v, want invalid ContentError", err) + } +} + +func TestAdmitAcpPrompt_ImageMimeTypeRejected(t *testing.T) { + _, err := AdmitAcpPrompt(context.Background(), newTestStore(t), []AcpContentBlock{ + {Type: "image", MimeType: "image/bmp", Data: canonicalB64(t, pngFixture(t))}, + }, true, nil) + ce, ok := asContentError(err) + if !ok || ce.Kind != FailureInvalid { + t.Fatalf("err = %v, want invalid ContentError", err) + } +} + +func TestAdmitAcpPrompt_NonCanonicalBase64Rejected(t *testing.T) { + // URL-safe base64 is not canonical: substitute '-' for '+' so the + // regexp rejects it. + data := pngFixture(t) + urlSafe := base64.RawURLEncoding.EncodeToString(data) + _, err := AdmitAcpPrompt(context.Background(), newTestStore(t), []AcpContentBlock{ + {Type: "image", MimeType: "image/png", Data: urlSafe}, + }, true, nil) + _ = data + ce, ok := asContentError(err) + if !ok || ce.Kind != FailureInvalid { + t.Fatalf("err = %v, want invalid ContentError", err) + } +} + +func TestAdmitAcpPrompt_NoStoreForImageRejected(t *testing.T) { + _, err := AdmitAcpPrompt(context.Background(), nil, []AcpContentBlock{ + imageBlock(t, attachment.MediaTypePNG), + }, true, nil) + ce, ok := asContentError(err) + if !ok || ce.Kind != FailureInvalid { + t.Fatalf("err = %v, want invalid ContentError", err) + } +} + +func TestAssistantBlockToAcp_Text(t *testing.T) { + out, err := AssistantBlockToAcp(context.Background(), newTestStore(t), ContentBlock{Type: "text", Text: "hi"}) + if err != nil { + t.Fatal(err) + } + if out == nil || out.Type != "text" || out.Text != "hi" { + t.Fatalf("out = %#v", out) + } + empty, err := AssistantBlockToAcp(context.Background(), nil, ContentBlock{Type: "text", Text: ""}) + if err != nil { + t.Fatal(err) + } + if empty != nil { + t.Fatalf("empty text should project to nil, got %#v", empty) + } +} + +func TestAssistantBlockToAcp_Image(t *testing.T) { + ctx := context.Background() + store := newTestStore(t) + content, err := AdmitAcpPrompt(ctx, store, []AcpContentBlock{ + imageBlock(t, attachment.MediaTypePNG), + }, true, nil) + if err != nil { + t.Fatal(err) + } + out, err := AssistantBlockToAcp(ctx, store, content[0]) + if err != nil { + t.Fatal(err) + } + if out == nil || out.Type != "image" { + t.Fatalf("out = %#v", out) + } + if out.MimeType != "image/png" { + t.Fatalf("mimeType = %q", out.MimeType) + } + decoded, err := base64.StdEncoding.DecodeString(out.Data) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(decoded, pngFixture(t)) { + t.Fatalf("re-projected bytes differ") + } +} + +func TestAssistantBlockToAcp_UnsupportedBlock(t *testing.T) { + // Non-output blocks (and image without a ref) project to nil. + if out, err := AssistantBlockToAcp(context.Background(), newTestStore(t), ContentBlock{Type: "tool_use"}); err != nil || out != nil { + t.Fatalf("tool_use -> %#v, %v", out, err) + } + if out, err := AssistantBlockToAcp(context.Background(), nil, ContentBlock{Type: "image"}); err != nil || out != nil { + t.Fatalf("image without ref -> %#v, %v", out, err) + } +} + +func TestSupportsAcpImagePrompts(t *testing.T) { + store := newTestStore(t) + if !SupportsAcpImagePrompts(store, true) { + t.Fatalf("expected true for mounted store with model support") + } + if SupportsAcpImagePrompts(store, false) { + t.Fatalf("expected false when model lacks image support") + } + if SupportsAcpImagePrompts(nil, true) { + t.Fatalf("expected false for nil store") + } + // A store whose limits admit only non-raster media types reports false. + stores := readOnlyStoreLackingRaster(t) + if SupportsAcpImagePrompts(stores, true) { + t.Fatalf("expected false when store admits no raster media types") + } +} + +// readOnlyStoreLackingRaster returns a Store with media types excluding the +// ACP raster vocabulary, for capability gate testing. +func readOnlyStoreLackingRaster(t *testing.T) *attachment.FSStore { + t.Helper() + root := t.TempDir() + // Persist one file so the store has content; limits override media types. + s := attachment.NewFSStore(root) + _, err := s.SaveImage(context.Background(), attachment.SaveImage{Data: pngFixture(t), MediaType: attachment.MediaTypePNG}) + if err != nil { + t.Fatal(err) + } + return attachment.NewFSStoreWithLimits(root, attachment.Limits{ + MaxImageBytes: 10 << 20, + MaxImagesPerMessage: 4, + MaxMessageImageBytes: 20 << 20, + MaxImagePixels: 16_000_000, + MediaTypes: []attachment.ImageMediaType{"image/tiff"}, + }) +} + +// runServerWith drives Serve for a caller-configured server (with a mounted +// store) over canned input lines and returns every output message. +func runServerWith(t *testing.T, srv *Server, inputLines []string) []rpcMessage { + t.Helper() + in := strings.NewReader(strings.Join(inputLines, "\n") + "\n") + pr, pw := io.Pipe() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + _ = srv.Serve(ctx, in, pw) + _ = pw.Close() + }() + + var msgs []rpcMessage + scanner := bufio.NewScanner(pr) + scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024) + for scanner.Scan() { + line := scanner.Bytes() + if len(line) == 0 { + continue + } + var m rpcMessage + if err := json.Unmarshal(line, &m); err == nil { + msgs = append(msgs, m) + } + } + wg.Wait() + return msgs +} + +// TestServer_ImageCapabilityAdvertisement verifies initialize advertises the +// runtime image capability derived from the mounted store + model gate, and +// that the initialize response carries the truth. +func TestServer_ImageCapabilityAdvertisement(t *testing.T) { + store := newTestStore(t) + srv := NewServer(testFactory) + srv.SetAttachmentStore(store, true) + if !srv.imageCapable { + t.Fatalf("expected imageCapable true with mounted store + model support") + } + srv.SetAttachmentStore(store, false) + if srv.imageCapable { + t.Fatalf("expected imageCapable false when model lacks image support") + } + srv.SetAttachmentStore(nil, true) + if srv.imageCapable { + t.Fatalf("expected imageCapable false when store is nil") + } + + // Truthful advertisement in the initialize response. + srv2 := NewServer(testFactory) + srv2.SetAttachmentStore(newTestStore(t), true) + msgs := runServerWith(t, srv2, []string{`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1}}`}) + if len(msgs) != 1 { + t.Fatalf("expected 1 initialize response, got %d", len(msgs)) + } + var initResp struct { + AgentCapabilities struct { + PromptCapabilities struct { + Image bool `json:"image"` + } `json:"promptCapabilities"` + } `json:"agentCapabilities"` + } + if err := json.Unmarshal(msgs[0].Result, &initResp); err != nil { + t.Fatal(err) + } + if !initResp.AgentCapabilities.PromptCapabilities.Image { + t.Fatalf("expected image capability advertised true, got %+v", initResp) + } +} + +// TestServer_AdmitInlineImage verifies a mounted server admits an inline image +// prompt end-to-end: the prompt streams and completes, and the image is +// durably committed by admission. +func TestServer_AdmitInlineImage(t *testing.T) { + store := newTestStore(t) + srv := NewServer(testFactory) + srv.SetAttachmentStore(store, true) + + img := imageBlock(t, attachment.MediaTypePNG) + imgRaw, _ := json.Marshal(img) + lines := []string{ + `{"jsonrpc":"2.0","id":1,"method":"session/new","params":{}}`, + `{"jsonrpc":"2.0","id":2,"method":"session/prompt","params":{"sessionId":"sess_1","prompt":[{"type":"text","text":"describe: "},` + string(imgRaw) + `]}}`, + } + msgs := runServerWith(t, srv, lines) + + var gotUpdate, gotPromptResult bool + for _, m := range msgs { + switch { + case m.Method == "session/update": + gotUpdate = true + case hasID(m, 2): + gotPromptResult = true + if m.Error != nil { + t.Fatalf("prompt errored: %+v", m.Error) + } + } + } + if !gotUpdate || !gotPromptResult { + t.Fatalf("missing responses: update=%v promptResult=%v (msgs=%d)", gotUpdate, gotPromptResult, len(msgs)) + } +} + +// TestServer_RejectInlineImageWhenNotAdvertised verifies a server with no +// mounted store refuses image prompts as invalid params (they were never +// advertised). +func TestServer_RejectInlineImageWhenNotAdvertised(t *testing.T) { + srv := NewServer(testFactory) // no store, imageCapable false + img := imageBlock(t, attachment.MediaTypePNG) + imgRaw, _ := json.Marshal(img) + msgs := runServerWith(t, srv, []string{ + `{"jsonrpc":"2.0","id":1,"method":"session/new","params":{}}`, + `{"jsonrpc":"2.0","id":2,"method":"session/prompt","params":{"sessionId":"sess_1","prompt":[` + string(imgRaw) + `]}}`, + }) + found := false + for _, m := range msgs { + if hasID(m, 2) { + found = true + if m.Error == nil || m.Error.Code != errCodeInvalidParams { + t.Fatalf("expected invalid-params error for unadvertised image, got %+v", m) + } + } + } + if !found { + t.Fatalf("no prompt response; msgs=%+v", msgs) + } +} diff --git a/internal/acp/server.go b/internal/acp/server.go index 7b1b60be..06a5ad5a 100644 --- a/internal/acp/server.go +++ b/internal/acp/server.go @@ -12,6 +12,7 @@ package acp import ( "bufio" "context" + "encoding/base64" "encoding/json" "fmt" "io" @@ -19,6 +20,7 @@ import ( "sync" "time" + "github.com/GrayCodeAI/hawk/internal/attachment" "github.com/GrayCodeAI/hawk/internal/engine" "github.com/GrayCodeAI/hawk/internal/session" ) @@ -56,6 +58,13 @@ type rpcError struct { type Server struct { factory SessionFactory + // store is the durable attachment service for inline image admission. + // When nil, image prompt content is rejected as not advertised. + store attachment.Store + // imageCapable reports whether the deployment can admit inline image + // prompts (store mounted and the model route supports image input). + imageCapable bool + mu sync.Mutex sessions map[string]*acpSession // order tracks session creation order for FIFO eviction when the session @@ -92,6 +101,14 @@ func NewServer(factory SessionFactory) *Server { } } +// SetAttachmentStore mounts the durable attachment service used for inline +// image admission, and recomputes image-prompt capability from the store. +// modelSupportsImage gates the capability on the active model route. +func (s *Server) SetAttachmentStore(store attachment.Store, modelSupportsImage bool) { + s.store = store + s.imageCapable = SupportsAcpImagePrompts(store, modelSupportsImage) +} + // ServeStdio runs the server on stdin/stdout until ctx is cancelled or EOF. func (s *Server) ServeStdio(ctx context.Context) error { return s.Serve(ctx, os.Stdin, os.Stdout) @@ -164,7 +181,7 @@ func (s *Server) handle(ctx context.Context, msg rpcMessage) { "loadSession": true, "listSessions": true, "promptCapabilities": map[string]any{ - "image": false, + "image": s.imageCapable, "audio": false, }, }, @@ -437,11 +454,8 @@ func (s *Server) teardown() { } type promptParams struct { - SessionID string `json:"sessionId"` - Prompt []struct { - Type string `json:"type"` - Text string `json:"text"` - } `json:"prompt"` + SessionID string `json:"sessionId"` + Prompt []AcpContentBlock `json:"prompt"` } func (s *Server) handlePrompt(ctx context.Context, msg rpcMessage) { @@ -458,20 +472,58 @@ func (s *Server) handlePrompt(ctx context.Context, msg rpcMessage) { return } - var text string - for _, b := range p.Prompt { - if b.Type == "text" { - text += b.Text - } - } - turnCtx, cancel := context.WithCancel(ctx) s.mu.Lock() as.cancel = cancel s.mu.Unlock() defer cancel() - as.sess.AddUser(text) + // Admit the untrusted prompt into durable, ordered core content before + // any user message is queued. Images are validated and durably committed + // to the store first; the session spine is appended only after admission + // returns, so no late message races a persisted image. + content, err := AdmitAcpPrompt(turnCtx, s.store, p.Prompt, s.imageCapable, turnCtx.Done()) + if err != nil { + if ce, ok := asContentError(err); ok { + code := errCodeInternal + if ce.Kind == FailureInvalid { + code = errCodeInvalidParams + } + s.writeError(msg.ID, code, ce.Msg) + return + } + s.writeError(msg.ID, errCodeInternal, "prompt admission failed: "+err.Error()) + return + } + + // Append admitted content to the session spine. Consecutive text blocks + // coalesce into a single user message (matching the text-only path); + // image references are re-read and attached via the engine's multimodal + // path, flushing pending text first so wire order is preserved. + var pendingText string + flushText := func() { + if pendingText == "" { + return + } + as.sess.AddUser(pendingText) + pendingText = "" + } + for _, block := range content { + switch { + case block.Type == "text": + pendingText += block.Text + case block.Type == "image" && block.Attachment != nil && s.store != nil: + flushText() + stored, rerr := s.store.ReadImage(turnCtx, *block.Attachment) + if rerr != nil { + s.writeError(msg.ID, errCodeInternal, "prompt image unavailable: "+rerr.Error()) + return + } + as.sess.AddUserWithAttachment("", base64.StdEncoding.EncodeToString(stored.Data), string(stored.Ref.MediaType)) + } + } + flushText() + events, err := as.sess.Stream(turnCtx) if err != nil { s.writeError(msg.ID, errCodeInternal, "stream failed: "+err.Error()) diff --git a/internal/engine/permission_exactrules_test.go b/internal/engine/permission_exactrules_test.go new file mode 100644 index 00000000..01d8e52f --- /dev/null +++ b/internal/engine/permission_exactrules_test.go @@ -0,0 +1,81 @@ +package engine + +import ( + "path/filepath" + "testing" + + "github.com/GrayCodeAI/hawk/internal/permissions" + "github.com/GrayCodeAI/hawk/internal/permissions/stableid" +) + +func newExactStore(t *testing.T) *permissions.StableRuleStore { + t.Helper() + s := permissions.NewStableRuleStore(filepath.Join(t.TempDir(), "stable-rules.json")) + if err := s.Load(); err != nil { + t.Fatalf("load store: %v", err) + } + return s +} + +// RememberExact persists a stable-id rule and RevokeExact removes it by id. +func TestPermissionServiceExactRememberRevoke(t *testing.T) { + svc := NewPermissionService(nil) + store := newExactStore(t) + svc.SetExactRuleStore(store) + + id, ok := svc.RememberExact(stableid.KindCommand, "command\x00git push", "git push", stableid.Allow) + if !ok || id == 0 { + t.Fatalf("remember failed: id=%d ok=%v", id, ok) + } + if d, ok := store.Resolve(stableid.KindCommand, "command\x00git push"); !ok || d != stableid.Allow { + t.Fatalf("expected resolved allow, got %v ok=%v", d, ok) + } + if len(svc.ListExact()) != 1 { + t.Fatalf("expected 1 exact rule, got %d", len(svc.ListExact())) + } + if !svc.RevokeExact(id) { + t.Fatal("revoke by stable id failed") + } + if len(svc.ListExact()) != 0 { + t.Fatal("expected empty list after revoke") + } + if svc.RevokeExact(id) { + t.Fatal("revoking a gone id must fail") + } +} + +// Upserting the same exact rule preserves its stable id. +func TestPermissionServiceExactUpsertPreservesID(t *testing.T) { + svc := NewPermissionService(nil) + store := newExactStore(t) + svc.SetExactRuleStore(store) + + id1, _ := svc.RememberExact(stableid.KindCommand, "command\x00go test", "v1", stableid.Deny) + id2, _ := svc.RememberExact(stableid.KindCommand, "command\x00go test", "v2", stableid.Allow) + if id1 != id2 { + t.Fatalf("upsert must preserve stable id: %d != %d", id1, id2) + } + if len(svc.ListExact()) != 1 { + t.Fatalf("upsert must not duplicate, got %d", len(svc.ListExact())) + } + if d, _ := store.Resolve(stableid.KindCommand, "command\x00go test"); d != stableid.Allow { + t.Fatal("decision must update to allow") + } +} + +// Without a configured store, remember/revoke are no-ops (nil-by-default). +func TestPermissionServiceExactNilByDefault(t *testing.T) { + svc := NewPermissionService(nil) + if svc.ExactRuleStore() != nil { + t.Fatal("exact store must be nil by default") + } + if id, ok := svc.RememberExact(stableid.KindCommand, "command\x00x", "x", stableid.Allow); ok || id != 0 { + t.Fatalf("remember on nil store must fail, got id=%d ok=%v", id, ok) + } + if svc.RevokeExact(1) { + t.Fatal("revoke on nil store must fail") + } + if svc.ListExact() != nil { + t.Fatal("list on nil store must be nil") + } +} diff --git a/internal/engine/permission_service.go b/internal/engine/permission_service.go index bdf202fa..54208399 100644 --- a/internal/engine/permission_service.go +++ b/internal/engine/permission_service.go @@ -2,8 +2,10 @@ package engine import ( "context" + "encoding/json" "fmt" "os" + "strconv" "strings" "sync" @@ -12,6 +14,8 @@ import ( "github.com/GrayCodeAI/hawk/internal/governance" "github.com/GrayCodeAI/hawk/internal/observability/logger" "github.com/GrayCodeAI/hawk/internal/permissions" + "github.com/GrayCodeAI/hawk/internal/permissions/stableid" + "github.com/GrayCodeAI/hawk/internal/permissions/turnrecovery" "github.com/GrayCodeAI/hawk/internal/sandbox" "github.com/GrayCodeAI/hawk/internal/spec" ) @@ -56,6 +60,20 @@ type PermissionService struct { journal *eventlog.Log // log is the session logger. log *logger.Logger + // recovery, when enabled, is the per-session opaque request-token + // registry (ports fx TurnPermissionRecovery). When nil (the default) + // the approval gate behaves exactly as before; when set, a denied + // high-risk action returns an opaque permission_request_id and a later + // identical call is denied again rather than re-prompted, unless the + // exact token is escalted via EscalatePermission (single-use). + recovery *turnrecovery.Recovery + // exact, when configured, is the session's persisted store of exact, + // stable-id permission rules (ports fx session_permission_state). Rules + // here are addressable by a stable, monotonically increasing id (they + // survive workspace changes) and can be remembered, listed, and revoked + // by that id. When nil (the default) nothing changes; when set, callers + // can RememberExact/RevokeExact/ListExact. + exact *permissions.StableRuleStore } // NewPermissionService constructs a PermissionService with a fresh @@ -236,6 +254,119 @@ func (s *PermissionService) CheckApproval(ctx context.Context, toolName string, return allowed, msg } +// EnableTurnRecovery activates the opaque request-token escalation layer +// (ports fx's TurnPermissionRecovery). When enabled, a denied high-risk +// action returns an opaque permission_request_id; a later identical call is +// denied again instead of being re-prompted, and only the exact token +// presented via EscalatePermission can re-open it — and then only once. +func (s *PermissionService) EnableTurnRecovery() { + if s == nil { + return + } + if s.recovery == nil { + s.recovery = turnrecovery.New() + } +} + +// SetExactRuleStore installs a persisted exact, stable-id rule store (ports +// fx session_permission_state). Nil (the default) leaves the service +// unchanged. +func (s *PermissionService) SetExactRuleStore(store *permissions.StableRuleStore) { + if s == nil { + return + } + s.mu.Lock() + s.exact = store + s.mu.Unlock() +} + +// ExactRuleStore returns the configured exact stable-id rule store, or nil. +func (s *PermissionService) ExactRuleStore() *permissions.StableRuleStore { + if s == nil { + return nil + } + s.mu.RLock() + defer s.mu.RUnlock() + return s.exact +} + +// RememberExact records an exact stable-id permission rule and returns its +// stable id. ok is false when the identity is invalid, the store is full, or +// no store is configured (fx invalid/full outcomes). The surviving id is +// stable across workspace changes and can be used with RevokeExact. +func (s *PermissionService) RememberExact(kind stableid.Kind, canonical, displayIdent string, decision stableid.Decision) (uint64, bool) { + st := s.ExactRuleStore() + if st == nil { + return 0, false + } + return st.Remember(kind, canonical, displayIdent, decision) +} + +// RevokeExact removes the exact rule with the given stable id. false when no +// such rule exists (fx stale outcome). +func (s *PermissionService) RevokeExact(id uint64) bool { + st := s.ExactRuleStore() + if st == nil { + return false + } + return st.Revoke(id) +} + +// ListExact returns the configured exact rules ordered by stable id. +func (s *PermissionService) ListExact() []stableid.RuleSnap { + st := s.ExactRuleStore() + if st == nil { + return nil + } + return st.List() +} + +// EscalatePermission re-opens a previously denied high-risk action by +// presenting the exact opaque permission_request_id returned in the denial. +// Generic text can never authorize; only the exact current-turn token binds, +// and the grant is consumed once at the next identical call's execution. +// Returns false when the id is not a pending denial or is not 64 hex digits. +func (s *PermissionService) EscalatePermission(requestID string) bool { + if s == nil || s.recovery == nil || len(requestID) != 64 { + return false + } + var id turnrecovery.ID + for i := range 32 { + v, err := strconv.ParseUint(requestID[i*2:i*2+2], 16, 8) + if err != nil { + return false + } + id[i] = byte(v) + } + if _, ok := s.recovery.DeniedCall(id); !ok { + return false + } + return s.recovery.RememberApproval(id, turnrecovery.Approval{ + Authority: "escalation", + HumanApproval: true, + }) +} + +// denyHighRiskAction registers a denied high-risk call in the recovery +// registry (when enabled) and appends its opaque token to the message. +func (s *PermissionService) denyHighRiskAction(cat ApprovalCategory, toolName string, args map[string]interface{}, baseMsg string) string { + if s == nil || s.recovery == nil { + return baseMsg + } + id, _ := s.recovery.RememberAutoDenial(".", approvalRecoveryCall(toolName, args)) + return baseMsg + " permission_request_id: " + id.Hash() +} + +// approvalRecoveryCall builds the exact tool-call identity for the recovery +// registry. args is marshaled deterministically (json.Marshal sorts map keys). +func approvalRecoveryCall(toolName string, args map[string]interface{}) turnrecovery.ToolCall { + b, err := json.Marshal(args) + if err != nil { + b = []byte("{}") + } + return turnrecovery.ToolCall{Name: toolName, ArgumentsJSON: string(b)} +} + func (s *PermissionService) checkApprovalGate(ctx context.Context, toolName string, args map[string]interface{}, asked *bool) (bool, string) { g := s.approval if g == nil || !g.Enabled { @@ -254,6 +385,23 @@ func (s *PermissionService) checkApprovalGate(ctx context.Context, toolName stri if g.tryConsumeApproval(cat) { return true, "" } + // Opaque request-token escalation (fx TurnPermissionRecovery). When the + // recovery registry is enabled, a denied action is bound to an opaque + // permission_request_id; only presenting that exact id (EscalatePermission) + // can re-open the action, and then only once. This prevents a model from + // re-invoking an identical call to re-enter the prompt after a denial. + if s.recovery != nil { + call := approvalRecoveryCall(toolName, args) + // Live single-use revalidation: the exact call was escalated, consume it now. + if appr, ok := s.recovery.TakeApproval(call); ok && appr.HumanApproval { + return true, "" + } + // A still-pending, unapproved denial is denied again — no re-prompt. + if s.recovery.PreservedOutcome(".", call) { + id, _ := s.recovery.RememberAutoDenial(".", call) + return false, "Action denied by human approval gate (" + string(cat) + "). permission_request_id: " + id.Hash() + } + } req := ApprovalRequest{ ToolName: canonicalToolName(toolName), Category: cat, @@ -287,7 +435,7 @@ func (s *PermissionService) checkApprovalGate(ctx context.Context, toolName stri case ApprovalApprove: return true, "" default: - return false, denyMsg + return false, s.denyHighRiskAction(cat, toolName, args, denyMsg) } } if g.ConfirmFn != nil { @@ -306,13 +454,13 @@ func (s *PermissionService) checkApprovalGate(ctx context.Context, toolName stri case ApprovalApprove: return true, "" default: - return false, "Action denied by human approval gate (" + string(cat) + ")." + return false, s.denyHighRiskAction(cat, toolName, args, "Action denied by human approval gate ("+string(cat)+").") } } if s.askUserFn != nil { ans, err := s.askUserFn("Approve high-risk action [" + string(cat) + "]: " + req.Summary + "? (yes/no/session/N)") if err != nil { - return false, "Action denied by human approval gate (" + string(cat) + ")." + return false, s.denyHighRiskAction(cat, toolName, args, "Action denied by human approval gate ("+string(cat)+").") } lower := strings.ToLower(strings.TrimSpace(ans)) switch lower { @@ -328,7 +476,7 @@ func (s *PermissionService) checkApprovalGate(ctx context.Context, toolName stri if isAffirmative(ans) { return true, "" } - return false, "Action denied by human approval gate (" + string(cat) + ")." + return false, s.denyHighRiskAction(cat, toolName, args, "Action denied by human approval gate ("+string(cat)+").") } } return false, fmt.Sprintf("High-risk action requires approval but no confirmation handler is configured (%q).", cat) diff --git a/internal/engine/permission_turnrecovery_test.go b/internal/engine/permission_turnrecovery_test.go new file mode 100644 index 00000000..b727aaf5 --- /dev/null +++ b/internal/engine/permission_turnrecovery_test.go @@ -0,0 +1,133 @@ +package engine + +import ( + "context" + "strings" + "testing" +) + +// extractedToken pulls the opaque permission_request_id out of a denial +// message, or fails the test. +func extractedToken(t *testing.T, msg string) string { + t.Helper() + const prefix = "permission_request_id: " + i := strings.Index(msg, prefix) + if i < 0 { + t.Fatalf("denial message missing opaque token: %q", msg) + } + tok := msg[i+len(prefix):] + if len(tok) != 64 || !isHex(tok) { + t.Fatalf("opaque token must be 64 hex digits: %q", tok) + } + return tok +} + +func isHex(s string) bool { + for _, c := range s { + if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) { + return false + } + } + return true +} + +// rejectingSession returns a session whose approval gate denies every +// high-risk ask, with turn-recovery enabled. +func rejectingSession(t *testing.T, promptCount *int) *Session { + t.Helper() + s := NewSession("recovery", "m", "", nil) + s.PermSvc().SetAutonomy(AutonomyFull) + s.EnableTurnRecovery() + s.SetApproval(&ApprovalGate{ + Enabled: true, + ConfirmFn: func(req ApprovalRequest) ApprovalResponse { + *promptCount++ + return ApprovalReject + }, + }) + return s +} + +// A denied high-risk action yields an opaque token; a re-invocation is denied +// again without re-prompting; only the exact token escalates, single-use. +func TestTurnRecovery_OnlyOpaqueTokenEscalates(t *testing.T) { + prompts := 0 + s := rejectingSession(t, &prompts) + args := map[string]interface{}{"command": "rm -rf build/"} + + ok, msg := s.CheckApproval(context.Background(), "Bash", args) + if ok { + t.Fatal("first ask must be denied") + } + token := extractedToken(t, msg) + if prompts != 1 { + t.Fatalf("expected 1 prompt, got %d", prompts) + } + + // Re-invoking the identical call is denied again WITHOUT a re-prompt. + ok, msg = s.CheckApproval(context.Background(), "Bash", args) + if ok { + t.Fatal("re-invocation must stay denied until escalated") + } + if got := extractedToken(t, msg); got != token { + t.Fatalf("re-invocation must return the same opaque token: got %q want %q", got, token) + } + if prompts != 1 { + t.Fatalf("no re-prompt expected on identical re-invocation, got %d prompts", prompts) + } + + // A fabricated / generic string can never authorize. + if s.EscalatePermission(strings.Repeat("f", 64)) { + t.Fatal("fabricated token must not escalate") + } + if s.EscalatePermission("please approve") { + t.Fatal("generic text must not escalate") + } + + // Only the exact token escalates. + if !s.EscalatePermission(token) { + t.Fatal("exact opaque token must escalate the pending denial") + } + + // The escalated action is allowed exactly once (single-use revalidation). + ok, _ = s.CheckApproval(context.Background(), "Bash", args) + if !ok { + t.Fatal("escalated action must execute on the next identical call") + } + if prompts != 1 { + t.Fatalf("escalated execution must not prompt, got %d prompts", prompts) + } + + // After the single consumption, a further identical call prompts afresh. + ok, _ = s.CheckApproval(context.Background(), "Bash", args) + if ok { + t.Fatal("after single-use consumption a later call must not be auto-allowed") + } + if prompts != 2 { + t.Fatalf("expected a fresh prompt after consumption, got %d prompts", prompts) + } +} + +// When turn-recovery is disabled (the default) the approval gate behaves +// exactly as before: every ask prompts, even on a re-invocation. +func TestTurnRecovery_DisabledIsUnchanged(t *testing.T) { + prompts := 0 + s := NewSession("plain", "m", "", nil) + s.PermSvc().SetAutonomy(AutonomyFull) + s.SetApproval(&ApprovalGate{ + Enabled: true, + ConfirmFn: func(req ApprovalRequest) ApprovalResponse { + prompts++ + return ApprovalReject + }, + }) + args := map[string]interface{}{"command": "rm -rf build/"} + + if _, msg := s.CheckApproval(context.Background(), "Bash", args); strings.Contains(msg, "permission_request_id") { + t.Fatalf("disabled recovery must not emit an opaque token: %q", msg) + } + s.CheckApproval(context.Background(), "Bash", args) + if prompts != 2 { + t.Fatalf("without recovery a re-invocation must re-prompt, got %d prompts", prompts) + } +} diff --git a/internal/engine/session.go b/internal/engine/session.go index 1b6649de..bbeb4cd5 100644 --- a/internal/engine/session.go +++ b/internal/engine/session.go @@ -850,6 +850,24 @@ func (s *Session) SetApproval(a *ApprovalGate) { } } +// EnableTurnRecovery activates the opaque request-token escalation layer on +// the permission service (see PermissionService.EnableTurnRecovery). +func (s *Session) EnableTurnRecovery() { + if s.perms != nil { + s.perms.EnableTurnRecovery() + } +} + +// EscalatePermission re-opens a previously denied high-risk action by +// presenting the exact opaque permission_request_id (single-use). Delegate to +// the permission service. +func (s *Session) EscalatePermission(requestID string) bool { + if s.perms == nil { + return false + } + return s.perms.EscalatePermission(requestID) +} + // SetConversationGraph attaches Hawk's product-owned conversation graph and // seeds it from an already-resumed linear transcript when the graph is new. func (s *Session) SetConversationGraph(graph *session.ConversationGraph) { diff --git a/internal/engine/stream_usage.go b/internal/engine/stream_usage.go index b2b9f388..d0d13b33 100644 --- a/internal/engine/stream_usage.go +++ b/internal/engine/stream_usage.go @@ -8,6 +8,7 @@ import ( "github.com/GrayCodeAI/hawk/internal/types" "github.com/GrayCodeAI/hawk/internal/ui/icons" + "github.com/GrayCodeAI/hawk/internal/usage" analytics "github.com/GrayCodeAI/hawk/internal/observability" ) @@ -91,6 +92,17 @@ func (s *Session) recordStreamUsage(ch chan<- StreamEvent, prompt, completion in Kept: true, }) } + + // Record this generation in the persistent usage ledger (fx `usage` parity). + _ = usage.Append(usage.Record{ + CreatedAtMS: apiStart.UnixMilli(), + Model: model, + Provider: provider, + InputTokens: prompt, + OutputTokens: completion, + TotalCost: requestCost, + }) + s.recordTokUsageBudgetObservation( prompt+completion, requestCost, diff --git a/internal/eventlog/projection.go b/internal/eventlog/projection.go index 2acae1d5..d1f7cde0 100644 --- a/internal/eventlog/projection.go +++ b/internal/eventlog/projection.go @@ -1,6 +1,9 @@ package eventlog -import "time" +import ( + "sort" + "time" +) // ProjectMessages folds the append-only events into the model-visible message // surface. This ports DeepSeek Harness's deriveMessages() surface semantics: @@ -10,12 +13,140 @@ import "time" // skipped (they exist only to host usage data) — matching DSH's // deriveEventMessage which returns null for content-less assistant/message. // +// Projection is surface-driven: the model-visible order and membership come +// from the canonical surface fold (FoldSurface), so a surface `replace` +// operation splices its replacement in at the replaced position and shadows its +// replaced nodes out of the history — DSH deriveMessages parity. Non-surface +// producing events (request headers, context injection, compaction facts) are +// injected at the surface position their log position implies. +// // Projection is defined over the in-memory Event.Data values (where Data is // already a typed Message). Consumers that load a persisted record must decode the // raw payloads back to Message before projecting; see the owning product package. func ProjectMessages(events []Event) []Message { + fold, err := FoldSurface(events) + if err != nil { + // A log that cannot be surface-folded (e.g. non-contiguous or + // malformed surface metadata) degrades to raw-order projection so the + // call never fails on a defensible history. + return projectMessagesRaw(events) + } + + // Surface nodes in model-visible order; position of each live eligible seq. + active := make(map[uint64]bool, len(fold.Nodes)) + pos := make(map[uint64]float64, len(fold.Nodes)) + for i, seq := range fold.Nodes { + active[seq] = true + pos[seq] = float64(i) + } + + // Collect ordered projection items. Each item carries a sort position so + // that surface nodes (integer positions in fold order) interleave with + // system-producing events (half-step between the surface nodes the + // event's log position implies) and compaction prunes. + type item struct { + p float64 + seq uint64 + order int // stable sort tiebreaker by raw index + sys string + drop int + ev Event + } + items := make([]item, 0, len(events)) + + // sysPos returns the surface position a non-surface event lands at: just + // before the first surface node sequenced after it. + sysPos := func(seq uint64) float64 { + n := 0.0 + for _, node := range fold.Nodes { + if node < seq { + n++ + } + } + return n - 0.5 + } + + for i, ev := range events { + switch ev.Type { + case UserMessage, AssistantMsg, ToolResult: + if active[ev.Seq] { + items = append(items, item{p: pos[ev.Seq], seq: ev.Seq, order: i, ev: ev}) + } + case RequestHeader: + if f, ok := ev.Data.(RequestHeaderFact); ok && f.System != "" { + items = append(items, item{p: sysPos(ev.Seq), seq: ev.Seq, order: i, sys: f.System}) + } + case ContextInjected: + if f, ok := ev.Data.(ContextInjectedFact); ok && f.Content != "" { + items = append(items, item{p: sysPos(ev.Seq), seq: ev.Seq, order: i, sys: f.Content}) + } + case CompactionPrune: + if f, ok := ev.Data.(CompactionPruneFact); ok && f.Messages > 0 { + items = append(items, item{p: sysPos(ev.Seq), seq: ev.Seq, order: i, drop: f.Messages}) + } + case CompactionSummary: + if f, ok := ev.Data.(CompactionSummaryFact); ok && f.Summary != "" { + items = append(items, item{p: sysPos(ev.Seq), seq: ev.Seq, order: i, sys: f.Summary}) + } + } + } + + sort.SliceStable(items, func(a, b int) bool { + if items[a].p != items[b].p { + return items[a].p < items[b].p + } + return items[a].order < items[b].order + }) + + var out []Message + for _, it := range items { + switch { + case it.ev.Type == UserMessage || it.ev.Type == AssistantMsg || it.ev.Type == ToolResult: + if m, ok := projectEligible(it.ev); ok { + out = append(out, m) + } + case it.sys != "": + out = append(out, Message{Role: "system", Content: it.sys}) + case it.drop > 0: + drop := it.drop + if drop > len(out) { + drop = len(out) + } + out = out[:len(out)-drop] + } + } + return out +} + +// projectEligible projects a single surface-eligible event's model-visible +// Message, skipping content-less assistant messages (DSH deriveEventMessage +// parity). It reports whether a message should be projected at all. +func projectEligible(ev Event) (Message, bool) { + switch ev.Type { + case UserMessage, ToolResult: + if m, ok := ev.Data.(Message); ok { + return m, true + } + case AssistantMsg: + if m, ok := ev.Data.(Message); ok { + // Skip empty-content assistant messages: they exist only to host + // usage/finish data and must not inject a content-less assistant + // turn into the provider transcript. + if m.Content == "" && m.Thinking == "" && len(m.ToolUse) == 0 && len(m.Images) == 0 && len(m.ContentParts) == 0 { + return Message{}, false + } + return m, true + } + } + return Message{}, false +} + +// projectMessagesRaw projects the log in raw append order, ignoring surface +// op shadowing. It is the fallback used when a history cannot be +// surface-folded, and the historical behavior for append-only logs. +func projectMessagesRaw(events []Event) []Message { var out []Message - inCompaction := false + var inCompaction bool for _, ev := range events { switch ev.Type { case UserMessage: @@ -24,10 +155,6 @@ func ProjectMessages(events []Event) []Message { } case AssistantMsg: if m, ok := ev.Data.(Message); ok { - // Skip empty-content assistant messages: they exist only - // to host usage/finish data and must not inject a content-less - // assistant turn into the provider transcript. (DSH seam: - // deriveEventMessage returns null for these.) if m.Content == "" && m.Thinking == "" && len(m.ToolUse) == 0 && len(m.Images) == 0 && len(m.ContentParts) == 0 { continue } @@ -39,23 +166,16 @@ func ProjectMessages(events []Event) []Message { } case RequestHeader: if f, ok := ev.Data.(RequestHeaderFact); ok && f.System != "" { - out = append(out, Message{ - Role: "system", - Content: f.System, - }) + out = append(out, Message{Role: "system", Content: f.System}) } case ContextInjected: if f, ok := ev.Data.(ContextInjectedFact); ok && f.Content != "" { - out = append(out, Message{ - Role: "system", - Content: f.Content, - }) + out = append(out, Message{Role: "system", Content: f.Content}) } case CompactionStart: inCompaction = true case CompactionPrune: if f, ok := ev.Data.(CompactionPruneFact); ok && f.Messages > 0 { - // Drop the last f.Messages model-visible entries that were pruned. drop := f.Messages if drop > len(out) { drop = len(out) @@ -64,10 +184,7 @@ func ProjectMessages(events []Event) []Message { } case CompactionSummary: if f, ok := ev.Data.(CompactionSummaryFact); ok && f.Summary != "" { - out = append(out, Message{ - Role: "system", - Content: f.Summary, - }) + out = append(out, Message{Role: "system", Content: f.Summary}) } case CompactionEnd: inCompaction = false diff --git a/internal/eventlog/projection_test.go b/internal/eventlog/projection_test.go index 34727a15..f4093461 100644 --- a/internal/eventlog/projection_test.go +++ b/internal/eventlog/projection_test.go @@ -156,3 +156,52 @@ func TestProjectMessagesFullSurface(t *testing.T) { t.Errorf("msg 4: got %+v, want Content=more context", got[4]) } } + +// TestProjectMessagesSurfaceReplaceShadowing verifies that a surface `replace` +// op splices its replacement in at the replaced position and shadows the +// replaced nodes out of the projected history — DSH deriveMessages parity. +func TestProjectMessagesSurfaceReplaceShadowing(t *testing.T) { + l := New(nil) + // A compact prior history: user turn + assistant reply. + oldUser := userMsg(t, l, "old turn") + asstSeq := assistantMsg(t, l, "old reply", "call-1") + + // Replace the whole prior surface (oldUser+asst) with a fresh user recap. + l.AppendSurface(UserMessage, Message{Role: "user", Content: "[recap]"}, "replace", oldUser, asstSeq, []uint64{oldUser, asstSeq}) + + // A new turn after the replacement. + userMsg(t, l, "follow-up") + + got := ProjectMessages(l.Snapshot()) + if len(got) != 2 { + t.Fatalf("projected %d messages, want 2 (replacement + follow-up): %+v", len(got), got) + } + // The replacement splices in at the replaced position (head), not the tail; + // the shadowed old turn/reply are absent. + if got[0].Content != "[recap]" { + t.Errorf("msg 0: got %+v, want replacement spliced at head", got[0]) + } + if got[1].Content != "follow-up" { + t.Errorf("msg 1: got %+v, want follow-up at tail", got[1]) + } +} + +// TestProjectMessagesSurfaceReplaceMidSurface verifies a replacement of a +// single interior node keeps its surface neighbors in the correct order. +func TestProjectMessagesSurfaceReplaceMidSurface(t *testing.T) { + l := New(nil) + userMsg(t, l, "a") + b := userMsg(t, l, "b") + userMsg(t, l, "c") + + // Replace interior node b with a rewritten reword. + l.AppendSurface(UserMessage, Message{Role: "user", Content: "B2"}, "replace", b, b, []uint64{b}) + + got := ProjectMessages(l.Snapshot()) + if len(got) != 3 { + t.Fatalf("projected %d messages, want 3: %+v", len(got), got) + } + if got[0].Content != "a" || got[1].Content != "B2" || got[2].Content != "c" { + t.Fatalf("interior replacement out of order: %+v", got) + } +} diff --git a/internal/eventlog/surface.go b/internal/eventlog/surface.go new file mode 100644 index 00000000..766cb0fe --- /dev/null +++ b/internal/eventlog/surface.go @@ -0,0 +1,375 @@ +package eventlog + +import ( + "fmt" +) + +// Surface op marker values carried by a surface-eligible event's SurfaceOp. +const ( + SurfaceOpAppend = "append" + SurfaceOpReplace = "replace" +) + +// SurfaceFoldReplacement records one positional replacement operation observed +// while folding a session surface: the replacing event's seq and the surface +// range it shadowed. Ported from DSH's surface.ts SurfaceFoldReplacement. +type SurfaceFoldReplacement struct { + // Seq is the seq of the event that replaced the prior surface range. + Seq uint64 + // Start is the declared inclusive start seq of the replaced surface range. + Start uint64 + // End is the declared inclusive end seq of the replaced surface range. + End uint64 + // ShadowedSeqs are the actual surface entries removed by the operation, in + // surface order. + ShadowedSeqs []uint64 +} + +// SurfaceFoldResult is the complete result of replaying the surface operations +// in a session log. Ported from DSH's surface.ts SurfaceFoldResult. +type SurfaceFoldResult struct { + // Nodes are the current surface event sequences in model-visible order. + Nodes []uint64 + // Replacements are the replacement operations in event order. + Replacements []SurfaceFoldReplacement +} + +// surfaceFoldState is the mutable state shared by complete and incremental +// folds. Replacement history is deliberately not retained here: the live +// surface only needs the current nodes and a monotonic generation counter so a +// consumer can detect a positional rewrite cheaply (DSH parity). +type surfaceFoldState struct { + nodes []uint64 + replaceGen uint64 +} + +// surfaceReplacePlan is a validated replacement transition that has not yet +// mutated the fold state. Ported from DSH's SurfaceReplacePlan. +type surfaceReplacePlan struct { + start uint64 + end uint64 + startIdx int + endIdx int + shadowedSeqs []uint64 +} + +// surfacePlan is one validated surface transition that has not yet mutated the +// fold state. Ported from DSH's SurfacePlan (append | replace). +type surfacePlan struct { + kind string // "append" | "replace" + seq uint64 + replace *surfaceReplacePlan +} + +// indexOf returns the index of seq in nodes, or -1 when absent. +func indexOfSeq(nodes []uint64, seq uint64) int { + for i, s := range nodes { + if s == seq { + return i + } + } + return -1 +} + +// surfaceOpOf validates an event's local surface eligibility and returns its +// operation. Non-surface-eligible types must carry neither a surfaceOp marker +// nor source-event references; surface-eligible types must carry a marker. +// Ported from DSH's surfaceOpOf. +func surfaceOpOf(ev Event) (*SurfaceOp, error) { + if !ev.Type.IsSurfaceEligible() { + if ev.SurfaceOp != nil { + return nil, fmt.Errorf("eventlog: session event %q is not surface-eligible and cannot carry surfaceOp", ev.Type) + } + if len(ev.SourceEventSeqs) > 0 { + return nil, fmt.Errorf("eventlog: session event %q is not surface-eligible and cannot carry sourceEventSeqs", ev.Type) + } + return nil, nil + } + if ev.SurfaceOp == nil { + // Unmarked surface-eligible events are treated as appends, matching + // Validate's backward-compatibility seam for version-1 logs written + // before the surfaceOp invariant was enforced. + return &SurfaceOp{Op: SurfaceOpAppend}, nil + } + op := ev.SurfaceOp + if op.Op != SurfaceOpAppend && op.Op != SurfaceOpReplace { + return nil, fmt.Errorf("eventlog: session event %q carries an invalid surfaceOp op %q", ev.Type, op.Op) + } + // Start/End are unsigned absolute seqs; any replace must name a range that + // exists in the surface, which replacementRange enforces, so no further + // shape check is needed here. + return op, nil +} + +// assertProvenance validates an event's cited source-event seqs against prior +// log entries and (for a replacement) the shadowed surface range. Sources must +// reference strictly earlier events, be free of duplicates, and cover every +// shadowed surface node. Ported from DSH's assertProvenance. +func assertProvenance(ev Event, shadowedSeqs []uint64) error { + // DSH invariant: an explicitly-present-but-empty source list is only + // tolerated on assistant/message; every other surface event that carries + // the field must cite at least one earlier event. + if ev.SourceEventSeqs != nil && len(ev.SourceEventSeqs) == 0 && ev.Type != AssistantMsg { + return fmt.Errorf("eventlog: session event %q sourceEventSeqs must not be empty (only assistant/message may carry an explicit empty list)", ev.Type) + } + sources := make(map[uint64]bool) + for _, source := range ev.SourceEventSeqs { + if sources[source] { + return fmt.Errorf("eventlog: session event %q sourceEventSeqs must not contain duplicates (%d)", ev.Type, source) + } + if source >= ev.Seq { + return fmt.Errorf("eventlog: session event %q sourceEventSeqs must reference earlier events: %d >= current seq %d", ev.Type, source, ev.Seq) + } + sources[source] = true + } + for _, seq := range shadowedSeqs { + if !sources[seq] { + return fmt.Errorf("eventlog: surface replace: sourceEventSeqs must include every shadowed surface node; missing %d", seq) + } + } + return nil +} + +// replacementRange locates a replacement range in the current fold state +// without mutating it. Ported from DSH's replacementRange. +func replacementRange(state *surfaceFoldState, op *SurfaceOp) (*surfaceReplacePlan, error) { + startIdx := indexOfSeq(state.nodes, op.Start) + if startIdx == -1 { + return nil, fmt.Errorf("eventlog: surface replace: start seq %d not found in surface", op.Start) + } + endIdx := indexOfSeq(state.nodes, op.End) + if endIdx == -1 { + return nil, fmt.Errorf("eventlog: surface replace: end seq %d not found in surface", op.End) + } + if startIdx > endIdx { + return nil, fmt.Errorf("eventlog: surface replace: start seq %d (index %d) is after end seq %d (index %d)", op.Start, startIdx, op.End, endIdx) + } + shadowed := make([]uint64, endIdx-startIdx+1) + copy(shadowed, state.nodes[startIdx:endIdx+1]) + return &surfaceReplacePlan{ + start: op.Start, + end: op.End, + startIdx: startIdx, + endIdx: endIdx, + shadowedSeqs: shadowed, + }, nil +} + +// toolResultDataEqualRest reports whether two tool/result payloads agree on +// every field except their content, mirroring DSH's tool-result rewrite rule: +// a surface replacement of a tool/result may change only the content text; the +// call identity, turn/step coordinates, and error flag must be preserved. +func toolResultDataEqualRest(a, b ToolResultPayload) bool { + return a.Turn == b.Turn && + a.Step == b.Step && + a.ToolUseID == b.ToolUseID && + a.IsError == b.IsError +} + +// assertToolResultRewrite restricts a tool/result replacement to rewriting +// exactly one current result's content. Ported from DSH's +// assertToolResultRewrite. +func assertToolResultRewrite(ev Event, shadowedSeqs []uint64, events []Event, baseSeq uint64) error { + if ev.Type != ToolResult { + return nil + } + if len(shadowedSeqs) != 1 { + return fmt.Errorf("eventlog: tool/result surface replacement must rewrite exactly one current node") + } + for _, originalSeq := range shadowedSeqs { + if originalSeq < baseSeq || originalSeq-baseSeq >= uint64(len(events)) { + return fmt.Errorf("eventlog: tool/result surface replacement shadowed seq %d is outside the log window", originalSeq) + } + original := events[originalSeq-baseSeq] + if original.Type != ToolResult { + return fmt.Errorf("eventlog: tool/result surface replacement must target a current tool/result") + } + orig, okO := original.Data.(ToolResultPayload) + repl, okR := ev.Data.(ToolResultPayload) + if !okO || !okR { + return fmt.Errorf("eventlog: tool/result surface replacement must carry a ToolResultPayload") + } + if !toolResultDataEqualRest(orig, repl) { + return fmt.Errorf("eventlog: tool/result surface replacement may change only content") + } + } + return nil +} + +// planSurfaceEvent validates one event at its replay boundary and prepares its +// atomic fold transition without mutating the committed fold state. Ported +// from DSH's planSurfaceEvent. +func planSurfaceEvent(state *surfaceFoldState, ev Event, expectedSeq uint64, events []Event, baseSeq uint64) (*surfacePlan, error) { + if ev.Seq != expectedSeq { + return nil, fmt.Errorf("eventlog: session event seq %d is not contiguous; expected %d", ev.Seq, expectedSeq) + } + op, err := surfaceOpOf(ev) + if err != nil || op == nil { + return nil, err + } + if op.Op == SurfaceOpAppend { + if err := assertProvenance(ev, nil); err != nil { + return nil, err + } + return &surfacePlan{kind: SurfaceOpAppend, seq: ev.Seq}, nil + } + repl, err := replacementRange(state, op) + if err != nil { + return nil, err + } + if err := assertProvenance(ev, repl.shadowedSeqs); err != nil { + return nil, err + } + if err := assertToolResultRewrite(ev, repl.shadowedSeqs, events, baseSeq); err != nil { + return nil, err + } + return &surfacePlan{kind: SurfaceOpReplace, seq: ev.Seq, replace: repl}, nil +} + +// applySurfacePlan commits one previously validated surface transition and +// returns replacement metadata when one occurred. Ported from DSH's +// applySurfacePlan. +func applySurfacePlan(state *surfaceFoldState, plan *surfacePlan) *SurfaceFoldReplacement { + if plan == nil { + return nil + } + if plan.kind == SurfaceOpAppend { + state.nodes = append(state.nodes, plan.seq) + return nil + } + repl := plan.replace + head := append([]uint64{}, state.nodes[:repl.startIdx]...) + tail := append([]uint64{}, state.nodes[repl.endIdx+1:]...) + state.nodes = append(head, append([]uint64{plan.seq}, tail...)...) + state.replaceGen++ + return &SurfaceFoldReplacement{ + Seq: plan.seq, + Start: repl.start, + End: repl.end, + ShadowedSeqs: append([]uint64{}, repl.shadowedSeqs...), + } +} + +// applySurfaceEvent validates and applies one event's transition, returning +// replacement metadata only when one occurred. Ported from DSH's +// applySurfaceEvent. +func applySurfaceEvent(state *surfaceFoldState, ev Event, expectedSeq uint64, events []Event, baseSeq uint64) (*SurfaceFoldReplacement, error) { + plan, err := planSurfaceEvent(state, ev, expectedSeq, events, baseSeq) + if err != nil { + return nil, err + } + return applySurfacePlan(state, plan), nil +} + +// FoldSurface replays a complete session log through the canonical surface +// fold: it returns the current surface event sequences in model-visible order +// together with the committed replacement history. It fails loud — returning +// the first violation — whenever an event violates surface metadata, +// source-event references, replacement range, contiguity, or tool-result +// rewrite rules. This is the pure, deterministic counterpart to the +// incremental SurfaceManager. Ported from DSH's foldSurface. +func FoldSurface(events []Event) (SurfaceFoldResult, error) { + state := &surfaceFoldState{} + var baseSeq uint64 + if len(events) > 0 { + baseSeq = events[0].Seq + } + var result SurfaceFoldResult + for i, ev := range events { + expectedSeq := baseSeq + uint64(i) + repl, err := applySurfaceEvent(state, ev, expectedSeq, events, baseSeq) + if err != nil { + return SurfaceFoldResult{}, err + } + if repl != nil { + result.Replacements = append(result.Replacements, *repl) + } + } + result.Nodes = append([]uint64{}, state.nodes...) + return result, nil +} + +// SurfaceManager is the incremental, live counterpart to FoldSurface. It is +// bound to a *Log and lazily folds only the events appended since the last +// access, so a consumer can observe the model-visible surface and detect +// positional rewrites (replaceGeneration) without replaying the log. Ported +// from DSH's SurfaceManager. +// +// ValidateNext pre-flights an event against the committed fold state and +// returns an error without mutating it, so a caller can reject an invalid +// append/replace before it is ever admitted to the log (atomic admission). +type SurfaceManager struct { + log *Log + + state surfaceFoldState + baseSeq uint64 + started bool + processed int +} + +// NewSurfaceManager binds an incremental surface manager to l. The fold is +// lazy: nothing is computed until Nodes, ReplaceGeneration, or ValidateNext is +// called. +func NewSurfaceManager(l *Log) *SurfaceManager { + return &SurfaceManager{log: l} +} + +// catchUp folds every log event not yet reflected in the committed surface, +// returning the first validation error encountered. +func (m *SurfaceManager) catchUp() error { + events := m.log.Snapshot() + if len(events) == 0 { + return nil + } + if !m.started { + m.baseSeq = events[0].Seq + m.started = true + } + for m.processed < len(events) { + ev := events[m.processed] + expectedSeq := m.baseSeq + uint64(m.processed) + repl, err := applySurfaceEvent(&m.state, ev, expectedSeq, events, m.baseSeq) + if err != nil { + return err + } + _ = repl + m.processed++ + } + return nil +} + +// Nodes returns the current model-visible surface event sequences in order, +// folding any log delta first. +func (m *SurfaceManager) Nodes() ([]uint64, error) { + if err := m.catchUp(); err != nil { + return nil, err + } + return append([]uint64{}, m.state.nodes...), nil +} + +// ReplaceGeneration returns a monotonically increasing count of replacement +// operations committed to the surface, folding any log delta first. It starts +// at 0 and increments once per successful replacement, so a consumer can cheaply +// detect a positional rewrite. +func (m *SurfaceManager) ReplaceGeneration() (uint64, error) { + if err := m.catchUp(); err != nil { + return 0, err + } + return m.state.replaceGen, nil +} + +// ValidateNext pre-flights ev against the committed surface. It validates that +// adding ev as the next event (at the next contiguous seq) would be legal and +// returns the first violation as an error. It never mutates the committed fold +// state, so a rejected candidate leaves both the surface and the generation +// counter unchanged (atomic admission at the orchestration layer). +func (m *SurfaceManager) ValidateNext(ev Event) error { + if err := m.catchUp(); err != nil { + return err + } + events := m.log.Snapshot() + // The candidate occupies the next contiguous position after the committed tail. + expectedSeq := m.baseSeq + uint64(m.processed) + _, err := planSurfaceEvent(&m.state, ev, expectedSeq, events, m.baseSeq) + return err +} diff --git a/internal/eventlog/surface_test.go b/internal/eventlog/surface_test.go new file mode 100644 index 00000000..ed889242 --- /dev/null +++ b/internal/eventlog/surface_test.go @@ -0,0 +1,458 @@ +package eventlog + +import ( + "strings" + "testing" +) + +// mkSurfaceEvent builds a surface-carrying event at seq with an explicit +// SurfaceOp and optional source-event provenance. +func mkSurfaceEvent(t Type, seq uint64, op string, start, end uint64, sources []uint64, data any) Event { + return Event{Type: t, Seq: seq, Data: data, SurfaceOp: &SurfaceOp{Op: op, Start: start, End: end}, SourceEventSeqs: sources} +} + +func userEv(seq uint64, content string) Event { + return mkSurfaceEvent(UserMessage, seq, SurfaceOpAppend, 0, 0, nil, Message{Role: "user", Content: content}) +} + +func asstEv(seq uint64, content string, calls ...string) Event { + tus := make([]ToolCallPayload, 0, len(calls)) + for _, id := range calls { + tus = append(tus, ToolCallPayload{ID: id, Name: "test_tool"}) + } + return mkSurfaceEvent(AssistantMsg, seq, SurfaceOpAppend, 0, 0, nil, Message{Role: "assistant", Content: content, ToolUse: tus}) +} + +func toolResultEv(seq uint64, callID string, content string, isErr bool) Event { + return mkSurfaceEvent(ToolResult, seq, SurfaceOpAppend, 0, 0, nil, ToolResultPayload{ToolUseID: callID, Content: content, IsError: isErr}) +} + +func replaceUserEv(seq uint64, start, end uint64, sources []uint64, content string) Event { + return mkSurfaceEvent(UserMessage, seq, SurfaceOpReplace, start, end, sources, Message{Role: "user", Content: content}) +} + +func replaceAsstEv(seq uint64, start, end uint64, sources []uint64, content string) Event { + return mkSurfaceEvent(AssistantMsg, seq, SurfaceOpReplace, start, end, sources, Message{Role: "assistant", Content: content}) +} + +func errContains(t *testing.T, err error, substr string) { + t.Helper() + if err == nil { + t.Fatalf("expected error containing %q, got nil", substr) + } + if !strings.Contains(err.Error(), substr) { + t.Fatalf("expected error containing %q, got %q", substr, err.Error()) + } +} + +func TestFoldSurfaceProvenanceAccept(t *testing.T) { + // A surface with absent provenance and a replace with complete coverage folds. + events := []Event{ + userEv(1, "first"), + asstEv(2, "summary", "call-1"), + // Replace the assistant summary (its tool call is shadowed); sources + // must cover both shadowed nodes. + replaceAsstEv(3, 2, 2, []uint64{2}, "cleaned"), + } + res, err := FoldSurface(events) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := []uint64{1, 3}; !equalUint64(res.Nodes, want) { + t.Fatalf("nodes = %v, want %v", res.Nodes, want) + } + if len(res.Replacements) != 1 { + t.Fatalf("want 1 replacement, got %d", len(res.Replacements)) + } + rep := res.Replacements[0] + if rep.Seq != 3 || rep.Start != 2 || rep.End != 2 { + t.Fatalf("replacement = %+v, want seq=3 start=2 end=2", rep) + } + if want := []uint64{2}; !equalUint64(rep.ShadowedSeqs, want) { + t.Fatalf("shadowed = %v, want %v", rep.ShadowedSeqs, want) + } +} + +func TestFoldSurfaceSourceRefOnNonSurface(t *testing.T) { + // A non-surface event carrying source references is rejected. + events := []Event{ + mkEvent(SessionMeta, 1, Meta{}), + {Type: TurnStart, Seq: 2, Data: BoundaryFact{Turn: 1}, SourceEventSeqs: []uint64{1}}, + } + _, err := FoldSurface(events) + errContains(t, err, "not surface-eligible") +} + +func TestFoldSurfaceEmptySourcesNonAssistant(t *testing.T) { + // Empty (non-nil) source list on a non-assistant surface event is rejected; + // an explicit-empty assistant append is permitted. + userWithEmpty := mkSurfaceEvent(UserMessage, 1, SurfaceOpAppend, 0, 0, []uint64{}, Message{Role: "user", Content: "x"}) + if _, err := FoldSurface([]Event{userWithEmpty}); err == nil { + t.Fatal("expected error for empty sources on user/message") + } + + asstWithEmpty := asstEv(1, "ok") + asstWithEmpty.SourceEventSeqs = []uint64{} + if _, err := FoldSurface([]Event{asstWithEmpty}); err != nil { + t.Fatalf("explicit-empty assistant append must be allowed: %v", err) + } +} + +func TestFoldSurfaceProvenanceRejections(t *testing.T) { + base := []Event{userEv(1, "a")} + + t.Run("duplicate sources", func(t *testing.T) { + ev := replaceUserEv(2, 1, 1, []uint64{1, 1}, "b") + _, err := FoldSurface(append(base, ev)) + errContains(t, err, "must not contain duplicates") + }) + + t.Run("self reference", func(t *testing.T) { + ev := replaceUserEv(2, 1, 1, []uint64{2}, "b") // source == self seq + _, err := FoldSurface(append(base, ev)) + errContains(t, err, "must reference earlier events") + }) + + t.Run("future reference", func(t *testing.T) { + ev := replaceUserEv(2, 1, 1, []uint64{9}, "b") + _, err := FoldSurface(append(base, ev)) + errContains(t, err, "must reference earlier events") + }) + + t.Run("incomplete replacement coverage", func(t *testing.T) { + // Replace range [1..2] but only cite 1, missing 2. + events := []Event{ + userEv(1, "a"), + userEv(2, "b"), + replaceUserEv(3, 1, 2, []uint64{1}, "c"), + } + _, err := FoldSurface(events) + errContains(t, err, "must include every shadowed surface node; missing 2") + }) +} + +func TestFoldSurfaceNonContiguousSeq(t *testing.T) { + events := []Event{userEv(1, "a"), userEv(3, "gap")} // 2 skipped + _, err := FoldSurface(events) + errContains(t, err, "is not contiguous") +} + +func TestFoldSurfaceReplaceRangeNotFound(t *testing.T) { + t.Run("start not found", func(t *testing.T) { + events := []Event{userEv(1, "a"), replaceUserEv(2, 42, 42, []uint64{}, "b")} + _, err := FoldSurface(events) + errContains(t, err, "start seq 42 not found") + }) + t.Run("end not found", func(t *testing.T) { + events := []Event{userEv(1, "a"), replaceUserEv(2, 1, 42, []uint64{}, "b")} + _, err := FoldSurface(events) + errContains(t, err, "end seq 42 not found") + }) + t.Run("start after end", func(t *testing.T) { + events := []Event{userEv(1, "a"), userEv(2, "b"), replaceUserEv(3, 2, 1, []uint64{}, "c")} + _, err := FoldSurface(events) + errContains(t, err, "is after") + }) +} + +// --- tool/result rewrite restriction (dsh assertToolResultRewrite) --- + +func TestFoldSurfaceToolResultReplace(t *testing.T) { + mk := func() []Event { + return []Event{ + userEv(1, "use tool"), + asstEv(2, "call", "call-1"), + toolResultEv(3, "call-1", "original", false), + } + } + + t.Run("content-only rewrite accepted", func(t *testing.T) { + base := mk() + ev := mkSurfaceEvent(ToolResult, 4, SurfaceOpReplace, 3, 3, []uint64{3}, ToolResultPayload{ToolUseID: "call-1", Content: "rewritten", IsError: false}) + res, err := FoldSurface(append(base, ev)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := []uint64{1, 2, 4}; !equalUint64(res.Nodes, want) { + t.Fatalf("nodes = %v, want %v", res.Nodes, want) + } + }) + + t.Run("rewrites exactly one node", func(t *testing.T) { + base := mk() + ev := mkSurfaceEvent(ToolResult, 4, SurfaceOpReplace, 2, 3, []uint64{2, 3}, ToolResultPayload{ToolUseID: "call-1", Content: "x", IsError: false}) + _, err := FoldSurface(append(base, ev)) + errContains(t, err, "must rewrite exactly one current node") + }) + + t.Run("must target a tool/result", func(t *testing.T) { + base := mk() + ev := mkSurfaceEvent(ToolResult, 4, SurfaceOpReplace, 2, 2, []uint64{2}, ToolResultPayload{ToolUseID: "call-1", Content: "x", IsError: false}) + _, err := FoldSurface(append(base, ev)) + errContains(t, err, "must target a current tool/result") + }) + + t.Run("toolUseID change rejected", func(t *testing.T) { + base := mk() + ev := mkSurfaceEvent(ToolResult, 4, SurfaceOpReplace, 3, 3, []uint64{3}, ToolResultPayload{ToolUseID: "other-call", Content: "x", IsError: false}) + _, err := FoldSurface(append(base, ev)) + errContains(t, err, "may change only content") + }) + + t.Run("isError change rejected", func(t *testing.T) { + base := mk() + ev := mkSurfaceEvent(ToolResult, 4, SurfaceOpReplace, 3, 3, []uint64{3}, ToolResultPayload{ToolUseID: "call-1", Content: "x", IsError: true}) + _, err := FoldSurface(append(base, ev)) + errContains(t, err, "may change only content") + }) +} + +// --- surface order / replacement splice semantics --- + +func TestFoldSurfaceOrderExamples(t *testing.T) { + t.Run("nodes for a turn", func(t *testing.T) { + // turn/start(0 non-surface), user(1), assistant(2) -> nodes [1,2] + events := []Event{ + mkEvent(TurnStart, 1, BoundaryFact{Turn: 1}), + userEv(2, "hello"), + asstEv(3, "hi"), + } + res, err := FoldSurface(events) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := []uint64{2, 3}; !equalUint64(res.Nodes, want) { + t.Fatalf("nodes = %v, want %v", res.Nodes, want) + } + }) + + t.Run("rebuild with replace splices range at head", func(t *testing.T) { + events := []Event{ + userEv(1, "u1"), + asstEv(2, "a2"), + replaceAsstEv(3, 1, 2, []uint64{1, 2}, "summary"), + } + res, err := FoldSurface(events) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := []uint64{3}; !equalUint64(res.Nodes, want) { + t.Fatalf("nodes = %v, want %v", res.Nodes, want) + } + }) + + t.Run("replace both ends splices only that range", func(t *testing.T) { + // dsh: 3 user msgs (seqs 1,2,3), replace 1..2 -> nodes [4,3] + events := []Event{ + userEv(1, "a"), + userEv(2, "b"), + userEv(3, "c"), + replaceUserEv(4, 1, 2, []uint64{1, 2}, "z"), + } + res, err := FoldSurface(events) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := []uint64{4, 3}; !equalUint64(res.Nodes, want) { + t.Fatalf("nodes = %v, want %v", res.Nodes, want) + } + }) + + t.Run("single-node replace keeps surrounding", func(t *testing.T) { + events := []Event{ + userEv(1, "a"), + userEv(2, "b"), + userEv(3, "c"), + replaceUserEv(4, 2, 2, []uint64{2}, "z"), + } + res, err := FoldSurface(events) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := []uint64{1, 4, 3}; !equalUint64(res.Nodes, want) { + t.Fatalf("nodes = %v, want %v", res.Nodes, want) + } + }) + + t.Run("mid-replace preserves position", func(t *testing.T) { + events := []Event{ + userEv(1, "a"), + userEv(2, "b"), + userEv(3, "c"), + replaceUserEv(4, 2, 2, []uint64{2}, "z"), + } + res, err := FoldSurface(events) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := []uint64{1, 4, 3}; !equalUint64(res.Nodes, want) { + t.Fatalf("nodes = %v, want %v", res.Nodes, want) + } + }) +} + +func equalUint64(a, b []uint64) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// --- SurfaceManager incremental behavior --- + +func TestSurfaceManagerMatchesFoldSurface(t *testing.T) { + l := New(nil) + userMsg(t, l, "hello") + assistantMsg(t, l, "hi") + mgr := NewSurfaceManager(l) + nodes, err := mgr.Nodes() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + res, _ := FoldSurface(l.Snapshot()) + if !equalUint64(nodes, res.Nodes) { + t.Fatalf("manager nodes %v != fold nodes %v", nodes, res.Nodes) + } + if gen, _ := mgr.ReplaceGeneration(); gen != 0 { + t.Fatalf("generation = %d, want 0", gen) + } +} + +func TestSurfaceManagerIncrementalPicksUpNewEvents(t *testing.T) { + l := New(nil) + userMsg(t, l, "hello") + mgr := NewSurfaceManager(l) + nodes, _ := mgr.Nodes() + if want := []uint64{1}; !equalUint64(nodes, want) { + t.Fatalf("nodes = %v, want %v", nodes, want) + } + // New events appended after construction must be folded on next access. + assistantMsg(t, l, "hi") + nodes, _ = mgr.Nodes() + if want := []uint64{1, 2}; !equalUint64(nodes, want) { + t.Fatalf("nodes after delta = %v, want %v", nodes, want) + } + if gen, _ := mgr.ReplaceGeneration(); gen != 0 { + t.Fatalf("generation = %d, want 0", gen) + } +} + +func TestSurfaceManagerReplaceGenerationAndAtomic(t *testing.T) { + l := New(nil) + seqU := userMsg(t, l, "u1") + seqA := assistantMsg(t, l, "a2") + _ = seqU + mgr := NewSurfaceManager(l) + if gen, _ := mgr.ReplaceGeneration(); gen != 0 { + t.Fatalf("initial generation = %d, want 0", gen) + } + // Replace [1..2] with a summary; generation must bump to 1. + replEv := mkSurfaceEvent(AssistantMsg, seqA+1, SurfaceOpReplace, 1, seqA, []uint64{1, seqA}, Message{Role: "assistant", Content: "summary"}) + if err := mgr.ValidateNext(replEv); err != nil { + t.Fatalf("unexpected validation error: %v", err) + } + // So far validation must NOT have mutated the surface. + nodes, _ := mgr.Nodes() + if want := []uint64{1, seqA}; !equalUint64(nodes, want) { + t.Fatalf("nodes before commit = %v, want %v (no mutation on validate)", nodes, want) + } + // Rejected candidate leaves state unchanged. + bad := mkSurfaceEvent(ToolResult, seqA+2, SurfaceOpReplace, seqA, seqA, []uint64{seqA}, ToolResultPayload{ToolUseID: "other", Content: "x", IsError: false}) + if err := mgr.ValidateNext(bad); err == nil { + t.Fatal("expected validation error for bad tool/result rewrite") + } + nodes, _ = mgr.Nodes() + if want := []uint64{1, seqA}; !equalUint64(nodes, want) { + t.Fatalf("nodes after rejected candidate = %v, want %v", nodes, want) + } + if gen, _ := mgr.ReplaceGeneration(); gen != 0 { + t.Fatalf("generation after rejection = %d, want 0", gen) + } +} + +func TestSurfaceManagerValidateNextNoMutationOnError(t *testing.T) { + l := New(nil) + u := userMsg(t, l, "a") + mgr := NewSurfaceManager(l) + // Non-contiguous candidate seq rejected. + cand := userEv(u+5, "gap") + if err := mgr.ValidateNext(cand); err == nil { + t.Fatal("expected contiguity error") + } + nodes, _ := mgr.Nodes() + if want := []uint64{u}; !equalUint64(nodes, want) { + t.Fatalf("nodes after rejected candidate = %v, want %v", nodes, want) + } +} + +func TestFoldSurfaceEmptyIsEmpty(t *testing.T) { + res, err := FoldSurface([]Event{}) + if err != nil { + t.Fatalf("empty fold must succeed, got %v", err) + } + if len(res.Nodes) != 0 || len(res.Replacements) != 0 { + t.Fatalf("empty fold produced %+v", res) + } +} + +// TestSurfaceManagerFullReplaceCommit exercises the realistic path: a caller +// approves a candidate (ValidateNext passes), appends it to the log, and the +// manager folds it into the surface with an incremented generation. +func TestSurfaceManagerFullReplaceCommit(t *testing.T) { + l := New(nil) + userMsg(t, l, "u1") + seqA := assistantMsg(t, l, "a2") + mgr := NewSurfaceManager(l) + nodes, _ := mgr.Nodes() + if want := []uint64{1, seqA}; !equalUint64(nodes, want) { + t.Fatalf("initial nodes = %v, want %v", nodes, want) + } + // Pre-validate the candidate. + replEv := mkSurfaceEvent(AssistantMsg, seqA+1, SurfaceOpReplace, 1, seqA, []uint64{1, seqA}, Message{Role: "assistant", Content: "summary"}) + if err := mgr.ValidateNext(replEv); err != nil { + t.Fatalf("candidate rejected: %v", err) + } + // Commit it to the log (simulating AppendSurface assigning the same seq). + l.AppendSurface(AssistantMsg, replEv.Data, SurfaceOpReplace, 1, seqA, []uint64{1, seqA}) + nodes, _ = mgr.Nodes() + if want := []uint64{seqA + 1}; !equalUint64(nodes, want) { + t.Fatalf("nodes after commit = %v, want %v", nodes, want) + } + if gen, _ := mgr.ReplaceGeneration(); gen != 1 { + t.Fatalf("generation after commit = %d, want 1", gen) + } +} + +// Ensure a valid replace consumes the full sources and witnesses were recorded. +func TestFoldSurfaceMultipleReplacements(t *testing.T) { + events := []Event{ + userEv(1, "a"), + userEv(2, "b"), + userEv(3, "c"), + replaceUserEv(4, 1, 2, []uint64{1, 2}, "z"), + userEv(5, "d"), + // Replace the node that still holds seq 3 (position index 1 after the + // previous splice), not seq 2 which was already shadowed. + replaceUserEv(6, 3, 3, []uint64{3}, "y"), + } + res, err := FoldSurface(events) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Build expected fold: start [1,2,3]; r4 splices [1,2]->4 -> [4,3]; + // append 5 -> [4,3,5]; r6 replaces node at index1 (seq3)->6 -> [4,6,5]. + if want := []uint64{4, 6, 5}; !equalUint64(res.Nodes, want) { + t.Fatalf("nodes = %v, want %v", res.Nodes, want) + } + if len(res.Replacements) != 2 { + t.Fatalf("want 2 replacements, got %d", len(res.Replacements)) + } + if res.Replacements[0].Seq != 4 || res.Replacements[1].Seq != 6 { + t.Fatalf("replacement seqs = %d,%d; want 4,6", res.Replacements[0].Seq, res.Replacements[1].Seq) + } +} diff --git a/internal/permissions/stableid/state.go b/internal/permissions/stableid/state.go new file mode 100644 index 00000000..318d6623 --- /dev/null +++ b/internal/permissions/stableid/state.go @@ -0,0 +1,310 @@ +// Package stableid ports fx's session permission state +// (vercel-labs/fx, src/core/permissions/session_permission_state.zig): +// session-scoped, exact permission rules each carrying a stable, monotonically +// increasing id and generation. Operations are pure: ApplySet/ApplyRevoke +// return a new immutable State plus a status, so concurrent updates can be +// merged optimistically with an expected_generation check (fx's +// applied/stale/full/invalid outcomes). +// +// The invariant this preserves over hawk's glob-based remembered rules is +// that a rule is addressable by a stable id for the lifetime of the session — +// it survives workspace changes — and can be listed and revoked by that id +// without re-deriving the rule from the current workspace. +package stableid + +import ( + "crypto/sha256" + "fmt" + "sort" +) + +// maxRules and maxIdentityBytes bound the state, matching fx. +const ( + maxRules = 1024 + maxIdentityBytes = 4096 +) + +// Kind classifies the identity a rule is keyed on. +type Kind int + +const ( + KindCommand Kind = iota + KindFileMutation + KindStructuredTool +) + +func (k Kind) String() string { + switch k { + case KindCommand: + return "command" + case KindFileMutation: + return "file_mutation" + case KindStructuredTool: + return "structured_tool" + } + return "unknown" +} + +// Decision is the action a rule prescribes. +type Decision int + +const ( + Deny Decision = iota + Allow +) + +func (d Decision) String() string { + if d == Allow { + return "allow" + } + return "deny" +} + +// RuleKey is the exact identity of a rule: kind + sha256 digest of the +// canonical identity string. +type RuleKey struct { + Kind Kind + Digest [32]byte + Canonical string +} + +// NewKey builds a RuleKey from a kind and canonical identity string, +// returning ok=false when the identity is empty or exceeds the bound. +func NewKey(kind Kind, canonical string) (RuleKey, bool) { + if len(canonical) == 0 || len(canonical) > maxIdentityBytes { + return RuleKey{}, false + } + var digest [32]byte + h := sha256.Sum256([]byte(canonical)) + copy(digest[:], h[:]) + return RuleKey{Kind: kind, Digest: digest, Canonical: canonical}, true +} + +// Equal reports whether two keys reference the same rule. +func (k RuleKey) Equal(o RuleKey) bool { + return k.Kind == o.Kind && k.Digest == o.Digest +} + +// Rule is a single exact permission rule with a stable id and generation. +type Rule struct { + ID uint64 + Key RuleKey + DisplayIdentity string + Decision Decision + Generation uint64 +} + +// RuleSnap is a copyable view of a rule. +type RuleSnap struct { + ID uint64 + Key RuleKey + DisplayIdentity string + Decision Decision + Generation uint64 +} + +// State is an immutable snapshot of the session's exact rules. +type State struct { + NextGeneration uint64 + Rules []Rule +} + +// NewState returns an empty, valid state. +func NewState() State { + return State{NextGeneration: 1} +} + +// SetEvent requests an upsert of an exact rule. +type SetEvent struct { + Key RuleKey + DisplayIdentity string + Decision Decision + ExpectedGeneration *uint64 // nil = must not already exist +} + +// RevokeEvent requests removal of the rule with the given stable id. +type RevokeEvent struct { + ID uint64 + ExpectedGeneration uint64 +} + +// Status is the outcome of applying an event. +type Status int + +const ( + Applied Status = iota + Stale + Full + Invalid +) + +func (s Status) String() string { + switch s { + case Applied: + return "applied" + case Stale: + return "stale" + case Full: + return "full" + case Invalid: + return "invalid" + } + return "unknown" +} + +// Validate checks all fx state invariants. +func Validate(state State) error { + if state.NextGeneration == 0 || len(state.Rules) > maxRules { + return fmt.Errorf("stableid: invalid generation or rule count") + } + seenID := map[uint64]bool{} + seenKey := map[RuleKey]bool{} + for _, r := range state.Rules { + if r.ID == 0 || r.Generation == 0 || + r.ID > r.Generation || r.ID >= state.NextGeneration || + r.Generation >= state.NextGeneration || + len(r.Key.Canonical) == 0 || len(r.Key.Canonical) > maxIdentityBytes || + len(r.DisplayIdentity) == 0 { + return fmt.Errorf("stableid: invalid rule %+v", r) + } + k := r.Key + var digest [32]byte + h := sha256.Sum256([]byte(k.Canonical)) + copy(digest[:], h[:]) + if k.Digest != digest { + return fmt.Errorf("stableid: digest mismatch for rule %d", r.ID) + } + if seenID[r.ID] { + return fmt.Errorf("stableid: duplicate rule id %d", r.ID) + } + if seenKey[k] { + return fmt.Errorf("stableid: duplicate rule key") + } + seenID[r.ID] = true + seenKey[k] = true + } + return nil +} + +// cloneState copies the rules slice so returned states are independent. +func cloneState(state State) State { + cp := make([]Rule, len(state.Rules)) + copy(cp, state.Rules) + state.Rules = cp + return state +} + +// keyIndex returns the index of the rule matching key, or -1. +func keyIndex(state State, key RuleKey) int { + for i := range state.Rules { + if state.Rules[i].Key.Equal(key) { + return i + } + } + return -1 +} + +// ApplySet upserts an exact rule and returns the new state and status. +func ApplySet(state State, ev SetEvent) (State, Status) { + if state.NextGeneration == 0 || + len(ev.DisplayIdentity) == 0 || + len(ev.Key.Canonical) == 0 || len(ev.Key.Canonical) > maxIdentityBytes { + return state, Invalid + } + nextGen := state.NextGeneration + 1 + if nextGen == 0 { // u64 overflow — matching fx's invalid path + return state, Invalid + } + if idx := keyIndex(state, ev.Key); idx >= 0 { + if ev.ExpectedGeneration == nil || *ev.ExpectedGeneration != state.Rules[idx].Generation { + return state, Stale + } + next := cloneState(state) + next.Rules[idx].DisplayIdentity = ev.DisplayIdentity + next.Rules[idx].Decision = ev.Decision + next.Rules[idx].Generation = state.NextGeneration + next.NextGeneration = nextGen + return next, Applied + } + if ev.ExpectedGeneration != nil { + return state, Stale + } + if len(state.Rules) >= maxRules { + return state, Full + } + next := cloneState(state) + next.Rules = append(next.Rules, Rule{ + ID: state.NextGeneration, + Key: ev.Key, + DisplayIdentity: ev.DisplayIdentity, + Decision: ev.Decision, + Generation: state.NextGeneration, + }) + next.NextGeneration = nextGen + return next, Applied +} + +// ApplyRevoke removes the rule with the given stable id and returns the new +// state and status. +func ApplyRevoke(state State, ev RevokeEvent) (State, Status) { + idx := -1 + for i := range state.Rules { + if state.Rules[i].ID == ev.ID { + idx = i + break + } + } + if idx < 0 { + return state, Stale + } + if state.Rules[idx].Generation != ev.ExpectedGeneration { + return state, Stale + } + nextGen := state.NextGeneration + 1 + if nextGen == 0 { + return state, Invalid + } + next := cloneState(state) + next.Rules = append(next.Rules[:idx], next.Rules[idx+1:]...) + next.NextGeneration = nextGen + return next, Applied +} + +// RuleForID returns the live rule with the given stable id. +func RuleForID(state State, id uint64) (Rule, bool) { + for _, r := range state.Rules { + if r.ID == id { + return r, true + } + } + return Rule{}, false +} + +// RuleForKey returns the live rule keyed by the exact identity. +func RuleForKey(state State, key RuleKey) (Rule, bool) { + idx := keyIndex(state, key) + if idx < 0 { + return Rule{}, false + } + return state.Rules[idx], true +} + +// Decide resolves the decision for an exact key. ok=false when no exact rule +// exists (fx's unresolved outcome) — never conflated with an explicit deny. +func Decide(state State, key RuleKey) (Decision, bool) { + idx := keyIndex(state, key) + if idx < 0 { + return Deny, false + } + return state.Rules[idx].Decision, true +} + +// Sorted returns a copy of the rules ordered by stable id, for stable listing. +func Sorted(state State) []RuleSnap { + out := make([]RuleSnap, 0, len(state.Rules)) + for _, r := range state.Rules { + out = append(out, RuleSnap(r)) + } + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out +} diff --git a/internal/permissions/stableid/state_test.go b/internal/permissions/stableid/state_test.go new file mode 100644 index 00000000..73fc21e7 --- /dev/null +++ b/internal/permissions/stableid/state_test.go @@ -0,0 +1,231 @@ +package stableid + +import ( + "testing" +) + +func mustKey(t *testing.T, kind Kind, canonical string) RuleKey { + t.Helper() + k, ok := NewKey(kind, canonical) + if !ok { + t.Fatalf("NewKey(%d,%q) rejected", kind, canonical) + } + return k +} + +// fx tests: set inserts a stable nonzero id. +func TestSetCreatesStableNonzeroID(t *testing.T) { + original := NewState() + key := mustKey(t, KindCommand, "command\x00git status") + next, status := ApplySet(original, SetEvent{ + Key: key, DisplayIdentity: "git status in /workspace", Decision: Deny, + }) + if status != Applied { + t.Fatalf("expected applied, got %s", status) + } + if len(original.Rules) != 0 { + t.Fatal("original state must remain unchanged") + } + if len(next.Rules) != 1 { + t.Fatalf("expected 1 rule, got %d", len(next.Rules)) + } + r := next.Rules[0] + if r.ID == 0 { + t.Fatal("rule id must be nonzero") + } + if got, ok := RuleForID(next, r.ID); !ok || got.ID != r.ID { + t.Fatal("ruleForId must return the inserted rule") + } +} + +// fx tests: replacing a rule preserves its id, changes decision/generation. +func TestSetReplacementPreservesID(t *testing.T) { + state := NewState() + key := mustKey(t, KindCommand, "command\x00git push") + state, st := ApplySet(state, SetEvent{Key: key, DisplayIdentity: "push", Decision: Deny}) + if st != Applied { + t.Fatalf("first set: %s", st) + } + origID := state.Rules[0].ID + + gen := state.Rules[0].Generation + state, st = ApplySet(state, SetEvent{ + Key: key, DisplayIdentity: "push now", Decision: Allow, + ExpectedGeneration: &gen, + }) + if st != Applied { + t.Fatalf("replacement: %s", st) + } + if len(state.Rules) != 1 { + t.Fatalf("replacement must not add a second rule, got %d", len(state.Rules)) + } + if state.Rules[0].ID != origID { + t.Fatalf("replacement must preserve id: got %d want %d", state.Rules[0].ID, origID) + } + if state.Rules[0].Decision != Allow { + t.Fatalf("decision must update to allow") + } + if state.Rules[0].Generation <= gen { + t.Fatal("generation must advance on replacement") + } +} + +// Stale set: expected_generation mismatch on an existing key is stale. +func TestSetStaleOnGenerationMismatch(t *testing.T) { + state := NewState() + key := mustKey(t, KindCommand, "command\x00go test") + state, _ = ApplySet(state, SetEvent{Key: key, DisplayIdentity: "t", Decision: Deny}) + + wrong := state.Rules[0].Generation + 1 + if _, st := ApplySet(state, SetEvent{Key: key, DisplayIdentity: "x", Decision: Allow, ExpectedGeneration: &wrong}); st != Stale { + t.Fatalf("wrong generation must be stale, got %s", st) + } + // A new rule with expected_generation set (must-not-exist) is stale if it exists. + if _, st := ApplySet(state, SetEvent{Key: key, DisplayIdentity: "y", Decision: Deny}); st != Stale { + t.Fatalf("insert of existing key without expected must be stale, got %s", st) + } +} + +// Nonzero and monotonic: successive inserts get distinct ascending ids. +func TestIDsAreMonotonicDistinct(t *testing.T) { + state := NewState() + ids := []uint64{} + for i := 0; i < 5; i++ { + k := mustKey(t, KindCommand, "command\x00cmd"+string(rune('a'+i))) + var st Status + state, st = ApplySet(state, SetEvent{Key: k, DisplayIdentity: "d", Decision: Deny}) + if st != Applied { + t.Fatalf("insert %d: %s", i, st) + } + ids = append(ids, state.Rules[len(state.Rules)-1].ID) + } + seen := map[uint64]bool{} + prev := uint64(0) + for _, id := range ids { + if seen[id] || id <= prev { + t.Fatalf("ids must be distinct and ascending: %v", ids) + } + seen[id] = true + prev = id + } +} + +// Revoke by id with matching generation removes exactly that rule. +func TestRevokeByStableID(t *testing.T) { + state := NewState() + for i := 0; i < 3; i++ { + k := mustKey(t, KindCommand, "command\x00c"+string(rune('a'+i))) + state, _ = ApplySet(state, SetEvent{Key: k, DisplayIdentity: "d", Decision: Deny}) + } + target := state.Rules[1] + gen := target.Generation + state, st := ApplyRevoke(state, RevokeEvent{ID: target.ID, ExpectedGeneration: gen}) + if st != Applied { + t.Fatalf("revoke: %s", st) + } + if _, ok := RuleForID(state, target.ID); ok { + t.Fatal("revoked rule must be gone") + } + if len(state.Rules) != 2 { + t.Fatalf("expected 2 rules after revoke, got %d", len(state.Rules)) + } + // Other rules survive with their ids intact. + for _, r := range state.Rules { + if r.ID == target.ID { + t.Fatal("target id must not remain") + } + if _, ok := RuleForID(state, r.ID); !ok { + t.Fatalf("survivor rule %d must still resolve", r.ID) + } + } +} + +// Revoke without a matching generation is stale (lost-update protection). +func TestRevokeStaleOnGenerationMismatch(t *testing.T) { + state := NewState() + k := mustKey(t, KindCommand, "command\x00x") + state, _ = ApplySet(state, SetEvent{Key: k, DisplayIdentity: "d", Decision: Deny}) + id := state.Rules[0].ID + if _, st := ApplyRevoke(state, RevokeEvent{ID: id, ExpectedGeneration: 999}); st != Stale { + t.Fatalf("mismatched generation must be stale, got %s", st) + } + if _, st := ApplyRevoke(state, RevokeEvent{ID: 424242, ExpectedGeneration: 1}); st != Stale { + t.Fatalf("unknown id must be stale, got %s", st) + } +} + +// Decide resolves allow/deny for an exact key and reports unresolved otherwise. +func TestDecideResolvesExactKey(t *testing.T) { + state := NewState() + k := mustKey(t, KindCommand, "command\x00rm -rf /") + state, _ = ApplySet(state, SetEvent{Key: k, DisplayIdentity: "danger", Decision: Deny}) + + if d, ok := Decide(state, k); !ok || d != Deny { + t.Fatalf("expected resolved deny, got %v ok=%v", d, ok) + } + other := mustKey(t, KindCommand, "command\x00ls") + if _, ok := Decide(state, other); ok { + t.Fatal("absent key must be unresolved, not a deny") + } +} + +// Validate rejects invariant violations: duplicate id, digest mismatch. +func TestValidateInvariants(t *testing.T) { + state := NewState() + k := mustKey(t, KindCommand, "command\x00v") + state, _ = ApplySet(state, SetEvent{Key: k, DisplayIdentity: "d", Decision: Allow}) + if err := Validate(state); err != nil { + t.Fatalf("valid state must pass: %v", err) + } + + // Duplicate id. + dup := cloneState(state) + dup.Rules[0].ID = 7 + dup.Rules = append(dup.Rules, Rule{ID: 7, Key: mustKey(t, KindCommand, "command\x00other"), DisplayIdentity: "x", Decision: Deny, Generation: 2}) + dup.NextGeneration = 3 + if err := Validate(dup); err == nil { + t.Fatal("duplicate id must be invalid") + } + + // Digest mismatch. + bad := cloneState(state) + bad.Rules[0].Key.Digest[0] ^= 0xff + if err := Validate(bad); err == nil { + t.Fatal("digest mismatch must be invalid") + } +} + +// Sorted yields rules ordered by stable id. +func TestSortedByStableID(t *testing.T) { + state := NewState() + for i := 0; i < 5; i++ { + k := mustKey(t, KindCommand, "command\x00s"+string(rune('a'+i))) + state, _ = ApplySet(state, SetEvent{Key: k, DisplayIdentity: "d", Decision: Deny}) + } + sorted := Sorted(state) + if len(sorted) != 5 { + t.Fatalf("expected 5, got %d", len(sorted)) + } + for i := 1; i < len(sorted); i++ { + if sorted[i].ID <= sorted[i-1].ID { + t.Fatalf("not sorted by id: %v", sorted) + } + } +} + +// Full: exceeding maxRules returns Full. +func TestSetFull(t *testing.T) { + state := NewState() + for i := 0; i < maxRules; i++ { + k := mustKey(t, KindCommand, "command\x00f"+string(rune('a'+i%26))+string(rune('0'+i/26))) + var st Status + state, st = ApplySet(state, SetEvent{Key: k, DisplayIdentity: "d", Decision: Deny}) + if st != Applied { + t.Fatalf("insert %d: %s", i, st) + } + } + k := mustKey(t, KindStructuredTool, "tool\x00overflow") + if _, st := ApplySet(state, SetEvent{Key: k, DisplayIdentity: "d", Decision: Deny}); st != Full { + t.Fatalf("over-capacity insert must be full, got %s", st) + } +} diff --git a/internal/permissions/stablerules.go b/internal/permissions/stablerules.go new file mode 100644 index 00000000..c19995f6 --- /dev/null +++ b/internal/permissions/stablerules.go @@ -0,0 +1,212 @@ +package permissions + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + + "github.com/GrayCodeAI/hawk/internal/permissions/stableid" + "github.com/GrayCodeAI/hawk/internal/storage" +) + +// StableRuleStore persists exact, stable-id permission rules (ports fx's +// session-permission state) per project, alongside the glob-based rules. +// +// Unlike the glob rules (which are re-derived pattern matches), each exact +// rule carries a stable id and generation that survive workspace changes, so +// it can be listed and revoked by id without re-deriving the rule from the +// current workspace. +type StableRuleStore struct { + path string + mu sync.RWMutex + state stableid.State +} + +// DefaultStableRulesPath returns the default persisted state path for a +// project directory, next to the glob-rules permissions.json. +func DefaultStableRulesPath(projectDir string) string { + return filepath.Join(storage.ProjectStateDir(projectDir), "stable-rules.json") +} + +// NewStableRuleStore returns an empty store persisted at path. +func NewStableRuleStore(path string) *StableRuleStore { + return &StableRuleStore{path: path, state: stableid.NewState()} +} + +type stableRuleFile struct { + NextGeneration uint64 `json:"next_generation"` + Rules []ruleDoc `json:"rules"` +} + +type ruleDoc struct { + ID uint64 `json:"id"` + Kind int `json:"kind"` + Canonical string `json:"canonical"` + DisplayIdentity string `json:"display_identity"` + Decision int `json:"decision"` + Generation uint64 `json:"generation"` +} + +// Load reads the persisted state from disk. A missing file is the empty state. +// Malformed content produces an error without corrupting the in-memory state. +func (s *StableRuleStore) Load() error { + if s == nil { + return nil + } + data, err := os.ReadFile(s.path) // #nosec G304 -- path is the caller-supplied stable-rules.json path (see DefaultStableRulesPath) + if err != nil { + if os.IsNotExist(err) { + s.mu.Lock() + s.state = stableid.NewState() + s.mu.Unlock() + return nil + } + return fmt.Errorf("read stable rules: %w", err) + } + var file stableRuleFile + if err := json.Unmarshal(data, &file); err != nil { + return fmt.Errorf("unmarshal stable rules: %w", err) + } + state := stableid.State{NextGeneration: file.NextGeneration} + if state.NextGeneration == 0 { + state.NextGeneration = 1 + } + for _, d := range file.Rules { + key, ok := stableid.NewKey(stableid.Kind(d.Kind), d.Canonical) + if !ok { + return fmt.Errorf("stable rules: invalid key for rule %d", d.ID) + } + decision := stableid.Deny + if d.Decision == int(stableid.Allow) { + decision = stableid.Allow + } + state.Rules = append(state.Rules, stableid.Rule{ + ID: d.ID, + Key: key, + DisplayIdentity: d.DisplayIdentity, + Decision: decision, + Generation: d.Generation, + }) + } + if err := stableid.Validate(state); err != nil { + return err + } + s.mu.Lock() + s.state = state + s.mu.Unlock() + return nil +} + +// Save persists the current state atomically. +func (s *StableRuleStore) Save() error { + if s == nil { + return nil + } + s.mu.RLock() + file := stableRuleFile{NextGeneration: s.state.NextGeneration} + for _, r := range s.state.Rules { + file.Rules = append(file.Rules, ruleDoc{ + ID: r.ID, Kind: int(r.Key.Kind), Canonical: r.Key.Canonical, + DisplayIdentity: r.DisplayIdentity, + Decision: int(r.Decision), Generation: r.Generation, + }) + } + s.mu.RUnlock() + + data, err := json.MarshalIndent(file, "", " ") + if err != nil { + return fmt.Errorf("marshal stable rules: %w", err) + } + dir := filepath.Dir(s.path) + if err := os.MkdirAll(dir, 0o750); err != nil { + return fmt.Errorf("create stable rules directory: %w", err) + } + tmp := s.path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { // #nosec G306 -- rule store is user-owned policy data + return fmt.Errorf("write stable rules temp file: %w", err) + } + if err := os.Rename(tmp, s.path); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("rename stable rules file: %w", err) + } + return nil +} + +// Remember upserts an exact rule and returns its stable id. ok=false when the +// identity is invalid, the state is stale, or the store is full (fx +// invalid/stale/full outcomes). +func (s *StableRuleStore) Remember(kind stableid.Kind, canonical, displayIdentity string, decision stableid.Decision) (uint64, bool) { + if s == nil { + return 0, false + } + key, ok := stableid.NewKey(kind, canonical) + if !ok { + return 0, false + } + s.mu.Lock() + defer s.mu.Unlock() + + // Upsert an existing exact rule by matching its current generation. + var expected *uint64 + if r, ok := stableid.RuleForKey(s.state, key); ok { + gen := r.Generation + expected = &gen + } + next, status := stableid.ApplySet(s.state, stableid.SetEvent{ + Key: key, DisplayIdentity: displayIdentity, Decision: decision, ExpectedGeneration: expected, + }) + if status != stableid.Applied { + return 0, false + } + s.state = next + rule, _ := stableid.RuleForKey(s.state, key) + return rule.ID, true +} + +// Revoke removes the rule with the given stable id. Returns false when no such +// rule exists (fx stale outcome). +func (s *StableRuleStore) Revoke(id uint64) bool { + if s == nil { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + rule, ok := stableid.RuleForID(s.state, id) + if !ok { + return false + } + next, status := stableid.ApplyRevoke(s.state, stableid.RevokeEvent{ + ID: id, ExpectedGeneration: rule.Generation, + }) + if status != stableid.Applied { + return false + } + s.state = next + return true +} + +// List returns all exact rules ordered by stable id. +func (s *StableRuleStore) List() []stableid.RuleSnap { + if s == nil { + return nil + } + s.mu.RLock() + defer s.mu.RUnlock() + return stableid.Sorted(s.state) +} + +// Resolve reports the decision for an exact key, or ok=false when unresolved. +func (s *StableRuleStore) Resolve(kind stableid.Kind, canonical string) (stableid.Decision, bool) { + if s == nil { + return stableid.Deny, false + } + key, ok := stableid.NewKey(kind, canonical) + if !ok { + return stableid.Deny, false + } + s.mu.RLock() + defer s.mu.RUnlock() + return stableid.Decide(s.state, key) +} diff --git a/internal/permissions/stablerules_test.go b/internal/permissions/stablerules_test.go new file mode 100644 index 00000000..e206943d --- /dev/null +++ b/internal/permissions/stablerules_test.go @@ -0,0 +1,139 @@ +package permissions + +import ( + "os" + "path/filepath" + "testing" + + "github.com/GrayCodeAI/hawk/internal/permissions/stableid" +) + +func newTempStore(t *testing.T) *StableRuleStore { + t.Helper() + dir := t.TempDir() + s := NewStableRuleStore(filepath.Join(dir, "stable-rules.json")) + if err := s.Load(); err != nil { + t.Fatalf("load empty store: %v", err) + } + return s +} + +// Remember yields a stable id; revoke by that id removes the rule; list +// reflects both. +func TestStoreRememberRevokeList(t *testing.T) { + s := newTempStore(t) + id, ok := s.Remember(stableid.KindCommand, "command\x00git push", "git push", stableid.Allow) + if !ok { + t.Fatal("remember failed") + } + if id == 0 { + t.Fatal("stable id must be nonzero") + } + if got, ok := s.Resolve(stableid.KindCommand, "command\x00git push"); !ok || got != stableid.Allow { + t.Fatalf("resolved allow expected, got %v ok=%v", got, ok) + } + + if !s.Revoke(id) { + t.Fatal("revoke failed for existing id") + } + if len(s.List()) != 0 { + t.Fatalf("expected empty list after revoke, got %d", len(s.List())) + } + if _, ok := s.Resolve(stableid.KindCommand, "command\x00git push"); ok { + t.Fatal("revoked rule must be unresolved") + } + // Revoking an unknown id fails. + if s.Revoke(id) { + t.Fatal("revoking a gone id must fail") + } +} + +// Remembering the same exact rule again preserves its stable id (upsert). +func TestStoreRememberPreservesID(t *testing.T) { + s := newTempStore(t) + id1, _ := s.Remember(stableid.KindCommand, "command\x00go test ./...", "go test", stableid.Deny) + id2, _ := s.Remember(stableid.KindCommand, "command\x00go test ./...", "go test -v", stableid.Allow) + if id1 != id2 { + t.Fatalf("upsert must preserve stable id: got %d want %d", id2, id1) + } + if got, _ := s.Resolve(stableid.KindCommand, "command\x00go test ./..."); got != stableid.Allow { + t.Fatal("decision must update to allow on upsert") + } + if len(s.List()) != 1 { + t.Fatalf("upsert must not duplicate rules, got %d", len(s.List())) + } +} + +// IDs survive a Save/Load round-trip (stable across workspace changes). +func TestStorePersistsAcrossReload(t *testing.T) { + s := newTempStore(t) + id1, _ := s.Remember(stableid.KindCommand, "command\x00ls", "ls", stableid.Allow) + _, _ = s.Remember(stableid.KindCommand, "command\x00pwd", "pwd", stableid.Deny) + if err := s.Save(); err != nil { + t.Fatalf("save: %v", err) + } + + reloaded := NewStableRuleStore(s.path) + if err := reloaded.Load(); err != nil { + t.Fatalf("reload: %v", err) + } + if got, ok := reloaded.Resolve(stableid.KindCommand, "command\x00ls"); !ok || got != stableid.Allow { + t.Fatal("persisted allow rule must still resolve") + } + if got, _ := reloaded.Resolve(stableid.KindCommand, "command\x00pwd"); got != stableid.Deny { + t.Fatal("persisted deny rule must still resolve") + } + list := reloaded.List() + if len(list) != 2 { + t.Fatalf("expected 2 persisted rules, got %d", len(list)) + } + // The original id is stable after reload. + if got, ok := reloaded.Resolve(stableid.KindCommand, "command\x00ls"); !ok || got != stableid.Allow { + t.Fatal("ls rule must survive") + } + _ = id1 + // Sorted by id includes both. + if list[0].ID == list[1].ID { + t.Fatal("distinct rules must have distinct ids") + } +} + +// A trailing rule can be revoked by id after reload. +func TestStoreRevokeAfterReload(t *testing.T) { + s := newTempStore(t) + id1, _ := s.Remember(stableid.KindCommand, "command\x00a", "a", stableid.Allow) + id2, _ := s.Remember(stableid.KindCommand, "command\x00b", "b", stableid.Deny) + if err := s.Save(); err != nil { + t.Fatal(err) + } + r := NewStableRuleStore(s.path) + _ = r.Load() + if !r.Revoke(id2) { + t.Fatal("must revoke persisted rule by stable id") + } + if len(r.List()) != 1 { + t.Fatalf("expected 1 after revoke, got %d", len(r.List())) + } + if _, ok := r.Resolve(stableid.KindCommand, "command\x00a"); !ok { + t.Fatal("surviving rule must still resolve") + } + _ = id1 +} + +// Missing file loads as empty; malformed file errors without losing state. +func TestStoreLoadEmptyAndMalformed(t *testing.T) { + s := newTempStore(t) + if len(s.List()) != 0 { + t.Fatal("empty store must have no rules") + } + + dir := t.TempDir() + path := filepath.Join(dir, "bad.json") + if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil { + t.Fatal(err) + } + bad := NewStableRuleStore(path) + if err := bad.Load(); err == nil { + t.Fatal("malformed file must error on Load") + } +} diff --git a/internal/permissions/turnrecovery/turn_recovery.go b/internal/permissions/turnrecovery/turn_recovery.go new file mode 100644 index 00000000..87f6d482 --- /dev/null +++ b/internal/permissions/turnrecovery/turn_recovery.go @@ -0,0 +1,231 @@ +// Package turnrecovery ports fx's per-agent-turn permission recovery +// (vercel-labs/fx, src/core/agent/runtime/tool_admission.zig). +// +// The invariant it preserves is the strongest part of fx's security model: +// a tool call denied by the auto/permission classifier can only re-enter the +// human permission screen through the exact, non-guessable opaque request id +// issued for that particular denial in this agent turn. Generic model or +// user text ("please approve", "allow it") can never authorize the action. +// +// The flow: +// - RememberAutoDenial registers a denied call and returns its opaque +// request id. The id is the only handle the model may present later. +// - PreservedOutcome re-denies a later call that is semantically identical +// to a still-unapproved denial, so the model cannot defeat the gate by +// re-issuing the same command with cosmetic differences. +// - RememberApproval binds a human approval to the request id. +// - TakeApproval is the live, single-use revalidation performed immediately +// before execution: it matches the exact original call, returns the +// approval credentials exactly once, and marks them consumed. +// +// IDs are domain-prefixed SHA-256 digests, matching fx's derivation so the +// semantics (not the bytes) are portable. +package turnrecovery + +import ( + "crypto/sha256" + "encoding/json" + "strconv" + "strings" +) + +// ID is a 256-bit opaque permission action/request id. +type ID [32]byte + +// maxTurnDenials caps how many auto-denied calls a single agent turn tracks, +// matching fx's bound and preventing the registry from unbounded growth. +const maxTurnDenials = 64 + +// ToolCall is the minimal identity of a tool invocation. +type ToolCall struct { + Name string + ArgumentsJSON string +} + +// Hash returns the lowercase 64-character hex rendering of the id. +func (id ID) Hash() string { + const hexDigits = "0123456789abcdef" + var buf [64]byte + for i, b := range id { + buf[i*2] = hexDigits[b>>4] + buf[i*2+1] = hexDigits[b&0x0f] + } + return string(buf[:]) +} + +func digest(domain string, parts ...string) ID { + h := sha256.New() + h.Write([]byte(domain)) + for _, p := range parts { + h.Write([]byte(p)) + } + var out ID + copy(out[:], h.Sum(nil)) + return out +} + +// ActionID returns the deterministic digest of the exact tool call. +func ActionID(call ToolCall) ID { + return digest("fx.permission-action.v1\x00", call.Name, "\x00", call.ArgumentsJSON) +} + +func requestID(sequence uint64) ID { + return digest("fx.permission-request.v1:" + strconv.FormatUint(sequence, 10)) +} + +// SemanticActionID returns an id stable under cosmetic changes that carry no +// semantic weight for a command run (shell-wrapper prefixes, " 2>&1" +// suffix). For non-command tools it falls back to the exact ActionID. +func SemanticActionID(workspaceRoot string, call ToolCall) ID { + if call.Name != "" { + var args map[string]any + if err := json.Unmarshal([]byte(call.ArgumentsJSON), &args); err == nil { + if command, ok := args["command"].(string); ok { + cwd, ok := args["cwd"].(string) + if !ok || cwd == "." { + cwd = workspaceRoot + } + return digest( + "", + "command\x00", normalizeCommand(command), + "\x00cwd\x00", cwd, + ) + } + } + } + return ActionID(call) +} + +// normalizeCommand strips shell-wrapper prefixes and stderr redirection so a +// command re-issued through a different wrapper still shares a semantic id. +func normalizeCommand(command string) string { + normalized := strings.Trim(command, " \t\r\n") + const redirect = " 2>&1" + if strings.HasSuffix(normalized, redirect) { + normalized = strings.TrimRight(normalized[:len(normalized)-len(redirect)], " \t") + } + for _, prefix := range []string{ + "sh -c '", "bash -c '", "zsh -c '", + "/bin/sh -c '", "/bin/bash -c '", "/bin/zsh -c '", + } { + if strings.HasPrefix(normalized, prefix) && + strings.HasSuffix(normalized, "'") && + len(normalized) > len(prefix) { + return strings.Trim(normalized[len(prefix):len(normalized)-1], " \t\r\n") + } + } + return normalized +} + +// Approval carries the authority credentials granted by a human approval. +type Approval struct { + Authority string + HumanApproval bool +} + +// approvedAction is the single-use approval bound to a denied entry. +type approvedAction struct { + approval Approval + consumed bool +} + +type deniedEntry struct { + requestID ID + exactID ID + semanticID ID + call ToolCall + approval *approvedAction +} + +// Recovery is the per-agent-turn registry of auto-denied tool calls. +type Recovery struct { + denied []deniedEntry + nextRequestSeq uint64 +} + +func New() *Recovery { + return &Recovery{nextRequestSeq: 1} +} + +// RememberAutoDenial registers an auto-denied call and returns its opaque +// request id, or ok=false when the call is not auto-denied or the turn budget +// is exhausted. A duplicate exact call returns the already-issued request id +// rather than registering a second entry. +func (r *Recovery) RememberAutoDenial(workspaceRoot string, call ToolCall) (ID, bool) { + exactID := ActionID(call) + for i := range r.denied { + if r.denied[i].exactID == exactID { + return r.denied[i].requestID, true + } + } + if len(r.denied) >= maxTurnDenials { + return ID{}, false + } + id := requestID(r.nextRequestSeq) + r.nextRequestSeq++ + r.denied = append(r.denied, deniedEntry{ + requestID: id, + exactID: exactID, + semanticID: SemanticActionID(workspaceRoot, call), + call: call, + }) + return id, true +} + +// DeniedCall returns the exact call bound to the given opaque request id. +// ok is false when no such denial is pending in this turn. +func (r *Recovery) DeniedCall(id ID) (ToolCall, bool) { + for i := range r.denied { + if r.denied[i].requestID == id { + return r.denied[i].call, true + } + } + return ToolCall{}, false +} + +// PreservedOutcome reports whether the call is semantically identical to a +// still-pending auto-denial, in which case it must be denied again. +func (r *Recovery) PreservedOutcome(workspaceRoot string, call ToolCall) bool { + semantic := SemanticActionID(workspaceRoot, call) + for i := range r.denied { + if r.denied[i].semanticID == semantic && r.denied[i].approval == nil { + return true + } + } + return false +} + +// RememberApproval binds a human approval to the request id. It returns false +// when there is no matching pending denial or no real human approval. +func (r *Recovery) RememberApproval(id ID, approval Approval) bool { + if !approval.HumanApproval { + return false + } + for i := range r.denied { + if r.denied[i].requestID == id { + a := approvedAction{approval: approval} + r.denied[i].approval = &a + return true + } + } + return false +} + +// TakeApproval is the live, single-use revalidation performed immediately +// before execution. It matches the exact original call, returns the approval +// credentials exactly once, and marks them consumed. +func (r *Recovery) TakeApproval(call ToolCall) (Approval, bool) { + exactID := ActionID(call) + for i := range r.denied { + e := &r.denied[i] + if e.exactID != exactID || e.approval == nil { + continue + } + if e.approval.consumed { + return Approval{}, false + } + e.approval.consumed = true + return e.approval.approval, true + } + return Approval{}, false +} diff --git a/internal/permissions/turnrecovery/turn_recovery_test.go b/internal/permissions/turnrecovery/turn_recovery_test.go new file mode 100644 index 00000000..900e8ee1 --- /dev/null +++ b/internal/permissions/turnrecovery/turn_recovery_test.go @@ -0,0 +1,173 @@ +package turnrecovery + +import ( + "strings" + "testing" +) + +// exactCall is a convenience for building tool calls. +func exactCall(name, args string) ToolCall { + return ToolCall{Name: name, ArgumentsJSON: args} +} + +func TestActionIDDeterministicAndDistinct(t *testing.T) { + a := ActionID(exactCall("bash", `{"command":"ls"}`)) + b := ActionID(exactCall("bash", `{"command":"ls"}`)) + if a != b { + t.Fatalf("ActionID must be deterministic: %x != %x", a, b) + } + c := ActionID(exactCall("bash", `{"command":"ls -la"}`)) + if a == c { + t.Fatalf("distinct calls must not share an ActionID") + } +} + +func TestRequestIDIsOpaqueSequenceDigest(t *testing.T) { + r1 := New() + _, ok1 := r1.RememberAutoDenial("/ws", exactCall("bash", `{"command":"rm -rf x"}`)) + if !ok1 { + t.Fatal("first denial must register") + } + if r1.denied[0].requestID != requestID(1) { + t.Fatalf("first request id must be seq 1 digest") + } +} + +// The core opaque-token invariant: two denials in one turn get non-guessable, +// distinct request ids, and no two denials share one. +func TestDenialsGetDistinctOpaqueIds(t *testing.T) { + r := New() + idA, _ := r.RememberAutoDenial("/ws", exactCall("bash", `{"command":"a"}`)) + idB, _ := r.RememberAutoDenial("/ws", exactCall("bash", `{"command":"b"}`)) + if idA == idB { + t.Fatal("distinct denials must not share a request id") + } + // The id derives from the sequence, not from the call, so it does not + // leak the action being gated. + if strings.Contains(idA.Hash(), "command") { + t.Fatal("request id must be opaque, not derived from call content") + } +} + +func TestDuplicateExactCallReusesRequestId(t *testing.T) { + r := New() + call := exactCall("bash", `{"command":"ls"}`) + first, ok1 := r.RememberAutoDenial("/ws", call) + second, ok2 := r.RememberAutoDenial("/ws", call) + if !ok1 || !ok2 { + t.Fatalf("both registrations must succeed: %v %v", ok1, ok2) + } + if first != second { + t.Fatalf("duplicate exact call must reuse the request id") + } + if want := 1; len(r.denied) != want { + t.Fatalf("duplicate must not add an entry: got %d want %d", len(r.denied), want) + } +} + +func TestDeniedCallLookupByOpaqueId(t *testing.T) { + r := New() + call := exactCall("bash", `{"command":"mv a b"}`) + id, _ := r.RememberAutoDenial("/ws", call) + got, ok := r.DeniedCall(id) + if !ok || got != call { + t.Fatalf("DeniedCall must return the exact stored call by opaque id: %+v ok=%v", got, ok) + } + // A fabricated / non-present id must not resolve. + garbage := ID{0xff} + if _, ok := r.DeniedCall(garbage); ok { + t.Fatal("non-pending request id must not resolve to a denied call") + } +} + +func TestPreservedOutcomeRedeniesSemanticEquivalent(t *testing.T) { + r := New() + r.RememberAutoDenial("/ws", exactCall("bash", `{"command":"rm -rf ./tmp","cwd":"/ws"}`)) + // Same command issued through a shell wrapper is still a semantic match. + if !r.PreservedOutcome("/ws", exactCall("bash", `{"command":"sh -c 'rm -rf ./tmp'","cwd":"/ws"}`)) { + t.Fatal("wrapped command must be preserved as auto-denied") + } + // A genuinely different command escapes the gate. + if r.PreservedOutcome("/ws", exactCall("bash", `{"command":"ls"}`)) { + t.Fatal("different command must not be preserved") + } +} + +func TestPreservedOutcomeDoesNotRedenyApprovedAction(t *testing.T) { + r := New() + call := exactCall("bash", `{"command":"git push","cwd":"/ws"}`) + id, _ := r.RememberAutoDenial("/ws", call) + r.RememberApproval(id, Approval{Authority: "user", HumanApproval: true}) + if r.PreservedOutcome("/ws", call) { + t.Fatal("a human-approved action must not be preserved as still denied") + } +} + +// Generic text can never authorize: the only authorization path is +// RememberApproval bound to the exact opaque request id, then single-use +// TakeApproval of the exact call. +func TestGenericTextCannotAuthorize(t *testing.T) { + r := New() + call := exactCall("bash", `{"command":"sudo rm -rf /"}`) + id, _ := r.RememberAutoDenial("/ws", call) + // An approval granted without the exact opaque id has no effect. + if _, ok := r.TakeApproval(call); ok { + t.Fatal("must not authorize before any approval") + } + // A "generic" approval that names the call but not the pending id must not + // bind: RememberApproval requires a real human approval and exact id. + if other := requestID(99); other != id { + if r.RememberApproval(other, Approval{Authority: "user", HumanApproval: true}) { + t.Fatal("approval for a non-pending id must not bind") + } + } + wantCall, ok := r.DeniedCall(id) + if !ok || wantCall != call { + t.Fatalf("denied call must still be recoverable by id: %+v ok=%v", wantCall, ok) + } + // Execution revalidation binds the exact call. + if _, ok := r.TakeApproval(exactCall("bash", `{"command":"sudo rm -rf /x"}`)); ok { + t.Fatal("a distinct exact call must not consume the approval") + } +} + +func TestTakeApprovalIsSingleUse(t *testing.T) { + r := New() + call := exactCall("bash", `{"command":"kubectl delete po x"}`) + id, _ := r.RememberAutoDenial("/ws", call) + r.RememberApproval(id, Approval{Authority: "user", HumanApproval: true}) + + approval, ok := r.TakeApproval(call) + if !ok || !approval.HumanApproval || approval.Authority != "user" { + t.Fatalf("first TakeApproval must yield the approval credentials: %+v ok=%v", approval, ok) + } + if _, ok := r.TakeApproval(call); ok { + t.Fatal("the same approval must be consumed after one live revalidation") + } +} + +func TestRememberApprovalRequiresHumanApproval(t *testing.T) { + r := New() + call := exactCall("bash", `{"command":"git reset --hard"}`) + id, _ := r.RememberAutoDenial("/ws", call) + if r.RememberApproval(id, Approval{Authority: "auto", HumanApproval: false}) { + t.Fatal("a non-human approval must not bind") + } + if _, ok := r.TakeApproval(call); ok { + t.Fatal("no approval may be consumable without a real human approval") + } +} + +func TestTurnBudgetCapsDenials(t *testing.T) { + r := New() + for i := 0; i < maxTurnDenials; i++ { + if _, ok := r.RememberAutoDenial("/ws", exactCall("bash", `{"command":"cmd"}`+strings.Repeat("x", i))); !ok { + t.Fatalf("denial %d must register within budget", i) + } + } + // Exact duplicates still re-register within budget (reuse), so push a new + // distinct call past the cap. + if _, ok := r.RememberAutoDenial("/ws", exactCall("bash", `{"command":"overflow"}`)); ok { + t.Fatal("denial beyond the per-turn budget must be refused") + } +} diff --git a/internal/session/migrate.go b/internal/session/migrate.go new file mode 100644 index 00000000..c838689f --- /dev/null +++ b/internal/session/migrate.go @@ -0,0 +1,156 @@ +package session + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "strings" +) + +// largeSessionBytes is the threshold above which a session is considered +// oversized and requires --allow-large to migrate (fx `session migrate` parity). +const largeSessionBytes = int64(32 << 20) + +// MigrateResult describes a completed session migration. +type MigrateResult struct { + ID string `json:"id"` + FromVersion int `json:"from_version"` + ToVersion int `json:"to_version"` + SizeBytes int64 `json:"size_bytes"` +} + +// MigrateSession upgrades a saved session to the current on-disk JSONL format +// (fx `session migrate` parity). Legacy .json sessions are loaded and +// re-persisted in the current format; .jsonl sessions have their format_version +// header bumped. Oversized sessions are refused unless allowLarge is set, and a +// session already on the current format is reported without rewriting. +func MigrateSession(id string, allowLarge bool) (MigrateResult, error) { + if err := ValidateID(id); err != nil { + return MigrateResult{}, err + } + + jsonl := jsonlPathFor(id) + if _, err := os.Stat(jsonl); err == nil { + return migrateJSONL(id, jsonl, allowLarge) + } + legacy := legacyPathFor(id) + if _, err := os.Stat(legacy); err == nil { + return migrateLegacy(id, legacy, allowLarge) + } + return MigrateResult{}, fmt.Errorf("session %s: %w", id, ErrNotFound) +} + +// migrateLegacy converts a legacy .json session into the current JSONL format, +// preserving every message and dropping the superseded file. +func migrateLegacy(id, path string, allowLarge bool) (MigrateResult, error) { + if err := checkSessionSize(id, path, allowLarge); err != nil { + return MigrateResult{}, err + } + s, err := loadLegacyJSON(id) + if err != nil { + return MigrateResult{}, fmt.Errorf("load legacy session %s: %w", id, err) + } + if err := Save(s); err != nil { + return MigrateResult{}, fmt.Errorf("save migrated session %s: %w", id, err) + } + _ = os.Remove(path) // clean cutover: the .jsonl file now owns the session + + size := int64(0) + if fi, err := os.Stat(jsonlPathFor(id)); err == nil { + size = fi.Size() + } + return MigrateResult{ID: id, FromVersion: 0, ToVersion: SessionFormatVersion, SizeBytes: size}, nil +} + +// migrateJSONL ensures a .jsonl session's header declares the current format. +func migrateJSONL(id, path string, allowLarge bool) (MigrateResult, error) { + if err := checkSessionSize(id, path, allowLarge); err != nil { + return MigrateResult{}, err + } + from, err := metaFormatVersion(path) + if err != nil { + return MigrateResult{}, fmt.Errorf("read session %s format: %w", id, err) + } + size := int64(0) + if fi, err := os.Stat(path); err == nil { + size = fi.Size() + } + if from >= SessionFormatVersion { + return MigrateResult{ID: id, FromVersion: from, ToVersion: SessionFormatVersion, SizeBytes: size}, nil + } + if err := bumpMetaVersion(path, SessionFormatVersion); err != nil { + return MigrateResult{}, fmt.Errorf("bump session %s format: %w", id, err) + } + return MigrateResult{ID: id, FromVersion: from, ToVersion: SessionFormatVersion, SizeBytes: size}, nil +} + +// checkSessionSize refuses oversized sessions unless allowLarge is set. +func checkSessionSize(id, path string, allowLarge bool) error { + fi, err := os.Stat(path) + if err != nil { + return fmt.Errorf("stat session %s: %w", id, err) + } + if fi.Size() > largeSessionBytes && !allowLarge { + return fmt.Errorf("session %s is %d bytes; oversized sessions require --allow-large", id, fi.Size()) + } + return nil +} + +// metaFormatVersion reads the format_version from the session_meta header line +// of a JSONL session. A line without the key is treated as version 0. +func metaFormatVersion(path string) (int, error) { + f, err := os.Open(path) + if err != nil { + return 0, err + } + defer func() { _ = f.Close() }() + sc := bufio.NewScanner(f) + if !sc.Scan() { + return 0, fmt.Errorf("empty session file") + } + var hdr map[string]json.RawMessage + if err := json.Unmarshal(sc.Bytes(), &hdr); err != nil { + return 0, fmt.Errorf("invalid session_meta line: %w", err) + } + if v, ok := hdr["format_version"]; ok { + var n int + if err := json.Unmarshal(v, &n); err != nil { + return 0, err + } + return n, nil + } + return 0, nil +} + +// bumpMetaVersion rewrites only the first (session_meta) line of a JSONL file, +// setting format_version to v while preserving every other byte. +func bumpMetaVersion(path string, v int) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + lines := strings.SplitN(string(data), "\n", 2) + var hdr map[string]any + if err := json.Unmarshal([]byte(lines[0]), &hdr); err != nil { + return fmt.Errorf("invalid session_meta line: %w", err) + } + hdr["format_version"] = v + out, err := json.Marshal(hdr) + if err != nil { + return err + } + var rest string + if len(lines) == 2 { + rest = "\n" + lines[1] + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, []byte(string(out)+rest), 0o600); err != nil { + return err + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + return err + } + return nil +} diff --git a/internal/session/migrate_test.go b/internal/session/migrate_test.go new file mode 100644 index 00000000..bcaac3e8 --- /dev/null +++ b/internal/session/migrate_test.go @@ -0,0 +1,113 @@ +package session + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func freshSessionsDir(t *testing.T) string { + t.Helper() + sdir := setTestSessionsDir(t, t.TempDir()) + if err := os.MkdirAll(sdir, 0o700); err != nil { + t.Fatal(err) + } + return sdir +} + +func writeLegacySessionJSON(t *testing.T, sdir, id, extra string) { + t.Helper() + now := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + body := `{"id":"` + id + `","messages":[{"role":"user","content":"hello"}],"created_at":"` + + now.Format(time.RFC3339) + `","updated_at":"` + now.Format(time.RFC3339) + `"` + extra + `}` + if err := os.WriteFile(filepath.Join(sdir, id+".json"), []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestMigrateSessionLegacyJSONToJSONL(t *testing.T) { + sdir := freshSessionsDir(t) + id := "migrate-legacy" + writeLegacySessionJSON(t, sdir, id, "") + + res, err := MigrateSession(id, false) + if err != nil { + t.Fatalf("MigrateSession: %v", err) + } + if res.FromVersion != 0 || res.ToVersion != SessionFormatVersion { + t.Fatalf("versions = %d->%d, want 0->%d", res.FromVersion, res.ToVersion, SessionFormatVersion) + } + if _, err := os.Stat(filepath.Join(sdir, id+".json")); !os.IsNotExist(err) { + t.Errorf("legacy .json still present, want removed") + } + jsonl, err := os.ReadFile(filepath.Join(sdir, id+".jsonl")) + if err != nil { + t.Fatalf("read .jsonl: %v", err) + } + if !strings.Contains(string(jsonl), "hello") { + t.Errorf("migrated .jsonl lost message content:\n%s", jsonl) + } +} + +func TestMigrateSessionJSONLAlreadyCurrent(t *testing.T) { + sdir := freshSessionsDir(t) + id := "migrate-current" + body := `{"type":"session_meta","id":"` + id + `","format_version":1}` + "\n" + + `{"type":"message.user","seq":1}` + "\n" + if err := os.WriteFile(filepath.Join(sdir, id+".jsonl"), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + + res, err := MigrateSession(id, false) + if err != nil { + t.Fatalf("MigrateSession: %v", err) + } + if res.FromVersion < SessionFormatVersion { + t.Fatalf("from = %d, want >= current %d", res.FromVersion, SessionFormatVersion) + } +} + +func TestMigrateSessionJSONLVersionBump(t *testing.T) { + sdir := freshSessionsDir(t) + id := "migrate-bump" + body := `{"type":"session_meta","id":"` + id + `"}` + "\n" + + `{"type":"message.user","seq":1}` + "\n" + if err := os.WriteFile(filepath.Join(sdir, id+".jsonl"), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + + res, err := MigrateSession(id, false) + if err != nil { + t.Fatalf("MigrateSession: %v", err) + } + if res.FromVersion != 0 || res.ToVersion != SessionFormatVersion { + t.Fatalf("versions = %d->%d, want 0->%d", res.FromVersion, res.ToVersion, SessionFormatVersion) + } + data, _ := os.ReadFile(filepath.Join(sdir, id+".jsonl")) + if !strings.Contains(string(data), `"format_version":1`) { + t.Errorf("meta line not bumped to current:\n%s", data) + } +} + +func TestMigrateSessionOversizedRefused(t *testing.T) { + sdir := freshSessionsDir(t) + id := "migrate-big" + pad := strings.Repeat("x", 40<<20) // 40 MiB, over the 32 MiB threshold + writeLegacySessionJSON(t, sdir, id, `,"pad":"`+pad+`"`) + + if _, err := MigrateSession(id, false); err == nil { + t.Fatal("expected oversized error without --allow-large") + } + if _, err := MigrateSession(id, true); err != nil { + t.Fatalf("MigrateSession with allowLarge: %v", err) + } +} + +func TestMigrateSessionNotFound(t *testing.T) { + setTestSessionsDir(t, t.TempDir()) + if _, err := MigrateSession("migrate-none", false); err == nil { + t.Fatal("expected not-found error") + } +} diff --git a/internal/terminal/tape/framesdir.go b/internal/terminal/tape/framesdir.go new file mode 100644 index 00000000..3d3e407c --- /dev/null +++ b/internal/terminal/tape/framesdir.go @@ -0,0 +1,220 @@ +// Package tape: fx `replay --frames-dir` parity (src/core/cli/cli_replay.zig) +// +// ExportFramesDir replays a tape and writes one artifact per frame into a +// directory mirroring fx's file layout and JSON schema: +// +// /frames/0001.json per-frame metadata (index, timing, kind, +// size, cursor, footer_candidates, +// visible_markers) +// /frames/0001.grid.txt the rendered terminal snapshot after the frame +// /manifest.json tape header summary + aggregate frame stats +// +// File names and the JSON index are 1-based, matching fx. Each JSON artifact +// is a single compact line ending in '\n'. +package tape + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" +) + +// FramesDirSummary reports aggregate counts mirroring fx's manifest fields. +type FramesDirSummary struct { + FrameCount int + ResizeCount int + StdoutBytes int +} + +// dirFrame mirrors fx's per-frame JSON artifact (cli_replay.zig +// writeFrameArtifacts). +type dirFrame struct { + Index int `json:"index"` + DeltaMS int32 `json:"delta_ms"` + ElapsedMS int64 `json:"elapsed_ms"` + Kind string `json:"kind"` + PayloadLen int `json:"payload_len"` + Size dirSize `json:"size"` + Cursor dirCursor `json:"cursor"` + Footers []dirFooter `json:"footer_candidates"` + Markers []string `json:"visible_markers"` +} + +type dirSize struct { + Cols int `json:"cols"` + Rows int `json:"rows"` +} + +type dirCursor struct { + Row int `json:"row"` + Col int `json:"col"` + Visible bool `json:"visible"` +} + +// dirFooter mirrors fx's footer_candidates entries. Fx requires the input row +// to be framed between two divider rows, then emits indices offset by one to +// match its own output; we mirror fx's indices verbatim for compatibility. +type dirFooter struct { + TopDivider int `json:"top_divider"` + Input int `json:"input"` + BottomDivider int `json:"bottom_divider"` +} + +// dirManifest mirrors fx's manifest.json (cli_replay.zig writeFramesManifest). +type dirManifest struct { + Cols uint16 `json:"cols"` + Rows uint16 `json:"rows"` + EpochMS int64 `json:"epoch_ms"` + Version string `json:"version"` + FrameCount int `json:"frame_count"` + ResizeCount int `json:"resize_count"` + StdoutBytes int `json:"stdout_bytes"` + FramesDir string `json:"frames_dir"` +} + +// ExportFramesDir replays t and writes per-frame artifacts plus manifest.json +// under root, following fx `replay --frames-dir` exactly: index and timing are +// 1-based cumulative, and each frame's artifact reflects the grid state after +// applying that frame. +func ExportFramesDir(root string, t *Tape) (*FramesDirSummary, error) { + framesPath := filepath.Join(root, "frames") + if err := os.MkdirAll(framesPath, 0o755); err != nil { + return nil, err + } + + grid := NewGrid(int(t.Header.Cols), int(t.Header.Rows)) + var markers []string + sum := &FramesDirSummary{} + var elapsed int64 + + for _, f := range t.Frames { + sum.FrameCount++ + elapsed += int64(f.DeltaMS) + switch f.Kind { + case KindStdout: + sum.StdoutBytes += len(f.Payload) + grid.Feed(f.Payload) + case KindResize: + if len(f.Payload) >= 4 { + cols := int(f.Payload[0]) | int(f.Payload[1])<<8 + rows := int(f.Payload[2]) | int(f.Payload[3])<<8 + grid.Resize(cols, rows) + sum.ResizeCount++ + } + case KindMarker: + markers = append(markers, string(f.Payload)) + } + if err := writeDirFrame(framesPath, sum.FrameCount, f, elapsed, grid, markers); err != nil { + return nil, err + } + } + + m := dirManifest{ + Cols: t.Header.Cols, + Rows: t.Header.Rows, + EpochMS: t.Header.EpochMS, + Version: t.Header.Version, + FrameCount: sum.FrameCount, + ResizeCount: sum.ResizeCount, + StdoutBytes: sum.StdoutBytes, + FramesDir: "frames", + } + manifest, err := json.Marshal(m) + if err != nil { + return nil, err + } + if err := os.WriteFile(filepath.Join(root, "manifest.json"), append(manifest, '\n'), 0o644); err != nil { + return nil, err + } + return sum, nil +} + +// writeDirFrame writes NNNN.json and NNNN.grid.txt for one frame, mirroring +// fx's writeFrameArtifacts. +func writeDirFrame(dir string, index int, f Frame, elapsed int64, grid *Grid, markers []string) error { + snapshot := grid.Snapshot() + stem := fmt.Sprintf("%04d", index) + + frameJSON := dirFrame{ + Index: index, + DeltaMS: f.DeltaMS, + ElapsedMS: elapsed, + Kind: f.Kind.String(), + PayloadLen: len(f.Payload), + Size: dirSize{Cols: grid.Cols, Rows: grid.Rows}, + Cursor: dirCursor{Row: grid.CursorRow(), Col: grid.CursorCol(), Visible: grid.CursorVisible()}, + Footers: footerCandidates(snapshot), + Markers: visibleMarkers(snapshot, markers), + } + raw, err := json.Marshal(frameJSON) + if err != nil { + return err + } + + base := filepath.Join(dir, stem) + if err := os.WriteFile(base+".json", append(raw, '\n'), 0o644); err != nil { + return err + } + return os.WriteFile(base+".grid.txt", []byte(snapshot), 0o644) +} + +// footerCandidates returns snapshot rows that look like framed input prompts, +// matching fx's isInputSnapshotRow + isDividerSnapshotRow logic and index skew. +func footerCandidates(snapshot string) []dirFooter { + var lines []string + for _, line := range strings.Split(snapshot, "\n") { + if line = trimSnapshotRow(line); line != "" { + lines = append(lines, line) + } + } + out := make([]dirFooter, 0) + for i, line := range lines { + if !isInputSnapshotRow(line) { + continue + } + if i == 0 || i+1 >= len(lines) { + continue + } + if !isDividerSnapshotRow(lines[i-1]) || !isDividerSnapshotRow(lines[i+1]) { + continue + } + // Fx emits {i, i+1, i+2} as {top, input, bottom}; mirror verbatim. + out = append(out, dirFooter{TopDivider: i, Input: i + 1, BottomDivider: i + 2}) + } + return out +} + +// visibleMarkers returns the markers whose text appears in the snapshot, +// preserving order (fx writeVisibleMarkers). +func visibleMarkers(snapshot string, markers []string) []string { + out := make([]string, 0) + for _, m := range markers { + if m != "" && strings.Contains(snapshot, m) { + out = append(out, m) + } + } + return out +} + +func isInputSnapshotRow(line string) bool { + text := trimSnapshotRow(line) + if strings.HasPrefix(text, "❯") || strings.HasPrefix(text, ">") { + return true + } + return strings.HasPrefix(text, "[") && + (strings.Contains(text, "] ❯") || strings.Contains(text, "] >")) +} + +func isDividerSnapshotRow(line string) bool { + text := trimSnapshotRow(line) + return strings.Contains(text, "──") || strings.Contains(text, "━━") || strings.Contains(text, "══") +} + +func trimSnapshotRow(line string) string { + if len(line) >= 2 && line[0] == '|' && line[len(line)-1] == '|' { + return strings.TrimRight(line[1:len(line)-1], " ") + } + return strings.TrimRight(line, " ") +} diff --git a/internal/terminal/tape/framesdir_test.go b/internal/terminal/tape/framesdir_test.go new file mode 100644 index 00000000..e75bee03 --- /dev/null +++ b/internal/terminal/tape/framesdir_test.go @@ -0,0 +1,143 @@ +package tape + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// frameBytesForTest builds a tiny tape with a stdout frame (a prompt banner +// plus a DECTCEM cursor-hide) and a marker frame, without Close so no trailing +// resize frame is appended. +func frameBytesForTest(t *testing.T, clk *fakeClock) []byte { + t.Helper() + var buf bytes.Buffer + w, err := NewWriter(&buf, 80, 24, "1.2", clk) + if err != nil { + t.Fatalf("new writer: %v", err) + } + clk.t = 1010 + if err := w.RecordStdout([]byte("────────────────\r\n❯ hello world\r\n────────────────\r\n\x1b[?25l")); err != nil { + t.Fatalf("record stdout: %v", err) + } + clk.t = 1030 + if err := w.RecordMarker("hello"); err != nil { + t.Fatalf("record marker: %v", err) + } + return buf.Bytes() +} + +func TestExportFramesDirArtifacts(t *testing.T) { + tp, err := Parse(frameBytesForTest(t, &fakeClock{t: 1000})) + if err != nil { + t.Fatalf("parse: %v", err) + } + + dir := t.TempDir() + sum, err := ExportFramesDir(dir, tp) + if err != nil { + t.Fatalf("export: %v", err) + } + if sum.FrameCount != 2 { + t.Errorf("FrameCount = %d, want 2", sum.FrameCount) + } + if sum.ResizeCount != 0 { + t.Errorf("ResizeCount = %d, want 0", sum.ResizeCount) + } + if sum.StdoutBytes == 0 { + t.Error("StdoutBytes = 0, want > 0") + } + + // manifest.json: compact, ends with newline, matches fx schema. + mraw, err := os.ReadFile(filepath.Join(dir, "manifest.json")) + if err != nil { + t.Fatalf("read manifest: %v", err) + } + if !strings.HasSuffix(string(mraw), "\n") { + t.Error("manifest.json missing trailing newline") + } + var m struct { + Cols float64 `json:"cols"` + Rows float64 `json:"rows"` + FrameCount float64 `json:"frame_count"` + ResizeCount float64 `json:"resize_count"` + StdoutBytes float64 `json:"stdout_bytes"` + FramesDir string `json:"frames_dir"` + } + if err := json.Unmarshal(mraw, &m); err != nil { + t.Fatalf("unmarshal manifest: %v", err) + } + if m.Cols != 80 || m.Rows != 24 || m.FrameCount != 2 || m.ResizeCount != 0 || m.FramesDir != "frames" { + t.Errorf("manifest fields wrong: %+v", m) + } + if m.StdoutBytes == 0 { + t.Errorf("manifest StdoutBytes = 0, want > 0") + } + + // Frame 1: stdout with the prompt banner; cursor hidden; footer + marker. + f1, err := os.ReadFile(filepath.Join(dir, "frames", "0001.json")) + if err != nil { + t.Fatalf("read 0001.json: %v", err) + } + if !strings.HasSuffix(string(f1), "\n") { + t.Error("0001.json missing trailing newline") + } + var d map[string]any + if err := json.Unmarshal(f1, &d); err != nil { + t.Fatalf("unmarshal 0001.json: %v", err) + } + if d["index"].(float64) != 1 { + t.Errorf("index = %v, want 1", d["index"]) + } + if d["kind"] != "stdout" { + t.Errorf("kind = %v, want stdout", d["kind"]) + } + cursor := d["cursor"].(map[string]any) + if cursor["visible"] != false { + t.Errorf("cursor.visible = %v, want false after CSI ?25l", cursor["visible"]) + } + foot := d["footer_candidates"].([]any) + if len(foot) != 1 { + t.Fatalf("footer_candidates = %d entries, want 1", len(foot)) + } + f0 := foot[0].(map[string]any) + // fx emits {top: i, input: i+1, bottom: i+2} for a row framed by dividers. + if f0["top_divider"].(float64) != 1 || f0["input"].(float64) != 2 || f0["bottom_divider"].(float64) != 3 { + t.Errorf("footer = %+v, want {top:1 input:2 bottom:3}", f0) + } + mark := d["visible_markers"].([]any) + if len(mark) != 0 { + t.Errorf("frame1 visible_markers = %v, want empty (marker not reached yet)", mark) + } + + gt, err := os.ReadFile(filepath.Join(dir, "frames", "0001.grid.txt")) + if err != nil { + t.Fatalf("read 0001.grid.txt: %v", err) + } + if !strings.Contains(string(gt), "❯ hello world") { + t.Errorf("grid.txt missing prompt, got:\n%s", gt) + } + + // Frame 2: marker artifact. + f2, err := os.ReadFile(filepath.Join(dir, "frames", "0002.json")) + if err != nil { + t.Fatalf("read 0002.json: %v", err) + } + var d2 map[string]any + if err := json.Unmarshal(f2, &d2); err != nil { + t.Fatalf("unmarshal 0002.json: %v", err) + } + if d2["index"].(float64) != 2 || d2["kind"] != "marker" { + t.Errorf("0002 = {index:%v kind:%v}, want {2 marker}", d2["index"], d2["kind"]) + } + mark2 := d2["visible_markers"].([]any) + if len(mark2) != 1 || mark2[0] != "hello" { + t.Errorf("frame2 visible_markers = %v, want [hello]", mark2) + } + if _, err := os.Stat(filepath.Join(dir, "frames", "0002.grid.txt")); err != nil { + t.Errorf("0002.grid.txt missing: %v", err) + } +} diff --git a/internal/terminal/tape/record.go b/internal/terminal/tape/record.go new file mode 100644 index 00000000..0c1771ad --- /dev/null +++ b/internal/terminal/tape/record.go @@ -0,0 +1,59 @@ +package tape + +import ( + "io" + "sync" +) + +// Recorder captures a live output stream into an fxtape (fx `--record` +// parity). It wraps the underlying terminal writer so every byte written is +// both forwarded to the terminal and stored as a stdout frame with a +// wall-clock delta; terminal size changes are recorded as resize frames. +type Recorder struct { + mu sync.Mutex + w *Writer + out io.Writer +} + +// NewRecorder returns a Recorder that forwards to out and writes an fxtape to +// file. Pass nil to use the default wall clock. +func NewRecorder(file io.Writer, out io.Writer, cols, rows uint16, clock Clock) (*Recorder, error) { + w, err := NewWriter(file, cols, rows, "1", clock) + if err != nil { + return nil, err + } + return &Recorder{w: w, out: out}, nil +} + +// Write implements io.Writer: it forwards p to the underlying output and +// records exactly the bytes written as a stdout frame. +func (r *Recorder) Write(p []byte) (int, error) { + r.mu.Lock() + defer r.mu.Unlock() + n, err := r.out.Write(p) + if n > 0 { + _ = r.w.RecordStdout(p[:n]) + } + return n, err +} + +// Resize records a terminal size change. +func (r *Recorder) Resize(cols, rows uint16) error { + r.mu.Lock() + defer r.mu.Unlock() + return r.w.RecordResize(cols, rows) +} + +// Marker records a named marker frame. +func (r *Recorder) Marker(label string) error { + r.mu.Lock() + defer r.mu.Unlock() + return r.w.RecordMarker(label) +} + +// Close finalizes the tape. Safe to call more than once. +func (r *Recorder) Close() error { + r.mu.Lock() + defer r.mu.Unlock() + return r.w.Close() +} diff --git a/internal/terminal/tape/record_test.go b/internal/terminal/tape/record_test.go new file mode 100644 index 00000000..c70ca13b --- /dev/null +++ b/internal/terminal/tape/record_test.go @@ -0,0 +1,83 @@ +package tape + +import ( + "bytes" + "testing" +) + +func TestRecorderCapturesStdoutAndResize(t *testing.T) { + clk := &fakeClock{t: 1000} + var file bytes.Buffer + var out bytes.Buffer + var hadWriterErr bool + rec, err := NewRecorder(&file, &out, 120, 30, clk) + if err != nil { + t.Fatalf("NewRecorder: %v", err) + } + _ = &hadWriterErr + + if _, err := rec.Write([]byte("hello")); err != nil { + t.Fatalf("Write: %v", err) + } + clk.t += 250 + if _, err := rec.Write([]byte(" world")); err != nil { + t.Fatalf("Write: %v", err) + } + clk.t += 100 + if err := rec.Resize(100, 20); err != nil { + t.Fatalf("Resize: %v", err) + } + clk.t += 50 + if err := rec.Marker("done"); err != nil { + t.Fatalf("Marker: %v", err) + } + if err := rec.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + // Underlying output receives every byte verbatim. + if got := out.String(); got != "hello world" { + t.Errorf("forwarded output = %q, want %q", got, "hello world") + } + + parsed, err := Parse(file.Bytes()) + if err != nil { + t.Fatalf("Parse: %v", err) + } + if parsed.Header.Cols != 120 || parsed.Header.Rows != 30 { + t.Errorf("header size = %dx%d, want 120x30", parsed.Header.Cols, parsed.Header.Rows) + } + if len(parsed.Frames) != 4 { + t.Fatalf("frames = %d, want 4", len(parsed.Frames)) + } + wantKinds := []Kind{KindStdout, KindStdout, KindResize, KindMarker} + for i, f := range parsed.Frames { + if f.Kind != wantKinds[i] { + t.Errorf("frame[%d] kind = %s, want %s", i, f.Kind.String(), wantKinds[i].String()) + } + } + if string(parsed.Frames[0].Payload) != "hello" { + t.Errorf("frame[0] payload = %q, want %q", parsed.Frames[0].Payload, "hello") + } + // Deltas: 0, 250, 100, 50. + wantDeltas := []int32{0, 250, 100, 50} + for i, f := range parsed.Frames { + if f.DeltaMS != wantDeltas[i] { + t.Errorf("frame[%d] delta = %d, want %d", i, f.DeltaMS, wantDeltas[i]) + } + } +} + +func TestRecorderCloseIdempotent(t *testing.T) { + var file bytes.Buffer + rec, err := NewRecorder(&file, &bytes.Buffer{}, 80, 24, &fakeClock{}) + if err != nil { + t.Fatalf("NewRecorder: %v", err) + } + if err := rec.Close(); err != nil { + t.Fatalf("first Close: %v", err) + } + if err := rec.Close(); err != nil { + t.Fatalf("second Close: %v", err) + } +} diff --git a/internal/terminal/tape/replay.go b/internal/terminal/tape/replay.go new file mode 100644 index 00000000..78fb51aa --- /dev/null +++ b/internal/terminal/tape/replay.go @@ -0,0 +1,345 @@ +package tape + +import ( + "strings" + "unicode/utf8" +) + +// Grid is a focused virtual terminal for fxtape replay: a scrollback of text +// lines plus a cursor, interpreting the common ANSI sequences that tool +// output emits (carriage return, newline, backspace, tab, SGR colour codes, +// clear line/screen, and cursor movement/positioning). It is intentionally a +// subset of a full VT100 emulator (fx's terminal/engine.zig) — sufficient to +// reconstruct field-displayable output from a real capture. +type Grid struct { + // lines is the scrollback: each entry is one row of runes up to Cols. + lines [][]rune + row int // current cursor row within lines + col int // current cursor column (0-based) + Cols int + Rows int + + // cursorVisible tracks DECTCEM cursor visibility (CSI ?25h shows, ?25l + // hides), reported by the per-frame artifact writer for parity with fx. + cursorVisible bool +} + +// NewGrid builds an empty grid with the given terminal size. +func NewGrid(cols, rows int) *Grid { + if cols < 1 { + cols = 1 + } + if rows < 1 { + rows = 1 + } + g := &Grid{Cols: cols, Rows: rows, lines: [][]rune{{}}, cursorVisible: true} + return g +} + +// CursorRow returns the current 0-based cursor row. +func (g *Grid) CursorRow() int { return g.row } + +// CursorCol returns the current 0-based cursor column. +func (g *Grid) CursorCol() int { return g.col } + +// CursorVisible reports whether the cursor is shown (DECTCEM: CSI ?25h shows, +// CSI ?25l hides). It defaults to shown. +func (g *Grid) CursorVisible() bool { return g.cursorVisible } + +func (g *Grid) ensureRow() { + for g.row >= len(g.lines) { + g.lines = append(g.lines, make([]rune, 0, g.Cols)) + } +} + +func (g *Grid) put(ch rune) { + g.ensureRow() + line := g.lines[g.row] + for len(line) <= g.col { + line = append(line, ' ') + } + line[g.col] = ch + g.lines[g.row] = line + g.col++ + if g.col >= g.Cols { + g.col = 0 + g.newline() + } +} + +func (g *Grid) newline() { + g.row++ + g.col = 0 + g.ensureRow() +} + +// Feed interprets a chunk of raw terminal output bytes. +func (g *Grid) Feed(b []byte) { + for i := 0; i < len(b); i++ { + c := b[i] + switch c { + case '\r': + g.col = 0 + case '\n': + g.newline() + case '\b': + if g.col > 0 { + g.col-- + } + case '\t': + g.col += 8 - g.col%8 + if g.col >= g.Cols { + g.col = g.Cols - 1 + } + case 0x07: // BEL + // bell — no visual effect + case 0x1b: // ESC + i = g.parseEscape(b, i) + default: + if c >= 0x20 { + if c < 0x80 { + g.put(rune(c)) + } else { + // Decode a multi-byte UTF-8 sequence (prompts use ❯ │ ─, + // which occupy >1 byte) instead of storing each byte as a + // rune. Malformed sequences degrade to RuneError. + r, size := utf8.DecodeRune(b[i:]) + g.put(r) + i += size - 1 + } + } + } + } +} + +// parseEscape consumes an ESC sequence starting at b[i]=='0x1b' and returns +// the index of the last consumed byte. Unknown sequences are skipped. +func (g *Grid) parseEscape(b []byte, i int) int { + if i+1 >= len(b) { + return len(b) - 1 + } + esc := b[i+1] + switch esc { + case '[': // CSI + return g.parseCSI(b, i+2) + case ']': // OSC — skip until BEL or ST + j := i + 2 + for j < len(b) { + if b[j] == 0x07 { + return j + } + if b[j] == 0x1b && j+1 < len(b) && b[j+1] == '\\' { + return j + 1 + } + j++ + } + return len(b) - 1 + case '\\', 'c', '7', '8', '=', '>', '(', ')': + return i + 1 + default: + return i + 1 + } +} + +// parseCSI consumes a Control Sequence Introducer body after `b[j]` (the byte +// after `\x1b[`) and returns the index of the final byte. +func (g *Grid) parseCSI(b []byte, j int) int { + params := make([]int, 0, 8) + var cur int + have := false + private := false + k := j + for ; k < len(b); k++ { + c := b[k] + switch { + case c >= '0' && c <= '9': + have = true + cur = cur*10 + int(c-'0') + case c == ';': + params = append(params, cur) + cur = 0 + have = false + case c == '?': + private = true + case c < 0x20: + // intervening control byte — ignore + default: + // final byte + if have { + params = append(params, cur) + } + if len(params) == 0 { + params = []int{0} + } + if private { + // DECTCEM cursor visibility: CSI ?25h shows, CSI ?25l hides. + if (c == 'h' || c == 'l') && len(params) == 1 && params[0] == 25 { + g.cursorVisible = c == 'h' + } + } else { + g.applyCSI(params, c) + } + return k + } + } + return len(b) - 1 +} + +func (g *Grid) applyCSI(p []int, final byte) { + n := p[0] + switch final { + case 'A': // cursor up + g.row -= n + if g.row < 0 { + g.row = 0 + } + case 'B': // cursor down + g.row += n + g.ensureRow() + case 'C': // cursor forward + g.col += n + case 'D': // cursor back + g.col -= n + if g.col < 0 { + g.col = 0 + } + case 'H', 'f': // cursor position (row;col), 1-based + row, col := p[0], 1 + if len(p) >= 2 { + col = p[1] + } + if row < 1 { + row = 1 + } + if col < 1 { + col = 1 + } + g.row = row - 1 + g.col = col - 1 + g.ensureRow() + case 'G': // cursor column (1-based) + if n < 1 { + n = 1 + } + g.col = n - 1 + case 'K': // erase in line + g.eraseLine(p[0]) + case 'J': // erase in display + g.eraseDisplay(p[0]) + case 'm': // SGR — colour/attribute, no layout effect + case 's', 'u': // save/restore cursor (approx: ignore) + case 'g': // tab clear — ignore + default: + // unknown CSI — ignore + } +} + +func (g *Grid) eraseLine(mode int) { + g.ensureRow() + line := g.lines[g.row] + switch mode { + case 0: // erase from cursor to end of line + for len(line) <= g.col { + line = append(line, ' ') + } + for i := g.col; i < len(line); i++ { + line[i] = ' ' + } + case 1: // erase from start to cursor + for i := 0; i <= g.col && i < len(line); i++ { + line[i] = ' ' + } + case 2: // erase entire line + for i := range line { + line[i] = ' ' + } + } + g.lines[g.row] = line +} + +func (g *Grid) eraseDisplay(mode int) { + switch mode { + case 0: // below cursor + g.eraseLine(0) + for r := g.row + 1; r < len(g.lines); r++ { + g.lines[r] = []rune{} + } + case 1: // above cursor + for r := 0; r < g.row; r++ { + g.lines[r] = []rune{} + } + g.eraseLine(1) + case 2, 3: // clear whole screen (+ scrollback) + g.lines = [][]rune{{}} + g.row = 0 + g.col = 0 + } +} + +// Resize changes the grid columns; rows affect the snapshotted window. +func (g *Grid) Resize(cols, rows int) { + if cols < 1 { + cols = 1 + } + if rows < 1 { + rows = 1 + } + g.Cols = cols + g.Rows = rows + if g.col >= cols { + g.col = cols - 1 + } +} + +// Snapshot renders the visible terminal contents (the last Rows lines, each +// trimmed of trailing space and padded to Cols). +func (g *Grid) Snapshot() string { + start := len(g.lines) - g.Rows + if start < 0 { + start = 0 + } + var rows []string + for r := start; r < len(g.lines); r++ { + line := strings.TrimRight(string(g.lines[r]), " ") + rows = append(rows, line) + } + // Drop fully-empty trailing rows so golden files are stable. + for len(rows) > 0 && strings.TrimRight(rows[len(rows)-1], " ") == "" { + rows = rows[:len(rows)-1] + } + return strings.Join(rows, "\n") +} + +// Replay applies a parsed tape's stdout frames into a grid, honoring resizes +// and markers. It returns the final snapshot and per-frame stats. +type Replay struct { + Grid *Grid + Stdout int // total stdout bytes fed + Frames int // frames processed + Markers []string + RenderedMS int64 // sum of deltas +} + +// ReplayTape feeds every frame of a parsed tape into a grid sized from its +// header, returning the finished Replay and final visible snapshot. +func ReplayTape(t *Tape) (*Replay, string) { + r := &Replay{Grid: NewGrid(int(t.Header.Cols), int(t.Header.Rows))} + for _, f := range t.Frames { + r.Frames++ + r.RenderedMS += int64(f.DeltaMS) + switch f.Kind { + case KindStdout: + r.Grid.Feed(f.Payload) + r.Stdout += len(f.Payload) + case KindResize: + if len(f.Payload) >= 4 { + cols := int(f.Payload[0]) | int(f.Payload[1])<<8 + rows := int(f.Payload[2]) | int(f.Payload[3])<<8 + r.Grid.Resize(cols, rows) + } + case KindMarker: + r.Markers = append(r.Markers, string(f.Payload)) + } + } + return r, r.Grid.Snapshot() +} diff --git a/internal/terminal/tape/smoke_test.go b/internal/terminal/tape/smoke_test.go new file mode 100644 index 00000000..8428a26b --- /dev/null +++ b/internal/terminal/tape/smoke_test.go @@ -0,0 +1,95 @@ +package tape + +import ( + "os" + "path/filepath" + "testing" +) + +// Smoke: record a short terminal session to a real file, then parse and +// replay it, verifying the recorded output is reconstructed. +func TestRecordReplaySmoke(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "session.fxtape") + + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + t.Fatal(err) + } + clk := &fakeClock{t: 1_700_000_000_000} + w, err := NewWriter(f, 120, 40, "hawk-test", clk) + if err != nil { + t.Fatal(err) + } + + // Simulate a real session transcript. + clk.t += 12 + _ = w.RecordStdout([]byte("$ git status\n")) + clk.t += 40 + _ = w.RecordStdout([]byte("On branch main\nnothing to commit\n")) + clk.t += 8 + _ = w.RecordResize(100, 30) + clk.t += 5 + _ = w.RecordStdout([]byte("\x1b[33m$ echo done\x1b[0m\n")) + clk.t += 7 + _ = w.RecordMarker("end-of-turn") + if err := w.Close(); err != nil { + t.Fatal(err) + } + _ = f.Close() + + // Different process path: read the file back and replay. + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + tp, err := Parse(data) + if err != nil { + t.Fatalf("parse recorded tape: %v", err) + } + if tp.Header.Cols != 120 || tp.Header.Rows != 40 || tp.Header.Version != "hawk-test" { + t.Fatalf("bad header: %+v", tp.Header) + } + r, snap := ReplayTape(tp) + if len(tp.Frames) != 5 { + t.Fatalf("expected 5 frames, got %d", len(tp.Frames)) + } + if len(r.Markers) != 1 || r.Markers[0] != "end-of-turn" { + t.Fatalf("markers=%v", r.Markers) + } + for _, want := range []string{"$ git status", "On branch main", "nothing to commit", "$ echo done"} { + if !containsLine(snap, want) { + t.Fatalf("snapshot missing %q:\n%s", want, snap) + } + } + // Resize honored. + if r.Grid.Cols != 100 || r.Grid.Rows != 30 { + t.Fatalf("grid not resized to 100x30, got %dx%d", r.Grid.Cols, r.Grid.Rows) + } +} + +func containsLine(s, line string) bool { + for _, l := range splitLines(s) { + if l == line { + return true + } + } + return false +} + +func splitLines(s string) []string { + var out []string + cur := "" + for _, r := range s { + if r == '\n' { + out = append(out, cur) + cur = "" + continue + } + cur += string(r) + } + if len(cur) > 0 { + out = append(out, cur) + } + return out +} diff --git a/internal/terminal/tape/store.go b/internal/terminal/tape/store.go new file mode 100644 index 00000000..2eb8145f --- /dev/null +++ b/internal/terminal/tape/store.go @@ -0,0 +1,183 @@ +// Package tape: hawk-native tape status + commit checkpoint (not an fx +// feature — fx exposes no `tape status`/`tape commit`). Status summarizes a +// tape's header, frame mix, and footprint; commit copies a validated tape into +// a named immutable location with a content hash so a session can be recalled +// as an artifact. +package tape + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" +) + +// TapeStatus summarizes a tape's header, frame mix, and footprint. +type TapeStatus struct { + Path string `json:"path"` + Size int64 `json:"size_bytes"` + Cols uint16 `json:"cols"` + Rows uint16 `json:"rows"` + EpochMS int64 `json:"epoch_ms"` + Version string `json:"version"` + FrameCount int `json:"frame_count"` + ResizeCount int `json:"resize_count"` + StdoutBytes int `json:"stdout_bytes"` + DurationMS int64 `json:"duration_ms"` + Kinds map[string]int `json:"kinds"` +} + +// InspectFile parses a tape from path and returns its status. +func InspectFile(path string) (*TapeStatus, error) { + info, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("stat %s: %w", path, err) + } + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + t, err := Parse(data) + if err != nil { + return nil, fmt.Errorf("bad tape %s: %w", path, err) + } + st := &TapeStatus{ + Path: path, + Size: info.Size(), + Cols: t.Header.Cols, + Rows: t.Header.Rows, + EpochMS: t.Header.EpochMS, + Version: t.Header.Version, + FrameCount: len(t.Frames), + Kinds: map[string]int{}, + } + for _, f := range t.Frames { + st.Kinds[f.Kind.String()]++ + st.DurationMS += int64(f.DeltaMS) + switch f.Kind { + case KindResize: + st.ResizeCount++ + case KindStdout: + st.StdoutBytes += len(f.Payload) + } + } + return st, nil +} + +// TapeCommit describes a committed (checkpointed) copy of a tape. +type TapeCommit struct { + Name string `json:"name"` + Path string `json:"path"` + MetaPath string `json:"meta_path"` + CommitID string `json:"commit_id"` // first 12 hex chars of the content SHA-256 + Frames int `json:"frame_count"` +} + +// commitMeta is written next to every committed tape. +type commitMeta struct { + Name string `json:"name"` + Source string `json:"source"` + CommitID string `json:"commit_id"` + Committed string `json:"committed"` // RFC3339 UTC + FrameCount int `json:"frame_count"` + Size int64 `json:"size_bytes"` + SHA256 string `json:"sha256"` +} + +// DefaultTapesDir returns where committed tapes are stored by default. +func DefaultTapesDir() string { + if d := os.Getenv("HAWK_TAPES_DIR"); d != "" { + return d + } + base, err := os.UserConfigDir() + if err != nil { + base = "." + } + return filepath.Join(base, "hawk", "tapes") +} + +// ValidCommitName reports whether name is a safe tape commit filename. +func ValidCommitName(name string) bool { + if name == "" || name == "." || name == ".." { + return false + } + for _, r := range name { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', + r == '-', r == '_', r == '.': + continue + default: + return false + } + } + return true +} + +// CommitFile validates src as a tape, then copies it into dir as +// .fxtape (failing if it already exists) with a sidecar +// .meta.json. An empty dir uses DefaultTapesDir. +func CommitFile(srcPath, name, dir string) (*TapeCommit, error) { + if !ValidCommitName(name) { + return nil, fmt.Errorf("invalid tape name %q", name) + } + if dir == "" { + dir = DefaultTapesDir() + } + data, err := os.ReadFile(srcPath) + if err != nil { + return nil, fmt.Errorf("read %s: %w", srcPath, err) + } + t, err := Parse(data) + if err != nil { + return nil, fmt.Errorf("bad tape %s: %w", srcPath, err) + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("make commit dir: %w", err) + } + + tapePath := filepath.Join(dir, name+".fxtape") + f, err := os.OpenFile(tapePath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + if errors.Is(err, os.ErrExist) { + return nil, fmt.Errorf("tape %q already committed at %s", name, tapePath) + } + return nil, fmt.Errorf("create %s: %w", tapePath, err) + } + if _, err := f.Write(data); err != nil { + _ = f.Close() + return nil, fmt.Errorf("write %s: %w", tapePath, err) + } + if err := f.Close(); err != nil { + return nil, err + } + + sha := sha256.Sum256(data) + meta := commitMeta{ + Name: name, + Source: srcPath, + CommitID: hex.EncodeToString(sha[:6]), + Committed: time.Now().UTC().Format(time.RFC3339), + FrameCount: len(t.Frames), + Size: int64(len(data)), + SHA256: hex.EncodeToString(sha[:]), + } + mraw, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return nil, err + } + metaPath := filepath.Join(dir, name+".meta.json") + if err := os.WriteFile(metaPath, append(mraw, '\n'), 0o644); err != nil { + return nil, fmt.Errorf("write %s: %w", metaPath, err) + } + return &TapeCommit{ + Name: name, + Path: tapePath, + MetaPath: metaPath, + CommitID: meta.CommitID, + Frames: len(t.Frames), + }, nil +} diff --git a/internal/terminal/tape/store_test.go b/internal/terminal/tape/store_test.go new file mode 100644 index 00000000..834687d2 --- /dev/null +++ b/internal/terminal/tape/store_test.go @@ -0,0 +1,127 @@ +package tape + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func writeTempTape(t *testing.T, data []byte) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "session.fxtape") + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatalf("write temp tape: %v", err) + } + return path +} + +func TestInspectFileMetrics(t *testing.T) { + path := writeTempTape(t, frameBytesForTest(t, &fakeClock{t: 1000})) + st, err := InspectFile(path) + if err != nil { + t.Fatalf("inspect: %v", err) + } + if st.Cols != 80 || st.Rows != 24 { + t.Errorf("terminal = %dx%d, want 80x24", st.Cols, st.Rows) + } + if st.Version != "1.2" { + t.Errorf("version = %q, want 1.2", st.Version) + } + if st.FrameCount != 2 { + t.Errorf("FrameCount = %d, want 2", st.FrameCount) + } + if st.Kinds["stdout"] != 1 || st.Kinds["marker"] != 1 { + t.Errorf("kinds = %v, want stdout:1 marker:1", st.Kinds) + } + if st.StdoutBytes == 0 { + t.Error("StdoutBytes = 0, want > 0") + } + if st.Size != int64(len(frameBytesForTest(t, &fakeClock{t: 1000}))) { + t.Errorf("Size mismatch") + } +} + +func TestCommitFileWritesArtifactAndRejectsDupes(t *testing.T) { + data := frameBytesForTest(t, &fakeClock{t: 1000}) + path := writeTempTape(t, data) + dir := t.TempDir() + + c, err := CommitFile(path, "demo", dir) + if err != nil { + t.Fatalf("commit: %v", err) + } + if c.Name != "demo" || c.Frames != 2 { + t.Errorf("commit = %+v, want {name demo frames 2}", c) + } + if len(c.CommitID) != 12 { + t.Errorf("CommitID = %q, want 12 hex chars", c.CommitID) + } + + tapeBytes, err := os.ReadFile(c.Path) + if err != nil { + t.Fatalf("read committed tape: %v", err) + } + if string(tapeBytes) != string(data) { + t.Error("committed tape bytes differ from source") + } + + metaRaw, err := os.ReadFile(c.MetaPath) + if err != nil { + t.Fatalf("read meta: %v", err) + } + var meta struct { + Name string `json:"name"` + CommitID string `json:"commit_id"` + SHA256 string `json:"sha256"` + Frames int `json:"frame_count"` + } + if err := json.Unmarshal(metaRaw, &meta); err != nil { + t.Fatalf("unmarshal meta: %v", err) + } + if meta.Name != "demo" || meta.CommitID != c.CommitID || meta.Frames != 2 { + t.Errorf("meta = %+v, want name demo commit_id %s frames 2", meta, c.CommitID) + } + if len(meta.SHA256) != 64 { + t.Errorf("sha256 = %q, want 64 hex", meta.SHA256) + } + + // Duplicate name must not overwrite. + if _, err := CommitFile(path, "demo", dir); err == nil { + t.Fatal("expected duplicate commit to fail") + } +} + +func TestCommitValidation(t *testing.T) { + if ValidCommitName("") || ValidCommitName("..") || ValidCommitName("a/b") || ValidCommitName("a b") { + t.Error("invalid names accepted") + } + if !ValidCommitName("session-2026.01_a") { + t.Error("valid name rejected") + } + + // Unparseable source is rejected before any write. + dir := t.TempDir() + bad := filepath.Join(dir, "bad.fxtape") + if err := os.WriteFile(bad, []byte("not a tape"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := CommitFile(bad, "y", dir); err == nil { + t.Fatal("expected commit of invalid tape to fail") + } + for _, p := range []string{filepath.Join(dir, "y.fxtape"), filepath.Join(dir, "y.meta.json")} { + if _, err := os.Stat(p); err == nil { + t.Errorf("invalid tape should not write %s", p) + } + } +} + +func TestDefaultTapesDirUsesEnv(t *testing.T) { + t.Setenv("HAWK_TAPES_DIR", "/tmp/ht") + got := DefaultTapesDir() + if got != "/tmp/ht" || !strings.Contains(got, "ht") { + t.Errorf("DefaultTapesDir = %q, want env override", got) + } +} diff --git a/internal/terminal/tape/tape.go b/internal/terminal/tape/tape.go new file mode 100644 index 00000000..6b480023 --- /dev/null +++ b/internal/terminal/tape/tape.go @@ -0,0 +1,266 @@ +// Package tape ports vercel-labs/fx's terminal capture/replay container +// (src/core/workspace/record_tape.zig + src/core/cli/cli_replay.zig): a +// compact binary "fxtape" that records terminal output (stdout bytes, resize, +// ctrl+c, and named markers) with per-frame timing deltas, and replays it by +// feeding stdout into a virtual terminal grid. +// +// The on-disk format is byte-for-byte compatible with fx: +// +// Header: +// magic "FXTP\x01" (5 bytes) +// cols u16 little-endian +// rows u16 little-endian +// epoch_ms i64 little-endian +// vlen u8 (<=255) followed by that many version bytes +// Frames (repeated): +// delta_ms i32 little-endian (delta from previous frame) +// kind u8: 1=stdout 2=stdin 3=resize 4=sigint 5=marker +// len u32 little-endian, then `len` payload bytes +// Resize payload: cols u16 + rows u16. +// +// This format is the durable capture contract; it survives version changes +// because deltas are wall-clock and stdout is raw bytes. +package tape + +import ( + "encoding/binary" + "errors" + "fmt" + "io" + "time" +) + +// Magic is the fxtape magic identifier (matches fx's "FXTP\x01"). +var Magic = []byte{'F', 'X', 'T', 'P', 0x01} + +// Kind identifies a tape frame's payload type. +type Kind uint8 + +// Frame kinds, matching fx's record_tape.Kind. +const ( + KindStdout Kind = 1 + KindStdin Kind = 2 + KindResize Kind = 3 + KindSigint Kind = 4 + KindMarker Kind = 5 +) + +// String returns the fx frame-kind name. +func (k Kind) String() string { + switch k { + case KindStdout: + return "stdout" + case KindStdin: + return "stdin" + case KindResize: + return "resize" + case KindSigint: + return "sigint" + case KindMarker: + return "marker" + } + return "unknown" +} + +// lastKind is the highest defined kind for validation. +const headerLen = len("FXTP\x01") + 2 + 2 + 8 + 1 + +// Header is the fixed tape preamble. +type Header struct { + Cols uint16 + Rows uint16 + EpochMS int64 + Version string +} + +// Frame is one tape record. +type Frame struct { + DeltaMS int32 + Kind Kind + Payload []byte +} + +// Clock abstracts time for deterministic deltas in tests. +type Clock interface { + NowMS() int64 +} + +type realClock struct{} + +func (realClock) NowMS() int64 { return time.Now().UnixMilli() } + +// Writer records frames to an io.Writer in the fxtape binary format. Frame +// deltas are wall-clock based (clamped to the i32 range, matching fx). +type Writer struct { + w io.Writer + clock Clock + lastMS int64 + hdr Header + frames int + closed bool + err error +} + +// NewWriter records a new tape with the given initial terminal size and +// version string. +func NewWriter(w io.Writer, cols, rows uint16, version string, clock Clock) (*Writer, error) { + if clock == nil { + clock = realClock{} + } + if len(version) > 255 { + return nil, errors.New("tape: version longer than 255 bytes") + } + now := clock.NowMS() + wr := &Writer{w: w, clock: clock, hdr: Header{Cols: cols, Rows: rows, EpochMS: now, Version: version}, lastMS: now} + if err := wr.writeHeader(); err != nil { + return nil, err + } + return wr, nil +} + +func (wr *Writer) writeHeader() error { + buf := make([]byte, 0, headerLen+len(wr.hdr.Version)) + buf = append(buf, Magic...) + buf = binary.LittleEndian.AppendUint16(buf, wr.hdr.Cols) + buf = binary.LittleEndian.AppendUint16(buf, wr.hdr.Rows) + buf = binary.LittleEndian.AppendUint64(buf, uint64(wr.hdr.EpochMS)) + buf = append(buf, byte(len(wr.hdr.Version))) + buf = append(buf, wr.hdr.Version...) + _, err := wr.w.Write(buf) + return err +} + +// delta returns the clamped wall-clock delta since the last frame. +func (wr *Writer) delta() int32 { + now := wr.clock.NowMS() + d := now - wr.lastMS + wr.lastMS = now + if d > int64(^uint32(0)>>1) { + return int32(^uint32(0) >> 1) + } + if d < 0 { + return 0 + } + return int32(d) +} + +func (wr *Writer) writeFrame(kind Kind, payload []byte) error { + if wr.closed { + return errors.New("tape: writer closed") + } + buf := make([]byte, 0, 9+len(payload)) + buf = binary.LittleEndian.AppendUint32(buf, uint32(wr.delta())) + buf = append(buf, byte(kind)) + buf = binary.LittleEndian.AppendUint32(buf, uint32(len(payload))) + buf = append(buf, payload...) + _, err := wr.w.Write(buf) + if err != nil { + wr.err = err + } + wr.frames++ + return err +} + +// RecordStdout appends raw terminal output bytes. +func (wr *Writer) RecordStdout(b []byte) error { + if len(b) == 0 { + return wr.err + } + return wr.writeFrame(KindStdout, b) +} + +// RecordStdin appends a typed input chunk. +func (wr *Writer) RecordStdin(b []byte) error { + if len(b) == 0 { + return wr.err + } + return wr.writeFrame(KindStdin, b) +} + +// RecordResize records a terminal resize to the given size. +func (wr *Writer) RecordResize(cols, rows uint16) error { + return wr.writeFrame(KindResize, []byte{byte(cols), byte(cols >> 8), byte(rows), byte(rows >> 8)}) +} + +// RecordSigint records a Ctrl-C interrupt. +func (wr *Writer) RecordSigint() error { + return wr.writeFrame(KindSigint, nil) +} + +// RecordMarker records a named marker (an arbitrary label string). +func (wr *Writer) RecordMarker(label string) error { + return wr.writeFrame(KindMarker, []byte(label)) +} + +// Close flushes a trailing final-resize frame (matching fx's shutdown) and +// marks the writer closed. Safe to call more than once. +func (wr *Writer) Close() error { + if wr.closed { + return wr.err + } + wr.closed = true + return wr.err +} + +// Frames returns the number of frames recorded. +func (wr *Writer) Frames() int { return wr.frames } + +// Tape is a parsed capture. +type Tape struct { + Header Header + Frames []Frame +} + +// Parse decodes an fxtape from raw bytes, validating the magic, header, and +// every frame bound. +func Parse(data []byte) (*Tape, error) { + if len(data) < headerLen { + return nil, errors.New("tape: too short") + } + if len(data) < len(Magic) || string(data[:len(Magic)]) != string(Magic) { + return nil, errors.New("tape: bad magic") + } + pos := len(Magic) + if pos+9 > len(data) { + return nil, errors.New("tape: truncated header") + } + cols := binary.LittleEndian.Uint16(data[pos:]) + rows := binary.LittleEndian.Uint16(data[pos+2:]) + epoch := int64(binary.LittleEndian.Uint64(data[pos+4:])) + pos += 12 + vlen := int(data[pos]) + pos++ + if pos+vlen > len(data) { + return nil, errors.New("tape: truncated version") + } + version := string(data[pos : pos+vlen]) + pos += vlen + + t := &Tape{Header: Header{Cols: cols, Rows: rows, EpochMS: epoch, Version: version}} + for pos < len(data) { + if pos+9 > len(data) { + return nil, errors.New("tape: truncated frame header") + } + d := int32(binary.LittleEndian.Uint32(data[pos:])) + kind := Kind(data[pos+4]) + l := int(binary.LittleEndian.Uint32(data[pos+5:])) + pos += 9 + if pos+l > len(data) { + return nil, errors.New("tape: truncated frame payload") + } + t.Frames = append(t.Frames, Frame{DeltaMS: d, Kind: kind, Payload: data[pos : pos+l]}) + pos += l + } + return t, nil +} + +// Dump renders a human-oriented summary of a tape (header + frame kinds). +func (t *Tape) Dump() string { + var b []byte + b = append(b, fmt.Sprintf("cols=%d rows=%d epoch_ms=%d version=%q frames=%d\n", + t.Header.Cols, t.Header.Rows, t.Header.EpochMS, t.Header.Version, len(t.Frames))...) + for i, f := range t.Frames { + b = append(b, fmt.Sprintf(" [%d] +%dms %-7s len=%d\n", i, f.DeltaMS, f.Kind, len(f.Payload))...) + } + return string(b) +} diff --git a/internal/terminal/tape/tape_test.go b/internal/terminal/tape/tape_test.go new file mode 100644 index 00000000..a045f86a --- /dev/null +++ b/internal/terminal/tape/tape_test.go @@ -0,0 +1,178 @@ +package tape + +import ( + "bytes" + "encoding/binary" + "testing" +) + +// fakeClock returns a caller-controlled millisecond timestamp. +type fakeClock struct{ t int64 } + +func (c *fakeClock) NowMS() int64 { return c.t } + +// appendFrameBytes serializes one frame the same way Writer does, for tests +// that craft tapes directly. +func appendFrameBytes(dst []byte, delta int32, kind byte, payload []byte) []byte { + dst = binary.LittleEndian.AppendUint32(dst, uint32(delta)) + dst = append(dst, kind) + dst = binary.LittleEndian.AppendUint32(dst, uint32(len(payload))) + return append(dst, payload...) +} + +func newTestWriter(t *testing.T) (*Writer, *bytes.Buffer, *fakeClock) { + t.Helper() + var buf bytes.Buffer + clk := &fakeClock{t: 1000} + w, err := NewWriter(&buf, 80, 24, "1.2", clk) + if err != nil { + t.Fatalf("new writer: %v", err) + } + return w, &buf, clk +} + +// Header layout matches fx exactly: 5-byte magic + LE cols + LE rows + LE +// epoch + vlen + version. +func TestWriterHeaderByteExact(t *testing.T) { + w, buf, _ := newTestWriter(t) + if err := w.Close(); err != nil { + t.Fatal(err) + } + got := buf.Bytes() + want := []byte{'F', 'X', 'T', 'P', 0x01, 80, 0, 24, 0, 0xe8, 0x03, 0, 0, 0, 0, 0, 0, 3, '1', '.', '2'} + if !bytes.Equal(got, want) { + t.Fatalf("\n got: %v\nwant: %v", got, want) + } +} + +// A frame is 9-byte header (LE delta i32, kind, LE len u32) + payload. +func TestWriterFramesByteExact(t *testing.T) { + w, buf, clk := newTestWriter(t) + clk.t += 25 + if err := w.RecordStdout([]byte("hi")); err != nil { + t.Fatal(err) + } + clk.t += 10 + if err := w.RecordResize(60, 20); err != nil { + t.Fatal(err) + } + clk.t += 5 + if err := w.RecordSigint(); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + + data := buf.Bytes() + tp, err := Parse(data) + if err != nil { + t.Fatalf("parse: %v", err) + } + if tp.Header.Cols != 80 || tp.Header.Rows != 24 || tp.Header.Version != "1.2" { + t.Fatalf("bad header: %+v", tp.Header) + } + if len(tp.Frames) != 3 { + t.Fatalf("expected 3 frames, got %d", len(tp.Frames)) + } + f0 := tp.Frames[0] + if f0.Kind != KindStdout || string(f0.Payload) != "hi" || f0.DeltaMS != 25 { + t.Fatalf("bad stdout frame: %+v", f0) + } + f1 := tp.Frames[1] + if f1.Kind != KindResize || len(f1.Payload) != 4 || f1.DeltaMS != 10 { + t.Fatalf("bad resize frame: %+v", f1) + } + if cols := int(f1.Payload[0]) | int(f1.Payload[1])<<8; cols != 60 { + t.Fatalf("resize cols=%d", cols) + } + if f2 := tp.Frames[2]; f2.Kind != KindSigint || f2.DeltaMS != 5 { + t.Fatalf("bad sigint frame: %+v", f2) + } +} + +// Round-trip: a recorded sequence parses back byte-identical and replays to +// the expected snapshot. +func TestWriterParserReplayRoundTrip(t *testing.T) { + w, buf, clk := newTestWriter(t) + clk.t += 5 + _ = w.RecordMarker("start") + clk.t += 3 + _ = w.RecordStdout([]byte("line one\n")) + clk.t += 4 + _ = w.RecordStdout([]byte("\x1b[32mgreen\x1b[0m\n")) + _ = w.Close() + + tp, err := Parse(buf.Bytes()) + if err != nil { + t.Fatal(err) + } + if len(tp.Frames) != 3 { + t.Fatalf("expected 3 frames, got %d", len(tp.Frames)) + } + replay, snap := ReplayTape(tp) + // "line one\n" (9) + "\x1b[32mgreen\x1b[0m\n" (15). + if replay.Stdout != 24 { + t.Fatalf("stdout bytes=%d, want 24", replay.Stdout) + } + if len(replay.Markers) != 1 || replay.Markers[0] != "start" { + t.Fatalf("markers=%v", replay.Markers) + } + // SGR codes are stripped, leaving "green". + want := "line one\ngreen" + if snap != want { + t.Fatalf("snapshot:\n%q\nwant:\n%q", snap, want) + } +} + +// Resize frames are honored during replay. +func TestReplayHonorsResize(t *testing.T) { + w, buf, clk := newTestWriter(t) + clk.t += 1 + _ = w.RecordResize(40, 10) + _ = w.Close() + tp, _ := Parse(buf.Bytes()) + r, _ := ReplayTape(tp) + if r.Grid.Cols != 40 || r.Grid.Rows != 10 { + t.Fatalf("grid not resized: %dx%d", r.Grid.Cols, r.Grid.Rows) + } +} + +// Unknown kinds and malformed resize payloads are tolerated, not fatal. +func TestParseToleratesUnknownAndMalformed(t *testing.T) { + var buf bytes.Buffer + // magic(5) + cols(2) + rows(2) + epoch(8) + vlen(1)=18 bytes, version empty. + _ = binary.Write(&buf, binary.LittleEndian, []byte{'F', 'X', 'T', 'P', 0x01, 80, 0, 24, 0, 0x01, 0, 0, 0, 0, 0, 0, 0, 0}) + // unknown kind 200 with payload "x"; resize with bad (1-byte) payload. + buf.Write(appendFrameBytes(nil, int32(0), 200, []byte("x"))) + buf.Write(appendFrameBytes(nil, int32(0), byte(KindResize), []byte("x"))) + + tp, err := Parse(buf.Bytes()) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(tp.Frames) != 2 { + t.Fatalf("expected 2 frames, got %d", len(tp.Frames)) + } + if tp.Frames[0].Kind != Kind(200) { + t.Fatalf("kind=%d", tp.Frames[0].Kind) + } + // Replay must not panic on the malformed resize. + _, _ = ReplayTape(tp) +} + +func TestParseRejectsBadMagicAndTruncation(t *testing.T) { + if _, err := Parse([]byte("NOTA\x01garbage")); err == nil { + t.Fatal("bad magic must error") + } + if _, err := Parse([]byte("FXTP")); err == nil { + t.Fatal("too-short tape must error") + } + // Valid header then a truncated frame header. + var buf bytes.Buffer + buf.Write([]byte{'F', 'X', 'T', 'P', 0x01, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}) + buf.Write([]byte{1, 2, 3}) + if _, err := Parse(buf.Bytes()); err == nil { + t.Fatal("truncated frame header must error") + } +} diff --git a/internal/testaudit/emoji_audit_test.go b/internal/testaudit/emoji_audit_test.go index a5a90fab..f33992b3 100644 --- a/internal/testaudit/emoji_audit_test.go +++ b/internal/testaudit/emoji_audit_test.go @@ -45,6 +45,11 @@ var emojiAuditPathExempt = []string{ // as fixture data for parser tests; the emoji are part of the // captured external tool output, not hawk's own rendering. "/internal/tool/test_fixtures.go", + // framesdir.go matches the literal "❯" prompt glyph inside replayed fx + // snapshots to classify input rows when exporting frame artifacts. The + // glyph is fx's own prompt character embedded in captured output, not + // hawk's rendering, so it is recognised rather than produced. + "/internal/terminal/tape/", } // isEmojiOrDingbat reports whether r is a glyph that should never appear diff --git a/internal/trace/file.go b/internal/trace/file.go new file mode 100644 index 00000000..69047ee3 --- /dev/null +++ b/internal/trace/file.go @@ -0,0 +1,108 @@ +package trace + +import ( + "crypto/rand" + "encoding/hex" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +// defaultTMPDir mirrors fx's `getenv("TMPDIR") orelse "/tmp"`. +func defaultTMPDir() string { + if d := os.Getenv("TMPDIR"); d != "" { + return d + } + return "/tmp" +} + +// fileNameSuffix returns `2026-08-21-120400-1a2b3c`. +func fileNameSuffix(ts time.Time, randomHex string) string { + return ts.Format("2006-01-02-150405") + "-" + randomHex +} + +// WriteReportToPath renders the snapshot and writes it to path with 0o600 +// mode. The parent directory must already exist. +func WriteReportToPath(path string, s *Snapshot) error { + if s == nil { + s = &Snapshot{} + } + contents := Build(s) + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600) + if err != nil { + return fmt.Errorf("create trace file: %w", err) + } + if _, err := f.WriteString(contents); err != nil { + _ = f.Close() + _ = os.Remove(path) + return fmt.Errorf("write trace file: %w", err) + } + if err := f.Close(); err != nil { + return fmt.Errorf("close trace file: %w", err) + } + return nil +} + +// WriteReportFile renders the snapshot and writes it to a private file in the +// temp directory with an exclusive-create name, mirroring fx's +// writeTraceReportFile (0o600 mode, up to 8 collision attempts, random suffix). +// It returns the absolute path of the written file. +func WriteReportFile(s *Snapshot) (string, error) { + if s == nil { + s = &Snapshot{} + } + contents := Build(s) + dir := strings.TrimRight(defaultTMPDir(), "/") + now := time.Now() + for attempt := 0; attempt < 8; attempt++ { + hexBytes := randomHex(3) // 6 hex chars + name := "hawk-trace-" + fileNameSuffix(now, hexBytes) + ".md" + path := filepath.Join(dir, name) + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + if os.IsExist(err) { + continue + } + return "", fmt.Errorf("create trace file: %w", err) + } + _, werr := f.WriteString(contents) + cerr := f.Close() + if werr != nil { + _ = os.Remove(path) + return "", fmt.Errorf("write trace file: %w", werr) + } + if cerr != nil { + return "", fmt.Errorf("close trace file: %w", cerr) + } + return path, nil + } + return "", fmt.Errorf("could not allocate a unique trace file name") +} + +// TryClipboard attempts to copy text to the system clipboard on macOS via +// pbcopy. It returns false when unsupported or the copy fails, matching fx's +// branch that reports "Clipboard copy failed." +func TryClipboard(text string) bool { + if !isDarwin() { + return false + } + cmd := exec.Command("pbcopy") + cmd.Stdin = strings.NewReader(text) + if err := cmd.Run(); err != nil { + return false + } + return true +} + +func isDarwin() bool { + return os.Getenv("TRACE_FORCE_DARWIN") == "1" +} + +func randomHex(nBytes int) string { + b := make([]byte, nBytes) + _, _ = rand.Read(b) + return hex.EncodeToString(b) +} diff --git a/internal/trace/file_test.go b/internal/trace/file_test.go new file mode 100644 index 00000000..9f3be26b --- /dev/null +++ b/internal/trace/file_test.go @@ -0,0 +1,73 @@ +package trace + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestWriteReportToPathPrivate(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "report.md") + if err := WriteReportToPath(path, sampleSnapshot()); err != nil { + t.Fatalf("WriteReportToPath: %v", err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat: %v", err) + } + if info.Mode().Perm() != 0o600 { + t.Errorf("mode = %o, want 600", info.Mode().Perm()) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read: %v", err) + } + if !strings.Contains(string(data), "# hawk trace") { + t.Errorf("report content missing header") + } +} + +func TestWriteReportFileUniqueness(t *testing.T) { + dir := t.TempDir() + t.Setenv("TMPDIR", dir) + p1, err := WriteReportFile(sampleSnapshot()) + if err != nil { + t.Fatalf("WriteReportFile: %v", err) + } + p2, err := WriteReportFile(sampleSnapshot()) + if err != nil { + t.Fatalf("WriteReportFile: %v", err) + } + if p1 == p2 { + t.Errorf("expected unique file names, got %q twice", p1) + } + if filepath.Dir(p1) != dir { + t.Errorf("report %q not written to TMPDIR %q", p1, dir) + } + for _, p := range []string{p1, p2} { + if !strings.HasPrefix(filepath.Base(p), "hawk-trace-") { + t.Errorf("unexpected file name %q", p) + } + if !strings.HasSuffix(p, ".md") { + t.Errorf("unexpected extension %q", p) + } + info, err := os.Stat(p) + if err != nil { + t.Fatalf("stat %s: %v", p, err) + } + if info.Mode().Perm() != 0o600 { + t.Errorf("mode = %o, want 600", info.Mode().Perm()) + } + } +} + +func TestFileNameSuffixFormat(t *testing.T) { + t.Parallel() + s := fileNameSuffix(sampleSnapshot().Timestamp, "abc123") + if s != "2026-08-21-120000-abc123" { + t.Errorf("suffix = %q", s) + } +} diff --git a/internal/trace/redact.go b/internal/trace/redact.go new file mode 100644 index 00000000..990cb573 --- /dev/null +++ b/internal/trace/redact.go @@ -0,0 +1,88 @@ +// Package trace builds a private diagnostic report (ported from fx's `/trace` +// slash command): a one-command snapshot of session context, logs, permissions, +// and recent activity rendered as a redactable markdown document. +package trace + +import ( + "regexp" + "strings" +) + +// maskSecrets redacts "obvious" secrets from a line: bearer tokens, common +// secret-bearing key=value or JSON pairs, known token prefixes, and long +// hex/base64 runs. It is deliberately conservative — over-redaction is fine +// for a private diagnostic that will be shared after a human review. +func maskSecrets(s string) string { + out := s + for _, rule := range secretRules { + out = rule.re.ReplaceAllString(out, rule.repl) + } + return out +} + +type secretRule struct { + re *regexp.Regexp + repl string +} + +var secretRules = []secretRule{ + // "Bearer " + {regexp.MustCompile(`(?i)\b(bearer\s+)[A-Za-z0-9._~+/=-]{12,}`), `${1}*****`}, + // "token"/"secret"/"password"/"api_key"/"auth" =/ + {regexp.MustCompile(`(?i)("?(?:api[_-]?key|secret|password|passwd|token|access[_-]?token|auth[_-]?key|auth)["' ]*\s*[:=]\s*["']?)[A-Za-z0-9._~+/=:-]{8,}`), `${1}*****`}, + // Well-known token/credential prefixes. + {regexp.MustCompile(`(?i)\b(sk-ant-[A-Za-z0-9_-]{8,}|sk-[A-Za-z0-9_-]{8,}|xox[bap]-[A-Za-z0-9-]{8,}|ghp_[A-Za-z0-9]{8,}|github_pat_[A-Za-z0-9_]{8,}|glpat-[A-Za-z0-9_-]{8,}|AKIA[0-9A-Z]{16}|ai-[A-Za-z0-9_-]{8,})`), `*****`}, + // Long hex runs (>= 40 chars) — likely key material. + {regexp.MustCompile(`\b[A-Fa-f0-9]{40,}\b`), `*****`}, + // Long base64 runs (>= 48 chars). + {regexp.MustCompile(`\b[A-Za-z0-9+/]{48,}\b`), `*****`}, +} + +// stripANSI removes ANSI escape sequences (SGR/CSI/OSC) from a string, leaving +// only the visible text. Mirrors fx renderTimelineEntryBody's ANSI stripping. +func stripANSI(s string) string { + var b strings.Builder + b.Grow(len(s)) + i := 0 + for i < len(s) { + c := s[i] + if c == 0x1b { // ESC + if i+1 < len(s) { + switch s[i+1] { + case '[': // CSI + i += 2 + for i < len(s) { + cc := s[i] + i++ + if cc >= 0x40 && cc <= 0x7e { // final byte + break + } + } + continue + case ']': // OSC — skip to BEL or ST + i += 2 + for i < len(s) { + if s[i] == 0x07 { + i++ + break + } + if i+1 < len(s) && s[i] == 0x1b && s[i+1] == '\\' { + i += 2 + break + } + i++ + } + continue + default: // standalone ESC — drop it + i++ + continue + } + } + i++ + continue + } + b.WriteByte(c) + i++ + } + return b.String() +} diff --git a/internal/trace/redact_test.go b/internal/trace/redact_test.go new file mode 100644 index 00000000..7e071eb4 --- /dev/null +++ b/internal/trace/redact_test.go @@ -0,0 +1,57 @@ +package trace + +import ( + "testing" +) + +func TestMaskSecrets(t *testing.T) { + t.Parallel() + cases := []struct { + name, in, want string + }{ + {"bearer token", "Authorization: Bearer abc123XYZ._-def456 !", "Authorization: Bearer ***** !"}, + {"sk- prefixed", "key sk-ant-api03-e1f3abcdefGHI", "key *****"}, + {"github token", "token ghp_abcdef12345678", "token *****"}, + {"akid", "AKIAIOSFODNN7EXAMPLE", "*****"}, + {"json pair", `{"api_key": "super-secret-value"}`, `{"api_key": "*****"}`}, + {"assignment", "API_SECRET=abcdef12345678", "API_SECRET=*****"}, + {"long hex", "sha 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", "sha *****"}, + {"plain text untouched", "git status --short", "git status --short"}, + {"short run untouched", "key abc", "key abc"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := maskSecrets(c.in) + if got != c.want { + t.Errorf("maskSecrets(%q) = %q, want %q", c.in, got, c.want) + } + }) + } +} + +func TestStripANSI(t *testing.T) { + t.Parallel() + cases := []struct{ in, want string }{ + {"\x1b[31mred\x1b[0m", "red"}, + {"\x1b[1;3mBold Italic\x1b[0m", "Bold Italic"}, + {"plain", "plain"}, + {"\x1b]0;title\x07visible", "visible"}, + {"\x1b[38;2;255;0;0mcolored", "colored"}, + } + for _, c := range cases { + got := stripANSI(c.in) + if got != c.want { + t.Errorf("stripANSI(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +func TestMaskSecretsIdempotent(t *testing.T) { + t.Parallel() + in := "token ghp_abcdef12345678 and sk-ant-api03-e1f3abc" + once := maskSecrets(in) + twice := maskSecrets(once) + if once != twice { + t.Errorf("maskSecrets not idempotent: %q -> %q -> %q", in, once, twice) + } +} diff --git a/internal/trace/report.go b/internal/trace/report.go new file mode 100644 index 00000000..da46dba8 --- /dev/null +++ b/internal/trace/report.go @@ -0,0 +1,238 @@ +package trace + +import ( + "fmt" + "sort" + "strings" + "time" +) + +// StableRule is the rendered form of a persisted exact permission rule. +type StableRule struct { + ID uint64 + Kind string + Identity string + Decision string + // Sensitive is set when the identity is a path/command that should be + // masked before this rule is shared. + Sensitive bool +} + +// Activity is one recent tool/command event in the session's Recent Activity. +type Activity struct { + Timestamp time.Time + Kind string // e.g. "tool", "command", "edit", "read" + Name string + OK bool + Duration time.Duration +} + +// LogEntry is one line of a log/transcript tail. +type LogEntry struct { + Line string + Sensitive bool +} + +const ( + // MaxLogTailLines caps the number of log lines rendered in the tail. + MaxLogTailLines = 80 + // MaxLogTailBytes is the number of trailing bytes read from a log file. + MaxLogTailBytes = 6 * 1024 + // MaxLineBytes caps any single rendered line. + MaxLineBytes = 300 +) + +// Snapshot captures the hawk diagnostic context rendered by Build. It is +// deliberately engine-agnostic: callers (engine integration or the CLI) fill +// it from their live state, and Build renders it purely. +type Snapshot struct { + Timestamp time.Time + Version string + GitCommit string + Build string + Platform string // os/arch + Model string + FastMode bool + PermissionMode string + Sandbox string + Workspace string + + SessionID string + SessionDir string + PID int + Terminal string // "colsxrows" + Env []string + + StableRules []StableRule + PermissionGrants []string + + Activity []Activity + LogTail []LogEntry + Transcript []LogEntry +} + +// Build renders the snapshot as the hawk trace diagnostic markdown, mirroring +// fx's `/trace` report structure. Every potentially secret field is masked. +func Build(s *Snapshot) string { + if s == nil { + s = &Snapshot{} + } + var b strings.Builder + b.Reset() + + b.WriteString("# hawk trace\n\n") + b.WriteString("Private diagnostic report. It may include prompts, file paths, command output, and file snippets.\n") + + writeSummary(&b, s) + writeCurrentState(&b, s) + writePermissions(&b, s) + writeRecentActivity(&b, s) + writeLogTail(&b, s) + writeTranscript(&b, s) + + return b.String() +} + +func writeSummary(b *strings.Builder, s *Snapshot) { + b.WriteString("\n## Summary\n") + ts := s.Timestamp + if ts.IsZero() { + ts = time.Now() + } + b.WriteString("generated: " + ts.UTC().Format("2006-01-02T15:04:05Z") + "\n") + ver := s.Version + if ver == "" { + ver = "dev" + } + b.WriteString("version: " + ver) + if s.GitCommit != "" { + b.WriteString(" (" + s.GitCommit + ")") + } + b.WriteString("\n") + b.WriteString("platform: " + s.Platform + "\n") + if s.Build != "" { + b.WriteString("build: " + s.Build + "\n") + } + if s.Model != "" { + b.WriteString("model: " + s.Model + "\n") + } + if s.FastMode { + b.WriteString("fast_mode: on\n") + } + pm := s.PermissionMode + if pm == "" { + pm = "default" + } + b.WriteString("permission_mode: " + pm + "\n") + sandbox := s.Sandbox + if sandbox == "" { + sandbox = "default" + } + b.WriteString("sandbox: " + sandbox + "\n") + b.WriteString("workspace: " + s.Workspace + "\n") +} + +func writeCurrentState(b *strings.Builder, s *Snapshot) { + b.WriteString("\n## Current State\n") + if s.SessionID != "" { + b.WriteString("session_id: " + s.SessionID + "\n") + } + if s.SessionDir != "" { + b.WriteString("session_dir: " + s.SessionDir + "\n") + } + b.WriteString("process: pid=" + fmt.Sprint(s.PID) + "\n") + if s.Terminal != "" { + b.WriteString("terminal: " + s.Terminal + "\n") + } + if len(s.Env) > 0 { + b.WriteString("env:\n") + for _, kv := range s.Env { + b.WriteString(" " + maskSecrets(kv) + "\n") + } + } +} + +func writePermissions(b *strings.Builder, s *Snapshot) { + b.WriteString("\n## Permissions\n") + if len(s.StableRules) == 0 && len(s.PermissionGrants) == 0 { + b.WriteString("(none)\n") + return + } + if len(s.StableRules) > 0 { + rules := append([]StableRule(nil), s.StableRules...) + sort.Slice(rules, func(i, j int) bool { return rules[i].ID < rules[j].ID }) + b.WriteString(fmt.Sprintf("stable_rules (%d):\n", len(rules))) + for _, r := range rules { + id := r.Identity + if r.Sensitive { + id = maskSecrets(id) + } + b.WriteString(fmt.Sprintf(" - id=%d kind=%s decision=%s identity=%s\n", + r.ID, r.Kind, r.Decision, id)) + } + } + for _, g := range s.PermissionGrants { + b.WriteString(" grant: " + maskSecrets(g) + "\n") + } +} + +func writeRecentActivity(b *strings.Builder, s *Snapshot) { + b.WriteString("\n## Recent Activity\n") + if len(s.Activity) == 0 { + b.WriteString("(none)\n") + return + } + for _, a := range s.Activity { + ts := "" + if !a.Timestamp.IsZero() { + ts = a.Timestamp.UTC().Format("15:04:05") + } + ok := "ok" + if !a.OK { + ok = "FAILED" + } + dur := "" + if a.Duration > 0 { + dur = " " + a.Duration.Round(time.Millisecond).String() + } + b.WriteString(fmt.Sprintf(" %s %s %s %s%s\n", ts, a.Kind, maskSecrets(a.Name), ok, dur)) + } +} + +func writeLogTail(b *strings.Builder, s *Snapshot) { + if len(s.LogTail) == 0 { + return + } + b.WriteString("\n## Logs\nonly obvious secrets masked\n") + if len(s.LogTail) > 0 { + b.WriteString(fmt.Sprintf("recent_lines (%d):\n", len(s.LogTail))) + } + for _, e := range s.LogTail { + line := e.Line + if e.Sensitive { + line = maskSecrets(line) + } + if len(line) > MaxLineBytes { + line = line[:MaxLineBytes] + " ..." + } + b.WriteString(" " + line + "\n") + } +} + +func writeTranscript(b *strings.Builder, s *Snapshot) { + if len(s.Transcript) == 0 { + return + } + b.WriteString("\n## Transcript\n") + for _, e := range s.Transcript { + line := e.Line + if e.Sensitive { + line = maskSecrets(line) + } + line = stripANSI(line) + if len(line) > MaxLineBytes { + line = line[:MaxLineBytes] + " ..." + } + b.WriteString(" " + line + "\n") + } +} diff --git a/internal/trace/report_test.go b/internal/trace/report_test.go new file mode 100644 index 00000000..b3d10af8 --- /dev/null +++ b/internal/trace/report_test.go @@ -0,0 +1,110 @@ +package trace + +import ( + "strings" + "testing" + "time" +) + +func sampleSnapshot() *Snapshot { + return &Snapshot{ + Timestamp: time.Date(2026, 8, 21, 12, 0, 0, 0, time.UTC), + Version: "v1.0.0", + GitCommit: "abc1234", + Build: "release", + Platform: "darwin/arm64", + Model: "claude-sonnet", + PermissionMode: "default", + Sandbox: "default", + Workspace: "/Users/me/repo", + SessionID: "sess-01", + SessionDir: "/tmp/hawk-sess-01", + PID: 4242, + Terminal: "120x30", + Env: []string{"HOME=/Users/me", "HOST=box", "API_TOKEN=sk-ant-abcdefABCDEF1234"}, + StableRules: []StableRule{ + {ID: 1, Kind: "command", Identity: "ls -la", Decision: "allow"}, + {ID: 2, Kind: "structured_tool", Identity: "/secrets/key=sk-abcdef123456", Decision: "deny", Sensitive: true}, + }, + PermissionGrants: []string{"shell:allow:.*"}, + Activity: []Activity{ + {Timestamp: time.Date(2026, 8, 21, 11, 59, 30, 0, time.UTC), Kind: "tool", Name: "read internal/x.go", OK: true, Duration: 12 * time.Millisecond}, + }, + LogTail: []LogEntry{ + {Line: "token=sk-ant-api03-abcdefGHIJKL", Sensitive: true}, + }, + Transcript: []LogEntry{ + {Line: "\x1b[32muser\x1b[0m: hello", Sensitive: true}, + }, + } +} + +func TestBuildSections(t *testing.T) { + t.Parallel() + out := Build(sampleSnapshot()) + for _, want := range []string{ + "# hawk trace", + "Private diagnostic report.", + "## Summary", + "## Current State", + "## Permissions", + "## Recent Activity", + "## Logs", + "## Transcript", + "generated: 2026-08-21T12:00:00Z", + "version: v1.0.0 (abc1234)", + "platform: darwin/arm64", + "workspace: /Users/me/repo", + "process: pid=4242", + "terminal: 120x30", + } { + if !strings.Contains(out, want) { + t.Errorf("Build output missing %q\n%s", want, out) + } + } + if strings.Contains(out, "sk-ant-abcdefABCDEF1234") { + t.Errorf("env secret leaked into output:\n%s", out) + } + if strings.Contains(out, "sk-abcdef123456") { + t.Errorf("sensitive stable-rule identity leaked:\n%s", out) + } + if strings.Contains(out, "sk-ant-api03-abcdefGHIJKL") { + t.Errorf("log secret leaked:\n%s", out) + } +} + +func TestBuildEmpty(t *testing.T) { + t.Parallel() + out := Build(nil) + if !strings.HasPrefix(out, "# hawk trace") { + t.Errorf("Build(nil) header missing") + } +} + +func TestBuildStripsANSIIntranscript(t *testing.T) { + t.Parallel() + s := sampleSnapshot() + s.Transcript = []LogEntry{{Line: "\x1b[31mred\x1b[0m text", Sensitive: true}} + out := Build(s) + if strings.Contains(out, "\x1b[") { + t.Errorf("ANSI escapes leaked into transcript output:\n%q", out) + } + if !strings.Contains(out, "red text") { + t.Errorf("visible transcript text missing") + } +} + +func TestBuildCapsLogLineLength(t *testing.T) { + t.Parallel() + s := sampleSnapshot() + s.Transcript = []LogEntry{{Line: strings.Repeat("x", MaxLineBytes+50)}} + out := Build(s) + // find the transcript line + idx := strings.Index(out, " "+strings.Repeat("x", MaxLineBytes)) + if idx < 0 { + t.Fatalf("expected capped long line") + } + if !strings.HasSuffix(out[:strings.Index(out[idx:], "\n")+idx], " ...") { + t.Errorf("long line not truncated with ellipsis") + } +} diff --git a/internal/usage/ledger.go b/internal/usage/ledger.go new file mode 100644 index 00000000..f59cb2b2 --- /dev/null +++ b/internal/usage/ledger.go @@ -0,0 +1,215 @@ +// Package usage provides an append-only, on-disk ledger of per-generation LLM +// usage, mirroring fx's `~/.fx/usage.jsonl` format. Sessions record one line +// per model generation; the `usage` command summarizes the ledger over a +// rolling window. +package usage + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/GrayCodeAI/hawk/internal/storage" +) + +// SchemaVersion is the on-disk ledger format version. +const SchemaVersion = 1 + +// Record is a single model generation's token and spend figure. +type Record struct { + ID string `json:"id,omitempty"` + CreatedAtMS int64 `json:"created_at_ms"` + Model string `json:"model"` + Provider string `json:"provider,omitempty"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + CacheReadTokens int `json:"cache_read_tokens,omitempty"` + CacheWriteTokens int `json:"cache_write_tokens,omitempty"` + ReasoningTokens int `json:"reasoning_tokens,omitempty"` + BillableWebSearchCalls int `json:"billable_web_search_calls,omitempty"` + TotalCost float64 `json:"total_cost"` +} + +// LedgerPath returns the on-disk location of the usage ledger. +func LedgerPath() string { + return filepath.Join(storage.StateDir(), "usage", "usage.jsonl") +} + +// coverageMarker is written once, at the head of a fresh ledger, to mark the +// tracking window start (fx parity). +const coverageMarker = `{"schema_version":1,"kind":"coverage","started_at_ms":` + +// Append records one generation to the default ledger, creating it (with the +// coverage marker) if absent. It is safe for concurrent callers. +func Append(r Record) error { + return appendTo(LedgerPath(), r) +} + +// appendTo writes a generation record to an explicit ledger path. +func appendTo(path string, r Record) error { + if r.CreatedAtMS == 0 { + r.CreatedAtMS = time.Now().UnixMilli() + } + line, err := json.Marshal(struct { + SchemaVersion int `json:"schema_version"` + Kind string `json:"kind"` + Fact Record `json:"fact"` + }{SchemaVersion, "generation", r}) + if err != nil { + return fmt.Errorf("usage: marshal record: %w", err) + } + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o750); err != nil { + return fmt.Errorf("usage: mkdir: %w", err) + } + + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return fmt.Errorf("usage: open ledger: %w", err) + } + defer func() { _ = f.Close() }() + + if fi, err := f.Stat(); err == nil && fi.Size() == 0 { + if _, err := f.WriteString(coverageMarker + fmt.Sprintf("%d}", r.CreatedAtMS) + "\n"); err != nil { + return fmt.Errorf("usage: write coverage marker: %w", err) + } + } + if _, err := f.Write(append(line, '\n')); err != nil { + return fmt.Errorf("usage: append record: %w", err) + } + return nil +} + +// Read loads every generation record from the default ledger, skipping the +// coverage marker. A missing ledger yields an empty slice, not an error. +func Read() ([]Record, error) { + return ReadFrom(LedgerPath()) +} + +// ReadFrom loads generation records from an explicit ledger path. +func ReadFrom(path string) ([]Record, error) { + f, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return []Record{}, nil + } + return nil, fmt.Errorf("usage: open ledger: %w", err) + } + defer func() { _ = f.Close() }() + + out := make([]Record, 0) + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.Contains(line, `"kind":"coverage"`) { + continue + } + var wrapped struct { + Fact Record `json:"fact"` + } + if err := json.Unmarshal([]byte(line), &wrapped); err != nil { + // Tolerate a malformed line (e.g. interrupted write) rather than + // failing the whole report. + continue + } + out = append(out, wrapped.Fact) + } + if err := sc.Err(); err != nil { + return nil, fmt.Errorf("usage: read ledger: %w", err) + } + return out, nil +} + +// ModelUsage aggregates one model across the window. +type ModelUsage struct { + Model string `json:"model"` + Generations int `json:"generations"` + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + CacheReadTokens int `json:"cache_read_tokens"` + CacheWriteTokens int `json:"cache_write_tokens"` + ReasoningTokens int `json:"reasoning_tokens"` + TotalTokens int `json:"total_tokens"` + TotalCostUSD float64 `json:"total_cost_usd"` +} + +// Summary is the full report for a window. +type Summary struct { + Generations int `json:"generations"` + SinceMS int64 `json:"since_ms"` + UntilMS int64 `json:"until_ms"` + ByModel []ModelUsage `json:"by_model"` + TotalTokens int `json:"total_tokens"` + TotalCostUSD float64 `json:"total_cost_usd"` +} + +// Summarize aggregates records whose CreatedAtMS falls within [now-dur, now]. +// Records at or after `since` (unix ms) are included. +func Summarize(records []Record, since int64) Summary { + byModel := map[string]*ModelUsage{} + order := []string{} + totalTokens, totalCost := 0, 0.0 + + for _, r := range records { + if since > 0 && r.CreatedAtMS < since { + continue + } + m, ok := byModel[r.Model] + if !ok { + m = &ModelUsage{Model: r.Model} + byModel[r.Model] = m + order = append(order, r.Model) + } + m.Generations++ + m.InputTokens += r.InputTokens + m.OutputTokens += r.OutputTokens + m.CacheReadTokens += r.CacheReadTokens + m.CacheWriteTokens += r.CacheWriteTokens + m.ReasoningTokens += r.ReasoningTokens + m.TotalTokens += r.InputTokens + r.OutputTokens + r.CacheReadTokens + r.CacheWriteTokens + r.ReasoningTokens + m.TotalCostUSD += r.TotalCost + totalTokens += r.InputTokens + r.OutputTokens + r.CacheReadTokens + r.CacheWriteTokens + r.ReasoningTokens + totalCost += r.TotalCost + } + + sum := Summary{Generations: len(recordsFor(records, since)), ByModel: make([]ModelUsage, 0, len(order)), TotalTokens: totalTokens, TotalCostUSD: totalCost} + sort.Slice(order, func(i, j int) bool { return order[i] < order[j] }) + for _, name := range order { + sum.ByModel = append(sum.ByModel, *byModel[name]) + } + return sum +} + +func recordsFor(records []Record, since int64) []Record { + var out []Record + for _, r := range records { + if since == 0 || r.CreatedAtMS >= since { + out = append(out, r) + } + } + return out +} + +// ParsePeriod converts an fx-style period flag ("24h", "7d", "30d") into a +// duration and the unix-ms start of the window. +func ParsePeriod(s string) (sinceMS int64, d time.Duration, err error) { + switch strings.TrimSpace(s) { + case "", "24h": + d = 24 * time.Hour + case "7d": + d = 7 * 24 * time.Hour + case "30d": + d = 30 * 24 * time.Hour + default: + return 0, 0, fmt.Errorf("usage: invalid period %q (want 24h, 7d, or 30d)", s) + } + now := time.Now() + return now.Add(-d).UnixMilli(), d, nil +} diff --git a/internal/usage/ledger_test.go b/internal/usage/ledger_test.go new file mode 100644 index 00000000..2a440eb9 --- /dev/null +++ b/internal/usage/ledger_test.go @@ -0,0 +1,119 @@ +package usage + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestAppendRoundTrip(t *testing.T) { + path := filepath.Join(t.TempDir(), "usage.jsonl") + rec := Record{ + CreatedAtMS: 1700000000000, + Model: "acme/m1", + Provider: "acme", + InputTokens: 100, + OutputTokens: 20, + TotalCost: 0.0012, + } + if err := appendTo(path, rec); err != nil { + t.Fatalf("appendTo: %v", err) + } + if err := appendTo(path, Record{CreatedAtMS: 1700000001000, Model: "acme/m1", InputTokens: 5, OutputTokens: 1, TotalCost: 0.0001}); err != nil { + t.Fatalf("appendTo #2: %v", err) + } + + got, err := ReadFrom(path) + if err != nil { + t.Fatalf("ReadFrom: %v", err) + } + if len(got) != 2 { + t.Fatalf("ReadFrom returned %d records, want 2", len(got)) + } + if got[0].Model != "acme/m1" || got[0].InputTokens != 100 || got[0].TotalCost != 0.0012 { + t.Errorf("record 0 = %+v, want model acme/m1, in=100, cost=0.0012", got[0]) + } + if got[1].OutputTokens != 1 { + t.Errorf("record 1 out = %d, want 1", got[1].OutputTokens) + } +} + +func TestReadFromToleratesMalformedLine(t *testing.T) { + path := filepath.Join(t.TempDir(), "usage.jsonl") + content := `{"schema_version":1,"kind":"coverage","started_at_ms":1700000000000} +{"schema_version":1,"kind":"generation","fact":{"created_at_ms":1700000000000,"model":"m","input_tokens":1,"output_tokens":0,"total_cost":0}} +{"schema_version":1,"kind":"generation","fact":{"created_at_ms":1700000000 +` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write fixture: %v", err) + } + got, err := ReadFrom(path) + if err != nil { + t.Fatalf("ReadFrom: %v", err) + } + if len(got) != 1 || got[0].Model != "m" { + t.Fatalf("ReadFrom = %+v, want 1 good record", got) + } +} + +func TestMissingLedgerIsEmpty(t *testing.T) { + got, err := ReadFrom(filepath.Join(t.TempDir(), "nope.jsonl")) + if err != nil { + t.Fatalf("ReadFrom missing: %v", err) + } + if got == nil || len(got) != 0 { + t.Fatalf("ReadFrom missing = %v, want empty non-nil", got) + } +} + +func TestSummarize(t *testing.T) { + const hourMS = int64(60 * 60 * 1000) + const dayMS = 24 * hourMS + now := time.Now().UnixMilli() + records := []Record{ + {CreatedAtMS: now - 2*hourMS, Model: "a/m", InputTokens: 100, OutputTokens: 10, TotalCost: 0.01}, + {CreatedAtMS: now - 2*hourMS, Model: "a/m", InputTokens: 50, OutputTokens: 5, CacheReadTokens: 20, TotalCost: 0.005}, + {CreatedAtMS: now - 10*dayMS, Model: "b/m", InputTokens: 999, OutputTokens: 999, TotalCost: 9.0}, + } + + sum := Summarize(records, now-7*dayMS) + if sum.Generations != 2 { + t.Fatalf("Generations = %d, want 2 (b/m excluded)", sum.Generations) + } + if len(sum.ByModel) != 1 || sum.ByModel[0].Model != "a/m" { + t.Fatalf("ByModel = %+v, want only a/m", sum.ByModel) + } + m := sum.ByModel[0] + if m.InputTokens != 150 || m.OutputTokens != 15 || m.CacheReadTokens != 20 || m.Generations != 2 { + t.Errorf("a/m usage = %+v, want in=150 out=15 cache_read=20 gens=2", m) + } + if m.TotalTokens != 150+15+20 { + t.Errorf("TotalTokens = %d, want %d", m.TotalTokens, 150+15+20) + } + if sum.TotalCostUSD != 0.015 { + t.Errorf("TotalCostUSD = %v, want 0.015", sum.TotalCostUSD) + } +} + +func TestParsePeriod(t *testing.T) { + for _, p := range []string{"24h", "7d", "30d", ""} { + since, d, err := ParsePeriod(p) + if err != nil { + t.Errorf("ParsePeriod(%q) unexpected error %v", p, err) + continue + } + if d <= 0 { + t.Errorf("ParsePeriod(%q) d = %v, want >0", p, d) + } + if since > time.Now().UnixMilli() { + t.Errorf("ParsePeriod(%q) since %d in the future", p, since) + } + } + if _, _, err := ParsePeriod("1d"); err == nil { + t.Error("ParsePeriod(\"1d\") = nil error, want error") + } + if _, _, err := ParsePeriod("1y"); err == nil { + t.Error("ParsePeriod(\"1y\") = nil error, want error") + } +} diff --git a/testdata/golden/help_root.txt b/testdata/golden/help_root.txt index fb4e1da3..0dc5d75a 100644 --- a/testdata/golden/help_root.txt +++ b/testdata/golden/help_root.txt @@ -27,64 +27,69 @@ Examples: hawk path Available Commands: - acp Run hawk as an Agent Client Protocol (ACP) server - agent Manage custom agent personas - attach Attach to a running background session - audit Analyze past sessions for wasteful patterns - bg Run a session in the background - bug-report Print a redacted diagnostic report for bug reports - checkpoint Save and restore named session checkpoints - cloud Manage optional Hawk Cloud synchronization - completion Generate shell completion script - config Show or update settings - context Export project context as a single document for use in any LLM - cost [Experimental] Analyze and optimize LLM API spend - credentials Manage secure API key storage (macOS Keychain / Linux secret service) - daemon Manage the hawk background server - doctor Run local diagnostics - ecosystem Show eyrie, yaad, and tok integration status - eval Evaluate model performance on coding benchmarks - exec Execute a single command non-interactively - features List and manage feature flags - feedback Submit feedback about hawk - fingerprint Generate a repository fingerprint (languages, deps, git info) - governance Inspect and validate the governance policy ceiling - graph Inspect Hawk's portable execution graph - harness Audit workspace AI agent harness, work loop dimensions, and generation reports - help Help about any command - history Search and browse command history - init Interactive onboarding wizard for first-time setup - learn Manage lessons learned across sessions - manpage Generate man page in roff format - mcp Show MCP configuration; run or register hawk as an MCP server - mission Run a multi-agent mission (parallel feature execution) - models Deployment-aware model catalog (via eyrie) - path Developer path readiness (setup, security, sandbox, ecosystem) - plan Create and manage structured development plans - plugin Manage plugins - pr AI-powered pull request workflow - preflight Check local readiness; use --live to verify the selected provider - recover Scan for interrupted sessions and resume - research Autonomous research loop (Karpathy autoresearch pattern) - resume Restore a named session checkpoint and resume it - review Continuous AI code review on commits - rules Detect, import, and export AI coding rules between tool formats - sandbox View, apply, or discard pending diff sandbox changes - schema Output JSON schema for hawk settings.json - search Search across saved sessions - securitylog Inspect the tamper-evident security event log - sessions List saved sessions - setup Run first-time setup again - skills Manage skills (list, search, install, remove, audit, info, trending) - snapshot Manage file snapshots (undo any change) - stats Show usage statistics and cost analytics - taste Manage taste profile (learned coding style preferences) - tools List built-in tools - trace Git-native session capture for AI coding agents - trust Manage folder trust for project automation - update Check for hawk updates - verify Run local self-verification (security log, governance policy) - version Print hawk version + acp Run hawk as an Agent Client Protocol (ACP) server + agent Manage custom agent personas + attach Attach to a running background session + audit Analyze past sessions for wasteful patterns + bg Run a session in the background + bug-report Print a redacted diagnostic report for bug reports + checkpoint Save and restore named session checkpoints + cloud Manage optional Hawk Cloud synchronization + completion Generate shell completion script + config Show or update settings + context Export project context as a single document for use in any LLM + cost [Experimental] Analyze and optimize LLM API spend + credentials Manage secure API key storage (macOS Keychain / Linux secret service) + daemon Manage the hawk background server + doctor Run local diagnostics + ecosystem Show eyrie, yaad, and tok integration status + eval Evaluate model performance on coding benchmarks + exec Execute a single command non-interactively + features List and manage feature flags + feedback Submit feedback about hawk + fingerprint Generate a repository fingerprint (languages, deps, git info) + governance Inspect and validate the governance policy ceiling + graph Inspect Hawk's portable execution graph + harness Audit workspace AI agent harness, work loop dimensions, and generation reports + help Help about any command + history Search and browse command history + init Interactive onboarding wizard for first-time setup + issue Draft or publish a GitHub issue (fx issue parity) + learn Manage lessons learned across sessions + manpage Generate man page in roff format + mcp Show MCP configuration; run or register hawk as an MCP server + mission Run a multi-agent mission (parallel feature execution) + models Deployment-aware model catalog (via eyrie) + path Developer path readiness (setup, security, sandbox, ecosystem) + plan Create and manage structured development plans + plugin Manage plugins + pr AI-powered pull request workflow + preflight Check local readiness; use --live to verify the selected provider + recover Scan for interrupted sessions and resume + replay Replay a recorded terminal capture (fxtape) + research Autonomous research loop (Karpathy autoresearch pattern) + resume Restore a named session checkpoint and resume it + review Continuous AI code review on commits + rules Detect, import, and export AI coding rules between tool formats + sandbox View, apply, or discard pending diff sandbox changes + schema Output JSON schema for hawk settings.json + search Search across saved sessions + securitylog Inspect the tamper-evident security event log + sessions List saved sessions + setup Run first-time setup again + skills Manage skills (list, search, install, remove, audit, info, trending) + snapshot Manage file snapshots (undo any change) + stats Show usage statistics and cost analytics + tape Inspect and checkpoint recorded terminal captures (fxtape) + taste Manage taste profile (learned coding style preferences) + tools List built-in tools + trace Git-native session capture for AI coding agents + trace-report Write a private diagnostic trace report (fx /trace parity) + trust Manage folder trust for project automation + update Check for hawk updates + usage Show local LLM token usage and spend (fx usage parity) + verify Run local self-verification (security log, governance policy) + version Print hawk version Flags: --add-dir stringArray additional directories to include in session context @@ -115,6 +120,7 @@ Flags: --prompt string send a single prompt and exit (legacy alias for --print) --provider string LLM provider (anthropic, openai, gemini, etc.) -q, --quiet suppress non-essential output (spinners, progress, decoration); machine-parseable output only + --record string record interactive REPL output to an fxtape file (fx --record parity) --recover scan for interrupted sessions and offer to resume --refresh-catalog refresh the eyrie model catalog before starting --repl start interactive REPL mode (like aider) for multi-turn conversation without TUI