diff --git a/Makefile b/Makefile index f631027..2cfef29 100644 --- a/Makefile +++ b/Makefile @@ -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 \ diff --git a/README.md b/README.md index 01aab3d..f720052 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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`, diff --git a/docs/sdk.md b/docs/sdk.md index e5edfaf..86eaa29 100644 --- a/docs/sdk.md +++ b/docs/sdk.md @@ -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 @@ -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") @@ -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. @@ -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 @@ -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 } ``` diff --git a/docs/silkd.md b/docs/silkd.md index af6408e..201ad7c 100644 --- a/docs/silkd.md +++ b/docs/silkd.md @@ -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 diff --git a/e2e/cmd/rpcbench/main.go b/e2e/cmd/rpcbench/main.go index 167e752..2b0f78e 100644 --- a/e2e/cmd/rpcbench/main.go +++ b/e2e/cmd/rpcbench/main.go @@ -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" ) @@ -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 diff --git a/e2e/cmd/smoke/main.go b/e2e/cmd/smoke/main.go index 1a04ac5..5e870c4 100644 --- a/e2e/cmd/smoke/main.go +++ b/e2e/cmd/smoke/main.go @@ -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() { @@ -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 @@ -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 @@ -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 @@ -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) } @@ -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 } @@ -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 diff --git a/e2e/go.mod b/e2e/go.mod index 6abe42a..1002523 100644 --- a/e2e/go.mod +++ b/e2e/go.mod @@ -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 diff --git a/mcp/go.mod b/mcp/go.mod index 2a086fb..c0a26a2 100644 --- a/mcp/go.mod +++ b/mcp/go.mod @@ -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 @@ -32,3 +33,5 @@ require ( ) replace github.com/cocoonstack/sandbox/sdk/go => ../sdk/go + +replace github.com/cocoonstack/sandbox/protocol/wire => ../protocol/wire diff --git a/protocol/README.md b/protocol/README.md index fe3a4e9..280229a 100644 --- a/protocol/README.md +++ b/protocol/README.md @@ -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 diff --git a/protocol/fixtures/v1/enums.json b/protocol/wire/fixtures/v1/enums.json similarity index 100% rename from protocol/fixtures/v1/enums.json rename to protocol/wire/fixtures/v1/enums.json diff --git a/protocol/fixtures/v1/req_attach.json b/protocol/wire/fixtures/v1/req_attach.json similarity index 100% rename from protocol/fixtures/v1/req_attach.json rename to protocol/wire/fixtures/v1/req_attach.json diff --git a/protocol/fixtures/v1/req_data.json b/protocol/wire/fixtures/v1/req_data.json similarity index 100% rename from protocol/fixtures/v1/req_data.json rename to protocol/wire/fixtures/v1/req_data.json diff --git a/protocol/fixtures/v1/req_data_end.json b/protocol/wire/fixtures/v1/req_data_end.json similarity index 100% rename from protocol/fixtures/v1/req_data_end.json rename to protocol/wire/fixtures/v1/req_data_end.json diff --git a/protocol/fixtures/v1/req_exec.json b/protocol/wire/fixtures/v1/req_exec.json similarity index 100% rename from protocol/fixtures/v1/req_exec.json rename to protocol/wire/fixtures/v1/req_exec.json diff --git a/protocol/fixtures/v1/req_exec_detach.json b/protocol/wire/fixtures/v1/req_exec_detach.json similarity index 100% rename from protocol/fixtures/v1/req_exec_detach.json rename to protocol/wire/fixtures/v1/req_exec_detach.json diff --git a/protocol/fixtures/v1/req_fs_find.json b/protocol/wire/fixtures/v1/req_fs_find.json similarity index 100% rename from protocol/fixtures/v1/req_fs_find.json rename to protocol/wire/fixtures/v1/req_fs_find.json diff --git a/protocol/fixtures/v1/req_fs_list.json b/protocol/wire/fixtures/v1/req_fs_list.json similarity index 100% rename from protocol/fixtures/v1/req_fs_list.json rename to protocol/wire/fixtures/v1/req_fs_list.json diff --git a/protocol/fixtures/v1/req_fs_mkdir.json b/protocol/wire/fixtures/v1/req_fs_mkdir.json similarity index 100% rename from protocol/fixtures/v1/req_fs_mkdir.json rename to protocol/wire/fixtures/v1/req_fs_mkdir.json diff --git a/protocol/fixtures/v1/req_fs_pull.json b/protocol/wire/fixtures/v1/req_fs_pull.json similarity index 100% rename from protocol/fixtures/v1/req_fs_pull.json rename to protocol/wire/fixtures/v1/req_fs_pull.json diff --git a/protocol/fixtures/v1/req_fs_push.json b/protocol/wire/fixtures/v1/req_fs_push.json similarity index 100% rename from protocol/fixtures/v1/req_fs_push.json rename to protocol/wire/fixtures/v1/req_fs_push.json diff --git a/protocol/fixtures/v1/req_fs_read.json b/protocol/wire/fixtures/v1/req_fs_read.json similarity index 100% rename from protocol/fixtures/v1/req_fs_read.json rename to protocol/wire/fixtures/v1/req_fs_read.json diff --git a/protocol/fixtures/v1/req_fs_rename.json b/protocol/wire/fixtures/v1/req_fs_rename.json similarity index 100% rename from protocol/fixtures/v1/req_fs_rename.json rename to protocol/wire/fixtures/v1/req_fs_rename.json diff --git a/protocol/fixtures/v1/req_fs_replace.json b/protocol/wire/fixtures/v1/req_fs_replace.json similarity index 100% rename from protocol/fixtures/v1/req_fs_replace.json rename to protocol/wire/fixtures/v1/req_fs_replace.json diff --git a/protocol/fixtures/v1/req_fs_rm.json b/protocol/wire/fixtures/v1/req_fs_rm.json similarity index 100% rename from protocol/fixtures/v1/req_fs_rm.json rename to protocol/wire/fixtures/v1/req_fs_rm.json diff --git a/protocol/fixtures/v1/req_fs_stat.json b/protocol/wire/fixtures/v1/req_fs_stat.json similarity index 100% rename from protocol/fixtures/v1/req_fs_stat.json rename to protocol/wire/fixtures/v1/req_fs_stat.json diff --git a/protocol/fixtures/v1/req_fs_watch.json b/protocol/wire/fixtures/v1/req_fs_watch.json similarity index 100% rename from protocol/fixtures/v1/req_fs_watch.json rename to protocol/wire/fixtures/v1/req_fs_watch.json diff --git a/protocol/fixtures/v1/req_fs_write.json b/protocol/wire/fixtures/v1/req_fs_write.json similarity index 100% rename from protocol/fixtures/v1/req_fs_write.json rename to protocol/wire/fixtures/v1/req_fs_write.json diff --git a/protocol/fixtures/v1/req_git_add.json b/protocol/wire/fixtures/v1/req_git_add.json similarity index 100% rename from protocol/fixtures/v1/req_git_add.json rename to protocol/wire/fixtures/v1/req_git_add.json diff --git a/protocol/fixtures/v1/req_git_branch.json b/protocol/wire/fixtures/v1/req_git_branch.json similarity index 100% rename from protocol/fixtures/v1/req_git_branch.json rename to protocol/wire/fixtures/v1/req_git_branch.json diff --git a/protocol/fixtures/v1/req_git_clone.json b/protocol/wire/fixtures/v1/req_git_clone.json similarity index 100% rename from protocol/fixtures/v1/req_git_clone.json rename to protocol/wire/fixtures/v1/req_git_clone.json diff --git a/protocol/fixtures/v1/req_git_commit.json b/protocol/wire/fixtures/v1/req_git_commit.json similarity index 100% rename from protocol/fixtures/v1/req_git_commit.json rename to protocol/wire/fixtures/v1/req_git_commit.json diff --git a/protocol/fixtures/v1/req_git_pull.json b/protocol/wire/fixtures/v1/req_git_pull.json similarity index 100% rename from protocol/fixtures/v1/req_git_pull.json rename to protocol/wire/fixtures/v1/req_git_pull.json diff --git a/protocol/fixtures/v1/req_git_push.json b/protocol/wire/fixtures/v1/req_git_push.json similarity index 100% rename from protocol/fixtures/v1/req_git_push.json rename to protocol/wire/fixtures/v1/req_git_push.json diff --git a/protocol/fixtures/v1/req_git_status.json b/protocol/wire/fixtures/v1/req_git_status.json similarity index 100% rename from protocol/fixtures/v1/req_git_status.json rename to protocol/wire/fixtures/v1/req_git_status.json diff --git a/protocol/fixtures/v1/req_info.json b/protocol/wire/fixtures/v1/req_info.json similarity index 100% rename from protocol/fixtures/v1/req_info.json rename to protocol/wire/fixtures/v1/req_info.json diff --git a/protocol/fixtures/v1/req_kill.json b/protocol/wire/fixtures/v1/req_kill.json similarity index 100% rename from protocol/fixtures/v1/req_kill.json rename to protocol/wire/fixtures/v1/req_kill.json diff --git a/protocol/fixtures/v1/req_logs.json b/protocol/wire/fixtures/v1/req_logs.json similarity index 100% rename from protocol/fixtures/v1/req_logs.json rename to protocol/wire/fixtures/v1/req_logs.json diff --git a/protocol/fixtures/v1/req_lsp_request.json b/protocol/wire/fixtures/v1/req_lsp_request.json similarity index 100% rename from protocol/fixtures/v1/req_lsp_request.json rename to protocol/wire/fixtures/v1/req_lsp_request.json diff --git a/protocol/fixtures/v1/req_lsp_start.json b/protocol/wire/fixtures/v1/req_lsp_start.json similarity index 100% rename from protocol/fixtures/v1/req_lsp_start.json rename to protocol/wire/fixtures/v1/req_lsp_start.json diff --git a/protocol/fixtures/v1/req_lsp_stop.json b/protocol/wire/fixtures/v1/req_lsp_stop.json similarity index 100% rename from protocol/fixtures/v1/req_lsp_stop.json rename to protocol/wire/fixtures/v1/req_lsp_stop.json diff --git a/protocol/fixtures/v1/req_port_forward.json b/protocol/wire/fixtures/v1/req_port_forward.json similarity index 100% rename from protocol/fixtures/v1/req_port_forward.json rename to protocol/wire/fixtures/v1/req_port_forward.json diff --git a/protocol/fixtures/v1/req_ps.json b/protocol/wire/fixtures/v1/req_ps.json similarity index 100% rename from protocol/fixtures/v1/req_ps.json rename to protocol/wire/fixtures/v1/req_ps.json diff --git a/protocol/fixtures/v1/req_pty_open.json b/protocol/wire/fixtures/v1/req_pty_open.json similarity index 100% rename from protocol/fixtures/v1/req_pty_open.json rename to protocol/wire/fixtures/v1/req_pty_open.json diff --git a/protocol/fixtures/v1/req_pty_resize.json b/protocol/wire/fixtures/v1/req_pty_resize.json similarity index 100% rename from protocol/fixtures/v1/req_pty_resize.json rename to protocol/wire/fixtures/v1/req_pty_resize.json diff --git a/protocol/fixtures/v1/req_session_create.json b/protocol/wire/fixtures/v1/req_session_create.json similarity index 100% rename from protocol/fixtures/v1/req_session_create.json rename to protocol/wire/fixtures/v1/req_session_create.json diff --git a/protocol/fixtures/v1/req_session_list.json b/protocol/wire/fixtures/v1/req_session_list.json similarity index 100% rename from protocol/fixtures/v1/req_session_list.json rename to protocol/wire/fixtures/v1/req_session_list.json diff --git a/protocol/fixtures/v1/req_session_rm.json b/protocol/wire/fixtures/v1/req_session_rm.json similarity index 100% rename from protocol/fixtures/v1/req_session_rm.json rename to protocol/wire/fixtures/v1/req_session_rm.json diff --git a/protocol/fixtures/v1/req_stdin.json b/protocol/wire/fixtures/v1/req_stdin.json similarity index 100% rename from protocol/fixtures/v1/req_stdin.json rename to protocol/wire/fixtures/v1/req_stdin.json diff --git a/protocol/fixtures/v1/req_stdin_close.json b/protocol/wire/fixtures/v1/req_stdin_close.json similarity index 100% rename from protocol/fixtures/v1/req_stdin_close.json rename to protocol/wire/fixtures/v1/req_stdin_close.json diff --git a/protocol/fixtures/v1/resp_data.json b/protocol/wire/fixtures/v1/resp_data.json similarity index 100% rename from protocol/fixtures/v1/resp_data.json rename to protocol/wire/fixtures/v1/resp_data.json diff --git a/protocol/fixtures/v1/resp_done.json b/protocol/wire/fixtures/v1/resp_done.json similarity index 100% rename from protocol/fixtures/v1/resp_done.json rename to protocol/wire/fixtures/v1/resp_done.json diff --git a/protocol/fixtures/v1/resp_entries.json b/protocol/wire/fixtures/v1/resp_entries.json similarity index 100% rename from protocol/fixtures/v1/resp_entries.json rename to protocol/wire/fixtures/v1/resp_entries.json diff --git a/protocol/fixtures/v1/resp_error.json b/protocol/wire/fixtures/v1/resp_error.json similarity index 100% rename from protocol/fixtures/v1/resp_error.json rename to protocol/wire/fixtures/v1/resp_error.json diff --git a/protocol/fixtures/v1/resp_event.json b/protocol/wire/fixtures/v1/resp_event.json similarity index 100% rename from protocol/fixtures/v1/resp_event.json rename to protocol/wire/fixtures/v1/resp_event.json diff --git a/protocol/fixtures/v1/resp_exit.json b/protocol/wire/fixtures/v1/resp_exit.json similarity index 100% rename from protocol/fixtures/v1/resp_exit.json rename to protocol/wire/fixtures/v1/resp_exit.json diff --git a/protocol/fixtures/v1/resp_git_branches.json b/protocol/wire/fixtures/v1/resp_git_branches.json similarity index 100% rename from protocol/fixtures/v1/resp_git_branches.json rename to protocol/wire/fixtures/v1/resp_git_branches.json diff --git a/protocol/fixtures/v1/resp_git_commit_result.json b/protocol/wire/fixtures/v1/resp_git_commit_result.json similarity index 100% rename from protocol/fixtures/v1/resp_git_commit_result.json rename to protocol/wire/fixtures/v1/resp_git_commit_result.json diff --git a/protocol/fixtures/v1/resp_git_status_result.json b/protocol/wire/fixtures/v1/resp_git_status_result.json similarity index 100% rename from protocol/fixtures/v1/resp_git_status_result.json rename to protocol/wire/fixtures/v1/resp_git_status_result.json diff --git a/protocol/fixtures/v1/resp_info.json b/protocol/wire/fixtures/v1/resp_info.json similarity index 100% rename from protocol/fixtures/v1/resp_info.json rename to protocol/wire/fixtures/v1/resp_info.json diff --git a/protocol/fixtures/v1/resp_lsp_started.json b/protocol/wire/fixtures/v1/resp_lsp_started.json similarity index 100% rename from protocol/fixtures/v1/resp_lsp_started.json rename to protocol/wire/fixtures/v1/resp_lsp_started.json diff --git a/protocol/fixtures/v1/resp_match.json b/protocol/wire/fixtures/v1/resp_match.json similarity index 100% rename from protocol/fixtures/v1/resp_match.json rename to protocol/wire/fixtures/v1/resp_match.json diff --git a/protocol/fixtures/v1/resp_procs.json b/protocol/wire/fixtures/v1/resp_procs.json similarity index 100% rename from protocol/fixtures/v1/resp_procs.json rename to protocol/wire/fixtures/v1/resp_procs.json diff --git a/protocol/fixtures/v1/resp_ready.json b/protocol/wire/fixtures/v1/resp_ready.json similarity index 100% rename from protocol/fixtures/v1/resp_ready.json rename to protocol/wire/fixtures/v1/resp_ready.json diff --git a/protocol/fixtures/v1/resp_replaced.json b/protocol/wire/fixtures/v1/resp_replaced.json similarity index 100% rename from protocol/fixtures/v1/resp_replaced.json rename to protocol/wire/fixtures/v1/resp_replaced.json diff --git a/protocol/fixtures/v1/resp_session_created.json b/protocol/wire/fixtures/v1/resp_session_created.json similarity index 100% rename from protocol/fixtures/v1/resp_session_created.json rename to protocol/wire/fixtures/v1/resp_session_created.json diff --git a/protocol/fixtures/v1/resp_sessions.json b/protocol/wire/fixtures/v1/resp_sessions.json similarity index 100% rename from protocol/fixtures/v1/resp_sessions.json rename to protocol/wire/fixtures/v1/resp_sessions.json diff --git a/protocol/fixtures/v1/resp_started.json b/protocol/wire/fixtures/v1/resp_started.json similarity index 100% rename from protocol/fixtures/v1/resp_started.json rename to protocol/wire/fixtures/v1/resp_started.json diff --git a/protocol/fixtures/v1/resp_stat.json b/protocol/wire/fixtures/v1/resp_stat.json similarity index 100% rename from protocol/fixtures/v1/resp_stat.json rename to protocol/wire/fixtures/v1/resp_stat.json diff --git a/protocol/fixtures/v1/resp_stderr.json b/protocol/wire/fixtures/v1/resp_stderr.json similarity index 100% rename from protocol/fixtures/v1/resp_stderr.json rename to protocol/wire/fixtures/v1/resp_stderr.json diff --git a/protocol/fixtures/v1/resp_stdout.json b/protocol/wire/fixtures/v1/resp_stdout.json similarity index 100% rename from protocol/fixtures/v1/resp_stdout.json rename to protocol/wire/fixtures/v1/resp_stdout.json diff --git a/sdk/go/silkd/frame.go b/protocol/wire/frame.go similarity index 97% rename from sdk/go/silkd/frame.go rename to protocol/wire/frame.go index 5d5da21..5440c05 100644 --- a/sdk/go/silkd/frame.go +++ b/protocol/wire/frame.go @@ -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" @@ -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":,"data":""} 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. diff --git a/sdk/go/silkd/frame_bench_test.go b/protocol/wire/frame_bench_test.go similarity index 99% rename from sdk/go/silkd/frame_bench_test.go rename to protocol/wire/frame_bench_test.go index bac7be3..597d74d 100644 --- a/sdk/go/silkd/frame_bench_test.go +++ b/protocol/wire/frame_bench_test.go @@ -1,4 +1,4 @@ -package silkd +package wire import ( "encoding/json" diff --git a/sdk/go/silkd/frame_test.go b/protocol/wire/frame_test.go similarity index 94% rename from sdk/go/silkd/frame_test.go rename to protocol/wire/frame_test.go index d4076e7..d9f9b9c 100644 --- a/sdk/go/silkd/frame_test.go +++ b/protocol/wire/frame_test.go @@ -1,4 +1,4 @@ -package silkd +package wire import ( "bytes" @@ -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 @@ -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) + } + } +} diff --git a/protocol/wire/go.mod b/protocol/wire/go.mod new file mode 100644 index 0000000..0bcc385 --- /dev/null +++ b/protocol/wire/go.mod @@ -0,0 +1,3 @@ +module github.com/cocoonstack/sandbox/protocol/wire + +go 1.26.4 diff --git a/sandboxd/engine/engine.go b/sandboxd/engine/engine.go index a0370fd..26d3632 100644 --- a/sandboxd/engine/engine.go +++ b/sandboxd/engine/engine.go @@ -23,6 +23,7 @@ import ( "github.com/projecteru2/core/log" + "github.com/cocoonstack/sandbox/protocol/wire" "github.com/cocoonstack/sandbox/sandboxd/types" ) @@ -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 @@ -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) } @@ -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 } @@ -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 @@ -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 } @@ -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 { diff --git a/sandboxd/engine/installca.go b/sandboxd/engine/installca.go index f01f5e7..9269b8d 100644 --- a/sandboxd/engine/installca.go +++ b/sandboxd/engine/installca.go @@ -3,10 +3,11 @@ package engine import ( "bytes" "context" - "encoding/base64" "errors" "fmt" "slices" + + "github.com/cocoonstack/sandbox/protocol/wire" ) const ( @@ -41,28 +42,27 @@ func (e *Engine) silkdWriteFile(ctx context.Context, vsockSocket, path string, m defer s.close() // A send racing the guest's early answer+close hits EPIPE; prefer the buffered verdict. sendErr := func() error { - if serr := s.send(map[string]any{"v": 1, "op": "fs_write", "path": path, "mode": mode}); serr != nil { + if serr := s.send(wire.FsWrite{Path: path, Mode: &mode}); serr != nil { return serr } for chunk := range slices.Chunk(data, silkdChunk) { - enc := base64.StdEncoding.EncodeToString(chunk) - if serr := s.send(map[string]any{"v": 1, "op": "data", "data": enc}); serr != nil { + if serr := s.send(wire.Data{Data: chunk}); serr != nil { return serr } } - return s.send(map[string]any{"v": 1, "op": "data_end"}) + return s.send(wire.DataEnd{}) }() frame, err := s.recv() if err != nil { return errors.Join(sendErr, err) } - switch frame.Type { - case "done": + switch resp := frame.(type) { + case *wire.Done: return nil - case "error": - return fmt.Errorf("silkd %s: %s", frame.Kind, frame.Message) + case *wire.ErrorResp: + return fmt.Errorf("silkd %w", resp) default: - return fmt.Errorf("unexpected silkd frame %q", frame.Type) + return fmt.Errorf("unexpected silkd frame %q", resp.RespType()) } } @@ -72,13 +72,10 @@ func (e *Engine) silkdExec(ctx context.Context, vsockSocket string, argv ...stri return err } defer s.close() - sendErr := s.send(map[string]any{ - "v": 1, "op": "exec", "argv": argv, "detach": false, - "env": map[string]string{"PATH": guestExecPATH}, - }) + sendErr := s.send(wire.Exec{Argv: argv, Env: map[string]string{"PATH": guestExecPATH}}) if sendErr == nil { // A child exiting without reading stdin races this close; prefer the buffered exit frame. - sendErr = s.send(map[string]any{"v": 1, "op": "stdin_close"}) + sendErr = s.send(wire.StdinClose{}) } var out []byte for { @@ -86,21 +83,29 @@ func (e *Engine) silkdExec(ctx context.Context, vsockSocket string, argv ...stri if err != nil { return errors.Join(sendErr, err) } - switch frame.Type { - case "started": - case "stdout", "stderr": - if room := 4096 - len(out); room > 0 { - out = append(out, frame.Data[:min(len(frame.Data), room)]...) - } - case "exit": - if frame.Code != 0 { - return fmt.Errorf("exit code %d: %s", frame.Code, bytes.TrimSpace(out)) + switch resp := frame.(type) { + case *wire.Started: + case *wire.Stdout: + out = appendCapped(out, resp.Data) + case *wire.Stderr: + out = appendCapped(out, resp.Data) + case *wire.Exit: + if resp.Code != 0 { + return fmt.Errorf("exit code %d: %s", resp.Code, bytes.TrimSpace(out)) } return nil - case "error": - return fmt.Errorf("silkd %s: %s", frame.Kind, frame.Message) + case *wire.ErrorResp: + return fmt.Errorf("silkd %w", resp) default: - return fmt.Errorf("unexpected silkd frame %q", frame.Type) + return fmt.Errorf("unexpected silkd frame %q", resp.RespType()) } } } + +// appendCapped keeps the first 4096 bytes of combined output for error context. +func appendCapped(out, data []byte) []byte { + if room := 4096 - len(out); room > 0 { + out = append(out, data[:min(len(data), room)]...) + } + return out +} diff --git a/sandboxd/engine/portconn.go b/sandboxd/engine/portconn.go index dba0a3a..dd920d7 100644 --- a/sandboxd/engine/portconn.go +++ b/sandboxd/engine/portconn.go @@ -8,6 +8,8 @@ import ( "fmt" "io" "net" + + "github.com/cocoonstack/sandbox/protocol/wire" ) const ( @@ -93,12 +95,9 @@ func (g *guestPortConn) Write(p []byte) (int, error) { written := 0 for len(p) > 0 { n := min(len(p), portWriteChunk) - // Hand-built envelope: the hot relay path reuses one buffer instead - // of allocating two frame-sized slices per chunk (base64 needs no - // JSON escaping). - g.wbuf = append(g.wbuf[:0], `{"v":1,"op":"data","data":"`...) - g.wbuf = base64.StdEncoding.AppendEncode(g.wbuf, p[:n]) - g.wbuf = append(g.wbuf, '"', '}', '\n') + // The hot relay path renders into one reused buffer instead of + // allocating two frame-sized slices per chunk. + g.wbuf = wire.AppendBulkRequest(g.wbuf, "data", p[:n]) if _, err := g.Conn.Write(g.wbuf); err != nil { return written, err } diff --git a/sandboxd/engine/portconn_test.go b/sandboxd/engine/portconn_test.go index 7b5afde..0311e9b 100644 --- a/sandboxd/engine/portconn_test.go +++ b/sandboxd/engine/portconn_test.go @@ -58,7 +58,7 @@ func TestGuestPortConnWriteWrapsInDataFrame(t *testing.T) { // to the shared protocol corpus: portconn is not built on sdk/go/silkd, so // without this test it could drift from the frames both real peers agree on. func TestGuestPortConnRoundTripsFixtures(t *testing.T) { - const fixtureDir = "../../protocol/fixtures/v1" + const fixtureDir = "../../protocol/wire/fixtures/v1" fixture := func(name string) []byte { t.Helper() raw, err := os.ReadFile(filepath.Join(fixtureDir, name)) diff --git a/sandboxd/engine/silkd.go b/sandboxd/engine/silkd.go index 52ebc0e..640c020 100644 --- a/sandboxd/engine/silkd.go +++ b/sandboxd/engine/silkd.go @@ -3,28 +3,16 @@ package engine import ( "bufio" "context" - "encoding/json" "fmt" "io" "net" -) -const ( - silkdChunk = 256 * 1024 - maxSilkdFrame = 8 << 20 + "github.com/cocoonstack/sandbox/protocol/wire" ) -// silkdFrame is one newline-JSON frame silkd replies with; a given reply -// populates only the fields its type carries. -type silkdFrame struct { - Type string `json:"type"` - Code int32 `json:"code"` - Kind string `json:"kind"` - Message string `json:"message"` - Data []byte `json:"data"` -} +const silkdChunk = 256 * 1024 -// silkdSession is a dialed silkd conn bound to a ctx, with newline-JSON +// silkdSession is a dialed silkd conn bound to a ctx, with wire-typed // request/reply helpers. The hot port-forward relay (portconn.go) does not use it. type silkdSession struct { conn net.Conn @@ -44,8 +32,8 @@ func (e *Engine) dialSilkdSession(ctx context.Context, vsockSocket string) (*sil }, nil } -func (s *silkdSession) send(frame map[string]any) error { - buf, err := json.Marshal(frame) +func (s *silkdSession) send(req wire.Request) error { + buf, err := wire.EncodeRequest(req) if err != nil { return err } @@ -55,18 +43,14 @@ func (s *silkdSession) send(frame map[string]any) error { return nil } -func (s *silkdSession) recv() (silkdFrame, error) { +func (s *silkdSession) recv() (wire.Response, error) { if !s.sc.Scan() { if err := s.sc.Err(); err != nil { - return silkdFrame{}, fmt.Errorf("read silkd frame: %w", err) + return nil, fmt.Errorf("read silkd frame: %w", err) } - return silkdFrame{}, io.ErrUnexpectedEOF - } - var frame silkdFrame - if err := json.Unmarshal(s.sc.Bytes(), &frame); err != nil { - return silkdFrame{}, fmt.Errorf("parse silkd frame: %w", err) + return nil, io.ErrUnexpectedEOF } - return frame, nil + return wire.DecodeResponse(s.sc.Bytes()) } func (s *silkdSession) close() { @@ -76,6 +60,6 @@ func (s *silkdSession) close() { func silkdScanner(conn net.Conn) *bufio.Scanner { sc := bufio.NewScanner(conn) - sc.Buffer(make([]byte, 64*1024), maxSilkdFrame) + sc.Buffer(make([]byte, 64*1024), wire.MaxFrame) return sc } diff --git a/sandboxd/go.mod b/sandboxd/go.mod index a449221..0a4f66b 100644 --- a/sandboxd/go.mod +++ b/sandboxd/go.mod @@ -8,6 +8,7 @@ require ( github.com/aws/aws-sdk-go-v2/feature/s3/transfermanager v0.2.14 github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0 github.com/aws/smithy-go v1.27.3 + github.com/cocoonstack/sandbox/protocol/wire v0.0.0-00010101000000-000000000000 github.com/google/nftables v0.3.0 github.com/hashicorp/memberlist v0.5.4 github.com/projecteru2/core v0.0.0-20241016125006-ff909eefe04c @@ -69,3 +70,5 @@ require ( google.golang.org/protobuf v1.33.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect ) + +replace github.com/cocoonstack/sandbox/protocol/wire => ../protocol/wire diff --git a/sdk/go/README.md b/sdk/go/README.md index 9733990..9edc3bd 100644 --- a/sdk/go/README.md +++ b/sdk/go/README.md @@ -17,8 +17,10 @@ out, _ := sb.Exec(ctx, "echo", "hello") - `lsp.go` — `StartLsp` + the JSON-RPC byte stream to a flavor's server - `proc.go` — background process management (Spawn/Ps/Kill/Logs/Attach) - `checkpoint.go` / `template.go` — branch/rewind and promote handles -- `silkd/` — the wire binding: frame types round-tripping - `protocol/fixtures/` (drift against the Rust guest fails CI); `silkdtest/` - is an in-process fake guest for consumers' tests +- `silkd/` — the conn/stream layer over the relay; the frame types live in + `protocol/wire`, whose tests round-trip `protocol/wire/fixtures/` (drift + against the Rust guest fails CI); `silkdtest/` is an in-process fake guest + for consumers' tests -Module path `github.com/cocoonstack/sandbox/sdk/go`; no dependencies. +Module path `github.com/cocoonstack/sandbox/sdk/go`; no third-party +dependencies (only the sibling `protocol/wire` module). diff --git a/sdk/go/files.go b/sdk/go/files.go index 191bbb6..c1232bb 100644 --- a/sdk/go/files.go +++ b/sdk/go/files.go @@ -6,6 +6,7 @@ import ( "fmt" "io" + "github.com/cocoonstack/sandbox/protocol/wire" "github.com/cocoonstack/sandbox/sdk/go/silkd" ) @@ -15,13 +16,13 @@ const fsChunk = 256 * 1024 // WriteFile writes data to path in the sandbox, atomically (silkd renames a // temp file into place). mode, when non-nil, sets the file's permission bits. func (s *Sandbox) WriteFile(ctx context.Context, path string, data []byte, mode *uint32) error { - return s.uploadRPC(ctx, &silkd.FsWrite{Path: path, Mode: mode}, bytes.NewReader(data)) + return s.uploadRPC(ctx, &wire.FsWrite{Path: path, Mode: mode}, bytes.NewReader(data)) } // ReadFile returns the contents of path. func (s *Sandbox) ReadFile(ctx context.Context, path string) ([]byte, error) { var out []byte - err := s.downloadRPC(ctx, &silkd.FsRead{Path: path}, func(b []byte) error { + err := s.downloadRPC(ctx, &wire.FsRead{Path: path}, func(b []byte) error { out = append(out, b...) return nil }) @@ -29,12 +30,12 @@ func (s *Sandbox) ReadFile(ctx context.Context, path string) ([]byte, error) { } // ListDir returns the entries of a directory (batched frames are concatenated). -func (s *Sandbox) ListDir(ctx context.Context, path string) ([]silkd.DirEntry, error) { - frames, err := collectRPC[silkd.Entries](ctx, s, &silkd.FsList{Path: path}) +func (s *Sandbox) ListDir(ctx context.Context, path string) ([]wire.DirEntry, error) { + frames, err := collectRPC[wire.Entries](ctx, s, &wire.FsList{Path: path}) if err != nil { return nil, err } - var entries []silkd.DirEntry + var entries []wire.DirEntry for _, f := range frames { entries = append(entries, f.Entries...) } @@ -42,31 +43,31 @@ func (s *Sandbox) ListDir(ctx context.Context, path string) ([]silkd.DirEntry, e } // Stat returns metadata for path. -func (s *Sandbox) Stat(ctx context.Context, path string) (silkd.FileInfo, error) { - st, err := oneShotRPC[silkd.Stat](ctx, s, &silkd.FsStat{Path: path}) +func (s *Sandbox) Stat(ctx context.Context, path string) (wire.FileInfo, error) { + st, err := oneShotRPC[wire.Stat](ctx, s, &wire.FsStat{Path: path}) if err != nil { - return silkd.FileInfo{}, err + return wire.FileInfo{}, err } return st.Info, nil } // Mkdir creates a directory, with parents when set. func (s *Sandbox) Mkdir(ctx context.Context, path string, parents bool) error { - return s.doneRPC(ctx, &silkd.FsMkdir{Path: path, Parents: parents}) + return s.doneRPC(ctx, &wire.FsMkdir{Path: path, Parents: parents}) } // Remove deletes a file or directory (recursively when set). func (s *Sandbox) Remove(ctx context.Context, path string, recursive bool) error { - return s.doneRPC(ctx, &silkd.FsRm{Path: path, Recursive: recursive}) + return s.doneRPC(ctx, &wire.FsRm{Path: path, Recursive: recursive}) } // Rename moves a file within the sandbox. func (s *Sandbox) Rename(ctx context.Context, from, to string) error { - return s.doneRPC(ctx, &silkd.FsRename{From: from, To: to}) + return s.doneRPC(ctx, &wire.FsRename{From: from, To: to}) } // doneRPC sends a request that answers with Done or an error frame. -func (s *Sandbox) doneRPC(ctx context.Context, req silkd.Request) error { +func (s *Sandbox) doneRPC(ctx context.Context, req wire.Request) error { conn, done, err := s.call(ctx, req) if err != nil { return err @@ -76,7 +77,7 @@ func (s *Sandbox) doneRPC(ctx context.Context, req silkd.Request) error { } // uploadRPC sends req, streams r as Data frames, and expects a terminal Done. -func (s *Sandbox) uploadRPC(ctx context.Context, req silkd.Request, r io.Reader) error { +func (s *Sandbox) uploadRPC(ctx context.Context, req wire.Request, r io.Reader) error { conn, done, err := s.call(ctx, req) if err != nil { return err @@ -89,7 +90,7 @@ func (s *Sandbox) uploadRPC(ctx context.Context, req silkd.Request, r io.Reader) } // downloadRPC sends req and drains its Data stream into sink until Done. -func (s *Sandbox) downloadRPC(ctx context.Context, req silkd.Request, sink func([]byte) error) error { +func (s *Sandbox) downloadRPC(ctx context.Context, req wire.Request, sink func([]byte) error) error { conn, done, err := s.call(ctx, req) if err != nil { return err @@ -99,7 +100,7 @@ func (s *Sandbox) downloadRPC(ctx context.Context, req silkd.Request, sink func( } // oneShotRPC sends req and returns its single typed reply frame. -func oneShotRPC[T any](ctx context.Context, s *Sandbox, req silkd.Request) (*T, error) { +func oneShotRPC[T any](ctx context.Context, s *Sandbox, req wire.Request) (*T, error) { conn, done, err := s.call(ctx, req) if err != nil { return nil, err @@ -109,7 +110,7 @@ func oneShotRPC[T any](ctx context.Context, s *Sandbox, req silkd.Request) (*T, } // collectRPC sends req and gathers every streamed frame of type T until Done. -func collectRPC[T any](ctx context.Context, s *Sandbox, req silkd.Request) ([]T, error) { +func collectRPC[T any](ctx context.Context, s *Sandbox, req wire.Request) ([]T, error) { conn, done, err := s.call(ctx, req) if err != nil { return nil, err @@ -128,9 +129,9 @@ func collectRPC[T any](ctx context.Context, s *Sandbox, req silkd.Request) ([]T, continue } switch r := resp.(type) { - case *silkd.Done: + case *wire.Done: return out, nil - case *silkd.ErrorResp: + case *wire.ErrorResp: return nil, r default: return nil, unexpected(resp) @@ -145,12 +146,12 @@ func uploadStream(conn *silkd.Conn, r io.Reader) error { for { n, readErr := r.Read(buf) if n > 0 { - if err := conn.Send(&silkd.Data{Data: buf[:n]}); err != nil { + if err := conn.Send(&wire.Data{Data: buf[:n]}); err != nil { return err } } if readErr == io.EOF { - return conn.Send(silkd.DataEnd{}) + return conn.Send(wire.DataEnd{}) } if readErr != nil { return readErr @@ -167,13 +168,13 @@ func drainData(ctx context.Context, conn *silkd.Conn, sink func([]byte) error) e return err } switch r := resp.(type) { - case *silkd.DataResp: + case *wire.DataResp: if err := sink(r.Data); err != nil { return err } - case *silkd.Done: + case *wire.Done: return nil - case *silkd.ErrorResp: + case *wire.ErrorResp: return r default: return unexpected(resp) @@ -183,7 +184,7 @@ func drainData(ctx context.Context, conn *silkd.Conn, sink func([]byte) error) e // terminalErr reads one frame and requires it to be Done (else the error). func terminalErr(ctx context.Context, conn *silkd.Conn) error { - _, err := expect[silkd.Done](ctx, conn) + _, err := expect[wire.Done](ctx, conn) return err } @@ -197,14 +198,14 @@ func expect[T any](ctx context.Context, conn *silkd.Conn) (*T, error) { if v, ok := any(resp).(*T); ok { return v, nil } - if e, ok := resp.(*silkd.ErrorResp); ok { + if e, ok := resp.(*wire.ErrorResp); ok { return nil, e } return nil, unexpected(resp) } // recv reads one frame, translating a canceled ctx and an early EOF. -func recv(ctx context.Context, conn *silkd.Conn) (silkd.Response, error) { +func recv(ctx context.Context, conn *silkd.Conn) (wire.Response, error) { resp, err := conn.Recv() if err != nil { if ctxErr := ctx.Err(); ctxErr != nil { @@ -218,6 +219,6 @@ func recv(ctx context.Context, conn *silkd.Conn) (silkd.Response, error) { return resp, nil } -func unexpected(resp silkd.Response) error { +func unexpected(resp wire.Response) error { return fmt.Errorf("unexpected frame %q", resp.RespType()) } diff --git a/sdk/go/find.go b/sdk/go/find.go index 3925240..9fd2c4b 100644 --- a/sdk/go/find.go +++ b/sdk/go/find.go @@ -3,18 +3,18 @@ package sandbox import ( "context" - "github.com/cocoonstack/sandbox/sdk/go/silkd" + "github.com/cocoonstack/sandbox/protocol/wire" ) // Find returns the lines under path matching pattern (a regular expression); // glob, when non-empty, narrows the walk to file names matching it (`*` and // `?` wildcards). -func (s *Sandbox) Find(ctx context.Context, path, pattern, glob string) ([]silkd.Match, error) { - return collectRPC[silkd.Match](ctx, s, &silkd.FsFind{Path: path, Pattern: pattern, Glob: glob}) +func (s *Sandbox) Find(ctx context.Context, path, pattern, glob string) ([]wire.Match, error) { + return collectRPC[wire.Match](ctx, s, &wire.FsFind{Path: path, Pattern: pattern, Glob: glob}) } // Replace rewrites pattern (a regular expression) to replacement in each of // files, returning one result per file with its replacement count. -func (s *Sandbox) Replace(ctx context.Context, files []string, pattern, replacement string) ([]silkd.Replaced, error) { - return collectRPC[silkd.Replaced](ctx, s, &silkd.FsReplace{Files: files, Pattern: pattern, Replacement: replacement}) +func (s *Sandbox) Replace(ctx context.Context, files []string, pattern, replacement string) ([]wire.Replaced, error) { + return collectRPC[wire.Replaced](ctx, s, &wire.FsReplace{Files: files, Pattern: pattern, Replacement: replacement}) } diff --git a/sdk/go/find_test.go b/sdk/go/find_test.go index d0d4b36..2b5861a 100644 --- a/sdk/go/find_test.go +++ b/sdk/go/find_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/cocoonstack/sandbox/sdk/go/silkd" + "github.com/cocoonstack/sandbox/protocol/wire" ) func TestFindStreamsMatches(t *testing.T) { @@ -36,8 +36,8 @@ func TestFindStreamsMatches(t *testing.T) { func TestFindBadPatternIsTypedError(t *testing.T) { sb := fakeSandbox(t) _, err := sb.Find(t.Context(), "/", "(", "") - var e *silkd.ErrorResp - if !errors.As(err, &e) || e.Kind != silkd.KindBadRequest { + var e *wire.ErrorResp + if !errors.As(err, &e) || e.Kind != wire.KindBadRequest { t.Errorf("got %v, want bad_request error frame", err) } } diff --git a/sdk/go/git.go b/sdk/go/git.go index 87d0ad8..da0c647 100644 --- a/sdk/go/git.go +++ b/sdk/go/git.go @@ -3,25 +3,25 @@ package sandbox import ( "context" - "github.com/cocoonstack/sandbox/sdk/go/silkd" + "github.com/cocoonstack/sandbox/protocol/wire" ) // GitClone clones url into path in the sandbox; depth > 0 makes a shallow // clone. Auth, when non-empty, is a token sent as an in-memory Authorization // header. Needs the egress lane. func (s *Sandbox) GitClone(ctx context.Context, url, path, branch string, depth uint32, auth string) error { - return s.doneRPC(ctx, &silkd.GitClone{URL: url, Path: path, Branch: branch, Depth: depth, Auth: auth}) + return s.doneRPC(ctx, &wire.GitClone{URL: url, Path: path, Branch: branch, Depth: depth, Auth: auth}) } // GitStatus returns the structured status of the repo at path. -func (s *Sandbox) GitStatus(ctx context.Context, path string) (*silkd.GitStatusResult, error) { - return oneShotRPC[silkd.GitStatusResult](ctx, s, &silkd.GitStatus{Path: path}) +func (s *Sandbox) GitStatus(ctx context.Context, path string) (*wire.GitStatusResult, error) { + return oneShotRPC[wire.GitStatusResult](ctx, s, &wire.GitStatus{Path: path}) } // GitCommit stages nothing (call GitAdd first); it commits the index with // message and author ("Name ") and returns the new commit hash. func (s *Sandbox) GitCommit(ctx context.Context, path, message, author string) (string, error) { - c, err := oneShotRPC[silkd.GitCommitResult](ctx, s, &silkd.GitCommit{Path: path, Message: message, Author: author}) + c, err := oneShotRPC[wire.GitCommitResult](ctx, s, &wire.GitCommit{Path: path, Message: message, Author: author}) if err != nil { return "", err } @@ -30,36 +30,36 @@ func (s *Sandbox) GitCommit(ctx context.Context, path, message, author string) ( // GitAdd stages files under the repo at path. func (s *Sandbox) GitAdd(ctx context.Context, path string, files ...string) error { - return s.doneRPC(ctx, &silkd.GitAdd{Path: path, Files: files}) + return s.doneRPC(ctx, &wire.GitAdd{Path: path, Files: files}) } // GitPush pushes the current branch. Auth as in GitClone. Needs the egress // lane; on the no-network lane it fails with a typed error pointing at Push. func (s *Sandbox) GitPush(ctx context.Context, path, auth string) error { - return s.doneRPC(ctx, &silkd.GitPush{Path: path, Auth: auth}) + return s.doneRPC(ctx, &wire.GitPush{Path: path, Auth: auth}) } // GitPull pulls the current branch; same lane rules as GitPush. func (s *Sandbox) GitPull(ctx context.Context, path, auth string) error { - return s.doneRPC(ctx, &silkd.GitPull{Path: path, Auth: auth}) + return s.doneRPC(ctx, &wire.GitPull{Path: path, Auth: auth}) } // GitBranches lists the repo's branches and the current one. -func (s *Sandbox) GitBranches(ctx context.Context, path string) (*silkd.GitBranches, error) { - return oneShotRPC[silkd.GitBranches](ctx, s, &silkd.GitBranch{Path: path, Action: silkd.BranchList}) +func (s *Sandbox) GitBranches(ctx context.Context, path string) (*wire.GitBranches, error) { + return oneShotRPC[wire.GitBranches](ctx, s, &wire.GitBranch{Path: path, Action: wire.BranchList}) } // GitCreateBranch creates branch name. func (s *Sandbox) GitCreateBranch(ctx context.Context, path, name string) error { - return s.doneRPC(ctx, &silkd.GitBranch{Path: path, Action: silkd.BranchCreate, Name: name}) + return s.doneRPC(ctx, &wire.GitBranch{Path: path, Action: wire.BranchCreate, Name: name}) } // GitDeleteBranch force-deletes branch name. func (s *Sandbox) GitDeleteBranch(ctx context.Context, path, name string) error { - return s.doneRPC(ctx, &silkd.GitBranch{Path: path, Action: silkd.BranchDelete, Name: name}) + return s.doneRPC(ctx, &wire.GitBranch{Path: path, Action: wire.BranchDelete, Name: name}) } // GitCheckout checks out branch name. func (s *Sandbox) GitCheckout(ctx context.Context, path, name string) error { - return s.doneRPC(ctx, &silkd.GitBranch{Path: path, Action: silkd.BranchCheckout, Name: name}) + return s.doneRPC(ctx, &wire.GitBranch{Path: path, Action: wire.BranchCheckout, Name: name}) } diff --git a/sdk/go/go.mod b/sdk/go/go.mod index 34ebdfe..b3c0f25 100644 --- a/sdk/go/go.mod +++ b/sdk/go/go.mod @@ -1,3 +1,10 @@ module github.com/cocoonstack/sandbox/sdk/go go 1.26.4 + +replace github.com/cocoonstack/sandbox/protocol/wire => ../../protocol/wire + +// Real pseudo-version so downstream `go get` resolves without the local +// replace (dependency-module replaces are ignored). Cut a protocol/wire tag +// alongside every sdk/go release and bump this to it. +require github.com/cocoonstack/sandbox/protocol/wire v0.0.0-20260718031021-a8af7bea15e5 diff --git a/sdk/go/lsp.go b/sdk/go/lsp.go index 5e7f2ee..aa9ccf1 100644 --- a/sdk/go/lsp.go +++ b/sdk/go/lsp.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - "github.com/cocoonstack/sandbox/sdk/go/silkd" + "github.com/cocoonstack/sandbox/protocol/wire" ) // Lsp is a language server running in the sandbox, spoken to over the relay. @@ -21,24 +21,24 @@ type Lsp struct { // server serves one Request for its lifetime — closing the stream ends the // session and reaps the server (start a new one to work again). func (l *Lsp) Request(ctx context.Context) (*PortConn, error) { - return l.s.openStream(ctx, &silkd.LspRequest{ServerID: l.ServerID}) + return l.s.openStream(ctx, &wire.LspRequest{ServerID: l.ServerID}) } // Stop kills the language server. func (l *Lsp) Stop(ctx context.Context) error { - return l.s.doneRPC(ctx, &silkd.LspStop{ServerID: l.ServerID}) + return l.s.doneRPC(ctx, &wire.LspStop{ServerID: l.ServerID}) } // StartLsp spawns the language server the flavor image provides for language // (rooted at root), returning a handle. On the base image, which ships no // language servers, this fails with silkd's typed not_found. func (s *Sandbox) StartLsp(ctx context.Context, language, root string) (*Lsp, error) { - conn, done, err := s.call(ctx, &silkd.LspStart{Language: language, Root: root}) + conn, done, err := s.call(ctx, &wire.LspStart{Language: language, Root: root}) if err != nil { return nil, err } defer done() - started, err := expect[silkd.LspStarted](ctx, conn) + started, err := expect[wire.LspStarted](ctx, conn) if err != nil { return nil, err } diff --git a/sdk/go/port.go b/sdk/go/port.go index 51b2477..d4ac332 100644 --- a/sdk/go/port.go +++ b/sdk/go/port.go @@ -10,11 +10,12 @@ import ( "sync" "time" + "github.com/cocoonstack/sandbox/protocol/wire" "github.com/cocoonstack/sandbox/sdk/go/silkd" ) // portWriteChunk keeps a data frame (payload ×4/3 base64 + envelope) well -// under silkd.MaxFrame. +// under wire.MaxFrame. const portWriteChunk = 1 << 20 var _ net.Conn = (*PortConn)(nil) @@ -40,7 +41,7 @@ func (p *PortConn) Write(b []byte) (int, error) { sent := 0 for len(b) > 0 { chunk := b[:min(len(b), portWriteChunk)] - if err := p.conn.Send(&silkd.Data{Data: chunk}); err != nil { + if err := p.conn.Send(&wire.Data{Data: chunk}); err != nil { return sent, err } sent += len(chunk) @@ -52,7 +53,7 @@ func (p *PortConn) Write(b []byte) (int, error) { // CloseWrite half-closes the guest socket (the server sees EOF) while reads // keep draining its response. func (p *PortConn) CloseWrite() error { - return p.conn.Send(silkd.DataEnd{}) + return p.conn.Send(wire.DataEnd{}) } func (p *PortConn) Close() error { @@ -94,17 +95,17 @@ func (p *PortConn) drain(ctx context.Context, pw *io.PipeWriter) { // the connection's lifetime: canceling it (or Close) tears the relay down. // A port nobody listens on fails here with silkd's not_found error. func (s *Sandbox) DialPort(ctx context.Context, port uint16) (*PortConn, error) { - return s.openStream(ctx, &silkd.PortForward{Port: port}) + return s.openStream(ctx, &wire.PortForward{Port: port}) } // openStream drives the call → Ready → attach dance shared by every verb // that turns the connection into a raw byte stream (DialPort, Lsp.Request). -func (s *Sandbox) openStream(ctx context.Context, req silkd.Request) (*PortConn, error) { +func (s *Sandbox) openStream(ctx context.Context, req wire.Request) (*PortConn, error) { conn, done, err := s.call(ctx, req) if err != nil { return nil, err } - if _, err = expect[silkd.Ready](ctx, conn); err != nil { + if _, err = expect[wire.Ready](ctx, conn); err != nil { done() return nil, err } diff --git a/sdk/go/proc.go b/sdk/go/proc.go index dbec53e..f637dac 100644 --- a/sdk/go/proc.go +++ b/sdk/go/proc.go @@ -5,7 +5,7 @@ import ( "fmt" "io" - "github.com/cocoonstack/sandbox/sdk/go/silkd" + "github.com/cocoonstack/sandbox/protocol/wire" ) // Spawn starts cmd detached: it returns the guest pid as soon as the @@ -15,8 +15,8 @@ func (s *Sandbox) Spawn(ctx context.Context, cmd Cmd) (uint32, error) { if len(cmd.Argv) == 0 { return 0, fmt.Errorf("empty argv") } - req := &silkd.Exec{Argv: cmd.Argv, Cwd: cmd.Cwd, Env: cmd.Env, User: cmd.User, Detach: true, Session: cmd.Session} - started, err := oneShotRPC[silkd.Started](ctx, s, req) + req := &wire.Exec{Argv: cmd.Argv, Cwd: cmd.Cwd, Env: cmd.Env, User: cmd.User, Detach: true, Session: cmd.Session} + started, err := oneShotRPC[wire.Started](ctx, s, req) if err != nil { return 0, err } @@ -25,8 +25,8 @@ func (s *Sandbox) Spawn(ctx context.Context, cmd Cmd) (uint32, error) { // Ps lists the guest's tracked processes — execs, spawns, and ptys — with // state and exit codes. -func (s *Sandbox) Ps(ctx context.Context) ([]silkd.ProcInfo, error) { - procs, err := oneShotRPC[silkd.Procs](ctx, s, silkd.Ps{}) +func (s *Sandbox) Ps(ctx context.Context) ([]wire.ProcInfo, error) { + procs, err := oneShotRPC[wire.Procs](ctx, s, wire.Ps{}) if err != nil { return nil, err } @@ -37,7 +37,7 @@ func (s *Sandbox) Ps(ctx context.Context) ([]silkd.ProcInfo, error) { // already exited is a no-op success — its OS pid may be recycled, so silkd // never signals a reaped child. func (s *Sandbox) Kill(ctx context.Context, pid uint32, sig int32) error { - req := &silkd.Kill{PID: pid} + req := &wire.Kill{PID: pid} if sig != 0 { req.Signal = &sig } @@ -48,19 +48,19 @@ func (s *Sandbox) Kill(ctx context.Context, pid uint32, sig int32) error { // (nil discards). exited reports whether the process has ended; code is its // exit code when it has. func (s *Sandbox) Logs(ctx context.Context, pid uint32, stdout, stderr io.Writer) (code int32, exited bool, err error) { - return s.drainProc(ctx, &silkd.Logs{PID: pid}, stdout, stderr) + return s.drainProc(ctx, &wire.Logs{PID: pid}, stdout, stderr) } // Attach replays the buffered output, then follows live output until the // process exits, returning its exit code. exited is false only when the // proc table dropped the process mid-attach (reap race). func (s *Sandbox) Attach(ctx context.Context, pid uint32, stdout, stderr io.Writer) (code int32, exited bool, err error) { - return s.drainProc(ctx, &silkd.Attach{PID: pid}, stdout, stderr) + return s.drainProc(ctx, &wire.Attach{PID: pid}, stdout, stderr) } // drainProc pumps stdout/stderr frames until the terminal frame: exit // carries the code, done means the stream ended without one. -func (s *Sandbox) drainProc(ctx context.Context, req silkd.Request, stdout, stderr io.Writer) (int32, bool, error) { +func (s *Sandbox) drainProc(ctx context.Context, req wire.Request, stdout, stderr io.Writer) (int32, bool, error) { conn, done, err := s.call(ctx, req) if err != nil { return 0, false, err @@ -78,19 +78,19 @@ func (s *Sandbox) drainProc(ctx context.Context, req silkd.Request, stdout, stde return 0, false, err } switch resp := resp.(type) { - case *silkd.Stdout: + case *wire.Stdout: if _, err := stdout.Write(resp.Data); err != nil { return 0, false, err } - case *silkd.Stderr: + case *wire.Stderr: if _, err := stderr.Write(resp.Data); err != nil { return 0, false, err } - case *silkd.Exit: + case *wire.Exit: return resp.Code, true, nil - case *silkd.Done: + case *wire.Done: return 0, false, nil - case *silkd.ErrorResp: + case *wire.ErrorResp: return 0, false, resp default: return 0, false, unexpected(resp) diff --git a/sdk/go/pty.go b/sdk/go/pty.go index 56793a7..6aa9cf2 100644 --- a/sdk/go/pty.go +++ b/sdk/go/pty.go @@ -5,6 +5,7 @@ import ( "io" "sync" + "github.com/cocoonstack/sandbox/protocol/wire" "github.com/cocoonstack/sandbox/sdk/go/silkd" ) @@ -41,7 +42,7 @@ func (p *Pty) Read(b []byte) (int, error) { // Write feeds input to the terminal. func (p *Pty) Write(b []byte) (int, error) { - if err := p.conn.Send(&silkd.Stdin{Data: b}); err != nil { + if err := p.conn.Send(&wire.Stdin{Data: b}); err != nil { return 0, err } return len(b), nil @@ -54,7 +55,7 @@ func (p *Pty) Resize(ctx context.Context, cols, rows uint16) error { return err } defer done() - if err := conn.Send(&silkd.PtyResize{PID: p.PID, Cols: cols, Rows: rows}); err != nil { + if err := conn.Send(&wire.PtyResize{PID: p.PID, Cols: cols, Rows: rows}); err != nil { return err } return terminalErr(ctx, conn) @@ -85,18 +86,18 @@ func (p *Pty) drain(ctx context.Context, pw *io.PipeWriter) { return } switch r := resp.(type) { - case *silkd.Started: - case *silkd.Stdout: + case *wire.Started: + case *wire.Stdout: if _, err := pw.Write(r.Data); err != nil { return } - case *silkd.Exit: + case *wire.Exit: p.mu.Lock() p.exitCode, p.exited = int(r.Code), true p.mu.Unlock() _ = pw.Close() return - case *silkd.ErrorResp: + case *wire.ErrorResp: _ = pw.CloseWithError(r) return default: @@ -109,12 +110,12 @@ func (p *Pty) drain(ctx context.Context, pw *io.PipeWriter) { // OpenPty starts a shell under a pty. The ctx governs the pty's lifetime: // canceling it (or calling Close) tears the session down. func (s *Sandbox) OpenPty(ctx context.Context, opts PtyOpts) (*Pty, error) { - req := &silkd.PtyOpen{Cols: opts.Cols, Rows: opts.Rows, Cwd: opts.Cwd, Env: opts.Env, User: opts.User} + req := &wire.PtyOpen{Cols: opts.Cols, Rows: opts.Rows, Cwd: opts.Cwd, Env: opts.Env, User: opts.User} conn, done, err := s.call(ctx, req) if err != nil { return nil, err } - started, err := expect[silkd.Started](ctx, conn) + started, err := expect[wire.Started](ctx, conn) if err != nil { done() return nil, err diff --git a/sdk/go/sandbox.go b/sdk/go/sandbox.go index f5a3153..f863d55 100644 --- a/sdk/go/sandbox.go +++ b/sdk/go/sandbox.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/cocoonstack/sandbox/protocol/wire" "github.com/cocoonstack/sandbox/sdk/go/silkd" ) @@ -95,14 +96,14 @@ func (s *Sandbox) Run(ctx context.Context, cmd Cmd) (int, error) { if len(cmd.Argv) == 0 { return 0, fmt.Errorf("empty argv") } - conn, done, err := s.call(ctx, &silkd.Exec{Argv: cmd.Argv, Cwd: cmd.Cwd, Env: cmd.Env, User: cmd.User, Session: cmd.Session}) + conn, done, err := s.call(ctx, &wire.Exec{Argv: cmd.Argv, Cwd: cmd.Cwd, Env: cmd.Env, User: cmd.User, Session: cmd.Session}) if err != nil { return 0, err } defer done() if cmd.Stdin == nil { - if err := conn.Send(silkd.StdinClose{}); err != nil { + if err := conn.Send(wire.StdinClose{}); err != nil { return 0, fmt.Errorf("close stdin: %w", err) } } else { @@ -122,18 +123,18 @@ func (s *Sandbox) Run(ctx context.Context, cmd Cmd) (int, error) { return 0, err } switch resp := resp.(type) { - case *silkd.Started: - case *silkd.Stdout: + case *wire.Started: + case *wire.Stdout: if _, err := stdout.Write(resp.Data); err != nil { return 0, err } - case *silkd.Stderr: + case *wire.Stderr: if _, err := stderr.Write(resp.Data); err != nil { return 0, err } - case *silkd.Exit: + case *wire.Exit: return int(resp.Code), nil - case *silkd.ErrorResp: + case *wire.ErrorResp: return 0, resp default: return 0, unexpected(resp) @@ -220,7 +221,7 @@ func (s *Sandbox) dial(ctx context.Context) (*silkd.Conn, func(), error) { // call dials one RPC connection and sends its leading request, cleaning up // itself on a send failure; the returned cleanup must be deferred. -func (s *Sandbox) call(ctx context.Context, req silkd.Request) (*silkd.Conn, func(), error) { +func (s *Sandbox) call(ctx context.Context, req wire.Request) (*silkd.Conn, func(), error) { conn, done, err := s.dial(ctx) if err != nil { return nil, nil, err @@ -245,12 +246,12 @@ func pumpStdin(conn *silkd.Conn, r io.Reader) { for { n, err := r.Read(buf) if n > 0 { - if sendErr := conn.Send(&silkd.Stdin{Data: buf[:n]}); sendErr != nil { + if sendErr := conn.Send(&wire.Stdin{Data: buf[:n]}); sendErr != nil { return } } if err != nil { - _ = conn.Send(silkd.StdinClose{}) + _ = conn.Send(wire.StdinClose{}) return } } diff --git a/sdk/go/sandbox_test.go b/sdk/go/sandbox_test.go index d95683c..b710fc2 100644 --- a/sdk/go/sandbox_test.go +++ b/sdk/go/sandbox_test.go @@ -11,7 +11,7 @@ import ( "testing" "time" - "github.com/cocoonstack/sandbox/sdk/go/silkd" + "github.com/cocoonstack/sandbox/protocol/wire" "github.com/cocoonstack/sandbox/sdk/go/silkd/silkdtest" ) @@ -43,8 +43,8 @@ func TestExecSurfacesErrorFrame(t *testing.T) { sb := testSandbox(t, newAgentServer(t, silkdtest.ServeConn)) _, err := sb.Exec(t.Context(), "no-such-binary") - var silkdErr *silkd.ErrorResp - if !errors.As(err, &silkdErr) || silkdErr.Kind != silkd.KindNotFound { + var silkdErr *wire.ErrorResp + if !errors.As(err, &silkdErr) || silkdErr.Kind != wire.KindNotFound { t.Errorf("got %v, want silkd not_found error", err) } } diff --git a/sdk/go/session.go b/sdk/go/session.go index 2b5212c..db52618 100644 --- a/sdk/go/session.go +++ b/sdk/go/session.go @@ -4,7 +4,7 @@ import ( "context" "strings" - "github.com/cocoonstack/sandbox/sdk/go/silkd" + "github.com/cocoonstack/sandbox/protocol/wire" ) // Session is a persistent shell in the sandbox: cwd, env, and shell state @@ -31,29 +31,29 @@ func (sess *Session) Exec(ctx context.Context, argv ...string) (string, error) { // Close terminates the session's shell and its process group. func (sess *Session) Close(ctx context.Context) error { - return sess.sb.doneRPC(ctx, &silkd.SessionRm{ID: sess.ID}) + return sess.sb.doneRPC(ctx, &wire.SessionRm{ID: sess.ID}) } // SessionOption configures NewSession. -type SessionOption func(*silkd.SessionCreate) +type SessionOption func(*wire.SessionCreate) // WithSessionCwd sets the session's initial working directory. func WithSessionCwd(dir string) SessionOption { - return func(r *silkd.SessionCreate) { r.Cwd = dir } + return func(r *wire.SessionCreate) { r.Cwd = dir } } // WithSessionEnv sets the session's initial environment. func WithSessionEnv(env map[string]string) SessionOption { - return func(r *silkd.SessionCreate) { r.Env = env } + return func(r *wire.SessionCreate) { r.Env = env } } // NewSession opens a persistent shell. func (s *Sandbox) NewSession(ctx context.Context, opts ...SessionOption) (*Session, error) { - req := &silkd.SessionCreate{} + req := &wire.SessionCreate{} for _, opt := range opts { opt(req) } - created, err := oneShotRPC[silkd.SessionCreated](ctx, s, req) + created, err := oneShotRPC[wire.SessionCreated](ctx, s, req) if err != nil { return nil, err } @@ -62,7 +62,7 @@ func (s *Sandbox) NewSession(ctx context.Context, opts ...SessionOption) (*Sessi // Sessions lists the sandbox's live session ids. func (s *Sandbox) Sessions(ctx context.Context) ([]string, error) { - list, err := oneShotRPC[silkd.Sessions](ctx, s, silkd.SessionList{}) + list, err := oneShotRPC[wire.Sessions](ctx, s, wire.SessionList{}) if err != nil { return nil, err } diff --git a/sdk/go/silkd/conn.go b/sdk/go/silkd/conn.go index 1657762..2be470f 100644 --- a/sdk/go/silkd/conn.go +++ b/sdk/go/silkd/conn.go @@ -2,10 +2,11 @@ package silkd import ( "bufio" - "encoding/base64" "fmt" "io" "sync" + + "github.com/cocoonstack/sandbox/protocol/wire" ) // Conn frames a byte stream (typically the upgraded relay connection) into @@ -21,23 +22,23 @@ type Conn struct { // NewConn wraps rwc; the caller keeps ownership of closing via Close. func NewConn(rwc io.ReadWriteCloser) *Conn { sc := bufio.NewScanner(rwc) - sc.Buffer(make([]byte, 64*1024), MaxFrame) + sc.Buffer(make([]byte, 64*1024), wire.MaxFrame) return &Conn{rwc: rwc, sc: sc} } // Send writes one request frame. -func (c *Conn) Send(r Request) error { +func (c *Conn) Send(r wire.Request) error { switch v := r.(type) { - case *Data: + case *wire.Data: return c.sendBulk(v.Op(), v.Data) - case *Stdin: + case *wire.Stdin: return c.sendBulk(v.Op(), v.Data) } - frame, err := EncodeRequest(r) + frame, err := wire.EncodeRequest(r) if err != nil { return fmt.Errorf("encode %s: %w", r.Op(), err) } - frame = append(frame, '\n') // in-place: EncodeRequest reserves the byte + frame = append(frame, '\n') // in-place: wire.EncodeRequest reserves the byte c.wmu.Lock() defer c.wmu.Unlock() _, err = c.rwc.Write(frame) @@ -45,30 +46,26 @@ func (c *Conn) Send(r Request) error { } // Recv reads the next response frame; io.EOF signals a clean close. -func (c *Conn) Recv() (Response, error) { +func (c *Conn) Recv() (wire.Response, error) { if !c.sc.Scan() { if err := c.sc.Err(); err != nil { return nil, err } return nil, io.EOF } - return DecodeResponse(c.sc.Bytes()) + return wire.DecodeResponse(c.sc.Bytes()) } func (c *Conn) Close() error { return c.rwc.Close() } -// sendBulk hand-builds a data/stdin frame in a reused buffer, skipping the -// json.Marshal alloc + escape rescan (base64 needs no escaping). +// sendBulk renders a data/stdin frame into the reused buffer, skipping the +// json.Marshal alloc + escape rescan. func (c *Conn) sendBulk(op string, payload []byte) error { c.wmu.Lock() defer c.wmu.Unlock() - c.wbuf = append(c.wbuf[:0], requestHead...) - c.wbuf = append(c.wbuf, op...) - c.wbuf = append(c.wbuf, `","data":"`...) - c.wbuf = base64.StdEncoding.AppendEncode(c.wbuf, payload) - c.wbuf = append(c.wbuf, '"', '}', '\n') + c.wbuf = wire.AppendBulkRequest(c.wbuf, op, payload) _, err := c.rwc.Write(c.wbuf) return err } diff --git a/sdk/go/silkd/conn_test.go b/sdk/go/silkd/conn_test.go index 615c353..1f221f2 100644 --- a/sdk/go/silkd/conn_test.go +++ b/sdk/go/silkd/conn_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "github.com/cocoonstack/sandbox/protocol/wire" "github.com/cocoonstack/sandbox/sdk/go/silkd" "github.com/cocoonstack/sandbox/sdk/go/silkd/silkdtest" ) @@ -16,12 +17,12 @@ import ( // byte-for-byte identity with the json.Marshal path — the fast path must not // drift from the wire contract silkd parses. func TestSendBulkMatchesJSONEncoding(t *testing.T) { - for _, req := range []silkd.Request{ - &silkd.Data{Data: []byte("hello\x00\xff binary")}, - &silkd.Stdin{Data: []byte{}}, - &silkd.Data{Data: bytes.Repeat([]byte{0xAB}, 300*1024)}, + for _, req := range []wire.Request{ + &wire.Data{Data: []byte("hello\x00\xff binary")}, + &wire.Stdin{Data: []byte{}}, + &wire.Data{Data: bytes.Repeat([]byte{0xAB}, 300*1024)}, } { - want, err := silkd.EncodeRequest(req) + want, err := wire.EncodeRequest(req) if err != nil { t.Fatalf("encode: %v", err) } @@ -42,14 +43,14 @@ func (bufferConn) Close() error { return nil } func TestConnInfoRoundTrip(t *testing.T) { conn := dialFake(t) - if err := conn.Send(silkd.Info{}); err != nil { + if err := conn.Send(wire.Info{}); err != nil { t.Fatalf("send: %v", err) } resp, err := conn.Recv() if err != nil { t.Fatalf("recv: %v", err) } - info, ok := resp.(*silkd.InfoResp) + info, ok := resp.(*wire.InfoResp) if !ok || info.Version != "silkdtest" { t.Errorf("got %#v, want silkdtest info", resp) } @@ -60,28 +61,28 @@ func TestConnInfoRoundTrip(t *testing.T) { func TestConnExecStdinEcho(t *testing.T) { conn := dialFake(t) - if err := conn.Send(&silkd.Exec{Argv: []string{"cat"}}); err != nil { + if err := conn.Send(&wire.Exec{Argv: []string{"cat"}}); err != nil { t.Fatalf("send exec: %v", err) } if resp, err := conn.Recv(); err != nil { t.Fatalf("recv started: %v", err) - } else if _, ok := resp.(*silkd.Started); !ok { + } else if _, ok := resp.(*wire.Started); !ok { t.Fatalf("got %#v, want started", resp) } - if err := conn.Send(&silkd.Stdin{Data: []byte("ping")}); err != nil { + if err := conn.Send(&wire.Stdin{Data: []byte("ping")}); err != nil { t.Fatalf("send stdin: %v", err) } if resp, err := conn.Recv(); err != nil { t.Fatalf("recv echo: %v", err) - } else if out, ok := resp.(*silkd.Stdout); !ok || string(out.Data) != "ping" { + } else if out, ok := resp.(*wire.Stdout); !ok || string(out.Data) != "ping" { t.Fatalf("got %#v, want stdout ping", resp) } - if err := conn.Send(silkd.StdinClose{}); err != nil { + if err := conn.Send(wire.StdinClose{}); err != nil { t.Fatalf("send stdin_close: %v", err) } if resp, err := conn.Recv(); err != nil { t.Fatalf("recv exit: %v", err) - } else if exit, ok := resp.(*silkd.Exit); !ok || exit.Code != 0 { + } else if exit, ok := resp.(*wire.Exit); !ok || exit.Code != 0 { t.Fatalf("got %#v, want exit 0", resp) } } @@ -91,7 +92,7 @@ func TestConnRejectsOversizedFrame(t *testing.T) { t.Cleanup(func() { client.Close(); server.Close() }) conn := silkd.NewConn(client) go func() { - huge := strings.Repeat("a", silkd.MaxFrame+2) + huge := strings.Repeat("a", wire.MaxFrame+2) _, _ = io.WriteString(server, huge+"\n") }() diff --git a/sdk/go/silkd/silkdtest/fake.go b/sdk/go/silkd/silkdtest/fake.go index f9963db..1819502 100644 --- a/sdk/go/silkd/silkdtest/fake.go +++ b/sdk/go/silkd/silkdtest/fake.go @@ -15,7 +15,7 @@ import ( "strings" "sync" - "github.com/cocoonstack/sandbox/sdk/go/silkd" + "github.com/cocoonstack/sandbox/protocol/wire" ) // Fake is a stateful silkd fake backing the fs verbs with a real directory @@ -52,57 +52,57 @@ func (f *Fake) ServeConn(conn net.Conn) { return } switch req := req.(type) { - case *silkd.FsWrite: + case *wire.FsWrite: f.fsWrite(conn, r, req) - case *silkd.FsRead: + case *wire.FsRead: f.fsRead(conn, req.Path) - case *silkd.FsList: + case *wire.FsList: f.fsList(conn, req.Path) - case *silkd.FsStat: + case *wire.FsStat: f.fsStat(conn, req.Path) - case *silkd.FsMkdir: + case *wire.FsMkdir: f.done(conn, os.MkdirAll(f.abs(req.Path), 0o750)) - case *silkd.FsRm: + case *wire.FsRm: f.fsRm(conn, req) - case *silkd.FsRename: + case *wire.FsRename: f.done(conn, os.Rename(f.abs(req.From), f.abs(req.To))) - case *silkd.SessionCreate: + case *wire.SessionCreate: f.sessionCreate(conn, req) - case *silkd.SessionList: + case *wire.SessionList: f.sessionList(conn) - case *silkd.SessionRm: + case *wire.SessionRm: f.sessionRm(conn, req.ID) - case *silkd.FsPush: + case *wire.FsPush: f.fsPush(conn, r, req.Dest) - case *silkd.FsPull: + case *wire.FsPull: f.fsPull(conn, req.Path) - case *silkd.FsFind: + case *wire.FsFind: f.fsFind(conn, req) - case *silkd.FsReplace: + case *wire.FsReplace: f.fsReplace(conn, req) - case *silkd.FsWatch: + case *wire.FsWatch: f.fsWatch(conn, r, req) - case *silkd.GitPush, *silkd.GitPull: - send(conn, silkd.Done{}) - case *silkd.GitBranch: + case *wire.GitPush, *wire.GitPull: + send(conn, wire.Done{}) + case *wire.GitBranch: f.gitBranch(conn, req) - case *silkd.PtyOpen: + case *wire.PtyOpen: ptyEcho(conn, r) - case *silkd.PtyResize: - send(conn, silkd.Done{}) - case *silkd.PortForward: + case *wire.PtyResize: + send(conn, wire.Done{}) + case *wire.PortForward: portEcho(conn, r, req.Port) default: if !serveCommon(conn, r, req) { - errFrame(conn, silkd.KindUnimplemented, "silkdtest: "+req.Op()) + errFrame(conn, wire.KindUnimplemented, "silkdtest: "+req.Op()) } } } -func (f *Fake) fsWrite(conn net.Conn, r *bufio.Reader, req *silkd.FsWrite) { +func (f *Fake) fsWrite(conn net.Conn, r *bufio.Reader, req *wire.FsWrite) { data, err := drainUpload(r) if err != nil { - errFrame(conn, silkd.KindInternal, err.Error()) + errFrame(conn, wire.KindInternal, err.Error()) return } mode := os.FileMode(0o644) @@ -115,20 +115,20 @@ func (f *Fake) fsWrite(conn net.Conn, r *bufio.Reader, req *silkd.FsWrite) { func (f *Fake) fsRead(conn net.Conn, path string) { data, err := os.ReadFile(f.abs(path)) if err != nil { - errFrame(conn, silkd.KindNotFound, err.Error()) + errFrame(conn, wire.KindNotFound, err.Error()) return } - send(conn, &silkd.DataResp{Data: data}) - send(conn, silkd.Done{}) + send(conn, &wire.DataResp{Data: data}) + send(conn, wire.Done{}) } func (f *Fake) fsList(conn net.Conn, path string) { ents, err := os.ReadDir(f.abs(path)) if err != nil { - errFrame(conn, silkd.KindNotFound, err.Error()) + errFrame(conn, wire.KindNotFound, err.Error()) return } - var entries []silkd.DirEntry + var entries []wire.DirEntry for _, e := range ents { info, _ := e.Info() kind := "file" @@ -139,30 +139,30 @@ func (f *Fake) fsList(conn net.Conn, path string) { if info != nil { size = uint64(info.Size()) } - entries = append(entries, silkd.DirEntry{Name: e.Name(), Kind: kind, Size: size}) + entries = append(entries, wire.DirEntry{Name: e.Name(), Kind: kind, Size: size}) } - send(conn, &silkd.Entries{Entries: entries}) - send(conn, silkd.Done{}) + send(conn, &wire.Entries{Entries: entries}) + send(conn, wire.Done{}) } func (f *Fake) fsStat(conn net.Conn, path string) { info, err := os.Stat(f.abs(path)) if err != nil { - errFrame(conn, silkd.KindNotFound, err.Error()) + errFrame(conn, wire.KindNotFound, err.Error()) return } kind := "file" if info.IsDir() { kind = "dir" } - send(conn, &silkd.Stat{Info: silkd.FileInfo{ + send(conn, &wire.Stat{Info: wire.FileInfo{ Kind: kind, Size: uint64(info.Size()), Mode: uint32(info.Mode().Perm()), }}) } -func (f *Fake) fsRm(conn net.Conn, req *silkd.FsRm) { +func (f *Fake) fsRm(conn net.Conn, req *wire.FsRm) { if req.Recursive { f.done(conn, os.RemoveAll(f.abs(req.Path))) } else { @@ -176,7 +176,7 @@ func (f *Fake) fsRm(conn net.Conn, req *silkd.FsRm) { func (f *Fake) fsPush(conn net.Conn, r *bufio.Reader, dest string) { data, err := drainUpload(r) if err != nil { - errFrame(conn, silkd.KindInternal, err.Error()) + errFrame(conn, wire.KindInternal, err.Error()) return } root := f.abs(dest) @@ -187,7 +187,7 @@ func (f *Fake) fsPush(conn net.Conn, r *bufio.Reader, dest string) { break } if err != nil { - errFrame(conn, silkd.KindInternal, err.Error()) + errFrame(conn, wire.KindInternal, err.Error()) return } target := filepath.Join(root, filepath.Clean("/"+hdr.Name)) @@ -203,7 +203,7 @@ func (f *Fake) fsPush(conn net.Conn, r *bufio.Reader, dest string) { } } if err != nil { - errFrame(conn, silkd.KindInternal, err.Error()) + errFrame(conn, wire.KindInternal, err.Error()) return } } @@ -235,15 +235,15 @@ func (f *Fake) fsPull(conn net.Conn, path string) { return err }) if err != nil { - errFrame(conn, silkd.KindNotFound, err.Error()) + errFrame(conn, wire.KindNotFound, err.Error()) return } _ = tw.Close() - send(conn, silkd.DataResp{Data: buf.Bytes()}) - send(conn, silkd.Done{}) + send(conn, wire.DataResp{Data: buf.Bytes()}) + send(conn, wire.Done{}) } -func (f *Fake) sessionCreate(conn net.Conn, req *silkd.SessionCreate) { +func (f *Fake) sessionCreate(conn net.Conn, req *wire.SessionCreate) { f.mu.Lock() id := req.ID if id == "" { @@ -252,7 +252,7 @@ func (f *Fake) sessionCreate(conn net.Conn, req *silkd.SessionCreate) { } f.sessions[id] = true f.mu.Unlock() - send(conn, &silkd.SessionCreated{ID: id}) + send(conn, &wire.SessionCreated{ID: id}) } func (f *Fake) sessionList(conn net.Conn) { @@ -262,7 +262,7 @@ func (f *Fake) sessionList(conn net.Conn) { ids = append(ids, id) } f.mu.Unlock() - send(conn, &silkd.Sessions{Sessions: ids}) + send(conn, &wire.Sessions{Sessions: ids}) } func (f *Fake) sessionRm(conn net.Conn, id string) { @@ -271,16 +271,16 @@ func (f *Fake) sessionRm(conn net.Conn, id string) { delete(f.sessions, id) f.mu.Unlock() if !ok { - errFrame(conn, silkd.KindNotFound, "no such session") + errFrame(conn, wire.KindNotFound, "no such session") return } - send(conn, silkd.Done{}) + send(conn, wire.Done{}) } -func (f *Fake) fsFind(conn net.Conn, req *silkd.FsFind) { +func (f *Fake) fsFind(conn net.Conn, req *wire.FsFind) { re, err := regexp.Compile(req.Pattern) if err != nil { - errFrame(conn, silkd.KindBadRequest, err.Error()) + errFrame(conn, wire.KindBadRequest, err.Error()) return } var nameRe *regexp.Regexp @@ -300,7 +300,7 @@ func (f *Fake) fsFind(conn net.Conn, req *silkd.FsFind) { } for i, line := range strings.Split(strings.TrimSuffix(string(body), "\n"), "\n") { if re.MatchString(line) { - send(conn, &silkd.Match{ + send(conn, &wire.Match{ File: strings.TrimPrefix(path, f.Root), Line: uint64(i) + 1, Content: line, @@ -310,81 +310,81 @@ func (f *Fake) fsFind(conn net.Conn, req *silkd.FsFind) { return nil }) if walkErr != nil { - errFrame(conn, silkd.KindNotFound, walkErr.Error()) + errFrame(conn, wire.KindNotFound, walkErr.Error()) return } - send(conn, silkd.Done{}) + send(conn, wire.Done{}) } -func (f *Fake) fsReplace(conn net.Conn, req *silkd.FsReplace) { +func (f *Fake) fsReplace(conn net.Conn, req *wire.FsReplace) { re, err := regexp.Compile(req.Pattern) if err != nil { - errFrame(conn, silkd.KindBadRequest, err.Error()) + errFrame(conn, wire.KindBadRequest, err.Error()) return } for _, file := range req.Files { body, err := os.ReadFile(f.abs(file)) if err != nil { - errFrame(conn, silkd.KindNotFound, err.Error()) + errFrame(conn, wire.KindNotFound, err.Error()) return } count := len(re.FindAll(body, -1)) if count > 0 { if err := os.WriteFile(f.abs(file), re.ReplaceAll(body, []byte(req.Replacement)), 0o600); err != nil { - errFrame(conn, silkd.KindInternal, err.Error()) + errFrame(conn, wire.KindInternal, err.Error()) return } } - send(conn, &silkd.Replaced{File: file, Replacements: uint64(count)}) + send(conn, &wire.Replaced{File: file, Replacements: uint64(count)}) } - send(conn, silkd.Done{}) + send(conn, wire.Done{}) } // fsWatch fakes a watch: ready, one synthetic created event, then it holds // the stream open until the client disconnects — the connection-bound // contract. -func (f *Fake) fsWatch(conn net.Conn, r *bufio.Reader, req *silkd.FsWatch) { +func (f *Fake) fsWatch(conn net.Conn, r *bufio.Reader, req *wire.FsWatch) { if _, err := os.Stat(f.abs(req.Path)); err != nil { - errFrame(conn, silkd.KindNotFound, err.Error()) + errFrame(conn, wire.KindNotFound, err.Error()) return } - send(conn, silkd.Ready{}) - send(conn, &silkd.Event{Kind: "created", Path: req.Path + "/silkdtest-event"}) + send(conn, wire.Ready{}) + send(conn, &wire.Event{Kind: "created", Path: req.Path + "/silkdtest-event"}) _, _ = io.Copy(io.Discard, r) } -func (f *Fake) gitBranch(conn net.Conn, req *silkd.GitBranch) { +func (f *Fake) gitBranch(conn net.Conn, req *wire.GitBranch) { f.mu.Lock() defer f.mu.Unlock() switch req.Action { - case silkd.BranchList: - send(conn, &silkd.GitBranches{Current: f.current, Branches: slices.Clone(f.branches)}) - case silkd.BranchCreate: + case wire.BranchList: + send(conn, &wire.GitBranches{Current: f.current, Branches: slices.Clone(f.branches)}) + case wire.BranchCreate: if !slices.Contains(f.branches, req.Name) { f.branches = append(f.branches, req.Name) } - send(conn, silkd.Done{}) - case silkd.BranchDelete: + send(conn, wire.Done{}) + case wire.BranchDelete: f.branches = slices.DeleteFunc(f.branches, func(b string) bool { return b == req.Name }) - send(conn, silkd.Done{}) - case silkd.BranchCheckout: + send(conn, wire.Done{}) + case wire.BranchCheckout: if !slices.Contains(f.branches, req.Name) { - errFrame(conn, silkd.KindInternal, "no such branch "+req.Name) + errFrame(conn, wire.KindInternal, "no such branch "+req.Name) return } f.current = req.Name - send(conn, silkd.Done{}) + send(conn, wire.Done{}) default: - errFrame(conn, silkd.KindBadRequest, "unknown branch action "+req.Action) + errFrame(conn, wire.KindBadRequest, "unknown branch action "+req.Action) } } func (f *Fake) done(conn net.Conn, err error) { if err != nil { - errFrame(conn, silkd.KindInternal, err.Error()) + errFrame(conn, wire.KindInternal, err.Error()) return } - send(conn, silkd.Done{}) + send(conn, wire.Done{}) } // abs keeps a request path inside Root; an absolute request path is rebased. @@ -397,23 +397,23 @@ func (f *Fake) abs(path string) string { // DataEnd (→ Done, like the server closing after EOF) or disconnect. func portEcho(conn net.Conn, r *bufio.Reader, port uint16) { if port == 1 { - errFrame(conn, silkd.KindNotFound, "connect refused") + errFrame(conn, wire.KindNotFound, "connect refused") return } - send(conn, silkd.Ready{}) + send(conn, wire.Ready{}) for { req, err := recvRequest(r) if err != nil { return } switch req := req.(type) { - case *silkd.Data: - send(conn, &silkd.DataResp{Data: req.Data}) - case *silkd.DataEnd: - send(conn, silkd.Done{}) + case *wire.Data: + send(conn, &wire.DataResp{Data: req.Data}) + case *wire.DataEnd: + send(conn, wire.Done{}) return default: - errFrame(conn, silkd.KindBadRequest, "expected data or data_end") + errFrame(conn, wire.KindBadRequest, "expected data or data_end") return } } @@ -422,18 +422,18 @@ func portEcho(conn net.Conn, r *bufio.Reader, port uint16) { // ptyEcho fakes a pty: Started, then echo each stdin chunk as stdout until a // chunk contains "exit" (→ Exit 0) or the client disconnects. func ptyEcho(conn net.Conn, r *bufio.Reader) { - send(conn, &silkd.Started{PID: 9999}) + send(conn, &wire.Started{PID: 9999}) for { req, err := recvRequest(r) if err != nil { return } - if in, ok := req.(*silkd.Stdin); ok { + if in, ok := req.(*wire.Stdin); ok { if strings.Contains(string(in.Data), "exit") { - send(conn, &silkd.Exit{Code: 0}) + send(conn, &wire.Exit{Code: 0}) return } - send(conn, &silkd.Stdout{Data: in.Data}) + send(conn, &wire.Stdout{Data: in.Data}) } } } @@ -449,7 +449,7 @@ func globRegexp(glob string) *regexp.Regexp { } func errFrame(conn net.Conn, kind, msg string) { - send(conn, &silkd.ErrorResp{Kind: kind, Message: msg}) + send(conn, &wire.ErrorResp{Kind: kind, Message: msg}) } // drainUpload reads Data frames until DataEnd, concatenating their payloads. @@ -461,9 +461,9 @@ func drainUpload(r *bufio.Reader) ([]byte, error) { return nil, err } switch req := req.(type) { - case *silkd.Data: + case *wire.Data: out = append(out, req.Data...) - case *silkd.DataEnd: + case *wire.DataEnd: return out, nil default: return nil, os.ErrInvalid diff --git a/sdk/go/silkd/silkdtest/silkdtest.go b/sdk/go/silkd/silkdtest/silkdtest.go index aedfb71..e04094f 100644 --- a/sdk/go/silkd/silkdtest/silkdtest.go +++ b/sdk/go/silkd/silkdtest/silkdtest.go @@ -10,7 +10,7 @@ import ( "net" "strings" - "github.com/cocoonstack/sandbox/sdk/go/silkd" + "github.com/cocoonstack/sandbox/protocol/wire" ) // Serve accepts connections until l closes, speaking one RPC per connection @@ -69,17 +69,17 @@ func handle(conn net.Conn, r *bufio.Reader) { return } if !serveCommon(conn, r, req) { - errFrame(conn, silkd.KindUnimplemented, "silkdtest: "+req.Op()) + errFrame(conn, wire.KindUnimplemented, "silkdtest: "+req.Op()) } } // serveCommon handles the verbs shared by the stateless server and the Fake, // reporting whether it recognized the request. -func serveCommon(conn net.Conn, r *bufio.Reader, req silkd.Request) bool { +func serveCommon(conn net.Conn, r *bufio.Reader, req wire.Request) bool { switch req := req.(type) { - case *silkd.Info: - send(conn, &silkd.InfoResp{Version: "silkdtest", Proto: silkd.ProtoVersion}) - case *silkd.Exec: + case *wire.Info: + send(conn, &wire.InfoResp{Version: "silkdtest", Proto: wire.ProtoVersion}) + case *wire.Exec: serveExec(conn, r, req) default: return false @@ -97,16 +97,16 @@ func acceptLoop(l net.Listener, serve func(net.Conn)) { } } -func serveExec(conn net.Conn, r *bufio.Reader, req *silkd.Exec) { +func serveExec(conn net.Conn, r *bufio.Reader, req *wire.Exec) { if len(req.Argv) == 0 { - errFrame(conn, silkd.KindBadRequest, "empty argv") + errFrame(conn, wire.KindBadRequest, "empty argv") return } - send(conn, &silkd.Started{PID: 4242}) + send(conn, &wire.Started{PID: 4242}) switch req.Argv[0] { case "echo": - send(conn, &silkd.Stdout{Data: []byte(strings.Join(req.Argv[1:], " ") + "\n")}) - send(conn, &silkd.Exit{Code: 0}) + send(conn, &wire.Stdout{Data: []byte(strings.Join(req.Argv[1:], " ") + "\n")}) + send(conn, &wire.Exit{Code: 0}) case "cat": for { in, err := recvRequest(r) @@ -114,35 +114,35 @@ func serveExec(conn net.Conn, r *bufio.Reader, req *silkd.Exec) { return } switch in := in.(type) { - case *silkd.Stdin: - send(conn, &silkd.Stdout{Data: in.Data}) - case *silkd.StdinClose: - send(conn, &silkd.Exit{Code: 0}) + case *wire.Stdin: + send(conn, &wire.Stdout{Data: in.Data}) + case *wire.StdinClose: + send(conn, &wire.Exit{Code: 0}) return default: - errFrame(conn, silkd.KindBadRequest, "expected stdin") + errFrame(conn, wire.KindBadRequest, "expected stdin") return } } case "false": - send(conn, &silkd.Exit{Code: 1}) + send(conn, &wire.Exit{Code: 1}) case "sleep": _, _ = io.Copy(io.Discard, r) // hold the RPC open until disconnect default: - errFrame(conn, silkd.KindNotFound, "silkdtest: no such command "+req.Argv[0]) + errFrame(conn, wire.KindNotFound, "silkdtest: no such command "+req.Argv[0]) } } -func recvRequest(r *bufio.Reader) (silkd.Request, error) { +func recvRequest(r *bufio.Reader) (wire.Request, error) { line, err := r.ReadString('\n') if err != nil { return nil, err } - return silkd.DecodeRequest([]byte(line)) + return wire.DecodeRequest([]byte(line)) } -func send(conn net.Conn, resp silkd.Response) { - frame, err := silkd.EncodeResponse(resp) +func send(conn net.Conn, resp wire.Response) { + frame, err := wire.EncodeResponse(resp) if err != nil { return } diff --git a/sdk/go/tree.go b/sdk/go/tree.go index 3e5eee7..a0efd44 100644 --- a/sdk/go/tree.go +++ b/sdk/go/tree.go @@ -4,19 +4,19 @@ import ( "context" "io" - "github.com/cocoonstack/sandbox/sdk/go/silkd" + "github.com/cocoonstack/sandbox/protocol/wire" ) // Push extracts a tar stream under dest in the sandbox — the no-network lane's // way to move a project in. tarStream is a reader of tar bytes (e.g. from // archive/tar); silkd runs `tar -x` under dest. func (s *Sandbox) Push(ctx context.Context, dest string, tarStream io.Reader) error { - return s.uploadRPC(ctx, &silkd.FsPush{Dest: dest}, tarStream) + return s.uploadRPC(ctx, &wire.FsPush{Dest: dest}, tarStream) } // Pull streams the tree at path back as a tar archive, written to tarStream. func (s *Sandbox) Pull(ctx context.Context, path string, tarStream io.Writer) error { - return s.downloadRPC(ctx, &silkd.FsPull{Path: path}, func(b []byte) error { + return s.downloadRPC(ctx, &wire.FsPull{Path: path}, func(b []byte) error { _, err := tarStream.Write(b) return err }) diff --git a/sdk/go/watch.go b/sdk/go/watch.go index 51bbfa6..dc230f8 100644 --- a/sdk/go/watch.go +++ b/sdk/go/watch.go @@ -4,6 +4,7 @@ import ( "context" "sync" + "github.com/cocoonstack/sandbox/protocol/wire" "github.com/cocoonstack/sandbox/sdk/go/silkd" ) @@ -11,7 +12,7 @@ import ( // ends — by Close, ctx cancellation, or a watcher error on the sandbox side — // after which Err reports the terminal error (nil after a clean Close). type Watcher struct { - events chan silkd.Event + events chan wire.Event stop func() closed chan struct{} once sync.Once @@ -21,7 +22,7 @@ type Watcher struct { } // Events yields filesystem events; the channel closes when the watch ends. -func (w *Watcher) Events() <-chan silkd.Event { return w.events } +func (w *Watcher) Events() <-chan wire.Event { return w.events } // Close ends the watch (the sandbox side stops on disconnect). func (w *Watcher) Close() error { @@ -54,13 +55,13 @@ func (w *Watcher) drain(ctx context.Context, conn *silkd.Conn) { return } switch r := resp.(type) { - case *silkd.Event: + case *wire.Event: select { case w.events <- *r: case <-w.closed: return } - case *silkd.ErrorResp: + case *wire.ErrorResp: w.setErr(r) return default: @@ -80,16 +81,16 @@ func (w *Watcher) setErr(err error) { // returns once the sandbox acknowledges the watch is armed, so events caused // after Watch returns are guaranteed captured. func (s *Sandbox) Watch(ctx context.Context, path string, recursive bool) (*Watcher, error) { - conn, done, err := s.call(ctx, &silkd.FsWatch{Path: path, Recursive: recursive}) + conn, done, err := s.call(ctx, &wire.FsWatch{Path: path, Recursive: recursive}) if err != nil { return nil, err } - if _, err = expect[silkd.Ready](ctx, conn); err != nil { + if _, err = expect[wire.Ready](ctx, conn); err != nil { done() return nil, err } w := &Watcher{ - events: make(chan silkd.Event, 16), + events: make(chan wire.Event, 16), stop: done, closed: make(chan struct{}), } diff --git a/sdk/go/watch_test.go b/sdk/go/watch_test.go index ae4425f..ed229d7 100644 --- a/sdk/go/watch_test.go +++ b/sdk/go/watch_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/cocoonstack/sandbox/sdk/go/silkd" + "github.com/cocoonstack/sandbox/protocol/wire" ) func TestWatchDeliversEventsUntilClose(t *testing.T) { @@ -40,8 +40,8 @@ func TestWatchDeliversEventsUntilClose(t *testing.T) { func TestWatchMissingPathFailsSynchronously(t *testing.T) { sb := fakeSandbox(t) _, err := sb.Watch(t.Context(), "/nope", false) - var e *silkd.ErrorResp - if !errors.As(err, &e) || e.Kind != silkd.KindNotFound { + var e *wire.ErrorResp + if !errors.As(err, &e) || e.Kind != wire.KindNotFound { t.Errorf("Watch = %v, want synchronous not_found (no ready frame)", err) } } diff --git a/sdk/python/cocoonsandbox/frames.py b/sdk/python/cocoonsandbox/frames.py index 74314d2..a17993a 100644 --- a/sdk/python/cocoonsandbox/frames.py +++ b/sdk/python/cocoonsandbox/frames.py @@ -1,6 +1,6 @@ """silkd wire frames: newline-delimited JSON, requests tagged by "op", responses by "type", binary payloads base64 in "data" fields. Mirrors the Go -and Rust implementations; all three round-trip protocol/fixtures/v1.""" +and Rust implementations; all three round-trip protocol/wire/fixtures/v1.""" from __future__ import annotations diff --git a/sdk/python/tests/test_fixtures.py b/sdk/python/tests/test_fixtures.py index 8007fec..4e9fd9b 100644 --- a/sdk/python/tests/test_fixtures.py +++ b/sdk/python/tests/test_fixtures.py @@ -10,7 +10,7 @@ from cocoonsandbox import frames -FIXTURES = pathlib.Path(__file__).parent / ".." / ".." / ".." / "protocol" / "fixtures" / "v1" +FIXTURES = pathlib.Path(__file__).parent / ".." / ".." / ".." / "protocol" / "wire" / "fixtures" / "v1" def fixture_files(prefix): diff --git a/sdk/python/tests/test_wire_binding.py b/sdk/python/tests/test_wire_binding.py index acb31b4..f08ca9d 100644 --- a/sdk/python/tests/test_wire_binding.py +++ b/sdk/python/tests/test_wire_binding.py @@ -16,7 +16,7 @@ from cocoonsandbox.conn import Conn from cocoonsandbox.frames import PROTO_VERSION -FIXTURES = pathlib.Path(__file__).parent / ".." / ".." / ".." / "protocol" / "fixtures" / "v1" +FIXTURES = pathlib.Path(__file__).parent / ".." / ".." / ".." / "protocol" / "wire" / "fixtures" / "v1" # op → (fixture stem, replies the fake guest scripts, invoke(sb, fixture)). # Invocations pass the fixture's own values so the subset assertion binds diff --git a/silkd/README.md b/silkd/README.md index e94b0f1..0e1edcc 100644 --- a/silkd/README.md +++ b/silkd/README.md @@ -11,7 +11,7 @@ guest port relay (`port_forward`), and an LSP broker that spawns the language server a flavor image ships under `/etc/silkd/lsp.d/`. - `src/proto.rs` — frame types + caps; the golden corpus in - `../protocol/fixtures/` is round-tripped by the Rust, Go, and Python + `../protocol/wire/fixtures/` is round-tripped by the Rust, Go, and Python sides, so wire drift fails CI - `src/server.rs` — dispatch; one module per verb family - `tests/` — e2e suites driving the daemon in-process diff --git a/silkd/src/proto.rs b/silkd/src/proto.rs index e15e71d..98ea6a4 100644 --- a/silkd/src/proto.rs +++ b/silkd/src/proto.rs @@ -593,7 +593,7 @@ mod b64 { mod tests { use super::*; - const FIXTURES_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../protocol/fixtures/v1"); + const FIXTURES_DIR: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../protocol/wire/fixtures/v1"); #[test] fn request_roundtrip_and_unknown_fields_ignored() {