From 579a31788268d1e6e21be9fb2f789522ff8b7065 Mon Sep 17 00:00:00 2001 From: Ilyas Salikhov Date: Tue, 22 Sep 2026 14:59:36 +0300 Subject: [PATCH 1/2] feat(go)!: align envd users and filesystem options --- README.md | 3 + docs/go-envd-user-selection.md | 101 +++++ packages/go-sdk/README.md | 28 ++ packages/go-sdk/commands.go | 6 + packages/go-sdk/envd_test.go | 34 +- packages/go-sdk/envd_user_test.go | 358 ++++++++++++++++++ packages/go-sdk/examples_test.go | 27 ++ packages/go-sdk/filesystem.go | 152 ++++++-- packages/go-sdk/integration/envd_user_test.go | 128 +++++++ packages/go-sdk/integration/sdk_test.go | 2 +- packages/go-sdk/pty.go | 6 + packages/go-sdk/sandbox.go | 25 +- packages/go-sdk/sandbox_test.go | 6 +- .../go-sdk/tests/consumer/consumer_test.go | 6 + reference/manifest.json | 2 +- reference/sdk/go/core.md | 138 +++++-- 16 files changed, 932 insertions(+), 90 deletions(-) create mode 100644 docs/go-envd-user-selection.md create mode 100644 packages/go-sdk/envd_user_test.go create mode 100644 packages/go-sdk/integration/envd_user_test.go 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..c362b6b3 --- /dev/null +++ b/docs/go-envd-user-selection.md @@ -0,0 +1,101 @@ +# 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. +These changes are not yet included in the published v0.1.8 release. + +## 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/go-sdk/README.md b/packages/go-sdk/README.md index ff297060..3ac952ba 100644 --- a/packages/go-sdk/README.md +++ b/packages/go-sdk/README.md @@ -54,6 +54,34 @@ 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 from v0.1.8. These API changes are not yet +in a published release. + ## 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/reference/manifest.json b/reference/manifest.json index 1d2e6204..5ec1ddd8 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": "b8603f551fb4db731d7a93160837a79bd4fdc174ecd60cd01258300754bab0d5", "sdk/javascript/code-interpreter/README.md": "a787206eb86f81fc306b373ce20bc6c91464b0ab3c3ec7650eccbb6be5f676c5", "sdk/javascript/code-interpreter/classes/Sandbox.md": "19d9f1f1e8847606a54d93c5a6e5d4c89f936a745866e8d6a2aa06758b605e60", "sdk/javascript/code-interpreter/enumerations/ChartType.md": "cf1ba00cd3258cc88814b7215e101b0d775ceb15dd833636d58ae538d8107ec1", diff --git a/reference/sdk/go/core.md b/reference/sdk/go/core.md index 2ce9a329..fb436ded 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>) @@ -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 From b346c3a7c8ae29a3692d7e0b3101cd3be71ee159 Mon Sep 17 00:00:00 2001 From: Ilyas Salikhov Date: Tue, 22 Sep 2026 15:03:01 +0300 Subject: [PATCH 2/2] chore: release AgentBox SDK v0.2.0 --- docs/go-envd-user-selection.md | 3 ++- packages/cli/package.json | 2 +- packages/code-interpreter-js/package.json | 2 +- packages/code-interpreter-python/package.json | 2 +- packages/code-interpreter-python/pyproject.toml | 4 ++-- packages/code-interpreter-python/uv.lock | 4 ++-- packages/go-sdk/README.md | 7 +++---- packages/go-sdk/version.go | 2 +- packages/js-sdk/package.json | 2 +- packages/python-sdk/package.json | 2 +- packages/python-sdk/pyproject.toml | 2 +- packages/python-sdk/uv.lock | 2 +- reference/manifest.json | 14 +++++++------- reference/sdk/go/core.md | 2 +- 14 files changed, 25 insertions(+), 25 deletions(-) diff --git a/docs/go-envd-user-selection.md b/docs/go-envd-user-selection.md index c362b6b3..d7a164ee 100644 --- a/docs/go-envd-user-selection.md +++ b/docs/go-envd-user-selection.md @@ -3,7 +3,8 @@ 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. -These changes are not yet included in the published v0.1.8 release. +Explicit user selection and the unified filesystem options API are introduced in +v0.2.0. ## User semantics 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 3ac952ba..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/), @@ -79,8 +79,7 @@ defaults. `FileOptions` covers reads and simple filesystem operations; 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 from v0.1.8. These API changes are not yet -in a published release. +for semantics and the signature changes introduced in v0.2.0. ## Command output 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 5ec1ddd8..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": "b8603f551fb4db731d7a93160837a79bd4fdc174ecd60cd01258300754bab0d5", + "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 fb436ded..1aafb5db 100644 --- a/reference/sdk/go/core.md +++ b/reference/sdk/go/core.md @@ -269,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