Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
102 changes: 102 additions & 0 deletions docs/go-envd-user-selection.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/code-interpreter-js/package.json
Original file line number Diff line number Diff line change
@@ -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/",
Expand Down
2 changes: 1 addition & 1 deletion packages/code-interpreter-python/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 2 additions & 2 deletions packages/code-interpreter-python/pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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]
Expand Down
4 changes: 2 additions & 2 deletions packages/code-interpreter-python/uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 29 additions & 2 deletions packages/go-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/),
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions packages/go-sdk/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
34 changes: 17 additions & 17 deletions packages/go-sdk/envd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand All @@ -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})
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand Down
Loading
Loading