yao/workspace/jsapi/fs.go
Max 3f390c223c feat(sandbox/v2): enhance V2 sandbox integration and testing
- Implemented V2 sandbox initialization in the assistant loading process, allowing for standalone sandbox.yao configuration.
- Added support for V2 sandbox execution paths in the Assistant's Stream method, differentiating between V1 and V2 sandboxes.
- Introduced comprehensive tests for V2 sandbox configurations, ensuring correct loading and execution behavior.
- Updated the context and types to accommodate V2 sandbox features, including system information and workspace management.

Made-with: Cursor
2026-03-10 01:13:17 +08:00

510 lines
14 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()
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()
}
}
}
var opts []volume.SyncOption
if len(excludes) > 0 {
opts = append(opts, volume.WithExcludes(excludes...))
}
if force {
opts = append(opts, volume.WithForceFull())
}
// Map JSAPI local:// (AppRoot-relative) to Go-layer local:/// (absolute)
src = mapHostURI(src)
dst = mapHostURI(dst)
wsFS, err := workspace.M().FS(ctx, wsID)
if err != nil {
return throwError(info, err.Error())
}
result, err := wsFS.Copy(src, dst, opts...)
if err != nil {
return throwError(info, err.Error())
}
if result != nil {
return syncResultToJS(info, result)
}
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
}
// mapHostURI converts JSAPI host URIs to Go-layer absolute URIs.
// local://relative -> local:///{AppSource}/relative (with security checks)
// tmp://relative -> tmp:///relative (Go layer resolves os.TempDir)
// other -> unchanged (workspace-relative path)
func mapHostURI(raw string) string {
switch {
case strings.HasPrefix(raw, "local://"):
rel := strings.TrimPrefix(raw, "local://")
if strings.Contains(rel, "..") {
return raw
}
appRoot := config.Conf.AppSource
abs := filepath.Join(appRoot, rel)
resolved, err := filepath.EvalSymlinks(abs)
if err != nil {
resolved = abs
}
if !strings.HasPrefix(resolved, appRoot) {
return raw
}
return "local:///" + resolved
case strings.HasPrefix(raw, "tmp://"):
rel := strings.TrimPrefix(raw, "tmp://")
if strings.Contains(rel, "..") {
return raw
}
return "tmp:///" + rel
default:
return raw
}
}
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
}