Skip to content

Commit 6b02066

Browse files
authored
feat(sdk): official Go SDK library (direct mode, hosted mitos.run) (#250) (#291)
1 parent 3c236dc commit 6b02066

9 files changed

Lines changed: 1240 additions & 0 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,8 @@ The Rust SDK is a thin, blocking (no async runtime) client for the standalone an
152152

153153
The Java SDK is a thin, dependency-free (JDK standard library only) client for the standalone and hosted sandbox-server REST API: create a template, fork a sandbox, run `exec`, and terminate. It targets Java 17, covers direct mode only, and parses the server envelope into a structured `MitosException`. Cluster mode stays Python and TypeScript. See [sdk/java/README.md](sdk/java/README.md).
154154

155+
The Go SDK is a thin, dependency-free (standard-library only) library for the standalone and hosted sandbox-server REST API: create a template, fork a sandbox, run `exec`, list, and terminate. It is a library distinct from the `mitos` CLI, ships in its own nested Go module (`github.com/mitos-run/mitos/sdk/go`) so importing it does not pull the controller into your build, is context-aware, and parses the server envelope into a typed, `errors.Is`-friendly `*mitos.Error`. It covers direct mode only; cluster mode stays Python and TypeScript. See [sdk/go/README.md](sdk/go/README.md).
156+
155157
### CLI
156158

157159
Install the `mitos` CLI. The full per-OS matrix, the env vars the installer

sdk/go/README.md

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
# mitos Go SDK
2+
3+
A thin, dependency-free (standard-library only) Go client for the standalone and
4+
hosted mitos sandbox-server REST API. It mirrors the direct-mode surface of the
5+
Python SDK (`sdk/python/mitos/direct.py`), the TypeScript SDK
6+
(`sdk/typescript/src/server.ts`), the Ruby SDK (`sdk/ruby`), the Rust SDK
7+
(`sdk/rust`), and the Java SDK (`sdk/java`): create a template, fork a sandbox,
8+
run `exec`, list, and terminate.
9+
10+
The SDK uses only the Go standard library (`net/http`, `encoding/json`,
11+
`crypto/rand`, `os`), so there are no third-party dependencies. It lives in its
12+
own nested Go module, so importing it does NOT pull the rest of the
13+
`mitos.run/mitos` repository (the controller, forkd, and their dependencies)
14+
into your build.
15+
16+
## Scope
17+
18+
This SDK covers DIRECT mode only: the standalone `cmd/sandbox-server` and the
19+
hosted control plane at `https://mitos.run`. The Kubernetes / cluster mode (the
20+
controller, forkd, and the SandboxTemplate / SandboxPool / SandboxClaim /
21+
SandboxFork CRDs) is served by the Python and TypeScript SDKs and is NOT part of
22+
this module.
23+
24+
## Install
25+
26+
```bash
27+
go get github.com/mitos-run/mitos/sdk/go
28+
```
29+
30+
Import it as the `mitos` package:
31+
32+
```go
33+
import mitos "github.com/mitos-run/mitos/sdk/go"
34+
```
35+
36+
A branded `mitos.run/go` vanity import path is a documented follow-up: it needs a
37+
`go-import` meta tag served from `mitos.run` (which already hosts the project
38+
site). Until that lands, the GitHub path above resolves directly.
39+
40+
## Quickstart (hosted)
41+
42+
The base URL defaults to the hosted endpoint `https://mitos.run`. Set your API
43+
key in the environment; it is sent as `Authorization: Bearer <key>` and is never
44+
logged or placed in an error message.
45+
46+
```bash
47+
export MITOS_API_KEY="sk-..."
48+
```
49+
50+
```go
51+
package main
52+
53+
import (
54+
"context"
55+
"fmt"
56+
"log"
57+
58+
mitos "github.com/mitos-run/mitos/sdk/go"
59+
)
60+
61+
func main() {
62+
ctx := context.Background()
63+
srv := mitos.NewSandboxServer() // base URL + token resolved from the env
64+
65+
if _, err := srv.CreateTemplate(ctx, "python"); err != nil {
66+
log.Fatal(err)
67+
}
68+
sb, err := srv.Fork(ctx, "python", "") // empty id -> generated sandbox-<hex>
69+
if err != nil {
70+
log.Fatal(err)
71+
}
72+
defer sb.Terminate(ctx)
73+
74+
res, err := sb.Exec(ctx, "echo hi")
75+
if err != nil {
76+
log.Fatal(err)
77+
}
78+
fmt.Println(res.Stdout)
79+
}
80+
```
81+
82+
Self-hosted or local standalone users opt out of the hosted default by setting
83+
`MITOS_BASE_URL` (for example `http://localhost:8080`) or passing
84+
`mitos.WithBaseURL(...)`. The standalone server is tokenless and ignores the
85+
bearer header; the hosted front door verifies it.
86+
87+
## Surface
88+
89+
| Method | HTTP | Returns | Notes |
90+
| --- | --- | --- | --- |
91+
| `NewSandboxServer(opts ...Option)` | - | `*SandboxServer` | Functional options: `WithBaseURL`, `WithAPIKey`, `WithHTTPClient`. |
92+
| `(*SandboxServer).CreateTemplate(ctx, id, opts...)` | `POST /v1/templates` | `*Template` | Sends a fresh `Idempotency-Key`. `WithInitWaitSeconds`, `WithTemplateIdempotencyKey`. |
93+
| `(*SandboxServer).ListTemplates(ctx)` | `GET /v1/templates` | `[]Template` | |
94+
| `(*SandboxServer).Fork(ctx, template, id, opts...)` | `POST /v1/fork` | `*Sandbox` | Generates a `sandbox-<hex>` id when `id` is empty; validates against the id allowlist (typed error before any request). Sends a fresh `Idempotency-Key`. `WithForkIdempotencyKey`. |
95+
| `(*SandboxServer).ListSandboxes(ctx)` | `GET /v1/sandboxes` | `[]ServerSandbox` | |
96+
| `(*Sandbox).Exec(ctx, command, opts...)` | `POST /v1/exec` | `*ExecResult` | Needs a Ready sandbox. `WithExecTimeout`. |
97+
| `(*Sandbox).Terminate(ctx)` | `DELETE /v1/sandboxes/{id}` | `error` | |
98+
99+
Every call takes a `context.Context` for cancellation and deadlines.
100+
101+
Value types:
102+
103+
- `Template`: `ID`, `Ready`, `CreatedAt`, `CreationTimeMs`.
104+
- `ServerSandbox`: `ID`, `TemplateID`, `Endpoint`, `CreatedAt`, `ForkTimeMs`.
105+
- `ExecResult`: `ExitCode`, `Stdout`, `Stderr`, `ExecTimeMs`.
106+
- `Sandbox`: `ID`, `Template`, `Endpoint`, `ForkTimeMs`.
107+
108+
## Auth and base-URL precedence
109+
110+
Resolved once when you call `NewSandboxServer`:
111+
112+
- Base URL: `WithBaseURL(...)`, else `MITOS_BASE_URL`, else `https://mitos.run`.
113+
- Bearer token: `WithAPIKey(...)`, else `MITOS_API_KEY`, else the `mitos auth
114+
login` credential file (the `token` field of
115+
`~/.config/mitos/credentials.json`, honoring `MITOS_CONFIG_DIR`), else
116+
tokenless.
117+
118+
A missing, unreadable, or non-JSON credential file is NOT an error: it just
119+
yields no token so the SDK stays usable tokenless. The path rule mirrors
120+
`internal/credfile`, the single source of truth shared with the CLI. The token
121+
VALUE is never logged and is redacted from any error body.
122+
123+
## Errors
124+
125+
Every non-2xx response returns a `*mitos.Error`, which parses the server
126+
envelope `{error:{code, message, cause, remediation}}`. Branch on the code with
127+
`errors.Is`, never on the message text:
128+
129+
```go
130+
res, err := sb.Exec(ctx, "false")
131+
if err != nil {
132+
var e *mitos.Error
133+
if errors.As(err, &e) {
134+
fmt.Println(e.Code) // e.g. "not_found"
135+
fmt.Println(e.Status) // e.g. 404
136+
fmt.Println(e.Remediation) // actionable hint
137+
}
138+
// or test a specific code directly:
139+
if errors.Is(err, &mitos.Error{Code: "not_found"}) {
140+
// ...
141+
}
142+
}
143+
```
144+
145+
The configured API key is redacted from any error body before it becomes the
146+
error cause, so a token a hostile or misconfigured server reflects back never
147+
surfaces in `err.Error()`.
148+
149+
## The sandbox id allowlist
150+
151+
Sandbox ids must match `^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$` (the same allowlist the
152+
Go daemon and the other SDKs enforce). `Fork` validates the id (the explicit one
153+
or the generated `sandbox-<hex>`) and returns a typed `invalid_sandbox_id` error
154+
BEFORE sending any request. `ValidSandboxID(id)` exposes the check.
155+
156+
## Tests
157+
158+
The tests use only the standard library (`net/http/httptest`, `testing`). They
159+
spin up a stub server reproducing the sandbox-server wire shapes and assert the
160+
SDK round-trips them, including the `Idempotency-Key` header, the credential-file
161+
auth fallback, and token redaction in errors.
162+
163+
```bash
164+
cd sdk/go
165+
go test ./... -count=1
166+
```
167+
168+
## Deferred
169+
170+
Not yet implemented in the Go SDK (covered by the Python / TypeScript SDKs):
171+
172+
- Kubernetes / cluster mode (controller, forkd, CRDs).
173+
- The files API (`/v1/files/*`).
174+
- Interactive PTY (`/v1/pty`).
175+
- `run_code`: the server exposes a streaming-only route
176+
(`POST /v1/run_code/stream`); a synchronous Go wrapper is deferred until a
177+
non-streaming contract exists.
178+
- Per-sandbox network posture, `set_timeout`, `pause` / `resume`, and
179+
`get_host(port)` preview URLs.
180+
- The branded `mitos.run/go` vanity import path (needs a `go-import` meta tag on
181+
`mitos.run`).

sdk/go/auth.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package mitos
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
)
9+
10+
// DefaultBaseURL is the hosted production control plane. When neither the
11+
// WithBaseURL option nor MITOS_BASE_URL is set, the client targets this hosted
12+
// endpoint so the examples work without a base URL. Self-hosted or local
13+
// standalone users opt out by setting MITOS_BASE_URL (for example
14+
// http://localhost:8080). It mirrors the Python, TypeScript, Ruby, Rust, and
15+
// Java SDK defaults.
16+
const DefaultBaseURL = "https://mitos.run"
17+
18+
// Environment variables for the direct-mode onboarding path. Explicit options
19+
// always take precedence over these.
20+
const (
21+
envAPIKey = "MITOS_API_KEY"
22+
envBaseURL = "MITOS_BASE_URL"
23+
envConfigDir = "MITOS_CONFIG_DIR"
24+
)
25+
26+
// resolveBaseURL applies the base-URL precedence: the explicit value, then
27+
// MITOS_BASE_URL, then the hosted production endpoint. Trailing slashes are
28+
// stripped. Parity with the other SDKs' base-URL resolution.
29+
func resolveBaseURL(url string) string {
30+
chosen := url
31+
if chosen == "" {
32+
chosen = os.Getenv(envBaseURL)
33+
}
34+
if chosen == "" {
35+
chosen = DefaultBaseURL
36+
}
37+
return strings.TrimRight(chosen, "/")
38+
}
39+
40+
// resolveToken applies the bearer precedence: the explicit value, then
41+
// MITOS_API_KEY, then the bearer token in the CLI login credential file written
42+
// by `mitos auth login` (so one login authenticates the SDK too), then the empty
43+
// string (tokenless). The file token is sent as-is and the gateway decides its
44+
// validity. The standalone server is tokenless and ignores the token; the hosted
45+
// front door verifies it. The token VALUE is never logged.
46+
func resolveToken(apiKey string) string {
47+
if apiKey != "" {
48+
return apiKey
49+
}
50+
if env := os.Getenv(envAPIKey); env != "" {
51+
return env
52+
}
53+
return tokenFromCredentialFile()
54+
}
55+
56+
// credentialsPath returns the location of the CLI login profile written by
57+
// `mitos auth login`. It honors MITOS_CONFIG_DIR and otherwise uses
58+
// $HOME/.config/mitos/credentials.json. It returns an empty string when no home
59+
// directory can be resolved, in which case there is simply no credential-file
60+
// fallback. The path rule mirrors internal/credfile, the single source of truth
61+
// shared with the CLI.
62+
func credentialsPath() string {
63+
if dir := os.Getenv(envConfigDir); dir != "" {
64+
return filepath.Join(dir, "credentials.json")
65+
}
66+
home, err := os.UserHomeDir()
67+
if err != nil || home == "" {
68+
return ""
69+
}
70+
return filepath.Join(home, ".config", "mitos", "credentials.json")
71+
}
72+
73+
// credentials mirrors the on-disk login profile fields this package needs. Only
74+
// the bearer token is read; the rest of the profile is ignored.
75+
type credentials struct {
76+
Token string `json:"token"`
77+
}
78+
79+
// tokenFromCredentialFile reads the bearer token from the CLI login profile, or
80+
// the empty string when there is none. A missing, unreadable, or non-JSON file
81+
// (or one without a non-empty "token") is NOT an error: it just yields no token
82+
// so the SDK stays usable tokenless. The token VALUE is never logged.
83+
func tokenFromCredentialFile() string {
84+
path := credentialsPath()
85+
if path == "" {
86+
return ""
87+
}
88+
body, err := os.ReadFile(path)
89+
if err != nil {
90+
// Missing or unreadable file: no token, no error.
91+
return ""
92+
}
93+
var c credentials
94+
if err := json.Unmarshal(body, &c); err != nil {
95+
// Corrupt or non-JSON file: no token, no error.
96+
return ""
97+
}
98+
return c.Token
99+
}

sdk/go/errors.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package mitos
2+
3+
import "strings"
4+
5+
// Error is the LLM-legible error returned by the mitos SDK. It mirrors the
6+
// server envelope {error:{code, message, cause, remediation}} and the Python
7+
// AgentRunError, the TypeScript AgentRunError, the Ruby MitosError, and the Java
8+
// MitosException.
9+
//
10+
// Code is a stable, machine-readable identifier callers branch on (with
11+
// errors.Is, never the message text). Cause is the underlying detail (the server
12+
// body, redacted of any bearer token). Remediation is a short actionable hint.
13+
// Status is the HTTP status when the error came from a response, or 0 otherwise
14+
// (for example an invalid id rejected before any request is sent).
15+
//
16+
// Security: an Error never carries a secret value. The SDK redacts the
17+
// configured bearer token from any response body before it becomes a cause, so a
18+
// token a hostile or misconfigured server reflects into its error body never
19+
// surfaces in Error.Error().
20+
type Error struct {
21+
// Code is the stable, machine-readable error code. Branch on this with
22+
// errors.Is(err, &mitos.Error{Code: ...}), not on the message text.
23+
Code string
24+
// Message is a human-readable summary. It never contains a secret value.
25+
Message string
26+
// Cause is the underlying detail (the server body, redacted of any token).
27+
Cause string
28+
// Remediation is a short, actionable hint for resolving the error.
29+
Remediation string
30+
// Status is the HTTP status code, or 0 when the error did not come from a
31+
// response.
32+
Status int
33+
}
34+
35+
// Error renders the error as a single line. It includes the code, message,
36+
// cause, and remediation but NEVER the bearer token, which the SDK has already
37+
// redacted from the cause.
38+
func (e *Error) Error() string {
39+
var b strings.Builder
40+
b.WriteString("[")
41+
b.WriteString(e.Code)
42+
b.WriteString("] ")
43+
b.WriteString(e.Message)
44+
if e.Cause != "" {
45+
b.WriteString(" | cause: ")
46+
b.WriteString(e.Cause)
47+
}
48+
if e.Remediation != "" {
49+
b.WriteString(" | remediation: ")
50+
b.WriteString(e.Remediation)
51+
}
52+
return b.String()
53+
}
54+
55+
// Is reports whether target is an *Error with the same Code, so callers can
56+
// write errors.Is(err, &mitos.Error{Code: "not_found"}). A target with an empty
57+
// Code matches any *Error, so errors.Is(err, &mitos.Error{}) tests "is this an
58+
// SDK error" without pinning the code.
59+
func (e *Error) Is(target error) bool {
60+
t, ok := target.(*Error)
61+
if !ok {
62+
return false
63+
}
64+
if t.Code == "" {
65+
return true
66+
}
67+
return e.Code == t.Code
68+
}

sdk/go/go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module github.com/mitos-run/mitos/sdk/go
2+
3+
go 1.26.2

0 commit comments

Comments
 (0)