yao/workspace/jsapi/fs.go
Max df420978f0 feat(workspace): implement JSAPI with archive support and full test coverage
- tai/volume: add WithRemotePath sync option, 8 archive/compression methods
  (Zip/Unzip/Gzip/Gunzip/Tar/Untar/Tgz/Untgz) with local+remote impls
- workspace/jsapi: implement 24-method WorkspaceFS (file I/O, Copy with
  local:///tmp:// URI dispatch, archive ops) and 4 static methods
- workspace/manager: add Rename, MkdirAll, Volume() accessors via singleton
- Tests: comprehensive Go tests for tai/volume, workspace, workspace/jsapi
  covering both local and remote modes via SANDBOX_TEST_REMOTE_ADDR
- CI: update tai Docker image to latest in pr-test.yml
- sandbox/v2: add JSAPI docs and host/node module stubs

Made-with: Cursor
2026-03-08 14:17:39 +08:00

632 lines
17 KiB
Go

package jsapi
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"time"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/tai/volume"
"github.com/yaoapp/yao/workspace"
"rogchap.com/v8go"
)
// NewFSObject creates a JS WorkspaceFS object backed by a workspace ID string.
// All methods delegate to workspace.M() — no Go object is passed to V8.
func NewFSObject(v8ctx *v8go.Context, workspaceID string) (*v8go.Value, error) {
iso := v8ctx.Isolate()
ctx := context.Background()
wsID := workspaceID
ws, err := workspace.M().Get(ctx, wsID)
if err != nil {
return nil, fmt.Errorf("workspace %s: %w", wsID, err)
}
tpl := v8go.NewObjectTemplate(iso)
tpl.Set("ReadFile", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
args := info.Args()
if len(args) < 1 {
return throwError(info, "ReadFile requires a path")
}
data, err := workspace.M().ReadFile(ctx, wsID, args[0].String())
if err != nil {
return throwError(info, err.Error())
}
val, _ := v8go.NewValue(iso, string(data))
return val
}))
tpl.Set("WriteFile", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
args := info.Args()
if len(args) < 2 {
return throwError(info, "WriteFile requires path and data")
}
perm := os.FileMode(0o644)
if len(args) > 2 && args[2].IsNumber() {
perm = os.FileMode(args[2].Int32())
}
if err := workspace.M().WriteFile(ctx, wsID, args[0].String(), []byte(args[1].String()), perm); err != nil {
return throwError(info, err.Error())
}
return v8go.Undefined(iso)
}))
tpl.Set("ReadDir", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
path := "."
args := info.Args()
if len(args) > 0 && args[0].IsString() {
path = args[0].String()
}
recursive := false
if len(args) > 1 && args[1].IsBoolean() {
recursive = args[1].Boolean()
}
if recursive {
return readDirRecursive(info, wsID, path)
}
entries, err := workspace.M().ListDir(ctx, wsID, path)
if err != nil {
return throwError(info, err.Error())
}
data, _ := json.Marshal(entries)
val, _ := v8go.JSONParse(info.Context(), string(data))
return val
}))
tpl.Set("Stat", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
args := info.Args()
if len(args) < 1 {
return throwError(info, "Stat requires a path")
}
fsys, err := workspace.M().FS(ctx, wsID)
if err != nil {
return throwError(info, err.Error())
}
fi, err := fsys.Stat(args[0].String())
if err != nil {
return throwError(info, err.Error())
}
return fileInfoToJS(info, fi)
}))
tpl.Set("MkdirAll", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
args := info.Args()
if len(args) < 1 {
return throwError(info, "MkdirAll requires a path")
}
if err := workspace.M().MkdirAll(ctx, wsID, args[0].String()); err != nil {
return throwError(info, err.Error())
}
return v8go.Undefined(iso)
}))
tpl.Set("Remove", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
args := info.Args()
if len(args) < 1 {
return throwError(info, "Remove requires a path")
}
fsys, err := workspace.M().FS(ctx, wsID)
if err != nil {
return throwError(info, err.Error())
}
if err := fsys.Remove(args[0].String()); err != nil {
return throwError(info, err.Error())
}
return v8go.Undefined(iso)
}))
tpl.Set("RemoveAll", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
args := info.Args()
if len(args) < 1 {
return throwError(info, "RemoveAll requires a path")
}
fsys, err := workspace.M().FS(ctx, wsID)
if err != nil {
return throwError(info, err.Error())
}
if err := fsys.RemoveAll(args[0].String()); err != nil {
return throwError(info, err.Error())
}
return v8go.Undefined(iso)
}))
tpl.Set("Rename", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
args := info.Args()
if len(args) < 2 {
return throwError(info, "Rename requires from and to paths")
}
if err := workspace.M().Rename(ctx, wsID, args[0].String(), args[1].String()); err != nil {
return throwError(info, err.Error())
}
return v8go.Undefined(iso)
}))
tpl.Set("ReadFileBase64", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
args := info.Args()
if len(args) < 1 {
return throwError(info, "ReadFileBase64 requires a path")
}
data, err := workspace.M().ReadFile(ctx, wsID, args[0].String())
if err != nil {
return throwError(info, err.Error())
}
val, _ := v8go.NewValue(iso, base64.StdEncoding.EncodeToString(data))
return val
}))
tpl.Set("WriteFileBase64", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
args := info.Args()
if len(args) < 2 {
return throwError(info, "WriteFileBase64 requires path and b64 data")
}
perm := os.FileMode(0o644)
if len(args) > 2 && args[2].IsNumber() {
perm = os.FileMode(args[2].Int32())
}
decoded, err := base64.StdEncoding.DecodeString(args[1].String())
if err != nil {
return throwError(info, "base64 decode: "+err.Error())
}
if err := workspace.M().WriteFile(ctx, wsID, args[0].String(), decoded, perm); err != nil {
return throwError(info, err.Error())
}
return v8go.Undefined(iso)
}))
tpl.Set("ReadFileBuffer", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
args := info.Args()
if len(args) < 1 {
return throwError(info, "ReadFileBuffer requires a path")
}
data, err := workspace.M().ReadFile(ctx, wsID, args[0].String())
if err != nil {
return throwError(info, err.Error())
}
val, _ := v8go.NewValue(iso, base64.StdEncoding.EncodeToString(data))
return val
}))
tpl.Set("WriteFileBuffer", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
args := info.Args()
if len(args) < 2 {
return throwError(info, "WriteFileBuffer requires path and data")
}
perm := os.FileMode(0o644)
if len(args) > 2 && args[2].IsNumber() {
perm = os.FileMode(args[2].Int32())
}
decoded, err := base64.StdEncoding.DecodeString(args[1].String())
if err != nil {
return throwError(info, "decode: "+err.Error())
}
if err := workspace.M().WriteFile(ctx, wsID, args[0].String(), decoded, perm); err != nil {
return throwError(info, err.Error())
}
return v8go.Undefined(iso)
}))
tpl.Set("Exists", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
args := info.Args()
if len(args) < 1 {
val, _ := v8go.NewValue(iso, false)
return val
}
fsys, err := workspace.M().FS(ctx, wsID)
if err != nil {
val, _ := v8go.NewValue(iso, false)
return val
}
_, err = fsys.Stat(args[0].String())
val, _ := v8go.NewValue(iso, err == nil)
return val
}))
tpl.Set("IsDir", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
args := info.Args()
if len(args) < 1 {
val, _ := v8go.NewValue(iso, false)
return val
}
fsys, err := workspace.M().FS(ctx, wsID)
if err != nil {
val, _ := v8go.NewValue(iso, false)
return val
}
fi, err := fsys.Stat(args[0].String())
val, _ := v8go.NewValue(iso, err == nil && fi.IsDir())
return val
}))
tpl.Set("IsFile", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
args := info.Args()
if len(args) < 1 {
val, _ := v8go.NewValue(iso, false)
return val
}
fsys, err := workspace.M().FS(ctx, wsID)
if err != nil {
val, _ := v8go.NewValue(iso, false)
return val
}
fi, err := fsys.Stat(args[0].String())
val, _ := v8go.NewValue(iso, err == nil && !fi.IsDir())
return val
}))
tpl.Set("Copy", v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
return copyHandler(info, wsID)
}))
tpl.Set("Zip", v8go.NewFunctionTemplate(iso, archiveHandler(iso, wsID, "zip")))
tpl.Set("Unzip", v8go.NewFunctionTemplate(iso, archiveHandler(iso, wsID, "unzip")))
tpl.Set("Gzip", v8go.NewFunctionTemplate(iso, archiveHandler(iso, wsID, "gzip")))
tpl.Set("Gunzip", v8go.NewFunctionTemplate(iso, archiveHandler(iso, wsID, "gunzip")))
tpl.Set("Tar", v8go.NewFunctionTemplate(iso, archiveHandler(iso, wsID, "tar")))
tpl.Set("Untar", v8go.NewFunctionTemplate(iso, archiveHandler(iso, wsID, "untar")))
tpl.Set("Tgz", v8go.NewFunctionTemplate(iso, archiveHandler(iso, wsID, "tgz")))
tpl.Set("Untgz", v8go.NewFunctionTemplate(iso, archiveHandler(iso, wsID, "untgz")))
obj, err := tpl.NewInstance(v8ctx)
if err != nil {
return nil, err
}
idVal, _ := v8go.NewValue(iso, ws.ID)
obj.Set("id", idVal)
nameVal, _ := v8go.NewValue(iso, ws.Name)
obj.Set("name", nameVal)
nodeVal, _ := v8go.NewValue(iso, ws.Node)
obj.Set("node", nodeVal)
return obj.Value, nil
}
func archiveHandler(iso *v8go.Isolate, wsID, op string) v8go.FunctionCallback {
return func(info *v8go.FunctionCallbackInfo) *v8go.Value {
ctx := context.Background()
args := info.Args()
if len(args) < 2 {
return throwError(info, op+": requires src and dst paths")
}
src := args[0].String()
dst := args[1].String()
var excludes []string
if len(args) > 2 && args[2].IsObject() {
excObj, _ := args[2].AsObject()
if excObj != nil {
if v, e := excObj.Get("excludes"); e == nil && v.IsObject() {
excludes = parseStringArray(v)
}
}
}
vol, sid, err := workspace.M().Volume(ctx, wsID)
if err != nil {
return throwError(info, err.Error())
}
var result *volume.ArchiveResult
switch op {
case "zip":
result, err = vol.Zip(ctx, sid, src, dst, excludes)
case "unzip":
result, err = vol.Unzip(ctx, sid, src, dst)
case "gzip":
result, err = vol.Gzip(ctx, sid, src, dst)
case "gunzip":
result, err = vol.Gunzip(ctx, sid, src, dst)
case "tar":
result, err = vol.Tar(ctx, sid, src, dst, excludes)
case "untar":
result, err = vol.Untar(ctx, sid, src, dst)
case "tgz":
result, err = vol.Tgz(ctx, sid, src, dst, excludes)
case "untgz":
result, err = vol.Untgz(ctx, sid, src, dst)
}
if err != nil {
return throwError(info, err.Error())
}
data, _ := json.Marshal(map[string]interface{}{
"size_bytes": result.SizeBytes,
"files_count": result.FilesCount,
})
val, _ := v8go.JSONParse(info.Context(), string(data))
return val
}
}
func readDirRecursive(info *v8go.FunctionCallbackInfo, wsID, path string) *v8go.Value {
ctx := context.Background()
fsys, err := workspace.M().FS(ctx, wsID)
if err != nil {
return throwError(info, err.Error())
}
type entry struct {
Name string `json:"name"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size"`
}
var entries []entry
_ = fs.WalkDir(fsys, path, func(p string, d fs.DirEntry, err error) error {
if err != nil || p == path {
return err
}
rel, _ := filepath.Rel(path, p)
var size int64
if fi, e := d.Info(); e == nil {
size = fi.Size()
}
entries = append(entries, entry{Name: filepath.ToSlash(rel), IsDir: d.IsDir(), Size: size})
return nil
})
data, _ := json.Marshal(entries)
val, _ := v8go.JSONParse(info.Context(), string(data))
return val
}
func fileInfoToJS(info *v8go.FunctionCallbackInfo, fi fs.FileInfo) *v8go.Value {
data, _ := json.Marshal(map[string]interface{}{
"name": fi.Name(),
"size": fi.Size(),
"is_dir": fi.IsDir(),
"mod_time": fi.ModTime().Format(time.RFC3339),
"mode": uint32(fi.Mode()),
})
val, _ := v8go.JSONParse(info.Context(), string(data))
return val
}
func copyHandler(info *v8go.FunctionCallbackInfo, wsID string) *v8go.Value {
iso := info.Context().Isolate()
ctx := context.Background()
args := info.Args()
if len(args) < 2 {
return throwError(info, "Copy requires src and dst paths")
}
src := args[0].String()
dst := args[1].String()
srcIsHost := isHostURI(src)
dstIsHost := isHostURI(dst)
var excludes []string
force := false
if len(args) > 2 && args[2].IsObject() {
optsObj, _ := args[2].AsObject()
if optsObj != nil {
if v, e := optsObj.Get("excludes"); e == nil && v.IsObject() {
excludes = parseStringArray(v)
}
if v, e := optsObj.Get("force"); e == nil && v.IsBoolean() {
force = v.Boolean()
}
}
}
vol, sid, err := workspace.M().Volume(ctx, wsID)
if err != nil {
return throwError(info, err.Error())
}
opts := []volume.SyncOption{}
if len(excludes) > 0 {
opts = append(opts, volume.WithExcludes(excludes...))
}
if force {
opts = append(opts, volume.WithForceFull())
}
switch {
case !srcIsHost && !dstIsHost:
if err := copyWithinWorkspace(ctx, wsID, src, dst); err != nil {
return throwError(info, err.Error())
}
return v8go.Undefined(iso)
case srcIsHost && !dstIsHost:
hostPath, err := resolveHostPath(src)
if err != nil {
return throwError(info, err.Error())
}
opts = append(opts, volume.WithRemotePath(dst))
result, err := vol.SyncPush(ctx, sid, hostPath, opts...)
if err != nil {
return throwError(info, err.Error())
}
return syncResultToJS(info, result)
case !srcIsHost && dstIsHost:
hostPath, err := resolveHostPath(dst)
if err != nil {
return throwError(info, err.Error())
}
opts = append(opts, volume.WithRemotePath(src))
result, err := vol.SyncPull(ctx, sid, hostPath, opts...)
if err != nil {
return throwError(info, err.Error())
}
return syncResultToJS(info, result)
case srcIsHost && dstIsHost:
srcPath, err := resolveHostPath(src)
if err != nil {
return throwError(info, err.Error())
}
dstPath, err := resolveHostPath(dst)
if err != nil {
return throwError(info, err.Error())
}
if err := copyLocalToLocal(srcPath, dstPath, excludes); err != nil {
return throwError(info, err.Error())
}
return v8go.Undefined(iso)
}
return v8go.Undefined(iso)
}
func syncResultToJS(info *v8go.FunctionCallbackInfo, r *volume.SyncResult) *v8go.Value {
data, _ := json.Marshal(map[string]interface{}{
"files_synced": r.FilesSynced,
"bytes_transferred": r.BytesTransferred,
"duration_ms": r.Duration.Milliseconds(),
})
val, _ := v8go.JSONParse(info.Context(), string(data))
return val
}
func copyWithinWorkspace(ctx context.Context, wsID, src, dst string) error {
fsys, err := workspace.M().FS(ctx, wsID)
if err != nil {
return err
}
fi, err := fsys.Stat(src)
if err != nil {
return err
}
if !fi.IsDir() {
data, err := fsys.ReadFile(src)
if err != nil {
return err
}
return fsys.WriteFile(dst, data, fi.Mode())
}
return fs.WalkDir(fsys, src, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, _ := filepath.Rel(src, p)
target := filepath.Join(dst, rel)
if d.IsDir() {
return fsys.MkdirAll(target, 0o755)
}
data, err := fsys.ReadFile(p)
if err != nil {
return err
}
info, _ := d.Info()
perm := os.FileMode(0o644)
if info != nil {
perm = info.Mode()
}
return fsys.WriteFile(target, data, perm)
})
}
func isHostURI(path string) bool {
return strings.HasPrefix(path, "local://") || strings.HasPrefix(path, "tmp://")
}
func resolveHostPath(rawPath string) (string, error) {
if strings.HasPrefix(rawPath, "tmp://") {
rel := strings.TrimPrefix(rawPath, "tmp://")
if strings.Contains(rel, "..") {
return "", fmt.Errorf("path traversal not allowed")
}
return filepath.Join(os.TempDir(), rel), nil
}
appRoot := config.Conf.AppSource
rel := strings.TrimPrefix(rawPath, "local://")
if strings.Contains(rel, "..") {
return "", fmt.Errorf("path traversal not allowed")
}
abs := filepath.Join(appRoot, rel)
resolved, err := filepath.EvalSymlinks(abs)
if err != nil {
resolved = abs
}
if !strings.HasPrefix(resolved, appRoot) {
return "", fmt.Errorf("path escapes app root")
}
return resolved, nil
}
func copyLocalToLocal(src, dst string, excludes []string) error {
info, err := os.Stat(src)
if err != nil {
return err
}
if !info.IsDir() {
data, err := os.ReadFile(src)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
return os.WriteFile(dst, data, info.Mode())
}
return filepath.WalkDir(src, func(abs string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, _ := filepath.Rel(src, abs)
if rel == "." {
return os.MkdirAll(dst, 0o755)
}
for _, p := range excludes {
if matched, _ := filepath.Match(p, filepath.Base(rel)); matched {
if d.IsDir() {
return filepath.SkipDir
}
return nil
}
}
target := filepath.Join(dst, rel)
if d.IsDir() {
return os.MkdirAll(target, 0o755)
}
data, err := os.ReadFile(abs)
if err != nil {
return err
}
fi, _ := d.Info()
perm := os.FileMode(0o644)
if fi != nil {
perm = fi.Mode()
}
return os.WriteFile(target, data, perm)
})
}
func parseStringArray(val *v8go.Value) []string {
obj, err := val.AsObject()
if err != nil {
return nil
}
lenVal, err := obj.Get("length")
if err != nil {
return nil
}
length := int(lenVal.Int32())
result := make([]string, 0, length)
for i := 0; i < length; i++ {
item, err := obj.GetIdx(uint32(i))
if err != nil || !item.IsString() {
continue
}
result = append(result, item.String())
}
return result
}