From fc1f033bb8d0f9c27503389e00ef1a361e3d2868 Mon Sep 17 00:00:00 2001 From: CMGS Date: Thu, 23 Jul 2026 16:02:32 +0800 Subject: [PATCH 1/6] review: comment budget pass Delete restated and section-label comments; tighten a WHY comment to its essential constraint without losing it. --- mcp/tools.go | 1 - sdk/python/cocoonsandbox/frames.py | 10 ++++------ silkd/tests/fs_e2e.rs | 3 --- silkd/tests/pty_e2e.rs | 1 - 4 files changed, 4 insertions(+), 11 deletions(-) diff --git a/mcp/tools.go b/mcp/tools.go index 8d78e3e..e4f4c97 100644 --- a/mcp/tools.go +++ b/mcp/tools.go @@ -83,7 +83,6 @@ type tool struct { handler func(context.Context, *server, json.RawMessage) (string, error) } -// toolSpecs renders the tools/list payload from the single table. func toolSpecs() []map[string]any { specs := make([]map[string]any, len(tools)) for i, t := range tools { diff --git a/sdk/python/cocoonsandbox/frames.py b/sdk/python/cocoonsandbox/frames.py index a17993a..2ecb7ca 100644 --- a/sdk/python/cocoonsandbox/frames.py +++ b/sdk/python/cocoonsandbox/frames.py @@ -32,12 +32,10 @@ def encode_request(op: str, **fields) -> bytes: def decode_response(line: bytes) -> dict: """Parses one response frame; the returned dict carries its tag under "type" and any binary payload decoded under "data".""" - # Bulk frames (stdout/stderr/data) are the throughput path where json.loads - # of the big base64 string dominates. base64 is JSON-escape-free and these - # frames carry exactly {"type":...,"data":...}, so slice both out and skip - # json — but only for that exact shape; anything else (extra fields, - # trailing bytes, non-alphabet bytes in the segment) gets the full parse - # instead of a silently wrong slice. + # base64 is JSON-escape-free, so a frame shaped exactly {"type":..., + # "data":...} can be sliced directly, skipping json.loads; any other + # shape (extra fields, trailing bytes, non-alphabet bytes) falls through + # to the full parse instead of risking a silently wrong slice. if line.startswith(b'{"type":"'): te = line.find(b'"', 9) if te > 0 and line[9:te] in (b"stdout", b"stderr", b"data") and line.startswith(b'","data":"', te): diff --git a/silkd/tests/fs_e2e.rs b/silkd/tests/fs_e2e.rs index 4e24823..7b12e2c 100644 --- a/silkd/tests/fs_e2e.rs +++ b/silkd/tests/fs_e2e.rs @@ -192,17 +192,14 @@ async fn overwrite_preserves_destination_mode() { #[tokio::test] async fn rm_plain_file_and_missing_and_nonempty_dir() { let dir = tempfile::tempdir().unwrap(); - // plain file let f = dir.path().join("f"); std::fs::write(&f, b"x").unwrap(); let r = exchange(&[json!({"op":"fs_rm","path":f.to_str().unwrap()}).to_string()]).await; assert_eq!(type_of(&r[0]), "done"); assert!(!f.exists()); - // missing path → not_found let m = exchange(&[json!({"op":"fs_rm","path":"/no/such/xyz"}).to_string()]).await; assert_eq!(type_of(&m[0]), "error"); assert_eq!(m[0]["kind"], "not_found"); - // non-recursive rm of a non-empty dir → error (not not_found) let sub = dir.path().join("d"); std::fs::create_dir(&sub).unwrap(); std::fs::write(sub.join("inner"), b"y").unwrap(); diff --git a/silkd/tests/pty_e2e.rs b/silkd/tests/pty_e2e.rs index 1c63552..c805250 100644 --- a/silkd/tests/pty_e2e.rs +++ b/silkd/tests/pty_e2e.rs @@ -55,7 +55,6 @@ async fn pty_runs_a_shell_and_echoes() { .await; assert_eq!(out["type"], "stdout"); - // Exit the shell; the stream ends with an exit frame. send(&mut cw, json!({"op":"stdin","data":common::b64(b"exit\n")})).await; let exit = read_until(&mut lines, |v| v["type"] == "exit").await; assert!(exit["code"].is_number()); From db7810b296c5ffe142554836bca8d60cc6a9ba43 Mon Sep 17 00:00:00 2001 From: CMGS Date: Thu, 23 Jul 2026 16:02:39 +0800 Subject: [PATCH 2/6] review: declaration layout and modern idioms Move Marks and guestPortConn.Write next to their type blocks so each type's method set stays contiguous; swap sort.Slice for slices.SortFunc+cmp.Compare, Printf with no verbs for Println, and add a return type hint to main(). --- boot/init/src/boot.rs | 52 ++++++++++++++++++------------------ e2e/cmd/egresssmoke/main.go | 2 +- mcp/e2e.py | 2 +- sandboxd/engine/portconn.go | 32 +++++++++++----------- sandboxd/store/s3/s3_test.go | 6 ++--- 5 files changed, 47 insertions(+), 47 deletions(-) diff --git a/boot/init/src/boot.rs b/boot/init/src/boot.rs index fbb38e5..ec52029 100644 --- a/boot/init/src/boot.rs +++ b/boot/init/src/boot.rs @@ -15,6 +15,32 @@ const POLL_INTERVAL: Duration = Duration::from_millis(2); // fallback, not stall rootfs handoff for the full disk budget (10s). const NIC_TIMEOUT: Duration = Duration::from_millis(200); +/// Cumulative µs checkpoints since sandbox-init start. +struct Marks { + start: Instant, + points: Vec<(&'static str, u128)>, +} + +impl Marks { + fn new() -> Self { + Marks { + start: Instant::now(), + points: Vec::new(), + } + } + + fn mark(&mut self, label: &'static str) { + self.points.push((label, self.start.elapsed().as_micros())); + } + + fn render(&self) -> String { + self.points + .iter() + .map(|(label, us)| format!(" {label}@{us}us")) + .collect() + } +} + pub fn run() -> ! { // Best-effort: if devtmpfs fails there is no console either; later // failures then power off silently, which is still the right end state. @@ -59,32 +85,6 @@ pub fn run() -> ! { sys::fatal(&format!("exec {}: {err}", cfg.init), cfg.debug) } -/// Cumulative µs checkpoints since sandbox-init start. -struct Marks { - start: Instant, - points: Vec<(&'static str, u128)>, -} - -impl Marks { - fn new() -> Self { - Marks { - start: Instant::now(), - points: Vec::new(), - } - } - - fn mark(&mut self, label: &'static str) { - self.points.push((label, self.start.elapsed().as_micros())); - } - - fn render(&self) -> String { - self.points - .iter() - .map(|(label, us)| format!(" {label}@{us}us")) - .collect() - } -} - fn assemble(cfg: &BootCfg, marks: &mut Marks) -> Result<(), String> { let ids: Vec<&str> = cfg .layers diff --git a/e2e/cmd/egresssmoke/main.go b/e2e/cmd/egresssmoke/main.go index e771e36..e62af4b 100644 --- a/e2e/cmd/egresssmoke/main.go +++ b/e2e/cmd/egresssmoke/main.go @@ -103,7 +103,7 @@ func run(addr, token, template, wantToken, netShape, reach, nicAddr, echo string if !strings.Contains(seen, wantToken) { return fmt.Errorf("origin %s did not echo injected token %q: %q", echo, wantToken, strings.TrimSpace(seen)) } - fmt.Printf(" allowed origin reached; injected credential observed host-side\n") + fmt.Println(" allowed origin reached; injected credential observed host-side") if out, _ := sb.Exec(ctx, "sh", "-c", "env; cat /proc/1/environ 2>/dev/null | tr '\\0' '\\n'"); wantToken != "" && strings.Contains(out, wantToken) { return fmt.Errorf("secret value leaked into the guest") diff --git a/mcp/e2e.py b/mcp/e2e.py index 2b869a3..24d7d6c 100644 --- a/mcp/e2e.py +++ b/mcp/e2e.py @@ -44,7 +44,7 @@ def close(self): self.proc.wait(timeout=10) -def main(): +def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--bin", required=True) parser.add_argument("--addr", default="127.0.0.1:7777") diff --git a/sandboxd/engine/portconn.go b/sandboxd/engine/portconn.go index dd920d7..a6a3544 100644 --- a/sandboxd/engine/portconn.go +++ b/sandboxd/engine/portconn.go @@ -70,6 +70,22 @@ func (g *guestPortConn) Read(p []byte) (int, error) { return n, nil } +func (g *guestPortConn) Write(p []byte) (int, error) { + written := 0 + for len(p) > 0 { + n := min(len(p), portWriteChunk) + // 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 + } + p = p[n:] + written += n + } + return written, nil +} + // fastPortData slices the canonical data frame's base64 out without a JSON // parse — json.Unmarshal otherwise dominates the download relay, and the // SDK's fastBulk sets the contract: only the exact canonical shape takes the @@ -90,19 +106,3 @@ func fastPortData(line []byte) ([]byte, bool) { } return out[:n], true } - -func (g *guestPortConn) Write(p []byte) (int, error) { - written := 0 - for len(p) > 0 { - n := min(len(p), portWriteChunk) - // 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 - } - p = p[n:] - written += n - } - return written, nil -} diff --git a/sandboxd/store/s3/s3_test.go b/sandboxd/store/s3/s3_test.go index 004ea1c..eda187f 100644 --- a/sandboxd/store/s3/s3_test.go +++ b/sandboxd/store/s3/s3_test.go @@ -9,7 +9,7 @@ import ( "net/http/httptest" "os" "path/filepath" - "sort" + "slices" "strconv" "strings" "sync" @@ -67,8 +67,8 @@ func (f *fakeS3) ServeHTTP(w http.ResponseWriter, r *http.Request) { } result.Contents = append(result.Contents, object{Key: k, Size: len(v)}) } - sort.Slice(result.Contents, func(i, j int) bool { return result.Contents[i].Key < result.Contents[j].Key }) - sort.Slice(result.CommonPrefixes, func(i, j int) bool { return result.CommonPrefixes[i].Prefix < result.CommonPrefixes[j].Prefix }) + slices.SortFunc(result.Contents, func(a, b object) int { return cmp.Compare(a.Key, b.Key) }) + slices.SortFunc(result.CommonPrefixes, func(a, b commonPrefix) int { return cmp.Compare(a.Prefix, b.Prefix) }) w.Header().Set("Content-Type", "application/xml") _ = xml.NewEncoder(w).Encode(result) case r.Method == http.MethodHead: From ac80352ff9b7ae5c989a0817379c51e2cd012ff3 Mon Sep 17 00:00:00 2001 From: CMGS Date: Thu, 23 Jul 2026 16:24:39 +0800 Subject: [PATCH 3/6] review: loc-justify cuts Drop the epochMu/epochWritten dedup guard in persistEpoch: the only call sites (New, and UpdateSelf under updateMu held for the whole method) already serialize writes with a strictly increasing epoch, so the guard never rejects a write in practice. persistEpoch now just delegates to storeEpoch. Remove TestPersistEpochMonotonic, which existed only to exercise the removed guard by calling the unexported method out of the normal locking order. --- sandboxd/mesh/mesh.go | 17 ++--------------- sandboxd/mesh/state_test.go | 23 ----------------------- 2 files changed, 2 insertions(+), 38 deletions(-) diff --git a/sandboxd/mesh/mesh.go b/sandboxd/mesh/mesh.go index bca85f0..b6c028d 100644 --- a/sandboxd/mesh/mesh.go +++ b/sandboxd/mesh/mesh.go @@ -48,9 +48,6 @@ type Mesh struct { mu sync.Mutex self NodeState view map[string]NodeState // node_id → latest known state (includes self) - - epochMu sync.Mutex - epochWritten uint64 // highest epoch on disk; guarded by epochMu } // New starts a mesh member listening per cfg. selfAddr is the data-plane @@ -245,19 +242,9 @@ func (m *Mesh) Shutdown() error { return m.ml.Shutdown() } -// persistEpoch durably records the epoch, serialized so a slower concurrent -// UpdateSelf's lower value cannot overwrite a higher one already on disk. +// persistEpoch durably records the epoch. func (m *Mesh) persistEpoch(epoch uint64) error { - m.epochMu.Lock() - defer m.epochMu.Unlock() - if epoch <= m.epochWritten { - return nil - } - if err := storeEpoch(m.epochPath, epoch); err != nil { - return err - } - m.epochWritten = epoch - return nil + return storeEpoch(m.epochPath, epoch) } // forget drops a departed node from the placement view so redirects stop diff --git a/sandboxd/mesh/state_test.go b/sandboxd/mesh/state_test.go index 3058471..44eba90 100644 --- a/sandboxd/mesh/state_test.go +++ b/sandboxd/mesh/state_test.go @@ -84,29 +84,6 @@ func TestUpdateSelfPersistsEpoch(t *testing.T) { } } -func TestPersistEpochMonotonic(t *testing.T) { - dir := t.TempDir() - m := newBoundMesh(t, dir) - base := m.self.Epoch // New already seeded a wall-clock floor - high := base + 1000 - if err := m.persistEpoch(high); err != nil { - t.Fatalf("persist high: %v", err) - } - // Concurrent lower values must never regress the on-disk floor below high. - var wg sync.WaitGroup - for e := base + 1; e < high; e++ { - wg.Go(func() { - if err := m.persistEpoch(e); err != nil { - t.Errorf("persist %d: %v", e, err) - } - }) - } - wg.Wait() - if got := loadEpoch(filepath.Join(dir, "mesh-epoch")); got != high { - t.Errorf("persisted epoch %d, want the %d floor held against lower concurrent writes", got, high) - } -} - func TestUpdateSelfConcurrentDropsNothing(t *testing.T) { m := newBoundMesh(t, t.TempDir()) base := m.self.Epoch From 33a48cf3bd1c7c2ff429e903c5ddfe1fa33a52e2 Mon Sep 17 00:00:00 2001 From: CMGS Date: Thu, 23 Jul 2026 17:13:36 +0800 Subject: [PATCH 4/6] fix(preview): mint a live token when a claim has no deadline handlePreview clamped the requested TTL against time.Until(deadline) unconditionally; for a claim with a zero deadline (e.g. an archived sandbox with ArchiveDeleteAfterSeconds=0) that clamp saturated to a deeply negative duration, so the minted token was already expired and every preview request 403'd on the first GET. Apply the lease ceiling only when the claim has one, and fall back to a default TTL otherwise. --- sandboxd/server/preview.go | 9 ++++++-- sandboxd/server/server.go | 1 + sandboxd/server/server_test.go | 39 ++++++++++++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/sandboxd/server/preview.go b/sandboxd/server/preview.go index 7b8ae85..45cf61a 100644 --- a/sandboxd/server/preview.go +++ b/sandboxd/server/preview.go @@ -190,8 +190,13 @@ func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) { return } ttl := time.Duration(req.TTLSeconds) * time.Second - if lease := time.Until(deadline); ttl <= 0 || ttl > lease { - ttl = lease // never outlive the claim + switch { + case !deadline.IsZero(): + if lease := time.Until(deadline); ttl <= 0 || ttl > lease { + ttl = lease // never outlive the claim + } + case ttl <= 0: + ttl = previewTTL } writeJSON(w, http.StatusOK, types.PreviewResponse{URL: s.preview.Mint(id, req.Port, ttl)}) } diff --git a/sandboxd/server/server.go b/sandboxd/server/server.go index 2006144..6c8c0f1 100644 --- a/sandboxd/server/server.go +++ b/sandboxd/server/server.go @@ -27,6 +27,7 @@ import ( const ( upgradeProto = "silkd" maxBodyBytes = 1 << 20 + previewTTL = time.Hour ) // poolErrHTTP maps pool sentinels to their HTTP replies; an empty msg diff --git a/sandboxd/server/server_test.go b/sandboxd/server/server_test.go index 3ad1266..41c696c 100644 --- a/sandboxd/server/server_test.go +++ b/sandboxd/server/server_test.go @@ -822,6 +822,41 @@ func TestOwnerEndpoint(t *testing.T) { } } +// TestPreviewHandlerZeroDeadlineMintsLiveToken covers a claim with no +// deadline (e.g. archived with ArchiveDeleteAfterSeconds=0): the minted +// preview token must still verify, not be stamped already-expired. +func TestPreviewHandlerZeroDeadlineMintsLiveToken(t *testing.T) { + mgr := &fakeManager{claimDeadline: func(string, string) (time.Time, error) { + return time.Time{}, nil + }} + ps := NewPreviewServer("secret", "node:7777", &fakePreviewMgr{}) + srv := New("", nil, "node:7777", mgr, &fakeDialer{}, nil, ps) + ts := httptest.NewServer(srv.Handler()) + t.Cleanup(func() { ts.Close(); srv.CloseRelays() }) + + resp, err := http.Post(ts.URL+"/v1/sandboxes/sb_1/preview", "application/json", + strings.NewReader(`{"token":"tok","port":8080,"ttl_seconds":300}`)) + if err != nil { + t.Fatalf("preview: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status %d, want 200", resp.StatusCode) + } + var out types.PreviewResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatalf("decode: %v", err) + } + token := strings.TrimSuffix(out.URL[strings.Index(out.URL, "/p/")+3:], "/") + claims, ok := ps.verify(token) + if !ok { + t.Fatalf("minted token %q does not verify", token) + } + if claims.Exp <= time.Now().Unix() { + t.Errorf("exp %d, want in the future", claims.Exp) + } +} + func newTestServer(t *testing.T, apiToken string, mgr Manager, dialer Dialer) *httptest.Server { t.Helper() return newTenantTestServer(t, apiToken, nil, mgr, dialer) @@ -863,6 +898,7 @@ type fakeManager struct { deleteCheckpoint func(ckptID string) error setPools func(pools []config.PoolSpec) error infoPools []pool.PoolInfo + claimDeadline func(id, token string) (time.Time, error) gotTenant string tenantClaims map[string]int @@ -938,6 +974,9 @@ func (f *fakeManager) HasGolden(context.Context, types.PoolKey) bool { } func (f *fakeManager) ClaimDeadline(id, token string) (time.Time, error) { + if f.claimDeadline != nil { + return f.claimDeadline(id, token) + } if f.socket == nil { return time.Now().Add(time.Hour), nil } From 0aabb8c1baad8d79ddff4f49fd45a62801affa95 Mon Sep 17 00:00:00 2001 From: CMGS Date: Thu, 23 Jul 2026 17:13:43 +0800 Subject: [PATCH 5/6] fix(silkd): reject detach combined with session The session exec path has no Started frame to answer a detached request: Session::run only ever emits stdout chunks and a terminal Exit/Internal frame, so detach=true with session set silently ran in the foreground instead of detaching, breaking any SDK caller (Go, Python) that sets both. Reject the combination up front with the same bad_request validation already used for an empty argv. --- docs/silkd.md | 2 +- silkd/src/server.rs | 8 ++++++++ silkd/tests/session_e2e.rs | 15 +++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) diff --git a/docs/silkd.md b/docs/silkd.md index 201ad7c..035adf6 100644 --- a/docs/silkd.md +++ b/docs/silkd.md @@ -30,7 +30,7 @@ a frame only one side can parse fails CI. | group | ops | response flow | |---|---|---| -| exec | `exec {argv, cwd?, env?, user?, detach?, session?}` | `started{pid}` → `stdout/stderr{data}`… → `exit{code}`; the client may stream `stdin{data}` / `stdin_close`. `detach` returns after `started`; the process keeps a bounded output ring for later `logs`/`attach` | +| exec | `exec {argv, cwd?, env?, user?, detach?, session?}` | `started{pid}` → `stdout/stderr{data}`… → `exit{code}`; the client may stream `stdin{data}` / `stdin_close`. `detach` returns after `started`; the process keeps a bounded output ring for later `logs`/`attach`; not combinable with `session` | | procs | `ps` / `kill {pid, signal?}` / `attach {pid}` / `logs {pid}` | handles are guest pids; any connection can list, signal, replay, or re-attach live | | sessions | `session_create {id?, cwd?, env?}` / `session_list` / `session_rm {id}` | a session is a real persistent bash; `exec` with `session` runs inside it. Idle sessions are reaped after 30 minutes | | fs | `fs_write {path, mode?}` (+`data`/`data_end` frames) / `fs_read` / `fs_list` / `fs_stat` / `fs_mkdir {parents?}` / `fs_rm {recursive?}` / `fs_rename {from, to}` | streaming both directions; write commits atomically via temp+rename and inherits an overwritten file's mode; `fs_list` streams 4096-entry batches | diff --git a/silkd/src/server.rs b/silkd/src/server.rs index c7288e2..eeb1ad2 100644 --- a/silkd/src/server.rs +++ b/silkd/src/server.rs @@ -67,6 +67,14 @@ impl State { ) .await; } + if e.detach && e.session.is_some() { + return proto::error_frame( + &mut writer, + ErrorKind::BadRequest, + "detach is not supported with session", + ) + .await; + } if let Some(sid) = e.session.as_deref() { let Some(sess) = self.sessions.get(sid) else { return proto::error_frame( diff --git a/silkd/tests/session_e2e.rs b/silkd/tests/session_e2e.rs index e43f1cc..b16b401 100644 --- a/silkd/tests/session_e2e.rs +++ b/silkd/tests/session_e2e.rs @@ -245,6 +245,21 @@ async fn empty_argv_in_session_is_a_bad_request() { assert_eq!(f[0]["kind"], "bad_request"); } +#[tokio::test] +async fn detach_with_session_is_a_bad_request() { + // silkd's session path has no Started frame to answer a detached exec, so + // the combination must be rejected up front rather than silently run. + let state = Arc::new(State::new()); + let id = create(&state, json!({})).await; + let f = one( + &state, + &json!({"op":"exec","argv":["true"],"session":id,"detach":true}).to_string(), + ) + .await; + assert_eq!(type_of(&f[0]), "error"); + assert_eq!(f[0]["kind"], "bad_request"); +} + #[tokio::test] async fn exec_in_unknown_session_is_not_found() { let state = Arc::new(State::new()); From 05bea8afc8ba9b5de6909d1a2052998d05dd70e5 Mon Sep 17 00:00:00 2001 From: CMGS Date: Thu, 23 Jul 2026 18:01:44 +0800 Subject: [PATCH 6/6] fix(sdk): reject session in Spawn client-side Guests running older silkd hang on detach+session instead of returning the BadRequest newer silkd responds with; failing fast in the SDK covers snapshot images that have not picked up the silkd fix. The Python spawn drops its never-usable session parameter for the same reason. --- sdk/go/proc.go | 8 ++++++-- sdk/go/proc_test.go | 4 ++++ sdk/python/cocoonsandbox/sandbox.py | 5 ++--- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/sdk/go/proc.go b/sdk/go/proc.go index de7513d..28610d5 100644 --- a/sdk/go/proc.go +++ b/sdk/go/proc.go @@ -11,12 +11,16 @@ import ( // Spawn starts cmd detached: it returns the guest pid as soon as the // process starts, and the process keeps running with a bounded output ring -// readable later via Logs or Attach. cmd's Stdin/Stdout/Stderr are ignored. +// readable later via Logs or Attach. cmd's Stdin/Stdout/Stderr are ignored; +// cmd.Session must be empty — the session exec path cannot detach. func (s *Sandbox) Spawn(ctx context.Context, cmd Cmd) (uint32, error) { if len(cmd.Argv) == 0 { return 0, fmt.Errorf("empty argv") } - req := &wire.Exec{Argv: cmd.Argv, Cwd: cmd.Cwd, Env: cmd.Env, User: cmd.User, Detach: true, Session: cmd.Session} + if cmd.Session != "" { + return 0, fmt.Errorf("spawn does not support session") + } + req := &wire.Exec{Argv: cmd.Argv, Cwd: cmd.Cwd, Env: cmd.Env, User: cmd.User, Detach: true} started, err := oneShotRPC[wire.Started](ctx, s, req) if err != nil { return 0, err diff --git a/sdk/go/proc_test.go b/sdk/go/proc_test.go index 8812c42..ee6280e 100644 --- a/sdk/go/proc_test.go +++ b/sdk/go/proc_test.go @@ -32,6 +32,10 @@ func TestProcVerbs(t *testing.T) { t.Fatalf("Spawn: pid %d, %v", pid, err) } + if _, err = sb.Spawn(ctx, Cmd{Argv: []string{"true"}, Session: "s1"}); err == nil || !strings.Contains(err.Error(), "session") { + t.Fatalf("Spawn with session: want session error, got %v", err) + } + procs, err := sb.Ps(ctx) if err != nil || len(procs) != 1 || procs[0].PID != 41 || !procs[0].Detached || procs[0].State != "running" { t.Fatalf("Ps: %+v, %v", procs, err) diff --git a/sdk/python/cocoonsandbox/sandbox.py b/sdk/python/cocoonsandbox/sandbox.py index abb5488..f646aa0 100644 --- a/sdk/python/cocoonsandbox/sandbox.py +++ b/sdk/python/cocoonsandbox/sandbox.py @@ -67,12 +67,11 @@ def run(self, argv: list[str], cwd: str = "", env: dict | None = None, return code def spawn(self, *argv: str, cwd: str = "", env: dict | None = None, - user: str = "", session: str = "") -> int: + user: str = "") -> int: """Starts argv detached, returning its pid immediately; the process keeps a bounded output ring readable later via logs()/attach().""" started = self._call("exec", "started", argv=list(argv), cwd=cwd or None, - env=env, user=user or None, session=session or None, - detach=True) + env=env, user=user or None, detach=True) return started["pid"] def ps(self) -> list[dict]: