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
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -711,6 +711,7 @@ devx includes an optional web interface for managing sessions from any browser
- **Session list** — view all sessions grouped by project, with attention flag indicators (◆)
- **In-browser terminal** — full tmux access via ttyd, with tmux window tabs in the header
- **Mobile-friendly** — responsive layout, soft key toolbar (Tab, Ctrl, arrows) for touchscreens
- **Artifacts** — attach plans, reports, screenshots, logs, recordings, and docs to a session; view them next to the terminal and organize them into folders
- **Image upload** — paste, drag-and-drop, or use the `[img]` button to inject an image path into the terminal
- **Create & delete sessions** — from the browser, same as the CLI
- **Service links** — tap "svc" on any session to open its Caddy routes in the browser
Expand Down Expand Up @@ -745,6 +746,50 @@ devx web stop # stop daemon

> **Auto-start:** Set `web_autostart: true` to have the web daemon start automatically when you open the TUI.

### Session artifacts

Artifacts are files stored under a session worktree's `.artifacts/` directory and indexed by `.artifacts/manifest.json`. They are useful for agent-generated plans, reviews, screenshots, QA reports, logs, diffs, and proof-of-work reports.

```bash
# Add a normal flat artifact
devx artifact add ./report.md \
--title "Completion report" \
--type report \
--agent pi

# Group related workflow outputs under a safe relative folder path
devx artifact add ./10-plan.md \
--title "Implementation plan" \
--type plan \
--folder workflow/run-123 \
--file 10-plan.md

# List all artifacts, filter one folder, or print a grouped tree
devx artifact list
devx artifact list --folder workflow/run-123
devx artifact list --tree

# Print web, local, or same-folder/embed URLs
devx artifact url <artifact-id>
devx artifact url <artifact-id> --local
devx artifact url <artifact-id> --embed
```

When `--folder` is supplied, DevX writes the file under `.artifacts/<folder>/<file>` and records both the full manifest `file` path and the artifact `folder`. Folder paths must be safe relative paths: no absolute paths, `..`, or empty segments such as `workflow//run`.

Example workflow layout:

```text
.artifacts/
workflow/run-123/00-office-hours.md
workflow/run-123/10-plan.md
workflow/run-123/20-review.md
workflow/run-123/30-qa/results.log
workflow/run-123/40-proof-of-work.html
```

DevX Web shows artifact folders as groups and keeps artifacts without a folder in **Unfiled**. See [docs/artifacts.md](docs/artifacts.md) for more examples.

### External domain routing (optional)

To access devx services from your phone via a custom domain:
Expand Down
13 changes: 13 additions & 0 deletions artifact/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ type AddOptions struct {
Source string
Reader io.Reader
Destination string
Folder string
ID string
Type string
Title string
Expand Down Expand Up @@ -72,13 +73,24 @@ func addLocked(sess *session.Session, opts AddOptions) (Artifact, error) {
if strings.TrimSpace(id) == "" || strings.ContainsAny(id, `/\`) {
return Artifact{}, fmt.Errorf("invalid artifact id %q", id)
}
folder := ""
if strings.TrimSpace(opts.Folder) != "" {
var err error
folder, err = NormalizeFolderPath(opts.Folder)
if err != nil {
return Artifact{}, fmt.Errorf("invalid folder: %w", err)
}
}
destRel := opts.Destination
if destRel == "" {
destRel = DefaultDestination(artifactType, opts.Source)
}
if err := ValidateRelativePath(destRel); err != nil {
return Artifact{}, fmt.Errorf("invalid destination: %w", err)
}
if folder != "" {
destRel = filepath.Join(folder, destRel)
}
if isReservedArtifactPath(destRel) {
return Artifact{}, fmt.Errorf("destination %q is reserved", destRel)
}
Expand Down Expand Up @@ -120,6 +132,7 @@ func addLocked(sess *session.Session, opts AddOptions) (Artifact, error) {
Type: artifactType,
Title: opts.Title,
File: filepath.ToSlash(finalRel),
Folder: folder,
Created: now.UTC(),
Agent: agent,
Retention: retention,
Expand Down
12 changes: 11 additions & 1 deletion artifact/filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,17 @@ type FilterOptions struct {
Tag string
Agent string
Search string
Folder string
}

func Filter(artifacts []Artifact, opts FilterOptions) []Artifact {
search := strings.ToLower(strings.TrimSpace(opts.Search))
folder := strings.TrimSpace(opts.Folder)
if folder != "" {
if normalized, err := NormalizeFolderPath(folder); err == nil {
folder = normalized
}
}
out := make([]Artifact, 0, len(artifacts))
for _, a := range artifacts {
if opts.Type != "" && a.Type != opts.Type {
Expand All @@ -19,6 +26,9 @@ func Filter(artifacts []Artifact, opts FilterOptions) []Artifact {
if opts.Agent != "" && a.Agent != opts.Agent {
continue
}
if folder != "" && a.Folder != folder {
continue
}
if opts.Tag != "" && !hasTag(a, opts.Tag) {
continue
}
Expand All @@ -41,7 +51,7 @@ func hasTag(a Artifact, tag string) bool {
}

func matchesSearch(a Artifact, search string) bool {
fields := []string{a.ID, a.Type, a.Title, a.File, a.Agent, a.Retention}
fields := []string{a.ID, a.Type, a.Title, a.File, a.Folder, a.Agent, a.Retention}
if a.Summary != nil {
fields = append(fields, *a.Summary)
}
Expand Down
62 changes: 61 additions & 1 deletion artifact/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"sort"
"strings"
"time"
"unicode"

"github.com/jfox85/devx/session"
)
Expand Down Expand Up @@ -50,6 +51,7 @@ type Artifact struct {
Type string `json:"type"`
Title string `json:"title"`
File string `json:"file"`
Folder string `json:"folder,omitempty"`
Created time.Time `json:"created"`
Agent string `json:"agent,omitempty"`
Retention string `json:"retention,omitempty"`
Expand Down Expand Up @@ -154,7 +156,11 @@ func SaveManifest(sess *session.Session, m *Manifest) error {

func ValidateManifest(m *Manifest) error {
seen := map[string]bool{}
for _, a := range m.Artifacts {
for i := range m.Artifacts {
if err := normalizeArtifact(&m.Artifacts[i]); err != nil {
return err
}
a := m.Artifacts[i]
if err := ValidateArtifact(a); err != nil {
return err
}
Expand All @@ -166,6 +172,18 @@ func ValidateManifest(m *Manifest) error {
return nil
}

func normalizeArtifact(a *Artifact) error {
if strings.TrimSpace(a.Folder) == "" {
return nil
}
folder, err := NormalizeFolderPath(a.Folder)
if err != nil {
return fmt.Errorf("invalid artifact folder %q: %w", a.Folder, err)
}
a.Folder = folder
return nil
}

func ValidateArtifact(a Artifact) error {
if strings.TrimSpace(a.ID) == "" {
return fmt.Errorf("artifact id is required")
Expand All @@ -179,6 +197,16 @@ func ValidateArtifact(a Artifact) error {
if err := ValidateRelativePath(a.File); err != nil {
return fmt.Errorf("invalid artifact file %q: %w", a.File, err)
}
if strings.TrimSpace(a.Folder) != "" {
folder, err := NormalizeFolderPath(a.Folder)
if err != nil {
return fmt.Errorf("invalid artifact folder %q: %w", a.Folder, err)
}
file := filepath.ToSlash(filepath.Clean(a.File))
if file != folder && !strings.HasPrefix(file, folder+"/") {
return fmt.Errorf("artifact file %q is not under folder %q", a.File, folder)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if a.Created.IsZero() {
return fmt.Errorf("artifact created time is required")
}
Expand Down Expand Up @@ -225,6 +253,38 @@ func ValidateRelativePath(p string) error {
return nil
}

func NormalizeFolderPath(folder string) (string, error) {
folder = strings.TrimSpace(folder)
if folder == "" {
return "", fmt.Errorf("folder is empty")
}
if filepath.IsAbs(folder) || hasWindowsVolumeName(folder) {
return "", fmt.Errorf("absolute paths are not allowed")
}
slash := strings.ReplaceAll(folder, "\\", "/")
if strings.HasPrefix(slash, "/") {
return "", fmt.Errorf("absolute paths are not allowed")
}
parts := strings.Split(slash, "/")
for _, part := range parts {
if part == "" {
return "", fmt.Errorf("empty path segments are not allowed")
}
if part == "." || part == ".." {
return "", fmt.Errorf("path traversal is not allowed")
}
}
clean := filepath.ToSlash(filepath.Clean(slash))
if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") {
return "", fmt.Errorf("path traversal is not allowed")
}
return clean, nil
}

func hasWindowsVolumeName(p string) bool {
return len(p) >= 2 && p[1] == ':' && unicode.IsLetter(rune(p[0]))
}

func SafeJoin(base, rel string) (string, error) {
if err := ValidateRelativePath(rel); err != nil {
return "", err
Expand Down
Loading
Loading