Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/daemon/internal/agent/clirunner/handle.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ import (
"time"
)

// Handle is a running process that clirunner did not start, such as a Harness in an agent-host Session view. Its end also ends every descendant.
// Handle is a running process that clirunner did not start, such as a Harness in an agent-host Session view. The implementation says which of the process's descendants Signal reaches and Wait waits for.
type Handle interface {
// Signal delivers sig to the process and every descendant it still has. Once the process itself has exited it delivers nothing and returns an error that matches os.ErrProcessDone, even while its descendants are still ending.
// Signal delivers sig to the process and the descendants the implementation reaches. Once the process itself has exited it delivers nothing and returns an error that matches os.ErrProcessDone, even while its descendants are still ending.
Signal(syscall.Signal) error
// Wait returns once the process and its descendants have ended. The code is -1 when a signal ended the process. An error means the exit is unknown.
// Wait returns once the process, and the descendants the implementation waits for, have ended. The code is -1 when a signal ended the process. An error means the exit is unknown.
Wait() (int, error)
// Close kills whatever still runs and releases the handle. It never closes the stdio ends in HandleOptions, which the Process owns. It is safe to call more than once and after Wait.
Close() error
Expand Down
25 changes: 25 additions & 0 deletions apps/daemon/internal/agent/harness.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,15 @@ var ErrInvalidView = errors.New("agent: invalid view declaration")
// a connection option.
var ErrViewHandoff = errors.New("agent: view request carries a connection outside the Session's gateway")

// ViewSession.Launch and ViewSession.Spawn outcomes.
var (
// ErrNotLocalExec is a Launch or Spawn whose Binary is not a LocalExec path.
ErrNotLocalExec = errors.New("agent: binary is not a LocalExec path")
// ErrNoLiveView is a Spawn while no view runs its Harness: none was
// launched yet, or its Harness has exited or its view has ended.
ErrNoLiveView = errors.New("agent: the Session has no live view")
)

// View declares how the Harness runs in an agent-host Session view. View
// paths are absolute and clean. The closure, Exec overlays and the shim are
// the only executable mounts, and all are read-only; the world, the home and
Expand Down Expand Up @@ -250,6 +259,22 @@ type ViewSession struct {
// TERM unless Cancel already did, and ends once they exit or KillTimeout
// passes from the first TERM.
Launch func(clirunner.StartOptions) (*clirunner.Process, error)
// Spawn runs Binary, a LocalExec path, as another process in the live
// view while its Harness runs, with StartOptions as Launch takes them. The
// process runs as the Harness does: as the same user, in the same
// namespaces, view cgroup, world and network, with no capabilities,
// no_new_privs and the same seccomp filter, in a process group of its own.
// The view starts one Spawn at a time, and Parent bounds the wait for its
// turn and for the start; once Parent ends, Spawn returns its error and
// kills a process that starts after all. Cancel sends TERM to the group
// and kills it after KillTimeout; once the process has exited, Cancel
// delivers nothing and what it left runs on as other processes in the view
// do. The view's end ends them all: a Cancel of the Harness reaches them,
// and when the Harness exits they are among the processes that remain. A
// view that ends after Spawn returned shows in the process's Wait. Spawn
// returns ErrNotLocalExec, ErrNoLiveView, or the error that kept the
// process from starting.
Spawn func(clirunner.StartOptions) (*clirunner.Process, error)
}

// checkViewHandoff enforces, before the factory runs, that the view request
Expand Down
2 changes: 1 addition & 1 deletion apps/daemon/internal/agenthost/agenthost.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ var (
// ErrWorld is a world that no longer shows the sandbox faithfully, or
// that cannot show that its attachment holds nothing.
ErrWorld = errors.New("agenthost: world lost")
// ErrLaunch is a view that could not be launched.
// ErrLaunch is a view, or a process in a view, that could not be started.
ErrLaunch = errors.New("agenthost: launch failed")
// ErrProcessBroker is a view's process broker that could not start, or
// whose process relay was lost while the view ran
Expand Down
13 changes: 7 additions & 6 deletions apps/daemon/internal/agenthost/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,12 +40,13 @@
// and calls the view's Executor factory. Each ViewSession.Launch builds one
// sessionview view, of which one at a time is live, over the world that
// worldfs serves from the attachment's File service, with the gateway
// listening in the view's network namespace. A view with a shim gets its own
// process broker, started once the view runs and closed once it has ended. The
// broker runs the shims' commands over the attachment's Process service in the
// strongest scope the service declares, with the view's ForwardEnv and the
// Session's Environment, and cancels a forwarded process whose shim is lost
// with the launch's kill timeout as its grace.
// listening in the view's network namespace. ViewSession.Spawn runs another
// process in the live view (sessionview.View.Spawn). A view with a shim gets
// its own process broker, started once the view runs and closed once it has
// ended. The broker runs the shims' commands over the attachment's Process
// service in the strongest scope the service declares, with the view's
// ForwardEnv and the Session's Environment, and cancels a forwarded process
// whose shim is lost with the launch's kill timeout as its grace.
//
// Each view presents the closure directories read-only and executable, the
// Session home read-write and noexec, the agent host's /etc/passwd, group,
Expand Down
53 changes: 48 additions & 5 deletions apps/daemon/internal/agenthost/launch_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type runningView interface {
Wait() (sessionview.Exit, error)
Close() error
Relay() *os.File
Spawn(ctx context.Context, path string, args, env []string, dir string, stdin bool) (*sessionview.Spawned, error)
}

// viewWorld is the part of *worldfs.World the Session watches.
Expand All @@ -61,15 +62,23 @@ func (s *session) closeLive() {
}
}

// launch is ViewSession.Launch: it builds one view and runs opts.Binary in it.
func (s *session) launch(opts clirunner.StartOptions) (*clirunner.Process, error) {
// checkStart checks the options of Launch and Spawn.
func (s *session) checkStart(opts clirunner.StartOptions) error {
switch {
case !slices.Contains(s.plan.view.LocalExec, opts.Binary):
return nil, &Error{Kind: ErrLaunch, Err: fmt.Errorf("%q is not a LocalExec path", opts.Binary)}
return &Error{Kind: ErrLaunch, Err: fmt.Errorf("%w: %q", agent.ErrNotLocalExec, opts.Binary)}
case !isViewPath(opts.Dir):
return nil, &Error{Kind: ErrLaunch, Err: fmt.Errorf("directory %q is not absolute and clean", opts.Dir)}
return &Error{Kind: ErrLaunch, Err: fmt.Errorf("directory %q is not absolute and clean", opts.Dir)}
case !opts.OwnProcessGroup:
return nil, &Error{Kind: ErrLaunch, Err: errors.New("a view process runs in its own process group")}
return &Error{Kind: ErrLaunch, Err: errors.New("a view process runs in its own process group")}
}
return nil
}

// launch is ViewSession.Launch: it builds one view and runs opts.Binary in it.
func (s *session) launch(opts clirunner.StartOptions) (*clirunner.Process, error) {
if err := s.checkStart(opts); err != nil {
return nil, err
}
if opts.Parent == nil {
opts.Parent = context.Background()
Expand All @@ -93,6 +102,40 @@ func (s *session) launch(opts clirunner.StartOptions) (*clirunner.Process, error
return s.start(lv, opts)
}

// spawn is ViewSession.Spawn: it runs opts.Binary in the live view.
func (s *session) spawn(opts clirunner.StartOptions) (*clirunner.Process, error) {
if err := s.checkStart(opts); err != nil {
return nil, err
}
var v runningView
s.mu.Lock()
if s.live != nil {
v = s.live.view
}
s.mu.Unlock()
if v == nil {
return nil, &Error{Kind: ErrLaunch, Op: "spawn", Err: agent.ErrNoLiveView}
}
if opts.Parent == nil {
opts.Parent = context.Background()
}
p, err := v.Spawn(opts.Parent, opts.Binary, append([]string{opts.Binary}, opts.Args...), opts.Env, opts.Dir, opts.NeedStdin)
if err != nil {
// Only a view that has ended has no live view; any other failure keeps its own error.
if errors.Is(err, sessionview.ErrExited) || errors.Is(err, sessionview.ErrClosed) {
err = fmt.Errorf("%w: %w", agent.ErrNoLiveView, err)
}
return nil, &Error{Kind: ErrLaunch, Op: "spawn", Err: err}
}
var stdin io.WriteCloser
if p.Stdin != nil {
stdin = p.Stdin
}
// FromHandle fails only without stdout and stderr, which a spawned process always has.
process, _ := clirunner.FromHandle(p, clirunner.HandleOptions{Parent: opts.Parent, Stdin: stdin, Stdout: p.Stdout, Stderr: p.Stderr, KillTimeout: opts.KillTimeout})
return process, nil
}

// release frees the view slot and ends the launch's count.
func (s *session) release(lv *liveView) {
s.mu.Lock()
Expand Down
1 change: 1 addition & 0 deletions apps/daemon/internal/agenthost/run_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ func run(ctx context.Context, cfg Config, in Session, d deps) error {
Proxy: s.plan.proxy,
MCP: s.plan.mcp,
Launch: s.launch,
Spawn: s.spawn,
})
if err != nil {
err = executorError(err)
Expand Down
28 changes: 25 additions & 3 deletions apps/daemon/internal/agenthost/session_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,23 @@ func TestViewEndReleasesTheSlotBeforeTheProcessEnds(t *testing.T) {
}
}

// TestSpawnKeepsItsErrors checks that only a view that has ended makes a Spawn fail with ErrNoLiveView, and that every other failure keeps its own error.
func TestSpawnKeepsItsErrors(t *testing.T) {
emfile := &sessionview.Error{Kind: sessionview.ErrLauncher, Op: "pipe", Err: syscall.EMFILE}
for _, c := range []struct {
err error
ended bool
}{{sessionview.ErrExited, true}, {sessionview.ErrClosed, true}, {emfile, false}, {sessionview.ErrExec, false}, {context.Canceled, false}} {
s := newOwnerSession(t)
s.plan = &plan{view: agent.View{LocalExec: []string{"/bin/true"}}}
s.live = &liveView{view: &fakeView{exit: make(chan struct{}), spawnErr: c.err}}
_, err := s.spawn(clirunner.StartOptions{Binary: "/bin/true", Dir: "/", OwnProcessGroup: true})
if !errors.Is(err, c.err) || errors.Is(err, agent.ErrNoLiveView) != c.ended {
t.Errorf("Spawn failing with %v = %v; want that error, and ErrNoLiveView only for an ended view", c.err, err)
}
}
}

func TestFailureDuringTeardownCounts(t *testing.T) {
s := newOwnerSession(t)
// The relay revokes the attachment while Executor.Close waits.
Expand Down Expand Up @@ -334,16 +351,21 @@ func (t *fakeTurn) AwaitSettlement(context.Context) (agent.TurnSettlement, error
return agent.TurnSettlement{Reusable: t.settleErr == nil}, t.settleErr
}

// fakeView is a view that ends when closed.
// fakeView is a view that ends when closed and whose spawns fail with spawnErr.
type fakeView struct {
exit chan struct{}
once sync.Once
exit chan struct{}
once sync.Once
spawnErr error
}

func (v *fakeView) Signal(syscall.Signal) error { return nil }

func (v *fakeView) Relay() *os.File { return nil }

func (v *fakeView) Spawn(context.Context, string, []string, []string, string, bool) (*sessionview.Spawned, error) {
return nil, v.spawnErr
}

func (v *fakeView) Wait() (sessionview.Exit, error) {
<-v.exit
return sessionview.Exit{}, nil
Expand Down
18 changes: 16 additions & 2 deletions apps/daemon/internal/agenthost/view_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ func TestSessionRunsInAViewOverItsAttachment(t *testing.T) {
t.Fatal(err)
}
copyExecutable(t, filepath.Join(closure, "harness"))
executors := make(chan *testExecutor, 1)
register(reg, "test", &agent.View{
Closure: []agent.ViewMount{{Name: "harness", HostDir: closure}},
Masks: []agent.ViewMask{{Path: "/etc/ld.so.preload"}, {Path: "/etc/hostname"}, {Path: "/etc/apt", Dir: true}},
Expand All @@ -101,8 +102,13 @@ func TestSessionRunsInAViewOverItsAttachment(t *testing.T) {
if err != nil {
return nil, err
}
return &testExecutor{session: s, dir: req.LocalEnvironment.WorkspaceRoot,
env: []string{harnessEnv + "=1", modelEnv + "=" + provider.BaseURL, caEnv + "=" + cfg.CADir}}, nil
e := &testExecutor{session: s, dir: req.LocalEnvironment.WorkspaceRoot,
env: []string{harnessEnv + "=1", modelEnv + "=" + provider.BaseURL, caEnv + "=" + cfg.CADir}}
select {
case executors <- e:
default:
}
return e, nil
},
})
sb.auth.AddRuntime(cfg.Credential, cfg.RuntimeID)
Expand Down Expand Up @@ -132,6 +138,14 @@ func TestSessionRunsInAViewOverItsAttachment(t *testing.T) {
if r.Exit != "" {
t.Errorf("Harness: %s; stderr %s", r.Exit, r.Stderr)
}
// The Turn waited for its Harness, so no view runs.
e := <-executors
if _, err := e.session.Spawn(clirunner.StartOptions{Binary: harnessPath, Dir: e.dir, OwnProcessGroup: true}); !errors.Is(err, agent.ErrNoLiveView) {
t.Errorf("Spawn after the Harness exited = %v, want ErrNoLiveView", err)
}
if _, err := e.session.Spawn(clirunner.StartOptions{Binary: "/bin/sh", Dir: e.dir, OwnProcessGroup: true}); !errors.Is(err, agent.ErrNotLocalExec) {
t.Errorf("Spawn of a binary outside LocalExec = %v, want ErrNotLocalExec", err)
}
select {
case ok := <-keyed:
if !ok {
Expand Down
Loading
Loading