From f519d8d3ad68248ec20799dea3bd7be2d096bc31 Mon Sep 17 00:00:00 2001 From: Sam Bretz Date: Tue, 15 Sep 2026 21:48:15 -0700 Subject: [PATCH] feat: open artifacts in a browser view that renders Markdown cleanly Pressing o showed the selected artifact as raw text in the terminal detail panel. Stage documents are Markdown and often long, so headings, lists, tables and code blocks were hard to read and wide lines were truncated. o now opens the artifact as a rendered page in the browser, served by the dashboard server that already exists: the same loopback-only listener, the same per-session token, the same token-to-cookie redirect the index uses. The page names the run, revision, stage and attempt the artifact came from, with its digest and size, and links the checkpoint's other artifacts so moving between plan, design and test results needs no terminal. The terminal preview stays as the fallback where no browser can be opened, and says why. Nothing is fetched from the network. Markdown is rendered in Go with goldmark, already a dependency, configured without raw HTML because artifacts are agent-written; dangerous link targets are dropped. The syntax highlighter is a small built-in lexer that escapes every token rather than a highlighter downloaded from a CDN, which the dashboard's script-src 'self' policy would refuse anyway. Screenshots are marked template.URL. html/template rejects a data: URI in a src attribute and substitutes #ZgotmplZ, which rendered every screenshot broken; the URIs are safe to mark because this package decodes the base64 itself and confirms the PNG magic number before building one. Refs #22 Co-Authored-By: Claude Opus 5 (1M context) --- cmd/envctl/artifact.go | 35 +++- cmd/envctl/web.go | 50 +++-- cmd/envctl/workflow.go | 15 ++ docs/src/content/docs/cli-reference.mdx | 9 + docs/src/content/docs/first-workflow.mdx | 2 +- internal/tui/model.go | 41 +++- internal/tui/model_test.go | 27 +++ internal/tui/open.go | 11 +- internal/web/artifact.go | 231 ++++++++++++++++++++++- internal/web/assets/app.css | 23 +++ internal/web/assets/artifact.html | 47 +++++ internal/web/server.go | 149 +++++++++++++++ internal/web/server_test.go | 76 +++++++- 13 files changed, 655 insertions(+), 61 deletions(-) create mode 100644 internal/web/assets/artifact.html diff --git a/cmd/envctl/artifact.go b/cmd/envctl/artifact.go index 4ba8d5a..d238b14 100644 --- a/cmd/envctl/artifact.go +++ b/cmd/envctl/artifact.go @@ -3,15 +3,22 @@ package main import ( "encoding/json" "errors" + "fmt" "os" "path/filepath" "github.com/spf13/cobra" + + "github.com/sam-bretz/envctl/internal/tui" ) func runArtifactCmd(g *globals) *cobra.Command { var output string - c := &cobra.Command{Use: "artifact ", Short: "Export a checksum-verified checkpoint artifact to a new file", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + var open bool + c := &cobra.Command{Use: "artifact ", Short: "Export or open a checksum-verified checkpoint artifact", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + if output == "" && !open { + return errors.New("either --output or --open is required") + } client, err := connect(cmd.Context(), g) if err != nil { return err @@ -20,17 +27,31 @@ func runArtifactCmd(g *globals) *cobra.Command { if err != nil { return err } - filename, err := filepath.Abs(output) + if output != "" { + filename, err := filepath.Abs(output) + if err != nil { + return err + } + if err = exportArtifact(filename, raw); err != nil { + return err + } + if !open { + return json.NewEncoder(cmd.OutOrStdout()).Encode(map[string]any{"path": filename, "digest": args[0], "bytes": len(raw)}) + } + } + session, _, err := startWebSession(cmd.Context(), g, client, "127.0.0.1:0") if err != nil { - return err + return fmt.Errorf("could not start the artifact browser: %w", err) } - if err = exportArtifact(filename, raw); err != nil { - return err + url := session.ArtifactURL(args[0]) + fmt.Fprintf(cmd.OutOrStdout(), "Open: %s\nPress Ctrl+C to stop.\n", url) + if err := tui.OpenInBrowser(url); err != nil { + return fmt.Errorf("could not open a browser (%v); open the link above", err) } - return json.NewEncoder(cmd.OutOrStdout()).Encode(map[string]any{"path": filename, "digest": args[0], "bytes": len(raw)}) + return session.Wait() }} c.Flags().StringVarP(&output, "output", "o", "", "new destination file (existing paths are never overwritten)") - _ = c.MarkFlagRequired("output") + c.Flags().BoolVar(&open, "open", false, "open the verified artifact in the local browser") return c } diff --git a/cmd/envctl/web.go b/cmd/envctl/web.go index 1729df6..984dfb7 100644 --- a/cmd/envctl/web.go +++ b/cmd/envctl/web.go @@ -5,9 +5,7 @@ import ( "errors" "fmt" "net" - "net/http" "os" - "time" "github.com/spf13/cobra" @@ -15,6 +13,26 @@ import ( "github.com/sam-bretz/envctl/internal/web" ) +func startWebSession(ctx context.Context, g *globals, api web.API, addr string) (*web.Session, *web.Server, error) { + dir, err := stateDir(g) + if err != nil { + return nil, nil, err + } + token, err := web.LoadToken(dir) + if err != nil { + return nil, nil, err + } + srv := &web.Server{API: api, Root: workflowRoot(ctx, g.dir), Token: token, Owner: "local"} + if user := os.Getenv("USER"); user != "" { + srv.Owner = user + } + session, err := web.Start(ctx, srv, addr) + if err != nil { + return nil, nil, err + } + return session, srv, nil +} + func webCmd(g *globals) *cobra.Command { var addr string var noOpen bool @@ -26,44 +44,22 @@ func webCmd(g *globals) *cobra.Command { if ip := net.ParseIP(host); host != "localhost" && (ip == nil || !ip.IsLoopback()) { return errors.New("--addr must be a loopback address such as 127.0.0.1:4777; the dashboard can approve and publish work") } - dir, err := stateDir(g) - if err != nil { - return err - } - token, err := web.LoadToken(dir) - if err != nil { - return err - } client, err := connect(cmd.Context(), g) if err != nil { return err } - listener, err := net.Listen("tcp", addr) + session, srv, err := startWebSession(cmd.Context(), g, client, addr) if err != nil { return fmt.Errorf("%w; choose another port with --addr 127.0.0.1:0", err) } - srv := &web.Server{API: client, Root: workflowRoot(cmd.Context(), g.dir), Token: token, Hosts: web.LoopbackHosts(listener.Addr()), Owner: "local"} - if user := os.Getenv("USER"); user != "" { - srv.Owner = user - } - url := fmt.Sprintf("http://%s/?token=%s", listener.Addr(), token) + url := session.URL("/?token=" + srv.Token) fmt.Fprintf(cmd.OutOrStdout(), "envctl web is serving %s\nOpen: %s\nPress Ctrl+C to stop. Runs keep going without it.\n", srv.Root, url) if !noOpen { if err := tui.OpenInBrowser(url); err != nil { fmt.Fprintf(cmd.ErrOrStderr(), "could not open a browser (%v); open the link above\n", err) } } - server := &http.Server{Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second} - go func() { - <-cmd.Context().Done() - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - _ = server.Shutdown(ctx) - }() - if err := server.Serve(listener); !errors.Is(err, http.ErrServerClosed) { - return err - } - return nil + return session.Wait() }} c.Flags().StringVar(&addr, "addr", "127.0.0.1:4777", "loopback address to serve on") c.Flags().BoolVar(&noOpen, "no-open", false, "print the link without opening a browser") diff --git a/cmd/envctl/workflow.go b/cmd/envctl/workflow.go index b00f26d..982dfcc 100644 --- a/cmd/envctl/workflow.go +++ b/cmd/envctl/workflow.go @@ -102,6 +102,21 @@ func launchTUI(cmd *cobra.Command, g *globals, themeName string) error { if err != nil { return err } + webSession, _, webErr := startWebSession(cmd.Context(), g, client, "127.0.0.1:0") + if webErr != nil { + opts.BrowserWarning = webErr.Error() + } else { + opts.OpenArtifact = func(artifact workflow.Artifact, _ workflow.Run, _ workflow.Revision, _ workflow.Checkpoint) error { + return tui.OpenInBrowser(webSession.ArtifactURL(artifact.Digest)) + } + } + if webSession != nil { + defer func() { + shutdown, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = webSession.Close(shutdown) + }() + } return tui.Run(cmd.Context(), client, workflowRoot(cmd.Context(), g.dir), opts) } diff --git a/docs/src/content/docs/cli-reference.mdx b/docs/src/content/docs/cli-reference.mdx index 300e889..9fc16e0 100644 --- a/docs/src/content/docs/cli-reference.mdx +++ b/docs/src/content/docs/cli-reference.mdx @@ -31,10 +31,19 @@ Version 2 workflow state belongs to its persistent coordinator. These commands i envctl run diff --node code envctl run diff --node qa --revision --from --json envctl run artifact --output +envctl run artifact --open # read it in your browser ``` `diff` defaults to comparing the selected checkpoint against its revision's immutable source pins. `--from` selects any recorded checkpoint in the same run, including historical revisions. The response includes source patches, checkpoint summaries, checks, approvals, and artifact/dataset identities. Repository patches are bounded to 1 MiB each and explicitly report truncation or unavailable retained source. Binary files and LFS objects are represented by Git's binary/pointer differences; database rows are not diffed. `artifact` verifies the transferred checksum and refuses to overwrite existing output. +### Reading an artifact + +Stage documents are Markdown and often long, so `o` in the dashboard (and `run artifact --open`) opens the selected artifact as a rendered page in your browser instead of as raw text in the terminal panel. Markdown gets headings, lists, tables, links and highlighted code; JSON is pretty-printed; base64 `screenshot_png` fields from the Playwright plugin become images. The page names the run, revision, stage and attempt the artifact came from, with its digest and size, and links the checkpoint's other artifacts so moving between plan, design and test results needs no terminal. + +The page is served by the same loopback-only dashboard server, on `127.0.0.1` with the same per-session token, and loads nothing from the network: the Markdown is rendered in Go and the highlighter is built in, so no CDN or web font is fetched. Artifacts are agent-written, so raw HTML in them is escaped rather than rendered and dangerous link targets are dropped. Content is the same checksum-verified bytes `run artifact --output` writes. + +Where no browser can be opened — an SSH session, or no `open`/`xdg-open` — the terminal preview is used instead and says why. + In Bubble Tea, `d` opens the comparison. `b` selects the current checkpoint as the comparison base; navigate to the target and press `d`. `B` resets the base, and Escape closes the loaded review. Selection and review never mutate the workflow. ## Experimental live progress and steering diff --git a/docs/src/content/docs/first-workflow.mdx b/docs/src/content/docs/first-workflow.mdx index 3d98ff2..920e9e2 100644 --- a/docs/src/content/docs/first-workflow.mdx +++ b/docs/src/content/docs/first-workflow.mdx @@ -114,7 +114,7 @@ The dashboard shows the stage strip, your in-flight runs, and a focused panel: | `tab` | Cycle panels: Conversation, Checkpoint, Changes, Tests, Services, Readiness, Graph, History | | `i` | Write to the agent; `s` switches between worker and supervisor | | `d` | Diff the stage; `b` sets a comparison base, `B` clears it | -| `o` | Open the selected artifact; `,` and `.` select | +| `o` | Open the selected artifact in your browser; `,` and `.` select | | `[` `]` | Browse revision history | | `r` `a` `x` | Rewind, approve, cancel | | `g` | Open the run's pull request in your browser | diff --git a/internal/tui/model.go b/internal/tui/model.go index 1f57e55..73f36ed 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -70,6 +70,10 @@ type Model struct { Picker *themePicker // OpenURL opens a link in the user's browser; tests substitute it. OpenURL func(string) error + // OpenArtifact hands a retained artifact to a browser view. A failure is + // shown to the user and the same verified bytes are rendered in the panel. + OpenArtifact func(workflow.Artifact, workflow.Run, workflow.Revision, workflow.Checkpoint) error + BrowserWarning string // refreshFailed marks Error as a refresh failure, the only kind a later // successful refresh may clear. Action errors stay until the next action. refreshFailed bool @@ -84,9 +88,11 @@ type actionMsg struct { notice string // replaces the generic "Saved" } type artifactMsg struct { - key string - body []byte - err error + key string + body []byte + err error + fallback string + opened bool } type diffMsg struct { key string @@ -203,11 +209,18 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if v.key != m.ArtifactRequest || v.key != m.viewKey() { return m, nil } - if v.err != nil { + if v.opened { + m.Error = "" + m.Notice = "Opened artifact in the browser" + } else if v.err != nil { m.Error = v.err.Error() } else { m.ArtifactText = artifactPreview(v.body) m.Offset = 0 + m.Error = "" + if v.fallback != "" { + m.Notice = "Browser unavailable (" + v.fallback + "); showing the terminal preview" + } } case diffMsg: if v.key != m.DiffRequest || v.key != m.diffKey() { @@ -473,14 +486,24 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "o": if cp, ok := m.selectedCheckpoint(); ok && len(cp.Result.Artifacts) > 0 { m.DiffRequest = "" + m.ArtifactText = "" artifact := cp.Result.Artifacts[min(m.ArtifactIndex, len(cp.Result.Artifacts)-1)] m.ArtifactRequest = m.viewKey() - api, digest, request := m.API, artifact.Digest, m.ArtifactRequest + api, digest, request, opener := m.API, artifact.Digest, m.ArtifactRequest, m.OpenArtifact + run, revision, checkpoint := *m.current(), *m.viewRevision(), cp + warning := m.BrowserWarning return m, func() tea.Msg { + if opener != nil { + if err := opener(artifact, run, revision, checkpoint); err == nil { + return artifactMsg{key: request, opened: true} + } else { + warning = err.Error() + } + } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() raw, err := api.Artifact(ctx, digest) - return artifactMsg{key: request, body: raw, err: err} + return artifactMsg{key: request, body: raw, err: err, fallback: warning} } } case "b": @@ -961,6 +984,10 @@ type Options struct { Settings Settings // Warning is shown on the first frame, for example an unreadable config. Warning string + // OpenArtifact opens a document in the browser and is optional so the TUI + // remains useful over SSH or on systems without a browser opener. + OpenArtifact func(workflow.Artifact, workflow.Run, workflow.Revision, workflow.Checkpoint) error + BrowserWarning string } func Run(ctx context.Context, api API, root string, opts Options) error { @@ -970,6 +997,8 @@ func Run(ctx context.Context, api API, root string, opts Options) error { m.Theme.override = opts.Theme m.Theme.resolve() m.Error = opts.Warning + m.OpenArtifact = opts.OpenArtifact + m.BrowserWarning = opts.BrowserWarning _, err := tea.NewProgram(m, tea.WithContext(ctx)).Run() return err } diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index 8ff1697..53362ff 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -138,3 +138,30 @@ func TestRefreshPreservesSelectedRun(t *testing.T) { t.Fatal("selection jumped after reorder") } } + +func TestBrowserFailureFallsBackToTheVerifiedTerminalPreview(t *testing.T) { + m := modelFixture(t) + m.Runs[0].Current().Checkpoints["plan"] = workflow.Checkpoint{ + Node: "plan", + Attempt: "attempt_1", + Result: workflow.Result{Artifacts: []workflow.Artifact{{Name: "plan.md", Digest: strings.Repeat("a", 64), MediaType: "text/markdown"}}}, + } + m.OpenArtifact = func(workflow.Artifact, workflow.Run, workflow.Revision, workflow.Checkpoint) error { + return errors.New("xdg-open is unavailable") + } + order, _ := m.viewRevision().Config.Workflow.Order() + for i, id := range order { + if id == "plan" { + m.Node = i + } + } + m, cmd := key(m, "o") + if cmd == nil { + t.Fatal("o did not request an artifact") + } + next, _ := m.Update(cmd()) + m = next.(Model) + if m.ArtifactText != "artifact" || !strings.Contains(m.Notice, "Browser unavailable (xdg-open is unavailable)") { + t.Fatalf("fallback preview: notice %q text %q", m.Notice, m.ArtifactText) + } +} diff --git a/internal/tui/open.go b/internal/tui/open.go index 58c852c..8427bec 100644 --- a/internal/tui/open.go +++ b/internal/tui/open.go @@ -6,8 +6,9 @@ import ( "runtime" ) -// OpenInBrowser hands a URL to the platform's opener without waiting for the -// browser, so the dashboard stays responsive. +// OpenInBrowser hands a URL to the platform's opener. Waiting for the opener +// command to report failure is necessary so callers can show a terminal +// fallback when an SSH session has no browser integration. func OpenInBrowser(url string) error { var cmd *exec.Cmd switch runtime.GOOS { @@ -18,9 +19,5 @@ func OpenInBrowser(url string) error { default: return errors.New("no browser opener for " + runtime.GOOS) } - if err := cmd.Start(); err != nil { - return err - } - go func() { _ = cmd.Wait() }() - return nil + return cmd.Run() } diff --git a/internal/web/artifact.go b/internal/web/artifact.go index 00f9671..400429c 100644 --- a/internal/web/artifact.go +++ b/internal/web/artifact.go @@ -2,17 +2,27 @@ package web import ( "bytes" + "encoding/base64" "encoding/json" + stdhtml "html" + "html/template" "strings" "unicode/utf8" + "github.com/sam-bretz/envctl/internal/workflow" "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/extension" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/util" ) // Artifacts are agent-written, so Markdown renders without raw HTML and with // goldmark's filtering of dangerous link targets. -var markdown = goldmark.New(goldmark.WithExtensions(extension.GFM)) +var markdown = goldmark.New( + goldmark.WithExtensions(extension.GFM), + goldmark.WithRendererOptions(renderer.WithNodeRenderers(util.Prioritized(codeRenderer{}, 999))), +) const maxRendered = 2 << 20 @@ -25,20 +35,108 @@ type RenderedArtifact struct { Truncated bool `json:"truncated,omitempty"` } +type artifactPageData struct { + Name string + Run string + RunID string + Revision string + Stage string + Attempt string + Digest string + Size int64 + MediaType string + Content RenderedArtifact + HTML template.HTML + // Screenshots are template.URL because html/template rejects a data: URI + // in a src attribute and writes #ZgotmplZ instead. These are safe to mark: + // each one is built here from base64 this package decoded itself and + // confirmed to start with the PNG magic number. + Screenshots []template.URL + Others []workflow.Artifact + Related []artifactLink +} + +type artifactLink struct { + workflow.Artifact + Stage string +} + +type artifactMetadataResult struct { + Artifact workflow.Artifact + Run string + RunID string + Revision string + Stage string + Attempt string + Others []workflow.Artifact + Related []artifactLink +} + +func artifactMetadata(runs []workflow.Run, digest string) artifactMetadataResult { + for _, run := range runs { + for _, rev := range run.Revisions { + order, _ := rev.Config.Workflow.Order() + seen := make(map[string]bool, len(order)) + visit := func(stage string) (artifactMetadataResult, bool) { + cp, ok := rev.Checkpoints[stage] + if !ok { + return artifactMetadataResult{}, false + } + for _, artifact := range cp.Result.Artifacts { + if artifact.Digest != digest { + continue + } + others := make([]workflow.Artifact, 0, len(cp.Result.Artifacts)-1) + for _, sibling := range cp.Result.Artifacts { + if sibling.Digest != artifact.Digest { + others = append(others, sibling) + } + } + related := make([]artifactLink, 0) + for _, relatedStage := range order { + if relatedStage == stage { + continue + } + relatedCP, exists := rev.Checkpoints[relatedStage] + if !exists { + continue + } + for _, relatedArtifact := range relatedCP.Result.Artifacts { + if relatedArtifact.Digest != artifact.Digest { + related = append(related, artifactLink{Artifact: relatedArtifact, Stage: relatedCP.Node}) + } + } + } + return artifactMetadataResult{Artifact: artifact, Run: run.Name, RunID: run.ID, Revision: rev.ID, Stage: cp.Node, Attempt: cp.Attempt, Others: others, Related: related}, true + } + return artifactMetadataResult{}, false + } + for _, stage := range order { + seen[stage] = true + if result, ok := visit(stage); ok { + return result + } + } + for stage := range rev.Checkpoints { + if !seen[stage] { + if result, ok := visit(stage); ok { + return result + } + } + } + } + } + return artifactMetadataResult{} +} + func renderArtifact(raw []byte, mediaType string) RenderedArtifact { out := RenderedArtifact{Size: len(raw)} if len(raw) > maxRendered { raw, out.Truncated = raw[:maxRendered], true } var value any + isJSON := json.Unmarshal(raw, &value) == nil switch { - case json.Unmarshal(raw, &value) == nil: - out.Kind = "json" - value = extractScreenshots(value, &out.Images) - pretty, _ := json.MarshalIndent(value, "", " ") - out.Text = string(pretty) - case !utf8.Valid(raw): - out.Kind = "binary" case strings.Contains(mediaType, "markdown") || looksLikeMarkdown(raw): var html bytes.Buffer if err := markdown.Convert(raw, &html); err == nil { @@ -46,6 +144,13 @@ func renderArtifact(raw []byte, mediaType string) RenderedArtifact { } else { out.Kind, out.Text = "text", string(raw) } + case isJSON: + out.Kind = "json" + value = extractScreenshots(value, &out.Images) + pretty, _ := json.MarshalIndent(value, "", " ") + out.Text = string(pretty) + case !utf8.Valid(raw): + out.Kind = "binary" default: out.Kind, out.Text = "text", string(raw) } @@ -64,8 +169,11 @@ func extractScreenshots(v any, images *[]string) any { case map[string]any: for k, item := range x { if s, ok := item.(string); ok && k == "screenshot_png" { - *images = append(*images, "data:image/png;base64,"+s) - x[k] = "[screenshot shown above]" + decoded, err := base64.StdEncoding.DecodeString(s) + if err == nil && len(decoded) >= 8 && bytes.Equal(decoded[:8], []byte{137, 80, 78, 71, 13, 10, 26, 10}) { + *images = append(*images, "data:image/png;base64,"+s) + delete(x, k) + } continue } x[k] = extractScreenshots(item, images) @@ -77,3 +185,106 @@ func extractScreenshots(v any, images *[]string) any { } return v } + +// codeRenderer adds colors to fenced blocks without asking the browser to +// execute a renderer or fetch a highlighter. The lexer is intentionally small: +// artifact readability benefits from highlighting common identifiers and +// literals, while every token is still escaped before it becomes HTML. +type codeRenderer struct{} + +func (codeRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { + reg.Register(ast.KindCodeBlock, renderCodeBlock) + reg.Register(ast.KindFencedCodeBlock, renderFencedCodeBlock) +} + +func renderCodeBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + _, _ = w.WriteString("
")
+		_, _ = w.WriteString(highlightCode(node.Text(source), ""))
+	} else {
+		_, _ = w.WriteString("
\n") + } + return ast.WalkContinue, nil +} + +func renderFencedCodeBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + n := node.(*ast.FencedCodeBlock) + if entering { + language := string(n.Language(source)) + _, _ = w.WriteString("
')
+		_, _ = w.WriteString(highlightCode(n.Text(source), language))
+	} else {
+		_, _ = w.WriteString("
\n") + } + return ast.WalkContinue, nil +} + +var keywords = map[string]bool{ + "break": true, "case": true, "chan": true, "const": true, "continue": true, + "def": true, "defer": true, "else": true, "enum": true, "fallthrough": true, + "for": true, "func": true, "go": true, "if": true, "import": true, + "in": true, "interface": true, "let": true, "map": true, "package": true, + "range": true, "return": true, "select": true, "struct": true, "switch": true, + "type": true, "var": true, "while": true, "class": true, "function": true, + "public": true, "private": true, "new": true, "try": true, "catch": true, +} + +func highlightCode(raw []byte, language string) string { + var out strings.Builder + for i := 0; i < len(raw); { + start := i + class := "" + switch { + case raw[i] == '#' || (raw[i] == '/' && i+1 < len(raw) && raw[i+1] == '/'): + class = "comment" + for i < len(raw) && raw[i] != '\n' { + i++ + } + case raw[i] == '\'' || raw[i] == '"' || raw[i] == '`': + quote := raw[i] + class = "string" + i++ + for i < len(raw) { + if raw[i] == '\\' && quote != '`' && i+1 < len(raw) { + i += 2 + continue + } + i++ + if raw[i-1] == quote { + break + } + } + case raw[i] >= '0' && raw[i] <= '9': + class = "number" + for i < len(raw) && ((raw[i] >= '0' && raw[i] <= '9') || raw[i] == '.' || raw[i] == 'x' || raw[i] == 'A' || raw[i] == 'B' || raw[i] == 'C' || raw[i] == 'D' || raw[i] == 'E' || raw[i] == 'F' || raw[i] == 'a' || raw[i] == 'b' || raw[i] == 'c' || raw[i] == 'd' || raw[i] == 'e' || raw[i] == 'f') { + i++ + } + case (raw[i] >= 'A' && raw[i] <= 'Z') || (raw[i] >= 'a' && raw[i] <= 'z') || raw[i] == '_': + for i < len(raw) && ((raw[i] >= 'A' && raw[i] <= 'Z') || (raw[i] >= 'a' && raw[i] <= 'z') || (raw[i] >= '0' && raw[i] <= '9') || raw[i] == '_') { + i++ + } + if keywords[string(raw[start:i])] { + class = "keyword" + } + default: + i++ + } + token := stdhtml.EscapeString(string(raw[start:i])) + if class != "" { + out.WriteString(``) + } + out.WriteString(token) + if class != "" { + out.WriteString("") + } + } + return out.String() +} diff --git a/internal/web/assets/app.css b/internal/web/assets/app.css index 0d67566..633ab5c 100644 --- a/internal/web/assets/app.css +++ b/internal/web/assets/app.css @@ -338,6 +338,29 @@ a.issue:hover { color: var(--accent); text-decoration: none; } .plain { font-family: var(--mono); font-size: 12.5px; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; max-width: 100ch; } .shots img { max-width: 100%; border: 1px solid var(--line); border-radius: 8px; margin-bottom: 14px; } +/* Standalone artifact pages use the same local, preference-aware palette as + the dashboard but keep the document readable at full browser width. */ +.artifact-shell { max-width: 1040px; margin: 0 auto; padding: 34px 32px 80px; } +.artifact-head { border-bottom: 1px solid var(--line); padding-bottom: 22px; } +.artifact-back { display: inline-block; margin-bottom: 22px; font-size: 13px; } +.artifact-head h1 { font-size: 28px; overflow-wrap: anywhere; } +.artifact-facts { display: flex; flex-wrap: wrap; gap: 10px 28px; margin: 18px 0 0; } +.artifact-facts div { min-width: 170px; } +.artifact-facts dt { color: var(--ink-3); font-size: 12px; text-transform: uppercase; letter-spacing: .06em; } +.artifact-facts dd { margin: 2px 0 0; overflow-wrap: anywhere; } +.artifact-facts code { font-size: 11px; } +.artifact-links { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: baseline; padding: 16px 0; border-bottom: 1px solid var(--line); color: var(--ink-3); font-size: 13px; } +.artifact-links a { white-space: nowrap; } +.artifact-content { padding-top: 30px; } +.artifact-note { max-width: 68ch; margin: 0 auto 22px; color: var(--ink-2); font-size: 13px; } +.artifact-plain { max-width: 110ch; margin: 0 auto; font-family: var(--mono); font-size: 13px; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere; } +.artifact-shot { display: block; max-width: min(100%, 1000px); margin: 0 auto 18px; border: 1px solid var(--line); border-radius: 8px; } +.tok-keyword { color: var(--accent); font-weight: 600; } +.tok-string { color: var(--ok); } +.tok-comment { color: var(--ink-3); font-style: italic; } +.tok-number { color: var(--needs); } +@media (max-width: 680px) { .artifact-shell { padding: 22px 18px 60px; } .artifact-facts { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } .artifact-facts div { min-width: 0; } } + /* Dialog */ dialog { border: 0; border-radius: 14px; background: var(--surface); color: var(--ink); padding: 0; width: min(560px, calc(100% - 32px)); box-shadow: var(--shadow), 0 0 0 1px var(--line); } dialog::backdrop { background: rgba(15, 18, 24, 0.36); } diff --git a/internal/web/assets/artifact.html b/internal/web/assets/artifact.html new file mode 100644 index 0000000..00b6e86 --- /dev/null +++ b/internal/web/assets/artifact.html @@ -0,0 +1,47 @@ + + + + + +{{.Name}} · envctl artifact + + + + +
+
+ ← envctl dashboard +

{{.Name}}

+
+
Run
{{if .Run}}{{.Run}}{{else}}Unknown{{end}}
+
Revision
{{if .Revision}}{{.Revision}}{{else}}Unknown{{end}}
+
Stage
{{if .Stage}}{{.Stage}}{{else}}Unknown{{end}}
+
Attempt
{{if .Attempt}}{{.Attempt}}{{else}}Unknown{{end}}
+
Digest
{{.Digest}}
+
Size
{{.Size}} bytes{{if .MediaType}} · {{.MediaType}}{{end}}
+
+
+ + {{if .Others}} + + {{end}} + {{if .Related}} + + {{end}} + +
+ {{if .Content.Truncated}}

This view shows the first 2 MB. Export the complete verified artifact with envctl run artifact {{.Digest}} --output <file>.

{{end}} + {{range .Screenshots}}Screenshot recorded by a check{{end}} + {{if eq .Content.Kind "markdown"}}
{{.HTML}}
+ {{else if eq .Content.Kind "binary"}}

This artifact is binary. Export the complete verified bytes with envctl run artifact {{.Digest}} --output <file>.

+ {{else}}
{{.Content.Text}}
{{end}} +
+
+ + diff --git a/internal/web/server.go b/internal/web/server.go index 3ac39ab..13d4a1b 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -7,6 +7,7 @@ import ( "bytes" "context" "crypto/rand" + "crypto/sha256" "crypto/subtle" "embed" "encoding/hex" @@ -17,6 +18,7 @@ import ( "io/fs" "net" "net/http" + "net/url" "os" "path/filepath" "regexp" @@ -58,6 +60,18 @@ type Server struct { Now func() time.Time } +// Session is one loopback dashboard server. It is deliberately short-lived +// for terminal clients: the token in its URLs is only useful while this +// process is serving the retained state. +type Session struct { + server *http.Server + listener net.Listener + baseURL string + token string + done chan error + cancel context.CancelFunc +} + const cookieName = "envctl_web" var digestPattern = regexp.MustCompile(`^[0-9a-f]{40,64}$`) @@ -86,6 +100,58 @@ func LoopbackHosts(addr net.Addr) []string { return []string{"127.0.0.1:" + port, "localhost:" + port, "[::1]:" + port} } +// Start binds a loopback-only dashboard and shuts it down with ctx. Using a +// random port avoids collisions between several terminal sessions. +func Start(ctx context.Context, s *Server, addr string) (*Session, error) { + host, _, err := net.SplitHostPort(addr) + if err != nil { + return nil, fmt.Errorf("web address: %w", err) + } + if ip := net.ParseIP(host); host != "localhost" && (ip == nil || !ip.IsLoopback()) { + return nil, errors.New("web address must be a loopback address") + } + listener, err := net.Listen("tcp", addr) + if err != nil { + return nil, err + } + s.Hosts = LoopbackHosts(listener.Addr()) + httpServer := &http.Server{Handler: s.Handler(), ReadHeaderTimeout: 10 * time.Second} + serverContext, cancel := context.WithCancel(ctx) + session := &Session{server: httpServer, listener: listener, baseURL: "http://" + listener.Addr().String(), token: s.Token, done: make(chan error, 1), cancel: cancel} + go func() { + <-serverContext.Done() + shutdown, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = httpServer.Shutdown(shutdown) + }() + go func() { + err := httpServer.Serve(listener) + if errors.Is(err, http.ErrServerClosed) { + err = nil + } + session.done <- err + }() + return session, nil +} + +func (s *Session) URL(path string) string { return s.baseURL + path } + +// ArtifactURL includes the one-time sign-in mechanism used by the dashboard. +// It is safe for the browser to follow sibling links after the redirect sets +// the HttpOnly session cookie. +func (s *Session) ArtifactURL(digest string) string { + return s.URL("/artifact/" + url.PathEscape(digest) + "?token=" + url.QueryEscape(s.token)) +} + +// Wait returns when the listener exits. Context cancellation is the normal +// exit and is reported as nil. +func (s *Session) Wait() error { return <-s.done } + +func (s *Session) Close(ctx context.Context) error { + s.cancel() + return s.server.Shutdown(ctx) +} + func (s *Server) now() time.Time { if s.Now != nil { return s.Now() @@ -104,6 +170,7 @@ func (s *Server) Handler() http.Handler { files.ServeHTTP(w, r) })) mux.HandleFunc("GET /{$}", s.index) + mux.HandleFunc("GET /artifact/{digest}", s.artifactPage) mux.HandleFunc("GET /api/state", s.authed(s.state)) mux.HandleFunc("GET /api/stream", s.authed(s.stream)) mux.HandleFunc("GET /api/workflows", s.authed(s.workflows)) @@ -164,6 +231,7 @@ func (s *Server) authed(next http.HandlerFunc) http.HandlerFunc { } var page = template.Must(template.ParseFS(assets, "assets/index.html")) +var artifactPage = template.Must(template.ParseFS(assets, "assets/artifact.html")) func (s *Server) index(w http.ResponseWriter, r *http.Request) { if t := r.URL.Query().Get("token"); t != "" { @@ -186,6 +254,68 @@ func (s *Server) index(w http.ResponseWriter, r *http.Request) { _ = page.Execute(w, map[string]string{"Token": s.Token}) } +func (s *Server) artifactPage(w http.ResponseWriter, r *http.Request) { + if t := r.URL.Query().Get("token"); t != "" { + if !s.validToken(t) { + http.Error(w, "This link has an unknown token. Run envctl web again and open the link it prints.", http.StatusUnauthorized) + return + } + http.SetCookie(w, &http.Cookie{Name: cookieName, Value: s.Token, Path: "/", HttpOnly: true, SameSite: http.SameSiteStrictMode, MaxAge: 30 * 24 * 3600}) + q := r.URL.Query() + q.Del("token") + location := r.URL.Path + if encoded := q.Encode(); encoded != "" { + location += "?" + encoded + } + http.Redirect(w, r, location, http.StatusSeeOther) + return + } + if !s.signedIn(r) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.WriteHeader(http.StatusUnauthorized) + _, _ = fmt.Fprintln(w, "Sign in by opening the link envctl web printed in your terminal.") + return + } + digest := r.PathValue("digest") + if !digestPattern.MatchString(digest) { + http.Error(w, "invalid artifact digest", http.StatusBadRequest) + return + } + runs, err := s.API.List(r.Context()) + if err != nil { + writeError(w, http.StatusServiceUnavailable, err) + return + } + meta := artifactMetadata(runs, digest) + raw, err := s.API.Artifact(r.Context(), digest) + if err != nil { + writeError(w, http.StatusNotFound, err) + return + } + if !artifactBytesMatch(digest, raw) { + writeError(w, http.StatusBadGateway, errors.New("artifact response failed its checksum verification")) + return + } + if meta.Artifact.Digest != "" && meta.Artifact.Size != int64(len(raw)) { + writeError(w, http.StatusInternalServerError, errors.New("retained artifact size does not match its checkpoint metadata")) + return + } + rendered := renderArtifact(raw, meta.Artifact.MediaType) + data := artifactPageData{ + Name: meta.Artifact.Name, Run: meta.Run, RunID: meta.RunID, Revision: meta.Revision, + Stage: meta.Stage, Attempt: meta.Attempt, Digest: digest, Size: int64(len(raw)), + MediaType: meta.Artifact.MediaType, Content: rendered, HTML: template.HTML(rendered.HTML), Screenshots: screenshots(rendered.Images), Others: meta.Others, Related: meta.Related, + } + if data.Name == "" { + data.Name = "Artifact" + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + if err := artifactPage.Execute(w, data); err != nil { + return + } +} + func (s *Server) snapshot(ctx context.Context) State { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() @@ -422,9 +552,18 @@ func (s *Server) artifact(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotFound, err) return } + if !artifactBytesMatch(digest, raw) { + writeError(w, http.StatusBadGateway, errors.New("artifact response failed its checksum verification")) + return + } writeJSON(w, renderArtifact(raw, r.URL.Query().Get("media_type"))) } +func artifactBytesMatch(digest string, raw []byte) bool { + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]) == digest +} + func firstLine(s string) string { line, _, _ := strings.Cut(s, "\n") if r := []rune(line); len(r) > 80 { @@ -454,3 +593,13 @@ func writeError(w http.ResponseWriter, status int, err error) { w.WriteHeader(status) _ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) } + +// screenshots marks decoded PNG data URIs as safe for a src attribute. See +// artifactPageData.Screenshots for why they are trustworthy. +func screenshots(images []string) []template.URL { + out := make([]template.URL, 0, len(images)) + for _, image := range images { + out = append(out, template.URL(image)) + } + return out +} diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 01cd009..e0f68a9 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -2,9 +2,14 @@ package web import ( "bufio" + "bytes" "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" "os" @@ -217,19 +222,60 @@ func TestLanesSayWhatARunNeeds(t *testing.T) { } func TestArtifactsRenderWithoutActiveContent(t *testing.T) { - md := renderArtifact([]byte("# Plan\n\n\n\n[bad](javascript:alert(1)) [good](https://example.com)\n\n| a | b |\n| - | - |\n| 1 | 2 |\n"), "text/markdown") - if md.Kind != "markdown" || strings.Contains(md.HTML, "") { + md := renderArtifact([]byte("# Plan\n\n\n\n[bad](javascript:alert(1)) [good](https://example.com)\n\n| a | b |\n| - | - |\n| 1 | 2 |\n\n```go\nfunc main() { return }\n```\n"), "text/markdown") + if md.Kind != "markdown" || strings.Contains(md.HTML, "") || !strings.Contains(md.HTML, `class="tok-keyword"`) { t.Fatalf("markdown rendered unsafely or incompletely: %s", md.HTML) } js := renderArtifact([]byte(`{"steps":[{"screenshot_png":"iVBORw0KGgo="}]}`), "application/json") - if js.Kind != "json" || len(js.Images) != 1 || !strings.HasPrefix(js.Images[0], "data:image/png;base64,") || strings.Contains(js.Text, "iVBOR") { + if js.Kind != "json" || len(js.Images) != 1 || !strings.HasPrefix(js.Images[0], "data:image/png;base64,") || strings.Contains(js.Text, "iVBOR") || strings.Contains(js.Text, "screenshot_png") { t.Fatalf("screenshot not extracted: %+v", js) } + if text := renderArtifact([]byte("wide plain text\n"), "text/plain"); text.Kind != "text" || text.Text != "wide plain text\n" { + t.Fatalf("plain text was not retained: %+v", text) + } if bin := renderArtifact([]byte{0xff, 0xfe, 0x00}, ""); bin.Kind != "binary" { t.Fatalf("binary artifact kind %q", bin.Kind) } } +func TestArtifactPageShowsProvenanceAndLinksSiblingArtifacts(t *testing.T) { + _, api, h := fixture(t) + primary := []byte("# Plan\n\nThe retained plan.\n") + second := workflow.Artifact{Name: "design.md", Digest: strings.Repeat("b", 64), Size: 4, MediaType: "text/markdown"} + stageArtifact := workflow.Artifact{Name: "test-results.md", Digest: strings.Repeat("c", 64), Size: 5, MediaType: "text/markdown"} + sum := sha256.Sum256(primary) + digest := hex.EncodeToString(sum[:]) + first := workflow.Artifact{Name: "plan.md", Digest: digest, Size: int64(len(primary)), MediaType: "text/markdown"} + c, err := workflow.Parse([]byte(config)) + if err != nil { + t.Fatal(err) + } + r := run(t, c) + r.Current().Checkpoints["plan"] = workflow.Checkpoint{Node: "plan", Attempt: "attempt_7", Result: workflow.Result{Artifacts: []workflow.Artifact{first, second}}} + r.Current().Checkpoints["qa"] = workflow.Checkpoint{Node: "qa", Attempt: "attempt_8", Result: workflow.Result{Artifacts: []workflow.Artifact{stageArtifact}}} + api.raw, api.runs = primary, []workflow.Run{*r} + redirect := serve(h, request("GET", "/artifact/"+digest+"?token="+testToken, "", false, nil)) + if redirect.Code != http.StatusSeeOther || len(redirect.Result().Cookies()) != 1 { + t.Fatalf("artifact sign-in redirect: %d %s", redirect.Code, redirect.Body) + } + pageReq := request("GET", "/artifact/"+digest, "", false, nil) + pageReq.AddCookie(redirect.Result().Cookies()[0]) + page := serve(h, pageReq) + body := page.Body.String() + for _, want := range []string{"plan.md", r.Name, r.CurrentRevision, "plan", "attempt_7", digest, fmt.Sprintf("%d bytes", len(primary)), "design.md", "/artifact/" + second.Digest, "qa · test-results.md", "/artifact/" + stageArtifact.Digest, "The retained plan."} { + if !strings.Contains(body, want) { + t.Fatalf("artifact page missing %q:\n%s", want, body) + } + } + if page.Code != http.StatusOK || page.Header().Get("Content-Security-Policy") == "" || strings.Contains(body, "