Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,10 +251,11 @@ Save a full session (metadata and the complete conversation) to a file with `dis
dispatch export 0a1b2c3d
dispatch export 0a1b2c3d --format json
dispatch export 0a1b2c3d --stdout
dispatch export 0a1b2c3d --redact --stdout
dispatch export 0a1b2c3d --out ./exports
```

By default the session is written as Markdown to the exports directory. Use `--format json` for machine-readable output, `--stdout` to print to the terminal instead of writing a file, and `--out <dir>` to choose the destination directory. `--stdout` and `--out` cannot be combined.
By default the session is written as Markdown to the exports directory. Use `--format json` for machine-readable output, `--stdout` to print to the terminal instead of writing a file, `--out <dir>` to choose the destination directory, and `--redact` to mask common secret patterns before writing. `--stdout` and `--out` cannot be combined.

### Search (JSON)

Expand Down
54 changes: 54 additions & 0 deletions cmd/dispatch/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"strings"

"github.com/jongio/dispatch/internal/data"
"github.com/jongio/dispatch/internal/platform"
)

// Function variables allow test substitution of external calls, matching the
Expand All @@ -27,6 +28,7 @@ type exportOptions struct {
format string // "md" or "json"
stdout bool
outDir string
redact bool
}

// runExport writes a session's full content as Markdown or JSON. It writes to
Expand All @@ -50,6 +52,10 @@ func runExport(w io.Writer, args []string) error {
return fmt.Errorf("session %q not found", opts.id)
}

if opts.redact {
detail = redactedSessionDetail(detail)
}

content, err := renderExport(detail, opts.format)
if err != nil {
return err
Expand Down Expand Up @@ -122,6 +128,8 @@ func parseExportArgs(args []string) (exportOptions, error) {
i = ni
case name == "--stdout":
opts.stdout = true
case name == "--redact":
opts.redact = true
case strings.HasPrefix(arg, "-"):
return exportOptions{}, fmt.Errorf("unknown flag: %s", arg)
default:
Expand Down Expand Up @@ -157,6 +165,52 @@ func normalizeExportFormat(format string) (string, error) {
}
}

func redactedSessionDetail(detail *data.SessionDetail) *data.SessionDetail {
if detail == nil {
return nil
}
redacted := *detail
redacted.Session = detail.Session
redacted.Session.Cwd = platform.RedactSecrets(redacted.Session.Cwd)
redacted.Session.Repository = platform.RedactSecrets(redacted.Session.Repository)
redacted.Session.Branch = platform.RedactSecrets(redacted.Session.Branch)
redacted.Session.Summary = platform.RedactSecrets(redacted.Session.Summary)

if len(detail.Turns) > 0 {
redacted.Turns = append([]data.Turn(nil), detail.Turns...)
for i := range redacted.Turns {
redacted.Turns[i].UserMessage = platform.RedactSecrets(redacted.Turns[i].UserMessage)
redacted.Turns[i].AssistantResponse = platform.RedactSecrets(redacted.Turns[i].AssistantResponse)
}
}
if len(detail.Checkpoints) > 0 {
redacted.Checkpoints = append([]data.Checkpoint(nil), detail.Checkpoints...)
for i := range redacted.Checkpoints {
redacted.Checkpoints[i].Title = platform.RedactSecrets(redacted.Checkpoints[i].Title)
redacted.Checkpoints[i].Overview = platform.RedactSecrets(redacted.Checkpoints[i].Overview)
redacted.Checkpoints[i].History = platform.RedactSecrets(redacted.Checkpoints[i].History)
redacted.Checkpoints[i].WorkDone = platform.RedactSecrets(redacted.Checkpoints[i].WorkDone)
redacted.Checkpoints[i].TechnicalDetails = platform.RedactSecrets(redacted.Checkpoints[i].TechnicalDetails)
redacted.Checkpoints[i].ImportantFiles = platform.RedactSecrets(redacted.Checkpoints[i].ImportantFiles)
redacted.Checkpoints[i].NextSteps = platform.RedactSecrets(redacted.Checkpoints[i].NextSteps)
}
}
if len(detail.Files) > 0 {
redacted.Files = append([]data.SessionFile(nil), detail.Files...)
for i := range redacted.Files {
redacted.Files[i].FilePath = platform.RedactSecrets(redacted.Files[i].FilePath)
redacted.Files[i].ToolName = platform.RedactSecrets(redacted.Files[i].ToolName)
}
}
if len(detail.Refs) > 0 {
redacted.Refs = append([]data.SessionRef(nil), detail.Refs...)
for i := range redacted.Refs {
redacted.Refs[i].RefValue = platform.RedactSecrets(redacted.Refs[i].RefValue)
}
}
return &redacted
}

// renderExport produces the export content for the given format.
func renderExport(detail *data.SessionDetail, format string) (string, error) {
switch format {
Expand Down
45 changes: 45 additions & 0 deletions cmd/dispatch/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,15 @@ func TestParseExportArgs(t *testing.T) {
wantFormat string
wantStdout bool
wantOut string
wantRedact bool
wantErr bool
}{
{name: "id only", args: []string{"export", "abc"}, wantID: "abc", wantFormat: "md"},
{name: "format json", args: []string{"export", "abc", "--format", "json"}, wantID: "abc", wantFormat: "json"},
{name: "format markdown alias", args: []string{"export", "abc", "--format=markdown"}, wantID: "abc", wantFormat: "md"},
{name: "short format", args: []string{"export", "-f", "json", "abc"}, wantID: "abc", wantFormat: "json"},
{name: "stdout", args: []string{"export", "abc", "--stdout"}, wantID: "abc", wantFormat: "md", wantStdout: true},
{name: "redact", args: []string{"export", "abc", "--redact"}, wantID: "abc", wantFormat: "md", wantRedact: true},
{name: "out dir", args: []string{"export", "abc", "--out", "/tmp/x"}, wantID: "abc", wantFormat: "md", wantOut: "/tmp/x"},
{name: "missing id", args: []string{"export"}, wantErr: true},
{name: "two ids", args: []string{"export", "a", "b"}, wantErr: true},
Expand Down Expand Up @@ -87,6 +89,9 @@ func TestParseExportArgs(t *testing.T) {
if opts.outDir != tc.wantOut {
t.Errorf("outDir = %q, want %q", opts.outDir, tc.wantOut)
}
if opts.redact != tc.wantRedact {
t.Errorf("redact = %v, want %v", opts.redact, tc.wantRedact)
}
})
}
}
Expand Down Expand Up @@ -120,6 +125,46 @@ func TestRunExport_StdoutJSON(t *testing.T) {
}
}

func TestRunExport_RedactsStdout(t *testing.T) {
detail := sampleDetail()
detail.Turns = []data.Turn{
{UserMessage: "Authorization: Bearer abcdefghijklmnopqrstuvwxyz123456", AssistantResponse: "API_TOKEN=super-secret-token"},
}
withExportDetail(t, func(string) (*data.SessionDetail, error) { return detail, nil })

var buf bytes.Buffer
if err := runExport(&buf, []string{"export", "ses-001", "--stdout", "--redact"}); err != nil {
t.Fatalf("runExport: %v", err)
}
out := buf.String()
if strings.Contains(out, "abcdefghijklmnopqrstuvwxyz123456") || strings.Contains(out, "super-secret-token") {
t.Fatalf("redacted export leaked a secret:\n%s", out)
}
if !strings.Contains(out, "[redacted]") {
t.Fatalf("redacted export missing placeholder:\n%s", out)
}
}

func TestRunExport_RedactsJSONWithoutBreakingJSON(t *testing.T) {
detail := sampleDetail()
detail.Turns = []data.Turn{
{UserMessage: "Bearer abcdefghijklmnopqrstuvwxyz123456", AssistantResponse: "safe"},
}
withExportDetail(t, func(string) (*data.SessionDetail, error) { return detail, nil })

var buf bytes.Buffer
if err := runExport(&buf, []string{"export", "ses-001", "--stdout", "--format", "json", "--redact"}); err != nil {
t.Fatalf("runExport: %v", err)
}
var got data.SessionDetail
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
t.Fatalf("redacted output is not valid JSON: %v\n%s", err, buf.String())
}
if strings.Contains(buf.String(), "abcdefghijklmnopqrstuvwxyz123456") {
t.Fatalf("redacted JSON leaked a token:\n%s", buf.String())
}
}

func TestRunExport_WritesFile(t *testing.T) {
withExportDetail(t, func(string) (*data.SessionDetail, error) { return sampleDetail(), nil })

Expand Down
1 change: 1 addition & 0 deletions cmd/dispatch/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,7 @@ Export flags:
--format md|json Output format (default md)
--out <dir> Write to a directory instead of the exports folder
--stdout Print to stdout instead of writing a file
--redact Mask common secret patterns in the export

Flags:
-h, --help Show this help message
Expand Down
3 changes: 2 additions & 1 deletion cmd/dispatch/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,7 @@ func TestPrintUsage_Output(t *testing.T) {

origStdout := os.Stdout
os.Stdout = w
defer func() { os.Stdout = origStdout }()

var buf bytes.Buffer
readDone := make(chan struct{})
Expand All @@ -466,7 +467,7 @@ func TestPrintUsage_Output(t *testing.T) {

printUsage()

w.Close()
_ = w.Close()
os.Stdout = origStdout
<-readDone

Expand Down