From 62ed7db7b2dc53ee5ec502680d077895f4c0b935 Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Wed, 11 Nov 2026 14:14:37 +0800 Subject: [PATCH] feat(tasks): add read-only task file browser (GITHUB-3) --- server/file_content.go | 38 ++ server/server.go | 6 + server/task_files.go | 445 ++++++++++++++++++ server/task_files_test.go | 424 +++++++++++++++++ server/workspace.go | 18 +- .../function/tasks/detail/_tabs/files-tab.tsx | 366 ++++++++++++++ .../app/(main)/function/tasks/detail/page.tsx | 5 + .../app/(main)/function/workspace/page.tsx | 41 +- web/src/i18n/screen-pages.test.cjs | 3 +- web/src/i18n/task-messages.ts | 59 +++ web/src/lib/api.ts | 30 +- web/src/lib/mock/handler.ts | 30 +- web/src/lib/mock/task-files.ts | 107 +++++ web/src/lib/types.ts | 16 + 14 files changed, 1567 insertions(+), 21 deletions(-) create mode 100644 server/file_content.go create mode 100644 server/task_files.go create mode 100644 server/task_files_test.go create mode 100644 web/src/app/(main)/function/tasks/detail/_tabs/files-tab.tsx create mode 100644 web/src/lib/mock/task-files.ts diff --git a/server/file_content.go b/server/file_content.go new file mode 100644 index 0000000..d0edcda --- /dev/null +++ b/server/file_content.go @@ -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) +} diff --git a/server/server.go b/server/server.go index 206f082..d34a0cd 100644 --- a/server/server.go +++ b/server/server.go @@ -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) diff --git a/server/task_files.go b/server/task_files.go new file mode 100644 index 0000000..22d729f --- /dev/null +++ b/server/task_files.go @@ -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) + } +} diff --git a/server/task_files_test.go b/server/task_files_test.go new file mode 100644 index 0000000..7b06411 --- /dev/null +++ b/server/task_files_test.go @@ -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("unsafe")) + 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) + } +} diff --git a/server/workspace.go b/server/workspace.go index 4e2b445..9b6e39a 100644 --- a/server/workspace.go +++ b/server/workspace.go @@ -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) } diff --git a/web/src/app/(main)/function/tasks/detail/_tabs/files-tab.tsx b/web/src/app/(main)/function/tasks/detail/_tabs/files-tab.tsx new file mode 100644 index 0000000..989370a --- /dev/null +++ b/web/src/app/(main)/function/tasks/detail/_tabs/files-tab.tsx @@ -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>({}); + const [expanded, setExpanded] = React.useState([]); + const [summary, setSummary] = React.useState(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 = { + 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 ( +
+

{ui("taskFiles.scope")}

+
+ + + + {summary && ui("taskFiles.summary", { count: summary.total, size: fileSize(summary.size) })} + + + + +
+ {error && ( +

+ {error} +

+ )} + {mode === "recent" && summary?.truncated && ( +

{ui("taskFiles.truncated")}

+ )} +
+ + + + {ui("taskFiles.name")} + {ui("taskFiles.size")} + {ui("taskFiles.modified")} + {ui("taskFiles.actions")} + + + + {!rows.length && ( + + + {loading ? ui("taskFiles.loading") : ui("taskFiles.empty")} + + + )} + {rows.map(({ entry, depth }) => ( + + +
+ + {entry.symlink && {ui("taskFiles.symlink")}} + {entry.intent_state && } + {entry.intent_id && ( + + {ui("taskFiles.session")} + + )} +
+
+ + {entry.dir || entry.symlink ? "—" : fileSize(entry.size)} + + + {new Date(entry.mtime).toLocaleString(i18n.language)} + + +
+ + + {!entry.symlink && ( + + {ui("taskFiles.workspace")} + + )} +
+
+
+ ))} +
+
+
+ { + if (!open) { + previewGeneration.current++; + setPreview(null); + locate(""); + } + }} + > + + + {preview?.path} + + {preview && ( +
+ + +
+ )} +
+ {preview?.error &&

{preview.error}

} + {preview && !preview.error && isImage(preview.path) && ( + // biome-ignore lint/performance/noImgElement: authenticated raster preview + {preview.path} setPreview((prev) => (prev ? { ...prev, error: ui("taskFiles.imageFailed") } : prev))} + /> + )} + {preview && !preview.error && !isImage(preview.path) && !preview.file &&

{ui("taskFiles.loading")}

} + {preview?.file && !preview.error && (preview.file.binary || preview.file.too_large) && ( +

{ui(preview.file.too_large ? "taskFiles.tooLarge" : "taskFiles.binary")}

+ )} + {preview?.file && !preview.error && !preview.file.binary && !preview.file.too_large && ( + + )} +
+
+
+
+ ); +} diff --git a/web/src/app/(main)/function/tasks/detail/page.tsx b/web/src/app/(main)/function/tasks/detail/page.tsx index 674a775..8e99dc2 100644 --- a/web/src/app/(main)/function/tasks/detail/page.tsx +++ b/web/src/app/(main)/function/tasks/detail/page.tsx @@ -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() { + + + diff --git a/web/src/app/(main)/function/workspace/page.tsx b/web/src/app/(main)/function/workspace/page.tsx index 2a765dc..0ff3b6d 100644 --- a/web/src/app/(main)/function/workspace/page.tsx +++ b/web/src/app/(main)/function/workspace/page.tsx @@ -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() { ); } + +export default function WorkspacePage() { + return ( + + + + ); +} diff --git a/web/src/i18n/screen-pages.test.cjs b/web/src/i18n/screen-pages.test.cjs index 7090bfa..f670980 100644 --- a/web/src/i18n/screen-pages.test.cjs +++ b/web/src/i18n/screen-pages.test.cjs @@ -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"); diff --git a/web/src/i18n/task-messages.ts b/web/src/i18n/task-messages.ts index ee8c641..534a8fc 100644 --- a/web/src/i18n/task-messages.ts +++ b/web/src/i18n/task-messages.ts @@ -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"], diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index c47bde9..8e1054e 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -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(`/tasks/${id}/asset-refs?asset_id=${assetId}`), + taskFiles: (id: string, path = "") => get(`/tasks/${id}/files?path=${encodeURIComponent(path)}`), + taskFilesAll: (id: string) => get(`/tasks/${id}/files/all`), + taskFileRead: (id: string, path: string) => + get(`/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(`/workspace/list?path=${encodeURIComponent(path)}`), workspaceRead: (path: string) => get(`/workspace/read?path=${encodeURIComponent(path)}`), diff --git a/web/src/lib/mock/handler.ts b/web/src/lib/mock/handler.ts index 4000d9a..61b8200 100644 --- a/web/src/lib/mock/handler.ts +++ b/web/src/lib/mock/handler.ts @@ -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(method: string, rawPath: string, body?: Body function route(m: string, path: string, seg: string[], q: URLSearchParams, b: Record): 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"; diff --git a/web/src/lib/mock/task-files.ts b/web/src/lib/mock/task-files.ts new file mode 100644 index 0000000..9b13a8f --- /dev/null +++ b/web/src/lib/mock/task-files.ts @@ -0,0 +1,107 @@ +import type { TaskFileEntry, TaskFileListing, WorkspaceFile } from "../types"; + +const contents: Record = { + "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" })); +} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 0599cc5..7998185 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -1976,3 +1976,19 @@ export interface EgressReport { warnings?: string[]; warning_details?: { code: string; params?: Record; 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; +}