Skip to content

Commit 14c2339

Browse files
authored
feat: implement Supervisor Orchestration & Mocking (#11)
* feat: implement MountWorkspace and CopyOut in Supervisor * docs: update roadmap for orchestration and mocking completion * refactor: optimize locking in MountWorkspace * feat: add policy validation to CopyOut * test: enhance supervisor test robustness and state coverage
1 parent 149c5e5 commit 14c2339

4 files changed

Lines changed: 198 additions & 17 deletions

File tree

ROADMAP.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,12 @@ This roadmap tracks the progress of the Sandforge Agent Sandbox based on [ARCHIT
1717
## Phase 2: Orchestration & Mocking
1818
*Goal: Build the state machine that manages sandbox lifecycles.*
1919

20-
- [ ] **2.1 Sandbox Supervisor**:
21-
- [ ] Implementation of the Lifecycle State Machine (Requested -> Provisioning -> Ready -> ...).
22-
- [ ] Concurrent session management.
23-
- [ ] **2.2 Mock Backend Driver**:
24-
- [ ] An in-memory/process-based driver for testing the supervisor without a VM.
25-
- [ ] **2.3 Artifact Manager**: Basic logic to handle "CopyOut" for logs and files.
20+
- [x] **2.1 Sandbox Supervisor**:
21+
- [x] Implementation of the Lifecycle State Machine (Start, Run, Stop, Mount, CopyOut).
22+
- [x] Concurrent session management.
23+
- [x] **2.2 Mock Backend Driver**:
24+
- [x] An in-memory/process-based driver for testing the supervisor without a VM.
25+
- [x] **2.3 Artifact Manager**: Basic logic to handle "CopyOut" for logs and files.
2626

2727
## Phase 3: macOS Execution Plane (macos-vz)
2828
*Goal: Boot a real Linux VM on macOS using the Apple Virtualization Framework.*

internal/policy/engine.go

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,18 +27,30 @@ type Engine struct {
2727
}
2828

2929
func (e *Engine) EvaluateMount(mount api.WorkspaceMount) error {
30-
path := filepath.Clean(mount.HostPath)
30+
return e.EvaluateHostPath(mount.HostPath)
31+
}
32+
33+
func (e *Engine) EvaluateHostPath(path string) error {
34+
path = filepath.Clean(path)
3135

3236
if !filepath.IsAbs(path) {
3337
return ErrPathNotAbs
3438
}
3539

3640
// Resolve symlinks to prevent bypasses (e.g., a symlink pointing to /etc)
41+
// For CopyOut, the file might not exist yet, so we resolve the parent directory if the path itself doesn't exist.
3742
resolved, err := filepath.EvalSymlinks(path)
3843
if err != nil {
39-
return err // Path must exist to be validated
44+
// If path doesn't exist, try resolving the directory
45+
dir := filepath.Dir(path)
46+
resolvedDir, errDir := filepath.EvalSymlinks(dir)
47+
if errDir != nil {
48+
return errDir
49+
}
50+
path = filepath.Join(resolvedDir, filepath.Base(path))
51+
} else {
52+
path = resolved
4053
}
41-
path = resolved
4254

4355
allowed := false
4456
for _, prefix := range e.AllowedHostPrefixes {
@@ -68,6 +80,7 @@ func (e *Engine) EvaluateMount(mount api.WorkspaceMount) error {
6880
return nil
6981
}
7082

83+
7184
func (e *Engine) EvaluateSandbox(spec api.SandboxSpec) error {
7285
if spec.CPU > e.MaxCPU {
7386
return ErrResourceLimitExceeded

internal/supervisor/supervisor.go

Lines changed: 77 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,14 @@ import (
1313
type State string
1414

1515
const (
16-
StateRequested State = "requested"
17-
StateProvisioning State = "provisioning"
18-
StateReady State = "ready"
19-
StateExecuting State = "executing"
20-
StateCopyingArtifacts State = "copying_artifacts"
21-
StateDestroying State = "destroying"
22-
StateDestroyed State = "destroyed"
23-
StateError State = "error"
16+
StateRequested State = "requested"
17+
StateProvisioning State = "provisioning"
18+
StateReady State = "ready"
19+
StateExecuting State = "executing"
20+
StateCopyingArtifacts State = "copying_artifacts"
21+
StateDestroying State = "destroying"
22+
StateDestroyed State = "destroyed"
23+
StateError State = "error"
2424
)
2525

2626
// SandboxInstance tracks the runtime state of a single sandbox.
@@ -210,3 +210,72 @@ func (s *Supervisor) Stop(id string) error {
210210

211211
return nil
212212
}
213+
214+
// MountWorkspace allows attaching a host path to the sandbox.
215+
func (s *Supervisor) MountWorkspace(id string, mount api.WorkspaceMount) error {
216+
// 1. Find the instance
217+
s.mu.RLock()
218+
instance, exists := s.instances[id]
219+
s.mu.RUnlock()
220+
221+
if !exists {
222+
return errors.New("sandbox not found")
223+
}
224+
225+
// 2. Initial state check
226+
instance.mu.RLock()
227+
if instance.State != StateReady {
228+
instance.mu.RUnlock()
229+
return fmt.Errorf("sandbox is in state %s, must be %s to mount", instance.State, StateReady)
230+
}
231+
instance.mu.RUnlock()
232+
233+
// 3. Evaluate policy (without holding instance lock)
234+
if err := s.policy.EvaluateMount(mount); err != nil {
235+
return err
236+
}
237+
238+
// 4. Re-check state and get handle
239+
instance.mu.Lock()
240+
if instance.State != StateReady {
241+
instance.mu.Unlock()
242+
return fmt.Errorf("sandbox is in state %s, must be %s to mount", instance.State, StateReady)
243+
}
244+
handle := instance.Handle
245+
instance.mu.Unlock()
246+
247+
// 5. Call backend
248+
return s.backend.MountWorkspace(handle, mount)
249+
}
250+
251+
// CopyOut retrieves a file or directory from the sandbox.
252+
func (s *Supervisor) CopyOut(id string, path string, dest string) error {
253+
// 1. Find the instance
254+
s.mu.RLock()
255+
instance, exists := s.instances[id]
256+
s.mu.RUnlock()
257+
258+
if !exists {
259+
return errors.New("sandbox not found")
260+
}
261+
262+
// 2. Policy check for destination host path
263+
if err := s.policy.EvaluateHostPath(dest); err != nil {
264+
return fmt.Errorf("policy denied copy out destination: %w", err)
265+
}
266+
267+
// 3. Validate state
268+
instance.mu.RLock()
269+
handle := instance.Handle
270+
state := instance.State
271+
instance.mu.RUnlock()
272+
273+
// We allow CopyOut if it's Ready or Executing (e.g. streaming logs)
274+
if state != StateReady && state != StateExecuting {
275+
return fmt.Errorf("sandbox is in state %s, must be %s or %s to copy out", state, StateReady, StateExecuting)
276+
}
277+
278+
// 4. Call backend
279+
return s.backend.CopyOut(handle, path, dest)
280+
}
281+

internal/supervisor/supervisor_test.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package supervisor
22

33
import (
44
"fmt"
5+
"path/filepath"
56
"sync"
67
"testing"
78

@@ -126,3 +127,101 @@ func TestSupervisorLifecycle(t *testing.T) {
126127
}
127128
})
128129
}
130+
131+
func TestSupervisorMountAndCopy(t *testing.T) {
132+
mockBackend := backend.NewMockBackend()
133+
134+
// Create a temp dir for allowed mounts
135+
tmpDir := t.TempDir()
136+
137+
engine := &policy.Engine{
138+
AllowedHostPrefixes: []string{tmpDir},
139+
MaxCPU: 4,
140+
MaxMemoryMb: 4096,
141+
MaxDiskGb: 10,
142+
AllowedNetworkModes: []string{"offline"},
143+
}
144+
sup, err := NewSupervisor(mockBackend, engine)
145+
if err != nil {
146+
t.Fatalf("Failed to create supervisor: %v", err)
147+
}
148+
149+
id := "test-mount"
150+
spec := api.SandboxSpec{CPU: 1, MemoryMb: 512, DiskGb: 1, NetworkMode: "offline"}
151+
152+
if err := sup.Start(id, spec); err != nil {
153+
t.Fatalf("Failed to start sandbox: %v", err)
154+
}
155+
defer sup.Stop(id)
156+
157+
t.Run("ValidMount", func(t *testing.T) {
158+
err := sup.MountWorkspace(id, api.WorkspaceMount{
159+
HostPath: tmpDir,
160+
GuestPath: "/workspace",
161+
})
162+
if err != nil {
163+
t.Errorf("Expected valid mount to succeed, got %v", err)
164+
}
165+
})
166+
167+
t.Run("InvalidMount_Path", func(t *testing.T) {
168+
err := sup.MountWorkspace(id, api.WorkspaceMount{
169+
HostPath: "/etc",
170+
GuestPath: "/workspace",
171+
})
172+
if err == nil {
173+
t.Error("Expected mount to /etc to be blocked by policy")
174+
}
175+
})
176+
177+
t.Run("InvalidMount_State", func(t *testing.T) {
178+
// Manually set state to Executing to test rejection
179+
sup.mu.Lock()
180+
instance := sup.instances[id]
181+
sup.mu.Unlock()
182+
183+
originalState := instance.GetState()
184+
instance.SetState(StateExecuting)
185+
defer instance.SetState(originalState)
186+
187+
err := sup.MountWorkspace(id, api.WorkspaceMount{
188+
HostPath: tmpDir,
189+
GuestPath: "/workspace",
190+
})
191+
if err == nil {
192+
t.Error("Expected mount to fail when state is Executing")
193+
}
194+
})
195+
196+
t.Run("CopyOut_Valid", func(t *testing.T) {
197+
dest := filepath.Join(tmpDir, "log.txt")
198+
err := sup.CopyOut(id, "/workspace/log.txt", dest)
199+
if err != nil {
200+
t.Errorf("Expected CopyOut to succeed, got %v", err)
201+
}
202+
})
203+
204+
t.Run("CopyOut_InvalidPath", func(t *testing.T) {
205+
err := sup.CopyOut(id, "/workspace/log.txt", "/etc/shadow")
206+
if err == nil {
207+
t.Error("Expected CopyOut to /etc/shadow to be blocked by policy")
208+
}
209+
})
210+
211+
t.Run("CopyOut_InvalidState", func(t *testing.T) {
212+
sup.mu.Lock()
213+
instance := sup.instances[id]
214+
sup.mu.Unlock()
215+
216+
originalState := instance.GetState()
217+
instance.SetState(StateError)
218+
defer instance.SetState(originalState)
219+
220+
err := sup.CopyOut(id, "/workspace/log.txt", filepath.Join(tmpDir, "error.txt"))
221+
if err == nil {
222+
t.Error("Expected CopyOut to fail when state is Error")
223+
}
224+
})
225+
}
226+
227+

0 commit comments

Comments
 (0)