feat(tasks): add read-only task file browser (GITHUB-3)

This commit is contained in:
ji ruo committed 2026-11-11 14:14:37 +08:00
1 parent 873e3a667b
commit 62ed7db7b2
14 files changed
+1567 -21

No files matched your search

+38
View File
@@ -0,0 +1,38 @@
package server
import (
"bytes"
"io"
"net/http"
"net/url"
"unicode/utf8"
)
func setFileDisposition(w http.ResponseWriter, kind, name string) {
w.Header().Set("Content-Disposition", kind+"; filename=\""+sanitizeFilename(name)+"\"; filename*=UTF-8''"+url.PathEscape(name))
}
// Limit the read itself, not just the preceding stat: running agents may append
// to a file between stat and read.
func writeTextFile(w http.ResponseWriter, path string, r io.Reader, size int64) {
result := map[string]any{"path": path, "size": size, "binary": false}
if size > maxWorkspaceRead {
result["too_large"], result["binary"] = true, true
writeJSON(w, 200, result)
return
}
data, err := io.ReadAll(io.LimitReader(r, maxWorkspaceRead+1))
if err != nil {
writeErr(w, 500, "taskFiles.failed")
return
}
result["size"] = len(data)
if len(data) > maxWorkspaceRead {
result["too_large"], result["binary"] = true, true
} else if bytes.IndexByte(data, 0) >= 0 || !utf8.Valid(data) {
result["binary"] = true
} else {
result["content"] = string(data)
}
writeJSON(w, 200, result)
}
+6
View File
@@ -873,6 +873,12 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/tasks/{id}/intent-assets", s.taskIntentAssets)
// 工作空间文件管理器(针对 workDir)
mux.HandleFunc("GET /api/tasks/{id}/files", s.taskFilesList)
mux.HandleFunc("GET /api/tasks/{id}/files/all", s.taskFilesAll)
mux.HandleFunc("GET /api/tasks/{id}/files/read", s.taskFilesServe)
mux.HandleFunc("GET /api/tasks/{id}/files/download", s.taskFilesServe)
mux.HandleFunc("GET /api/tasks/{id}/files/preview", s.taskFilesServe)
mux.HandleFunc("GET /api/tasks/{id}/files/archive", s.taskFilesArchive)
mux.HandleFunc("GET /api/workspace/list", s.wsList)
mux.HandleFunc("GET /api/workspace/read", s.wsRead)
mux.HandleFunc("POST /api/workspace/write", s.wsWrite)
+445
View File
@@ -0,0 +1,445 @@
package server
import (
"archive/zip"
"container/heap"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path"
"path/filepath"
"sort"
"strconv"
"strings"
)
const maxTaskFiles = 2000
type taskFileEntry struct {
wsEntry
Symlink bool `json:"symlink"`
IntentID int64 `json:"intent_id,omitempty"`
IntentTitle string `json:"intent_title,omitempty"`
IntentState string `json:"intent_state,omitempty"`
}
type taskFileListing struct {
Path string `json:"path"`
Entries []taskFileEntry `json:"entries"`
Total int `json:"total"`
Size int64 `json:"size"`
Truncated bool `json:"truncated"`
Missing bool `json:"missing"`
}
// Reject nonportable paths as well as traversal, rather than silently cleaning
// them. All actual filesystem access goes through os.Root, including traversal.
func taskFilePath(raw string) (string, error) {
if raw == "" {
return ".", nil
}
if !fs.ValidPath(raw) || strings.ContainsAny(raw, "\\:\x00") || !filepath.IsLocal(raw) {
return "", errors.New("invalid path")
}
return raw, nil
}
func taskFileError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, fs.ErrNotExist):
writeErr(w, 404, "taskFiles.unavailable")
case errors.Is(err, fs.ErrPermission):
writeErr(w, 403, "taskFiles.denied")
default:
writeErr(w, 400, "taskFiles.invalidPath")
}
}
// Open the task root through its parent so a task-root symlink cannot escape
// the workspace. Never hold a task operation lock while reading or streaming.
func (s *Server) openTaskFiles(w http.ResponseWriter, r *http.Request) (*os.Root, *Task, string, bool) {
id := r.PathValue("id")
n, err := strconv.ParseInt(id, 10, 64)
if err != nil || n <= 0 || strconv.FormatInt(n, 10) != id {
writeErr(w, 404, "taskFiles.unavailable")
return nil, nil, "", false
}
t, ok := s.m.Task(id)
if !ok {
writeErr(w, 404, "taskFiles.unavailable")
return nil, nil, "", false
}
rel, err := taskFilePath(r.URL.Query().Get("path"))
if err != nil {
taskFileError(w, err)
return nil, nil, "", false
}
base, err := os.OpenRoot(s.m.dir)
if err != nil {
taskFileError(w, err)
return nil, nil, "", false
}
defer base.Close()
name := "tasks/" + id
info, err := base.Lstat(name)
if errors.Is(err, fs.ErrNotExist) {
return nil, t, rel, true
}
if err != nil {
taskFileError(w, err)
return nil, nil, "", false
}
if info.Mode()&os.ModeSymlink != 0 {
taskFileError(w, fs.ErrPermission)
return nil, nil, "", false
}
root, err := base.OpenRoot(name)
if err != nil {
taskFileError(w, err)
return nil, nil, "", false
}
return root, t, rel, true
}
func newTaskFileEntry(name string, info fs.FileInfo) taskFileEntry {
return taskFileEntry{wsEntry: wsEntry{Name: path.Base(name), Path: name, Dir: info.IsDir(), Size: info.Size(), MTime: info.ModTime().UnixMilli()}, Symlink: info.Mode()&os.ModeSymlink != 0}
}
func (s *Server) taskFilesList(w http.ResponseWriter, r *http.Request) {
root, t, rel, ok := s.openTaskFiles(w, r)
if !ok {
return
}
out := taskFileListing{Path: rel, Entries: []taskFileEntry{}}
if rel == "." {
out.Path = ""
}
if root == nil {
if rel != "." {
taskFileError(w, fs.ErrNotExist)
return
}
out.Missing = true
writeJSON(w, 200, out)
return
}
defer root.Close()
dir, err := root.Open(rel)
if err != nil {
taskFileError(w, err)
return
}
defer dir.Close()
entries, err := dir.ReadDir(-1)
if err != nil {
taskFileError(w, err)
return
}
for _, e := range entries {
info, err := e.Info()
if errors.Is(err, fs.ErrNotExist) {
continue
}
if err != nil {
taskFileError(w, err)
return
}
entry := newTaskFileEntry(path.Join(rel, e.Name()), info)
if rel == "." && entry.Dir && strings.HasPrefix(e.Name(), "i") && t.Store != nil {
id, err := strconv.ParseInt(strings.TrimPrefix(e.Name(), "i"), 10, 64)
if err == nil {
node, err := t.Store.GetNode(id)
if err != nil {
writeErr(w, 500, "taskFiles.failed")
return
}
if node != nil && node.Kind == "intent" {
var payload map[string]any
_ = json.Unmarshal(node.Payload, &payload)
entry.IntentID, entry.IntentState = id, node.State
entry.IntentTitle, _ = payload["summary"].(string)
if entry.IntentTitle == "" {
entry.IntentTitle, _ = payload["title"].(string)
}
if entry.IntentTitle == "" {
entry.IntentTitle = rawString(node.Payload)
}
}
}
}
out.Entries = append(out.Entries, entry)
}
sort.Slice(out.Entries, func(i, j int) bool {
a, b := out.Entries[i], out.Entries[j]
if a.Dir != b.Dir {
return a.Dir
}
return a.Name < b.Name
})
writeJSON(w, 200, out)
}
// A bounded min-heap retains the newest entries over the ENTIRE scan, with a
// deterministic path tie-breaker. Counts and bytes always describe all files.
type recentTaskFiles []taskFileEntry
func newerTaskFile(a, b taskFileEntry) bool {
return a.MTime > b.MTime || (a.MTime == b.MTime && a.Path < b.Path)
}
func (h recentTaskFiles) Len() int { return len(h) }
func (h recentTaskFiles) Less(i, j int) bool { return newerTaskFile(h[j], h[i]) }
func (h recentTaskFiles) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *recentTaskFiles) Push(x any) { *h = append(*h, x.(taskFileEntry)) }
func (h *recentTaskFiles) Pop() any {
old := *h
n := len(old)
x := old[n-1]
*h = old[:n-1]
return x
}
func scanTaskFiles(r *http.Request, root *os.Root) (taskFileListing, error) {
out := taskFileListing{Entries: []taskFileEntry{}}
h := recentTaskFiles{}
err := fs.WalkDir(root.FS(), ".", func(name string, d fs.DirEntry, err error) error {
if r.Context().Err() != nil {
return r.Context().Err()
}
if errors.Is(err, fs.ErrNotExist) {
return nil
}
if err != nil {
return err
}
if d.IsDir() {
return nil
}
info, err := d.Info()
if errors.Is(err, fs.ErrNotExist) {
return nil
}
if err != nil {
return err
}
if !info.Mode().IsRegular() {
return nil
}
entry := newTaskFileEntry(name, info)
out.Total++
out.Size += info.Size()
if len(h) < maxTaskFiles {
heap.Push(&h, entry)
} else if newerTaskFile(entry, h[0]) {
h[0] = entry
heap.Fix(&h, 0)
}
return nil
})
sort.Slice(h, func(i, j int) bool { return newerTaskFile(h[i], h[j]) })
out.Entries = append(out.Entries, h...)
out.Truncated = out.Total > maxTaskFiles
return out, err
}
func (s *Server) taskFilesAll(w http.ResponseWriter, r *http.Request) {
root, _, _, ok := s.openTaskFiles(w, r)
if !ok {
return
}
if root == nil {
writeJSON(w, 200, taskFileListing{Entries: []taskFileEntry{}, Missing: true})
return
}
defer root.Close()
out, err := scanTaskFiles(r, root)
if err != nil {
taskFileError(w, err)
return
}
writeJSON(w, 200, out)
}
func (s *Server) taskFilesServe(w http.ResponseWriter, r *http.Request) {
root, _, rel, ok := s.openTaskFiles(w, r)
if !ok {
return
}
if root == nil {
taskFileError(w, fs.ErrNotExist)
return
}
defer root.Close()
// Inspect before opening to avoid blocking on FIFOs and other special files.
info, err := root.Stat(rel)
if err != nil {
taskFileError(w, err)
return
}
if !info.Mode().IsRegular() {
writeErr(w, 400, "taskFiles.notFile")
return
}
f, err := root.Open(rel)
if err != nil {
taskFileError(w, err)
return
}
defer f.Close()
info, err = f.Stat()
if err != nil || !info.Mode().IsRegular() {
writeErr(w, 400, "taskFiles.notFile")
return
}
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Cache-Control", "no-store")
switch path.Base(r.URL.Path) {
case "read":
writeTextFile(w, rel, f, info.Size())
case "preview":
types := map[string]string{".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif"}
want := types[strings.ToLower(path.Ext(rel))]
buf := make([]byte, 512)
n, err := f.Read(buf)
if err != nil && err != io.EOF {
taskFileError(w, err)
return
}
if want == "" || http.DetectContentType(buf[:n]) != want {
writeErr(w, 415, "taskFiles.notImage")
return
}
if _, err := f.Seek(0, io.SeekStart); err != nil {
taskFileError(w, err)
return
}
w.Header().Set("Content-Type", want)
setFileDisposition(w, "inline", path.Base(rel))
http.ServeContent(w, r, path.Base(rel), info.ModTime(), f)
case "download":
w.Header().Set("Content-Type", "application/octet-stream")
setFileDisposition(w, "attachment", path.Base(rel))
http.ServeContent(w, r, path.Base(rel), info.ModTime(), f)
}
}
// Each directory gets a nested root; symlinks are skipped and every file is
// opened through the root. Entries are copied directly into the ZIP stream.
func writeTaskFilesZip(r *http.Request, zw *zip.Writer, root *os.Root, prefix string) error {
d, err := root.Open(".")
if err != nil {
return err
}
defer d.Close()
for {
entries, err := d.ReadDir(128)
if err != nil && err != io.EOF {
return err
}
for _, e := range entries {
if err := r.Context().Err(); err != nil {
return err
}
info, err := root.Lstat(e.Name())
if errors.Is(err, fs.ErrNotExist) {
continue
}
if err != nil {
return err
}
if info.Mode()&os.ModeSymlink != 0 {
continue
}
name := path.Join(prefix, e.Name())
// ZIP names must remain relative and portable when extracted on
// Windows too, even if the producer ran on a Unix filesystem.
if _, err := taskFilePath(name); err != nil {
continue
}
if info.IsDir() {
sub, err := root.OpenRoot(e.Name())
if err != nil {
return err
}
_, err = zw.Create(name + "/")
if err == nil {
err = writeTaskFilesZip(r, zw, sub, name)
}
sub.Close()
if err != nil {
return err
}
} else if info.Mode().IsRegular() {
f, err := root.Open(e.Name())
if err != nil {
return err
}
header, err := zip.FileInfoHeader(info)
if err == nil {
header.Name, header.Method = name, zip.Deflate
var out io.Writer
out, err = zw.CreateHeader(header)
if err == nil {
_, err = io.Copy(out, f)
}
}
f.Close()
if err != nil {
return err
}
}
}
if err == io.EOF {
return nil
}
}
}
func (s *Server) taskFilesArchive(w http.ResponseWriter, r *http.Request) {
root, t, rel, ok := s.openTaskFiles(w, r)
if !ok {
return
}
if root == nil {
taskFileError(w, fs.ErrNotExist)
return
}
defer root.Close()
info, err := root.Lstat(rel)
if err != nil {
taskFileError(w, err)
return
}
if !info.IsDir() {
writeErr(w, 400, "taskFiles.notDirectory")
return
}
sub, err := root.OpenRoot(rel)
if err != nil {
taskFileError(w, err)
return
}
defer sub.Close()
name := path.Base(rel)
if rel == "." {
name = fmt.Sprintf("task-%s", t.ID)
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Cache-Control", "no-store")
setFileDisposition(w, "attachment", name+".zip")
if r.Method == http.MethodHead {
return
}
zw := zip.NewWriter(w)
if err := writeTaskFilesZip(r, zw, sub, ""); err != nil {
// Never finalize a partial ZIP as a successful download.
panic(http.ErrAbortHandler)
}
if err := zw.Close(); err != nil {
panic(http.ErrAbortHandler)
}
}
+424
View File
@@ -0,0 +1,424 @@
package server
import (
"archive/zip"
"bytes"
"context"
"encoding/json"
"fmt"
"image"
"image/png"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
)
// These tests need no database: the task registry and a temporary run directory
// exercise the same filesystem handlers used by the authenticated production mux.
func taskFilesFixture(t *testing.T) (*Server, string, func(string, string, string) *httptest.ResponseRecorder) {
t.Helper()
base := t.TempDir()
m := &Manager{dir: base, tasks: map[string]*Task{"313": {ID: "313"}}}
s := &Server{m: m}
mux := http.NewServeMux()
mux.HandleFunc("GET /api/tasks/{id}/files", s.taskFilesList)
mux.HandleFunc("GET /api/tasks/{id}/files/all", s.taskFilesAll)
for _, action := range []string{"read", "download", "preview"} {
mux.HandleFunc("GET /api/tasks/{id}/files/"+action, s.taskFilesServe)
}
mux.HandleFunc("GET /api/tasks/{id}/files/archive", s.taskFilesArchive)
run := filepath.Join(base, "tasks", "313")
request := func(id, action, path string) *httptest.ResponseRecorder {
t.Helper()
target := "/api/tasks/" + id + "/files"
if action != "" {
target += "/" + action
}
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest("GET", target+"?path="+url.QueryEscape(path), nil))
return rec
}
return s, run, request
}
func taskFileWrite(t *testing.T, base, name string, data []byte) {
t.Helper()
p := filepath.Join(base, name)
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, data, 0644); err != nil {
t.Fatal(err)
}
}
func taskFileListingResult(t *testing.T, rec *httptest.ResponseRecorder) taskFileListing {
t.Helper()
if rec.Code != 200 {
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
}
var result taskFileListing
if err := json.Unmarshal(rec.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
return result
}
func TestTaskFilesValidationAndMissing(t *testing.T) {
_, run, get := taskFilesFixture(t)
for _, id := range []string{"abc", "-1", "0", "0313", "99999999999999999999", "314"} {
if r := get(id, "", ""); r.Code != 404 {
t.Errorf("%s status %d", id, r.Code)
}
}
for _, action := range []string{"", "all"} {
got := taskFileListingResult(t, get("313", action, ""))
if !got.Missing || got.Total != 0 || len(got.Entries) != 0 {
t.Fatalf("missing: %+v", got)
}
}
if r := get("313", "read", "missing"); r.Code != 404 {
t.Fatal(r.Code)
}
taskFileWrite(t, run, "ok.txt", []byte("safe"))
for _, path := range []string{"../secret", "a/../../secret", "/etc/passwd", "C:/secret", `..\secret`, "a//b", "a/../ok.txt", "a\x00b"} {
for _, action := range []string{"", "all", "read", "download", "preview", "archive"} {
if r := get("313", action, path); r.Code != 400 {
t.Errorf("%s %q status %d", action, path, r.Code)
}
}
}
for _, action := range []string{"", "read", "download", "archive"} {
if r := get("313", action, "missing"); r.Code != 404 {
t.Errorf("%s missing %d", action, r.Code)
}
}
if err := os.RemoveAll(run); err != nil {
t.Fatal(err)
}
if r := get("313", "download", "ok.txt"); r.Code != 404 {
t.Fatal(r.Code)
}
}
func TestTaskFilesSymlinkIsolationAndZip(t *testing.T) {
s, run, get := taskFilesFixture(t)
taskFileWrite(t, run, "nested/hello.txt", []byte("hello"))
taskFileWrite(t, run, ".hidden", []byte("hidden"))
taskFileWrite(t, run, `..\unsafe.txt`, []byte("unsafe ZIP name"))
outside := t.TempDir()
taskFileWrite(t, outside, "secret.txt", []byte("SECRET"))
for name, target := range map[string]string{"escape.txt": filepath.Join(outside, "secret.txt"), "escape-dir": outside, "inside": "nested/hello.txt"} {
if err := os.Symlink(target, filepath.Join(run, name)); err != nil {
t.Fatal(err)
}
}
for _, name := range []string{"escape.txt", "escape-dir/secret.txt"} {
for _, action := range []string{"read", "download", "preview", "archive"} {
if r := get("313", action, name); r.Code == 200 {
t.Errorf("escaped %s %s", action, name)
}
}
}
if r := get("313", "", "escape-dir"); r.Code == 200 {
t.Fatal("directory escape")
}
listing := taskFileListingResult(t, get("313", "", ""))
links := 0
for _, e := range listing.Entries {
if e.Symlink {
links++
}
}
if links != 3 {
t.Fatalf("links=%d", links)
}
if r := get("313", "read", "inside"); r.Code != 200 || !strings.Contains(r.Body.String(), "hello") {
t.Fatalf("internal link %d %s", r.Code, r.Body.String())
}
all := taskFileListingResult(t, get("313", "all", ""))
if all.Total != 3 || all.Size != 26 {
t.Fatalf("all: %+v", all)
}
for _, dir := range []string{"", "nested"} {
rec := get("313", "archive", dir)
if rec.Code != 200 || rec.Header().Get("Content-Type") != "application/zip" {
t.Fatal(rec.Code)
}
zr, err := zip.NewReader(bytes.NewReader(rec.Body.Bytes()), int64(rec.Body.Len()))
if err != nil {
t.Fatal(err)
}
found := false
for _, entry := range zr.File {
if strings.Contains(entry.Name, "escape") || entry.Name == "inside" || strings.Contains(entry.Name, "\\") {
t.Fatalf("zipped link %s", entry.Name)
}
if strings.HasSuffix(entry.Name, "hello.txt") {
f, err := entry.Open()
if err != nil {
t.Fatal(err)
}
data, err := io.ReadAll(f)
f.Close()
if err != nil || string(data) != "hello" {
t.Fatalf("ZIP contents %q %v", data, err)
}
found = true
}
}
if !found {
t.Fatal("missing file in zip")
}
}
// A symlink used as the task root must not be followed either.
s.m.tasks["314"] = &Task{ID: "314"}
if err := os.Symlink(outside, filepath.Join(filepath.Dir(run), "314")); err != nil {
t.Fatal(err)
}
if r := get("314", "read", "secret.txt"); r.Code == 200 {
t.Fatal("task root escaped")
}
}
func TestTaskFilesTextAndPreview(t *testing.T) {
_, run, get := taskFilesFixture(t)
cases := []struct {
name string
data []byte
binary, large bool
}{
{"empty", nil, false, false}, {"utf8", []byte("文本\n"), false, false},
{"nul", []byte{'a', 0}, true, false}, {"invalid", []byte{0xff}, true, false},
{"boundary", bytes.Repeat([]byte{'a'}, maxWorkspaceRead), false, false},
{"large", bytes.Repeat([]byte{'a'}, maxWorkspaceRead+1), true, true},
}
for _, tc := range cases {
taskFileWrite(t, run, tc.name, tc.data)
var got struct {
Binary bool `json:"binary"`
Large bool `json:"too_large"`
Content string `json:"content"`
}
rec := get("313", "read", tc.name)
if rec.Code != 200 {
t.Fatal(rec.Code)
}
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got.Binary != tc.binary || got.Large != tc.large || (!tc.binary && got.Content != string(tc.data)) {
t.Fatalf("%s flags %v/%v", tc.name, got.Binary, got.Large)
}
}
// Content and extension must agree; HTML/SVG never render inline.
for _, name := range []string{"x.html", "x.svg", "spoof.png"} {
taskFileWrite(t, run, name, []byte("<html>unsafe</html>"))
if r := get("313", "preview", name); r.Code != 415 {
t.Fatalf("%s %d", name, r.Code)
}
}
var imageData bytes.Buffer
if err := png.Encode(&imageData, image.NewRGBA(image.Rect(0, 0, 2, 2))); err != nil {
t.Fatal(err)
}
taskFileWrite(t, run, "image.png", imageData.Bytes())
rec := get("313", "preview", "image.png")
if rec.Code != 200 || rec.Header().Get("Content-Type") != "image/png" || rec.Header().Get("X-Content-Type-Options") != "nosniff" {
t.Fatalf("preview %d %v", rec.Code, rec.Header())
}
rec = get("313", "download", "spoof.png")
if rec.Code != 200 || !strings.HasPrefix(rec.Header().Get("Content-Disposition"), "attachment;") || rec.Header().Get("Content-Type") != "application/octet-stream" {
t.Fatalf("download %d %v", rec.Code, rec.Header())
}
// Simulate growth after the stat: the shared reader must still cap allocation.
rec = httptest.NewRecorder()
writeTextFile(rec, "growing", bytes.NewReader(bytes.Repeat([]byte{'a'}, maxWorkspaceRead+100)), 1)
if !strings.Contains(rec.Body.String(), `"too_large":true`) {
t.Fatal("growing file not capped")
}
}
func TestTaskFilesRecentTruncation(t *testing.T) {
_, run, get := taskFilesFixture(t)
stamp := time.Unix(1700000000, 0)
for i := 0; i < 2005; i++ {
name := fmt.Sprintf("%04d.txt", i)
taskFileWrite(t, run, name, []byte("x"))
tm := stamp.Add(time.Duration(i) * time.Second)
if err := os.Chtimes(filepath.Join(run, name), tm, tm); err != nil {
t.Fatal(err)
}
}
all := taskFileListingResult(t, get("313", "all", ""))
if !all.Truncated || all.Total != 2005 || all.Size != 2005 || len(all.Entries) != 2000 {
t.Fatalf("counts: total=%d entries=%d truncated=%v", all.Total, len(all.Entries), all.Truncated)
}
if all.Entries[0].Name != "2004.txt" || all.Entries[1999].Name != "0005.txt" {
t.Fatal("truncated before sorting")
}
for i := 1; i < len(all.Entries); i++ {
if newerTaskFile(all.Entries[i], all.Entries[i-1]) {
t.Fatal("not sorted")
}
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
root, err := os.OpenRoot(run)
if err != nil {
t.Fatal(err)
}
defer root.Close()
if _, err := scanTaskFiles(httptest.NewRequest("GET", "/", nil).WithContext(ctx), root); err == nil {
t.Fatal("ignored cancellation")
}
}
func TestTaskFilesAuthAndHead(t *testing.T) {
s, run, _ := taskFilesFixture(t)
s.jwtKey = []byte("task-files-test-signing-key")
taskFileWrite(t, run, "readme.txt", []byte("read only"))
mux := http.NewServeMux()
mux.HandleFunc("GET /api/tasks/{id}/files", s.taskFilesList)
mux.HandleFunc("GET /api/tasks/{id}/files/all", s.taskFilesAll)
for _, action := range []string{"read", "download", "preview"} {
mux.HandleFunc("GET /api/tasks/{id}/files/"+action, s.taskFilesServe)
}
mux.HandleFunc("GET /api/tasks/{id}/files/archive", s.taskFilesArchive)
handler := s.requireAuth(mux)
for _, action := range []string{"", "/all", "/read", "/download", "/preview", "/archive"} {
req := httptest.NewRequest("GET", "/api/tasks/313/files"+action+"?path=readme.txt", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != 401 {
t.Fatalf("unauthenticated %s: %d", action, rec.Code)
}
}
token, err := signJWT(s.jwtKey)
if err != nil {
t.Fatal(err)
}
for _, action := range []string{"archive", "download"} {
path := "readme.txt"
if action == "archive" {
path = ""
}
req := httptest.NewRequest("HEAD", "/api/tasks/313/files/"+action+"?path="+path, nil)
req.AddCookie(&http.Cookie{Name: "artex_token", Value: token})
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != 200 || rec.Body.Len() != 0 {
t.Fatalf("HEAD %s %d body=%d", action, rec.Code, rec.Body.Len())
}
}
}
// Opt-in, strictly read-only smoke check against an existing run directory.
func TestTaskFilesRealDirectory(t *testing.T) {
base := os.Getenv("ARTEX_TASK_FILES_TEST_DIR")
if base == "" {
t.Skip("set ARTEX_TASK_FILES_TEST_DIR to read an existing task 313 directory")
}
m := &Manager{dir: base, tasks: map[string]*Task{"313": {ID: "313"}}}
s := &Server{m: m}
mux := http.NewServeMux()
mux.HandleFunc("GET /api/tasks/{id}/files", s.taskFilesList)
mux.HandleFunc("GET /api/tasks/{id}/files/all", s.taskFilesAll)
mux.HandleFunc("GET /api/tasks/{id}/files/read", s.taskFilesServe)
mux.HandleFunc("GET /api/tasks/{id}/files/download", s.taskFilesServe)
mux.HandleFunc("GET /api/tasks/{id}/files/preview", s.taskFilesServe)
mux.HandleFunc("GET /api/tasks/{id}/files/archive", s.taskFilesArchive)
get := func(action, path string) *httptest.ResponseRecorder {
target := "/api/tasks/313/files"
if action != "" {
target += "/" + action
}
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest("GET", target+"?path="+url.QueryEscape(path), nil))
return rec
}
root := taskFileListingResult(t, get("", ""))
all := taskFileListingResult(t, get("all", ""))
if len(root.Entries) == 0 || all.Total == 0 {
t.Fatal("real directory is empty")
}
textChecked, imageChecked := false, false
for _, entry := range all.Entries {
if !textChecked && strings.HasSuffix(entry.Path, ".txt") {
if rec := get("read", entry.Path); rec.Code != 200 {
t.Fatal("real text preview", rec.Code)
}
rec := get("download", entry.Path)
data, err := os.ReadFile(filepath.Join(base, "tasks", "313", entry.Path))
if err != nil {
t.Fatal(err)
}
if rec.Code != 200 || !bytes.Equal(rec.Body.Bytes(), data) {
t.Fatal("real download mismatch")
}
textChecked = true
}
if !imageChecked && strings.HasSuffix(entry.Path, ".jpg") {
if rec := get("preview", entry.Path); rec.Code == 200 {
imageChecked = true
}
}
}
rec := get("archive", "")
if rec.Code != 200 {
t.Fatal("real zip", rec.Code)
}
zr, err := zip.NewReader(bytes.NewReader(rec.Body.Bytes()), int64(rec.Body.Len()))
if err != nil {
t.Fatal(err)
}
count := 0
for _, file := range zr.File {
if file.FileInfo().IsDir() {
continue
}
f, err := file.Open()
if err != nil {
t.Fatal(err)
}
_, err = io.Copy(io.Discard, f)
f.Close()
if err != nil {
t.Fatal(err)
}
count++
}
if count != all.Total || !textChecked {
t.Fatalf("real smoke: zip=%d total=%d text=%v", count, all.Total, textChecked)
}
t.Logf("read-only task 313: root entries=%d files=%d bytes=%d zip files=%d text/download=%v valid JPEG=%v", len(root.Entries), all.Total, all.Size, count, textChecked, imageChecked)
}
func TestTaskFilesIntentMetadata(t *testing.T) {
m, err := NewManager(t.TempDir(), "")
if err != nil {
t.Skipf("isolated PostgreSQL required: %v", err)
}
defer m.Close()
task, err := m.CreateTask("task files metadata", "", nil, 0, 0)
if err != nil {
t.Fatal(err)
}
numericID, _ := strconv.ParseInt(task.ID, 10, 64)
defer m.pg.DeleteTask(numericID)
id, err := task.Store.AddIntent(map[string]any{"summary": "Readable intent title"}, 1, nil, "planner")
if err != nil {
t.Fatal(err)
}
run := filepath.Join(m.dir, "tasks", task.ID)
taskFileWrite(t, run, fmt.Sprintf("i%d/output.txt", id), []byte("result"))
req := httptest.NewRequest("GET", "/api/tasks/"+task.ID+"/files", nil)
req.SetPathValue("id", task.ID)
rec := httptest.NewRecorder()
(&Server{m: m}).taskFilesList(rec, req)
listing := taskFileListingResult(t, rec)
if len(listing.Entries) != 1 || listing.Entries[0].IntentID != id || listing.Entries[0].IntentTitle != "Readable intent title" || listing.Entries[0].IntentState != "open" {
t.Fatalf("intent metadata: %+v", listing.Entries)
}
}
+4 -14
View File
@@ -1,18 +1,15 @@
package server
import (
"bytes"
"fmt"
"io"
"mime/multipart"
"net/http"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"time"
"unicode/utf8"
)
// Workspace file manager — browse / view / edit / download / upload / delete the
@@ -119,20 +116,13 @@ func (s *Server) wsRead(w http.ResponseWriter, r *http.Request) {
writeErr(w, 400, "是目录,不能作为文件读取")
return
}
if fi.Size() > maxWorkspaceRead {
writeJSON(w, 200, map[string]any{"path": s.wsRel(abs), "size": fi.Size(), "too_large": true, "binary": true})
return
}
data, err := os.ReadFile(abs)
f, err := os.Open(abs)
if err != nil {
writeErr(w, 500, err.Error())
return
}
if bytes.IndexByte(data, 0) >= 0 || !utf8.Valid(data) {
writeJSON(w, 200, map[string]any{"path": s.wsRel(abs), "size": fi.Size(), "binary": true})
return
}
writeJSON(w, 200, map[string]any{"path": s.wsRel(abs), "size": fi.Size(), "binary": false, "content": string(data)})
defer f.Close()
writeTextFile(w, s.wsRel(abs), f, fi.Size())
}
// POST /api/workspace/write {path, content} — create/overwrite a text file.
@@ -223,7 +213,7 @@ func (s *Server) wsDownload(w http.ResponseWriter, r *http.Request) {
}
name := filepath.Base(abs)
// RFC 5987 filename* keeps non-ASCII names intact; plain filename is the fallback.
w.Header().Set("Content-Disposition", "attachment; filename=\""+sanitizeFilename(name)+"\"; filename*=UTF-8''"+url.PathEscape(name))
setFileDisposition(w, "attachment", name)
http.ServeFile(w, r, abs)
}
@@ -0,0 +1,366 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import {
ChevronDownIcon,
ChevronRightIcon,
CopyIcon,
DownloadIcon,
FileIcon,
FolderIcon,
RefreshCwIcon,
} from "lucide-react";
import { toast } from "sonner";
import { PacketCodeEditor } from "@/components/packet-code-editor";
import { StatusBadge } from "@/components/status-badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { useUITranslation } from "@/i18n/use-ui-translation";
import { api } from "@/lib/api";
import type { Task, TaskFileEntry, TaskFileListing, WorkspaceFile } from "@/lib/types";
function fileSize(n: number) {
if (n < 1024) return `${n} B`;
if (n < 1024 ** 2) return `${(n / 1024).toFixed(1)} KiB`;
return `${(n / 1024 ** 2).toFixed(1)} MiB`;
}
const isImage = (path: string) => /\.(png|jpe?g|webp|gif)$/i.test(path);
export function FilesTab({ task, active }: { task: Task; active: boolean }) {
const { ui, liveUI, i18n } = useUITranslation();
const router = useRouter();
const searchParams = useSearchParams();
const requestedPath = searchParams.get("path") ?? "";
const [mode, setMode] = React.useState<"tree" | "recent">("tree");
const [listings, setListings] = React.useState<Record<string, TaskFileEntry[]>>({});
const [expanded, setExpanded] = React.useState<string[]>([]);
const [summary, setSummary] = React.useState<TaskFileListing | null>(null);
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState("");
const [preview, setPreview] = React.useState<{ path: string; file?: WorkspaceFile; error?: string } | null>(null);
const generation = React.useRef(0);
const previewGeneration = React.useRef(0);
const message = React.useCallback(
(err: unknown) => {
const raw = (err as Error).message;
return raw.startsWith("taskFiles.") ? liveUI(raw) : liveUI("taskFiles.failed");
},
[liveUI],
);
const refresh = React.useCallback(async () => {
const seq = ++generation.current;
setLoading(true);
try {
const [all, ...dirs] = await Promise.all([
api.taskFilesAll(task.id),
...["", ...expanded].map(async (path) => {
try {
return { path, result: await api.taskFiles(task.id, path) };
} catch (err) {
if (path === "") throw err;
return { path, result: null };
}
}),
]);
if (generation.current !== seq) return;
setSummary(all);
setListings(Object.fromEntries(dirs.filter((d) => d.result).map((d) => [d.path, d.result?.entries ?? []])));
setError(dirs.some((d) => !d.result) ? liveUI("taskFiles.pathGone") : "");
} catch (err) {
if (generation.current === seq) setError(message(err));
} finally {
if (generation.current === seq) setLoading(false);
}
}, [task.id, expanded, liveUI, message]);
React.useEffect(() => {
if (!active) return;
void refresh();
return () => {
generation.current++;
};
}, [active, refresh]);
React.useEffect(() => {
if (!active || task.status !== "running" || task.paused) return;
const timer = setInterval(() => {
if (document.visibilityState === "visible") void refresh();
}, 5000);
return () => clearInterval(timer);
}, [active, task.status, task.paused, refresh]);
const openPreview = React.useCallback(
async (path: string) => {
const seq = ++previewGeneration.current;
setPreview({ path });
if (isImage(path)) return;
try {
const file = await api.taskFileRead(task.id, path);
if (previewGeneration.current === seq) setPreview({ path, file });
} catch (err) {
if (previewGeneration.current === seq) setPreview({ path, error: message(err) });
}
},
[task.id, message],
);
React.useEffect(() => {
if (!active || !requestedPath) return;
let alive = true;
const parts = requestedPath.split("/");
const ancestors = parts.slice(0, -1).map((_, i) => parts.slice(0, i + 1).join("/"));
void api
.taskFiles(task.id, requestedPath)
.then(() => {
if (alive) setExpanded((prev) => Array.from(new Set([...prev, ...ancestors, requestedPath])));
})
.catch(() => {
if (!alive) return;
setExpanded((prev) => Array.from(new Set([...prev, ...ancestors])));
void openPreview(requestedPath);
});
return () => {
alive = false;
};
}, [active, task.id, requestedPath, openPreview]);
const locate = (path: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set("tab", "files");
if (path) params.set("path", path);
else params.delete("path");
router.replace(`/function/tasks/detail?${params}`, { scroll: false });
};
const workspacePath = (path: string) => `tasks/${task.id}${path ? `/${path}` : ""}`;
const download = (path: string, archive = false) => {
void api.taskFileDownload(task.id, path, archive).catch((err: unknown) => toast.error(message(err)));
};
const name = (entry: TaskFileEntry) => {
if (entry.intent_id) return ui("taskFiles.intent", { id: entry.intent_id, title: entry.intent_title ?? "" });
const key: Record<string, string> = {
uploads: "uploads",
"cmd-output": "commands",
"findings/screenshots": "screenshots",
};
return key[entry.path] ? ui(`taskFiles.${key[entry.path]}`) : entry.name;
};
const rows: { entry: TaskFileEntry; depth: number }[] = [];
const append = (path: string, depth: number) => {
for (const entry of listings[path] ?? []) {
rows.push({ entry, depth });
if (entry.dir && expanded.includes(entry.path)) append(entry.path, depth + 1);
}
};
if (mode === "tree") append("", 0);
else for (const entry of summary?.entries ?? []) rows.push({ entry, depth: 0 });
return (
<div className="flex flex-col gap-4">
<p className="text-muted-foreground text-sm">{ui("taskFiles.scope")}</p>
<div className="flex flex-wrap items-center gap-2">
<Button variant={mode === "tree" ? "secondary" : "ghost"} onClick={() => setMode("tree")}>
{ui("taskFiles.tree")}
</Button>
<Button variant={mode === "recent" ? "secondary" : "ghost"} onClick={() => setMode("recent")}>
{ui("taskFiles.recent")}
</Button>
<span className="mr-auto text-muted-foreground text-sm">
{summary && ui("taskFiles.summary", { count: summary.total, size: fileSize(summary.size) })}
</span>
<Button variant="outline" disabled={loading} onClick={() => void refresh()}>
<RefreshCwIcon />
{ui("taskFiles.refresh")}
</Button>
<Button variant="outline" disabled={summary?.missing ?? true} onClick={() => download("", true)}>
<DownloadIcon />
{ui("taskFiles.archiveAll")}
</Button>
<Button variant="outline" asChild>
<Link href={`/function/workspace?path=${encodeURIComponent(workspacePath(""))}`}>
{ui("taskFiles.workspace")}
</Link>
</Button>
</div>
{error && (
<p role="alert" className="text-destructive text-sm">
{error}
</p>
)}
{mode === "recent" && summary?.truncated && (
<p className="text-muted-foreground text-sm">{ui("taskFiles.truncated")}</p>
)}
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>{ui("taskFiles.name")}</TableHead>
<TableHead>{ui("taskFiles.size")}</TableHead>
<TableHead>{ui("taskFiles.modified")}</TableHead>
<TableHead>{ui("taskFiles.actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{!rows.length && (
<TableRow>
<TableCell colSpan={4} className="py-12 text-center text-muted-foreground">
{loading ? ui("taskFiles.loading") : ui("taskFiles.empty")}
</TableCell>
</TableRow>
)}
{rows.map(({ entry, depth }) => (
<TableRow key={entry.path}>
<TableCell>
<div className="flex items-center gap-2" style={{ paddingLeft: depth * 20 }}>
<button
type="button"
className="flex min-w-0 items-center gap-2 text-left hover:underline"
disabled={entry.symlink}
aria-expanded={entry.dir ? expanded.includes(entry.path) : undefined}
onClick={() => {
if (entry.dir) {
if (expanded.includes(entry.path))
setExpanded((prev) =>
prev.filter((p) => p !== entry.path && !p.startsWith(`${entry.path}/`)),
);
else {
setExpanded((prev) => [...prev, entry.path]);
locate(entry.path);
}
} else {
locate(entry.path);
}
}}
>
{entry.dir ? (
<>
{expanded.includes(entry.path) ? (
<ChevronDownIcon className="size-4" />
) : (
<ChevronRightIcon className="size-4" />
)}
<FolderIcon className="size-4 shrink-0" />
</>
) : (
<FileIcon className="size-4 shrink-0" />
)}
<span className="max-w-lg truncate" title={entry.path}>
{mode === "recent" ? entry.path : name(entry)}
</span>
</button>
{entry.symlink && <span className="text-muted-foreground text-xs">{ui("taskFiles.symlink")}</span>}
{entry.intent_state && <StatusBadge domain="intent" value={entry.intent_state} />}
{entry.intent_id && (
<Link
className="whitespace-nowrap text-xs underline"
href={`/function/tasks/detail?id=${task.id}&tab=sessions&session=intent:${entry.intent_id}`}
>
{ui("taskFiles.session")}
</Link>
)}
</div>
</TableCell>
<TableCell className="whitespace-nowrap text-muted-foreground">
{entry.dir || entry.symlink ? "—" : fileSize(entry.size)}
</TableCell>
<TableCell className="whitespace-nowrap text-muted-foreground">
{new Date(entry.mtime).toLocaleString(i18n.language)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
disabled={entry.symlink}
onClick={() => download(entry.path, entry.dir)}
title={entry.dir ? ui("taskFiles.archive") : ui("taskFiles.download")}
aria-label={entry.dir ? ui("taskFiles.archive") : ui("taskFiles.download")}
>
<DownloadIcon />
</Button>
<Button
variant="ghost"
size="sm"
title={ui("taskFiles.copy")}
aria-label={ui("taskFiles.copy")}
onClick={() => {
void navigator.clipboard
.writeText(workspacePath(entry.path))
.then(() => toast.success(ui("taskFiles.copied")))
.catch(() => toast.error(ui("taskFiles.copyFailed")));
}}
>
<CopyIcon />
</Button>
{!entry.symlink && (
<Link
className="whitespace-nowrap text-xs underline"
href={`/function/workspace?path=${encodeURIComponent(workspacePath(entry.path))}`}
>
{ui("taskFiles.workspace")}
</Link>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<Dialog
open={!!preview}
onOpenChange={(open) => {
if (!open) {
previewGeneration.current++;
setPreview(null);
locate("");
}
}}
>
<DialogContent className="flex h-[80vh] flex-col sm:max-w-5xl">
<DialogHeader>
<DialogTitle className="truncate pr-6">{preview?.path}</DialogTitle>
</DialogHeader>
{preview && (
<div className="flex gap-2">
<Button variant="outline" onClick={() => download(preview.path)}>
{ui("taskFiles.download")}
</Button>
<Button variant="outline" asChild>
<Link href={`/function/workspace?path=${encodeURIComponent(workspacePath(preview.path))}`}>
{ui("taskFiles.workspace")}
</Link>
</Button>
</div>
)}
<div className="min-h-0 flex-1 overflow-auto">
{preview?.error && <p role="alert">{preview.error}</p>}
{preview && !preview.error && isImage(preview.path) && (
// biome-ignore lint/performance/noImgElement: authenticated raster preview
<img
src={api.taskFilePreview(task.id, preview.path)}
alt={preview.path}
className="mx-auto max-h-full object-contain"
onError={() => setPreview((prev) => (prev ? { ...prev, error: ui("taskFiles.imageFailed") } : prev))}
/>
)}
{preview && !preview.error && !isImage(preview.path) && !preview.file && <p>{ui("taskFiles.loading")}</p>}
{preview?.file && !preview.error && (preview.file.binary || preview.file.too_large) && (
<p>{ui(preview.file.too_large ? "taskFiles.tooLarge" : "taskFiles.binary")}</p>
)}
{preview?.file && !preview.error && !preview.file.binary && !preview.file.too_large && (
<PacketCodeEditor value={preview.file.content ?? ""} readOnly ariaLabel={preview.path} />
)}
</div>
</DialogContent>
</Dialog>
</div>
);
}
@@ -44,6 +44,7 @@ import type { LLMProfile, Task } from "@/lib/types";
import { AssetsTab } from "./_tabs/assets-tab";
import { BroadcastTab } from "./_tabs/broadcast-tab";
import { CoverageGraphTab } from "./_tabs/coverage-graph-tab";
import { FilesTab } from "./_tabs/files-tab";
import { FindingsTab } from "./_tabs/findings-tab";
import { GraphTab } from "./_tabs/graph-tab";
import { InterceptTab } from "./_tabs/intercept-tab";
@@ -61,6 +62,7 @@ const TABS = (ui: UITranslate) => [
{ value: "intelligence", label: ui("screen.bfaee3ed1f063c") },
{ value: "retests", label: ui("screen.d95315ddeb80c2") },
{ value: "traffic", label: ui("screen.81a9d0b5a2a24f") },
{ value: "files", label: ui("taskFiles.tab") },
{ value: "assets", label: ui("screen.e3a1dbbaefc3d7") },
{ value: "coverage", label: ui("screen.8bd56c272abd3c") },
{ value: "intercept", label: ui("screen.b4c245b6421a35") },
@@ -522,6 +524,9 @@ function TaskDetailInner() {
<TabsContent value="retests" className="mt-0">
<RetestsTab key={id} taskId={id} />
</TabsContent>
<TabsContent value="files" className="mt-0">
<FilesTab key={id} task={task} active={tab === "files"} />
</TabsContent>
<TabsContent value="assets" className="mt-0">
<AssetsTab taskId={id} />
</TabsContent>
+38 -3
View File
@@ -1,6 +1,8 @@
"use client";
import * as React from "react";
import { useSearchParams } from "next/navigation";
import {
DownloadIcon,
FileIcon,
@@ -49,7 +51,9 @@ type EditState = {
saving: boolean;
};
export default function WorkspacePage() {
function WorkspaceInner() {
const searchParams = useSearchParams();
const requestedPath = searchParams.get("path") ?? "";
const { ui, liveUI } = useUITranslation();
const [path, setPath] = React.useState("");
@@ -76,8 +80,31 @@ export default function WorkspacePage() {
);
React.useEffect(() => {
load("");
}, [load]);
let alive = true;
// A path may identify a file or a directory. Directory listing wins;
// otherwise open the file and show its containing directory.
void api
.workspaceList(requestedPath)
.then((result) => {
if (!alive) return;
setEntries(result.entries);
setPath(result.path);
setLoading(false);
})
.catch(async () => {
if (!alive) return;
load(requestedPath.split("/").slice(0, -1).join("/"));
try {
const file = await api.workspaceRead(requestedPath);
if (alive) setEdit({ file, content: file.content ?? "", dirty: false, saving: false });
} catch (error) {
if (alive) toast.error((error as Error).message);
}
});
return () => {
alive = false;
};
}, [load, requestedPath]);
const crumbs = React.useMemo(() => {
const parts = path ? path.split("/") : [];
@@ -358,3 +385,11 @@ export default function WorkspacePage() {
</div>
);
}
export default function WorkspacePage() {
return (
<React.Suspense fallback={null}>
<WorkspaceInner />
</React.Suspense>
);
}
+2 -1
View File
@@ -118,9 +118,10 @@ test("all task detail tabs render their English fixed UI", async () => {
["intercept", "InterceptTab"],
["report", "ReportTab"],
["graph", "GraphTab"],
["files", "FilesTab"],
]) {
const Component = load(base + `/src/app/(main)/function/tasks/detail/_tabs/${file}-tab.tsx`)[name];
const html = visible(render(Component, { taskId: "1" }));
const html = visible(render(Component, { taskId: "1", task: { id: "1", status: "done" }, active: true }));
assert.doesNotMatch(html, /[\p{Script=Han}]/u, `${name}: ${html}`);
}
const { IntelligencePanel } = load(base + "/src/components/intelligence-panel.tsx");
+59
View File
@@ -1,4 +1,63 @@
export const taskMessages = {
"taskFiles.tab": ["文件", "Files"],
"taskFiles.tree": ["目录树", "Directory tree"],
"taskFiles.recent": ["最近文件", "Recent files"],
"taskFiles.summary": ["{{count}} 个文件 · {{size}}", "{{count}} files · {{size}}"],
"taskFiles.refresh": ["刷新", "Refresh"],
"taskFiles.archiveAll": ["下载整个任务 ZIP", "Download task ZIP"],
"taskFiles.workspace": ["在工作空间中打开", "Open in workspace"],
"taskFiles.scope": [
"显示任务运行目录中的文件;写到目录外的文件不会出现在此处。",
"Files in this task’s run directory. Files written elsewhere are not included.",
],
"taskFiles.truncated": [
"仅显示最新的 2000 个文件;统计包含全部文件。",
"Showing the latest 2,000 files; totals include all files.",
],
"taskFiles.name": ["名称", "Name"],
"taskFiles.size": ["大小", "Size"],
"taskFiles.modified": ["修改时间", "Modified"],
"taskFiles.actions": ["操作", "Actions"],
"taskFiles.loading": ["正在加载…", "Loading…"],
"taskFiles.empty": [
"任务尚未产生文件或此目录为空。",
"This task has not produced files, or this directory is empty.",
],
"taskFiles.symlink": ["符号链接(不跟随)", "Symbolic link (not followed)"],
"taskFiles.intent": ["意图 #{{id}} · {{title}}", "Intent #{{id}} · {{title}}"],
"taskFiles.session": ["查看会话", "View session"],
"taskFiles.uploads": ["上传附件", "Uploaded attachments"],
"taskFiles.commands": ["命令输出", "Command output"],
"taskFiles.screenshots": ["报告截图", "Report screenshots"],
"taskFiles.archive": ["下载目录 ZIP", "Download directory ZIP"],
"taskFiles.download": ["下载", "Download"],
"taskFiles.copy": ["复制工作空间相对路径", "Copy workspace-relative path"],
"taskFiles.copied": ["路径已复制", "Path copied"],
"taskFiles.copyFailed": ["无法复制路径,请检查剪贴板权限。", "Could not copy the path. Check clipboard permissions."],
"taskFiles.tooLarge": ["文件超过 2 MiB,请下载后查看。", "This file exceeds 2 MiB. Download it to view."],
"taskFiles.binary": ["二进制文件,请下载后查看。", "Binary file. Download it to view."],
"taskFiles.imageFailed": [
"图片无法预览,文件可能已消失或内容不是支持的图片。",
"Cannot preview this image. It may be missing or contain unsupported data.",
],
"taskFiles.unavailable": [
"任务已删除或归档,或文件已不存在。",
"The task was deleted or archived, or the file no longer exists.",
],
"taskFiles.denied": ["无法访问该文件或符号链接目标。", "Access to this file or symbolic link target is denied."],
"taskFiles.invalidPath": ["路径无效或无法访问。", "Invalid or inaccessible path."],
"taskFiles.notFile": ["请选择普通文件。", "Select a regular file."],
"taskFiles.notDirectory": ["请选择目录进行打包。", "Select a directory to archive."],
"taskFiles.notImage": [
"仅支持 PNG、JPEG、WebP 和 GIF 图片预览。",
"Only PNG, JPEG, WebP and GIF images can be previewed.",
],
"taskFiles.failed": ["文件加载失败,请刷新重试。", "Could not load files. Refresh to try again."],
"taskFiles.pathGone": [
"部分目录已消失或无法访问,请重新选择目录。",
"Some directories are missing or inaccessible. Select another directory.",
],
"taskUI.filesDeleted": ["删除任务文件", "Task files deleted"],
"taskUI.taskDeleted": ["任务已删除", "Task deleted"],
"taskUI.created": ["已创建", "Created"],
+29 -1
View File
@@ -5,7 +5,7 @@
import type { ChatMention } from "@/lib/chat-mentions";
import { MOCK } from "@/lib/mock/enabled";
import { mockHandle } from "@/lib/mock/handler";
import { mockHandle, mockTaskFileURL } from "@/lib/mock/handler";
import type {
ActiveFindingRetest,
Activity,
@@ -98,6 +98,7 @@ import type {
TaskAssetScopeMutation,
TaskCategory,
TaskConstraint,
TaskFileListing,
TaskGoal,
TaskIntelligence,
TaskLLMResolutions,
@@ -596,6 +597,33 @@ export const api = {
// 某资产在本任务里关联的意图 / 事实 / 发现(覆盖图节点抽屉用)。
taskAssetRefs: (id: string, assetId: number) => get<CoverageAssetRefs>(`/tasks/${id}/asset-refs?asset_id=${assetId}`),
taskFiles: (id: string, path = "") => get<TaskFileListing>(`/tasks/${id}/files?path=${encodeURIComponent(path)}`),
taskFilesAll: (id: string) => get<TaskFileListing>(`/tasks/${id}/files/all`),
taskFileRead: (id: string, path: string) =>
get<WorkspaceFile>(`/tasks/${id}/files/read?path=${encodeURIComponent(path)}`),
// Browser downloads stream to disk using the same auth cookie as SSE. Avoid
// fetching ZIPs into a Blob, which would buffer the entire archive in memory.
taskFileDownload: async (id: string, path: string, archive = false) => {
const action = archive ? "archive" : "download";
if (!MOCK) {
const token = getToken();
const response = await apiFetch(`/api/tasks/${id}/files/${action}?path=${encodeURIComponent(path)}`, {
method: "HEAD",
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!response.ok) throw new Error(response.status === 404 ? "taskFiles.unavailable" : "taskFiles.failed");
}
const a = document.createElement("a");
a.href = MOCK ? mockTaskFileURL(path, action) : `/api/tasks/${id}/files/${action}?path=${encodeURIComponent(path)}`;
a.download = archive ? `${path.split("/").pop() || `task-${id}`}.zip` : path.split("/").pop() || "download";
document.body.appendChild(a);
a.click();
a.remove();
if (MOCK && a.href.startsWith("blob:")) setTimeout(() => URL.revokeObjectURL(a.href), 60_000);
},
taskFilePreview: (id: string, path: string) =>
MOCK ? mockTaskFileURL(path, "preview") : `/api/tasks/${id}/files/preview?path=${encodeURIComponent(path)}`,
// ---- workspace file manager (workDir) ----
workspaceList: (path = "") => get<WorkspaceListing>(`/workspace/list?path=${encodeURIComponent(path)}`),
workspaceRead: (path: string) => get<WorkspaceFile>(`/workspace/read?path=${encodeURIComponent(path)}`),
+28 -2
View File
@@ -38,6 +38,7 @@ import { MAX_TASK_REMARK_RUNES, MAX_TASK_WORKER_COUNT } from "../types";
import * as D from "./data";
import { createMockNotifications } from "./notifications";
import { createMockSchedules } from "./schedules";
import { mockTaskFileRead, mockTaskFiles } from "./task-files";
const delay = (ms = 120) => new Promise((r) => setTimeout(r, ms));
@@ -1060,6 +1061,13 @@ export async function mockHandle<T>(method: string, rawPath: string, body?: Body
function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Record<string, unknown>): unknown {
const task = q.get("task") ?? undefined;
if (seg[0] === "tasks" && seg[2] === "files") {
if (!mockTasks.some((item) => item.id === seg[1] || mockTaskAssetID(item.id) === Number(seg[1])))
throw new Error("taskFiles.unavailable");
return seg[3] === "read"
? mockTaskFileRead(q.get("path") ?? "")
: mockTaskFiles(q.get("path") ?? "", seg[3] === "all");
}
if (seg[0] === "schedules") return mockSchedules(m, path, b, new Set(mockTaskAssetIDs.values()));
@@ -1783,8 +1791,24 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
};
// ── 工作空间文件管理器(demo:静态示例树;写/建/删走下方写兜底 {ok:true})──
if (path === "/workspace/list") return D.workspaceList(q.get("path") ?? "");
if (path === "/workspace/read") return D.workspaceRead(q.get("path") ?? "");
if (path === "/workspace/list") {
const value = q.get("path") ?? "";
const taskPath = value.match(/^tasks\/[^/]+(?:\/(.*))?$/);
if (taskPath) {
const result = mockTaskFiles(taskPath[1] ?? "");
return {
...result,
path: value,
entries: result.entries.map((entry) => ({ ...entry, path: `tasks/${value.split("/")[1]}/${entry.path}` })),
};
}
return D.workspaceList(value);
}
if (path === "/workspace/read") {
const value = q.get("path") ?? "";
if (value.startsWith("tasks/")) return { ...mockTaskFileRead(value.split("/").slice(2).join("/")), path: value };
return D.workspaceRead(value);
}
// ── stats ──
if (path === "/stats") {
@@ -2804,3 +2828,5 @@ function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Re
? []
: {};
}
export { mockTaskFileURL } from "./task-files";
+107
View File
@@ -0,0 +1,107 @@
import type { TaskFileEntry, TaskFileListing, WorkspaceFile } from "../types";
const contents: Record<string, string> = {
"notes.md": "# Task artifacts\n\nRead-only task file browser demo.\n",
"i12/recon.txt": "acme.com\nwww.acme.com\napi.acme.com\n",
"uploads/scope.txt": "https://www.acme.com\n",
"cmd-output/scan.log": "[demo] Scan complete: 3 hosts discovered.\n",
};
const screenshot =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jRZkAAAAASUVORK5CYII=";
const paths = [
"i12",
"uploads",
"cmd-output",
"findings",
"findings/screenshots",
...Object.keys(contents),
"findings/screenshots/finding-1-step-1.png",
];
const entries: TaskFileEntry[] = paths.map((path, i) => ({
path,
name: path.split("/").pop() ?? path,
dir: !path.includes("."),
size: contents[path]?.length ?? 68,
mtime: Date.parse("2026-11-11T05:00:00Z") - i * 60000,
symlink: false,
...(path === "i12" ? { intent_id: 12, intent_title: "HTTP reconnaissance", intent_state: "done" } : {}),
}));
export function mockTaskFiles(path = "", all = false): TaskFileListing {
if (!all && path && !entries.some((e) => e.path === path && e.dir)) throw new Error("taskFiles.notDirectory");
const files = entries.filter((e) => !e.dir);
return {
path,
entries: all
? files.toSorted((a, b) => b.mtime - a.mtime)
: entries.filter((e) => e.path.split("/").slice(0, -1).join("/") === path),
total: files.length,
size: files.reduce((n, e) => n + e.size, 0),
truncated: false,
missing: false,
};
}
export function mockTaskFileRead(path: string): WorkspaceFile {
if (!(path in contents)) throw new Error("taskFiles.unavailable");
return { path, size: contents[path].length, binary: false, content: contents[path] };
}
// A small valid, stored ZIP keeps demo downloads useful without a dependency.
export function mockTaskFileURL(path: string, action: string): string {
if (action === "preview" || path.endsWith(".png")) return screenshot;
if (action !== "archive")
return URL.createObjectURL(new Blob([contents[path] ?? "Demo file\n"], { type: "text/plain" }));
const parts: Uint8Array[] = [];
const central: Uint8Array[] = [];
const encoder = new TextEncoder();
let offset = 0;
const files: [string, Uint8Array][] = Object.entries(contents).map(([name, text]) => [name, encoder.encode(text)]);
files.push([
"findings/screenshots/finding-1-step-1.png",
Uint8Array.from(atob(screenshot.split(",")[1]), (c) => c.charCodeAt(0)),
]);
for (const [fullName, body] of files.filter(([name]) => !path || name.startsWith(`${path}/`))) {
const name = encoder.encode(path ? fullName.slice(path.length + 1) : fullName);
let crc = 0xffffffff;
for (const byte of body) {
crc ^= byte;
for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
}
crc = (crc ^ 0xffffffff) >>> 0;
const header = new Uint8Array(30 + name.length);
const h = new DataView(header.buffer);
h.setUint32(0, 0x04034b50, true);
h.setUint16(4, 20, true);
h.setUint32(14, crc, true);
h.setUint32(18, body.length, true);
h.setUint32(22, body.length, true);
h.setUint16(26, name.length, true);
header.set(name, 30);
const entry = new Uint8Array(46 + name.length);
const c = new DataView(entry.buffer);
c.setUint32(0, 0x02014b50, true);
c.setUint16(4, 20, true);
c.setUint16(6, 20, true);
c.setUint32(16, crc, true);
c.setUint32(20, body.length, true);
c.setUint32(24, body.length, true);
c.setUint16(28, name.length, true);
c.setUint32(42, offset, true);
entry.set(name, 46);
central.push(entry);
parts.push(header, body);
offset += header.length + body.length;
}
const end = new Uint8Array(22);
const e = new DataView(end.buffer);
e.setUint32(0, 0x06054b50, true);
e.setUint16(8, central.length, true);
e.setUint16(10, central.length, true);
e.setUint32(
12,
central.reduce((n, p) => n + p.length, 0),
true,
);
e.setUint32(16, offset, true);
return URL.createObjectURL(new Blob([...parts, ...central, end] as BlobPart[], { type: "application/zip" }));
}
+16
View File
@@ -1976,3 +1976,19 @@ export interface EgressReport {
warnings?: string[];
warning_details?: { code: string; params?: Record<string, unknown>; message: string }[];
}
// Task-relative, read-only file browser.
export interface TaskFileEntry extends WorkspaceEntry {
symlink: boolean;
intent_id?: number;
intent_title?: string;
intent_state?: string;
}
export interface TaskFileListing {
path: string;
entries: TaskFileEntry[];
total: number;
size: number;
truncated: boolean;
missing: boolean;
}