mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-11-11 21:23:50 +08:00
feat(tasks): add read-only task file browser (GITHUB-3)
This commit is contained in:
1 parent
873e3a667b
commit
62ed7db7b2
14 files changed
+1567
-21
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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)}`),
|
||||
|
||||
@@ -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";
|
||||
@@ -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" }));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user