From f4a4d12d7d0c45d3e1acdf50b5df333b5439275a Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 1 Oct 2026 06:52:41 +0000 Subject: [PATCH 1/2] Stop the world when a view's Start is cancelled during the build --- .../internal/sessionview/control_linux.go | 6 ++ apps/daemon/internal/sessionview/doc.go | 2 +- apps/daemon/internal/sessionview/spec.go | 2 +- .../daemon/internal/sessionview/view_linux.go | 8 ++- .../internal/sessionview/view_linux_test.go | 69 +++++++++++++++++++ 5 files changed, 83 insertions(+), 4 deletions(-) diff --git a/apps/daemon/internal/sessionview/control_linux.go b/apps/daemon/internal/sessionview/control_linux.go index 37c6e2c2..8a7411dd 100644 --- a/apps/daemon/internal/sessionview/control_linux.go +++ b/apps/daemon/internal/sessionview/control_linux.go @@ -169,6 +169,12 @@ func (c *control) recv() (message, []*os.File, error) { return m, files, nil } +// interrupt shuts the daemon's end down in both directions, so that a pending recv returns io.EOF and every later send fails. +func (c *control) interrupt() { + c.conn.CloseRead() + c.conn.CloseWrite() +} + func (c *control) close() error { return c.conn.Close() } diff --git a/apps/daemon/internal/sessionview/doc.go b/apps/daemon/internal/sessionview/doc.go index 4966a9c7..3bb2aec2 100644 --- a/apps/daemon/internal/sessionview/doc.go +++ b/apps/daemon/internal/sessionview/doc.go @@ -8,7 +8,7 @@ // // A view that declares a [Shim] also runs the Session's process relay, the shim binary in relay mode (package processshim). The launcher starts it before the process, under the same restrictions and as the same user, with a listening socket at processshim.SocketPath on a read-only mount and its end of a socket pair whose other end is [View.Relay]. The relay is the only process that receives the descriptors a shim hands over; the broker outside the view holds only its end of the pair. The launcher's drain ignores the relay, which ends with the view. // -// Teardown never waits unconditionally. Once the launcher has exited, the view shuts its end of the relay connection down, stops the world server, which ends the requests still pending on the view's FUSE connection so that a process blocked on the world can exit, and waits for the world server and the view's processes together for up to 30 seconds. Past that, Wait and Close report [ErrCleanup]. +// Teardown never waits unconditionally. Once the launcher has exited, the view shuts its end of the relay connection down, stops the world server, which ends the requests still pending on the view's FUSE connection so that a process blocked on the world can exit, and waits for the world server and the view's processes together for up to 30 seconds. Past that, Wait and Close report [ErrCleanup]. When Start's context ends during the build, Start kills the launcher, stops waiting for it and tears the view down the same way, so a launcher blocked on the world cannot hold Start past this bound. // // The package works only on Linux. Elsewhere [Start] returns [ErrUnsupported]. package sessionview diff --git a/apps/daemon/internal/sessionview/spec.go b/apps/daemon/internal/sessionview/spec.go index 6d3f84d8..e6299219 100644 --- a/apps/daemon/internal/sessionview/spec.go +++ b/apps/daemon/internal/sessionview/spec.go @@ -32,7 +32,7 @@ type World func(ctx context.Context, dev *os.File, mount WorldMount) (WorldServe // WorldServer is a running world. type WorldServer interface { - // Stop ends serving and returns within a bound of its own. sessionview calls it once the launcher has exited, while the view's processes may still be ending, and waits for it and for them together. A process blocked on a request the world has not answered cannot exit, and it keeps the view's mount alive, so Stop must end every request still pending rather than only wait for the view to end. + // Stop ends serving and returns within a bound of its own. sessionview calls it once, as the view ends or as a failed or cancelled Start tears it down, while the launcher and the view's processes may still be ending, and waits for it and for them together. A process blocked on a request the world has not answered cannot exit, and it keeps the view's mount alive, so Stop must end every request still pending rather than only wait for the view to end. This holds during the build too: a launcher killed while it waits on the world exits only once Stop ends that request. Stop() error } diff --git a/apps/daemon/internal/sessionview/view_linux.go b/apps/daemon/internal/sessionview/view_linux.go index 1892608c..f678d0ea 100644 --- a/apps/daemon/internal/sessionview/view_linux.go +++ b/apps/daemon/internal/sessionview/view_linux.go @@ -62,7 +62,7 @@ type View struct { err error } -// Start builds a view for spec and starts its process. ctx bounds only the construction. +// Start builds a view for spec and starts its process. ctx bounds only the construction: once it ends, Start kills the launcher and tears the view down, which stops the world, and returns within the teardown's bound. func Start(ctx context.Context, spec Spec) (*View, error) { if err := spec.validate(); err != nil { return nil, err @@ -74,7 +74,11 @@ func Start(ctx context.Context, spec Spec) (*View, error) { if err := v.launch(&spec); err != nil { return nil, v.abort(err) } - stop := context.AfterFunc(ctx, func() { _ = v.cmd.Process.Kill() }) + stop := context.AfterFunc(ctx, func() { + _ = v.cmd.Process.Kill() + // A launcher blocked on a world request that the world read cannot exit, and it keeps its end of the control socket open until the world answers. The handshake stops waiting for it here; abort's teardown then stops the world, which answers the request. + v.ctl.interrupt() + }) err := v.handshake(ctx, &spec) if !stop() { // The world's own error stays: it may say that the attachment must be ended. diff --git a/apps/daemon/internal/sessionview/view_linux_test.go b/apps/daemon/internal/sessionview/view_linux_test.go index 870cae15..3ee0a632 100644 --- a/apps/daemon/internal/sessionview/view_linux_test.go +++ b/apps/daemon/internal/sessionview/view_linux_test.go @@ -213,6 +213,44 @@ func TestTeardownIsBounded(t *testing.T) { } } +// TestCancelledStartStopsTheWorld checks that Start returns once its context ends while the launcher waits on a world that never answers during the build, and that it stops the world, which lets the launcher exit. +func TestCancelledStartStopsTheWorld(t *testing.T) { + requireView(t) + f := newFixture(t) + w := &stallWorld{loopbackWorld: loopbackWorld{dir: f.world}, stalled: make(chan struct{}), release: make(chan struct{})} + spec := f.spec(&w.loopbackWorld, "noop") + spec.World = w.serve + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + started := make(chan error, 1) + go func() { + v, err := Start(ctx, spec) + if err == nil { + v.Close() + } + started <- err + }() + select { + case <-w.stalled: + case <-time.After(10 * time.Second): + t.Fatal("the launcher never looked up /proc in the world") + } + cancel() + select { + case err := <-started: + if !errors.Is(err, ErrLauncher) || !errors.Is(err, context.Canceled) || errors.Is(err, ErrCleanup) { + t.Errorf("Start = %v, want ErrLauncher with context.Canceled and no ErrCleanup", err) + } + case <-time.After(20 * time.Second): + t.Fatal("Start did not return after its context ended") + } + select { + case <-w.served: + default: + t.Error("world server still serving") + } +} + // TestViewRefusesSymlinkedMountpoint checks that the launcher still refuses a symlink on the way to a target, so a world that reports a target it did not resolve cannot redirect a mount. func TestViewRefusesSymlinkedMountpoint(t *testing.T) { requireView(t) @@ -455,6 +493,37 @@ func (n *hangNode) Write(context.Context, gofs.FileHandle, []byte, int64) (uint3 return 0, syscall.EIO } +// stallWorld is a loopbackWorld that answers no lookup of proc until Stop. +type stallWorld struct { + loopbackWorld + stalled, release chan struct{} // stalled closes at the first lookup of proc + stallOnce sync.Once +} + +func (w *stallWorld) serve(_ context.Context, dev *os.File, mount WorldMount) (WorldServer, Presentation, error) { + root, err := gofs.NewLoopbackRoot(w.dir) + if err != nil { + return nil, Presentation{}, err + } + root.(*gofs.LoopbackNode).RootData.NewNode = func(r *gofs.LoopbackRoot, _ *gofs.Inode, name string, _ *syscall.Stat_t) gofs.InodeEmbedder { + if name == "proc" { + w.stallOnce.Do(func() { close(w.stalled) }) + <-w.release + } + return &gofs.LoopbackNode{RootData: r} + } + _, p, err := w.serveRoot(root, dev, mount) + if err != nil { + return nil, p, err + } + return w, p, nil +} + +func (w *stallWorld) Stop() error { + close(w.release) + return w.loopbackWorld.Stop() +} + // serveBroker listens in the view's network namespace, as the broker does. func serveBroker(t *testing.T) func(*os.File) error { return func(netns *os.File) error { From ba0079b95efbf74e7b48968bbb5cb2e5ae10a45b Mon Sep 17 00:00:00 2001 From: SaladDay <1203511142@qq.com> Date: Thu, 1 Oct 2026 07:03:16 +0000 Subject: [PATCH 2/2] Run a process broker per agent-host view --- apps/daemon/internal/agenthost/admit.go | 25 ++- .../internal/agenthost/admit_linux_test.go | 31 +-- apps/daemon/internal/agenthost/agenthost.go | 7 +- .../agenthost/agenthost_linux_test.go | 18 +- apps/daemon/internal/agenthost/broker.go | 49 ----- apps/daemon/internal/agenthost/doc.go | 45 ++-- .../daemon/internal/agenthost/launch_linux.go | 195 ++++++++++++------ apps/daemon/internal/agenthost/run_linux.go | 45 ++-- .../internal/agenthost/session_linux_test.go | 4 +- .../internal/agenthost/view_linux_test.go | 79 ++++++- 10 files changed, 322 insertions(+), 176 deletions(-) delete mode 100644 apps/daemon/internal/agenthost/broker.go diff --git a/apps/daemon/internal/agenthost/admit.go b/apps/daemon/internal/agenthost/admit.go index e4a320f9..94659be7 100644 --- a/apps/daemon/internal/agenthost/admit.go +++ b/apps/daemon/internal/agenthost/admit.go @@ -15,6 +15,7 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/gateway" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/processbroker" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxfs" @@ -38,6 +39,9 @@ type plan struct { // mcp and proxy are ViewSession.MCP and ViewSession.Proxy. mcp []agent.MCPBinding proxy string + // executables is each view's process broker table: each shim name runs + // that name on the sandbox PATH, and each shim path the same path. + executables processbroker.Executables } // checkConfig validates cfg and loads the roots in its CA directory. @@ -115,6 +119,8 @@ func admit(cfg Config, roots *x509.CertPool, s Session, openNetwork func(context case len(req.FunctionTools) > 0 || req.ToolSearch: // A function call waits for a result that Input cannot deliver. return nil, unsupported("function tools and their discovery") + case len(view.Shims) > 0 && !hasPATH(s.Environment): + return nil, invalidSession("the view's shims run names on the sandbox PATH, and the Environment sets no PATH") } raw, ok := req.AgentOptions["model_provider"] if !ok { @@ -148,7 +154,8 @@ func admit(cfg Config, roots *x509.CertPool, s Session, openNetwork func(context if err != nil { return nil, &Error{Kind: ErrInvalidSession, Op: "gateway", Err: err} } - p := &plan{view: view, gateway: gw, proxy: endpoints.Proxy} + p := &plan{view: view, gateway: gw, proxy: endpoints.Proxy, + executables: processbroker.Executables{Names: identity(view.Shims), Paths: identity(view.ShimPaths)}} if p.request, err = handoff(req, provider, endpoints); err != nil { return nil, err } @@ -228,6 +235,22 @@ func checkSession(s Session) error { return nil } +// hasPATH reports whether a forwarded process receives PATH. +func hasPATH(env Environment) bool { + _, sandbox := env.Sandbox["PATH"] + _, tool := env.Tool["PATH"] + return sandbox || tool +} + +// identity maps each of keys to itself. +func identity(keys []string) map[string]string { + m := make(map[string]string, len(keys)) + for _, k := range keys { + m[k] = k + } + return m +} + // valid reports whether r is a nonempty range of nonzero uids. func (r UIDRange) valid() bool { return r.First != 0 && r.Count != 0 && uint64(r.First)+uint64(r.Count) <= math.MaxUint32 diff --git a/apps/daemon/internal/agenthost/admit_linux_test.go b/apps/daemon/internal/agenthost/admit_linux_test.go index a4a3cbba..bd044f20 100644 --- a/apps/daemon/internal/agenthost/admit_linux_test.go +++ b/apps/daemon/internal/agenthost/admit_linux_test.go @@ -25,8 +25,9 @@ import ( var errFactory = errors.New("factory reached") // viewFixture registers "viewed", whose factory records what it receives, -// "masked", whose view masks an /etc file the agent host writes, and -// "plain", which declares no view. +// "masked", whose view masks an /etc file the agent host writes, "shimmed", +// whose view runs a shim name on the sandbox PATH, and "plain", which +// declares no view. type viewFixture struct { cfg Config req proto.PromptRequestPayload @@ -54,6 +55,9 @@ func newViewFixture(t *testing.T) *viewFixture { masked := view masked.Masks = []agent.ViewMask{{Path: "/etc/passwd"}} register(reg, "masked", &masked) + shimmed := view + shimmed.Shims = []string{"git"} + register(reg, "shimmed", &shimmed) register(reg, "plain", nil) f.cfg = newConfig(t, reg, upstream.Certificate()) return f @@ -70,13 +74,14 @@ func TestAdmissionRejectsBeforeAnyEffect(t *testing.T) { "environment none": {func(r *proto.PromptRequestPayload) { r.DisableExecutionEnvironment, r.LocalEnvironment = true, nil }, []error{ErrUnsupported, agent.ErrUnsupportedOperation}}, - "relative workspace": {func(r *proto.PromptRequestPayload) { r.LocalEnvironment.WorkspaceRoot = "workspace" }, []error{ErrInvalidSession}}, - "no model provider": {func(r *proto.PromptRequestPayload) { delete(r.AgentOptions, "model_provider") }, []error{ErrUnsupported}}, - "no strict resume": {func(r *proto.PromptRequestPayload) { r.StrictResume = false }, []error{ErrUnsupported}}, - "capabilities": {func(r *proto.PromptRequestPayload) { r.LocalEnvironment.Capabilities = true }, []error{ErrUnsupported}}, - "restricted network": {func(r *proto.PromptRequestPayload) { r.LocalEnvironment.NetworkAccess = "disabled" }, []error{ErrUnsupported}}, - "allowed domains only": {func(r *proto.PromptRequestPayload) { r.LocalEnvironment.AllowedDomains = []string{"example.com"} }, []error{ErrUnsupported}}, - "function tools": {func(r *proto.PromptRequestPayload) { r.FunctionTools = []proto.FunctionTool{{Name: "lookup"}} }, []error{ErrUnsupported, agent.ErrUnsupportedOperation}}, + "shim name without PATH": {func(r *proto.PromptRequestPayload) { r.AgentKind = "shimmed" }, []error{ErrInvalidSession}}, + "relative workspace": {func(r *proto.PromptRequestPayload) { r.LocalEnvironment.WorkspaceRoot = "workspace" }, []error{ErrInvalidSession}}, + "no model provider": {func(r *proto.PromptRequestPayload) { delete(r.AgentOptions, "model_provider") }, []error{ErrUnsupported}}, + "no strict resume": {func(r *proto.PromptRequestPayload) { r.StrictResume = false }, []error{ErrUnsupported}}, + "capabilities": {func(r *proto.PromptRequestPayload) { r.LocalEnvironment.Capabilities = true }, []error{ErrUnsupported}}, + "restricted network": {func(r *proto.PromptRequestPayload) { r.LocalEnvironment.NetworkAccess = "disabled" }, []error{ErrUnsupported}}, + "allowed domains only": {func(r *proto.PromptRequestPayload) { r.LocalEnvironment.AllowedDomains = []string{"example.com"} }, []error{ErrUnsupported}}, + "function tools": {func(r *proto.PromptRequestPayload) { r.FunctionTools = []proto.FunctionTool{{Name: "lookup"}} }, []error{ErrUnsupported, agent.ErrUnsupportedOperation}}, "stdio MCP": {func(r *proto.PromptRequestPayload) { r.LocalEnvironment.MCP = []proto.EnvironmentMCP{{Server: agentplugin.MCPServer{Name: "tools", Type: "stdio", Command: "tools"}}} }, []error{ErrUnsupported, agent.ErrUnsupportedOperation}}, @@ -85,7 +90,7 @@ func TestAdmissionRejectsBeforeAnyEffect(t *testing.T) { c.change(&req) s, _, _ := newSession(newResource(), req) var dials atomic.Int32 - err := run(context.Background(), f.cfg, s, deps{dial: countingDial(&dials), broker: func() processBroker { return noBroker{} }, procs: &fakeProcesses{}}) + err := run(context.Background(), f.cfg, s, deps{dial: countingDial(&dials), procs: &fakeProcesses{}}) for _, want := range c.want { if !errors.Is(err, want) { t.Errorf("%s: Run = %v, want %v", name, err, want) @@ -107,7 +112,7 @@ func TestAdmissionRejectsBeforeAnyEffect(t *testing.T) { s, _, _ := newSession(newResource(), request("viewed", "/workspace", "https://model.test", "sk-test")) change(&s.Binding) var dials atomic.Int32 - err := run(context.Background(), f.cfg, s, deps{dial: countingDial(&dials), broker: func() processBroker { return noBroker{} }, procs: &fakeProcesses{}}) + err := run(context.Background(), f.cfg, s, deps{dial: countingDial(&dials), procs: &fakeProcesses{}}) if !errors.Is(err, ErrInvalidSession) || dials.Load() != 0 { t.Errorf("%s: Run = %v after %d dials, want ErrInvalidSession", name, err, dials.Load()) } @@ -125,7 +130,7 @@ func TestViewExecutorReceivesTheGatewayRequest(t *testing.T) { original := maps.Clone(req.AgentOptions) s, _, _ := newSession(newResource(), req) var dials atomic.Int32 - err := run(context.Background(), f.cfg, s, deps{dial: countingDial(&dials), broker: func() processBroker { return noBroker{} }, procs: &fakeProcesses{}}) + err := run(context.Background(), f.cfg, s, deps{dial: countingDial(&dials), procs: &fakeProcesses{}}) if !errors.Is(err, ErrExecutor) || !errors.Is(err, errFactory) { t.Fatalf("Run = %v, want the factory's error as ErrExecutor", err) } @@ -159,7 +164,7 @@ func TestViewExecutorReceivesTheGatewayRequest(t *testing.T) { req.AgentOptions["mcp_servers"] = map[string]any{} s, _, _ = newSession(newResource(), req) f.req = proto.PromptRequestPayload{} - err = run(context.Background(), f.cfg, s, deps{dial: countingDial(&dials), broker: func() processBroker { return noBroker{} }, procs: &fakeProcesses{}}) + err = run(context.Background(), f.cfg, s, deps{dial: countingDial(&dials), procs: &fakeProcesses{}}) if !errors.Is(err, ErrUnsupported) || !errors.Is(err, agent.ErrViewHandoff) || f.req.AgentKind != "" { t.Errorf("Run with a connection option = %v, want ErrUnsupported and ErrViewHandoff before the adapter", err) } diff --git a/apps/daemon/internal/agenthost/agenthost.go b/apps/daemon/internal/agenthost/agenthost.go index 7ad0558f..ffad077f 100644 --- a/apps/daemon/internal/agenthost/agenthost.go +++ b/apps/daemon/internal/agenthost/agenthost.go @@ -120,11 +120,14 @@ var ( ErrWorld = errors.New("agenthost: world lost") // ErrLaunch is a view that could not be launched. ErrLaunch = errors.New("agenthost: launch failed") - // ErrProcessBroker is a process broker that could not start. + // ErrProcessBroker is a view's process broker that could not start, or + // whose process relay was lost while the view ran + // (processbroker.ErrRelayLost). ErrProcessBroker = errors.New("agenthost: process broker failed") // ErrTurn is a Turn that failed or left its Executor unusable. ErrTurn = errors.New("agenthost: turn failed") - // ErrTeardown is a Session resource that could not be released. + // ErrTeardown is a Session resource that could not be released, such as + // a view whose teardown did not finish (sessionview.ErrCleanup). ErrTeardown = errors.New("agenthost: teardown incomplete") ) diff --git a/apps/daemon/internal/agenthost/agenthost_linux_test.go b/apps/daemon/internal/agenthost/agenthost_linux_test.go index 8c7243f6..686d8364 100644 --- a/apps/daemon/internal/agenthost/agenthost_linux_test.go +++ b/apps/daemon/internal/agenthost/agenthost_linux_test.go @@ -13,6 +13,7 @@ import ( "testing" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/processshim" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/sessionview" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto/prototest" @@ -22,10 +23,17 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxwire" ) -// The test binary is also the privileged suite's Harness inside the view and -// its process with a zombie leader. +// The test binary is also the privileged suite's Harness inside the view, +// the view's shim and process relay, and its process with a zombie leader. func TestMain(m *testing.M) { sessionview.Init() + if processshim.Relaying() { + os.Exit(processshim.Relay()) + } + // The shim runs with the Harness's environment, so it comes first. + if filepath.Base(os.Args[0]) == "sh" { + os.Exit(processshim.Run(processshim.SocketPath)) + } if os.Getenv(harnessEnv) != "" { os.Exit(runHarness(os.Args[1:])) } @@ -97,12 +105,6 @@ func countingDial(n *atomic.Int32) dialFunc { } } -// noBroker is a process broker that serves nothing. -type noBroker struct{} - -func (noBroker) Start(brokerConfig) error { return nil } -func (noBroker) Close() error { return nil } - // leftSessions lists what remains under the state directory's sessions. func leftSessions(t *testing.T, cfg Config) []os.DirEntry { t.Helper() diff --git a/apps/daemon/internal/agenthost/broker.go b/apps/daemon/internal/agenthost/broker.go deleted file mode 100644 index 1df6d799..00000000 --- a/apps/daemon/internal/agenthost/broker.go +++ /dev/null @@ -1,49 +0,0 @@ -package agenthost - -import ( - "context" - "errors" - "io" - "time" -) - -// processBroker runs the commands the view's shims forward to the sandbox -// over the Process service. One broker serves a Session from its first launch -// until teardown. -type processBroker interface { - // Start begins serving the Session. - Start(brokerConfig) error - // Close cancels and releases the remote operations that remain and stops - // serving. - Close() error -} - -// brokerConfig is what a Session's broker serves. -type brokerConfig struct { - // UID and GID are the Session's. - UID, GID uint32 - // Names maps each shim name to the program it runs in the sandbox, found - // on the remote PATH; Paths maps each view path the shim is bound over to - // the same sandbox path. - Names, Paths map[string]string - // Pass names the Harness variables a forwarded process keeps - // (agent.View.ForwardEnv). - Pass []string - // Sandbox and Tool are the Session's Environment. - Sandbox, Tool map[string]string - // Dial opens a Process stream on the Session's attachment. - Dial func(context.Context) (io.ReadWriteCloser, error) - // CancelGrace is the grace of a Cancel the broker sends on its own. - CancelGrace time.Duration -} - -// errNoBroker is unavailableBroker's Start error. -var errNoBroker = errors.New("no process broker in this build") - -// unavailableBroker is the broker this build has. Its Start fails, so a -// Session admits, prepares its Executor and fails its first launch with -// ErrProcessBroker. -type unavailableBroker struct{} - -func (unavailableBroker) Start(brokerConfig) error { return errNoBroker } -func (unavailableBroker) Close() error { return nil } diff --git a/apps/daemon/internal/agenthost/doc.go b/apps/daemon/internal/agenthost/doc.go index 1c6e7e89..e954859e 100644 --- a/apps/daemon/internal/agenthost/doc.go +++ b/apps/daemon/internal/agenthost/doc.go @@ -3,18 +3,24 @@ // Session's Link attachment. It needs Linux; elsewhere Run returns // ErrUnsupported. // -// Run runs one Session. It admits the Session before any effect: the kind -// must declare an agent.View, and the request must use only what a view runs -// and no function tools, whose results Input cannot carry. It then allocates -// the Session uid, skipping each uid that a running thread holds as its real, -// effective, saved or file-system uid; this check only detects a conflict and -// never ends a process. It creates the Session directory under -// Config.StateDir, rewrites the request so the model provider and HTTP MCP -// reach the network only through the Session's gateway, and calls the view's -// Executor factory. The first ViewSession.Launch starts the process broker; -// each 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. +// Run runs one Session. It admits the Session before any effect: the kind must +// declare an agent.View, the request must use only what a view runs and no +// function tools, whose results Input cannot carry, and when the view declares +// shim names, which run on the sandbox PATH, the Session's Environment must +// set PATH. It then allocates the Session uid, skipping each uid that a +// running thread holds as its real, effective, saved or file-system uid; this +// check only detects a conflict and never ends a process. It creates the +// Session directory under Config.StateDir, rewrites the request so the model +// provider and HTTP MCP reach the network only through the Session's gateway, +// 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. // // Each view presents the closure directories read-only and executable, the // Session home read-write and noexec, the agent host's /etc/passwd, group, @@ -25,11 +31,13 @@ // The agent host owns the Session's Link attachment: it opens each stream // with the Session's binding, renews the lease and fails the Session when the // relay closes the attachment, a Link request fails in a way that is not -// retryable, or the world is lost or did not stop cleanly, which leaves what -// the attachment holds uncertain. A failure cancels the running Turn and -// closes the live view. A view's end is settled before its clirunner.Process -// reports it: the gateway has stopped, the world's end is recorded and the -// view slot is free. Teardown releases, in order, the Executor, the view, the +// retryable, the world is lost or did not stop cleanly, which leaves what the +// attachment holds uncertain, a view's process relay is lost while the view +// runs, or a view's teardown does not finish within sessionview's bound. A +// failure cancels the running Turn and closes the live view. A view's end is +// settled before its clirunner.Process reports it: the gateway and the +// process broker have stopped, the world's end is recorded and the view slot +// is free. Teardown releases, in order, the Executor, the view with its // process broker, the Link attachment, the Session directory and the uid; // Run decides its result only afterwards, so a failure recorded during // teardown counts, and from the close of the attachment on, what the Link @@ -37,7 +45,8 @@ // views, which kills their processes, and retries Close once. If Close // fails again, the Executor may still use the Session directory: Run returns // ErrTeardown and keeps the directory, and the uid stays in use until the -// agent host exits. +// agent host exits. A view whose teardown did not finish keeps both the same +// way, because its processes may still run. // // Run drives each Turn as the daemon's dispatch drives a prepared execution. // One output consumer starts before StartTurn and forwards the Turn's diff --git a/apps/daemon/internal/agenthost/launch_linux.go b/apps/daemon/internal/agenthost/launch_linux.go index b38e63dd..e3d6dbd9 100644 --- a/apps/daemon/internal/agenthost/launch_linux.go +++ b/apps/daemon/internal/agenthost/launch_linux.go @@ -11,14 +11,15 @@ import ( "slices" "sync" "syscall" - "time" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/clirunner" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/gateway" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/processbroker" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/sessionview" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/worldfs" "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxlink" + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxprocess" ) // worldExport is the File service export that holds the sandbox's world, as @@ -36,6 +37,7 @@ type runningView interface { Signal(syscall.Signal) error Wait() (sessionview.Exit, error) Close() error + Relay() *os.File } // viewWorld is the part of *worldfs.World the Session watches. @@ -104,9 +106,21 @@ func (s *session) release(lv *liveView) { // start builds the view for lv. Until the view runs, each failure releases // lv; from then on the view's owner does. func (s *session) start(lv *liveView, opts clirunner.StartOptions) (*clirunner.Process, error) { - if err := s.startBroker(opts.KillTimeout); err != nil { - s.release(lv) - return nil, err + // Construction ends with the Session or with the caller. + startCtx, cancel := context.WithCancel(s.ctx) + defer cancel() + defer context.AfterFunc(opts.Parent, cancel)() + view := s.plan.view + var scope sandboxprocess.Scope + if len(view.Shims) > 0 || len(view.ShimPaths) > 0 { + var err error + if scope, err = s.processScope(startCtx); err != nil { + s.release(lv) + if startCtx.Err() != nil { + return nil, &Error{Kind: ErrLaunch, Op: "describe", Err: err} + } + return nil, s.brokerFailed("describe", err) + } } if err := s.dir.chownHome(s.uid); err != nil { s.release(lv) @@ -121,77 +135,154 @@ func (s *session) start(lv *liveView, opts clirunner.StartOptions) (*clirunner.P viewCtx, stopGateway := context.WithCancel(context.Background()) world := worldfs.New(worldExport, s.openFile) spec := s.spec(viewCtx, world, opts, ends) - - // Construction ends with the Session or with the caller. - startCtx, cancel := context.WithCancel(s.ctx) - stop := context.AfterFunc(opts.Parent, cancel) v, err := sessionview.Start(startCtx, spec) - stop() - cancel() ends.closeChild() if err != nil { stopGateway() ends.closeParent() defer s.release(lv) // sessionview stops a world that served; Stop reports how that went. - if serr := world.Stop(); serr != nil || errors.Is(err, worldfs.ErrAttachmentDirty) { + serr := world.Stop() + var left error + if errors.Is(err, sessionview.ErrCleanup) { + left = s.viewLeft(err) + } + switch { + case serr != nil || errors.Is(err, worldfs.ErrAttachmentDirty): return nil, s.worldEnded("launch", errors.Join(err, serr)) + case left != nil: + return nil, left } return nil, &Error{Kind: ErrLaunch, Err: err} } p := v.Presentation() s.log.Info("agent host view started", "binary", opts.Binary, "targets", p.Targets, "links", p.Links, "synthesized", p.Synthesized) - return s.own(lv, v, world, stopGateway, opts, ends) + return s.own(lv, v, world, stopGateway, opts, ends, scope) +} + +// processScope describes the Session's Process service and returns the +// scope a view's forwarded processes start in: the strongest one the service +// declares. +func (s *session) processScope(ctx context.Context) (sandboxprocess.Scope, error) { + rw, err := s.openProcess(ctx) + if err != nil { + return 0, err + } + c := sandboxprocess.NewClient(rw) + defer c.Close() + d, err := c.Describe(ctx) + if err != nil { + return 0, err + } + for _, scope := range []sandboxprocess.Scope{sandboxprocess.ScopeCgroupV2, sandboxprocess.ScopePOSIXSession} { + if slices.Contains(d.Capabilities.Scopes, scope) { + return scope, nil + } + } + return 0, fmt.Errorf("the Process service declares no scope among %v", d.Capabilities.Scopes) +} + +// brokerFailed fails the Session with a process broker failure. +func (s *session) brokerFailed(op string, err error) error { + e := &Error{Kind: ErrProcessBroker, Op: op, Err: err} + s.fail(e) + return e } -// own hands a started view to the clirunner.Process the adapter receives. -func (s *session) own(lv *liveView, v runningView, world viewWorld, stopGateway func(), opts clirunner.StartOptions, ends *stdio) (*clirunner.Process, error) { +// own hands a started view to the clirunner.Process the adapter receives. A +// view with a relay gets its own process broker, which serves the view's +// shims in scope until the view ends. +func (s *session) own(lv *liveView, v runningView, world viewWorld, stopGateway func(), opts clirunner.StartOptions, ends *stdio, scope sandboxprocess.Scope) (*clirunner.Process, error) { h := &ownedView{s: s, lv: lv, v: v, world: world, stopGateway: stopGateway, ended: make(chan struct{}), watched: make(chan struct{})} + var brokerErr error + if relay := v.Relay(); relay != nil { + h.broker, brokerErr = processbroker.Start(processbroker.Config{ + Relay: relay, + Executables: s.plan.executables, + Environment: processbroker.Environment{Pass: s.plan.view.ForwardEnv, Sandbox: s.in.Environment.Sandbox, Tool: s.in.Environment.Tool}, + Scope: scope, + Dial: s.openProcess, + CancelGrace: opts.KillTimeout, + Logger: s.log, + }) + } go h.watch() s.mu.Lock() lv.view = v closed := lv.closed s.mu.Unlock() - if closed { - v.Close() - h.Wait() - ends.closeParent() - return nil, &Error{Kind: ErrLaunch, Err: errors.New("the Session is ending")} + var err error + switch { + case brokerErr != nil: + err = s.brokerFailed("start", brokerErr) + case closed: + err = &Error{Kind: ErrLaunch, Err: errors.New("the Session is ending")} + } + var process *clirunner.Process + if err == nil { + process, err = clirunner.FromHandle(h, clirunner.HandleOptions{Parent: opts.Parent, Stdin: ends.stdin(), + Stdout: ends.parent[1], Stderr: ends.parent[2], KillTimeout: opts.KillTimeout}) + if err != nil { + err = &Error{Kind: ErrLaunch, Err: err} + } } - process, err := clirunner.FromHandle(h, clirunner.HandleOptions{Parent: opts.Parent, Stdin: ends.stdin(), - Stdout: ends.parent[1], Stderr: ends.parent[2], KillTimeout: opts.KillTimeout}) if err != nil { v.Close() h.Wait() ends.closeParent() - return nil, &Error{Kind: ErrLaunch, Err: err} + return nil, err } return process, nil } // ownedView is a running view as a clirunner.Handle. Its Wait ends the // Session's ownership of the view before it returns, so the end the adapter -// observes through the Process comes after it: the gateway has stopped, a -// lost world or one that did not stop cleanly has failed the Session, and -// the view slot is free for the next Launch. +// observes through the Process comes after it: the gateway and the process +// broker have stopped, a lost world, a lost relay or a view or world that +// did not stop cleanly has failed the Session, and the view slot is free for +// the next Launch. type ownedView struct { s *session lv *liveView v runningView world viewWorld + broker *processbroker.Broker // nil when the view has no relay stopGateway func() ended chan struct{} // closed once the view has ended watched chan struct{} // closed when watch returns once sync.Once } -// watch fails the Session as soon as the world is lost while the view runs. +// watch fails the Session as soon as the world or the process relay is lost +// while the view runs. func (h *ownedView) watch() { defer close(h.watched) - select { - case <-h.world.Lost(): - h.s.fail(&Error{Kind: ErrWorld, Op: "world", Err: h.world.Err()}) - case <-h.ended: + var relayEnded <-chan struct{} + if h.broker != nil { + relayEnded = h.broker.Done() + } + for { + select { + case <-h.world.Lost(): + h.s.fail(&Error{Kind: ErrWorld, Op: "world", Err: h.world.Err()}) + return + case <-relayEnded: + // The broker stops serving on Close, which end calls only after + // watch returns, or when its relay connection ends. sessionview + // ends that connection itself only in its teardown, which starts + // once the launcher has stopped answering, and from then on + // Signal fails with ErrExited, ErrClosed or a lost launcher. So a + // delivered signal means that the view still runs its process + // and the relay was lost while the Harness ran. A failed one + // means that the view is ending, and Wait reports how. + if h.v.Signal(0) == nil { + h.s.brokerFailed("relay", h.broker.Err()) + return + } + relayEnded = nil + case <-h.ended: + return + } } } @@ -201,7 +292,7 @@ func (h *ownedView) Close() error { return h.v.Close() } func (h *ownedView) Wait() (int, error) { exit, err := h.v.Wait() - h.once.Do(h.end) + h.once.Do(func() { h.end(err) }) switch { case err != nil: return -1, err @@ -211,11 +302,20 @@ func (h *ownedView) Wait() (int, error) { return exit.Code, nil } -// end releases the view once it has ended and its world has stopped. -func (h *ownedView) end() { +// end releases the view once it has ended and its world has stopped. waitErr +// is how the view's Wait ended. +func (h *ownedView) end(waitErr error) { h.stopGateway() close(h.ended) <-h.watched + if h.broker != nil { + if err := h.broker.Close(); err != nil { + h.s.ended(&Error{Kind: ErrTeardown, Op: "close process broker", Err: err}, false) + } + } + if errors.Is(waitErr, sessionview.ErrCleanup) { + h.s.viewLeft(waitErr) + } if lost := h.world.Err(); lost != nil { h.s.fail(&Error{Kind: ErrWorld, Op: "world", Err: lost}) } @@ -226,35 +326,6 @@ func (h *ownedView) end() { h.s.release(h.lv) } -// startBroker starts the Session's process broker at its first launch. -func (s *session) startBroker(grace time.Duration) error { - s.brokerMu.Lock() - defer s.brokerMu.Unlock() - if s.broker != nil { - return nil - } - view := s.plan.view - names := make(map[string]string, len(view.Shims)) - for _, n := range view.Shims { - names[n] = n - } - paths := make(map[string]string, len(view.ShimPaths)) - for _, p := range view.ShimPaths { - paths[p] = p - } - b := s.deps.broker() - err := b.Start(brokerConfig{UID: s.uid, GID: s.uid, Names: names, Paths: paths, - Pass: slices.Clone(view.ForwardEnv), Sandbox: s.in.Environment.Sandbox, Tool: s.in.Environment.Tool, - Dial: s.openProcess, CancelGrace: grace}) - if err != nil { - err = &Error{Kind: ErrProcessBroker, Err: err} - s.fail(err) - return err - } - s.broker = b - return nil -} - // spec builds the view: the closure and home directories, the agent // host's /etc files and CA directory, the adapter's overlays and masks, the // shim and the gateway in the view's network namespace. diff --git a/apps/daemon/internal/agenthost/run_linux.go b/apps/daemon/internal/agenthost/run_linux.go index 0cb07bfb..171c0202 100644 --- a/apps/daemon/internal/agenthost/run_linux.go +++ b/apps/daemon/internal/agenthost/run_linux.go @@ -30,9 +30,8 @@ const ( // deps are the parts tests replace. type deps struct { - dial dialFunc - broker func() processBroker - procs processTable + dial dialFunc + procs processTable } // Run runs one Session until Input is closed, ctx ends or the Session fails, @@ -41,7 +40,7 @@ type deps struct { // that ended it, joined with any view cleanup and teardown failure. A failure // recorded during teardown counts. func Run(ctx context.Context, cfg Config, s Session) error { - return run(ctx, cfg, s, deps{dial: relayDial(cfg), broker: func() processBroker { return unavailableBroker{} }, procs: procfs{}}) + return run(ctx, cfg, s, deps{dial: relayDial(cfg), procs: procfs{}}) } // session is one running Session. @@ -61,7 +60,8 @@ type session struct { failMu sync.Mutex failure error // the first failure, which ended the Session - cleanup []error // each world that did not stop cleanly + cleanup []error // each world, view or broker that did not stop cleanly + left bool // a view's teardown did not finish mu sync.Mutex // live is the one view that may run; nil when none does. @@ -69,9 +69,6 @@ type session struct { // views counts launches and their views until each is torn down. views sync.WaitGroup - brokerMu sync.Mutex - broker processBroker // started at the first launch - // The goroutine that runs drive and then teardown owns these. fwd *forwarder // the last Turn's forwarder execClosed bool // a Close of the Executor succeeded @@ -163,9 +160,23 @@ func (s *session) fail(err error) { // state, so the Session fails, and Run reports the error even after another // failure. func (s *session) worldEnded(op string, err error) error { - e := &Error{Kind: ErrWorld, Op: op, Err: err} + return s.ended(&Error{Kind: ErrWorld, Op: op, Err: err}, false) +} + +// viewLeft records a view whose teardown did not finish within sessionview's +// bound (sessionview.ErrCleanup): its processes may still run and use the +// Session directory. The Session fails, Run reports the error even after +// another failure, and teardown keeps the directory and the uid. +func (s *session) viewLeft(err error) error { + return s.ended(&Error{Kind: ErrTeardown, Op: "view", Err: err}, true) +} + +// ended records e, a resource that did not stop cleanly, and fails the +// Session with it. left says that the Session directory may still be in use. +func (s *session) ended(e *Error, left bool) error { s.failMu.Lock() s.cleanup = append(s.cleanup, e) + s.left = s.left || left s.failMu.Unlock() s.fail(e) return e @@ -418,12 +429,13 @@ func (s *session) closeExecutor(exec agent.Executor) error { return s.execErr } -// teardown releases the Session in order: the Executor, the view, the +// teardown releases the Session in order: the Executor, the view with its // process broker, the Link attachment, the Session directory and the uid. // When Close fails, teardown ends the views, which kills each view's // processes, and retries Close once. If that fails too, the Executor may // still use the Session directory: teardown returns ErrTeardown and keeps // the directory and the uid, which stays in use until the agent host exits. +// It keeps both too when a view's teardown did not finish. func (s *session) teardown(exec agent.Executor) error { var errs []error closeErr := s.execErr @@ -444,17 +456,14 @@ func (s *session) teardown(exec agent.Executor) error { f.halt() <-f.done } - s.brokerMu.Lock() - broker := s.broker - s.brokerMu.Unlock() - if broker != nil { - if err := broker.Close(); err != nil { - errs = append(errs, &Error{Kind: ErrTeardown, Op: "close process broker", Err: err}) - } - } errs = append(errs, s.link.close()) if closeErr != nil { errs = append(errs, &Error{Kind: ErrTeardown, Op: "close executor", Err: closeErr}) + } + s.failMu.Lock() + left := s.left + s.failMu.Unlock() + if closeErr != nil || left { return errors.Join(errs...) } if err := os.RemoveAll(string(s.dir)); err != nil { diff --git a/apps/daemon/internal/agenthost/session_linux_test.go b/apps/daemon/internal/agenthost/session_linux_test.go index daa5e3c3..9ba45339 100644 --- a/apps/daemon/internal/agenthost/session_linux_test.go +++ b/apps/daemon/internal/agenthost/session_linux_test.go @@ -54,7 +54,7 @@ func TestViewEndReleasesTheSlotBeforeTheProcessEnds(t *testing.T) { } ends.closeChild() v := &fakeView{exit: make(chan struct{})} - p, err := s.own(lv, v, fakeWorld{stop: stop}, func() {}, clirunner.StartOptions{Parent: context.Background(), KillTimeout: time.Second}, ends) + p, err := s.own(lv, v, fakeWorld{stop: stop}, func() {}, clirunner.StartOptions{Parent: context.Background(), KillTimeout: time.Second}, ends, 0) if err != nil { t.Fatal(err) } @@ -342,6 +342,8 @@ type fakeView struct { func (v *fakeView) Signal(syscall.Signal) error { return nil } +func (v *fakeView) Relay() *os.File { return nil } + func (v *fakeView) Wait() (sessionview.Exit, error) { <-v.exit return sessionview.Exit{}, nil diff --git a/apps/daemon/internal/agenthost/view_linux_test.go b/apps/daemon/internal/agenthost/view_linux_test.go index 3331d495..0d8c5701 100644 --- a/apps/daemon/internal/agenthost/view_linux_test.go +++ b/apps/daemon/internal/agenthost/view_linux_test.go @@ -29,6 +29,8 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/clirunner" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/processbroker" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/processshim" "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/sessionview" "github.com/MiniMax-AI/OpenAgentCore/internal/agentdaemon/proto" "github.com/MiniMax-AI/OpenAgentCore/internal/modelprovider" @@ -88,6 +90,7 @@ func TestSessionRunsInAViewOverItsAttachment(t *testing.T) { Closure: []agent.ViewMount{{Name: "harness", HostDir: closure}}, Masks: []agent.ViewMask{{Path: "/etc/ld.so.preload"}, {Path: "/etc/hostname"}, {Path: "/etc/apt", Dir: true}}, LocalExec: []string{harnessPath}, + ShimPaths: []string{"/bin/sh"}, Proxy: agent.ViewProxyNone, Executor: func(_ context.Context, req proto.PromptRequestPayload, s agent.ViewSession) (agent.Executor, error) { provider, err := modelprovider.ParseProvider(req.AgentOptions["model_provider"]) @@ -149,6 +152,41 @@ func TestSessionRunsInAViewOverItsAttachment(t *testing.T) { checkReleased(t, cfg) }) + t.Run("a command runs in the sandbox through the shim", func(t *testing.T) { + s := startSession(t, cfg, sb, request("test", workspace, upstream.URL, upstreamKey), time.Minute) + if r := s.turn(t, "shim"); r.Stdout != "42\n" || r.Code != 3 { + t.Errorf("the forwarded command printed %q and exited %d, want 42 and 3; stderr %s", r.Stdout, r.Code, r.Stderr) + } + close(s.in) + if err := s.wait(t); err != nil { + t.Fatalf("Run = %v", err) + } + checkReleased(t, cfg) + }) + + t.Run("a lost relay fails the Session", func(t *testing.T) { + s := startSession(t, cfg, sb, request("test", workspace, upstream.URL, upstreamKey), time.Minute) + s.send(t, "wait") + beat := filepath.Join(workspace, "beat") + defer os.Remove(beat) + until(t, "the Harness to run", func() bool { + _, err := os.Stat(beat) + return err == nil + }) + relays := processesWith(processshim.RelayArgs) + if len(relays) != 1 { + t.Fatalf("%d process relays run, want 1", len(relays)) + } + if err := syscall.Kill(relays[0], syscall.SIGKILL); err != nil { + t.Fatal(err) + } + err := s.wait(t) + if !errors.Is(err, ErrProcessBroker) || !errors.Is(err, processbroker.ErrRelayLost) || errors.Is(err, ErrTeardown) { + t.Errorf("Run = %v, want ErrProcessBroker with ErrRelayLost", err) + } + checkReleased(t, cfg) + }) + t.Run("a restarted sandbox service fails the Session", func(t *testing.T) { s := startSession(t, cfg, sb, request("test", workspace, upstream.URL, upstreamKey), time.Minute) s.send(t, "wait") @@ -198,6 +236,22 @@ func TestSessionRunsInAViewOverItsAttachment(t *testing.T) { }) } +// processesWith lists the processes whose argv is args. +func processesWith(args []string) []int { + var pids []int + entries, _ := os.ReadDir("/proc") + for _, e := range entries { + pid, err := strconv.Atoi(e.Name()) + if err != nil { + continue + } + if cmdline, err := os.ReadFile(procPath(pid, "cmdline")); err == nil && string(cmdline) == strings.Join(args, "\x00")+"\x00" { + pids = append(pids, pid) + } + } + return pids +} + func until(t *testing.T, what string, ok func() bool) { t.Helper() deadline := time.Now().Add(wait) @@ -396,7 +450,7 @@ func startSession(t *testing.T, cfg Config, sb *sandbox, req proto.PromptRequest sb.grant(s.Binding, cfg.RuntimeID, lease) r := &sessionRun{binding: s.Binding, in: in, out: out, done: make(chan error, 1)} go func() { - r.done <- run(context.Background(), cfg, s, deps{dial: relayDial(cfg), broker: func() processBroker { return noBroker{} }, procs: procfs{}}) + r.done <- run(context.Background(), cfg, s, deps{dial: relayDial(cfg), procs: procfs{}}) }() return r } @@ -522,12 +576,14 @@ func (e *testExecutor) StartTurn(_ context.Context, runID string, input proto.Me func (e *testExecutor) Close(context.Context) error { return nil } -// report is a Turn's report envelope: the Harness's checks, its stderr and how -// it exited. +// report is a Turn's report envelope: the Harness's checks, or its stdout +// when that is not a check report, its stderr and how it exited. type report struct { Checks map[string]string `json:"checks"` + Stdout string `json:"stdout"` Stderr string `json:"stderr"` Exit string `json:"exit"` + Code int `json:"code"` } type testTurn struct { @@ -547,10 +603,13 @@ func (t *testTurn) run(runID string, out chan<- proto.Envelope) { stdout, _ := io.ReadAll(t.p.Stdout) <-copied var r report - json.Unmarshal(stdout, &r.Checks) + if json.Unmarshal(stdout, &r.Checks) != nil { + r.Stdout = string(stdout) + } if err := t.p.Wait(); err != nil { r.Exit = err.Error() } + r.Code, _ = t.p.ExitCode() r.Stderr = stderr.String() payload, _ := json.Marshal(r) out <- proto.Envelope{Type: proto.TypeOutputMessage, ID: runID, Payload: payload} @@ -657,6 +716,18 @@ func runHarness(args []string) int { } case "touch": checks["touch"] = func() error { return os.WriteFile("touched", []byte("renewed"), 0o644) } + case "shim": + // /bin/sh is the shim, so the command runs in the sandbox. + cmd := exec.Command("/bin/sh", "-c", "echo $((6*7)); exit 3") + cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr + var exit *exec.ExitError + if err := cmd.Run(); errors.As(err, &exit) { + return exit.ExitCode() + } else if err != nil { + fmt.Fprintln(os.Stderr, err) + return 125 + } + return 0 case "wait": // Beat in the world until the view ends. for i := 0; i < 600; i++ {