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
21 changes: 13 additions & 8 deletions apps/daemon/internal/gateway/view_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,9 @@ func TestListenersExistOnlyInTheSession(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
v, err := sessionview.Start(context.Background(), sessionview.Spec{
World: (&loopbackWorld{dir: world}).serve,
Private: []sessionview.PrivateDir{{Name: "harness", HostDir: harness, Exec: true}},
World: (&loopbackWorld{dir: world}).serve,
StagingParent: t.TempDir(),
Private: []sessionview.PrivateDir{{Name: "harness", HostDir: harness, Exec: true}},
Process: sessionview.Process{
Path: "/.oac/harness/harness", Args: []string{"harness"}, Dir: "/", UID: viewID, GID: viewID, Stderr: os.Stderr,
Env: []string{harnessEnv + "=1", endpointsEnv + "=" + string(encoded), externalEnv + "=" + net.JoinHostPort(hostAddress(t), port)},
Expand Down Expand Up @@ -224,33 +225,37 @@ func copyExecutable(t *testing.T, dst string) {
}
}

// loopbackWorld serves a directory as the view's world.
// loopbackWorld serves a directory as the view's world. It presents each mountpoint at its declared path.
type loopbackWorld struct {
dir string
served chan struct{}
}

func (w *loopbackWorld) serve(dev *os.File, _ sessionview.WorldMount) (sessionview.WorldServer, error) {
func (w *loopbackWorld) serve(_ context.Context, dev *os.File, mount sessionview.WorldMount) (sessionview.WorldServer, sessionview.Presentation, error) {
fd, err := unix.Dup(int(dev.Fd()))
if err != nil {
return nil, err
return nil, sessionview.Presentation{}, err
}
root, err := gofs.NewLoopbackRoot(w.dir)
if err != nil {
unix.Close(fd)
return nil, err
return nil, sessionview.Presentation{}, err
}
srv, err := fuse.NewServer(gofs.NewNodeFS(root, &gofs.Options{}), fmt.Sprintf("/dev/fd/%d", fd), &fuse.MountOptions{})
if err != nil {
unix.Close(fd)
return nil, err
return nil, sessionview.Presentation{}, err
}
w.served = make(chan struct{})
go func() {
srv.Serve()
close(w.served)
}()
return w, nil
var p sessionview.Presentation
for _, m := range mount.Mountpoints {
p.Targets = append(p.Targets, m.Path)
}
return w, p, nil
}

func (w *loopbackWorld) Stop() error {
Expand Down
50 changes: 41 additions & 9 deletions apps/daemon/internal/sessionview/build_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,11 @@ import (
"github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent"
)

// builder mounts the local pieces onto the world. Every target is resolved beneath its parent mount without following symlinks, so the sandbox cannot redirect a mount.
// builder mounts the local pieces onto the world, at the paths the world presents the mountpoints at. Every target is resolved beneath its parent mount without following symlinks, so the sandbox cannot redirect a mount.
type builder struct {
root int // the world root, an O_PATH fd
fds []int
root int // the world root, an O_PATH fd
targets map[string]string // each mountpoint's view path to where the world presents it
fds []int
}

// devNodes are bound from the host's /dev into the view's /dev.
Expand All @@ -42,11 +43,15 @@ func (b *builder) build(spec *launchSpec) error {
}
b.root = b.keep(root)
for _, d := range spec.Private {
at, err := b.at(agent.ViewPrivateRoot + "/" + d.Name)
if err != nil {
return err
}
src, err := b.source(d.HostDir)
if err != nil {
return err
}
if err := b.bind(src, b.root, agent.ViewPrivateRoot+"/"+d.Name, bindAttr(d.Writable, d.Exec, false)); err != nil {
if err := b.bind(src, b.root, at, bindAttr(d.Writable, d.Exec, false)); err != nil {
return err
}
}
Expand All @@ -60,33 +65,56 @@ func (b *builder) build(spec *launchSpec) error {
return err
}
for _, o := range spec.Overlays {
at, err := b.at(o.Path)
if err != nil {
return err
}
src, err := b.source(o.Source)
if err != nil {
return err
}
if err := b.bind(src, b.root, o.Path, bindAttr(false, o.Exec, false)); err != nil {
if err := b.bind(src, b.root, at, bindAttr(false, o.Exec, false)); err != nil {
return err
}
}
for _, p := range spec.Shim.Paths {
if err := b.bind(shim, b.root, p, bindAttr(false, true, false)); err != nil {
at, err := b.at(p)
if err != nil {
return err
}
if err := b.bind(shim, b.root, at, bindAttr(false, true, false)); err != nil {
return err
}
}
at, err := b.at(agent.ViewProcRoot)
if err != nil {
return err
}
proc, err := newFS("proc", nil, attrNoSuid|attrNoDev|attrNoExec)
if err != nil {
return err
}
defer unix.Close(proc)
if err := b.attach(proc, b.root, agent.ViewProcRoot, true); err != nil {
if err := b.attach(proc, b.root, at, true); err != nil {
return err
}
return b.dev()
}

// at returns where the world presents the mountpoint at view path p.
func (b *builder) at(p string) (string, error) {
if t, ok := b.targets[p]; ok {
return t, nil
}
return "", &Error{Kind: ErrLauncher, Op: "present", Path: p, Err: errors.New("the world presents no target")}
}

// shimDir presents the shim at /.oac/bin/<name> on a read-only tmpfs.
func (b *builder) shimDir(names []string, shim int) error {
dir := agent.ViewPrivateRoot + "/" + agent.ViewShimName
dir, err := b.at(agent.ViewPrivateRoot + "/" + agent.ViewShimName)
if err != nil {
return err
}
mnt, err := newFS("tmpfs", [][2]string{{"mode", "0755"}, {"size", "64k"}}, attrNoSuid|attrNoDev|attrNoExec)
if err != nil {
return err
Expand All @@ -108,12 +136,16 @@ func (b *builder) shimDir(names []string, shim int) error {

// dev builds a minimal read-only /dev with host device nodes, a new devpts instance and a noexec /dev/shm.
func (b *builder) dev() error {
at, err := b.at(agent.ViewDevRoot)
if err != nil {
return err
}
mnt, err := newFS("tmpfs", [][2]string{{"mode", "0755"}, {"size", "64k"}}, attrNoSuid|attrNoDev|attrNoExec)
if err != nil {
return err
}
defer unix.Close(mnt)
if err := b.attach(mnt, b.root, agent.ViewDevRoot, true); err != nil {
if err := b.attach(mnt, b.root, at, true); err != nil {
return err
}
for _, n := range devNodes {
Expand Down
3 changes: 2 additions & 1 deletion apps/daemon/internal/sessionview/control_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ type msgKind uint8

const (
msgMounted msgKind = iota + 1 // launcher: the world is mounted; carries the /dev/fuse and netns fds
msgProceed // daemon: the world serves and the network is set up
msgProceed // daemon: the world serves and the network is set up; carries the mount targets
msgStarted // launcher: the process runs
msgFailed // launcher: construction failed
msgExited // launcher: the process ended
Expand All @@ -61,6 +61,7 @@ type message struct {
Delivered bool
Exit Exit
Fail failure
Targets map[string]string // each mountpoint's view path to the path the world presents it at
}

// failure carries a launcher *Error across the control socket.
Expand Down
2 changes: 1 addition & 1 deletion apps/daemon/internal/sessionview/doc.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Package sessionview runs one process inside a per-Session view on the agent host.
//
// A view is a private mount, PID and network namespace whose root is the Session's world: a FUSE file system that the daemon serves over a /dev/fuse connection. The launcher adds the local pieces on top of the world: private directories under /.oac, trusted overlays, the command shim, a fresh /proc and a minimal /dev. The process starts with no capabilities, no_new_privs, a seccomp filter and only stdin, stdout and stderr open. Its network namespace has only loopback up.
// A view is a private mount, PID and network namespace whose root is the Session's world: a FUSE file system that the daemon serves over a /dev/fuse connection. The launcher adds the local pieces on top of the world: private directories under /.oac, trusted overlays, the command shim, a fresh /proc and a minimal /dev. The world presents a mountpoint for each piece and reports where, following the sandbox's symlinks, and the launcher mounts at those paths without following any symlink itself. The process starts with no capabilities, no_new_privs, a seccomp filter and only stdin, stdout and stderr open. Its network namespace has only loopback up.
//
// The exec guard is narrow. Mount flags alone decide which files can be executed: the world and every writable mount are nosuid and noexec, so executing a file from the file system works only from read-only mounts declared executable, such as the Harness directory, the shim and exec-flagged overlays. The seccomp filter denies creating a user namespace and every setns, so the process cannot create or enter another user namespace. Executing from a memfd and code that an allowed interpreter runs are outside this guard.
//
Expand Down
8 changes: 6 additions & 2 deletions apps/daemon/internal/sessionview/launcher_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ type launcher struct {
ctl *control
proceed chan struct{}
proceedOnce sync.Once
targets map[string]string // set before proceed closes

// mu orders signals against the process's exit. running holds from the process's start until it is reaped; termAt is when TERM first went to the view.
mu sync.Mutex
Expand Down Expand Up @@ -69,7 +70,7 @@ func (l *launcher) run() (int, error) {
return 0, err
}
<-l.proceed
b := &builder{root: -1}
b := &builder{root: -1, targets: l.targets}
defer b.close()
if err := b.build(spec); err != nil {
return 0, err
Expand Down Expand Up @@ -122,7 +123,10 @@ func (l *launcher) serveControl() {
}
switch m.Kind {
case msgProceed:
l.proceedOnce.Do(func() { close(l.proceed) })
l.proceedOnce.Do(func() {
l.targets = m.Targets
close(l.proceed)
})
case msgSignal:
_ = l.ctl.send(message{Kind: msgSignaled, Delivered: l.signal(m.Signal)})
}
Expand Down
27 changes: 24 additions & 3 deletions apps/daemon/internal/sessionview/spec.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package sessionview

import (
"context"
"os"
"path"
"path/filepath"
Expand All @@ -23,8 +24,10 @@ type Spec struct {
StagingParent string
}

// World starts serving the view's root file system on dev, a /dev/fuse connection that the launcher has already mounted as mount describes. The server runs in the daemon, outside the view, and never mounts or unmounts anything. sessionview closes dev after Stop returns.
type World func(dev *os.File, mount WorldMount) (WorldServer, error)
// World starts serving the view's root file system on dev, a /dev/fuse connection that the launcher has already mounted as mount describes, and reports how it presents mount's mountpoints. ctx is Start's: it bounds connecting, attaching and presenting, not the serving. The server runs in the daemon, outside the view, and never mounts or unmounts anything. sessionview closes dev after Stop returns.
//
// A World that fails releases what it acquired, or its error says that it cannot show it did. The owner of the attachment the world serves from then ends that attachment, which releases everything it holds.
type World func(ctx context.Context, dev *os.File, mount WorldMount) (WorldServer, Presentation, error)

// WorldServer is a running world.
type WorldServer interface {
Expand All @@ -38,10 +41,28 @@ type WorldMount struct {
Options []string
// Flags are the mount flags.
Flags []string
// Mountpoints are the view paths the launcher mounts over. The world presents each one, and each ancestor as a directory, with no symlinks and a stable identity for the view's lifetime, whether or not the sandbox has the path. A mountpoint that disappears or changes identity detaches what is mounted on it.
// UID and GID are the identity the view's process runs as.
UID, GID uint32
// Mountpoints are the view paths the launcher mounts over. The world presents each one with its type, whether or not the sandbox has the path, and keeps it and its ancestors stable for the view's lifetime: a mountpoint that disappears or changes identity detaches what is mounted on it.
Mountpoints []Mountpoint
}

// Presentation is how the world presents the mountpoints.
type Presentation struct {
// Targets holds, for each of WorldMount.Mountpoints in order, the absolute view path without symlinks where the world presents it. The launcher mounts there, still refusing to follow a symlink.
Targets []string
// Links are the sandbox symlinks on the way to a mountpoint. They stay symlinks in the view.
Links []PresentedLink
// Synthesized are the directories the world presents because the sandbox lacks them.
Synthesized []string
}

// PresentedLink is the symlink at Path, whose target is Target.
type PresentedLink struct {
Path string
Target string
}

// Mountpoint is a view path the world presents as a directory or a regular file.
type Mountpoint struct {
Path string
Expand Down
38 changes: 32 additions & 6 deletions apps/daemon/internal/sessionview/view_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ type View struct {
cmd *exec.Cmd
ctl *control
world WorldServer
present Presentation
dev *os.File
staging string
pipes [3]*os.File
Expand Down Expand Up @@ -65,9 +66,10 @@ func Start(ctx context.Context, spec Spec) (*View, error) {
return nil, err
}
stop := context.AfterFunc(ctx, func() { _ = v.cmd.Process.Kill() })
err := v.handshake(&spec)
err := v.handshake(ctx, &spec)
if !stop() {
err = &Error{Kind: ErrLauncher, Op: "start", Err: ctx.Err()}
// The world's own error stays: it may say that the attachment must be ended.
err = errors.Join(&Error{Kind: ErrLauncher, Op: "start", Err: ctx.Err()}, err)
}
if err != nil {
v.abort()
Expand Down Expand Up @@ -166,7 +168,7 @@ func (v *View) stdio(p *Process) ([3]*os.File, error) {
return child, nil
}

func (v *View) handshake(spec *Spec) error {
func (v *View) handshake(ctx context.Context, spec *Spec) error {
m, files, err := v.ctl.recv()
if err != nil {
return v.lost("mount", err)
Expand All @@ -181,17 +183,22 @@ func (v *View) handshake(spec *Spec) error {
v.dev = files[0]
netns := files[1]
defer netns.Close()
world, err := spec.World(v.dev, WorldMount{Options: fuseOptions, Flags: fuseFlags, Mountpoints: spec.mountpoints()})
mps := spec.mountpoints()
world, present, err := spec.World(ctx, v.dev, WorldMount{Options: fuseOptions, Flags: fuseFlags, UID: spec.Process.UID, GID: spec.Process.GID, Mountpoints: mps})
if err != nil {
return &Error{Kind: ErrWorld, Op: "serve", Err: err}
}
v.world = world
v.world, v.present = world, present
targets, err := targetsOf(mps, present)
if err != nil {
return &Error{Kind: ErrWorld, Op: "present", Err: err}
}
if spec.Network.Setup != nil {
if err := spec.Network.Setup(netns); err != nil {
return &Error{Kind: ErrNetwork, Op: "setup", Err: err}
}
}
if err := v.ctl.send(message{Kind: msgProceed}); err != nil {
if err := v.ctl.send(message{Kind: msgProceed, Targets: targets}); err != nil {
return v.lost("proceed", err)
}
m, files, err = v.ctl.recv()
Expand All @@ -207,6 +214,22 @@ func (v *View) handshake(spec *Spec) error {
return nil
}

// targetsOf pairs each mountpoint with the path the world presents it at.
func targetsOf(mps []Mountpoint, p Presentation) (map[string]string, error) {
if len(p.Targets) != len(mps) {
return nil, fmt.Errorf("%d targets for %d mountpoints", len(p.Targets), len(mps))
}
targets := make(map[string]string, len(mps))
for i, m := range mps {
t := p.Targets[i]
if !isViewAbs(t) || t == "/" {
return nil, fmt.Errorf("target %q for %s", t, m.Path)
}
targets[m.Path] = t
}
return targets, nil
}

// lost reports a launcher that stopped talking, with its exit status when it has exited.
func (v *View) lost(op string, err error) error {
if errors.Is(err, io.EOF) {
Expand Down Expand Up @@ -302,6 +325,9 @@ func (v *View) Wait() (Exit, error) {
return v.exit, v.err
}

// Presentation reports how the world presented the view's mountpoints.
func (v *View) Presentation() Presentation { return v.present }

// Signal delivers sig to every process in the view while the process runs. Once the process has exited it delivers nothing and returns ErrExited, even while the processes it left still drain.
func (v *View) Signal(sig syscall.Signal) error {
v.signalMu.Lock()
Expand Down
Loading
Loading