Skip to content

Commit 4d870e2

Browse files
Kiran MagicKiran Magic
authored andcommitted
feat(e2b): return run-session handles
1 parent 943798f commit 4d870e2

5 files changed

Lines changed: 119 additions & 16 deletions

File tree

docs/providers/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ Access terms:
8686
| [daytona](daytona.md) | built-in; `ssh-lease` · direct-cloud | Crabbox-managed SSH; `archive-sync` · direct only; features: `ssh`, `crabbox-sync` | `linux`; Daytona sandbox | `provider-managed`; GPU: unknown | Daytona; sandbox delete | Managed development sandbox with delegated archive sync and execution | SSH access is short-lived; run and sync use Daytona toolbox APIs |
8787
| [digitalocean](digitalocean.md) | built-in; `ssh-lease` · direct-cloud | Crabbox-managed SSH; `crabbox-sync` · direct only; features: `ssh`, `crabbox-sync`, `cleanup`, `tailscale` | `linux`; DigitalOcean Droplet | `cloud`; GPU: optional | Crabbox; Droplet and key delete | Simple direct Linux VM | Direct-only; no coordinator scheduling |
8888
| [docker-sandbox](docker-sandbox.md) | built-in; `delegated-run` · local-sandbox | No SSH; `provider-owned` · direct only; features: `run-session` | `linux`; Docker Sandbox | `local`; GPU: no | Docker sbx CLI; sandbox delete | Local delegated sandbox with reusable session handles | Requires the standalone sbx CLI |
89-
| [e2b](e2b.md) | built-in; `delegated-run` · delegated-sandbox | No SSH; `provider-owned` · direct only; features: `url-bridge` | `linux`; E2B Firecracker sandbox | `provider-managed`; GPU: no | E2B; sandbox kill or expiry | Hosted ephemeral code sandbox | URL bridge is provider-specific; no normal SSH lease |
89+
| [e2b](e2b.md) | built-in; `delegated-run` · delegated-sandbox | No SSH; `provider-owned` · direct only; features: `url-bridge`, `run-session` | `linux`; E2B Firecracker sandbox | `provider-managed`; GPU: no | E2B; sandbox kill or expiry | Hosted ephemeral code sandbox | URL bridge is provider-specific; no normal SSH lease |
9090
| [exe-dev](exe-dev.md) (`exe`, `exedev`) | built-in; `ssh-lease` · direct-cloud | Crabbox-managed SSH; `crabbox-sync` · direct only; features: `ssh`, `crabbox-sync` | `linux`; exe.dev managed VM | `provider-managed`; GPU: unknown | exe.dev; provider lifecycle | Fast managed Linux VM exposed over SSH | Public SSH only; provider CLI owns auth |
9191
| [external](external.md) (`exec-provider`) | built-in; `ssh-lease` · external-provider | Crabbox-managed SSH; `crabbox-sync` · direct only; features: `ssh`, `crabbox-sync`, `cleanup`, `desktop`, `browser`, `code` | `linux`; Configured executable contract | `byo`; GPU: unknown | external executable; contract-defined | Private or organization-specific provider integration | Safety and semantics depend on the configured executable |
9292
| [freestyle](freestyle.md) | built-in; `delegated-run` · delegated-sandbox | No SSH; `archive-sync` · direct only; features: `archive-sync` | `linux`; Freestyle VM | `provider-managed`; GPU: unknown | Freestyle; provider VM cleanup | Hosted delegated Linux VM execution | No Crabbox-managed SSH path |

internal/providers/e2b/backend.go

Lines changed: 32 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -150,25 +150,41 @@ func (b *e2bBackend) Run(ctx context.Context, req RunRequest) (RunResult, error)
150150
removeLeaseClaim(leaseID)
151151
}()
152152
}
153+
result := RunResult{
154+
SyncDelegated: true,
155+
Session: &RunSessionHandle{
156+
Provider: e2bProvider,
157+
LeaseID: leaseID,
158+
Slug: slug,
159+
Reused: !acquired,
160+
Kept: !shouldStop,
161+
CleanupCommand: e2bCleanupCommand(leaseID),
162+
},
163+
}
164+
finishResult := func() RunResult {
165+
result.Total = b.now().Sub(started)
166+
result.Session.Kept = !shouldStop
167+
return result
168+
}
153169

154170
session, err := client.ConnectSandbox(ctx, sandboxID, e2bTimeoutSeconds(b.cfg.TTL))
155171
if err != nil {
156-
return RunResult{}, e2bError("connect sandbox", err)
172+
return finishResult(), e2bError("connect sandbox", err)
157173
}
158174
workspace := e2bWorkspacePath(b.cfg)
159175
syncDuration := time.Duration(0)
160176
syncPhases := []timingPhase{{Name: "sync", Skipped: true, Reason: "--no-sync"}}
161177
if !req.NoSync {
162178
syncPhases, syncDuration, err = b.syncWorkspace(ctx, client, session, req, workspace)
163179
if err != nil {
164-
return RunResult{Total: b.now().Sub(started), SyncDelegated: true}, err
180+
return finishResult(), err
165181
}
166182
fmt.Fprintf(b.rt.Stderr, "sync complete in %s\n", syncDuration.Round(time.Millisecond))
167183
} else if err := b.prepareWorkspace(ctx, client, session, workspace); err != nil {
168-
return RunResult{}, err
184+
return finishResult(), err
169185
}
170186
if req.SyncOnly {
171-
result := RunResult{Total: b.now().Sub(started), SyncDelegated: true}
187+
result := finishResult()
172188
fmt.Fprintf(b.rt.Stdout, "synced %s\n", workspace)
173189
if req.TimingJSON {
174190
err := writeTimingJSON(b.rt.Stderr, timingReport{
@@ -189,7 +205,7 @@ func (b *e2bBackend) Run(ctx context.Context, req RunRequest) (RunResult, error)
189205
}
190206
command := e2bCommandString(req.Command, req.ShellMode)
191207
if command == "" {
192-
return RunResult{}, exit(2, "missing command")
208+
return finishResult(), exit(2, "missing command")
193209
}
194210
commandStarted := b.now()
195211
fmt.Fprintf(b.rt.Stderr, "running on e2b %s\n", strings.Join(req.Command, " "))
@@ -203,12 +219,10 @@ func (b *e2bBackend) Run(ctx context.Context, req RunRequest) (RunResult, error)
203219
Stderr: b.rt.Stderr,
204220
})
205221
commandDuration := b.now().Sub(commandStarted)
206-
result := RunResult{
207-
ExitCode: exitCode,
208-
Command: commandDuration,
209-
Total: b.now().Sub(started),
210-
SyncDelegated: true,
211-
}
222+
result.ExitCode = exitCode
223+
result.Command = commandDuration
224+
result.Total = b.now().Sub(started)
225+
result.Session.Kept = !shouldStop
212226
if req.NoSync {
213227
fmt.Fprintf(b.rt.Stderr, "e2b run summary sync_skipped=true command=%s total=%s exit=%d\n", result.Command.Round(time.Millisecond), result.Total.Round(time.Millisecond), result.ExitCode)
214228
} else {
@@ -233,13 +247,13 @@ func (b *e2bBackend) Run(ctx context.Context, req RunRequest) (RunResult, error)
233247
}
234248
if commandErr != nil {
235249
handleDelegatedRunFailure(b.rt.Stderr, req, e2bProvider, leaseID, slug, b.cfg.IdleTimeout, b.cfg.TTL, acquired, &shouldStop)
236-
return result, ExitError{Code: 1, Message: fmt.Sprintf("e2b run failed: %v", commandErr)}
250+
return finishResult(), ExitError{Code: 1, Message: fmt.Sprintf("e2b run failed: %v", commandErr)}
237251
}
238252
if result.ExitCode != 0 {
239253
handleDelegatedRunFailure(b.rt.Stderr, req, e2bProvider, leaseID, slug, b.cfg.IdleTimeout, b.cfg.TTL, acquired, &shouldStop)
240-
return result, ExitError{Code: result.ExitCode, Message: fmt.Sprintf("e2b run exited %d", result.ExitCode)}
254+
return finishResult(), ExitError{Code: result.ExitCode, Message: fmt.Sprintf("e2b run exited %d", result.ExitCode)}
241255
}
242-
return result, nil
256+
return finishResult(), nil
243257
}
244258

245259
func (b *e2bBackend) List(ctx context.Context, req ListRequest) ([]LeaseView, error) {
@@ -369,6 +383,10 @@ func (b *e2bBackend) deleteSandboxForCleanup(client e2bAPI, sandboxID string) er
369383
return client.DeleteSandbox(ctx, sandboxID)
370384
}
371385

386+
func e2bCleanupCommand(leaseID string) string {
387+
return fmt.Sprintf("crabbox stop --provider %s --id %s", e2bProvider, shellQuote(leaseID))
388+
}
389+
372390
func (b *e2bBackend) resolveSandboxID(ctx context.Context, client e2bAPI, id, repoRoot string, reclaim bool) (string, string, string, error) {
373391
if id == "" {
374392
return "", "", "", exit(2, "provider=e2b requires a Crabbox lease id, slug, or E2B sandbox id")

internal/providers/e2b/backend_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import (
1717
"strings"
1818
"testing"
1919
"time"
20+
21+
core "github.com/openclaw/crabbox/internal/cli"
2022
)
2123

2224
func TestParseE2BProcessStream(t *testing.T) {
@@ -129,6 +131,12 @@ func TestE2BWarmupRejectsUnsafeUserBeforeClient(t *testing.T) {
129131
}
130132
}
131133

134+
func TestProviderSpecAdvertisesRunSession(t *testing.T) {
135+
if !(Provider{}).Spec().Features.Has(core.FeatureRunSession) {
136+
t.Fatalf("features=%#v want run session", Provider{}.Spec().Features)
137+
}
138+
}
139+
132140
func TestCleanE2BWorkspacePath(t *testing.T) {
133141
tests := []struct {
134142
name string
@@ -475,6 +483,76 @@ func TestE2BCreateSandboxReportsCleanupFailureAfterClaimFailure(t *testing.T) {
475483
}
476484
}
477485

486+
func TestE2BRunReturnsSessionHandleForKeptSandbox(t *testing.T) {
487+
t.Setenv("XDG_STATE_HOME", t.TempDir())
488+
client := &fakeE2BSyncClient{}
489+
restore := swapNewE2BClient(client)
490+
defer restore()
491+
backend := &e2bBackend{
492+
cfg: Config{
493+
IdleTimeout: 30 * time.Minute,
494+
TTL: 2 * time.Minute,
495+
E2B: E2BConfig{APIKey: "test", Workdir: "repo"},
496+
},
497+
rt: Runtime{Stdout: io.Discard, Stderr: io.Discard},
498+
}
499+
500+
result, err := backend.Run(context.Background(), RunRequest{
501+
Repo: Repo{Name: "repo", Root: t.TempDir()},
502+
Command: []string{"true"},
503+
Keep: true,
504+
NoSync: true,
505+
})
506+
if err != nil {
507+
t.Fatal(err)
508+
}
509+
if result.Session == nil {
510+
t.Fatal("missing session handle")
511+
}
512+
got := result.Session
513+
if got.Provider != e2bProvider || got.LeaseID == "" || got.Slug == "" || got.Reused || !got.Kept {
514+
t.Fatalf("session=%#v", got)
515+
}
516+
if got.CleanupCommand != "crabbox stop --provider e2b --id "+shellQuote(got.LeaseID) {
517+
t.Fatalf("cleanup command=%q", got.CleanupCommand)
518+
}
519+
if len(client.deleteIDs) != 0 {
520+
t.Fatalf("deleteIDs=%#v, want kept sandbox", client.deleteIDs)
521+
}
522+
}
523+
524+
func TestE2BRunReturnsSessionHandleWhenKeepOnFailureRetainsSandbox(t *testing.T) {
525+
t.Setenv("XDG_STATE_HOME", t.TempDir())
526+
client := &fakeE2BSyncClient{processCodes: []int{0, 7}}
527+
restore := swapNewE2BClient(client)
528+
defer restore()
529+
backend := &e2bBackend{
530+
cfg: Config{
531+
IdleTimeout: 30 * time.Minute,
532+
TTL: 2 * time.Minute,
533+
E2B: E2BConfig{APIKey: "test", Workdir: "repo"},
534+
},
535+
rt: Runtime{Stdout: io.Discard, Stderr: io.Discard},
536+
}
537+
538+
result, err := backend.Run(context.Background(), RunRequest{
539+
Repo: Repo{Name: "repo", Root: t.TempDir()},
540+
Command: []string{"false"},
541+
KeepOnFailure: true,
542+
NoSync: true,
543+
})
544+
var ee ExitError
545+
if !errors.As(err, &ee) || ee.Code != 7 {
546+
t.Fatalf("err=%v want ExitError code 7", err)
547+
}
548+
if result.Session == nil || !result.Session.Kept || result.Session.CleanupCommand == "" {
549+
t.Fatalf("session=%#v", result.Session)
550+
}
551+
if len(client.deleteIDs) != 0 {
552+
t.Fatalf("deleteIDs=%#v, want kept sandbox", client.deleteIDs)
553+
}
554+
}
555+
478556
func TestE2BSandboxToServerUsesMetadata(t *testing.T) {
479557
server := e2bSandboxToServer(e2bSandbox{
480558
SandboxID: "sbx_1",
@@ -538,6 +616,12 @@ type fakeE2BSyncClient struct {
538616
processCodes []int
539617
}
540618

619+
func swapNewE2BClient(fake e2bAPI) func() {
620+
prev := newE2BClient
621+
newE2BClient = func(Config, Runtime) (e2bAPI, error) { return fake, nil }
622+
return func() { newE2BClient = prev }
623+
}
624+
541625
func (f *fakeE2BSyncClient) CreateSandbox(_ context.Context, req e2bCreateSandboxRequest) (e2bSandbox, error) {
542626
f.createReq = req
543627
f.createCalls++

internal/providers/e2b/core.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ type DoctorResult = core.DoctorResult
2020
type WarmupRequest = core.WarmupRequest
2121
type RunRequest = core.RunRequest
2222
type RunResult = core.RunResult
23+
type RunSessionHandle = core.RunSessionHandle
2324
type ListRequest = core.ListRequest
2425
type LeaseView = core.LeaseView
2526
type StatusRequest = core.StatusRequest

internal/providers/e2b/provider.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ func (Provider) Spec() core.ProviderSpec {
2121
Family: "e2b",
2222
Kind: core.ProviderKindDelegatedRun,
2323
Targets: []core.TargetSpec{{OS: core.TargetLinux}},
24-
Features: core.FeatureSet{core.FeatureURLBridge},
24+
Features: core.FeatureSet{core.FeatureURLBridge, core.FeatureRunSession},
2525
Coordinator: core.CoordinatorNever,
2626
}
2727
}

0 commit comments

Comments
 (0)