Add kubeconfig generation for Tai K8s in CI workflows

- Implement steps to generate kubeconfig for both the Tai K8s container and the test runner, ensuring proper configuration for Kubernetes interactions.
- Update the Docker run command to mount the generated kubeconfig, enhancing the integration of Tai with K8s.
- Remove the previous kubeconfig generation step for the Tai K8s proxy, streamlining the workflow.

These changes improve the CI workflows for the Tai service by ensuring accurate kubeconfig generation and integration with Kubernetes environments.
This commit is contained in:
Max 2026-03-05 23:32:09 +08:00
parent c797fcf74d
commit b7eb0e81e3
9 changed files with 768 additions and 220 deletions

View file

@ -1112,6 +1112,25 @@ jobs:
echo "::error::Tai Docker gRPC failed"; docker logs tai-docker 2>&1; exit 1
}
- name: Generate kubeconfig for Tai K8s
run: |
K3D_IP=$(docker inspect k3d-tai-test-server-0 | jq -r '.[0].NetworkSettings.Networks["k3d-tai-test"].IPAddress')
echo "k3d server IP: ${K3D_IP}"
k3d kubeconfig get tai-test > /tmp/kubeconfig-k3d.yml
# Kubeconfig for tai-k8s container (uses k3d-internal IP)
sed "s|server: .*|server: https://${K3D_IP}:6443|" /tmp/kubeconfig-k3d.yml \
> /tmp/kubeconfig-tai-k8s.yml
echo "Container kubeconfig server:"
grep server: /tmp/kubeconfig-tai-k8s.yml
# Kubeconfig for test runner (uses localhost via port-mapped 6443)
sed 's|server: .*|server: https://127.0.0.1:6443|' /tmp/kubeconfig-k3d.yml \
> ${{ runner.temp }}/kubeconfig-tai.yml
echo "Test runner kubeconfig server:"
grep server: ${{ runner.temp }}/kubeconfig-tai.yml
- name: Start Tai K8s instance
run: |
K3D_IP=$(docker inspect k3d-tai-test-server-0 | jq -r '.[0].NetworkSettings.Networks["k3d-tai-test"].IPAddress')
@ -1120,7 +1139,9 @@ jobs:
docker run -d --name tai-k8s \
--network k3d-tai-test \
-p 8081:8080 -p 9101:9100 -p 6443:6443 -p 6081:6080 \
-v /tmp/kubeconfig-tai-k8s.yml:/etc/tai/kubeconfig.yml:ro \
-e TAI_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \
-e TAI_KUBECONFIG=/etc/tai/kubeconfig.yml \
yaoapp/tai:latest
for i in $(seq 1 30); do
@ -1143,14 +1164,6 @@ jobs:
echo "::error::Tai K8s gRPC failed"; docker logs tai-k8s 2>&1; exit 1
}
- name: Generate kubeconfig for Tai K8s proxy
run: |
k3d kubeconfig get tai-test > /tmp/kubeconfig-k3d.yml
sed 's|server: .*|server: https://127.0.0.1:6443|' /tmp/kubeconfig-k3d.yml \
> ${{ runner.temp }}/kubeconfig-tai.yml
echo "Generated kubeconfig:"
grep server: ${{ runner.temp }}/kubeconfig-tai.yml
- name: Run Sandbox V2 Tests (tai + sandbox-v2 + workspace)
env:
TAI_TEST_HOST: "127.0.0.1"

View file

@ -820,6 +820,25 @@ jobs:
echo "::error::Tai Docker gRPC failed"; docker logs tai-docker 2>&1; exit 1
}
- name: Generate kubeconfig for Tai K8s
run: |
K3D_IP=$(docker inspect k3d-tai-test-server-0 | jq -r '.[0].NetworkSettings.Networks["k3d-tai-test"].IPAddress')
echo "k3d server IP: ${K3D_IP}"
k3d kubeconfig get tai-test > /tmp/kubeconfig-k3d.yml
# Kubeconfig for tai-k8s container (uses k3d-internal IP)
sed "s|server: .*|server: https://${K3D_IP}:6443|" /tmp/kubeconfig-k3d.yml \
> /tmp/kubeconfig-tai-k8s.yml
echo "Container kubeconfig server:"
grep server: /tmp/kubeconfig-tai-k8s.yml
# Kubeconfig for test runner (uses localhost via port-mapped 6443)
sed 's|server: .*|server: https://127.0.0.1:6443|' /tmp/kubeconfig-k3d.yml \
> ${{ runner.temp }}/kubeconfig-tai.yml
echo "Test runner kubeconfig server:"
grep server: ${{ runner.temp }}/kubeconfig-tai.yml
- name: Start Tai K8s instance
run: |
K3D_IP=$(docker inspect k3d-tai-test-server-0 | jq -r '.[0].NetworkSettings.Networks["k3d-tai-test"].IPAddress')
@ -828,7 +847,9 @@ jobs:
docker run -d --name tai-k8s \
--network k3d-tai-test \
-p 8081:8080 -p 9101:9100 -p 6443:6443 -p 6081:6080 \
-v /tmp/kubeconfig-tai-k8s.yml:/etc/tai/kubeconfig.yml:ro \
-e TAI_K8S_UPSTREAM="tcp://${K3D_IP}:6443" \
-e TAI_KUBECONFIG=/etc/tai/kubeconfig.yml \
yaoapp/tai:latest
for i in $(seq 1 30); do
@ -851,14 +872,6 @@ jobs:
echo "::error::Tai K8s gRPC failed"; docker logs tai-k8s 2>&1; exit 1
}
- name: Generate kubeconfig for Tai K8s proxy
run: |
k3d kubeconfig get tai-test > /tmp/kubeconfig-k3d.yml
sed 's|server: .*|server: https://127.0.0.1:6443|' /tmp/kubeconfig-k3d.yml \
> ${{ runner.temp }}/kubeconfig-tai.yml
echo "Generated kubeconfig:"
grep server: ${{ runner.temp }}/kubeconfig-tai.yml
- name: Run Sandbox V2 Tests (tai + sandbox-v2 + workspace)
env:
TAI_TEST_HOST: "127.0.0.1"

View file

@ -21,7 +21,8 @@ Sandbox does NOT import or depend on Agent. Agent is one of many consumers.
```
┌─────────────────────────────────────────────────┐
│ Consumers (know nothing about tai/Docker/K8s) │
│ ├── JSAPI: Sandbox("my-app") │
│ ├── JSAPI: sandbox.Create/Get/List/Delete │
│ ├── JSAPI: workspace.Create/Get/List/Delete │
│ ├── Process: sandbox.Create, sandbox.Exec │
│ ├── Agent: uses sandbox via interface │
│ └── API: /api/__yao/sandbox/* │
@ -576,8 +577,9 @@ sandbox/v2/
├── config.go // Config struct
├── errors.go // sentinel errors
├── grpc.go // token creation/revocation, gRPC env var injection
├── jsapi/ // (Phase 2) V8 JSAPI Sandbox() constructor
│ └── sandbox.go
├── jsapi/ // (Phase 2) V8 JSAPI sandbox.* namespace
│ ├── jsapi.go // RegisterObject("sandbox"), Create/Get/List/Delete
│ └── box.go // Box JS object: Exec/Attach/VNC/Proxy/Workspace/Info/Start/Stop/Remove
├── export_test.go // ResetForTest() for test isolation
├── testutils_test.go // shared test helpers (multi-pool setup)
├── sandbox_test.go // Init/M singleton tests
@ -729,6 +731,9 @@ workspace/
├── workspace.go // types, metadata marshal/unmarshal
├── manager.go // Manager: CRUD, file I/O, node management
├── errors.go // sentinel errors
├── jsapi/ // (Phase 2) V8 JSAPI workspace.* namespace
│ ├── jsapi.go // RegisterObject("workspace"), Create/Get/List/Delete
│ └── fs.go // WorkspaceFS JS object: ReadFile/WriteFile/ReadDir/Stat/MkdirAll/Remove/RemoveAll/Rename
├── testutils_test.go // shared test helpers
├── workspace_test.go // CRUD tests (Create/Get/List/Update/Delete/Nodes)
├── fileio_test.go // File I/O + fs.FS tests
@ -824,12 +829,269 @@ Docker `StopStart` ~2.2s is expected: `DefaultStopTimeout = 2s` and Docker waits
- Tests: unit + integration + benchmarks
- CI: consolidated SandboxV2Test + BenchmarkSandboxV2
## Phase 2: JSAPI + OAuth (PENDING)
## Phase 2: JSAPI + OAuth + Auth (PENDING)
### Prerequisites
| Task | Detail |
|------|--------|
| `sandbox/v2/jsapi/` | V8 `Sandbox()` / `Workspace()` constructors (registered in gou runtime) |
| Wire `openapi/oauth` | `grpc.go` currently uses random token placeholders; replace with real OAuth issue/revoke |
### JSAPI Design
All JSAPI methods are static — no constructors, no Go objects in V8, no bridge/Release.
JS objects only hold string IDs, delegate everything to Go singletons (`sandbox.M()`, `workspace.M()`).
#### sandbox namespace (`RegisterObject("sandbox")`)
Static methods:
| JS | Go | Returns |
|----|-----|---------|
| `sandbox.Create(opts)` | `Manager.Create(ctx, CreateOptions)` | `Box` |
| `sandbox.Create(opts)` (opts.id set) | `Manager.GetOrCreate(ctx, CreateOptions)` | `Box` |
| `sandbox.Get(id)` | `Manager.Get(ctx, id)` | `Box \| null` |
| `sandbox.List(filter?)` | `Manager.List(ctx, ListOptions)``Box.Info()` | `BoxInfo[]` |
| `sandbox.Delete(id)` | `Manager.Remove(ctx, id)` | `void` |
`sandbox.Create(options)` — JS options → Go `CreateOptions`:
```
{
id: string → CreateOptions.ID // optional; triggers GetOrCreate
owner: string → CreateOptions.Owner // required
pool: string → CreateOptions.Pool // default: first pool
image: string → CreateOptions.Image // required
workdir: string → CreateOptions.WorkDir
user: string → CreateOptions.User // e.g. "1000:1000"
env: object → CreateOptions.Env // map[string]string
memory: number → CreateOptions.Memory // bytes (int64)
cpus: number → CreateOptions.CPUs // float64
vnc: boolean → CreateOptions.VNC
ports: array → CreateOptions.Ports // [{container, host, host_ip, protocol}] → []PortMapping
policy: string → CreateOptions.Policy // "oneshot"|"session"|"longrunning"|"persistent"
idle_timeout: number → CreateOptions.IdleTimeout // ms → time.Duration
stop_timeout: number → CreateOptions.StopTimeout // ms → time.Duration
workspace_id: string → CreateOptions.WorkspaceID
mount_mode: string → CreateOptions.MountMode // "rw"|"ro"
mount_path: string → CreateOptions.MountPath
labels: object → CreateOptions.Labels // map[string]string
}
```
`sandbox.List(filter?)` — JS filter → Go `ListOptions`:
```
{
owner: string → ListOptions.Owner // empty = all
pool: string → ListOptions.Pool // empty = all
labels: object → ListOptions.Labels
}
```
Returns `BoxInfo[]` — each element:
```
{
id: string ← BoxInfo.ID
container_id: string ← BoxInfo.ContainerID
pool: string ← BoxInfo.Pool
owner: string ← BoxInfo.Owner
status: string ← BoxInfo.Status
image: string ← BoxInfo.Image
vnc: boolean ← BoxInfo.VNC
policy: string ← BoxInfo.Policy
labels: object ← BoxInfo.Labels
created_at: string ← BoxInfo.CreatedAt (ISO 8601)
last_active: string ← BoxInfo.LastActive (ISO 8601)
process_count: number ← BoxInfo.ProcessCount
}
```
#### Box object
Read-only properties:
| JS | Go |
|----|----|
| `box.id` | `Box.ID()` |
| `box.owner` | `Box.Owner()` |
| `box.pool` | `Box.Pool()` |
Methods:
| JS | Go | Returns |
|----|-----|---------|
| `box.Exec(cmd, opts?)` | `Box.Exec(ctx, cmd, ...ExecOption)` | `ExecResult` |
| `box.Stream(cmd, opts?)` | `Box.Stream(ctx, cmd, ...ExecOption)` | `ExecStream` |
| `box.Attach(port, opts?)` | `Box.Attach(ctx, port, ...AttachOption)` | `ServiceConn` |
| `box.VNC()` | `Box.VNC(ctx)` | `string` |
| `box.Proxy(port, path?)` | `Box.Proxy(ctx, port, path)` | `string` |
| `box.Workspace()` | `Box.WorkspaceID()``NewFSObject` | `WorkspaceFS` |
| `box.Info()` | `Box.Info(ctx)` | `BoxInfo` |
| `box.Start()` | `Box.Start(ctx)` | `void` |
| `box.Stop()` | `Box.Stop(ctx)` | `void` |
| `box.Remove()` | `Box.Remove(ctx)` | `void` |
`box.Exec(cmd, options?)`:
```
cmd: string[] → cmd []string
options: {
workdir: string, → WithWorkDir(dir)
env: object, → WithEnv(map[string]string)
timeout: number → WithTimeout(ms → time.Duration)
}
returns: {
exit_code: number, ← ExecResult.ExitCode
stdout: string, ← ExecResult.Stdout
stderr: string ← ExecResult.Stderr
}
```
`box.Stream(cmd, options?)`:
```
options: same as Exec
returns: {
stdout: ReadableStream, ← ExecStream.Stdout
stderr: ReadableStream, ← ExecStream.Stderr
stdin: WritableStream, ← ExecStream.Stdin
wait: function() → number, ← ExecStream.Wait() (int, error)
cancel: function() → void ← ExecStream.Cancel()
}
```
`box.Attach(port, options?)`:
```
port: number → port int
options: {
protocol: "ws"|"sse", → WithProtocol(protocol)
path: string, → WithPath(path)
headers: object → WithHeaders(map[string]string)
}
returns: {
url: string, ← ServiceConn.URL
read: function() → Uint8Array, ← ServiceConn.Read()
write: function(data) → void, ← ServiceConn.Write(data)
events: AsyncIterable<Uint8Array>, ← ServiceConn.Events
close: function() → void ← ServiceConn.Close()
}
```
`box.Info()` returns same structure as `BoxInfo[]` element above.
#### workspace namespace (`RegisterObject("workspace")`)
Static methods:
| JS | Go | Returns |
|----|-----|---------|
| `workspace.Create(opts)` | `Manager.Create(ctx, CreateOptions)` | `WorkspaceFS` |
| `workspace.Get(id)` | `Manager.Get(ctx, id)` | `WorkspaceFS \| null` |
| `workspace.List(filter?)` | `Manager.List(ctx, ListOptions)` | `WorkspaceInfo[]` |
| `workspace.Delete(id)` | `Manager.Delete(ctx, id, false)` | `void` |
`workspace.Create(options)` — JS options → Go `CreateOptions`:
```
{
id: string → CreateOptions.ID // optional; auto-generated if empty
name: string → CreateOptions.Name // required
owner: string → CreateOptions.Owner // required
node: string → CreateOptions.Node // required
labels: object → CreateOptions.Labels // map[string]string
}
```
`workspace.List(filter?)` — JS filter → Go `ListOptions`:
```
{
owner: string → ListOptions.Owner // empty = all
node: string → ListOptions.Node // empty = all
}
```
Returns `WorkspaceInfo[]` — each element:
```
{
id: string ← Workspace.ID
name: string ← Workspace.Name
owner: string ← Workspace.Owner
node: string ← Workspace.Node
labels: object ← Workspace.Labels
created_at: string ← Workspace.CreatedAt (ISO 8601)
updated_at: string ← Workspace.UpdatedAt (ISO 8601)
}
```
#### WorkspaceFS object
Read-only properties:
| JS | Go |
|----|----|
| `ws.id` | workspace ID |
| `ws.name` | `Workspace.Name` |
| `ws.node` | `Workspace.Node` |
Methods (1:1 to Go `taiworkspace.FS` + `Manager` shortcuts):
| JS | Go | Returns |
|----|-----|---------|
| `ws.ReadFile(path)` | `FS.ReadFile(name)` / `Manager.ReadFile(ctx, id, path)` | `string` |
| `ws.WriteFile(path, data, perm?)` | `FS.WriteFile(name, data, perm)` / `Manager.WriteFile(ctx, id, path, data, perm)` | `void` |
| `ws.ReadDir(path?)` | `FS.ReadDir(name)` / `Manager.ListDir(ctx, id, path)` | `DirEntry[]` |
| `ws.Stat(path)` | `FS.Stat(name)` | `FileInfo` |
| `ws.MkdirAll(path, perm?)` | `FS.MkdirAll(name, perm)` | `void` |
| `ws.Remove(path)` | `FS.Remove(name)` / `Manager.Remove(ctx, id, path)` | `void` |
| `ws.RemoveAll(path)` | `FS.RemoveAll(name)` | `void` |
| `ws.Rename(from, to)` | `FS.Rename(old, new)` | `void` |
Planned (not yet implemented):
| JS | Go | Returns | Note |
|----|-----|---------|------|
| `ws.ReadFileBase64(path)` | `FS.ReadFile``base64.StdEncoding.EncodeToString` | `string` | Avoids V8↔Go binary bridge overhead for images, archives, etc. |
| `ws.WriteFileBase64(path, b64, perm?)` | `base64.StdEncoding.DecodeString``FS.WriteFile` | `void` | Same — base64 string transfer is far more efficient than Uint8Array across the bridge |
| `ws.CopyFromHost(hostPath, destPath?)` | Host `os.Read``FS.WriteFile` / `FS.MkdirAll` per entry | `void` | Copy file/dir from Yao host into workspace; `destPath` defaults to basename |
| `ws.CopyFromHostArchive(hostPath, destPath?)` | Zip on host → Tai Volume upload → Tai-side unarchive | `void` | For large directory trees; requires Tai server-side unarchive support |
Return types:
```
DirEntry: { name: string, is_dir: boolean, size: number }
FileInfo: { name: string, size: number, is_dir: boolean, mod_time: string (ISO 8601) }
```
### Auth
JSAPI does not enforce permissions internally. The Go Manager methods execute operations directly without owner/admin checks.
Developers retrieve the current caller identity via the gou global `Authorized()` function (registered by `gou/runtime/v8/functions/authorized`, reads from `bridge.Share.Authorized` / `__yao_data.AUTHORIZED`) and implement permission logic in their JS scripts.
`Authorized()` returns `map[string]interface{}` (or null if not set). The exact fields depend on what the caller sets via `Context.WithAuthorized()`. There is no fixed schema — typical fields include `user_id`, `team_id`, `scope`, etc.
```javascript
const auth = Authorized() // gou global — returns caller info or null
const box = sandbox.Get(id)
// Developer decides permission logic — fields depend on application's auth setup
if (box.owner !== auth.user_id) {
throw new Error("permission denied")
}
```
Permission control is the responsibility of the caller (JS scripts, Agent hooks, API middleware, etc.).
### Implementation Tasks
| Task | Detail |
|------|--------|
| `sandbox/v2/jsapi/` | `RegisterObject("sandbox")` with Create/Get/List/Delete + Box object |
| `workspace/jsapi/` | `RegisterObject("workspace")` with Create/Get/List/Delete + FS object |
| Integration with `cmd/start.go` | Call `sandbox.Init()` + `sandbox.M().Start()` |
## Phase 3: Agent Integration (PENDING)

View file

@ -4,49 +4,126 @@ import (
"rogchap.com/v8go"
)
// NewBoxObject creates a JS Box object with the following methods:
// NewBoxObject creates a JS Box object backed by a sandbox ID string.
// All methods delegate to the Go sandbox.M() singleton — no Go object is
// passed to V8, no bridge registration, no Release() needed.
//
// box.ID() → string // sandbox ID
// box.Owner() → string // owner
// box.ContainerID() → string // underlying container/pod ID
// box.Pool() → string // pool name
// box.WorkspaceID() → string // mounted workspace ID (empty if none)
// # Properties (read-only)
//
// box.Exec(cmd, options?) → ExecResult // run command, wait for completion
// cmd: string[] // command + args
// options: { workdir, env, timeout }
// returns: { exit_code: number, stdout: string, stderr: string }
// box.id → string // sandbox ID ← Box.ID()
// box.owner → string // owner user ID ← Box.Owner()
// box.pool → string // pool name ← Box.Pool()
//
// box.Stream(cmd, options?) → ExecStream // streaming I/O
// returns: { stdout: ReadableStream, stderr: ReadableStream,
// stdin: WritableStream, wait: ()=>number, cancel: ()=>void }
// # Methods — Go mapping
//
// box.Attach(port, options?) → ServiceConn // WebSocket/SSE attach
// port: number // container port
// options: { protocol, path, headers }
// returns: { url: string, close: ()=>void }
// box.Exec(cmd, options?) → ExecResult
//
// box.VNC() → string // VNC WebSocket URL
// box.Proxy(port, path?) → string // HTTP proxy URL
// Go: Box.Exec(ctx, cmd []string, opts ...ExecOption) (*ExecResult, error)
//
// box.Workspace() → WorkspaceFS // workspace file system
// returns WorkspaceFS object (see workspace/jsapi)
// JS args:
// cmd: string[] → cmd []string
// options: { → ExecOption functional options
// workdir: string, → WithWorkDir(dir)
// env: object, → WithEnv(map[string]string)
// timeout: number → WithTimeout(ms → time.Duration)
// }
// JS returns: {
// exit_code: number, ← ExecResult.ExitCode
// stdout: string, ← ExecResult.Stdout
// stderr: string ← ExecResult.Stderr
// }
//
// box.Info() → BoxInfo // container status
// returns: { id, container_id, pool, owner, status, policy,
// labels, image, created_at, last_active, process_count, vnc }
// box.Stream(cmd, options?) → ExecStream
//
// box.Start() → void // start stopped box
// box.Stop() → void // stop running box
// box.Remove() → void // remove box permanently
// box.Release() → void // release JS bridge ref
func NewBoxObject(v8ctx *v8go.Context /* , box *sandbox.Box */) (*v8go.Value, error) {
// Go: Box.Stream(ctx, cmd []string, opts ...ExecOption) (*ExecStream, error)
//
// JS returns: {
// stdout: ReadableStream, ← ExecStream.Stdout
// stderr: ReadableStream, ← ExecStream.Stderr
// stdin: WritableStream, ← ExecStream.Stdin
// wait: function() → number, ← ExecStream.Wait() (int, error)
// cancel: function() → void ← ExecStream.Cancel()
// }
//
// box.Attach(port, options?) → ServiceConn
//
// Go: Box.Attach(ctx, port int, opts ...AttachOption) (*ServiceConn, error)
//
// JS args:
// port: number → port int
// options: { → AttachOption functional options
// protocol: "ws"|"sse", → WithProtocol(protocol)
// path: string, → WithPath(path)
// headers: object → WithHeaders(map[string]string)
// }
// JS returns: {
// url: string, ← ServiceConn.URL
// read: function() → Uint8Array, ← ServiceConn.Read() ([]byte, error)
// write: function(data) → void, ← ServiceConn.Write(data) error
// events: AsyncIterable<Uint8Array>, ← ServiceConn.Events <-chan []byte
// close: function() → void ← ServiceConn.Close() error
// }
//
// box.VNC() → string
//
// Go: Box.VNC(ctx) (string, error)
// Returns: VNC WebSocket URL
//
// box.Proxy(port, path?) → string
//
// Go: Box.Proxy(ctx, port int, path string) (string, error)
// Returns: HTTP proxy URL
//
// box.Workspace() → WorkspaceFS
//
// Go: Box.Workspace() workspace.FS
// Box.WorkspaceID() string
// Returns: WorkspaceFS object (see workspace/jsapi/fs.go)
// Uses box.WorkspaceID() to create NewFSObject
//
// box.Info() → BoxInfo
//
// Go: Box.Info(ctx) (*BoxInfo, error)
// JS returns: {
// id: string, ← BoxInfo.ID
// container_id: string, ← BoxInfo.ContainerID
// pool: string, ← BoxInfo.Pool
// owner: string, ← BoxInfo.Owner
// status: string, ← BoxInfo.Status
// image: string, ← BoxInfo.Image
// vnc: boolean, ← BoxInfo.VNC
// policy: string, ← BoxInfo.Policy (LifecyclePolicy)
// labels: object, ← BoxInfo.Labels (map[string]string)
// created_at: string, ← BoxInfo.CreatedAt (ISO 8601)
// last_active: string, ← BoxInfo.LastActive (ISO 8601)
// process_count: number ← BoxInfo.ProcessCount
// }
//
// box.Start() → void
//
// Go: Box.Start(ctx) error
//
// box.Stop() → void
//
// Go: Box.Stop(ctx) error
//
// box.Remove() → void
//
// Go: Box.Remove(ctx) error
func NewBoxObject(v8ctx *v8go.Context, boxID string) (*v8go.Value, error) {
// TODO: Phase 2 implementation
// 1. Create ObjectTemplate with InternalFieldCount(1)
// 2. Register box in bridge
// 3. Bind property accessors: ID, Owner, ContainerID, Pool, WorkspaceID
// 4. Bind methods: Exec, Stream, Attach, VNC, Proxy, Workspace,
// Info, Start, Stop, Remove, Release
// 5. Create instance, set internal field
// 1. Create JS object via v8go.NewObjectTemplate
// 2. Set read-only properties: id, owner, pool (from sandbox.M().Get(boxID))
// 3. Bind each method as FunctionTemplate:
// - Exec → sandbox.M().Get(id).Exec(ctx, cmd, opts...)
// - Stream → sandbox.M().Get(id).Stream(ctx, cmd, opts...)
// - Attach → sandbox.M().Get(id).Attach(ctx, port, opts...)
// - VNC → sandbox.M().Get(id).VNC(ctx)
// - Proxy → sandbox.M().Get(id).Proxy(ctx, port, path)
// - Workspace → NewFSObject(v8ctx, sandbox.M().Get(id).WorkspaceID())
// - Info → sandbox.M().Get(id).Info(ctx) → JS object
// - Start → sandbox.M().Get(id).Start(ctx)
// - Stop → sandbox.M().Get(id).Stop(ctx)
// - Remove → sandbox.M().Get(id).Remove(ctx)
return nil, nil
}

View file

@ -1,14 +1,24 @@
// Package jsapi registers the Sandbox() constructor into the Yao V8 runtime.
// Package jsapi registers the sandbox namespace into the Yao V8 runtime.
//
// All methods are static on the sandbox object — no constructor.
//
// # JavaScript API
//
// const sb = new Sandbox({ pool: "default", image: "node:20", owner: "user1" })
// const box = sb.Create({ workdir: "/app", env: { NODE_ENV: "dev" } })
// const box = sandbox.Create({ image: "node:20", owner: "user1" })
// const result = box.Exec(["node", "-e", "console.log('hi')"])
// box.Remove()
// console.log(result.stdout)
//
// The constructor returns a SandboxManager object; Create/GetOrCreate returns
// a Box object with Exec/Stream/Attach/VNC/Proxy/Workspace/Info/Stop/Start/Remove.
// const box = sandbox.Get(id) // → Box
// const list = sandbox.List({ owner: "u1" }) // → BoxInfo[]
// sandbox.Delete(id) // → void
//
// # Go mapping
//
// sandbox.Create(opts) → Manager.Create(ctx, CreateOptions) → Box
// sandbox.Create(opts) → Manager.GetOrCreate(ctx, opts) → Box (when opts.id is set)
// sandbox.Get(id) → Manager.Get(ctx, id) → Box
// sandbox.List(filter?) → Manager.List(ctx, ListOptions) → []*Box → BoxInfo[]
// sandbox.Delete(id) → Manager.Remove(ctx, id) → void
//
// Registration happens via init() — import with:
//
@ -21,30 +31,125 @@ import (
)
func init() {
v8.RegisterFunction("Sandbox", ExportFunction)
v8.RegisterObject("sandbox", ExportObject)
}
// ExportFunction exports the Sandbox constructor to V8.
func ExportFunction(iso *v8go.Isolate) *v8go.FunctionTemplate {
return v8go.NewFunctionTemplate(iso, sandboxConstructor)
// ExportObject exports the sandbox namespace object to V8.
func ExportObject(iso *v8go.Isolate) *v8go.ObjectTemplate {
obj := v8go.NewObjectTemplate(iso)
obj.Set("Create", v8go.NewFunctionTemplate(iso, sbCreate))
obj.Set("Get", v8go.NewFunctionTemplate(iso, sbGet))
obj.Set("List", v8go.NewFunctionTemplate(iso, sbList))
obj.Set("Delete", v8go.NewFunctionTemplate(iso, sbDelete))
return obj
}
// sandboxConstructor is called when JS executes `new Sandbox(options)`.
// sbCreate: `sandbox.Create(options)` → Box
//
// Options:
// Go: Manager.Create(ctx, CreateOptions) (*Box, error)
//
// Manager.GetOrCreate(ctx, CreateOptions) (*Box, error) — when opts.id is set
//
// JS options → Go CreateOptions mapping:
//
// {
// pool: string // pool name (required)
// image: string // container image (required)
// owner: string // owner ID (required)
// id: string → CreateOptions.ID // optional; triggers GetOrCreate
// owner: string → CreateOptions.Owner // required
// pool: string → CreateOptions.Pool // default: first pool
// image: string → CreateOptions.Image // required
// workdir: string → CreateOptions.WorkDir
// user: string → CreateOptions.User // e.g. "1000:1000"
// env: object → CreateOptions.Env // map[string]string
// memory: number → CreateOptions.Memory // bytes (int64)
// cpus: number → CreateOptions.CPUs // float64 e.g. 1.5
// vnc: boolean → CreateOptions.VNC
// ports: array → CreateOptions.Ports // [{container, host, host_ip, protocol}] → []PortMapping
// policy: string → CreateOptions.Policy // "oneshot"|"session"|"longrunning"|"persistent"
// idle_timeout: number → CreateOptions.IdleTimeout // ms → time.Duration
// stop_timeout: number → CreateOptions.StopTimeout // ms → time.Duration
// workspace_id: string → CreateOptions.WorkspaceID
// mount_mode: string → CreateOptions.MountMode // "rw"|"ro"
// mount_path: string → CreateOptions.MountPath
// labels: object → CreateOptions.Labels // map[string]string
// }
//
// Returns a SandboxManager JS object.
func sandboxConstructor(info *v8go.FunctionCallbackInfo) *v8go.Value {
// TODO: Phase 2 implementation
// 1. Parse options from args[0]
// 2. Validate required fields (pool, image, owner)
// 3. Get sandbox.M() singleton
// 4. Return NewManagerObject(v8ctx, manager, options)
// Returns: Box object (see box.go)
func sbCreate(info *v8go.FunctionCallbackInfo) *v8go.Value {
// TODO: Phase 2
// 1. Parse options from info.Args()[0]
// 2. Validate required fields (image, owner)
// 3. If opts.id != "" → sandbox.M().GetOrCreate(ctx, opts)
// else → sandbox.M().Create(ctx, opts)
// 4. Return NewBoxObject(v8ctx, box.ID())
return v8go.Undefined(info.Context().Isolate())
}
// sbGet: `sandbox.Get(id)` → Box | null
//
// Go: Manager.Get(ctx, id) (*Box, error)
//
// Args:
//
// id: string — sandbox ID
//
// Returns: Box object if found, null if not found
func sbGet(info *v8go.FunctionCallbackInfo) *v8go.Value {
// TODO: Phase 2
// 1. id = info.Args()[0].String()
// 2. box, err := sandbox.M().Get(ctx, id)
// 3. Return NewBoxObject(v8ctx, id) or null
return v8go.Undefined(info.Context().Isolate())
}
// sbList: `sandbox.List(filter?)` → BoxInfo[]
//
// Go: Manager.List(ctx, ListOptions) ([]*Box, error)
//
// then Box.Info(ctx) for each → BoxInfo
//
// JS filter → Go ListOptions mapping:
//
// {
// owner: string → ListOptions.Owner // filter by owner; empty = all
// pool: string → ListOptions.Pool // filter by pool; empty = all
// labels: object → ListOptions.Labels // filter by labels
// }
//
// Returns: BoxInfo[] — each element:
//
// {
// id: string ← BoxInfo.ID
// container_id: string ← BoxInfo.ContainerID
// pool: string ← BoxInfo.Pool
// owner: string ← BoxInfo.Owner
// status: string ← BoxInfo.Status
// image: string ← BoxInfo.Image
// vnc: boolean ← BoxInfo.VNC
// policy: string ← BoxInfo.Policy
// labels: object ← BoxInfo.Labels
// created_at: string ← BoxInfo.CreatedAt (ISO 8601)
// last_active: string ← BoxInfo.LastActive (ISO 8601)
// process_count: number ← BoxInfo.ProcessCount
// }
func sbList(info *v8go.FunctionCallbackInfo) *v8go.Value {
// TODO: Phase 2
// 1. Parse optional filter from info.Args()[0]
// 2. boxes := sandbox.M().List(ctx, opts)
// 3. For each box: box.Info(ctx) → BoxInfo → JS object
// 4. Return JS array of BoxInfo objects
return v8go.Undefined(info.Context().Isolate())
}
// sbDelete: `sandbox.Delete(id)` → void
//
// Go: Manager.Remove(ctx, id) error
//
// Args:
//
// id: string — sandbox ID to remove
func sbDelete(info *v8go.FunctionCallbackInfo) *v8go.Value {
// TODO: Phase 2
// 1. id = info.Args()[0].String()
// 2. sandbox.M().Remove(ctx, id)
return v8go.Undefined(info.Context().Isolate())
}

View file

@ -1,45 +0,0 @@
package jsapi
import (
"rogchap.com/v8go"
)
// NewManagerObject creates a JS SandboxManager object with the following methods:
//
// manager.Create(options?) → Box // create a new sandbox box
// manager.GetOrCreate(opts) → Box // get existing or create
// manager.Get(id) → Box|null // get by sandbox ID
// manager.List(options?) → Box[] // list boxes
// manager.Remove(id) → void // remove a box
// manager.EnsureImage(ref) → void // pull image if missing
// manager.ImageExists(ref) → boolean // check image presence
// manager.Pools() → PoolInfo[] // list pool info
// manager.Release() → void // release JS bridge ref
//
// Create options (merged with constructor defaults):
//
// {
// id: string // explicit sandbox ID (optional)
// workdir: string // container working directory
// user: string // container user (e.g. "1000:1000")
// env: object // environment variables
// memory: number // memory limit in bytes
// cpus: number // CPU limit (e.g. 1.5)
// vnc: boolean // enable VNC
// ports: array // port mappings [{container: 8080, host: 0}]
// policy: string // "oneshot"|"session"|"longrunning"|"persistent"
// idle_timeout: number // idle timeout in ms
// stop_timeout: number // stop timeout in ms
// workspace_id: string // workspace to mount
// mount_mode: string // "rw"|"ro"
// mount_path: string // mount target in container
// }
func NewManagerObject(v8ctx *v8go.Context /* manager *sandbox.Manager, defaults CreateDefaults */) (*v8go.Value, error) {
// TODO: Phase 2 implementation
// 1. Create ObjectTemplate with InternalFieldCount(1)
// 2. Register manager in bridge
// 3. Bind methods: Create, GetOrCreate, Get, List, Remove,
// EnsureImage, ImageExists, Pools, Release
// 4. Create instance, set internal field
return nil, nil
}

View file

@ -4,33 +4,127 @@ import (
"rogchap.com/v8go"
)
// NewFSObject creates a JS WorkspaceFS object implementing a file system interface.
// NewFSObject creates a JS WorkspaceFS object backed by a workspace ID string.
// All methods delegate to workspace.M() → FS — no Go object passed to V8.
//
// This is the object returned by both:
// - WorkspaceManager.FS(id) (standalone workspace access)
// - Box.Workspace() (sandbox-mounted workspace access)
// # Properties (read-only)
//
// Methods:
// ws.id → string // workspace ID ← workspaceID arg
// ws.name → string // workspace name ← Workspace.Name
// ws.node → string // tai node name ← Workspace.Node
//
// fs.ReadFile(path) → string // read UTF-8 content
// fs.ReadFileBytes(path) → ArrayBuffer // read binary content
// fs.WriteFile(path, data) → void // write string or ArrayBuffer
// fs.Stat(path) → FileInfo // file metadata
// returns: { name, size, mode, mod_time, is_dir }
// fs.ReadDir(path?) → DirEntry[] // list directory (default ".")
// returns: [{ name, is_dir, size }]
// fs.MkdirAll(path) → void // create directory tree
// fs.Remove(path) → void // remove single file/empty dir
// fs.RemoveAll(path) → void // remove recursively
// fs.Rename(from, to) → void // rename/move
// fs.Close() → void // close FS handle
// fs.Release() → void // release JS bridge ref
func NewFSObject(v8ctx *v8go.Context /* , wfs taiworkspace.FS */) (*v8go.Value, error) {
// # Methods — Go mapping
//
// Each method internally does: fs, _ := workspace.M().FS(ctx, workspaceID)
// then calls the corresponding method on taiworkspace.FS.
//
// ws.ReadFile(path) → string
//
// Go: FS.ReadFile(name string) ([]byte, error)
// — also available via Manager.ReadFile(ctx, id, path)
// JS args: path string
// JS returns: string (UTF-8 content of the file)
//
// ws.WriteFile(path, data, perm?) → void
//
// Go: FS.WriteFile(name string, data []byte, perm os.FileMode) error
// — also available via Manager.WriteFile(ctx, id, path, data, perm)
// JS args: path string, data string|Uint8Array, perm? number (default 0644)
//
// ws.ReadDir(path?) → DirEntry[]
//
// Go: FS.ReadDir(name string) ([]fs.DirEntry, error)
// — also available via Manager.ListDir(ctx, id, path)
// JS args: path string (default ".")
// JS returns: [{
// name: string, ← DirEntry.Name()
// is_dir: boolean, ← DirEntry.IsDir()
// size: number ← DirEntry.Info().Size()
// }]
//
// ws.Stat(path) → FileInfo
//
// Go: FS.Stat(name string) (fs.FileInfo, error)
// JS args: path string
// JS returns: {
// name: string, ← FileInfo.Name()
// size: number, ← FileInfo.Size()
// is_dir: boolean, ← FileInfo.IsDir()
// mod_time: string ← FileInfo.ModTime() (ISO 8601)
// }
//
// ws.MkdirAll(path, perm?) → void
//
// Go: FS.MkdirAll(name string, perm os.FileMode) error
// JS args: path string, perm? number (default 0755)
//
// ws.Remove(path) → void
//
// Go: FS.Remove(name string) error
// — also available via Manager.Remove(ctx, id, path)
// JS args: path string (single file or empty directory)
//
// ws.RemoveAll(path) → void
//
// Go: FS.RemoveAll(name string) error
// JS args: path string (recursive removal)
//
// ws.Rename(from, to) → void
//
// Go: FS.Rename(oldname, newname string) error
// JS args: from string, to string
//
// # Base64 variants (PLANNED — not yet implemented)
//
// Avoids V8↔Go binary bridge overhead for images, archives, etc.
//
// ws.ReadFileBase64(path) → string
//
// Go: FS.ReadFile(name) → base64.StdEncoding.EncodeToString(data)
// JS args: path string
// JS returns: string (base64-encoded content)
//
// ws.WriteFileBase64(path, b64, perm?) → void
//
// Go: base64.StdEncoding.DecodeString(b64) → FS.WriteFile(name, data, perm)
// JS args: path string, b64 string, perm? number (default 0644)
//
// # Host copy (PLANNED — not yet implemented)
//
// Copy files/dirs from Yao host filesystem into the workspace volume.
// Useful for seeding workspaces with templates, config files, assets, etc.
//
// ws.CopyFromHost(hostPath, destPath?) → void
//
// Copies a single file or directory tree from the Yao host into the workspace.
// Go: read host file(s) → FS.WriteFile / FS.MkdirAll for each entry
// JS args: hostPath string (absolute path on Yao host),
// destPath? string (target path inside workspace, default basename of hostPath)
//
// ws.CopyFromHostArchive(hostPath, destPath?) → void
//
// For large directory trees: zip on host → transfer → unzip on Tai node.
// Requires Tai server-side unarchive support.
// Go: zip hostPath → tai Volume upload → tai unarchive at destPath
// JS args: hostPath string, destPath? string (default ".")
func NewFSObject(v8ctx *v8go.Context, workspaceID string) (*v8go.Value, error) {
// TODO: Phase 2 implementation
// 1. Create ObjectTemplate with InternalFieldCount(1)
// 2. Register FS in bridge
// 3. Bind methods: ReadFile, ReadFileBytes, WriteFile,
// Stat, ReadDir, MkdirAll, Remove, RemoveAll, Rename, Close, Release
// 4. Create instance, set internal field
// 1. Create JS object via v8go.NewObjectTemplate
// 2. Set read-only properties: id, name, node (from workspace.M().Get(workspaceID))
// 3. Bind each method as FunctionTemplate:
// - ReadFile → workspace.M().FS(ctx, id).ReadFile(path)
// - WriteFile → workspace.M().FS(ctx, id).WriteFile(path, data, perm)
// - ReadDir → workspace.M().FS(ctx, id).ReadDir(path)
// - Stat → workspace.M().FS(ctx, id).Stat(path)
// - MkdirAll → workspace.M().FS(ctx, id).MkdirAll(path, perm)
// - Remove → workspace.M().FS(ctx, id).Remove(path)
// - RemoveAll → workspace.M().FS(ctx, id).RemoveAll(path)
// - Rename → workspace.M().FS(ctx, id).Rename(old, new)
//
// PLANNED (not yet implemented):
// - ReadFileBase64 → ReadFile + base64 encode in Go
// - WriteFileBase64 → base64 decode in Go + WriteFile
// - CopyFromHost → host fs.Read → FS.Write (file-by-file)
// - CopyFromHostArchive → zip on host → tai transfer → unzip (needs Tai support)
return nil, nil
}

View file

@ -1,14 +1,22 @@
// Package jsapi registers the Workspace() constructor into the Yao V8 runtime.
// Package jsapi registers the workspace namespace into the Yao V8 runtime.
//
// All methods are static on the workspace object — no constructor.
//
// # JavaScript API
//
// const ws = new Workspace({ node: "tai-1" })
// const info = ws.Create({ name: "my-project", owner: "user1" })
// const file = ws.ReadFile(info.id, "/README.md")
// ws.WriteFile(info.id, "/app.ts", content)
// const ws = workspace.Create({ name: "proj", owner: "user1", node: "default" })
// const ws = workspace.Get(id)
// ws.ReadFile("main.go") → string
// ws.WriteFile("out.txt", data) → void
// ws.ReadDir("src/") → [{ name, is_dir, size }]
// workspace.Delete(id) → void
//
// The constructor returns a WorkspaceManager object; individual workspace
// files are accessed through ReadFile/WriteFile/ListDir or the FS() handle.
// # Go mapping
//
// workspace.Create(opts) → Manager.Create(ctx, CreateOptions) → *Workspace → WorkspaceFS
// workspace.Get(id) → Manager.Get(ctx, id) → *Workspace → WorkspaceFS
// workspace.List(filter?) → Manager.List(ctx, ListOptions) → []*Workspace → WorkspaceInfo[]
// workspace.Delete(id) → Manager.Delete(ctx, id, false) → void
//
// Registration happens via init() — import with:
//
@ -21,27 +29,101 @@ import (
)
func init() {
v8.RegisterFunction("Workspace", ExportFunction)
v8.RegisterObject("workspace", ExportObject)
}
// ExportFunction exports the Workspace constructor to V8.
func ExportFunction(iso *v8go.Isolate) *v8go.FunctionTemplate {
return v8go.NewFunctionTemplate(iso, workspaceConstructor)
// ExportObject exports the workspace namespace object to V8.
func ExportObject(iso *v8go.Isolate) *v8go.ObjectTemplate {
obj := v8go.NewObjectTemplate(iso)
obj.Set("Create", v8go.NewFunctionTemplate(iso, wsCreate))
obj.Set("Get", v8go.NewFunctionTemplate(iso, wsGet))
obj.Set("List", v8go.NewFunctionTemplate(iso, wsList))
obj.Set("Delete", v8go.NewFunctionTemplate(iso, wsDelete))
return obj
}
// workspaceConstructor is called when JS executes `new Workspace(options?)`.
// wsCreate: `workspace.Create(options)` → WorkspaceFS
//
// Options (all optional — uses global workspace.Manager if omitted):
// Go: Manager.Create(ctx, CreateOptions) (*Workspace, error)
//
// JS options → Go CreateOptions mapping:
//
// {
// node: string // default target node for Create (optional)
// id: string → CreateOptions.ID // optional; auto-generated if empty
// name: string → CreateOptions.Name // required, human-readable name
// owner: string → CreateOptions.Owner // required, user ID
// node: string → CreateOptions.Node // required, target Tai node
// labels: object → CreateOptions.Labels // optional, map[string]string
// }
//
// Returns a WorkspaceManager JS object.
func workspaceConstructor(info *v8go.FunctionCallbackInfo) *v8go.Value {
// TODO: Phase 2 implementation
// 1. Parse optional options from args[0]
// 2. Get workspace manager instance
// 3. Return NewManagerObject(v8ctx, manager, defaults)
// Returns: WorkspaceFS object (see fs.go)
func wsCreate(info *v8go.FunctionCallbackInfo) *v8go.Value {
// TODO: Phase 2
// 1. Parse options from info.Args()[0]
// 2. Validate required fields (name, owner, node)
// 3. ws := workspace.M().Create(ctx, opts)
// 4. Return NewFSObject(v8ctx, ws.ID)
return v8go.Undefined(info.Context().Isolate())
}
// wsGet: `workspace.Get(id)` → WorkspaceFS | null
//
// Go: Manager.Get(ctx, id) (*Workspace, error)
//
// Args:
//
// id: string — workspace ID
//
// Returns: WorkspaceFS object if found, null if not found
func wsGet(info *v8go.FunctionCallbackInfo) *v8go.Value {
// TODO: Phase 2
// 1. id = info.Args()[0].String()
// 2. ws, err := workspace.M().Get(ctx, id)
// 3. Return NewFSObject(v8ctx, id) or null
return v8go.Undefined(info.Context().Isolate())
}
// wsList: `workspace.List(filter?)` → WorkspaceInfo[]
//
// Go: Manager.List(ctx, ListOptions) ([]*Workspace, error)
//
// JS filter → Go ListOptions mapping:
//
// {
// owner: string → ListOptions.Owner // filter by owner; empty = all
// node: string → ListOptions.Node // filter by node; empty = all
// }
//
// Returns: WorkspaceInfo[] — each element:
//
// {
// id: string ← Workspace.ID
// name: string ← Workspace.Name
// owner: string ← Workspace.Owner
// node: string ← Workspace.Node
// labels: object ← Workspace.Labels
// created_at: string ← Workspace.CreatedAt (ISO 8601)
// updated_at: string ← Workspace.UpdatedAt (ISO 8601)
// }
func wsList(info *v8go.FunctionCallbackInfo) *v8go.Value {
// TODO: Phase 2
// 1. Parse optional filter from info.Args()[0]
// 2. list := workspace.M().List(ctx, opts)
// 3. Convert each *Workspace → JS object
// 4. Return JS array
return v8go.Undefined(info.Context().Isolate())
}
// wsDelete: `workspace.Delete(id)` → void
//
// Go: Manager.Delete(ctx, id string, force bool) error
//
// Args:
//
// id: string — workspace ID to remove (force = false)
func wsDelete(info *v8go.FunctionCallbackInfo) *v8go.Value {
// TODO: Phase 2
// 1. id = info.Args()[0].String()
// 2. workspace.M().Delete(ctx, id, false)
return v8go.Undefined(info.Context().Isolate())
}

View file

@ -1,53 +0,0 @@
package jsapi
import (
"rogchap.com/v8go"
)
// NewManagerObject creates a JS WorkspaceManager object with the following methods:
//
// wm.Create(options) → WorkspaceInfo // create workspace
// options: {
// id: string // explicit ID (optional, auto uuid)
// name: string // display name (required)
// owner: string // owner user ID (required)
// node: string // target Tai node (required, or use constructor default)
// labels: object // metadata key-value pairs
// }
// returns: { id, name, owner, node, labels, created_at, updated_at }
//
// wm.Get(id) → WorkspaceInfo|null
// wm.List(options?) → WorkspaceInfo[]
// options: { owner: string, node: string }
//
// wm.Update(id, options) → WorkspaceInfo
// options: { name: string, labels: object }
//
// wm.Delete(id, force?) → void
// force: boolean // delete even if has active mounts
//
// wm.ReadFile(id, path) → string // read file content (UTF-8)
// wm.ReadFileBytes(id, path) → ArrayBuffer // read file content (binary)
// wm.WriteFile(id, path, data) → void // write file (string or ArrayBuffer)
// wm.ListDir(id, path?) → DirEntry[] // list directory
// returns: [{ name, is_dir, size }]
// wm.Remove(id, path) → void // remove file or dir
// wm.MkdirAll(id, path) → void // create directory tree
// wm.Rename(id, from, to) → void // rename/move file
//
// wm.FS(id) → WorkspaceFS // get full FS handle
// wm.MountPath(id) → string // host mount path
// wm.Nodes() → NodeInfo[] // list available nodes
// returns: [{ name, addr, online }]
//
// wm.Release() → void // release JS bridge ref
func NewManagerObject(v8ctx *v8go.Context /* , manager *workspace.Manager, defaults ManagerDefaults */) (*v8go.Value, error) {
// TODO: Phase 2 implementation
// 1. Create ObjectTemplate with InternalFieldCount(1)
// 2. Register manager in bridge
// 3. Bind methods: Create, Get, List, Update, Delete,
// ReadFile, ReadFileBytes, WriteFile, ListDir, Remove,
// MkdirAll, Rename, FS, MountPath, Nodes, Release
// 4. Create instance, set internal field
return nil, nil
}