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
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ EXTRACT_IMAGE ?= $(BOOT_IMAGE)

# The parent workspace's go.work excludes these modules; GOWORK=off keeps
# local invocations identical to CI.
GO_MODULES := sandboxd sdk/go e2e mcp
GO_MODULES := protocol/wire sandboxd sdk/go e2e mcp
GO_OSES := linux darwin

.PHONY: help test lint boot boot-debug extract extract-debug silkd-image base python images \
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ Design docs:
- `sdk/go/` — Go SDK (stdlib-only): `Connect/New/Lookup`, `Exec/Run`, files,
`Push/Pull`, sessions, `Find/Replace`, `Watch`, git verbs, `OpenPty`,
`Fork/Hibernate/Promote/Checkpoint`, `DialPort/ProxyPort/PreviewURL`,
`StartLsp`, `Spawn/Ps/Kill/Logs/Attach`; `sdk/go/silkd` is the wire
binding, `silkdtest` a test fake
`StartLsp`, `Spawn/Ps/Kill/Logs/Attach`; `protocol/wire` carries the frame
vocabulary, `sdk/go/silkd` the conn layer, `silkdtest` a test fake
- `sdk/python/` — Python SDK (stdlib-only, sync), the same surface for the
Python-first agent ecosystem; round-trips the shared fixture corpus
- `mcp/` — `sandbox-mcp`, an MCP stdio server exposing the surface as tools
Expand All @@ -55,7 +55,7 @@ Design docs:
for the OpenAI Agents SDK (Python, over the Python SDK)
- `sdk/langchain/` — `cocoonstack-sandbox-langchain`, a LangChain toolkit
(StructuredTools over the Python SDK, checkpoint branching)
- `protocol/fixtures/` — golden frame corpus; the Rust, Go, and Python
- `protocol/wire/fixtures/` — golden frame corpus; the Rust, Go, and Python
protocol tests all round-trip it, so wire drift fails CI
- `e2e/` — in-process full-stack tests (real pool/engine/relay/SDK, fake
cocoon+guest) plus bare-metal acceptance drivers under `cmd/`: `demo`,
Expand Down
22 changes: 11 additions & 11 deletions docs/sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,7 @@ Non-zero exits surface as `*sandbox.ExitError{Code, Stderr}` from `Exec`

```go
pid, err := sb.Spawn(ctx, sandbox.Cmd{Argv: []string{"sh", "-c", "make build"}})
procs, err := sb.Ps(ctx) // []silkd.ProcInfo{PID, Argv, Detached, State, ExitCode, ...}
procs, err := sb.Ps(ctx) // []wire.ProcInfo{PID, Argv, Detached, State, ExitCode, ...}
code, exited, err := sb.Logs(ctx, pid, w, nil) // replay the bounded output ring
code, exited, err = sb.Attach(ctx, pid, w, nil) // replay, then follow live until exit
err = sb.Kill(ctx, pid, 0) // 0 = SIGKILL
Expand Down Expand Up @@ -344,8 +344,8 @@ Idle sessions are reaped guest-side after 30 minutes.
```go
err := sb.WriteFile(ctx, "/work/a.txt", data, nil) // atomic; *uint32 mode optional
data, err := sb.ReadFile(ctx, "/work/a.txt")
ents, err := sb.ListDir(ctx, "/work") // []silkd.DirEntry{Name,Kind,Size}
info, err := sb.Stat(ctx, "/work/a.txt") // silkd.FileInfo{Kind,Size,Mode,MtimeEpochSecs}
ents, err := sb.ListDir(ctx, "/work") // []wire.DirEntry{Name,Kind,Size}
info, err := sb.Stat(ctx, "/work/a.txt") // wire.FileInfo{Kind,Size,Mode,MtimeEpochSecs}
err = sb.Mkdir(ctx, "/work/sub", true) // parents
err = sb.Remove(ctx, "/work/sub", true) // recursive
err = sb.Rename(ctx, "/a", "/b")
Expand All @@ -368,10 +368,10 @@ err = sb.Pull(ctx, "/work", tarWriter) // stream /work back as a tar

```go
matches, err := sb.Find(ctx, "/work", `TODO|FIXME`, "*.go")
// []silkd.Match{File, Line, Content}; glob is anchored *? wildcards on the file name
// []wire.Match{File, Line, Content}; glob is anchored *? wildcards on the file name

results, err := sb.Replace(ctx, []string{"/work/main.go"}, `foo`, "bar")
// []silkd.Replaced{File, Replacements}; per-file atomic
// []wire.Replaced{File, Replacements}; per-file atomic
```

Patterns are regular expressions evaluated in the guest — no shell quoting.
Expand All @@ -381,7 +381,7 @@ Patterns are regular expressions evaluated in the guest — no shell quoting.
```go
w, err := sb.Watch(ctx, "/work", true)
defer w.Close()
for ev := range w.Events() { // silkd.Event{Kind, Path}
for ev := range w.Events() { // wire.Event{Kind, Path}
fmt.Println(ev.Kind, ev.Path) // created|modified|deleted|renamed
}
err = w.Err() // why the stream ended; nil after Close
Expand Down Expand Up @@ -428,13 +428,13 @@ ctx) tears the shell down.
## Error handling

- `*sandbox.ExitError` — non-zero exit from `Exec` (`Code`, `Stderr`)
- `*silkd.ErrorResp` — a typed guest-side failure; `Kind` is one of
`silkd.KindBadRequest`, `KindNotFound`, `KindUnimplemented`,
`KindInternal` (import `github.com/cocoonstack/sandbox/sdk/go/silkd`)
- `*wire.ErrorResp` — a typed guest-side failure; `Kind` is one of
`wire.KindBadRequest`, `KindNotFound`, `KindUnimplemented`,
`KindInternal` (import `github.com/cocoonstack/sandbox/protocol/wire`)

```go
var e *silkd.ErrorResp
if errors.As(err, &e) && e.Kind == silkd.KindUnimplemented {
var e *wire.ErrorResp
if errors.As(err, &e) && e.Kind == wire.KindUnimplemented {
// no-network lane: fall back to sb.Push
}
```
Expand Down
2 changes: 1 addition & 1 deletion docs/silkd.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ connection loses nothing (`attach` resumes). The only connection-bound verb
is `fs_watch`.

The authoritative wire contract is the shared fixture corpus in
`protocol/fixtures/v1`, round-tripped by both the Rust and Go test suites —
`protocol/wire/fixtures/v1`, round-tripped by both the Rust and Go test suites —
a frame only one side can parse fails CI.

## Verbs
Expand Down
6 changes: 4 additions & 2 deletions e2e/cmd/rpcbench/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import (
"slices"
"time"

"github.com/cocoonstack/sandbox/protocol/wire"

sandbox "github.com/cocoonstack/sandbox/sdk/go"
"github.com/cocoonstack/sandbox/sdk/go/silkd"
)
Expand Down Expand Up @@ -115,14 +117,14 @@ func run(addr, token, template string, n int) error {
func statRPC(conn net.Conn) error {
defer func() { _ = conn.Close() }()
sc := silkd.NewConn(conn)
if err := sc.Send(&silkd.FsStat{Path: "/"}); err != nil {
if err := sc.Send(&wire.FsStat{Path: "/"}); err != nil {
return err
}
resp, err := sc.Recv()
if err != nil {
return err
}
if e, ok := resp.(*silkd.ErrorResp); ok {
if e, ok := resp.(*wire.ErrorResp); ok {
return e
}
return nil
Expand Down
15 changes: 8 additions & 7 deletions e2e/cmd/smoke/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ import (
"testing/iotest"
"time"

"github.com/cocoonstack/sandbox/protocol/wire"

sandbox "github.com/cocoonstack/sandbox/sdk/go"
"github.com/cocoonstack/sandbox/sdk/go/silkd"
)

func main() {
Expand Down Expand Up @@ -283,7 +284,7 @@ func smokeGit(ctx context.Context, sb *sandbox.Sandbox) error {

// This sandbox is on the no-network lane: push must fail with the typed
// unimplemented error, not hang on an unreachable remote.
if err := sb.GitPush(ctx, "/work", ""); !isSilkdKind(err, silkd.KindUnimplemented) {
if err := sb.GitPush(ctx, "/work", ""); !isSilkdKind(err, wire.KindUnimplemented) {
return fmt.Errorf("push on none lane: %v, want unimplemented", err)
}
return nil
Expand All @@ -302,7 +303,7 @@ func smokeEgress(ctx context.Context, client *sandbox.Client, template string) e
if _, err := sb.Exec(ctx, "git", "init", "-q", "-b", "main", "/tmp/r"); err != nil {
return err
}
if err := sb.GitPush(ctx, "/tmp/r", ""); !isSilkdKind(err, silkd.KindInternal) {
if err := sb.GitPush(ctx, "/tmp/r", ""); !isSilkdKind(err, wire.KindInternal) {
return fmt.Errorf("push on egress lane: %v, want internal git failure (lane misdetected?)", err)
}
return nil
Expand Down Expand Up @@ -572,7 +573,7 @@ func smokePortForward(ctx context.Context, sb *sandbox.Sandbox) error {
if string(banner) != "SSH-" {
return fmt.Errorf("banner %q, want an SSH greeting", banner)
}
if _, err := sb.DialPort(ctx, 9); !isSilkdKind(err, silkd.KindNotFound) {
if _, err := sb.DialPort(ctx, 9); !isSilkdKind(err, wire.KindNotFound) {
return fmt.Errorf("dead port: %v, want not_found", err)
}
return nil
Expand Down Expand Up @@ -600,7 +601,7 @@ func want(got, exp string) error {
// typed not_found (no manifests), and a python-flavor sandbox serves a real
// pylsp session — initialize, didOpen, hover — over the relay.
func smokeLsp(ctx context.Context, client *sandbox.Client, base *sandbox.Sandbox, template string) error {
if _, err := base.StartLsp(ctx, "python", ""); !isSilkdKind(err, silkd.KindNotFound) {
if _, err := base.StartLsp(ctx, "python", ""); !isSilkdKind(err, wire.KindNotFound) {
return fmt.Errorf("base StartLsp: %v, want typed not_found", err)
}

Expand Down Expand Up @@ -655,7 +656,7 @@ func smokeLsp(ctx context.Context, client *sandbox.Client, base *sandbox.Sandbox
}

func isSilkdKind(err error, kind string) bool {
var er *silkd.ErrorResp
var er *wire.ErrorResp
return errors.As(err, &er) && er.Kind == kind
}

Expand Down Expand Up @@ -723,7 +724,7 @@ func smokeProcs(ctx context.Context, sb *sandbox.Sandbox) error {
if err != nil {
return fmt.Errorf("ps: %w", err)
}
if !slices.ContainsFunc(procs, func(p silkd.ProcInfo) bool { return p.PID == pid && p.Detached }) {
if !slices.ContainsFunc(procs, func(p wire.ProcInfo) bool { return p.PID == pid && p.Detached }) {
return fmt.Errorf("ps does not list spawned pid %d: %+v", pid, procs)
}
var out bytes.Buffer
Expand Down
4 changes: 4 additions & 0 deletions e2e/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,7 @@ replace (
github.com/cocoonstack/sandbox/sandboxd => ../sandboxd
github.com/cocoonstack/sandbox/sdk/go => ../sdk/go
)

require github.com/cocoonstack/sandbox/protocol/wire v0.0.0

replace github.com/cocoonstack/sandbox/protocol/wire => ../protocol/wire
3 changes: 3 additions & 0 deletions mcp/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ require (
github.com/cockroachdb/errors v1.9.1 // indirect
github.com/cockroachdb/logtags v0.0.0-20230118201751-21c54148d20b // indirect
github.com/cockroachdb/redact v1.1.3 // indirect
github.com/cocoonstack/sandbox/protocol/wire v0.0.0-20260718031021-a8af7bea15e5 // indirect
github.com/getsentry/sentry-go v0.20.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
Expand All @@ -32,3 +33,5 @@ require (
)

replace github.com/cocoonstack/sandbox/sdk/go => ../sdk/go

replace github.com/cocoonstack/sandbox/protocol/wire => ../protocol/wire
3 changes: 2 additions & 1 deletion protocol/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
Golden JSON frames shared by all three implementations of the silkd
protocol:
- silkd (Rust, guest) parses/emits these in `silkd/src/proto.rs` tests.
- the Go SDK (host) parses/emits the same corpus in its tests.
- `protocol/wire` (Go, host) round-trips the corpus in its tests; it is the
one Go implementation, consumed by both the Go SDK and sandboxd.
- the Python SDK round-trips it in `sdk/python/tests/test_fixtures.py`.

`req_*.json` are client→server frames, `resp_*.json` server→client. A frame
Expand Down
File renamed without changes.
File renamed without changes.
19 changes: 16 additions & 3 deletions sdk/go/silkd/frame.go → protocol/wire/frame.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
// Package silkd is the host-side binding of the silkd wire protocol:
// Package wire is the Go binding of the silkd wire protocol, shared by the
// SDK and sandboxd:
// newline-delimited JSON frames over one connection per RPC, requests tagged
// by "op", responses by "type", binary payloads base64 in data fields. The
// authoritative contract is the shared corpus in protocol/fixtures/v1 —
// authoritative contract is the shared corpus in protocol/wire/fixtures/v1 —
// silkd's Rust tests and this package's tests round-trip the same files.
package silkd
package wire

import (
"bytes"
Expand Down Expand Up @@ -698,6 +699,18 @@ func fastBulk(tag string, slow func([]byte) (Response, error), mk func([]byte) R
}
}

// AppendBulkRequest renders a data-carrying request frame —
// {"v":1,"op":<op>,"data":"<base64>"} plus newline — into buf, reused across
// calls: the zero-alloc twin of EncodeRequest for the bulk send paths
// (base64's alphabet needs no JSON escaping).
func AppendBulkRequest(buf []byte, op string, data []byte) []byte {
buf = append(buf[:0], requestHead...)
buf = append(buf, op...)
buf = append(buf, `","data":"`...)
buf = base64.StdEncoding.AppendEncode(buf, data)
return append(buf, '"', '}', '\n')
}

// encodeTagged marshals v as a flat object and splices the tag head in front
// of its fields, so wire tags never live on the structs themselves. The
// spare capacity byte lets Conn.Send append the newline without a copy.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package silkd
package wire

import (
"encoding/json"
Expand Down
17 changes: 15 additions & 2 deletions sdk/go/silkd/frame_test.go → protocol/wire/frame_test.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package silkd
package wire

import (
"bytes"
Expand All @@ -10,7 +10,7 @@ import (
"testing"
)

const fixtureDir = "../../../protocol/fixtures/v1"
const fixtureDir = "fixtures/v1"

// TestFixtureCorpusRoundTrips is the protocol/README contract: every golden
// frame must decode and re-encode to canonical-JSON equality, both request
Expand Down Expand Up @@ -317,3 +317,16 @@ func jsonEqual(t *testing.T, a, b []byte) bool {
}
return reflect.DeepEqual(av, bv)
}

func TestAppendBulkRequestMatchesEncodeRequest(t *testing.T) {
for _, payload := range [][]byte{nil, {}, []byte("hello\x00\xff"), bytes.Repeat([]byte{0xAB}, 300*1024)} {
want, err := EncodeRequest(Data{Data: payload})
if err != nil {
t.Fatalf("EncodeRequest: %v", err)
}
got := AppendBulkRequest(nil, "data", payload)
if !bytes.Equal(got, append(want, '\n')) {
t.Fatalf("payload len %d: bulk %q, want %q", len(payload), got, want)
}
}
}
3 changes: 3 additions & 0 deletions protocol/wire/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
module github.com/cocoonstack/sandbox/protocol/wire

go 1.26.4
44 changes: 30 additions & 14 deletions sandboxd/engine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (

"github.com/projecteru2/core/log"

"github.com/cocoonstack/sandbox/protocol/wire"
"github.com/cocoonstack/sandbox/sandboxd/types"
)

Expand All @@ -44,8 +45,6 @@ const (
portForwardMax = 4096
)

var infoProbe = []byte(`{"v":1,"op":"info"}` + "\n")

// Engine runs cocoon commands on the local node.
type Engine struct {
bin string
Expand Down Expand Up @@ -268,8 +267,12 @@ func (e *Engine) DialGuestPort(ctx context.Context, vsockSocket string, port uin
}
stop := context.AfterFunc(ctx, func() { _ = conn.Close() })
defer stop()
req := fmt.Sprintf(`{"v":1,"op":"port_forward","port":%d}`+"\n", port)
if _, err := conn.Write([]byte(req)); err != nil {
req, err := wire.EncodeRequest(wire.PortForward{Port: port})
if err != nil {
_ = conn.Close()
return nil, err
}
if _, err := conn.Write(append(req, '\n')); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("write port_forward: %w", err)
}
Expand All @@ -281,14 +284,14 @@ func (e *Engine) DialGuestPort(ctx context.Context, vsockSocket string, port uin
}
return nil, fmt.Errorf("read port_forward reply: %w", readErr)
}
var frame silkdFrame
if err := json.Unmarshal([]byte(line), &frame); err != nil {
resp, parseErr := wire.DecodeResponse([]byte(line))
if parseErr != nil {
_ = conn.Close()
return nil, fmt.Errorf("parse port_forward reply: %w", err)
return nil, fmt.Errorf("parse port_forward reply: %w", parseErr)
}
if frame.Type != "ready" {
if _, ok := resp.(*wire.Ready); !ok {
_ = conn.Close()
return nil, fmt.Errorf("port_forward %d: %s: %s", port, frame.Kind, frame.Message)
return nil, fmt.Errorf("port_forward %d: %s", port, respFail(resp))
}
return newGuestPortConn(conn), nil
}
Expand Down Expand Up @@ -363,7 +366,11 @@ func (e *Engine) infoRoundTrip(ctx context.Context, vsockSocket string) error {
defer func() { _ = conn.Close() }()
stop := context.AfterFunc(ctx, func() { _ = conn.Close() })
defer stop()
if _, err = conn.Write(infoProbe); err != nil {
probe, err := wire.EncodeRequest(wire.Info{})
if err != nil {
return fmt.Errorf("encode info: %w", err)
}
if _, err = conn.Write(append(probe, '\n')); err != nil {
return fmt.Errorf("write info: %w", err)
}
// Unlike the handshake reader, buffered over-read is safe here: the conn
Expand All @@ -372,12 +379,12 @@ func (e *Engine) infoRoundTrip(ctx context.Context, vsockSocket string) error {
if err != nil {
return fmt.Errorf("read info reply: %w", err)
}
var frame silkdFrame
if err := json.Unmarshal(reply, &frame); err != nil {
resp, err := wire.DecodeResponse(reply)
if err != nil {
return fmt.Errorf("parse info reply: %w", err)
}
if frame.Type != "info" {
return fmt.Errorf("info reply type %q", frame.Type)
if _, ok := resp.(*wire.InfoResp); !ok {
return fmt.Errorf("info reply type %q", resp.RespType())
}
return nil
}
Expand All @@ -388,6 +395,15 @@ func EgressSocketPath(vsockSocket string) string {
return fmt.Sprintf("%s_%d", vsockSocket, egressPort)
}

// respFail renders a non-success reply: the error frame's own text, or the
// unexpected frame's type.
func respFail(resp wire.Response) string {
if errResp, ok := resp.(*wire.ErrorResp); ok {
return errResp.Error()
}
return "unexpected frame " + resp.RespType()
}

// parseRecord reads a lifecycle command's --output json VM record; best-effort,
// so an unparseable record yields the zero value (callers fall back to vm list).
func parseRecord(ctx context.Context, out []byte) types.VMRecord {
Expand Down
Loading
Loading