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/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/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/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/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/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 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 } 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: 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/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/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]: 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/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()); 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());