diff --git a/README.md b/README.md
index 27578946..48b4e9cd 100644
--- a/README.md
+++ b/README.md
@@ -96,4 +96,7 @@ and publication are documented in [RELEASING.md](RELEASING.md).
The [Go command streaming design](docs/go-command-streaming.md) documents the
opt-in output policy for long-running processes.
+The [Go envd user-selection guide](docs/go-envd-user-selection.md) describes
+per-operation users and the unified filesystem options API.
+
AgentBox SDK is derived from upstream work described in [UPSTREAM.md](UPSTREAM.md). Licensing notices are in [LICENSE](LICENSE) and [NOTICE](NOTICE).
diff --git a/docs/go-envd-user-selection.md b/docs/go-envd-user-selection.md
new file mode 100644
index 00000000..d7a164ee
--- /dev/null
+++ b/docs/go-envd-user-selection.md
@@ -0,0 +1,102 @@
+# Go envd user selection
+
+The Go SDK supports explicit per-operation users for command launch, PTY creation,
+and filesystem operations. Behavior follows the TypeScript and Python SDKs;
+Go uses option structs, contexts, and channels for the corresponding operations.
+Explicit user selection and the unified filesystem options API are introduced in
+v0.2.0.
+
+## User semantics
+
+- `CommandOptions.User` and `PTYOptions.User` select the OS user of a new process.
+ Selection works with collecting output, callbacks, and streaming delivery.
+- Filesystem `User` selects the home directory used for path resolution and the
+ owner of created filesystem objects. It does not provide OS permission isolation
+ for filesystem RPCs. In particular, watching `~` selects that user's home; envd
+ does not switch its own UID to install a filesystem watcher.
+- Empty `User`, nil options, and zero-valued options all use the template default
+ on envd 0.4.0 and newer. Older envd versions receive the compatibility username
+ `user`, matching omitted users in TypeScript and Python.
+- Go intentionally treats an empty string as omission. TypeScript/Python can
+ distinguish omission from an explicit empty string on old envd; Go has no
+ separate option to suppress that compatibility default.
+- Existing-process operations (`Connect`, input, stdin close, signals, process
+ listing, and PTY resize) do not select or change the process user. This matches
+ TypeScript and Python; `CommandConnectOptions` only configures output delivery.
+- Every selection belongs to one request. Concurrent operations on the same
+ sandbox can select different users without changing sandbox or client defaults.
+
+`USER` and `HOME` environment variables do not replace process identity selection.
+An unknown RPC username returns `AuthenticationError`; a username containing a
+colon or control character returns `InvalidArgumentError` before any request.
+There is no fallback or retry with another identity. Stream errors are available
+through the command/watch handle according to the existing handle contract.
+
+## Public API
+
+All filesystem methods take an options pointer as their final argument. Pass nil
+for defaults. The API intentionally replaces the v0.1.8 signatures, without
+parallel `WithOptions` methods.
+
+| Operations | Options |
+| ------------------------------------------------------ | ----------------------------------------------------------- |
+| `Commands.Run`, `Commands.Start` | `*CommandOptions` with `User` |
+| `PTY.Create` | `*PTYOptions` with `User` |
+| `Files.Read`, `ReadBytes`, `ReadText`, `ReadTo` | `*FileOptions` |
+| `Files.Stat`, `Exists`, `MakeDir`, `Rename`, `Remove` | `*FileOptions` |
+| `Files.List` | `*ListFilesOptions`: `User`, `Depth` (zero means one level) |
+| `Files.Write`, `WriteText`, `WriteBytes`, `WriteBatch` | `*WriteFileOptions` |
+| `Files.Watch` | `*WatchOptions` with `User` |
+| `Files.SignedReadURL`, `SignedWriteURL` | `*FileURLOptions`: `User`, `Expiration` |
+
+For example:
+
+```go
+result, err := sandbox.Commands.Run(ctx, "id", &agentbox.CommandOptions{
+ User: "root",
+ Args: []string{"-un"},
+})
+info, err := sandbox.Files.Stat(ctx, "~", &agentbox.FileOptions{User: "root"})
+entries, err := sandbox.Files.List(ctx, "~", &agentbox.ListFilesOptions{
+ User: "root",
+ Depth: 2,
+})
+watcher, err := sandbox.Files.Watch(ctx, "~", &agentbox.WatchOptions{User: "root"})
+```
+
+`ReadTo` takes `(ctx, path, writer, options)`. Signed URL methods take
+`(path, options)`; a zero `Expiration` preserves non-expiring URL behavior.
+`WriteBatch` applies the shared user and upload timeout to each file separately;
+per-file metadata overrides common metadata keys without modifying caller maps.
+
+Consumers migrating from v0.1.8 replace positional usernames with options, pass
+nil to filesystem RPCs that previously had no options, and move listing depth
+and signed URL expiration into their option structs.
+
+## Transport and scope
+
+Envd RPCs use `Authorization: Basic base64(username:)`, as implemented by
+[TypeScript](../packages/js-sdk/src/envd/rpc.ts) and
+[Python](../packages/python-sdk/agentbox/envd/utils.py).
+HTTP file operations continue to use the `username` query parameter; signed URLs
+include it in the signature. Access tokens, traffic tokens, and sandbox-routing
+headers remain independent of the selected user.
+
+The implementation sets identity on individual envd requests, not on the shared
+HTTP client or control-plane client. It uses the existing envd wire contract and
+does not change generated clients, protobuf schemas, or backend behavior.
+
+## Validation
+
+- Hermetic request tests cover command launch, PTY, every filesystem method,
+ signed URLs, nil/empty options, and old/new envd defaults.
+- Invalid names fail before transport; unknown RPC users produce typed errors
+ without retry. Concurrent collecting/streaming commands return their own user.
+- Scope tests cover process reattachment/control, platform requests, and unrelated
+ HTTP requests. Existing watch cancellation/body-close tests use an explicit user.
+- `TestEnvdUsersKVM` checks actual command/PTY identity for `root` and `user`,
+ selected-user home paths, ownership, watch events, and file CRUD in a real
+ sandbox. The test cleans up its sandbox.
+
+The independent [command streaming](go-command-streaming.md) contract continues to
+apply. Consumers can combine user selection and streaming in the same options.
diff --git a/packages/cli/package.json b/packages/cli/package.json
index 18c875d9..519c8e5e 100644
--- a/packages/cli/package.json
+++ b/packages/cli/package.json
@@ -1,6 +1,6 @@
{
"name": "@abox-dev/cli",
- "version": "0.1.8",
+ "version": "0.2.0",
"description": "CLI for AgentBox sandboxes and templates",
"homepage": "https://docs.agentbox.ru/en/cli/",
"license": "MIT",
diff --git a/packages/code-interpreter-js/package.json b/packages/code-interpreter-js/package.json
index b93cf0ec..21c95a98 100644
--- a/packages/code-interpreter-js/package.json
+++ b/packages/code-interpreter-js/package.json
@@ -1,6 +1,6 @@
{
"name": "@abox-dev/code-interpreter",
- "version": "0.1.8",
+ "version": "0.2.0",
"packageManager": "pnpm@10.34.5",
"description": "AgentBox Code Interpreter - Stateful code execution",
"homepage": "https://docs.agentbox.ru/en/sdk/code-interpreter/",
diff --git a/packages/code-interpreter-python/package.json b/packages/code-interpreter-python/package.json
index 4cd53c63..eaad4e01 100644
--- a/packages/code-interpreter-python/package.json
+++ b/packages/code-interpreter-python/package.json
@@ -1,7 +1,7 @@
{
"name": "@abox-dev/code-interpreter-python",
"private": true,
- "version": "0.1.8",
+ "version": "0.2.0",
"scripts": {
"test": "uv run pytest -n 2 --verbose -x tests/test_sandbox_url.py",
"test:integration": "uv run pytest -n 2 --verbose -x",
diff --git a/packages/code-interpreter-python/pyproject.toml b/packages/code-interpreter-python/pyproject.toml
index 1bb86bc8..2ae21f77 100644
--- a/packages/code-interpreter-python/pyproject.toml
+++ b/packages/code-interpreter-python/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "abox-code-interpreter"
-version = "0.1.8"
+version = "0.2.0"
description = "AgentBox Code Interpreter - Stateful code execution"
authors = [{ name = "RetailDriver LLC" }]
license = "MIT"
@@ -10,7 +10,7 @@ requires-python = ">=3.10"
dependencies = [
"httpx>=0.20.0,<1.0.0",
"attrs>=21.3.0",
- "abox-sdk>=0.1.0,<0.2.0",
+ "abox-sdk>=0.2.0,<0.3.0",
]
[project.urls]
diff --git a/packages/code-interpreter-python/uv.lock b/packages/code-interpreter-python/uv.lock
index 63963386..bdefa622 100644
--- a/packages/code-interpreter-python/uv.lock
+++ b/packages/code-interpreter-python/uv.lock
@@ -16,7 +16,7 @@ members = [
[[package]]
name = "abox-code-interpreter"
-version = "0.1.8"
+version = "0.2.0"
source = { editable = "." }
dependencies = [
{ name = "abox-sdk" },
@@ -58,7 +58,7 @@ dev = [
[[package]]
name = "abox-sdk"
-version = "0.1.8"
+version = "0.2.0"
source = { editable = "../python-sdk" }
dependencies = [
{ name = "attrs" },
diff --git a/packages/go-sdk/README.md b/packages/go-sdk/README.md
index ff297060..e1bab974 100644
--- a/packages/go-sdk/README.md
+++ b/packages/go-sdk/README.md
@@ -44,8 +44,8 @@ func main() {
Code Interpreter is available from
`github.com/abox-dev/sdk/packages/go-sdk/codeinterpreter`.
-API reference for this release: [core SDK on pkg.go.dev](https://pkg.go.dev/github.com/abox-dev/sdk/packages/go-sdk@v0.1.8) and
-[Code Interpreter on pkg.go.dev](https://pkg.go.dev/github.com/abox-dev/sdk/packages/go-sdk/codeinterpreter@v0.1.8).
+API reference for this release: [core SDK on pkg.go.dev](https://pkg.go.dev/github.com/abox-dev/sdk/packages/go-sdk@v0.2.0) and
+[Code Interpreter on pkg.go.dev](https://pkg.go.dev/github.com/abox-dev/sdk/packages/go-sdk/codeinterpreter@v0.2.0).
Documentation: [core SDK](https://docs.agentbox.ru/en/sdk/),
[sandboxes](https://docs.agentbox.ru/en/sdk/sandboxes/),
@@ -54,6 +54,33 @@ Documentation: [core SDK](https://docs.agentbox.ru/en/sdk/),
`Sandbox.Kill` returns `false, nil` when the sandbox no longer exists.
+## Execution user and filesystem options
+
+Set `User` in `CommandOptions`, `PTYOptions`, or filesystem options to select a
+sandbox user. Empty `User` uses the template default (or `user` on envd older
+than 0.4.0). The selection belongs to each operation and works with streaming.
+Existing-process operations retain the user selected at launch.
+
+```go
+result, err := sandbox.Commands.Run(ctx, "id", &agentbox.CommandOptions{
+ User: "root",
+ Args: []string{"-un"},
+})
+info, err := sandbox.Files.Stat(ctx, "~", &agentbox.FileOptions{User: "root"})
+entries, err := sandbox.Files.List(ctx, "~", &agentbox.ListFilesOptions{
+ User: "root",
+ Depth: 2,
+})
+```
+
+All filesystem methods accept options as their final argument; nil selects
+defaults. `FileOptions` covers reads and simple filesystem operations;
+`ListFilesOptions`, `WriteFileOptions`, `WatchOptions`, and `FileURLOptions`
+cover listing, uploads (including batches), watches, and signed URLs.
+Filesystem users affect path resolution and ownership of created objects, not
+OS permission isolation. See the [user-selection guide](../../docs/go-envd-user-selection.md)
+for semantics and the signature changes introduced in v0.2.0.
+
## Command output
By default, command handles collect complete stdout/stderr for `Wait`, which can
diff --git a/packages/go-sdk/commands.go b/packages/go-sdk/commands.go
index 90fc2a7c..a34f8519 100644
--- a/packages/go-sdk/commands.go
+++ b/packages/go-sdk/commands.go
@@ -19,6 +19,8 @@ import (
// CommandOptions configures a command process.
type CommandOptions struct {
+ // User selects the process owner. Empty uses the template default (user on envd < 0.4.0).
+ User string
Args []string
Env map[string]string
Cwd string
@@ -184,6 +186,10 @@ func (service *CommandService) Start(ctx context.Context, command string, option
request.Msg.Tag = &options.Tag
}
service.addHeaders(request.Header())
+ if err := service.sandbox.addUserHeader(request.Header(), options.User); err != nil {
+ cancel()
+ return nil, err
+ }
stream, err := service.outputClient(options.Streaming).Start(ctx, request)
if err != nil {
cancel()
diff --git a/packages/go-sdk/envd_test.go b/packages/go-sdk/envd_test.go
index 81610ae6..86b7d3cf 100644
--- a/packages/go-sdk/envd_test.go
+++ b/packages/go-sdk/envd_test.go
@@ -239,14 +239,14 @@ func TestFilesystem(t *testing.T) {
sandbox, closeServer := newEnvdTestSandbox(t)
defer closeServer()
ctx := context.Background()
- if text, err := sandbox.Files.ReadText(ctx, "/file.txt", ""); err != nil || text != "hello" {
+ if text, err := sandbox.Files.ReadText(ctx, "/file.txt", nil); err != nil || text != "hello" {
t.Fatalf("read: %q %v", text, err)
}
- if _, err := sandbox.Files.Read(ctx, "", ""); err == nil {
+ if _, err := sandbox.Files.Read(ctx, "", nil); err == nil {
t.Fatal("expected empty path validation")
}
var output strings.Builder
- if count, err := sandbox.Files.ReadTo(ctx, "/file.txt", "", &output); err != nil || count != 5 {
+ if count, err := sandbox.Files.ReadTo(ctx, "/file.txt", &output, nil); err != nil || count != 5 {
t.Fatalf("read to: %d %v", count, err)
}
if entry, err := sandbox.Files.WriteText(ctx, "/file.txt", "hello", &WriteFileOptions{Metadata: map[string]string{"kind": "test"}}); err != nil || entry.Path != "/file.txt" {
@@ -255,31 +255,31 @@ func TestFilesystem(t *testing.T) {
if _, err := sandbox.Files.WriteBytes(ctx, "/file.txt", []byte("hello"), nil); err != nil {
t.Fatal(err)
}
- if entries, err := sandbox.Files.WriteBatch(ctx, []WriteFile{{Path: "/file.txt", Data: strings.NewReader("hello")}}, ""); err != nil || len(entries) != 1 {
+ if entries, err := sandbox.Files.WriteBatch(ctx, []WriteFile{{Path: "/file.txt", Data: strings.NewReader("hello")}}, nil); err != nil || len(entries) != 1 {
t.Fatalf("batch: %v", err)
}
- if entry, err := sandbox.Files.Stat(ctx, "/file.txt"); err != nil || entry.Metadata["kind"] != "test" {
+ if entry, err := sandbox.Files.Stat(ctx, "/file.txt", nil); err != nil || entry.Metadata["kind"] != "test" {
t.Fatalf("stat: %#v %v", entry, err)
}
- if exists, err := sandbox.Files.Exists(ctx, "missing"); err != nil || exists {
+ if exists, err := sandbox.Files.Exists(ctx, "missing", nil); err != nil || exists {
t.Fatalf("exists: %v %v", exists, err)
}
- if exists, err := sandbox.Files.Exists(ctx, "/file.txt"); err != nil || !exists {
+ if exists, err := sandbox.Files.Exists(ctx, "/file.txt", nil); err != nil || !exists {
t.Fatalf("existing file: %v %v", exists, err)
}
- if entries, err := sandbox.Files.WriteBatch(ctx, []WriteFile{{Path: "", Data: nil}}, ""); err == nil || len(entries) != 0 {
+ if entries, err := sandbox.Files.WriteBatch(ctx, []WriteFile{{Path: "", Data: nil}}, nil); err == nil || len(entries) != 0 {
t.Fatal("expected batch failure")
}
- if entries, err := sandbox.Files.List(ctx, "/", 1); err != nil || len(entries) != 1 {
+ if entries, err := sandbox.Files.List(ctx, "/", &ListFilesOptions{Depth: 1}); err != nil || len(entries) != 1 {
t.Fatalf("list: %v", err)
}
- if _, err := sandbox.Files.MakeDir(ctx, "/dir"); err != nil {
+ if _, err := sandbox.Files.MakeDir(ctx, "/dir", nil); err != nil {
t.Fatal(err)
}
- if _, err := sandbox.Files.Rename(ctx, "/file.txt", "/new.txt"); err != nil {
+ if _, err := sandbox.Files.Rename(ctx, "/file.txt", "/new.txt", nil); err != nil {
t.Fatal(err)
}
- if err := sandbox.Files.Remove(ctx, "/new.txt"); err != nil {
+ if err := sandbox.Files.Remove(ctx, "/new.txt", nil); err != nil {
t.Fatal(err)
}
watcher, err := sandbox.Files.Watch(ctx, "/", &WatchOptions{IncludeEntry: true})
@@ -296,7 +296,7 @@ func TestFilesystem(t *testing.T) {
if _, err := sandbox.Files.WriteText(ctx, "/x", "x", &WriteFileOptions{Metadata: map[string]string{"bad key": "x"}}); err == nil {
t.Fatal("expected metadata validation")
}
- if _, err := sandbox.Files.ReadText(ctx, "missing-http", ""); err == nil {
+ if _, err := sandbox.Files.ReadText(ctx, "missing-http", nil); err == nil {
t.Fatal("expected HTTP file error")
} else {
var missing *FileNotFoundError
@@ -343,7 +343,7 @@ func TestEnvdVersionCompatibility(t *testing.T) {
sandbox, closeServer := newEnvdTestSandbox(t)
defer closeServer()
sandbox.EnvdVersion = "0.3.9"
- if _, err := sandbox.Files.ReadText(t.Context(), "old-user", ""); err != nil {
+ if _, err := sandbox.Files.ReadText(t.Context(), "old-user", nil); err != nil {
t.Fatal(err)
}
if _, err := sandbox.Files.WriteText(t.Context(), "/file.txt", "hello", &WriteFileOptions{Metadata: map[string]string{"kind": "test"}}); err == nil {
@@ -379,7 +379,7 @@ func TestUnaryEnvdRequestTimeout(t *testing.T) {
sandbox, closeServer := newEnvdTestSandbox(t)
defer closeServer()
sandbox.client.config.requestTimeout = 10 * time.Millisecond
- _, err := sandbox.Files.Stat(t.Context(), "slow")
+ _, err := sandbox.Files.Stat(t.Context(), "slow", nil)
var timeout *TimeoutError
if !errors.As(err, &timeout) {
t.Fatalf("expected TimeoutError, got %T: %v", err, err)
@@ -567,7 +567,7 @@ func TestStreamingResponsesAreClosed(t *testing.T) {
t.Fatal(err)
}
- watcher, err := sandbox.Files.Watch(t.Context(), "/", nil)
+ watcher, err := sandbox.Files.Watch(t.Context(), "/", &WatchOptions{User: "root"})
if err != nil {
t.Fatal(err)
}
@@ -586,7 +586,7 @@ func TestStreamingResponsesAreClosed(t *testing.T) {
func TestWatchCloseDoesNotRequireDrainingEvents(t *testing.T) {
sandbox, closeServer := newEnvdTestSandbox(t)
defer closeServer()
- watcher, err := sandbox.Files.Watch(t.Context(), "/flood", nil)
+ watcher, err := sandbox.Files.Watch(t.Context(), "/flood", &WatchOptions{User: "root"})
if err != nil {
t.Fatal(err)
}
diff --git a/packages/go-sdk/envd_user_test.go b/packages/go-sdk/envd_user_test.go
new file mode 100644
index 00000000..9218353d
--- /dev/null
+++ b/packages/go-sdk/envd_user_test.go
@@ -0,0 +1,358 @@
+package agentbox
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "connectrpc.com/connect"
+ api "github.com/abox-dev/sdk/packages/go-sdk/internal/gen/api"
+ "github.com/abox-dev/sdk/packages/go-sdk/internal/gen/envd/filesystem/filesystemconnect"
+ process "github.com/abox-dev/sdk/packages/go-sdk/internal/gen/envd/process"
+ "github.com/abox-dev/sdk/packages/go-sdk/internal/gen/envd/process/processconnect"
+)
+
+type userProcessServer struct{ testProcessServer }
+
+func (userProcessServer) Start(_ context.Context, request *connect.Request[process.StartRequest], stream *connect.ServerStream[process.StartResponse]) error {
+ user, _, _ := (&http.Request{Header: request.Header()}).BasicAuth()
+ if err := stream.Send(&process.StartResponse{Event: &process.ProcessEvent{Event: &process.ProcessEvent_Start{Start: &process.ProcessEvent_StartEvent{Pid: 7}}}}); err != nil {
+ return err
+ }
+ data := &process.ProcessEvent_DataEvent{Output: &process.ProcessEvent_DataEvent_Stdout{Stdout: []byte(user)}}
+ if request.Msg.Pty != nil {
+ data.Output = &process.ProcessEvent_DataEvent_Pty{Pty: []byte(user)}
+ }
+ if err := stream.Send(&process.StartResponse{Event: &process.ProcessEvent{Event: &process.ProcessEvent_Data{Data: data}}}); err != nil {
+ return err
+ }
+ return stream.Send(&process.StartResponse{Event: &process.ProcessEvent{Event: &process.ProcessEvent_End{End: &process.ProcessEvent_EndEvent{Exited: true}}}})
+}
+
+func newUserSelectionSandbox(t *testing.T, check func(*http.Request)) *Sandbox {
+ t.Helper()
+ mux := http.NewServeMux()
+ path, handler := processconnect.NewProcessHandler(userProcessServer{}, connect.WithCodec(tolerantJSONCodec{}))
+ mux.Handle(path, handler)
+ path, handler = filesystemconnect.NewFilesystemHandler(testFilesystemServer{}, connect.WithCodec(tolerantJSONCodec{}))
+ mux.Handle(path, handler)
+ mux.HandleFunc("/files", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodGet {
+ fmt.Fprint(w, "hello")
+ return
+ }
+ if err := r.ParseMultipartForm(1 << 20); err != nil {
+ t.Error(err)
+ http.Error(w, "invalid multipart upload", 400)
+ return
+ }
+ defer r.MultipartForm.RemoveAll()
+ w.Header().Set("Content-Type", "application/json")
+ fmt.Fprint(w, `[{"name":"file.txt","path":"/file.txt","type":"file"}]`)
+ })
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if check != nil {
+ check(r)
+ }
+ user, _, _ := r.BasicAuth()
+ if user == "unknown" {
+ _ = connect.NewErrorWriter().Write(w, r, connect.NewError(connect.CodeUnauthenticated, errors.New("unknown user")))
+ return
+ }
+ mux.ServeHTTP(w, r)
+ }))
+ t.Cleanup(server.Close)
+ client, err := NewClient(WithAPIURL(server.URL), WithSandboxURL(server.URL), WithAPIKey("test"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return client.sandboxFromAPI(api.Sandbox{SandboxID: "sbx", TemplateID: "base", EnvdVersion: "0.6.4", EnvdAccessToken: ptr("envd-token"), TrafficAccessToken: ptr("traffic-token")})
+}
+
+// These operations exercise public entry points, including the HTTP and RPC
+// transports, without depending on the internal header helper.
+func userSelectionOperations(ctx context.Context, sandbox *Sandbox, user string, nilOptions bool) map[string]func() error {
+ file := &FileOptions{User: user}
+ list := &ListFilesOptions{User: user, Depth: 2}
+ write := &WriteFileOptions{User: user}
+ watch := &WatchOptions{User: user}
+ command := &CommandOptions{User: user}
+ pty := &PTYOptions{User: user}
+ if nilOptions {
+ file, list, write, watch, command, pty = nil, nil, nil, nil, nil, nil
+ }
+ return map[string]func() error{
+ "run": func() error { _, err := sandbox.Commands.Run(ctx, "id", command); return err },
+ "start": func() error {
+ h, err := sandbox.Commands.Start(ctx, "id", command)
+ if err != nil {
+ return err
+ }
+ defer h.Close()
+ _, err = h.Wait(ctx)
+ return err
+ },
+ "pty": func() error {
+ h, err := sandbox.PTY.Create(ctx, "sh", pty)
+ if err != nil {
+ return err
+ }
+ defer h.Close()
+ _, err = h.Wait(ctx)
+ return err
+ },
+ "watch": func() error {
+ h, err := sandbox.Files.Watch(ctx, "~", watch)
+ if err != nil {
+ return err
+ }
+ defer h.Close()
+ for range h.Events {
+ }
+ return h.Close()
+ },
+ "stat": func() error { _, err := sandbox.Files.Stat(ctx, "~", file); return err },
+ "exists": func() error { _, err := sandbox.Files.Exists(ctx, "~", file); return err },
+ "list": func() error { _, err := sandbox.Files.List(ctx, "~", list); return err },
+ "mkdir": func() error { _, err := sandbox.Files.MakeDir(ctx, "~/dir", file); return err },
+ "rename": func() error { _, err := sandbox.Files.Rename(ctx, "~/a", "~/b", file); return err },
+ "remove": func() error { return sandbox.Files.Remove(ctx, "~/dir", file) },
+ "read": func() error { _, err := sandbox.Files.ReadText(ctx, "~/file", file); return err },
+ "readTo": func() error { _, err := sandbox.Files.ReadTo(ctx, "~/file", io.Discard, file); return err },
+ "write": func() error { _, err := sandbox.Files.WriteText(ctx, "~/file", "hello", write); return err },
+ "batch": func() error {
+ _, err := sandbox.Files.WriteBatch(ctx, []WriteFile{{Path: "~/file", Data: strings.NewReader("hello")}}, write)
+ return err
+ },
+ }
+}
+
+func TestEnvdUserSelection(t *testing.T) {
+ for _, tc := range []struct {
+ name, version, user, want string
+ nilOptions bool
+ }{
+ {"default", "0.6.4", "", "", true},
+ {"empty", "0.6.4", "", "", false},
+ {"boundary", "0.4.0", "", "", true},
+ {"legacy-default", "0.3.9", "", "user", true},
+ {"legacy-empty", "0.3.9", "", "user", false},
+ {"explicit", "0.6.4", "root", "root", false},
+ {"legacy-explicit", "0.3.9", "root", "root", false},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ sandbox := newUserSelectionSandbox(t, func(r *http.Request) {
+ user, password, ok := r.BasicAuth()
+ if r.URL.Path == "/files" {
+ user = r.URL.Query().Get("username")
+ if r.Header.Get("Authorization") != "" {
+ t.Error("HTTP files unexpectedly carry RPC authentication")
+ }
+ } else if ok != (tc.want != "") || password != "" {
+ t.Errorf("invalid Basic auth presence or password: %s", r.URL.Path)
+ }
+ if user != tc.want {
+ t.Errorf("%s: user = %q, want %q", r.URL.Path, user, tc.want)
+ }
+ for key, want := range map[string]string{"X-Access-Token": "envd-token", "Agentbox-Traffic-Access-Token": "traffic-token", "Agentbox-Sandbox-Id": "sbx", "Agentbox-Sandbox-Port": "49983"} {
+ if r.Header.Get(key) != want {
+ t.Errorf("missing %s", key)
+ }
+ }
+ })
+ sandbox.EnvdVersion = tc.version
+ for name, call := range userSelectionOperations(t.Context(), sandbox, tc.user, tc.nilOptions) {
+ t.Run(name, func(t *testing.T) {
+ if err := call(); err != nil {
+ t.Fatal(err)
+ }
+ })
+ }
+ for _, sign := range []func(string, *FileURLOptions) (string, error){sandbox.Files.SignedReadURL, sandbox.Files.SignedWriteURL} {
+ opts := &FileURLOptions{User: tc.user, Expiration: time.Unix(100, 0)}
+ if tc.nilOptions {
+ opts = nil
+ }
+ raw, err := sign("~/file", opts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ u, err := url.Parse(raw)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if u.Query().Get("username") != tc.want {
+ t.Fatalf("signed URL user: %s", raw)
+ }
+ }
+ })
+ }
+}
+
+func TestEnvdInvalidUser(t *testing.T) {
+ var requests atomic.Int64
+ sandbox := newUserSelectionSandbox(t, func(*http.Request) { requests.Add(1) })
+ for _, user := range []string{"root:ignored", "root\n", "root\x00", "root\x7f"} {
+ for name, call := range userSelectionOperations(t.Context(), sandbox, user, false) {
+ t.Run(user+"/"+name, func(t *testing.T) {
+ var invalid *InvalidArgumentError
+ if err := call(); !errors.As(err, &invalid) {
+ t.Fatalf("expected argument error, got %v", err)
+ }
+ })
+ }
+ if _, err := sandbox.Files.SignedReadURL("~/file", &FileURLOptions{User: user}); err == nil {
+ t.Fatal("accepted invalid signed URL user")
+ }
+ }
+ if requests.Load() != 0 {
+ t.Fatal("invalid user reached envd")
+ }
+ for name, call := range userSelectionOperations(t.Context(), sandbox, "unknown", false) {
+ // Unknown HTTP users are handled by the existing file endpoint error contract.
+ if name == "read" || name == "readTo" || name == "write" || name == "batch" {
+ continue
+ }
+ t.Run("unknown/"+name, func(t *testing.T) {
+ before := requests.Load()
+ var auth *AuthenticationError
+ if err := call(); !errors.As(err, &auth) {
+ t.Fatalf("expected authentication error, got %v", err)
+ }
+ if requests.Load()-before != 1 {
+ t.Fatal("unexpected retry or fallback")
+ }
+ })
+ }
+ _, err := sandbox.Commands.Run(t.Context(), "id", &CommandOptions{User: "unknown", Streaming: &CommandStreamingOptions{}})
+ var auth *AuthenticationError
+ if !errors.As(err, &auth) {
+ t.Fatalf("streaming authentication error: %T %v", err, err)
+ }
+
+}
+
+func TestEnvdConcurrentUsersAndStreaming(t *testing.T) {
+ sandbox := newUserSelectionSandbox(t, nil)
+ var wg sync.WaitGroup
+ for i := range 24 {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ user := []string{"root", "user"}[i%2]
+ var output strings.Builder
+ opts := &CommandOptions{User: user}
+ if i%3 != 0 {
+ opts.Streaming = &CommandStreamingOptions{}
+ opts.OnStdout = func(p []byte) { output.Write(p) }
+ }
+ result, err := sandbox.Commands.Run(t.Context(), "id", opts)
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ if opts.Streaming == nil {
+ output.Write(result.Stdout)
+ }
+ if output.String() != user {
+ t.Errorf("identity crossed calls: got %q, want %q", output.String(), user)
+ }
+ }()
+ }
+ wg.Wait()
+ var terminal strings.Builder
+ h, err := sandbox.PTY.Create(t.Context(), "sh", &PTYOptions{User: "root", Streaming: &CommandStreamingOptions{}, OnPTY: func(p []byte) { terminal.Write(p) }})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer h.Close()
+ if _, err := h.Wait(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ if terminal.String() != "root" {
+ t.Fatalf("PTY user: %q", terminal.String())
+ }
+}
+
+func TestEnvdUserHeaderScope(t *testing.T) {
+ sandbox := newUserSelectionSandbox(t, func(r *http.Request) {
+ if r.URL.Path == processconnect.ProcessStartProcedure {
+ return
+ }
+ if r.Header.Get("Authorization") != "" {
+ t.Errorf("user leaked to %s", r.URL.Path)
+ }
+ })
+ if _, err := sandbox.Commands.Run(t.Context(), "id", &CommandOptions{User: "root"}); err != nil {
+ t.Fatal(err)
+ }
+ // Existing-process operations do not select or change the process identity,
+ // including on old envd versions.
+ sandbox.EnvdVersion = "0.3.9"
+ h, err := sandbox.Commands.ConnectWithOptions(t.Context(), 7, "", &CommandConnectOptions{Streaming: &CommandStreamingOptions{}})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer h.Close()
+ if _, err := h.Wait(t.Context()); err != nil {
+ t.Fatal(err)
+ }
+ for _, call := range []func() error{
+ func() error { _, err := sandbox.Commands.List(t.Context()); return err },
+ func() error { return sandbox.Commands.Kill(t.Context(), 7, "") },
+ func() error { _, err := h.Write(t.Context(), []byte("input")); return err },
+ func() error { return h.CloseStdin(t.Context()) },
+ func() error { return sandbox.PTY.Resize(t.Context(), h, 80, 24) },
+ } {
+ if err := call(); err != nil {
+ t.Fatal(err)
+ }
+ }
+ _, _ = sandbox.client.Sandboxes.List(t.Context(), nil)
+ request, err := http.NewRequestWithContext(t.Context(), http.MethodGet, sandbox.client.config.apiURL+"/unrelated", nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ response, err := sandbox.client.httpClient.Do(request)
+ if err != nil {
+ t.Fatal(err)
+ }
+ response.Body.Close()
+}
+
+func TestWriteBatchUserAndMetadata(t *testing.T) {
+ var calls atomic.Int64
+ sandbox := newUserSelectionSandbox(t, func(r *http.Request) {
+ index := calls.Add(1)
+ if r.URL.Query().Get("username") != "root" {
+ t.Error("batch lost user")
+ }
+ want := "common"
+ if index == 1 {
+ want = "file"
+ }
+ if r.Header.Get("X-Metadata-Kind") != want || r.Header.Get("X-Metadata-Shared") != "yes" {
+ t.Errorf("batch metadata: %v", r.Header)
+ }
+ })
+ opts := &WriteFileOptions{User: "root", Metadata: map[string]string{"kind": "common", "shared": "yes"}, RequestTimeout: time.Second}
+ files := []WriteFile{
+ {Path: "~/one", Data: strings.NewReader("one"), Metadata: map[string]string{"kind": "file"}},
+ {Path: "~/two", Data: strings.NewReader("two")},
+ }
+ if entries, err := sandbox.Files.WriteBatch(t.Context(), files, opts); err != nil || len(entries) != 2 {
+ t.Fatalf("batch: %#v %v", entries, err)
+ }
+ if opts.Metadata["kind"] != "common" || len(files[0].Metadata) != 1 {
+ t.Fatal("batch modified caller options")
+ }
+}
diff --git a/packages/go-sdk/examples_test.go b/packages/go-sdk/examples_test.go
index 65745a21..99e52f0d 100644
--- a/packages/go-sdk/examples_test.go
+++ b/packages/go-sdk/examples_test.go
@@ -67,3 +67,30 @@ func ExampleCommandService_Start_streaming() {
_ = attached.CloseStdin(ctx)
_, _ = attached.Wait(ctx)
}
+
+func ExampleFileService_Stat() {
+ ctx := context.Background()
+ client, err := agentbox.NewClient()
+ if err != nil {
+ log.Fatal(err)
+ }
+ sandbox, err := client.Sandboxes.Create(ctx, nil)
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer sandbox.Kill(context.Background())
+ // The same user selects process identity and resolves filesystem paths.
+ result, err := sandbox.Commands.Run(ctx, "id", &agentbox.CommandOptions{
+ User: "root",
+ Args: []string{"-un"},
+ })
+ if err != nil {
+ log.Fatal(err)
+ }
+ log.Print(string(result.Stdout))
+ info, err := sandbox.Files.Stat(ctx, "~", &agentbox.FileOptions{User: "root"})
+ if err != nil {
+ log.Fatal(err)
+ }
+ log.Print(info.Path)
+}
diff --git a/packages/go-sdk/filesystem.go b/packages/go-sdk/filesystem.go
index 01eed14b..040d0a8f 100644
--- a/packages/go-sdk/filesystem.go
+++ b/packages/go-sdk/filesystem.go
@@ -58,8 +58,32 @@ type WriteFile struct {
Metadata map[string]string
}
+// FileOptions selects the user for a filesystem operation. User affects relative
+// path resolution and ownership of created objects, not OS permission isolation.
+// Empty User uses the template default, or user on envd older than 0.4.0.
+type FileOptions struct {
+ User string
+}
+
+// ListFilesOptions selects the user and recursion depth for directory listing.
+type ListFilesOptions struct {
+ // User follows FileOptions.User semantics.
+ User string
+ // Depth is the maximum directory depth. Zero defaults to one level.
+ Depth uint32
+}
+
+// FileURLOptions configures a signed file URL.
+type FileURLOptions struct {
+ // User follows FileOptions.User semantics and is included in the signature.
+ User string
+ // Expiration is the absolute expiry time. Zero creates a URL without expiry.
+ Expiration time.Time
+}
+
// WriteFileOptions configures file ownership, metadata, and upload timeout.
type WriteFileOptions struct {
+ // User follows FileOptions.User semantics.
User string
Metadata map[string]string
// RequestTimeout limits the complete streaming upload. Zero leaves the
@@ -68,7 +92,11 @@ type WriteFileOptions struct {
}
// WatchOptions configures recursive and enriched filesystem events.
-type WatchOptions struct{ Recursive, IncludeEntry, AllowNetworkMounts bool }
+type WatchOptions struct {
+ // User follows FileOptions.User semantics, including expansion of ~.
+ User string
+ Recursive, IncludeEntry, AllowNetworkMounts bool
+}
// FileEvent describes a filesystem change.
type FileEvent struct {
@@ -106,11 +134,17 @@ func newFileService(sandbox *Sandbox) *FileService {
}
// Read opens a streaming file response. The caller must close it.
-func (service *FileService) Read(ctx context.Context, path, user string) (io.ReadCloser, error) {
+func (service *FileService) Read(ctx context.Context, path string, options *FileOptions) (io.ReadCloser, error) {
if path == "" {
return nil, &InvalidArgumentError{Message: "file path cannot be empty"}
}
- user = service.sandbox.resolveUser(user)
+ if options == nil {
+ options = &FileOptions{}
+ }
+ user, err := service.sandbox.resolveUser(options.User)
+ if err != nil {
+ return nil, err
+ }
endpoint, _ := url.Parse(service.sandbox.envdURL(envdPort, false) + "/files")
query := endpoint.Query()
query.Set("path", path)
@@ -135,8 +169,8 @@ func (service *FileService) Read(ctx context.Context, path, user string) (io.Rea
}
// ReadBytes reads a complete file.
-func (service *FileService) ReadBytes(ctx context.Context, path, user string) ([]byte, error) {
- reader, err := service.Read(ctx, path, user)
+func (service *FileService) ReadBytes(ctx context.Context, path string, options *FileOptions) ([]byte, error) {
+ reader, err := service.Read(ctx, path, options)
if err != nil {
return nil, err
}
@@ -145,14 +179,14 @@ func (service *FileService) ReadBytes(ctx context.Context, path, user string) ([
}
// ReadText reads a UTF-8 file as a string.
-func (service *FileService) ReadText(ctx context.Context, path, user string) (string, error) {
- data, err := service.ReadBytes(ctx, path, user)
+func (service *FileService) ReadText(ctx context.Context, path string, options *FileOptions) (string, error) {
+ data, err := service.ReadBytes(ctx, path, options)
return string(data), err
}
// ReadTo streams a file into writer.
-func (service *FileService) ReadTo(ctx context.Context, path, user string, writer io.Writer) (int64, error) {
- reader, err := service.Read(ctx, path, user)
+func (service *FileService) ReadTo(ctx context.Context, path string, writer io.Writer, options *FileOptions) (int64, error) {
+ reader, err := service.Read(ctx, path, options)
if err != nil {
return 0, err
}
@@ -174,7 +208,10 @@ func (service *FileService) Write(ctx context.Context, path string, reader io.Re
if len(options.Metadata) > 0 && !envdAtLeast(service.sandbox.EnvdVersion, 0, 6, 2) {
return nil, &TemplateError{APIError: APIError{Message: "file metadata requires envd 0.6.2 or later"}}
}
- user := service.sandbox.resolveUser(options.User)
+ user, err := service.sandbox.resolveUser(options.User)
+ if err != nil {
+ return nil, err
+ }
endpoint, _ := url.Parse(service.sandbox.envdURL(envdPort, false) + "/files")
query := endpoint.Query()
query.Set("path", path)
@@ -233,11 +270,19 @@ func (service *FileService) WriteBytes(ctx context.Context, path string, data []
return service.Write(ctx, path, bytes.NewReader(data), options)
}
-// WriteBatch writes files in order and stops at the first failure.
-func (service *FileService) WriteBatch(ctx context.Context, files []WriteFile, user string) ([]EntryInfo, error) {
+// WriteBatch writes files in order and stops at the first failure. Each file
+// overrides common metadata keys. RequestTimeout applies separately to each upload.
+func (service *FileService) WriteBatch(ctx context.Context, files []WriteFile, options *WriteFileOptions) ([]EntryInfo, error) {
+ if options == nil {
+ options = &WriteFileOptions{}
+ }
result := make([]EntryInfo, 0, len(files))
for _, file := range files {
- entry, err := service.Write(ctx, file.Path, file.Data, &WriteFileOptions{User: user, Metadata: file.Metadata})
+ fileOptions := *options
+ fileOptions.Metadata = make(map[string]string, len(options.Metadata)+len(file.Metadata))
+ maps.Copy(fileOptions.Metadata, options.Metadata)
+ maps.Copy(fileOptions.Metadata, file.Metadata)
+ entry, err := service.Write(ctx, file.Path, file.Data, &fileOptions)
if err != nil {
return result, err
}
@@ -247,11 +292,16 @@ func (service *FileService) WriteBatch(ctx context.Context, files []WriteFile, u
}
// Stat returns information about a path.
-func (service *FileService) Stat(ctx context.Context, path string) (*EntryInfo, error) {
+func (service *FileService) Stat(ctx context.Context, path string, options *FileOptions) (*EntryInfo, error) {
+ if options == nil {
+ options = &FileOptions{}
+ }
requestCtx, cancel := service.sandbox.unaryContext(ctx)
defer cancel()
request := connect.NewRequest(&filesystem.StatRequest{Path: path})
- service.addHeaders(request.Header())
+ if err := service.addHeaders(request.Header(), options.User); err != nil {
+ return nil, err
+ }
response, err := service.client.Stat(requestCtx, request)
if err != nil {
return nil, fileConnectError(err)
@@ -260,8 +310,8 @@ func (service *FileService) Stat(ctx context.Context, path string) (*EntryInfo,
}
// Exists reports whether path exists.
-func (service *FileService) Exists(ctx context.Context, path string) (bool, error) {
- _, err := service.Stat(ctx, path)
+func (service *FileService) Exists(ctx context.Context, path string, options *FileOptions) (bool, error) {
+ _, err := service.Stat(ctx, path, options)
var notFound *FileNotFoundError
if errors.As(err, ¬Found) {
return false, nil
@@ -269,12 +319,17 @@ func (service *FileService) Exists(ctx context.Context, path string) (bool, erro
return err == nil, err
}
-// List lists path recursively up to depth.
-func (service *FileService) List(ctx context.Context, path string, depth uint32) ([]EntryInfo, error) {
+// List lists path recursively up to the selected depth (one level by default).
+func (service *FileService) List(ctx context.Context, path string, options *ListFilesOptions) ([]EntryInfo, error) {
+ if options == nil {
+ options = &ListFilesOptions{}
+ }
requestCtx, cancel := service.sandbox.unaryContext(ctx)
defer cancel()
- request := connect.NewRequest(&filesystem.ListDirRequest{Path: path, Depth: depth})
- service.addHeaders(request.Header())
+ request := connect.NewRequest(&filesystem.ListDirRequest{Path: path, Depth: options.Depth})
+ if err := service.addHeaders(request.Header(), options.User); err != nil {
+ return nil, err
+ }
response, err := service.client.ListDir(requestCtx, request)
if err != nil {
return nil, fileConnectError(err)
@@ -287,11 +342,16 @@ func (service *FileService) List(ctx context.Context, path string, depth uint32)
}
// MakeDir creates a directory.
-func (service *FileService) MakeDir(ctx context.Context, path string) (*EntryInfo, error) {
+func (service *FileService) MakeDir(ctx context.Context, path string, options *FileOptions) (*EntryInfo, error) {
+ if options == nil {
+ options = &FileOptions{}
+ }
requestCtx, cancel := service.sandbox.unaryContext(ctx)
defer cancel()
request := connect.NewRequest(&filesystem.MakeDirRequest{Path: path})
- service.addHeaders(request.Header())
+ if err := service.addHeaders(request.Header(), options.User); err != nil {
+ return nil, err
+ }
response, err := service.client.MakeDir(requestCtx, request)
if err != nil {
return nil, fileConnectError(err)
@@ -300,11 +360,16 @@ func (service *FileService) MakeDir(ctx context.Context, path string) (*EntryInf
}
// Rename moves a filesystem entry.
-func (service *FileService) Rename(ctx context.Context, source, destination string) (*EntryInfo, error) {
+func (service *FileService) Rename(ctx context.Context, source, destination string, options *FileOptions) (*EntryInfo, error) {
+ if options == nil {
+ options = &FileOptions{}
+ }
requestCtx, cancel := service.sandbox.unaryContext(ctx)
defer cancel()
request := connect.NewRequest(&filesystem.MoveRequest{Source: source, Destination: destination})
- service.addHeaders(request.Header())
+ if err := service.addHeaders(request.Header(), options.User); err != nil {
+ return nil, err
+ }
response, err := service.client.Move(requestCtx, request)
if err != nil {
return nil, fileConnectError(err)
@@ -313,11 +378,16 @@ func (service *FileService) Rename(ctx context.Context, source, destination stri
}
// Remove recursively removes a filesystem entry.
-func (service *FileService) Remove(ctx context.Context, path string) error {
+func (service *FileService) Remove(ctx context.Context, path string, options *FileOptions) error {
+ if options == nil {
+ options = &FileOptions{}
+ }
requestCtx, cancel := service.sandbox.unaryContext(ctx)
defer cancel()
request := connect.NewRequest(&filesystem.RemoveRequest{Path: path})
- service.addHeaders(request.Header())
+ if err := service.addHeaders(request.Header(), options.User); err != nil {
+ return err
+ }
_, err := service.client.Remove(requestCtx, request)
return fileConnectError(err)
}
@@ -353,7 +423,10 @@ func (service *FileService) Watch(ctx context.Context, path string, options *Wat
}
watchCtx, cancel := context.WithCancel(ctx)
request := connect.NewRequest(&filesystem.WatchDirRequest{Path: path, Recursive: options.Recursive, IncludeEntry: options.IncludeEntry, AllowNetworkMounts: options.AllowNetworkMounts})
- service.addHeaders(request.Header())
+ if err := service.addHeaders(request.Header(), options.User); err != nil {
+ cancel()
+ return nil, err
+ }
stream, err := service.client.WatchDir(watchCtx, request)
if err != nil {
cancel()
@@ -386,17 +459,23 @@ func (service *FileService) Watch(ctx context.Context, path string, options *Wat
}
// SignedReadURL creates a directly usable download URL.
-func (service *FileService) SignedReadURL(path, user string, expiration time.Time) (string, error) {
- return service.signedURL(path, user, "read", expiration)
+func (service *FileService) SignedReadURL(path string, options *FileURLOptions) (string, error) {
+ return service.signedURL(path, "read", options)
}
// SignedWriteURL creates a directly usable upload URL.
-func (service *FileService) SignedWriteURL(path, user string, expiration time.Time) (string, error) {
- return service.signedURL(path, user, "write", expiration)
+func (service *FileService) SignedWriteURL(path string, options *FileURLOptions) (string, error) {
+ return service.signedURL(path, "write", options)
}
-func (service *FileService) signedURL(path, user, operation string, expiration time.Time) (string, error) {
- user = service.sandbox.resolveUser(user)
- signature, unix, err := fileSignature(path, operation, user, service.sandbox.envdAccessToken, expiration)
+func (service *FileService) signedURL(path, operation string, options *FileURLOptions) (string, error) {
+ if options == nil {
+ options = &FileURLOptions{}
+ }
+ user, err := service.sandbox.resolveUser(options.User)
+ if err != nil {
+ return "", err
+ }
+ signature, unix, err := fileSignature(path, operation, user, service.sandbox.envdAccessToken, options.Expiration)
if err != nil {
return "", err
}
@@ -414,11 +493,12 @@ func (service *FileService) signedURL(path, user, operation string, expiration t
return endpoint.String(), nil
}
-func (service *FileService) addHeaders(header http.Header) {
+func (service *FileService) addHeaders(header http.Header, user string) error {
for key, values := range service.sandbox.envdHeaders(envdPort) {
header[key] = slices.Clone(values)
}
header.Set("Keepalive-Ping-Interval", "50")
+ return service.sandbox.addUserHeader(header, user)
}
func mapEntry(entry *filesystem.EntryInfo) *EntryInfo {
if entry == nil {
diff --git a/packages/go-sdk/integration/envd_user_test.go b/packages/go-sdk/integration/envd_user_test.go
new file mode 100644
index 00000000..5859d32f
--- /dev/null
+++ b/packages/go-sdk/integration/envd_user_test.go
@@ -0,0 +1,128 @@
+//go:build integration
+
+package integration_test
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "path"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/abox-dev/sdk/packages/go-sdk"
+)
+
+func TestEnvdUsersKVM(t *testing.T) {
+ ctx, cancel := context.WithTimeout(t.Context(), 3*time.Minute)
+ defer cancel()
+ client, err := agentbox.NewClient()
+ if err != nil {
+ t.Fatal(err)
+ }
+ sandbox, err := client.Sandboxes.Create(ctx, &agentbox.CreateSandboxOptions{Timeout: 3 * time.Minute})
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ cleanup, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ if _, err := sandbox.Kill(cleanup); err != nil {
+ t.Errorf("sandbox cleanup: %v", err)
+ }
+ })
+ for _, user := range []string{"root", "user"} {
+ t.Run(user, func(t *testing.T) {
+ result, err := sandbox.Commands.Run(ctx, "id", &agentbox.CommandOptions{User: user, Args: []string{"-un"}})
+ if err != nil || strings.TrimSpace(string(result.Stdout)) != user {
+ t.Fatalf("identity: %q %v", result.Stdout, err)
+ }
+ var output strings.Builder
+ h, err := sandbox.Commands.Start(ctx, "id", &agentbox.CommandOptions{User: user, Args: []string{"-un"}, Streaming: &agentbox.CommandStreamingOptions{}, OnStdout: func(p []byte) { output.Write(p) }})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer h.Close()
+ if _, err := h.Wait(ctx); err != nil {
+ t.Fatal(err)
+ }
+ if strings.TrimSpace(output.String()) != user {
+ t.Fatalf("streaming identity: %q", output.String())
+ }
+ var terminal strings.Builder
+ pty, err := sandbox.PTY.Create(ctx, "id", &agentbox.PTYOptions{User: user, Args: []string{"-un"}, Streaming: &agentbox.CommandStreamingOptions{}, OnPTY: func(p []byte) { terminal.Write(p) }})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer pty.Close()
+ if _, err := pty.Wait(ctx); err != nil {
+ t.Fatal(err)
+ }
+ if strings.TrimSpace(terminal.String()) != user {
+ t.Fatalf("PTY identity: %q", terminal.String())
+ }
+
+ options := &agentbox.FileOptions{User: user}
+ directory := fmt.Sprintf("~/go-sdk-user-%d", time.Now().UnixNano())
+ entry, err := sandbox.Files.MakeDir(ctx, directory, options)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if entry.Owner != user {
+ t.Fatalf("directory owner: %q", entry.Owner)
+ }
+ watchCtx, stop := context.WithTimeout(ctx, 15*time.Second)
+ defer stop()
+ watcher, err := sandbox.Files.Watch(watchCtx, directory, &agentbox.WatchOptions{User: user, IncludeEntry: true})
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer watcher.Close()
+ file := directory + "/file"
+ if _, err := sandbox.Files.WriteText(ctx, file, user, &agentbox.WriteFileOptions{User: user}); err != nil {
+ t.Fatal(err)
+ }
+ select {
+ case event, ok := <-watcher.Events:
+ if !ok {
+ t.Fatalf("watch ended: %v", watcher.Close())
+ }
+ if event.Entry == nil || event.Entry.Path != path.Join(entry.Path, "file") {
+ t.Fatalf("watch path: %#v", event)
+ }
+ case <-watchCtx.Done():
+ t.Fatal("watch did not observe the selected user's directory")
+ }
+ if err := watcher.Close(); err != nil {
+ t.Fatal(err)
+ }
+ info, err := sandbox.Files.Stat(ctx, file, options)
+ if err != nil || info.Owner != user {
+ t.Fatalf("file owner: %#v %v", info, err)
+ }
+ text, err := sandbox.Files.ReadText(ctx, file, options)
+ if err != nil || text != user {
+ t.Fatalf("read: %q %v", text, err)
+ }
+ entries, err := sandbox.Files.List(ctx, directory, &agentbox.ListFilesOptions{User: user, Depth: 1})
+ if err != nil || len(entries) != 1 {
+ t.Fatalf("list: %#v %v", entries, err)
+ }
+ if _, err := sandbox.Files.Rename(ctx, file, directory+"/renamed", options); err != nil {
+ t.Fatal(err)
+ }
+ if exists, err := sandbox.Files.Exists(ctx, directory+"/renamed", options); err != nil || !exists {
+ t.Fatalf("exists: %v %v", exists, err)
+ }
+ if err := sandbox.Files.Remove(ctx, directory, options); err != nil {
+ t.Fatal(err)
+ }
+ })
+ }
+ _, err = sandbox.Commands.Run(ctx, "id", &agentbox.CommandOptions{User: "agentbox-user-does-not-exist"})
+ var auth *agentbox.AuthenticationError
+ if !errors.As(err, &auth) {
+ t.Fatalf("unknown user: %T %v", err, err)
+ }
+}
diff --git a/packages/go-sdk/integration/sdk_test.go b/packages/go-sdk/integration/sdk_test.go
index 3428a92a..fcc23aff 100644
--- a/packages/go-sdk/integration/sdk_test.go
+++ b/packages/go-sdk/integration/sdk_test.go
@@ -35,7 +35,7 @@ func TestCoreKVM(t *testing.T) {
if _, err := sandbox.Files.WriteText(ctx, "/tmp/go-sdk.txt", "content", nil); err != nil {
t.Fatal(err)
}
- if text, err := sandbox.Files.ReadText(ctx, "/tmp/go-sdk.txt", ""); err != nil || text != "content" {
+ if text, err := sandbox.Files.ReadText(ctx, "/tmp/go-sdk.txt", nil); err != nil || text != "content" {
t.Fatalf("file: %q %v", text, err)
}
if info, err := sandbox.Info(ctx); err != nil || info.SandboxID != sandbox.ID {
diff --git a/packages/go-sdk/pty.go b/packages/go-sdk/pty.go
index af0e534f..8922b09f 100644
--- a/packages/go-sdk/pty.go
+++ b/packages/go-sdk/pty.go
@@ -9,6 +9,8 @@ import (
// PTYOptions configures an interactive terminal.
type PTYOptions struct {
+ // User selects the process owner. Empty uses the template default (user on envd < 0.4.0).
+ User string
Args []string
Env map[string]string
Cwd string
@@ -52,6 +54,10 @@ func (service *PTYService) Create(ctx context.Context, command string, options *
request.Msg.Tag = &options.Tag
}
service.commands.addHeaders(request.Header())
+ if err := service.commands.sandbox.addUserHeader(request.Header(), options.User); err != nil {
+ cancel()
+ return nil, err
+ }
stream, err := service.commands.outputClient(options.Streaming).Start(ctx, request)
if err != nil {
cancel()
diff --git a/packages/go-sdk/sandbox.go b/packages/go-sdk/sandbox.go
index 41c32505..6cc70662 100644
--- a/packages/go-sdk/sandbox.go
+++ b/packages/go-sdk/sandbox.go
@@ -691,11 +691,30 @@ func (sandbox *Sandbox) envdHeaders(port int) http.Header {
return headers
}
-func (sandbox *Sandbox) resolveUser(user string) string {
+// resolveUser applies the same envd compatibility default to HTTP and RPC calls.
+func (sandbox *Sandbox) resolveUser(user string) (string, error) {
+ for _, char := range user {
+ if char == ':' || char < 0x20 || char == 0x7f {
+ return "", &InvalidArgumentError{Message: "sandbox username cannot contain colons or control characters"}
+ }
+ }
if user == "" && !envdAtLeast(sandbox.EnvdVersion, 0, 4, 0) {
- return "user"
+ return "user", nil
}
- return user
+ return user, nil
+}
+
+// addUserHeader selects the execution user only for an individual envd RPC.
+func (sandbox *Sandbox) addUserHeader(header http.Header, user string) error {
+ user, err := sandbox.resolveUser(user)
+ if err != nil {
+ return err
+ }
+ if user != "" {
+ request := &http.Request{Header: header}
+ request.SetBasicAuth(user, "")
+ }
+ return nil
}
func envdAtLeast(version string, major, minor, patch int) bool {
diff --git a/packages/go-sdk/sandbox_test.go b/packages/go-sdk/sandbox_test.go
index 4d721c9c..24490cb1 100644
--- a/packages/go-sdk/sandbox_test.go
+++ b/packages/go-sdk/sandbox_test.go
@@ -195,7 +195,7 @@ func TestSandboxValidationAndSigning(t *testing.T) {
t.Fatal("expected invalid IAM config")
}
sandbox := client.sandboxFromAPI(api.Sandbox{SandboxID: "id", TemplateID: "base", EnvdVersion: "1"})
- if _, err := sandbox.Files.SignedReadURL("/x", "", time.Time{}); err == nil {
+ if _, err := sandbox.Files.SignedReadURL("/x", nil); err == nil {
t.Fatal("expected missing token")
}
sandbox.envdAccessToken = "secret"
@@ -203,11 +203,11 @@ func TestSandboxValidationAndSigning(t *testing.T) {
if rendered := fmt.Sprintf("%+v %#v", sandbox, sandbox); strings.Contains(rendered, "secret") {
t.Fatalf("sandbox formatting leaked credentials: %s", rendered)
}
- read, err := sandbox.Files.SignedReadURL("/x", "user", time.Unix(100, 0))
+ read, err := sandbox.Files.SignedReadURL("/x", &FileURLOptions{User: "user", Expiration: time.Unix(100, 0)})
if err != nil || !strings.Contains(read, "signature=v1_") || !strings.Contains(read, "signature_expiration=100") {
t.Fatalf("signed URL: %s %v", read, err)
}
- write, err := sandbox.Files.SignedWriteURL("/x", "", time.Time{})
+ write, err := sandbox.Files.SignedWriteURL("/x", nil)
if err != nil || write == read {
t.Fatalf("signed write URL: %v", err)
}
diff --git a/packages/go-sdk/tests/consumer/consumer_test.go b/packages/go-sdk/tests/consumer/consumer_test.go
index 14c2e312..1eeaf0a8 100644
--- a/packages/go-sdk/tests/consumer/consumer_test.go
+++ b/packages/go-sdk/tests/consumer/consumer_test.go
@@ -20,4 +20,10 @@ func TestPublicPackagesCompile(t *testing.T) {
_ = agentbox.TemplateInfoOptions{Limit: 10}
_ = agentbox.ForkResult{}
_ = agentbox.SandboxRequestOptions{}
+ _ = agentbox.CommandOptions{User: "user"}
+ _ = agentbox.PTYOptions{User: "root"}
+ _ = agentbox.FileOptions{User: "user"}
+ _ = agentbox.ListFilesOptions{User: "user", Depth: 2}
+ _ = agentbox.WatchOptions{User: "user"}
+ _ = agentbox.FileURLOptions{User: "user"}
}
diff --git a/packages/go-sdk/version.go b/packages/go-sdk/version.go
index 926be685..91656afe 100644
--- a/packages/go-sdk/version.go
+++ b/packages/go-sdk/version.go
@@ -1,4 +1,4 @@
package agentbox
// Version is the AgentBox SDK release version.
-const Version = "0.1.8"
+const Version = "0.2.0"
diff --git a/packages/js-sdk/package.json b/packages/js-sdk/package.json
index 7d587f33..9cde8041 100644
--- a/packages/js-sdk/package.json
+++ b/packages/js-sdk/package.json
@@ -1,6 +1,6 @@
{
"name": "@abox-dev/sdk",
- "version": "0.1.8",
+ "version": "0.2.0",
"description": "AgentBox SDK for secure cloud sandboxes",
"homepage": "https://docs.agentbox.ru/en/sdk/",
"license": "MIT",
diff --git a/packages/python-sdk/package.json b/packages/python-sdk/package.json
index 61506ba1..9fbcfc53 100644
--- a/packages/python-sdk/package.json
+++ b/packages/python-sdk/package.json
@@ -1,7 +1,7 @@
{
"name": "@abox-dev/python-sdk",
"private": true,
- "version": "0.1.8",
+ "version": "0.2.0",
"scripts": {
"test": "uv run pytest -n 4 --verbose -x tests/test_*.py tests/shared",
"test:integration": "uv run pytest -n 4 --verbose -x tests/sync tests/async",
diff --git a/packages/python-sdk/pyproject.toml b/packages/python-sdk/pyproject.toml
index ab9d5ebe..b3ef1898 100644
--- a/packages/python-sdk/pyproject.toml
+++ b/packages/python-sdk/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "abox-sdk"
-version = "0.1.8"
+version = "0.2.0"
description = "AgentBox SDK for secure cloud sandboxes"
authors = [{ name = "RetailDriver LLC" }]
license = "MIT"
diff --git a/packages/python-sdk/uv.lock b/packages/python-sdk/uv.lock
index 92a38cc9..7924acb1 100644
--- a/packages/python-sdk/uv.lock
+++ b/packages/python-sdk/uv.lock
@@ -8,7 +8,7 @@ resolution-markers = [
[[package]]
name = "abox-sdk"
-version = "0.1.8"
+version = "0.2.0"
source = { editable = "." }
dependencies = [
{ name = "attrs" },
diff --git a/reference/manifest.json b/reference/manifest.json
index 1d2e6204..ba83a016 100644
--- a/reference/manifest.json
+++ b/reference/manifest.json
@@ -45,7 +45,7 @@
"sdk/cli/sandbox.md": "16b64c5a4e932eca2452e400e0faf154eba1d3c7373513aa7cd9744e02908313",
"sdk/cli/template.md": "f270b22b9ee10a9b954a14f24e04c5449b4d5823bb28002ffde3ed682594cfc5",
"sdk/go/code-interpreter.md": "9c56333b8181878d656baa27a4b56b768a0cb01c9d764f7ac8d52d32d8faa70c",
- "sdk/go/core.md": "5f53d0230c06cde85d119df4f21cfeb6c69ce5f84ef65d3b86776b76ad7bc2ea",
+ "sdk/go/core.md": "6da3d84150f2c1695f64cd8694800679fe55005720e7c52d7657546e8efb1d6a",
"sdk/javascript/code-interpreter/README.md": "a787206eb86f81fc306b373ce20bc6c91464b0ab3c3ec7650eccbb6be5f676c5",
"sdk/javascript/code-interpreter/classes/Sandbox.md": "19d9f1f1e8847606a54d93c5a6e5d4c89f936a745866e8d6a2aa06758b605e60",
"sdk/javascript/code-interpreter/enumerations/ChartType.md": "cf1ba00cd3258cc88814b7215e101b0d775ceb15dd833636d58ae538d8107ec1",
@@ -185,12 +185,12 @@
},
"monoRevision": "a7d59d50e3dd84faee59aca08df1b5bb175266e6",
"packages": {
- "@abox-dev/cli": "0.1.8",
- "@abox-dev/code-interpreter": "0.1.8",
- "@abox-dev/sdk": "0.1.8",
- "abox-code-interpreter": "0.1.8",
- "abox-sdk": "0.1.8",
- "github.com/abox-dev/sdk/packages/go-sdk": "0.1.8"
+ "@abox-dev/cli": "0.2.0",
+ "@abox-dev/code-interpreter": "0.2.0",
+ "@abox-dev/sdk": "0.2.0",
+ "abox-code-interpreter": "0.2.0",
+ "abox-sdk": "0.2.0",
+ "github.com/abox-dev/sdk/packages/go-sdk": "0.2.0"
},
"schemaVersion": 1
}
diff --git a/reference/sdk/go/core.md b/reference/sdk/go/core.md
index 2ce9a329..1aafb5db 100644
--- a/reference/sdk/go/core.md
+++ b/reference/sdk/go/core.md
@@ -87,25 +87,27 @@ Create a client, start a sandbox, and run a command:
- [type FileNotFoundError](<#FileNotFoundError>)
- [func \(e \*FileNotFoundError\) Error\(\) string](<#FileNotFoundError.Error>)
- [func \(e \*FileNotFoundError\) Unwrap\(\) error](<#FileNotFoundError.Unwrap>)
+- [type FileOptions](<#FileOptions>)
- [type FileService](<#FileService>)
- - [func \(service \*FileService\) Exists\(ctx context.Context, path string\) \(bool, error\)](<#FileService.Exists>)
- - [func \(service \*FileService\) List\(ctx context.Context, path string, depth uint32\) \(\[\]EntryInfo, error\)](<#FileService.List>)
- - [func \(service \*FileService\) MakeDir\(ctx context.Context, path string\) \(\*EntryInfo, error\)](<#FileService.MakeDir>)
- - [func \(service \*FileService\) Read\(ctx context.Context, path, user string\) \(io.ReadCloser, error\)](<#FileService.Read>)
- - [func \(service \*FileService\) ReadBytes\(ctx context.Context, path, user string\) \(\[\]byte, error\)](<#FileService.ReadBytes>)
- - [func \(service \*FileService\) ReadText\(ctx context.Context, path, user string\) \(string, error\)](<#FileService.ReadText>)
- - [func \(service \*FileService\) ReadTo\(ctx context.Context, path, user string, writer io.Writer\) \(int64, error\)](<#FileService.ReadTo>)
- - [func \(service \*FileService\) Remove\(ctx context.Context, path string\) error](<#FileService.Remove>)
- - [func \(service \*FileService\) Rename\(ctx context.Context, source, destination string\) \(\*EntryInfo, error\)](<#FileService.Rename>)
- - [func \(service \*FileService\) SignedReadURL\(path, user string, expiration time.Time\) \(string, error\)](<#FileService.SignedReadURL>)
- - [func \(service \*FileService\) SignedWriteURL\(path, user string, expiration time.Time\) \(string, error\)](<#FileService.SignedWriteURL>)
- - [func \(service \*FileService\) Stat\(ctx context.Context, path string\) \(\*EntryInfo, error\)](<#FileService.Stat>)
+ - [func \(service \*FileService\) Exists\(ctx context.Context, path string, options \*FileOptions\) \(bool, error\)](<#FileService.Exists>)
+ - [func \(service \*FileService\) List\(ctx context.Context, path string, options \*ListFilesOptions\) \(\[\]EntryInfo, error\)](<#FileService.List>)
+ - [func \(service \*FileService\) MakeDir\(ctx context.Context, path string, options \*FileOptions\) \(\*EntryInfo, error\)](<#FileService.MakeDir>)
+ - [func \(service \*FileService\) Read\(ctx context.Context, path string, options \*FileOptions\) \(io.ReadCloser, error\)](<#FileService.Read>)
+ - [func \(service \*FileService\) ReadBytes\(ctx context.Context, path string, options \*FileOptions\) \(\[\]byte, error\)](<#FileService.ReadBytes>)
+ - [func \(service \*FileService\) ReadText\(ctx context.Context, path string, options \*FileOptions\) \(string, error\)](<#FileService.ReadText>)
+ - [func \(service \*FileService\) ReadTo\(ctx context.Context, path string, writer io.Writer, options \*FileOptions\) \(int64, error\)](<#FileService.ReadTo>)
+ - [func \(service \*FileService\) Remove\(ctx context.Context, path string, options \*FileOptions\) error](<#FileService.Remove>)
+ - [func \(service \*FileService\) Rename\(ctx context.Context, source, destination string, options \*FileOptions\) \(\*EntryInfo, error\)](<#FileService.Rename>)
+ - [func \(service \*FileService\) SignedReadURL\(path string, options \*FileURLOptions\) \(string, error\)](<#FileService.SignedReadURL>)
+ - [func \(service \*FileService\) SignedWriteURL\(path string, options \*FileURLOptions\) \(string, error\)](<#FileService.SignedWriteURL>)
+ - [func \(service \*FileService\) Stat\(ctx context.Context, path string, options \*FileOptions\) \(\*EntryInfo, error\)](<#FileService.Stat>)
- [func \(service \*FileService\) Watch\(ctx context.Context, path string, options \*WatchOptions\) \(\*WatchHandle, error\)](<#FileService.Watch>)
- [func \(service \*FileService\) Write\(ctx context.Context, path string, reader io.Reader, options \*WriteFileOptions\) \(\*EntryInfo, error\)](<#FileService.Write>)
- - [func \(service \*FileService\) WriteBatch\(ctx context.Context, files \[\]WriteFile, user string\) \(\[\]EntryInfo, error\)](<#FileService.WriteBatch>)
+ - [func \(service \*FileService\) WriteBatch\(ctx context.Context, files \[\]WriteFile, options \*WriteFileOptions\) \(\[\]EntryInfo, error\)](<#FileService.WriteBatch>)
- [func \(service \*FileService\) WriteBytes\(ctx context.Context, path string, data \[\]byte, options \*WriteFileOptions\) \(\*EntryInfo, error\)](<#FileService.WriteBytes>)
- [func \(service \*FileService\) WriteText\(ctx context.Context, path, text string, options \*WriteFileOptions\) \(\*EntryInfo, error\)](<#FileService.WriteText>)
- [type FileType](<#FileType>)
+- [type FileURLOptions](<#FileURLOptions>)
- [type FileUploadError](<#FileUploadError>)
- [func \(e \*FileUploadError\) Error\(\) string](<#FileUploadError.Error>)
- [func \(e \*FileUploadError\) Unwrap\(\) error](<#FileUploadError.Unwrap>)
@@ -115,6 +117,7 @@ Create a client, start a sandbox, and run a command:
- [type InvalidArgumentError](<#InvalidArgumentError>)
- [func \(e \*InvalidArgumentError\) Error\(\) string](<#InvalidArgumentError.Error>)
- [func \(e \*InvalidArgumentError\) Unwrap\(\) error](<#InvalidArgumentError.Unwrap>)
+- [type ListFilesOptions](<#ListFilesOptions>)
- [type ListSandboxOptions](<#ListSandboxOptions>)
- [type ListedSandbox](<#ListedSandbox>)
- [type MetricsOptions](<#MetricsOptions>)
@@ -266,7 +269,7 @@ Create a client, start a sandbox, and run a command:
Version is the AgentBox SDK release version.
- const Version = "0.1.8"
+ const Version = "0.2.0"
## func IAMTokenPlaceholder
@@ -630,6 +633,8 @@ Write writes bytes to process stdin.
CommandOptions configures a command process.
type CommandOptions struct {
+ // User selects the process owner. Empty uses the template default (user on envd < 0.4.0).
+ User string
Args []string
Env map[string]string
Cwd string
@@ -864,6 +869,15 @@ Error formats the missing\-file failure.
Unwrap returns the underlying missing\-file error, if any.
+
+## type FileOptions
+
+FileOptions selects the user for a filesystem operation. User affects relative path resolution and ownership of created objects, not OS permission isolation. Empty User uses the template default, or user on envd older than 0.4.0.
+
+ type FileOptions struct {
+ User string
+ }
+
## type FileService
@@ -876,87 +890,122 @@ FileService reads and mutates sandbox files.
### func \(\*FileService\) Exists
- func (service *FileService) Exists(ctx context.Context, path string) (bool, error)
+ func (service *FileService) Exists(ctx context.Context, path string, options *FileOptions) (bool, error)
Exists reports whether path exists.
### func \(\*FileService\) List
- func (service *FileService) List(ctx context.Context, path string, depth uint32) ([]EntryInfo, error)
+ func (service *FileService) List(ctx context.Context, path string, options *ListFilesOptions) ([]EntryInfo, error)
-List lists path recursively up to depth.
+List lists path recursively up to the selected depth \(one level by default\).
### func \(\*FileService\) MakeDir
- func (service *FileService) MakeDir(ctx context.Context, path string) (*EntryInfo, error)
+ func (service *FileService) MakeDir(ctx context.Context, path string, options *FileOptions) (*EntryInfo, error)
MakeDir creates a directory.
### func \(\*FileService\) Read
- func (service *FileService) Read(ctx context.Context, path, user string) (io.ReadCloser, error)
+ func (service *FileService) Read(ctx context.Context, path string, options *FileOptions) (io.ReadCloser, error)
Read opens a streaming file response. The caller must close it.
### func \(\*FileService\) ReadBytes
- func (service *FileService) ReadBytes(ctx context.Context, path, user string) ([]byte, error)
+ func (service *FileService) ReadBytes(ctx context.Context, path string, options *FileOptions) ([]byte, error)
ReadBytes reads a complete file.
### func \(\*FileService\) ReadText
- func (service *FileService) ReadText(ctx context.Context, path, user string) (string, error)
+ func (service *FileService) ReadText(ctx context.Context, path string, options *FileOptions) (string, error)
ReadText reads a UTF\-8 file as a string.
### func \(\*FileService\) ReadTo
- func (service *FileService) ReadTo(ctx context.Context, path, user string, writer io.Writer) (int64, error)
+ func (service *FileService) ReadTo(ctx context.Context, path string, writer io.Writer, options *FileOptions) (int64, error)
ReadTo streams a file into writer.
### func \(\*FileService\) Remove
- func (service *FileService) Remove(ctx context.Context, path string) error
+ func (service *FileService) Remove(ctx context.Context, path string, options *FileOptions) error
Remove recursively removes a filesystem entry.
### func \(\*FileService\) Rename
- func (service *FileService) Rename(ctx context.Context, source, destination string) (*EntryInfo, error)
+ func (service *FileService) Rename(ctx context.Context, source, destination string, options *FileOptions) (*EntryInfo, error)
Rename moves a filesystem entry.
### func \(\*FileService\) SignedReadURL
- func (service *FileService) SignedReadURL(path, user string, expiration time.Time) (string, error)
+ func (service *FileService) SignedReadURL(path string, options *FileURLOptions) (string, error)
SignedReadURL creates a directly usable download URL.
### func \(\*FileService\) SignedWriteURL
- func (service *FileService) SignedWriteURL(path, user string, expiration time.Time) (string, error)
+ func (service *FileService) SignedWriteURL(path string, options *FileURLOptions) (string, error)
SignedWriteURL creates a directly usable upload URL.
### func \(\*FileService\) Stat
- func (service *FileService) Stat(ctx context.Context, path string) (*EntryInfo, error)
+ func (service *FileService) Stat(ctx context.Context, path string, options *FileOptions) (*EntryInfo, error)
Stat returns information about a path.
+###### Example
+
+
+
+
+ ctx := context.Background()
+ client, err := agentbox.NewClient()
+ if err != nil {
+ log.Fatal(err)
+ }
+ sandbox, err := client.Sandboxes.Create(ctx, nil)
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer sandbox.Kill(context.Background())
+ // The same user selects process identity and resolves filesystem paths.
+ result, err := sandbox.Commands.Run(ctx, "id", &agentbox.CommandOptions{
+ User: "root",
+ Args: []string{"-un"},
+ })
+ if err != nil {
+ log.Fatal(err)
+ }
+ log.Print(string(result.Stdout))
+ info, err := sandbox.Files.Stat(ctx, "~", &agentbox.FileOptions{User: "root"})
+ if err != nil {
+ log.Fatal(err)
+ }
+ log.Print(info.Path)
+
+
+
+
+
+
### func \(\*FileService\) Watch
@@ -974,9 +1023,9 @@ Write uploads a file from reader.
### func \(\*FileService\) WriteBatch
- func (service *FileService) WriteBatch(ctx context.Context, files []WriteFile, user string) ([]EntryInfo, error)
+ func (service *FileService) WriteBatch(ctx context.Context, files []WriteFile, options *WriteFileOptions) ([]EntryInfo, error)
-WriteBatch writes files in order and stops at the first failure.
+WriteBatch writes files in order and stops at the first failure. Each file overrides common metadata keys. RequestTimeout applies separately to each upload.
### func \(\*FileService\) WriteBytes
@@ -1010,6 +1059,18 @@ FileType identifies a filesystem entry kind.
FileTypeSymlink FileType = "symlink"
)
+
+## type FileURLOptions
+
+FileURLOptions configures a signed file URL.
+
+ type FileURLOptions struct {
+ // User follows FileOptions.User semantics and is included in the signature.
+ User string
+ // Expiration is the absolute expiry time. Zero creates a URL without expiry.
+ Expiration time.Time
+ }
+
## type FileUploadError
@@ -1085,6 +1146,18 @@ Error formats the invalid argument failure.
Unwrap returns the underlying validation error, if any.
+
+## type ListFilesOptions
+
+ListFilesOptions selects the user and recursion depth for directory listing.
+
+ type ListFilesOptions struct {
+ // User follows FileOptions.User semantics.
+ User string
+ // Depth is the maximum directory depth. Zero defaults to one level.
+ Depth uint32
+ }
+
## type ListSandboxOptions
@@ -1150,6 +1223,8 @@ Unwrap returns the underlying storage error, if any.
PTYOptions configures an interactive terminal.
type PTYOptions struct {
+ // User selects the process owner. Empty uses the template default (user on envd < 0.4.0).
+ User string
Args []string
Env map[string]string
Cwd string
@@ -2322,7 +2397,11 @@ Close stops the watcher.
WatchOptions configures recursive and enriched filesystem events.
- type WatchOptions struct{ Recursive, IncludeEntry, AllowNetworkMounts bool }
+ type WatchOptions struct {
+ // User follows FileOptions.User semantics, including expansion of ~.
+ User string
+ Recursive, IncludeEntry, AllowNetworkMounts bool
+ }
## type WriteFile
@@ -2341,6 +2420,7 @@ WriteFile describes one batch upload.
WriteFileOptions configures file ownership, metadata, and upload timeout.
type WriteFileOptions struct {
+ // User follows FileOptions.User semantics.
User string
Metadata map[string]string
// RequestTimeout limits the complete streaming upload. Zero leaves the